diff --git a/firmware/.github/instructions/api-server.instructions.md b/firmware/.github/instructions/bifrost.instructions.md similarity index 76% rename from firmware/.github/instructions/api-server.instructions.md rename to firmware/.github/instructions/bifrost.instructions.md index 6d16bcb..4185422 100644 --- a/firmware/.github/instructions/api-server.instructions.md +++ b/firmware/.github/instructions/bifrost.instructions.md @@ -1,9 +1,9 @@ --- -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}" +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 bifrost." +name: "Bifrost (API Server) Instructions" +applyTo: "components/bifrost/**/*.{c,h}" --- -# API Server Guidelines +# Bifrost (API Server) Guidelines - Keep handler registration order stable in api_handlers.c: 1) specific /api routes @@ -20,6 +20,6 @@ applyTo: "components/api-server/**/*.{c,h}" - 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. +- Prefer localized changes in bifrost 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/prompts/add-api-endpoint.prompt.md b/firmware/.github/prompts/add-api-endpoint.prompt.md index 27cf345..5b713c7 100644 --- a/firmware/.github/prompts/add-api-endpoint.prompt.md +++ b/firmware/.github/prompts/add-api-endpoint.prompt.md @@ -26,8 +26,8 @@ Create a new API endpoint that follows this project's conventions: 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 +- [components/bifrost/src/api_handlers.c](components/bifrost/src/api_handlers.c#L65)—api_capabilities_get_handler, api_wifi_status_handler +- [.github/instructions/bifrost.instructions.md](.github/instructions/bifrost.instructions.md)—handler order, helper function patterns **Output Format:** ```c diff --git a/firmware/README-API.md b/firmware/README-API.md index 277ab86..d523dc6 100644 --- a/firmware/README-API.md +++ b/firmware/README-API.md @@ -12,6 +12,7 @@ This document describes all REST API endpoints and WebSocket messages required f - [Schema](#schema) - [Devices](#devices) - [Scenes](#scenes) + - [Input](#input) - [WebSocket](#websocket) - [Connection](#connection) - [Client to Server Messages](#client-to-server-messages) @@ -683,6 +684,49 @@ Executes all actions of a scene. --- +### Input + +#### Simulate Button Input + +Remotely triggers a button action as if pressed on the physical device. Useful for controlling the menu UI via the web interface. + +- **URL:** `/api/input` +- **Method:** `POST` +- **Content-Type:** `application/json` +- **Request Body:** + +```json +{ + "action": "button_up", + "value": "" +} +``` + +| Field | Type | Required | Description | +|--------|--------|----------|------------------------------------------------------------------| +| action | string | Yes | Action name to execute (see list below) | +| value | string | No | Optional value passed to the action handler (default: empty) | + +**Available actions:** + +| Action | Description | +|----------------|------------------------------------------| +| `button_up` | Navigate up in the menu | +| `button_down` | Navigate down in the menu | +| `button_left` | Adjust value left (e.g. cycle selection) | +| `button_right` | Adjust value right | +| `button_select`| Activate the selected menu item | +| `button_back` | Go back to the previous screen | + +- **Response:** `200 OK` on success, `400 Bad Request` if `action` field is missing + +**Notes:** +- Actions are dispatched via the heimdall action manager +- Any registered action can be triggered, not just button actions +- The value field is passed through to the action handler but is currently unused for button actions + +--- + ## WebSocket ### Connection diff --git a/firmware/components/analytics/src/analytics.c b/firmware/components/analytics/src/analytics.c index 9e5285d..7779144 100644 --- a/firmware/components/analytics/src/analytics.c +++ b/firmware/components/analytics/src/analytics.c @@ -1,6 +1,7 @@ #include "analytics.h" -#include "esp_insights.h" -#include "esp_rmaker_utils.h" + +#include +#include extern const char insights_auth_key_start[] asm("_binary_insights_auth_key_txt_start"); extern const char insights_auth_key_end[] asm("_binary_insights_auth_key_txt_end"); diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c deleted file mode 100644 index 96b455c..0000000 --- a/firmware/components/api-server/src/api_handlers.c +++ /dev/null @@ -1,1205 +0,0 @@ -#include "api_handlers.h" -#include "common.h" -#include "message_manager.h" - -#include "esp_heap_caps.h" -#include "led_segment.h" -#include "persistence_manager.h" -#include "storage.h" -#include -#include -#include -#include -#include -#include - -#define MAX_BODY_SIZE 4096 - -static const char *TAG = "api_handlers"; - -// Helper function to set CORS headers -static esp_err_t set_cors_headers(httpd_req_t *req) -{ - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); - return ESP_OK; -} - -// Helper function to send JSON response -static esp_err_t send_json_response(httpd_req_t *req, const char *json) -{ - set_cors_headers(req); - httpd_resp_set_type(req, "application/json"); - return httpd_resp_sendstr(req, json); -} - -// Helper function to send error response -static esp_err_t send_error_response(httpd_req_t *req, int status_code, const char *message) -{ - set_cors_headers(req); - httpd_resp_set_type(req, "application/json"); - httpd_resp_set_status(req, status_code == 400 ? "400 Bad Request" - : status_code == 404 ? "404 Not Found" - : "500 Internal Server Error"); - char buffer[128]; - snprintf(buffer, sizeof(buffer), "{\"error\":\"%s\"}", message); - return httpd_resp_sendstr(req, buffer); -} - -// OPTIONS handler for CORS preflight -static esp_err_t options_handler(httpd_req_t *req) -{ - set_cors_headers(req); - httpd_resp_set_status(req, "204 No Content"); - httpd_resp_send(req, NULL, 0); - return ESP_OK; -} - -// ============================================================================ -// Capabilities API -// ============================================================================ - -esp_err_t api_capabilities_get_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/capabilities"); - - // Thread only available for esp32c6 or esp32h2 - bool thread = false; -#if defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2) - thread = false; -#endif - cJSON *json = cJSON_CreateObject(); - cJSON_AddBoolToObject(json, "thread", thread); - char *response = cJSON_PrintUnformatted(json); - cJSON_Delete(json); - esp_err_t res = send_json_response(req, response); - free(response); - return res; -} - -// ============================================================================ -// WiFi API -// ============================================================================ - -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, false); - if (err != ESP_OK) - { - 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); - - 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') - { - 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); - esp_err_t res = send_json_response(req, response); - free(response); - return res; -} - -static void reboot_task(void *param) -{ - vTaskDelay(pdMS_TO_TICKS(100)); - esp_restart(); -} - -static bool is_valid(const cJSON *string) -{ - return string && cJSON_IsString(string) && string->valuestring && strlen(string->valuestring) > 0; -} - -esp_err_t api_wifi_config_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/wifi/config"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[256]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - cJSON *json = cJSON_Parse(buf); - if (json) - { - cJSON *ssid = cJSON_GetObjectItem(json, "ssid"); - cJSON *pw = cJSON_GetObjectItem(json, "password"); - if (is_valid(ssid) && is_valid(pw)) - { - persistence_manager_t pm; - if (persistence_manager_init(&pm, "wifi_config") == ESP_OK) - { - persistence_manager_set_string(&pm, "ssid", ssid->valuestring); - persistence_manager_set_string(&pm, "password", pw->valuestring); - persistence_manager_deinit(&pm); - } - } - if (is_valid(pw)) - { - size_t pwlen = strlen(pw->valuestring); - char *masked = heap_caps_malloc(pwlen + 1, MALLOC_CAP_DEFAULT); - if (masked) - { - memset(masked, '*', pwlen); - masked[pwlen] = '\0'; - cJSON_ReplaceItemInObject(json, "password", cJSON_CreateString(masked)); - char *logstr = cJSON_PrintUnformatted(json); - ESP_LOGI(TAG, "Received WiFi config: %s", logstr); - free(logstr); - free(masked); - } - else - { - ESP_LOGI(TAG, "Received WiFi config: %s", buf); - } - } - else - { - ESP_LOGI(TAG, "Received WiFi config: %s", buf); - } - cJSON_Delete(json); - } - else - { - ESP_LOGI(TAG, "Received WiFi config: %s", buf); - } - - // Define a reboot task function - xTaskCreate(reboot_task, "reboot_task", 2048, NULL, 5, NULL); - - set_cors_headers(req); - httpd_resp_set_status(req, "200 OK"); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_wifi_status_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/wifi/status"); - - wifi_ap_record_t ap_info; - bool connected = false; - char ssid[33] = ""; - char ip[16] = ""; - int rssi = 0; - - wifi_mode_t mode; - esp_wifi_get_mode(&mode); - if (mode == WIFI_MODE_STA || mode == WIFI_MODE_APSTA) - { - if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) - { - connected = true; - strncpy(ssid, (const char *)ap_info.ssid, sizeof(ssid) - 1); - rssi = ap_info.rssi; - } - esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - if (netif) - { - esp_netif_ip_info_t ip_info; - if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) - { - snprintf(ip, sizeof(ip), "%d.%d.%d.%d", esp_ip4_addr1(&ip_info.ip), esp_ip4_addr2(&ip_info.ip), - esp_ip4_addr3(&ip_info.ip), esp_ip4_addr4(&ip_info.ip)); - } - } - } - - cJSON *json = cJSON_CreateObject(); - cJSON_AddBoolToObject(json, "connected", connected); - cJSON_AddStringToObject(json, "ssid", ssid); - cJSON_AddStringToObject(json, "ip", ip); - cJSON_AddNumberToObject(json, "rssi", rssi); - char *response = cJSON_PrintUnformatted(json); - cJSON_Delete(json); - esp_err_t res = send_json_response(req, response); - free(response); - return res; -} - -// ============================================================================ -// Light Control API -// ============================================================================ - -esp_err_t api_light_power_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/light/power"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[64]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Received light power: %s", buf); - - cJSON *json = cJSON_Parse(buf); - if (json) - { - cJSON *active = cJSON_GetObjectItem(json, "on"); - if (cJSON_IsBool(active)) - { - message_t msg = {}; - msg.type = MESSAGE_TYPE_SETTINGS; - msg.data.settings.type = SETTINGS_TYPE_BOOL; - strncpy(msg.data.settings.key, "light_active", sizeof(msg.data.settings.key) - 1); - msg.data.settings.value.bool_value = cJSON_IsTrue(active); - message_manager_post(&msg, pdMS_TO_TICKS(100)); - } - cJSON_Delete(json); - } - - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_light_thunder_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/light/thunder"); - - char buf[64]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Received thunder setting: %s", buf); - - // TODO: Parse JSON and control thunder effect - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_light_mode_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/light/mode"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[64]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Received light mode: %s", buf); - - cJSON *json = cJSON_Parse(buf); - if (json) - { - cJSON *mode = cJSON_GetObjectItem(json, "mode"); - if (cJSON_IsString(mode)) - { - message_t msg = {}; - msg.type = MESSAGE_TYPE_SETTINGS; - msg.data.settings.type = SETTINGS_TYPE_INT; - strncpy(msg.data.settings.key, "light_mode", sizeof(msg.data.settings.key) - 1); - if (strcmp(mode->valuestring, "simulation") == 0) - { - msg.data.settings.value.int_value = 0; - } - else if (strcmp(mode->valuestring, "day") == 0) - { - msg.data.settings.value.int_value = 1; - } - else if (strcmp(mode->valuestring, "night") == 0) - { - msg.data.settings.value.int_value = 2; - } - else - { - msg.data.settings.value.int_value = -1; // Unknown mode - } - message_manager_post(&msg, pdMS_TO_TICKS(100)); - } - cJSON_Delete(json); - } - - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_light_schema_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/light/schema"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[128]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Received schema setting: %s", buf); - - cJSON *json = cJSON_Parse(buf); - if (json) - { - cJSON *schema_file = cJSON_GetObjectItem(json, "schema"); - if (cJSON_IsString(schema_file)) - { - int schema_id = 0; - sscanf(schema_file->valuestring, "schema_%d.csv", &schema_id); - - message_t msg = {}; - msg.type = MESSAGE_TYPE_SETTINGS; - msg.data.settings.type = SETTINGS_TYPE_INT; - strncpy(msg.data.settings.key, "light_variant", sizeof(msg.data.settings.key) - 1); - msg.data.settings.value.int_value = schema_id; - message_manager_post(&msg, pdMS_TO_TICKS(100)); - } - cJSON_Delete(json); - } - - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_light_status_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/light/status"); - cJSON *json = create_light_status_json(); - char *response = cJSON_PrintUnformatted(json); - cJSON_Delete(json); - esp_err_t res = send_json_response(req, response); - free(response); - return res; -} - -// ============================================================================ -// LED Configuration API -// ============================================================================ - -esp_err_t api_wled_config_get_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/wled/config"); - - extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; - extern size_t segment_count; - size_t required_size = sizeof(segments) * segment_count; - - cJSON *json = cJSON_CreateObject(); - - persistence_manager_t pm; - if (persistence_manager_init(&pm, "led_config") == ESP_OK) - { - persistence_manager_get_blob(&pm, "segments", segments, required_size, NULL); - uint8_t segment_count = persistence_manager_get_int(&pm, "segment_count", 0); - persistence_manager_deinit(&pm); - - cJSON *segments_arr = cJSON_CreateArray(); - for (uint8_t i = 0; i < segment_count; ++i) - { - cJSON *seg = cJSON_CreateObject(); - cJSON_AddStringToObject(seg, "name", segments[i].name); - cJSON_AddNumberToObject(seg, "start", segments[i].start); - cJSON_AddNumberToObject(seg, "leds", segments[i].leds); - cJSON_AddItemToArray(segments_arr, seg); - } - cJSON_AddItemToObject(json, "segments", segments_arr); - } - else - { - cJSON_AddItemToObject(json, "segments", cJSON_CreateArray()); - } - - char *response = cJSON_PrintUnformatted(json); - cJSON_Delete(json); - esp_err_t res = send_json_response(req, response); - free(response); - return res; -} - -esp_err_t api_wled_config_post_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/wled/config"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char *buf = heap_caps_malloc(MAX_BODY_SIZE, MALLOC_CAP_DEFAULT); - if (!buf) - return send_error_response(req, 500, "Memory allocation failed"); - int total = 0, ret; - while (total < MAX_BODY_SIZE - 1) - { - ret = httpd_req_recv(req, buf + total, MAX_BODY_SIZE - 1 - total); - if (ret <= 0) - break; - total += ret; - } - buf[total] = '\0'; - - ESP_LOGI(TAG, "Received WLED config: %s", buf); - - cJSON *json = cJSON_Parse(buf); - free(buf); - - if (!json) - { - return send_error_response(req, 400, "Invalid JSON"); - } - - cJSON *segments_arr = cJSON_GetObjectItem(json, "segments"); - if (!cJSON_IsArray(segments_arr)) - { - cJSON_Delete(json); - return send_error_response(req, 400, "Missing segments array"); - } - - extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; - extern size_t segment_count; - size_t count = cJSON_GetArraySize(segments_arr); - if (count > LED_SEGMENT_MAX_LEN) - count = LED_SEGMENT_MAX_LEN; - segment_count = count; - for (size_t i = 0; i < LED_SEGMENT_MAX_LEN; ++i) - { - cJSON *seg = cJSON_GetArrayItem(segments_arr, i); - cJSON *name = cJSON_GetObjectItem(seg, "name"); - cJSON *start = cJSON_GetObjectItem(seg, "start"); - cJSON *leds = cJSON_GetObjectItem(seg, "leds"); - if (cJSON_IsString(name) && cJSON_IsNumber(start) && cJSON_IsNumber(leds) && i < count) - { - strncpy(segments[i].name, name->valuestring, sizeof(segments[i].name) - 1); - segments[i].name[sizeof(segments[i].name) - 1] = '\0'; - segments[i].start = (uint16_t)start->valuedouble; - segments[i].leds = (uint16_t)leds->valuedouble; - } - else - { - // Invalid entry, skip or set defaults - segments[i].name[0] = '\0'; - segments[i].start = 0; - segments[i].leds = 0; - } - } - cJSON_Delete(json); - - persistence_manager_t pm; - if (persistence_manager_init(&pm, "led_config") == ESP_OK) - { - persistence_manager_set_blob(&pm, "segments", segments, sizeof(led_segment_t) * segment_count); - persistence_manager_set_int(&pm, "segment_count", (int32_t)segment_count); - persistence_manager_deinit(&pm); - } - - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -// ============================================================================ -// Schema API -// ============================================================================ -static char *heap_caps_strdup(const char *src, uint32_t caps) -{ - if (!src) - return NULL; - size_t len = strlen(src) + 1; - char *dst = heap_caps_malloc(len, caps); - if (dst) - memcpy(dst, src, len); - return dst; -} - -esp_err_t api_schema_get_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/schema/*"); - - // Extract filename from URI - const char *uri = req->uri; - const char *filename = strrchr(uri, '/'); - if (filename == NULL) - { - return send_error_response(req, 400, "Invalid schema path"); - } - filename++; // Skip the '/' - - ESP_LOGI(TAG, "Requested schema: %s", filename); - - // Read schema file - char path[128]; - snprintf(path, sizeof(path), "%s", filename); - - int line_count = 0; - char **lines = read_lines_filtered(path, &line_count); - - set_cors_headers(req); - httpd_resp_set_type(req, "text/csv"); - - if (!lines || line_count == 0) - { - return httpd_resp_sendstr(req, ""); - } - - // Calculate total length - size_t total_len = 0; - for (int i = 0; i < line_count; ++i) - total_len += strlen(lines[i]) + 1; - char *csv = heap_caps_malloc(total_len + 1, MALLOC_CAP_DEFAULT); - char *p = csv; - for (int i = 0; i < line_count; ++i) - { - size_t l = strlen(lines[i]); - memcpy(p, lines[i], l); - p += l; - *p++ = '\n'; - } - *p = '\0'; - free_lines(lines, line_count); - esp_err_t res = httpd_resp_sendstr(req, csv); - free(csv); - return res; -} - -esp_err_t api_schema_post_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/schema/*"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - // Extract filename from URI - if (!req) - { - ESP_LOGE(TAG, "Request pointer is NULL"); - return send_error_response(req, 500, "Internal error: req is NULL"); - } - const char *uri = req->uri; - ESP_LOGI(TAG, "Request URI: %s", uri ? uri : "(null)"); - if (!uri) - { - ESP_LOGE(TAG, "Request URI is NULL"); - return send_error_response(req, 400, "Invalid schema path (no URI)"); - } - const char *filename = strrchr(uri, '/'); - if (filename == NULL || filename[1] == '\0') - { - ESP_LOGE(TAG, "Could not extract filename from URI: %s", uri); - return send_error_response(req, 400, "Invalid schema path"); - } - filename++; - ESP_LOGI(TAG, "Extracted filename: %s", filename); - - // Dynamically read POST body (like api_wled_config_post_handler) - char *buf = heap_caps_malloc(MAX_BODY_SIZE, MALLOC_CAP_DEFAULT); - if (!buf) - { - ESP_LOGE(TAG, "Memory allocation failed for POST body"); - return send_error_response(req, 500, "Memory allocation failed"); - } - int total = 0, ret; - while (total < MAX_BODY_SIZE - 1) - { - ret = httpd_req_recv(req, buf + total, MAX_BODY_SIZE - 1 - total); - if (ret <= 0) - break; - total += ret; - } - buf[total] = '\0'; - - ESP_LOGI(TAG, "Saving schema %s, size: %d bytes", filename, total); - - // Split CSV body into line array - int line_count = 0; - // Count lines - for (int i = 0; i < total; ++i) - if (buf[i] == '\n') - line_count++; - if (total > 0 && buf[total - 1] != '\n') - line_count++; // last line without \n - - char **lines = (char **)heap_caps_malloc(line_count * sizeof(char *), MALLOC_CAP_DEFAULT); - int idx = 0; - char *saveptr = NULL; - char *line = strtok_r(buf, "\n", &saveptr); - while (line && idx < line_count) - { - // Ignore empty lines - if (line[0] != '\0') - lines[idx++] = heap_caps_strdup(line, MALLOC_CAP_DEFAULT); - line = strtok_r(NULL, "\n", &saveptr); - } - int actual_count = idx; - esp_err_t err = write_lines(filename, lines, actual_count); - for (int i = 0; i < actual_count; ++i) - free(lines[i]); - free(lines); - set_cors_headers(req); - - if (err != ESP_OK) - return send_error_response(req, 500, "Failed to save schema"); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -// ============================================================================ -// Devices API (Matter) -// ============================================================================ - -esp_err_t api_devices_scan_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/devices/scan"); - - // TODO: Implement Matter device scanning - const char *response = "[" - "{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Matter Lamp\"}," - "{\"id\":\"matter-002\",\"type\":\"sensor\",\"name\":\"Temperature Sensor\"}" - "]"; - return send_json_response(req, response); -} - -esp_err_t api_devices_pair_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/devices/pair"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[256]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Pairing device: %s", buf); - - // TODO: Implement Matter device pairing - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_devices_paired_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/devices/paired"); - - // TODO: Get list of paired devices - const char *response = "[" - "{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Living Room Lamp\"}" - "]"; - return send_json_response(req, response); -} - -esp_err_t api_devices_update_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/devices/update"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[256]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Updating device: %s", buf); - - // TODO: Update device name - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_devices_unpair_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/devices/unpair"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[128]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Unpairing device: %s", buf); - - // TODO: Unpair device - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_devices_toggle_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/devices/toggle"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[128]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Toggling device: %s", buf); - - // TODO: Toggle device - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -// ============================================================================ -// Scenes API -// ============================================================================ - -esp_err_t api_scenes_get_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "GET /api/scenes"); - - // TODO: Get scenes from storage - const char *response = "[" - "{" - "\"id\":\"scene-1\"," - "\"name\":\"Evening Mood\"," - "\"icon\":\"🌅\"," - "\"actions\":{" - "\"light\":\"on\"," - "\"mode\":\"simulation\"," - "\"schema\":\"schema_02.csv\"" - "}" - "}," - "{" - "\"id\":\"scene-2\"," - "\"name\":\"Night Mode\"," - "\"icon\":\"🌙\"," - "\"actions\":{" - "\"light\":\"on\"," - "\"mode\":\"night\"" - "}" - "}" - "]"; - return send_json_response(req, response); -} - -esp_err_t api_scenes_post_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/scenes"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[512]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Creating/updating scene: %s", buf); - - // TODO: Save scene to storage - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_scenes_delete_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "DELETE /api/scenes"); - - char buf[128]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Deleting scene: %s", buf); - - // TODO: Delete scene from storage - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -esp_err_t api_scenes_activate_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "POST /api/scenes/activate"); - ESP_LOGI(TAG, "Request content length: %d", req->content_len); - - char buf[128]; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) - { - return send_error_response(req, 400, "Failed to receive request body"); - } - buf[ret] = '\0'; - - ESP_LOGI(TAG, "Activating scene: %s", buf); - - // TODO: Activate scene - set_cors_headers(req); - return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); -} - -// ============================================================================ -// Static file serving -// ============================================================================ - -// Get MIME type from file extension -static const char *get_mime_type(const char *path) -{ - const char *ext = strrchr(path, '.'); - if (ext == NULL) - return "text/plain"; - - if (strcmp(ext, ".html") == 0) - return "text/html"; - if (strcmp(ext, ".css") == 0) - return "text/css"; - if (strcmp(ext, ".js") == 0) - return "application/javascript"; - if (strcmp(ext, ".json") == 0) - return "application/json"; - if (strcmp(ext, ".png") == 0) - return "image/png"; - if (strcmp(ext, ".jpg") == 0 || strcmp(ext, ".jpeg") == 0) - return "image/jpeg"; - if (strcmp(ext, ".svg") == 0) - return "image/svg+xml"; - if (strcmp(ext, ".ico") == 0) - return "image/x-icon"; - if (strcmp(ext, ".csv") == 0) - return "text/csv"; - - return "text/plain"; -} - -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; - esp_wifi_get_mode(&mode); - // Always serve captive.html in AP mode - if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA) - { - if (strcmp(uri, "/") == 0 || strcmp(uri, "/index.html") == 0) - { - uri = "/captive.html"; - } - } - else - { - // Default to index.html for root - if (strcmp(uri, "/") == 0) - { - uri = "/index.html"; - } - } - - const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; - int written = snprintf(filepath, sizeof(filepath), "%s%s", base_path, uri); - if (written < 0 || (size_t)written >= sizeof(filepath)) - { - ESP_LOGE(TAG, "URI too long: %s", uri); - return send_error_response(req, 400, "URI too long"); - } - - 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(served_path, &st) != 0) - { - 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(served_path, "rb"); - if (f == NULL) - { - 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; - while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0) - { - if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK) - { - fclose(f); - ESP_LOGE(TAG, "Failed to send file chunk"); - return ESP_FAIL; - } - } - - fclose(f); - httpd_resp_send_chunk(req, NULL, 0); // End response - return ESP_OK; -} - -// ============================================================================ -// Captive portal detection -// ============================================================================ - -esp_err_t api_captive_portal_handler(httpd_req_t *req) -{ - ESP_LOGI(TAG, "Captive portal detection: %s", req->uri); - - // Serve captive.html directly (status 200, text/html) - const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; - char filepath[256]; - snprintf(filepath, sizeof(filepath), "%s/captive.html", base_path); - FILE *f = fopen(filepath, "r"); - if (!f) - { - ESP_LOGE(TAG, "captive.html not found: %s", filepath); - httpd_resp_set_status(req, "500 Internal Server Error"); - httpd_resp_sendstr(req, "Captive portal not available"); - return ESP_FAIL; - } - httpd_resp_set_type(req, "text/html"); - char buf[512]; - size_t read_bytes; - while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0) - { - if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK) - { - fclose(f); - ESP_LOGE(TAG, "Failed to send captive chunk"); - return ESP_FAIL; - } - } - fclose(f); - httpd_resp_send_chunk(req, NULL, 0); - return ESP_OK; -} - -// ============================================================================ -// Handler Registration -// ============================================================================ - -esp_err_t api_handlers_register(httpd_handle_t server) -{ - esp_err_t err; - - // Capabilities - httpd_uri_t capabilities_get = { - .uri = "/api/capabilities", .method = HTTP_GET, .handler = api_capabilities_get_handler}; - err = httpd_register_uri_handler(server, &capabilities_get); - if (err != ESP_OK) - return err; - - // WiFi endpoints - httpd_uri_t wifi_scan = {.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = api_wifi_scan_handler}; - err = httpd_register_uri_handler(server, &wifi_scan); - if (err != ESP_OK) - return err; - - httpd_uri_t wifi_config = {.uri = "/api/wifi/config", .method = HTTP_POST, .handler = api_wifi_config_handler}; - err = httpd_register_uri_handler(server, &wifi_config); - if (err != ESP_OK) - return err; - - httpd_uri_t wifi_status = {.uri = "/api/wifi/status", .method = HTTP_GET, .handler = api_wifi_status_handler}; - err = httpd_register_uri_handler(server, &wifi_status); - if (err != ESP_OK) - return err; - - // Light endpoints - httpd_uri_t light_power = {.uri = "/api/light/power", .method = HTTP_POST, .handler = api_light_power_handler}; - err = httpd_register_uri_handler(server, &light_power); - if (err != ESP_OK) - return err; - - httpd_uri_t light_thunder = { - .uri = "/api/light/thunder", .method = HTTP_POST, .handler = api_light_thunder_handler}; - err = httpd_register_uri_handler(server, &light_thunder); - if (err != ESP_OK) - return err; - - httpd_uri_t light_mode = {.uri = "/api/light/mode", .method = HTTP_POST, .handler = api_light_mode_handler}; - err = httpd_register_uri_handler(server, &light_mode); - if (err != ESP_OK) - return err; - - httpd_uri_t light_schema = {.uri = "/api/light/schema", .method = HTTP_POST, .handler = api_light_schema_handler}; - err = httpd_register_uri_handler(server, &light_schema); - if (err != ESP_OK) - return err; - - httpd_uri_t light_status = {.uri = "/api/light/status", .method = HTTP_GET, .handler = api_light_status_handler}; - err = httpd_register_uri_handler(server, &light_status); - if (err != ESP_OK) - return err; - - // WLED config endpoints - httpd_uri_t wled_config_get = { - .uri = "/api/wled/config", .method = HTTP_GET, .handler = api_wled_config_get_handler}; - err = httpd_register_uri_handler(server, &wled_config_get); - if (err != ESP_OK) - return err; - - httpd_uri_t wled_config_post = { - .uri = "/api/wled/config", .method = HTTP_POST, .handler = api_wled_config_post_handler}; - err = httpd_register_uri_handler(server, &wled_config_post); - if (err != ESP_OK) - return err; - - // Schema endpoints (wildcard) - httpd_uri_t schema_get = {.uri = "/api/schema/*", .method = HTTP_GET, .handler = api_schema_get_handler}; - err = httpd_register_uri_handler(server, &schema_get); - if (err != ESP_OK) - return err; - - httpd_uri_t schema_post = {.uri = "/api/schema/*", .method = HTTP_POST, .handler = api_schema_post_handler}; - err = httpd_register_uri_handler(server, &schema_post); - if (err != ESP_OK) - return err; - - // Devices endpoints - httpd_uri_t devices_scan = {.uri = "/api/devices/scan", .method = HTTP_GET, .handler = api_devices_scan_handler}; - err = httpd_register_uri_handler(server, &devices_scan); - if (err != ESP_OK) - return err; - - httpd_uri_t devices_pair = {.uri = "/api/devices/pair", .method = HTTP_POST, .handler = api_devices_pair_handler}; - err = httpd_register_uri_handler(server, &devices_pair); - if (err != ESP_OK) - return err; - - httpd_uri_t devices_paired = { - .uri = "/api/devices/paired", .method = HTTP_GET, .handler = api_devices_paired_handler}; - err = httpd_register_uri_handler(server, &devices_paired); - if (err != ESP_OK) - return err; - - httpd_uri_t devices_update = { - .uri = "/api/devices/update", .method = HTTP_POST, .handler = api_devices_update_handler}; - err = httpd_register_uri_handler(server, &devices_update); - if (err != ESP_OK) - return err; - - httpd_uri_t devices_unpair = { - .uri = "/api/devices/unpair", .method = HTTP_POST, .handler = api_devices_unpair_handler}; - err = httpd_register_uri_handler(server, &devices_unpair); - if (err != ESP_OK) - return err; - - httpd_uri_t devices_toggle = { - .uri = "/api/devices/toggle", .method = HTTP_POST, .handler = api_devices_toggle_handler}; - err = httpd_register_uri_handler(server, &devices_toggle); - if (err != ESP_OK) - return err; - - // Scenes endpoints - httpd_uri_t scenes_get = {.uri = "/api/scenes", .method = HTTP_GET, .handler = api_scenes_get_handler}; - err = httpd_register_uri_handler(server, &scenes_get); - if (err != ESP_OK) - return err; - - httpd_uri_t scenes_post = {.uri = "/api/scenes", .method = HTTP_POST, .handler = api_scenes_post_handler}; - err = httpd_register_uri_handler(server, &scenes_post); - if (err != ESP_OK) - return err; - - httpd_uri_t scenes_delete = {.uri = "/api/scenes", .method = HTTP_DELETE, .handler = api_scenes_delete_handler}; - err = httpd_register_uri_handler(server, &scenes_delete); - if (err != ESP_OK) - return err; - - httpd_uri_t scenes_activate = { - .uri = "/api/scenes/activate", .method = HTTP_POST, .handler = api_scenes_activate_handler}; - err = httpd_register_uri_handler(server, &scenes_activate); - if (err != ESP_OK) - return err; - - // Captive portal detection endpoints - httpd_uri_t captive_generate_204 = { - .uri = "/generate_204", .method = HTTP_GET, .handler = api_captive_portal_handler}; - err = httpd_register_uri_handler(server, &captive_generate_204); - if (err != ESP_OK) - return err; - - httpd_uri_t captive_hotspot = { - .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = api_captive_portal_handler}; - err = httpd_register_uri_handler(server, &captive_hotspot); - if (err != ESP_OK) - return err; - - httpd_uri_t captive_connecttest = { - .uri = "/connecttest.txt", .method = HTTP_GET, .handler = api_captive_portal_handler}; - err = httpd_register_uri_handler(server, &captive_connecttest); - if (err != ESP_OK) - return err; - - // OPTIONS handler for CORS preflight (wildcard) - httpd_uri_t options = {.uri = "/api/*", .method = HTTP_OPTIONS, .handler = options_handler}; - err = httpd_register_uri_handler(server, &options); - if (err != ESP_OK) - return err; - - // Static file handler (must be last due to wildcard) - httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = api_static_file_handler}; - err = httpd_register_uri_handler(server, &static_files); - if (err != ESP_OK) - return err; - - ESP_LOGI(TAG, "All API handlers registered"); - return ESP_OK; -} diff --git a/firmware/components/api-server/CMakeLists.txt b/firmware/components/bifrost/CMakeLists.txt similarity index 68% rename from firmware/components/api-server/CMakeLists.txt rename to firmware/components/bifrost/CMakeLists.txt index 64b6999..09a2c8a 100644 --- a/firmware/components/api-server/CMakeLists.txt +++ b/firmware/components/bifrost/CMakeLists.txt @@ -2,6 +2,11 @@ idf_component_register(SRCS src/api_server.c src/common.c src/api_handlers.c + src/api_handlers_util.c + src/api_handlers_wifi.c + src/api_handlers_light.c + src/api_handlers_devices.c + src/api_handlers_static.c src/websocket_handler.c INCLUDE_DIRS "include" REQUIRES @@ -16,4 +21,5 @@ idf_component_register(SRCS persistence-manager message-manager my_mqtt_client + heimdall ) diff --git a/firmware/components/api-server/Kconfig b/firmware/components/bifrost/Kconfig similarity index 100% rename from firmware/components/api-server/Kconfig rename to firmware/components/bifrost/Kconfig diff --git a/firmware/components/api-server/idf_component.yml b/firmware/components/bifrost/idf_component.yml similarity index 100% rename from firmware/components/api-server/idf_component.yml rename to firmware/components/bifrost/idf_component.yml diff --git a/firmware/components/api-server/include/api_handlers.h b/firmware/components/bifrost/include/bifrost/api_handlers.h similarity index 100% rename from firmware/components/api-server/include/api_handlers.h rename to firmware/components/bifrost/include/bifrost/api_handlers.h diff --git a/firmware/components/bifrost/include/bifrost/api_handlers_util.h b/firmware/components/bifrost/include/bifrost/api_handlers_util.h new file mode 100644 index 0000000..1489ff2 --- /dev/null +++ b/firmware/components/bifrost/include/bifrost/api_handlers_util.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +#define MAX_BODY_SIZE 4096 + +esp_err_t set_cors_headers(httpd_req_t *req); +esp_err_t send_json_response(httpd_req_t *req, const char *json); +esp_err_t send_error_response(httpd_req_t *req, int status_code, const char *message); +esp_err_t options_handler(httpd_req_t *req); + +bool is_valid(const cJSON *string); +char *heap_caps_strdup(const char *src, uint32_t caps); +const char *get_mime_type(const char *path); diff --git a/firmware/components/api-server/include/api_server.h b/firmware/components/bifrost/include/bifrost/api_server.h similarity index 100% rename from firmware/components/api-server/include/api_server.h rename to firmware/components/bifrost/include/bifrost/api_server.h diff --git a/firmware/components/api-server/include/common.h b/firmware/components/bifrost/include/bifrost/common.h similarity index 100% rename from firmware/components/api-server/include/common.h rename to firmware/components/bifrost/include/bifrost/common.h diff --git a/firmware/components/api-server/include/websocket_handler.h b/firmware/components/bifrost/include/bifrost/websocket_handler.h similarity index 100% rename from firmware/components/api-server/include/websocket_handler.h rename to firmware/components/bifrost/include/bifrost/websocket_handler.h diff --git a/firmware/components/bifrost/src/api_handlers.c b/firmware/components/bifrost/src/api_handlers.c new file mode 100644 index 0000000..b31550e --- /dev/null +++ b/firmware/components/bifrost/src/api_handlers.c @@ -0,0 +1,223 @@ +#include "bifrost/api_handlers.h" +#include "bifrost/api_handlers_util.h" +#include "heimdall/action_manager.h" + +#include +#include +#include + +static const char *TAG = "api_handlers"; + +// ============================================================================ +// Input API (Heimdall) +// ============================================================================ + +static esp_err_t api_input_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/input"); + + char body[MAX_BODY_SIZE]; + int received = httpd_req_recv(req, body, sizeof(body) - 1); + if (received <= 0) + return send_error_response(req, 400, "Empty body"); + body[received] = '\0'; + + cJSON *json = cJSON_Parse(body); + if (!json) + return send_error_response(req, 400, "Invalid JSON"); + + const cJSON *action = cJSON_GetObjectItem(json, "action"); + if (!cJSON_IsString(action) || !action->valuestring[0]) + { + cJSON_Delete(json); + return send_error_response(req, 400, "Missing or empty 'action' field"); + } + + const cJSON *value = cJSON_GetObjectItem(json, "value"); + const char *value_str = (cJSON_IsString(value)) ? value->valuestring : NULL; + + action_manager_execute(action->valuestring, value_str); + + cJSON_Delete(json); + return send_json_response(req, "{\"ok\":true}"); +} + +// ============================================================================ +// Handler Registration +// ============================================================================ + +esp_err_t api_handlers_register(httpd_handle_t server) +{ + esp_err_t err; + + // Capabilities + httpd_uri_t capabilities_get = { + .uri = "/api/capabilities", .method = HTTP_GET, .handler = api_capabilities_get_handler}; + err = httpd_register_uri_handler(server, &capabilities_get); + if (err != ESP_OK) + return err; + + // WiFi endpoints + httpd_uri_t wifi_scan = {.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = api_wifi_scan_handler}; + err = httpd_register_uri_handler(server, &wifi_scan); + if (err != ESP_OK) + return err; + + httpd_uri_t wifi_config = {.uri = "/api/wifi/config", .method = HTTP_POST, .handler = api_wifi_config_handler}; + err = httpd_register_uri_handler(server, &wifi_config); + if (err != ESP_OK) + return err; + + httpd_uri_t wifi_status = {.uri = "/api/wifi/status", .method = HTTP_GET, .handler = api_wifi_status_handler}; + err = httpd_register_uri_handler(server, &wifi_status); + if (err != ESP_OK) + return err; + + // Light endpoints + httpd_uri_t light_power = {.uri = "/api/light/power", .method = HTTP_POST, .handler = api_light_power_handler}; + err = httpd_register_uri_handler(server, &light_power); + if (err != ESP_OK) + return err; + + httpd_uri_t light_thunder = { + .uri = "/api/light/thunder", .method = HTTP_POST, .handler = api_light_thunder_handler}; + err = httpd_register_uri_handler(server, &light_thunder); + if (err != ESP_OK) + return err; + + httpd_uri_t light_mode = {.uri = "/api/light/mode", .method = HTTP_POST, .handler = api_light_mode_handler}; + err = httpd_register_uri_handler(server, &light_mode); + if (err != ESP_OK) + return err; + + httpd_uri_t light_schema = {.uri = "/api/light/schema", .method = HTTP_POST, .handler = api_light_schema_handler}; + err = httpd_register_uri_handler(server, &light_schema); + if (err != ESP_OK) + return err; + + httpd_uri_t light_status = {.uri = "/api/light/status", .method = HTTP_GET, .handler = api_light_status_handler}; + err = httpd_register_uri_handler(server, &light_status); + if (err != ESP_OK) + return err; + + // WLED config endpoints + httpd_uri_t wled_config_get = { + .uri = "/api/wled/config", .method = HTTP_GET, .handler = api_wled_config_get_handler}; + err = httpd_register_uri_handler(server, &wled_config_get); + if (err != ESP_OK) + return err; + + httpd_uri_t wled_config_post = { + .uri = "/api/wled/config", .method = HTTP_POST, .handler = api_wled_config_post_handler}; + err = httpd_register_uri_handler(server, &wled_config_post); + if (err != ESP_OK) + return err; + + // Schema endpoints (wildcard) + httpd_uri_t schema_get = {.uri = "/api/schema/*", .method = HTTP_GET, .handler = api_schema_get_handler}; + err = httpd_register_uri_handler(server, &schema_get); + if (err != ESP_OK) + return err; + + httpd_uri_t schema_post = {.uri = "/api/schema/*", .method = HTTP_POST, .handler = api_schema_post_handler}; + err = httpd_register_uri_handler(server, &schema_post); + if (err != ESP_OK) + return err; + + // Devices endpoints + httpd_uri_t devices_scan = {.uri = "/api/devices/scan", .method = HTTP_GET, .handler = api_devices_scan_handler}; + err = httpd_register_uri_handler(server, &devices_scan); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_pair = {.uri = "/api/devices/pair", .method = HTTP_POST, .handler = api_devices_pair_handler}; + err = httpd_register_uri_handler(server, &devices_pair); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_paired = { + .uri = "/api/devices/paired", .method = HTTP_GET, .handler = api_devices_paired_handler}; + err = httpd_register_uri_handler(server, &devices_paired); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_update = { + .uri = "/api/devices/update", .method = HTTP_POST, .handler = api_devices_update_handler}; + err = httpd_register_uri_handler(server, &devices_update); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_unpair = { + .uri = "/api/devices/unpair", .method = HTTP_POST, .handler = api_devices_unpair_handler}; + err = httpd_register_uri_handler(server, &devices_unpair); + if (err != ESP_OK) + return err; + + httpd_uri_t devices_toggle = { + .uri = "/api/devices/toggle", .method = HTTP_POST, .handler = api_devices_toggle_handler}; + err = httpd_register_uri_handler(server, &devices_toggle); + if (err != ESP_OK) + return err; + + // Scenes endpoints + httpd_uri_t scenes_get = {.uri = "/api/scenes", .method = HTTP_GET, .handler = api_scenes_get_handler}; + err = httpd_register_uri_handler(server, &scenes_get); + if (err != ESP_OK) + return err; + + httpd_uri_t scenes_post = {.uri = "/api/scenes", .method = HTTP_POST, .handler = api_scenes_post_handler}; + err = httpd_register_uri_handler(server, &scenes_post); + if (err != ESP_OK) + return err; + + httpd_uri_t scenes_delete = {.uri = "/api/scenes", .method = HTTP_DELETE, .handler = api_scenes_delete_handler}; + err = httpd_register_uri_handler(server, &scenes_delete); + if (err != ESP_OK) + return err; + + httpd_uri_t scenes_activate = { + .uri = "/api/scenes/activate", .method = HTTP_POST, .handler = api_scenes_activate_handler}; + err = httpd_register_uri_handler(server, &scenes_activate); + if (err != ESP_OK) + return err; + + // Input endpoint (Heimdall action dispatch) + httpd_uri_t input_post = {.uri = "/api/input", .method = HTTP_POST, .handler = api_input_handler}; + err = httpd_register_uri_handler(server, &input_post); + if (err != ESP_OK) + return err; + + // Captive portal detection endpoints + httpd_uri_t captive_generate_204 = { + .uri = "/generate_204", .method = HTTP_GET, .handler = api_captive_portal_handler}; + err = httpd_register_uri_handler(server, &captive_generate_204); + if (err != ESP_OK) + return err; + + httpd_uri_t captive_hotspot = { + .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = api_captive_portal_handler}; + err = httpd_register_uri_handler(server, &captive_hotspot); + if (err != ESP_OK) + return err; + + httpd_uri_t captive_connecttest = { + .uri = "/connecttest.txt", .method = HTTP_GET, .handler = api_captive_portal_handler}; + err = httpd_register_uri_handler(server, &captive_connecttest); + if (err != ESP_OK) + return err; + + // OPTIONS handler for CORS preflight (wildcard) + httpd_uri_t options = {.uri = "/api/*", .method = HTTP_OPTIONS, .handler = options_handler}; + err = httpd_register_uri_handler(server, &options); + if (err != ESP_OK) + return err; + + // Static file handler (must be last due to wildcard) + httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = api_static_file_handler}; + err = httpd_register_uri_handler(server, &static_files); + if (err != ESP_OK) + return err; + + ESP_LOGI(TAG, "All API handlers registered"); + return ESP_OK; +} diff --git a/firmware/components/bifrost/src/api_handlers_devices.c b/firmware/components/bifrost/src/api_handlers_devices.c new file mode 100644 index 0000000..0bd073a --- /dev/null +++ b/firmware/components/bifrost/src/api_handlers_devices.c @@ -0,0 +1,207 @@ +#include "bifrost/api_handlers.h" +#include "bifrost/api_handlers_util.h" + +#include +#include +#include + +static const char *TAG = "api_devices"; + +// ============================================================================ +// Devices API (Matter) +// ============================================================================ + +esp_err_t api_devices_scan_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/devices/scan"); + + // TODO: Implement Matter device scanning + const char *response = "[" + "{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Matter Lamp\"}," + "{\"id\":\"matter-002\",\"type\":\"sensor\",\"name\":\"Temperature Sensor\"}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_devices_pair_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/pair"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[256]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Pairing device: %s", buf); + + // TODO: Implement Matter device pairing + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_devices_paired_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/devices/paired"); + + // TODO: Get list of paired devices + const char *response = "[" + "{\"id\":\"matter-001\",\"type\":\"light\",\"name\":\"Living Room Lamp\"}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_devices_update_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/update"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[256]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Updating device: %s", buf); + + // TODO: Update device name + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_devices_unpair_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/unpair"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Unpairing device: %s", buf); + + // TODO: Unpair device + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_devices_toggle_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/devices/toggle"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Toggling device: %s", buf); + + // TODO: Toggle device + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Scenes API +// ============================================================================ + +esp_err_t api_scenes_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/scenes"); + + // TODO: Get scenes from storage + const char *response = "[" + "{" + "\"id\":\"scene-1\"," + "\"name\":\"Evening Mood\"," + "\"icon\":\"🌅\"," + "\"actions\":{" + "\"light\":\"on\"," + "\"mode\":\"simulation\"," + "\"schema\":\"schema_02.csv\"" + "}" + "}," + "{" + "\"id\":\"scene-2\"," + "\"name\":\"Night Mode\"," + "\"icon\":\"🌙\"," + "\"actions\":{" + "\"light\":\"on\"," + "\"mode\":\"night\"" + "}" + "}" + "]"; + return send_json_response(req, response); +} + +esp_err_t api_scenes_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/scenes"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[512]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Creating/updating scene: %s", buf); + + // TODO: Save scene to storage + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_scenes_delete_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "DELETE /api/scenes"); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Deleting scene: %s", buf); + + // TODO: Delete scene from storage + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_scenes_activate_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/scenes/activate"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Activating scene: %s", buf); + + // TODO: Activate scene + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} diff --git a/firmware/components/bifrost/src/api_handlers_light.c b/firmware/components/bifrost/src/api_handlers_light.c new file mode 100644 index 0000000..3e3d565 --- /dev/null +++ b/firmware/components/bifrost/src/api_handlers_light.c @@ -0,0 +1,423 @@ +#include "bifrost/api_handlers.h" +#include "bifrost/api_handlers_util.h" +#include "bifrost/common.h" +#include "led_segment.h" +#include "message_manager.h" +#include "persistence_manager.h" +#include "storage.h" + +#include +#include +#include +#include + +static const char *TAG = "api_light"; + +// ============================================================================ +// Light Control API +// ============================================================================ + +esp_err_t api_light_power_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/power"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[64]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received light power: %s", buf); + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *active = cJSON_GetObjectItem(json, "on"); + if (cJSON_IsBool(active)) + { + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_BOOL; + strncpy(msg.data.settings.key, "light_active", sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.bool_value = cJSON_IsTrue(active); + message_manager_post(&msg, pdMS_TO_TICKS(100)); + } + cJSON_Delete(json); + } + + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_thunder_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/thunder"); + + char buf[64]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received thunder setting: %s", buf); + + // TODO: Parse JSON and control thunder effect + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_mode_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/mode"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[64]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received light mode: %s", buf); + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *mode = cJSON_GetObjectItem(json, "mode"); + if (cJSON_IsString(mode)) + { + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_INT; + strncpy(msg.data.settings.key, "light_mode", sizeof(msg.data.settings.key) - 1); + if (strcmp(mode->valuestring, "simulation") == 0) + { + msg.data.settings.value.int_value = 0; + } + else if (strcmp(mode->valuestring, "day") == 0) + { + msg.data.settings.value.int_value = 1; + } + else if (strcmp(mode->valuestring, "night") == 0) + { + msg.data.settings.value.int_value = 2; + } + else + { + msg.data.settings.value.int_value = -1; // Unknown mode + } + message_manager_post(&msg, pdMS_TO_TICKS(100)); + } + cJSON_Delete(json); + } + + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_schema_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/light/schema"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[128]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received schema setting: %s", buf); + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *schema_file = cJSON_GetObjectItem(json, "schema"); + if (cJSON_IsString(schema_file)) + { + int schema_id = 0; + sscanf(schema_file->valuestring, "schema_%d.csv", &schema_id); + + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + msg.data.settings.type = SETTINGS_TYPE_INT; + strncpy(msg.data.settings.key, "light_variant", sizeof(msg.data.settings.key) - 1); + msg.data.settings.value.int_value = schema_id; + message_manager_post(&msg, pdMS_TO_TICKS(100)); + } + cJSON_Delete(json); + } + + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_light_status_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/light/status"); + cJSON *json = create_light_status_json(); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; +} + +// ============================================================================ +// LED Configuration API +// ============================================================================ + +esp_err_t api_wled_config_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wled/config"); + + extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; + extern size_t segment_count; + size_t required_size = sizeof(segments) * segment_count; + + cJSON *json = cJSON_CreateObject(); + + persistence_manager_t pm; + if (persistence_manager_init(&pm, "led_config") == ESP_OK) + { + persistence_manager_get_blob(&pm, "segments", segments, required_size, NULL); + uint8_t segment_count = persistence_manager_get_int(&pm, "segment_count", 0); + persistence_manager_deinit(&pm); + + cJSON *segments_arr = cJSON_CreateArray(); + for (uint8_t i = 0; i < segment_count; ++i) + { + cJSON *seg = cJSON_CreateObject(); + cJSON_AddStringToObject(seg, "name", segments[i].name); + cJSON_AddNumberToObject(seg, "start", segments[i].start); + cJSON_AddNumberToObject(seg, "leds", segments[i].leds); + cJSON_AddItemToArray(segments_arr, seg); + } + cJSON_AddItemToObject(json, "segments", segments_arr); + } + else + { + cJSON_AddItemToObject(json, "segments", cJSON_CreateArray()); + } + + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; +} + +esp_err_t api_wled_config_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/wled/config"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char *buf = heap_caps_malloc(MAX_BODY_SIZE, MALLOC_CAP_DEFAULT); + if (!buf) + return send_error_response(req, 500, "Memory allocation failed"); + int total = 0, ret; + while (total < MAX_BODY_SIZE - 1) + { + ret = httpd_req_recv(req, buf + total, MAX_BODY_SIZE - 1 - total); + if (ret <= 0) + break; + total += ret; + } + buf[total] = '\0'; + + ESP_LOGI(TAG, "Received WLED config: %s", buf); + + cJSON *json = cJSON_Parse(buf); + free(buf); + + if (!json) + { + return send_error_response(req, 400, "Invalid JSON"); + } + + cJSON *segments_arr = cJSON_GetObjectItem(json, "segments"); + if (!cJSON_IsArray(segments_arr)) + { + cJSON_Delete(json); + return send_error_response(req, 400, "Missing segments array"); + } + + extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; + extern size_t segment_count; + size_t count = cJSON_GetArraySize(segments_arr); + if (count > LED_SEGMENT_MAX_LEN) + count = LED_SEGMENT_MAX_LEN; + segment_count = count; + for (size_t i = 0; i < LED_SEGMENT_MAX_LEN; ++i) + { + cJSON *seg = cJSON_GetArrayItem(segments_arr, i); + cJSON *name = cJSON_GetObjectItem(seg, "name"); + cJSON *start = cJSON_GetObjectItem(seg, "start"); + cJSON *leds = cJSON_GetObjectItem(seg, "leds"); + if (cJSON_IsString(name) && cJSON_IsNumber(start) && cJSON_IsNumber(leds) && i < count) + { + strncpy(segments[i].name, name->valuestring, sizeof(segments[i].name) - 1); + segments[i].name[sizeof(segments[i].name) - 1] = '\0'; + segments[i].start = (uint16_t)start->valuedouble; + segments[i].leds = (uint16_t)leds->valuedouble; + } + else + { + // Invalid entry, skip or set defaults + segments[i].name[0] = '\0'; + segments[i].start = 0; + segments[i].leds = 0; + } + } + cJSON_Delete(json); + + persistence_manager_t pm; + if (persistence_manager_init(&pm, "led_config") == ESP_OK) + { + persistence_manager_set_blob(&pm, "segments", segments, sizeof(led_segment_t) * segment_count); + persistence_manager_set_int(&pm, "segment_count", (int32_t)segment_count); + persistence_manager_deinit(&pm); + } + + set_cors_headers(req); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +// ============================================================================ +// Schema API +// ============================================================================ + +esp_err_t api_schema_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/schema/*"); + + // Extract filename from URI + const char *uri = req->uri; + const char *filename = strrchr(uri, '/'); + if (filename == NULL) + { + return send_error_response(req, 400, "Invalid schema path"); + } + filename++; // Skip the '/' + + ESP_LOGI(TAG, "Requested schema: %s", filename); + + // Read schema file + char path[128]; + snprintf(path, sizeof(path), "%s", filename); + + int line_count = 0; + char **lines = read_lines_filtered(path, &line_count); + + set_cors_headers(req); + httpd_resp_set_type(req, "text/csv"); + + if (!lines || line_count == 0) + { + return httpd_resp_sendstr(req, ""); + } + + // Calculate total length + size_t total_len = 0; + for (int i = 0; i < line_count; ++i) + total_len += strlen(lines[i]) + 1; + char *csv = heap_caps_malloc(total_len + 1, MALLOC_CAP_DEFAULT); + char *p = csv; + for (int i = 0; i < line_count; ++i) + { + size_t l = strlen(lines[i]); + memcpy(p, lines[i], l); + p += l; + *p++ = '\n'; + } + *p = '\0'; + free_lines(lines, line_count); + esp_err_t res = httpd_resp_sendstr(req, csv); + free(csv); + return res; +} + +esp_err_t api_schema_post_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/schema/*"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + // Extract filename from URI + if (!req) + { + ESP_LOGE(TAG, "Request pointer is NULL"); + return send_error_response(req, 500, "Internal error: req is NULL"); + } + const char *uri = req->uri; + ESP_LOGI(TAG, "Request URI: %s", uri ? uri : "(null)"); + if (!uri) + { + ESP_LOGE(TAG, "Request URI is NULL"); + return send_error_response(req, 400, "Invalid schema path (no URI)"); + } + const char *filename = strrchr(uri, '/'); + if (filename == NULL || filename[1] == '\0') + { + ESP_LOGE(TAG, "Could not extract filename from URI: %s", uri); + return send_error_response(req, 400, "Invalid schema path"); + } + filename++; + ESP_LOGI(TAG, "Extracted filename: %s", filename); + + // Dynamically read POST body + char *buf = heap_caps_malloc(MAX_BODY_SIZE, MALLOC_CAP_DEFAULT); + if (!buf) + { + ESP_LOGE(TAG, "Memory allocation failed for POST body"); + return send_error_response(req, 500, "Memory allocation failed"); + } + int total = 0, ret; + while (total < MAX_BODY_SIZE - 1) + { + ret = httpd_req_recv(req, buf + total, MAX_BODY_SIZE - 1 - total); + if (ret <= 0) + break; + total += ret; + } + buf[total] = '\0'; + + ESP_LOGI(TAG, "Saving schema %s, size: %d bytes", filename, total); + + // Split CSV body into line array + int line_count = 0; + // Count lines + for (int i = 0; i < total; ++i) + if (buf[i] == '\n') + line_count++; + if (total > 0 && buf[total - 1] != '\n') + line_count++; // last line without \n + + char **lines = (char **)heap_caps_malloc(line_count * sizeof(char *), MALLOC_CAP_DEFAULT); + int idx = 0; + char *saveptr = NULL; + char *line = strtok_r(buf, "\n", &saveptr); + while (line && idx < line_count) + { + // Ignore empty lines + if (line[0] != '\0') + lines[idx++] = heap_caps_strdup(line, MALLOC_CAP_DEFAULT); + line = strtok_r(NULL, "\n", &saveptr); + } + int actual_count = idx; + esp_err_t err = write_lines(filename, lines, actual_count); + for (int i = 0; i < actual_count; ++i) + free(lines[i]); + free(lines); + set_cors_headers(req); + + if (err != ESP_OK) + return send_error_response(req, 500, "Failed to save schema"); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} diff --git a/firmware/components/bifrost/src/api_handlers_static.c b/firmware/components/bifrost/src/api_handlers_static.c new file mode 100644 index 0000000..5151a1b --- /dev/null +++ b/firmware/components/bifrost/src/api_handlers_static.c @@ -0,0 +1,139 @@ +#include "bifrost/api_handlers.h" +#include "bifrost/api_handlers_util.h" + +#include +#include +#include +#include + +static const char *TAG = "api_static"; + +// ============================================================================ +// Static file serving +// ============================================================================ + +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; + esp_wifi_get_mode(&mode); + // Always serve captive.html in AP mode + if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA) + { + if (strcmp(uri, "/") == 0 || strcmp(uri, "/index.html") == 0) + { + uri = "/captive.html"; + } + } + else + { + // Default to index.html for root + if (strcmp(uri, "/") == 0) + { + uri = "/index.html"; + } + } + + const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; + int written = snprintf(filepath, sizeof(filepath), "%s%s", base_path, uri); + if (written < 0 || (size_t)written >= sizeof(filepath)) + { + ESP_LOGE(TAG, "URI too long: %s", uri); + return send_error_response(req, 400, "URI too long"); + } + + 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(served_path, &st) != 0) + { + 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(served_path, "rb"); + if (f == NULL) + { + 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; + while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0) + { + if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK) + { + fclose(f); + ESP_LOGE(TAG, "Failed to send file chunk"); + return ESP_FAIL; + } + } + + fclose(f); + httpd_resp_send_chunk(req, NULL, 0); // End response + return ESP_OK; +} + +// ============================================================================ +// Captive portal detection +// ============================================================================ + +esp_err_t api_captive_portal_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "Captive portal detection: %s", req->uri); + + // Serve captive.html directly (status 200, text/html) + const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; + char filepath[256]; + snprintf(filepath, sizeof(filepath), "%s/captive.html", base_path); + FILE *f = fopen(filepath, "r"); + if (!f) + { + ESP_LOGE(TAG, "captive.html not found: %s", filepath); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "Captive portal not available"); + return ESP_FAIL; + } + httpd_resp_set_type(req, "text/html"); + char buf[512]; + size_t read_bytes; + while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0) + { + if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK) + { + fclose(f); + ESP_LOGE(TAG, "Failed to send captive chunk"); + return ESP_FAIL; + } + } + fclose(f); + httpd_resp_send_chunk(req, NULL, 0); + return ESP_OK; +} diff --git a/firmware/components/bifrost/src/api_handlers_util.c b/firmware/components/bifrost/src/api_handlers_util.c new file mode 100644 index 0000000..750a9aa --- /dev/null +++ b/firmware/components/bifrost/src/api_handlers_util.c @@ -0,0 +1,83 @@ +#include "bifrost/api_handlers_util.h" + +#include +#include + +esp_err_t set_cors_headers(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); + return ESP_OK; +} + +esp_err_t send_json_response(httpd_req_t *req, const char *json) +{ + set_cors_headers(req); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_sendstr(req, json); +} + +esp_err_t send_error_response(httpd_req_t *req, int status_code, const char *message) +{ + set_cors_headers(req); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_status(req, status_code == 400 ? "400 Bad Request" + : status_code == 404 ? "404 Not Found" + : "500 Internal Server Error"); + char buffer[128]; + snprintf(buffer, sizeof(buffer), "{\"error\":\"%s\"}", message); + return httpd_resp_sendstr(req, buffer); +} + +esp_err_t options_handler(httpd_req_t *req) +{ + set_cors_headers(req); + httpd_resp_set_status(req, "204 No Content"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +bool is_valid(const cJSON *string) +{ + return string && cJSON_IsString(string) && string->valuestring && strlen(string->valuestring) > 0; +} + +char *heap_caps_strdup(const char *src, uint32_t caps) +{ + if (!src) + return NULL; + size_t len = strlen(src) + 1; + char *dst = heap_caps_malloc(len, caps); + if (dst) + memcpy(dst, src, len); + return dst; +} + +const char *get_mime_type(const char *path) +{ + const char *ext = strrchr(path, '.'); + if (ext == NULL) + return "text/plain"; + + if (strcmp(ext, ".html") == 0) + return "text/html"; + if (strcmp(ext, ".css") == 0) + return "text/css"; + if (strcmp(ext, ".js") == 0) + return "application/javascript"; + if (strcmp(ext, ".json") == 0) + return "application/json"; + if (strcmp(ext, ".png") == 0) + return "image/png"; + if (strcmp(ext, ".jpg") == 0 || strcmp(ext, ".jpeg") == 0) + return "image/jpeg"; + if (strcmp(ext, ".svg") == 0) + return "image/svg+xml"; + if (strcmp(ext, ".ico") == 0) + return "image/x-icon"; + if (strcmp(ext, ".csv") == 0) + return "text/csv"; + + return "text/plain"; +} diff --git a/firmware/components/bifrost/src/api_handlers_wifi.c b/firmware/components/bifrost/src/api_handlers_wifi.c new file mode 100644 index 0000000..db56f80 --- /dev/null +++ b/firmware/components/bifrost/src/api_handlers_wifi.c @@ -0,0 +1,203 @@ +#include "bifrost/api_handlers.h" +#include "bifrost/api_handlers_util.h" +#include "persistence_manager.h" + +#include +#include +#include +#include +#include + +static const char *TAG = "api_wifi"; + +// ============================================================================ +// Capabilities API +// ============================================================================ + +esp_err_t api_capabilities_get_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/capabilities"); + + // Thread only available for esp32c6 or esp32h2 + bool thread = false; +#if defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2) + thread = false; +#endif + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "thread", thread); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; +} + +// ============================================================================ +// WiFi API +// ============================================================================ + +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, false); + if (err != ESP_OK) + { + 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); + + 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') + { + 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); + esp_err_t res = send_json_response(req, response); + free(response); + return res; +} + +static void reboot_task(void *param) +{ + vTaskDelay(pdMS_TO_TICKS(100)); + esp_restart(); +} + +esp_err_t api_wifi_config_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "POST /api/wifi/config"); + ESP_LOGI(TAG, "Request content length: %d", req->content_len); + + char buf[256]; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) + { + return send_error_response(req, 400, "Failed to receive request body"); + } + buf[ret] = '\0'; + + cJSON *json = cJSON_Parse(buf); + if (json) + { + cJSON *ssid = cJSON_GetObjectItem(json, "ssid"); + cJSON *pw = cJSON_GetObjectItem(json, "password"); + if (is_valid(ssid) && is_valid(pw)) + { + persistence_manager_t pm; + if (persistence_manager_init(&pm, "wifi_config") == ESP_OK) + { + persistence_manager_set_string(&pm, "ssid", ssid->valuestring); + persistence_manager_set_string(&pm, "password", pw->valuestring); + persistence_manager_deinit(&pm); + } + } + if (is_valid(pw)) + { + size_t pwlen = strlen(pw->valuestring); + char *masked = heap_caps_malloc(pwlen + 1, MALLOC_CAP_DEFAULT); + if (masked) + { + memset(masked, '*', pwlen); + masked[pwlen] = '\0'; + cJSON_ReplaceItemInObject(json, "password", cJSON_CreateString(masked)); + char *logstr = cJSON_PrintUnformatted(json); + ESP_LOGI(TAG, "Received WiFi config: %s", logstr); + free(logstr); + free(masked); + } + else + { + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + } + } + else + { + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + } + cJSON_Delete(json); + } + else + { + ESP_LOGI(TAG, "Received WiFi config: %s", buf); + } + + // Define a reboot task function + xTaskCreate(reboot_task, "reboot_task", 2048, NULL, 5, NULL); + + set_cors_headers(req); + httpd_resp_set_status(req, "200 OK"); + return httpd_resp_sendstr(req, "{\"status\":\"ok\"}"); +} + +esp_err_t api_wifi_status_handler(httpd_req_t *req) +{ + ESP_LOGI(TAG, "GET /api/wifi/status"); + + wifi_ap_record_t ap_info; + bool connected = false; + char ssid[33] = ""; + char ip[16] = ""; + int rssi = 0; + + wifi_mode_t mode; + esp_wifi_get_mode(&mode); + if (mode == WIFI_MODE_STA || mode == WIFI_MODE_APSTA) + { + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) + { + connected = true; + strncpy(ssid, (const char *)ap_info.ssid, sizeof(ssid) - 1); + rssi = ap_info.rssi; + } + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (netif) + { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) + { + snprintf(ip, sizeof(ip), "%d.%d.%d.%d", esp_ip4_addr1(&ip_info.ip), esp_ip4_addr2(&ip_info.ip), + esp_ip4_addr3(&ip_info.ip), esp_ip4_addr4(&ip_info.ip)); + } + } + } + + cJSON *json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "connected", connected); + cJSON_AddStringToObject(json, "ssid", ssid); + cJSON_AddStringToObject(json, "ip", ip); + cJSON_AddNumberToObject(json, "rssi", rssi); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + esp_err_t res = send_json_response(req, response); + free(response); + return res; +} diff --git a/firmware/components/api-server/src/api_server.c b/firmware/components/bifrost/src/api_server.c similarity index 97% rename from firmware/components/api-server/src/api_server.c rename to firmware/components/bifrost/src/api_server.c index cc93277..b43922f 100644 --- a/firmware/components/api-server/src/api_server.c +++ b/firmware/components/bifrost/src/api_server.c @@ -1,9 +1,9 @@ -#include "api_server.h" -#include "api_handlers.h" -#include "websocket_handler.h" - -#include "common.h" +#include "bifrost/api_server.h" +#include "bifrost/api_handlers.h" +#include "bifrost/common.h" +#include "bifrost/websocket_handler.h" #include "storage.h" + #include #include #include diff --git a/firmware/components/api-server/src/common.c b/firmware/components/bifrost/src/common.c similarity index 73% rename from firmware/components/api-server/src/common.c rename to firmware/components/bifrost/src/common.c index f3ed6d4..35f4d0f 100644 --- a/firmware/components/api-server/src/common.c +++ b/firmware/components/bifrost/src/common.c @@ -1,12 +1,12 @@ -#include "common.h" -#include -#include - -#include "api_server.h" +#include "bifrost/common.h" +#include "bifrost/api_server.h" #include "color.h" #include "message_manager.h" #include "persistence_manager.h" #include "simulator.h" + +#include +#include #include #include @@ -29,6 +29,21 @@ static void on_message_received(const message_t *msg) api_server_ws_broadcast(response); free(response); } + else if (msg->type == MESSAGE_TYPE_SETTINGS) + { + const char *key = msg->data.settings.key; + if (strcmp(key, "light_active") == 0 || + strcmp(key, "light_mode") == 0 || + strcmp(key, "light_variant") == 0) + { + cJSON *json = create_light_status_json(); + cJSON_AddStringToObject(json, "type", "status"); + char *response = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + api_server_ws_broadcast(response); + free(response); + } + } } void common_init(void) @@ -36,7 +51,7 @@ void common_init(void) message_manager_register_listener(on_message_received); } -// Gibt ein cJSON-Objekt with dem aktuellen Lichtstatus zurück +// Returns a cJSON object with the current light status cJSON *create_light_status_json(void) { persistence_manager_t pm; @@ -48,7 +63,7 @@ cJSON *create_light_status_json(void) cJSON_AddBoolToObject(json, "thunder", false); - int mode = persistence_manager_get_int(&pm, "light_mode", 0); + int mode = persistence_manager_get_int(&pm, "light_mode", 1); const char *mode_str = "simulation"; if (mode == 1) { @@ -60,7 +75,7 @@ cJSON *create_light_status_json(void) } cJSON_AddStringToObject(json, "mode", mode_str); - int variant = persistence_manager_get_int(&pm, "light_variant", 3); + int variant = persistence_manager_get_int(&pm, "light_variant", 1); char schema_filename[20]; snprintf(schema_filename, sizeof(schema_filename), "schema_%02d.csv", variant); cJSON_AddStringToObject(json, "schema", schema_filename); diff --git a/firmware/components/api-server/src/websocket_handler.c b/firmware/components/bifrost/src/websocket_handler.c similarity index 98% rename from firmware/components/api-server/src/websocket_handler.c rename to firmware/components/bifrost/src/websocket_handler.c index 7e682c3..98f0444 100644 --- a/firmware/components/api-server/src/websocket_handler.c +++ b/firmware/components/bifrost/src/websocket_handler.c @@ -1,11 +1,11 @@ -#include "websocket_handler.h" -#include "api_server.h" -#include "common.h" - +#include "bifrost/websocket_handler.h" +#include "bifrost/api_server.h" +#include "bifrost/common.h" +#include "message_manager.h" #include "my_mqtt_client.h" + #include #include -#include #include static const char *TAG = "websocket_handler"; diff --git a/firmware/components/connectivity-manager/CMakeLists.txt b/firmware/components/connectivity-manager/CMakeLists.txt index a57233e..807d5b7 100644 --- a/firmware/components/connectivity-manager/CMakeLists.txt +++ b/firmware/components/connectivity-manager/CMakeLists.txt @@ -12,5 +12,5 @@ idf_component_register(SRCS esp_insights analytics led-manager - api-server + bifrost ) diff --git a/firmware/components/connectivity-manager/src/ble/ble_scanner.c b/firmware/components/connectivity-manager/src/ble/ble_scanner.c index e6cc282..6abc235 100644 --- a/firmware/components/connectivity-manager/src/ble/ble_scanner.c +++ b/firmware/components/connectivity-manager/src/ble/ble_scanner.c @@ -1,7 +1,7 @@ #include "ble/ble_scanner.h" - #include "ble/ble_device.h" #include "led_status.h" + #include #include #include diff --git a/firmware/components/connectivity-manager/src/dns_hijack.c b/firmware/components/connectivity-manager/src/dns_hijack.c index eb5657b..f12ebb9 100644 --- a/firmware/components/connectivity-manager/src/dns_hijack.c +++ b/firmware/components/connectivity-manager/src/dns_hijack.c @@ -1,5 +1,6 @@ -// Minimaler DNS-Server für Captive Portal (alle Anfragen auf AP-IP) -// Quelle: https://github.com/espressif/esp-idf/blob/master/examples/protocols/sntp/main/dns_server.c (angepasst) +// Minimal DNS server for captive portal (redirects all queries to AP IP) +#include "dns_hijack.h" + #include #include #include diff --git a/firmware/components/connectivity-manager/src/wifi_manager.c b/firmware/components/connectivity-manager/src/wifi_manager.c index dc04433..f54d085 100644 --- a/firmware/components/connectivity-manager/src/wifi_manager.c +++ b/firmware/components/connectivity-manager/src/wifi_manager.c @@ -1,8 +1,10 @@ #include "wifi_manager.h" -#include "dns_hijack.h" - #include "analytics.h" -#include "api_server.h" +#include "bifrost/api_server.h" +#include "dns_hijack.h" +#include "led_status.h" +#include "persistence_manager.h" + #include #include #include @@ -11,12 +13,10 @@ #include #include #include -#include #include #include #include #include -#include #include #include diff --git a/firmware/components/heimdall/include/heimdall/action_manager.h b/firmware/components/heimdall/include/heimdall/action_manager.h index 17d794d..4faa8a2 100644 --- a/firmware/components/heimdall/include/heimdall/action_manager.h +++ b/firmware/components/heimdall/include/heimdall/action_manager.h @@ -1,23 +1,25 @@ #pragma once +#include "heimdall/button_type.h" + #ifdef __cplusplus extern "C" { #endif /** - * @brief Signatur für Callback-Funktionen (reines C) - * @param value Der übergebene Wert als C-String (z.B. "true", "false", "2") + * @brief Callback signature for action handlers + * @param value The value passed as a C string (e.g. "true", "false", "2") */ typedef void (*action_callback_t)(const char *value); /** - * @brief Registriert eine Aktion, die über die dynamische UI aufgerufen werden kann + * @brief Registers an action that can be triggered via the dynamic UI or API */ void action_manager_register(const char *action_name, action_callback_t callback); /** - * @brief Führt eine registrierte Aktion aus + * @brief Executes a registered action by name */ void action_manager_execute(const char *action_name, const char *value); diff --git a/firmware/components/heimdall/include/heimdall/button_type.h b/firmware/components/heimdall/include/heimdall/button_type.h new file mode 100644 index 0000000..19908ca --- /dev/null +++ b/firmware/components/heimdall/include/heimdall/button_type.h @@ -0,0 +1,21 @@ +#pragma once + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef enum + { + BTN_NONE, + BTN_UP, + BTN_DOWN, + BTN_LEFT, + BTN_RIGHT, + BTN_SELECT, + BTN_BACK + } button_type_t; + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/heimdall/src/action_manager.cpp b/firmware/components/heimdall/src/action_manager.cpp index 39498e7..4c0035b 100644 --- a/firmware/components/heimdall/src/action_manager.cpp +++ b/firmware/components/heimdall/src/action_manager.cpp @@ -1,11 +1,11 @@ #include "heimdall/action_manager.h" + #include #include #include static const char *TAG = "ActionMgr"; -// Hier speichern wir alle registrierten C-Funktionszeiger static std::unordered_map s_actions; extern "C" void action_manager_register(const char *action_name, action_callback_t callback) @@ -13,7 +13,7 @@ extern "C" void action_manager_register(const char *action_name, action_callback if (action_name && callback) { s_actions[action_name] = callback; - ESP_LOGD(TAG, "Aktion registriert: %s", action_name); + ESP_LOGD(TAG, "Action registered: %s", action_name); } } @@ -25,12 +25,11 @@ extern "C" void action_manager_execute(const char *action_name, const char *valu auto it = s_actions.find(action_name); if (it != s_actions.end()) { - ESP_LOGI(TAG, "Führe Aktion aus: %s (Wert: %s)", action_name, value ? value : "NULL"); - // Ruft den hinterlegten C-Funktionszeiger auf + ESP_LOGI(TAG, "Executing action: %s (value: %s)", action_name, value ? value : "NULL"); it->second(value ? value : ""); } else { - ESP_LOGW(TAG, "Aktion nicht gefunden: %s", action_name); + ESP_LOGW(TAG, "Action not found: %s", action_name); } } diff --git a/firmware/components/hermes/CMakeLists.txt b/firmware/components/hermes/CMakeLists.txt new file mode 100644 index 0000000..98dacf5 --- /dev/null +++ b/firmware/components/hermes/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register(SRCS + src/screensaver/clock_screensaver.cpp + src/hermes.cpp + INCLUDE_DIRS "include" + REQUIRES + mercedes + u8g2 +) diff --git a/firmware/components/hermes/include/hermes/hermes.h b/firmware/components/hermes/include/hermes/hermes.h new file mode 100644 index 0000000..5bcb197 --- /dev/null +++ b/firmware/components/hermes/include/hermes/hermes.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + /** + * @brief Initializes the menu renderer with the display context + * @param u8g2 Display context + * @param inactivity_timeout_ms Timeout in ms before screensaver activates (0 = disabled) + */ + void hermes_init(u8g2_t *u8g2, uint32_t inactivity_timeout_ms); + + /** + * @brief Renders the current state to the display (splash, menu, or screensaver) + * @param dt Delta time in milliseconds since the last call + */ + void hermes_draw(uint64_t dt); + + /** + * @brief Resets the inactivity timer (call on button input) + */ + void hermes_reset_inactivity(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/hermes/include/hermes/screensaver/clock_screensaver.h b/firmware/components/hermes/include/hermes/screensaver/clock_screensaver.h new file mode 100644 index 0000000..c5863a8 --- /dev/null +++ b/firmware/components/hermes/include/hermes/screensaver/clock_screensaver.h @@ -0,0 +1,30 @@ +#pragma once + +#include "hermes/screensaver/screensaver.h" + +#include +#include + +class ClockScreensaver : public Screensaver +{ + public: + explicit ClockScreensaver(u8g2_t *u8g2); + + void init() override; + void draw(uint64_t dt) override; + + private: + u8g2_t *m_u8g2; + + int m_posX = 0; + int m_posY = 0; + int m_velX = 1; + int m_velY = 1; + uint64_t m_moveTimer = 0; + + static constexpr int MOVE_INTERVAL_MS = 50; + static constexpr int VELOCITY = 1; + static constexpr const uint8_t *FONT = u8g2_font_profont15_tf; + + static void get_time_string(char *buffer, size_t bufferSize); +}; diff --git a/firmware/components/hermes/include/hermes/screensaver/screensaver.h b/firmware/components/hermes/include/hermes/screensaver/screensaver.h new file mode 100644 index 0000000..019490d --- /dev/null +++ b/firmware/components/hermes/include/hermes/screensaver/screensaver.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +class Screensaver +{ + public: + virtual ~Screensaver() = default; + + virtual void init() = 0; + virtual void draw(uint64_t dt) = 0; +}; diff --git a/firmware/components/hermes/src/hermes.cpp b/firmware/components/hermes/src/hermes.cpp new file mode 100644 index 0000000..36a6959 --- /dev/null +++ b/firmware/components/hermes/src/hermes.cpp @@ -0,0 +1,268 @@ +#include "hermes/hermes.h" +#include "hermes/screensaver/clock_screensaver.h" +#include "hermes/screensaver/screensaver.h" +#include "mercedes/mercedes.h" + +#include + +static const char *TAG = "Hermes"; + +// UI layout constants +namespace UI +{ +constexpr int LEFT_MARGIN = 8; +constexpr int RIGHT_PADDING = 8; +constexpr int SCROLLBAR_WIDTH = 3; +constexpr int FRAME_BOX_SIZE = 14; +constexpr int FRAME_OFFSET = 11; +constexpr int SELECTION_MARGIN = 10; +constexpr int CORNER_RADIUS = 3; +constexpr int LINE_SPACING = 14; +constexpr int BOTTOM_OFFSET = 10; +} // namespace UI + +// Renderer state +enum class RenderState +{ + SPLASH, + MENU, + SCREENSAVER +}; + +static u8g2_t *s_u8g2 = nullptr; +static RenderState s_state = RenderState::SPLASH; + +// Inactivity tracking +static uint32_t s_inactivity_timeout_ms = 0; +static uint64_t s_inactivity_elapsed = 0; + +static Screensaver *s_screensaver = nullptr; + +// ============================================================================ +// Scrollbar +// ============================================================================ + +static void draw_scrollbar(size_t current, size_t total) +{ + if (total <= 1) + return; + + int x = s_u8g2->width - UI::SCROLLBAR_WIDTH; + int trackHeight = s_u8g2->height - 6; + int trackY = 3; + + for (int y = trackY; y < trackY + trackHeight; y += 2) + { + u8g2_DrawPixel(s_u8g2, x + 1, y); + } + + int thumbHeight = trackHeight / static_cast(total); + if (thumbHeight < 3) + thumbHeight = 3; + int thumbY = trackY + static_cast(current) * (trackHeight - thumbHeight) / static_cast(total - 1); + u8g2_DrawBox(s_u8g2, x, thumbY, UI::SCROLLBAR_WIDTH, thumbHeight); +} + +// ============================================================================ +// Selection box +// ============================================================================ + +static void draw_selection_box() +{ + int h = s_u8g2->height; + int w = s_u8g2->width; + int boxHeight = h / 3; + int y = boxHeight * 2 - 2; + int x = w - UI::RIGHT_PADDING; + + u8g2_DrawRFrame(s_u8g2, 2, boxHeight, w - UI::RIGHT_PADDING, boxHeight, UI::CORNER_RADIUS); + u8g2_DrawLine(s_u8g2, 4, y, w - UI::RIGHT_PADDING, y); + u8g2_DrawLine(s_u8g2, x, y - boxHeight + 3, x, y - 1); +} + +// ============================================================================ +// Item rendering +// ============================================================================ + +static void draw_item(const MenuItemDef &item, const uint8_t *font, int x, int y) +{ + u8g2_SetFont(s_u8g2, font); + u8g2_DrawStr(s_u8g2, x, y, item.label.c_str()); + + if (item.type == "label") + { + auto &menu = Mercedes::getInstance(); + const auto *provider = menu.getItemValueProvider(); + if (provider) + { + static char val[32]; + val[0] = '\0'; + (*provider)(item.id, val, sizeof(val)); + if (val[0] != '\0') + { + u8g2_uint_t tw = u8g2_GetStrWidth(s_u8g2, val); + u8g2_DrawStr(s_u8g2, s_u8g2->width - tw - UI::SELECTION_MARGIN, y, val); + } + } + } + else if (item.type == "submenu" || item.type == "action") + { + const char *arrow = ">"; + u8g2_uint_t tw = u8g2_GetStrWidth(s_u8g2, arrow); + u8g2_DrawStr(s_u8g2, s_u8g2->width - tw - UI::SELECTION_MARGIN, y, arrow); + } + else if (item.type == "selection" && !item.selectionItems.empty()) + { + int idx = item.selectionIndex; + if (idx >= 0 && idx < static_cast(item.selectionItems.size())) + { + static char formatted[48]; + snprintf(formatted, sizeof(formatted), "< %s >", item.selectionItems[idx].label.c_str()); + u8g2_uint_t tw = u8g2_GetStrWidth(s_u8g2, formatted); + u8g2_DrawStr(s_u8g2, s_u8g2->width - tw - UI::SELECTION_MARGIN, y, formatted); + } + } + else if (item.type == "toggle") + { + int frameX = s_u8g2->width - UI::FRAME_BOX_SIZE - UI::SELECTION_MARGIN; + int frameY = y - UI::FRAME_OFFSET; + u8g2_DrawFrame(s_u8g2, frameX, frameY, UI::FRAME_BOX_SIZE, UI::FRAME_BOX_SIZE); + + if (item.toggleValue) + { + int x1 = frameX + 2; + int y1 = frameY + 2; + int x2 = frameX + UI::FRAME_BOX_SIZE - 3; + int y2 = frameY + UI::FRAME_BOX_SIZE - 3; + u8g2_DrawLine(s_u8g2, x1, y1, x2, y2); + u8g2_DrawLine(s_u8g2, x1, y2, x2, y1); + } + } +} + +// ============================================================================ +// Splash screen +// ============================================================================ + +static void draw_splash() +{ + u8g2_SetFont(s_u8g2, u8g2_font_DigitalDisco_tr); + u8g2_DrawStr(s_u8g2, 28, s_u8g2->height / 2 - 10, "HO Anlage"); + u8g2_DrawStr(s_u8g2, 30, s_u8g2->height / 2 + 5, "Axel Janz"); + u8g2_SetFont(s_u8g2, u8g2_font_haxrcorp4089_tr); + u8g2_DrawStr(s_u8g2, 35, 50, "Initialisierung..."); +} + +// ============================================================================ +// Menu screen +// ============================================================================ + +static void draw_menu() +{ + auto &menu = Mercedes::getInstance(); + const MenuScreenDef *screen = menu.getCurrentScreen(); + if (!screen || screen->items.empty()) + return; + + size_t selected = menu.getSelectedIndex(); + + static size_t visibleIdx[32]; + size_t visibleCount = 0; + size_t visibleSelected = 0; + + for (size_t i = 0; i < screen->items.size(); i++) + { + if (menu.isItemVisible(screen->items[i])) + { + if (i == selected) + visibleSelected = visibleCount; + visibleIdx[visibleCount++] = i; + } + } + + if (visibleCount == 0) + return; + + draw_scrollbar(visibleSelected, visibleCount); + draw_selection_box(); + + int centerY = s_u8g2->height / 2 + 3; + draw_item(screen->items[visibleIdx[visibleSelected]], u8g2_font_helvB08_tr, UI::LEFT_MARGIN, centerY); + + if (visibleSelected > 0) + draw_item(screen->items[visibleIdx[visibleSelected - 1]], u8g2_font_haxrcorp4089_tr, UI::LEFT_MARGIN, UI::LINE_SPACING); + + if (visibleSelected < visibleCount - 1) + draw_item(screen->items[visibleIdx[visibleSelected + 1]], u8g2_font_haxrcorp4089_tr, UI::LEFT_MARGIN, + s_u8g2->height - UI::BOTTOM_OFFSET); +} + +// ============================================================================ +// Public API +// ============================================================================ + +extern "C" void hermes_init(u8g2_t *u8g2, uint32_t inactivity_timeout_ms) +{ + s_u8g2 = u8g2; + s_inactivity_timeout_ms = inactivity_timeout_ms; + s_inactivity_elapsed = 0; + s_state = RenderState::SPLASH; + s_screensaver = new ClockScreensaver(u8g2); + ESP_LOGI(TAG, "Hermes initialized (screensaver timeout: %lu ms)", (unsigned long)inactivity_timeout_ms); +} + +extern "C" void hermes_draw(uint64_t dt) +{ + if (!s_u8g2) + return; + + // Clear + u8g2_SetDrawColor(s_u8g2, 0); + u8g2_DrawBox(s_u8g2, 0, 0, s_u8g2->width, s_u8g2->height); + u8g2_SetDrawColor(s_u8g2, 1); + + // Determine state transitions + auto &menu = Mercedes::getInstance(); + const MenuScreenDef *screen = menu.getCurrentScreen(); + bool hasItems = (screen && !screen->items.empty()); + + if (s_state == RenderState::SPLASH && hasItems) + { + s_state = RenderState::MENU; + s_inactivity_elapsed = 0; + } + + // Inactivity tracking (only in MENU state) + if (s_state == RenderState::MENU && s_inactivity_timeout_ms > 0) + { + s_inactivity_elapsed += dt; + if (s_inactivity_elapsed >= s_inactivity_timeout_ms) + { + s_state = RenderState::SCREENSAVER; + s_screensaver->init(); + } + } + + // Draw current state + switch (s_state) + { + case RenderState::SPLASH: + draw_splash(); + break; + case RenderState::MENU: + draw_menu(); + break; + case RenderState::SCREENSAVER: + s_screensaver->draw(dt); + break; + } +} + +extern "C" void hermes_reset_inactivity(void) +{ + s_inactivity_elapsed = 0; + if (s_state == RenderState::SCREENSAVER) + { + s_state = RenderState::MENU; + } +} diff --git a/firmware/components/hermes/src/screensaver/clock_screensaver.cpp b/firmware/components/hermes/src/screensaver/clock_screensaver.cpp new file mode 100644 index 0000000..caffd44 --- /dev/null +++ b/firmware/components/hermes/src/screensaver/clock_screensaver.cpp @@ -0,0 +1,77 @@ +#include "hermes/screensaver/clock_screensaver.h" + +#include + +ClockScreensaver::ClockScreensaver(u8g2_t *u8g2) : m_u8g2(u8g2) +{ +} + +void ClockScreensaver::get_time_string(char *buffer, size_t bufferSize) +{ + time_t rawtime; + struct tm *timeinfo; + time(&rawtime); + timeinfo = localtime(&rawtime); + strftime(buffer, bufferSize, "%H:%M:%S", timeinfo); +} + +void ClockScreensaver::init() +{ + u8g2_SetFont(m_u8g2, FONT); + char timeBuf[32]; + get_time_string(timeBuf, sizeof(timeBuf)); + int tw = u8g2_GetStrWidth(m_u8g2, timeBuf); + int th = u8g2_GetAscent(m_u8g2) - u8g2_GetDescent(m_u8g2); + + m_posX = (m_u8g2->width - tw) / 2; + m_posY = (m_u8g2->height - th) / 2; + m_velX = VELOCITY; + m_velY = VELOCITY; + m_moveTimer = 0; +} + +void ClockScreensaver::draw(uint64_t dt) +{ + m_moveTimer += dt; + + if (m_moveTimer >= MOVE_INTERVAL_MS) + { + m_moveTimer = 0; + + char timeBuf[32]; + get_time_string(timeBuf, sizeof(timeBuf)); + u8g2_SetFont(m_u8g2, FONT); + int tw = u8g2_GetStrWidth(m_u8g2, timeBuf); + int th = u8g2_GetAscent(m_u8g2) - u8g2_GetDescent(m_u8g2); + + m_posX += m_velX; + m_posY += m_velY; + + if (m_posX <= 0) + { + m_posX = 0; + m_velX = VELOCITY; + } + else if (m_posX + tw >= m_u8g2->width) + { + m_posX = m_u8g2->width - tw; + m_velX = -VELOCITY; + } + + if (m_posY <= th) + { + m_posY = th; + m_velY = VELOCITY; + } + else if (m_posY >= m_u8g2->height) + { + m_posY = m_u8g2->height; + m_velY = -VELOCITY; + } + } + + char timeBuf[32]; + get_time_string(timeBuf, sizeof(timeBuf)); + u8g2_SetFont(m_u8g2, FONT); + u8g2_DrawStr(m_u8g2, m_posX, m_posY, timeBuf); +} diff --git a/firmware/components/led-manager/include/Matrix.h b/firmware/components/led-manager/include/Matrix.h index fe97725..924a192 100644 --- a/firmware/components/led-manager/include/Matrix.h +++ b/firmware/components/led-manager/include/Matrix.h @@ -1,6 +1,6 @@ #pragma once -#include "SDL3/SDL_render.h" +#include #include diff --git a/firmware/components/mercedes/CMakeLists.txt b/firmware/components/mercedes/CMakeLists.txt index 00f9179..aeff897 100644 --- a/firmware/components/mercedes/CMakeLists.txt +++ b/firmware/components/mercedes/CMakeLists.txt @@ -1,8 +1,10 @@ idf_component_register(SRCS - src/DynamicMenuBuilder.cpp + src/mercedes.cpp INCLUDE_DIRS "include" - PRIV_REQUIRES + REQUIRES heimdall + PRIV_REQUIRES json - insa + message-manager + persistence-manager ) diff --git a/firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h b/firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h deleted file mode 100644 index 07459db..0000000 --- a/firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h +++ /dev/null @@ -1,102 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -/** - * @brief Callback for dynamic menu events - * @param id The String ID of the menu item interacted with - * @param actionTopic The optional MQTT topic or action identifier from the JSON - * @param value The new value (e.g. "true" for toggle, "2" for selection) - */ -using MenuActionCallback = - std::function; - -/** - * @brief Provider callback to fetch real-time state from NVS/OpenThread - * @param id The String ID of the menu item - * @return The current state as string (e.g. "true", "false", or a selection value), or empty string to use JSON - * defaults - */ -using ItemValueProvider = std::function; - -struct MenuSelectionItemDef -{ - std::string value; - std::string label; -}; - -struct MenuItemDef -{ - std::string id; - std::string type; - std::string label; - std::string actionTopic; - std::string targetScreenId; - bool persistent = false; - bool toggleValue = false; - std::vector selectionItems; -}; - -struct MenuScreenDef -{ - std::string id; - std::string title; - std::vector items; -}; - -/** - * @class DynamicMenuBuilder - * @brief A helper class to construct Menus from a JSON payload - */ -class DynamicMenuBuilder -{ - public: - /** - * @brief Get the singleton instance of the DynamicMenuBuilder - */ - static DynamicMenuBuilder &getInstance(); - - // Delete copy and move constructors to enforce singleton pattern - DynamicMenuBuilder(const DynamicMenuBuilder &) = delete; - void operator=(const DynamicMenuBuilder &) = delete; - ~DynamicMenuBuilder() = default; - - /** - * @brief Parses a JSON string and populates the target Menu - * @param jsonPayload The JSON string containing menu definitions - * @return true if successfully parsed and built, false otherwise - */ - bool buildFromJson(const std::string &jsonPayload); - - /** - * @brief Sets the callback to be executed when a dynamic item is triggered - */ - void setActionCallback(MenuActionCallback callback); - - /** - * @brief Sets the provider to fetch real-time states for rendering - */ - void setItemValueProvider(ItemValueProvider provider); - - /** - * @brief Pseudo-rendering: Outputs the current menu to the log - */ - void render(); - - /** - * @brief Simulates pressing an item via String ID - */ - void handleItemPress(const std::string &itemId); - - private: - DynamicMenuBuilder(); // Private constructor - - MenuActionCallback m_actionCallback; // Optional, in case you need callbacks in addition to the ActionManager - ItemValueProvider m_valueProvider; // Fetches real states from external sources (NVS/OpenThread) - - std::map m_screens; - std::string m_currentScreenId; -}; diff --git a/firmware/components/mercedes/include/mercedes/mercedes.h b/firmware/components/mercedes/include/mercedes/mercedes.h new file mode 100644 index 0000000..c5f7f4e --- /dev/null +++ b/firmware/components/mercedes/include/mercedes/mercedes.h @@ -0,0 +1,146 @@ +#pragma once + +#include "heimdall/button_type.h" + +#include +#include +#include +#include +#include + +/** + * @brief Callback for dynamic menu events + * @param id The string ID of the menu item interacted with + * @param actionTopic The optional MQTT topic or action identifier from the JSON + * @param value The new value (e.g. "true" for toggle, "2" for selection) + */ +using MenuActionCallback = + std::function; + +/** + * @brief Provider callback to fetch real-time state from NVS/OpenThread + * @param id The string ID of the menu item + * @return The current state as string, or empty string to use JSON defaults + */ +using ItemValueProvider = std::function; + +/** + * @brief Callback notified when menu state changes (screen switch, selection move, value change) + */ +using MenuStateChangedCallback = std::function; + +struct MenuSelectionItemDef +{ + std::string value; + std::string label; +}; + +struct MenuItemDef +{ + std::string id; + std::string type; + std::string label; + std::string actionTopic; + std::string targetScreenId; + bool persistent = false; + std::string valueType; // "string" (default) or "int" or "bool" — controls NVS read/write type + bool toggleValue = false; + int selectionIndex = 0; + std::vector selectionItems; + std::string visibleWhenItemId; + std::string visibleWhenValue; +}; + +struct MenuScreenDef +{ + std::string id; + std::string title; + std::vector items; +}; + +/** + * @class Mercedes + * @brief Data model and navigation logic for JSON-driven dynamic menus + */ +class Mercedes +{ + public: + static Mercedes &getInstance(); + + Mercedes(const Mercedes &) = delete; + void operator=(const Mercedes &) = delete; + ~Mercedes() = default; + + /** + * @brief Parses a JSON string and populates the menu structure + */ + bool buildFromJson(const std::string &jsonPayload); + + /** + * @brief Handles button input for navigation and item interaction + */ + void handleInput(button_type_t button); + + /** + * @brief Sets the callback executed when a dynamic item is triggered + */ + void setActionCallback(MenuActionCallback callback); + + /** + * @brief Sets the provider to fetch real-time states for rendering + */ + void setItemValueProvider(ItemValueProvider provider); + + /** + * @brief Sets the callback notified when menu state changes (for rendering) + */ + void setStateChangedCallback(MenuStateChangedCallback callback); + + // --- State accessors (used by hermes for rendering) --- + + /** + * @brief Returns the current screen definition, or nullptr if none + */ + const MenuScreenDef *getCurrentScreen() const; + + /** + * @brief Returns the currently selected item index + */ + size_t getSelectedIndex() const; + + /** + * @brief Returns true if there are screens on the back-stack + */ + bool canGoBack() const; + + /** + * @brief Returns the item value provider (for rendering dynamic values) + */ + const ItemValueProvider *getItemValueProvider() const; + + /** + * @brief Returns true if the item should be visible given current menu state + */ + bool isItemVisible(const MenuItemDef &item) const; + + /** + * @brief Updates an item's in-memory state by id and string value — triggers stateChangedCallback + */ + void updateItemValue(const std::string &id, const std::string &value); + + private: + Mercedes(); + + void navigateToScreen(const std::string &screenId); + void activateCurrentItem(); + void adjustCurrentItem(button_type_t button); + + MenuActionCallback m_actionCallback; + ItemValueProvider m_valueProvider; + MenuStateChangedCallback m_stateChangedCallback; + + std::map m_screens; + std::string m_currentScreenId; + size_t m_selectedIndex = 0; + std::stack> m_screenHistory; +}; diff --git a/firmware/components/mercedes/src/DynamicMenuBuilder.cpp b/firmware/components/mercedes/src/DynamicMenuBuilder.cpp deleted file mode 100644 index 35358ae..0000000 --- a/firmware/components/mercedes/src/DynamicMenuBuilder.cpp +++ /dev/null @@ -1,309 +0,0 @@ -#include "mercedes/DynamicMenuBuilder.h" -#include "heimdall/action_manager.h" -#include -#include - -static const char *TAG = "DynamicMenu"; - -DynamicMenuBuilder &DynamicMenuBuilder::getInstance() -{ - static DynamicMenuBuilder instance; - return instance; -} - -DynamicMenuBuilder::DynamicMenuBuilder() -{ -} - -void DynamicMenuBuilder::setActionCallback(MenuActionCallback callback) -{ - m_actionCallback = callback; -} - -void DynamicMenuBuilder::setItemValueProvider(ItemValueProvider provider) -{ - m_valueProvider = provider; -} - -bool DynamicMenuBuilder::buildFromJson(const std::string &jsonPayload) -{ - // Parse JSON - cJSON *root = cJSON_Parse(jsonPayload.c_str()); - if (!root) - { - ESP_LOGE(TAG, "Error parsing JSON payload"); - return false; - } - - // Reset previous state - m_screens.clear(); - m_currentScreenId = ""; - - cJSON *screens = cJSON_GetObjectItem(root, "screens"); - if (screens && cJSON_IsArray(screens)) - { - int numScreens = cJSON_GetArraySize(screens); - - for (int i = 0; i < numScreens; i++) - { - cJSON *screenItem = cJSON_GetArrayItem(screens, i); - if (!screenItem) - continue; - - MenuScreenDef screenDef; - cJSON *screenId = cJSON_GetObjectItem(screenItem, "id"); - cJSON *screenTitle = cJSON_GetObjectItem(screenItem, "title"); - - if (screenId && cJSON_IsString(screenId)) - screenDef.id = screenId->valuestring; - if (screenTitle && cJSON_IsString(screenTitle)) - screenDef.title = screenTitle->valuestring; - - // Set the first screen as the start screen - if (m_currentScreenId.empty() && !screenDef.id.empty()) - { - m_currentScreenId = screenDef.id; - } - - cJSON *items = cJSON_GetObjectItem(screenItem, "items"); - if (items && cJSON_IsArray(items)) - { - int numItems = cJSON_GetArraySize(items); - for (int j = 0; j < numItems; j++) - { - cJSON *item = cJSON_GetArrayItem(items, j); - if (!item) - continue; - - MenuItemDef itemDef; - cJSON *idItem = cJSON_GetObjectItem(item, "id"); - cJSON *typeItem = cJSON_GetObjectItem(item, "type"); - cJSON *labelItem = cJSON_GetObjectItem(item, "label"); - cJSON *actionItem = cJSON_GetObjectItem(item, "actionTopic"); - cJSON *targetItem = cJSON_GetObjectItem(item, "targetScreenId"); - cJSON *persistentItem = cJSON_GetObjectItem(item, "persistent"); - - if (idItem && cJSON_IsString(idItem)) - itemDef.id = idItem->valuestring; - if (typeItem && cJSON_IsString(typeItem)) - itemDef.type = typeItem->valuestring; - if (labelItem && cJSON_IsString(labelItem)) - itemDef.label = labelItem->valuestring; - if (actionItem && cJSON_IsString(actionItem)) - itemDef.actionTopic = actionItem->valuestring; - if (targetItem && cJSON_IsString(targetItem)) - itemDef.targetScreenId = targetItem->valuestring; - if (persistentItem && cJSON_IsBool(persistentItem)) - itemDef.persistent = cJSON_IsTrue(persistentItem); - - cJSON *valueItem = cJSON_GetObjectItem(item, "value"); - if (valueItem && cJSON_IsBool(valueItem)) - itemDef.toggleValue = cJSON_IsTrue(valueItem); - - // Parse sub-items for 'selection' type - if (itemDef.type == "selection") - { - cJSON *selectionItems = cJSON_GetObjectItem(item, "items"); - if (selectionItems && cJSON_IsArray(selectionItems)) - { - int numSelItems = cJSON_GetArraySize(selectionItems); - for (int k = 0; k < numSelItems; k++) - { - cJSON *selItem = cJSON_GetArrayItem(selectionItems, k); - if (!selItem) - continue; - - MenuSelectionItemDef selDef; - cJSON *valItem = cJSON_GetObjectItem(selItem, "value"); - cJSON *lblItem = cJSON_GetObjectItem(selItem, "label"); - - if (valItem && cJSON_IsString(valItem)) - selDef.value = valItem->valuestring; - if (lblItem && cJSON_IsString(lblItem)) - selDef.label = lblItem->valuestring; - - itemDef.selectionItems.push_back(selDef); - } - } - } - - screenDef.items.push_back(itemDef); - } - } - m_screens[screenDef.id] = screenDef; - } - } - - // Free RAM (IMPORTANT for cJSON!) - cJSON_Delete(root); - return true; -} - -void DynamicMenuBuilder::render() -{ - if (m_currentScreenId.empty() || m_screens.find(m_currentScreenId) == m_screens.end()) - { - ESP_LOGE(TAG, "No active screen found to render."); - return; - } - - const MenuScreenDef &screen = m_screens[m_currentScreenId]; - - ESP_LOGI(TAG, "==================================="); - ESP_LOGI(TAG, " SCREEN: %s", screen.title.c_str()); - ESP_LOGI(TAG, "-----------------------------------"); - - for (size_t i = 0; i < screen.items.size(); i++) - { - const auto &item = screen.items[i]; - std::string persistentStr = item.persistent ? " [Persistent]" : ""; - - // Fetch real state from NVS/OpenThread if available, otherwise use JSON default - std::string externalState = m_valueProvider ? m_valueProvider(item.id) : ""; - - if (item.type == "submenu") - { - ESP_LOGI(TAG, " - %s%s (-> opens '%s')", item.label.c_str(), persistentStr.c_str(), - item.targetScreenId.c_str()); - } - else if (item.type == "action") - { - ESP_LOGI(TAG, " - %s%s [Action: %s]", item.label.c_str(), persistentStr.c_str(), item.actionTopic.c_str()); - } - else if (item.type == "toggle") - { - bool isOn = item.toggleValue; - if (!externalState.empty()) - isOn = (externalState == "true"); - - ESP_LOGI(TAG, " - %s%s [Toggle: %s]", item.label.c_str(), persistentStr.c_str(), isOn ? "ON" : "OFF"); - } - else if (item.type == "selection") - { - ESP_LOGI(TAG, " - %s%s [Selection]:", item.label.c_str(), persistentStr.c_str()); - for (const auto &sel : item.selectionItems) - { - bool isSelected = (!externalState.empty() && externalState == sel.value); - ESP_LOGI(TAG, " %s %s (Value: %s)", isSelected ? "[x]" : "[ ]", sel.label.c_str(), - sel.value.c_str()); - } - } - else - { - ESP_LOGI(TAG, " - %s%s", item.label.c_str(), persistentStr.c_str()); - } - } - ESP_LOGI(TAG, "==================================="); -} - -void DynamicMenuBuilder::handleItemPress(const std::string &itemId) -{ - if (m_currentScreenId.empty() || m_screens.find(m_currentScreenId) == m_screens.end()) - { - return; - } - - std::string mainItemId = itemId; - std::string subValue; - size_t separatorPos = itemId.find(':'); - if (separatorPos != std::string::npos) - { - mainItemId = itemId.substr(0, separatorPos); - subValue = itemId.substr(separatorPos + 1); - } - - for (const auto &item : m_screens[m_currentScreenId].items) - { - if (item.id == mainItemId) - { - ESP_LOGI(TAG, "-> Handling press for '%s'", item.label.c_str()); - - if (item.type == "submenu") - { - if (!item.targetScreenId.empty() && m_screens.find(item.targetScreenId) != m_screens.end()) - { - m_currentScreenId = item.targetScreenId; - render(); // Render the new submenu! - } - else - { - ESP_LOGW(TAG, "Target screen '%s' not found!", item.targetScreenId.c_str()); - } - } - else if (item.type == "action") - { - if (!item.actionTopic.empty()) - { - ESP_LOGI(TAG, "Calling ActionManager: %s", item.actionTopic.c_str()); - const char *value = "true"; - action_manager_execute(item.actionTopic.c_str(), value); - if (m_actionCallback) - { - m_actionCallback(item.id, item.actionTopic, value); - } - } - } - else if (item.type == "toggle") - { - // Fetch the *actual* current state to determine what the *new* state should be - bool currentState = item.toggleValue; - if (m_valueProvider) - { - std::string val = m_valueProvider(item.id); - if (!val.empty()) - currentState = (val == "true"); - } - - bool newState = !currentState; - const char *valStr = newState ? "true" : "false"; - ESP_LOGI(TAG, "Toggle item '%s' requested flip to %s", item.label.c_str(), valStr); - - if (!item.actionTopic.empty()) - { - action_manager_execute(item.actionTopic.c_str(), valStr); - if (m_actionCallback) - { - m_actionCallback(item.id, item.actionTopic, valStr); - } - } - } - else if (item.type == "selection") - { - if (subValue.empty()) - { - ESP_LOGI(TAG, "Selection item '%s' pressed, opening options.", item.label.c_str()); - // In a real UI, this would open the list of options. - // For our pseudo-UI, we do nothing and wait for a press with a sub-value. - return; - } - - // A choice was made, validate it - bool isValidChoice = false; - for (const auto &sel_item : item.selectionItems) - { - if (sel_item.value == subValue) - { - isValidChoice = true; - break; - } - } - - if (isValidChoice) - { - ESP_LOGI(TAG, "-> Chose option with value '%s' for '%s'", subValue.c_str(), item.label.c_str()); - if (!item.actionTopic.empty()) - { - action_manager_execute(item.actionTopic.c_str(), subValue.c_str()); - if (m_actionCallback) - { - m_actionCallback(item.id, item.actionTopic, subValue); - } - } - } - } - return; // Item found and processed - } - } - - ESP_LOGW(TAG, "Item ID '%s' not found in current screen", mainItemId.c_str()); -} \ No newline at end of file diff --git a/firmware/components/mercedes/src/mercedes.cpp b/firmware/components/mercedes/src/mercedes.cpp new file mode 100644 index 0000000..942fcc7 --- /dev/null +++ b/firmware/components/mercedes/src/mercedes.cpp @@ -0,0 +1,530 @@ +#include "mercedes/mercedes.h" +#include "heimdall/action_manager.h" +#include "message_manager.h" +#include "persistence_manager.h" + +#include +#include +#include +#include + +static persistence_manager_t s_pm; +static bool s_pm_initialized = false; + +static void ensure_pm() +{ + if (!s_pm_initialized) + { + s_pm_initialized = (persistence_manager_init(&s_pm, "config") == ESP_OK); + } +} + +static void post_settings_message(const MenuItemDef &item, const std::string &value) +{ + if (!item.persistent) + return; + + message_t msg = {}; + msg.type = MESSAGE_TYPE_SETTINGS; + strncpy(msg.data.settings.key, item.id.c_str(), sizeof(msg.data.settings.key) - 1); + + if (item.valueType == "int") + { + msg.data.settings.type = SETTINGS_TYPE_INT; + msg.data.settings.value.int_value = atoi(value.c_str()); + } + else if (item.valueType == "bool" || item.type == "toggle") + { + msg.data.settings.type = SETTINGS_TYPE_BOOL; + msg.data.settings.value.bool_value = (value == "true"); + } + else + { + msg.data.settings.type = SETTINGS_TYPE_STRING; + strncpy(msg.data.settings.value.string_value, value.c_str(), + sizeof(msg.data.settings.value.string_value) - 1); + } + message_manager_post(&msg, pdMS_TO_TICKS(10)); +} + +static bool nvs_read_item(const MenuItemDef &item, char *buf, size_t bufSize) +{ + if (!item.persistent) + return false; + ensure_pm(); + if (!s_pm_initialized) + return false; + + if (item.valueType == "int") + { + int32_t v = 0; + if (nvs_get_i32(s_pm.nvs_handle, item.id.c_str(), &v) != ESP_OK) + return false; + snprintf(buf, bufSize, "%d", (int)v); + return true; + } + else if (item.valueType == "bool" || item.type == "toggle") + { + uint8_t v = 0; + if (nvs_get_u8(s_pm.nvs_handle, item.id.c_str(), &v) != ESP_OK) + return false; + snprintf(buf, bufSize, "%s", v ? "true" : "false"); + return true; + } + else + { + if (!persistence_manager_has_key(&s_pm, item.id.c_str())) + return false; + persistence_manager_get_string(&s_pm, item.id.c_str(), buf, bufSize, ""); + return buf[0] != '\0'; + } +} + +static const char *TAG = "Mercedes"; + +Mercedes &Mercedes::getInstance() +{ + static Mercedes instance; + return instance; +} + +Mercedes::Mercedes() +{ +} + +void Mercedes::setActionCallback(MenuActionCallback callback) +{ + m_actionCallback = callback; +} + +void Mercedes::setItemValueProvider(ItemValueProvider provider) +{ + m_valueProvider = provider; +} + +void Mercedes::setStateChangedCallback(MenuStateChangedCallback callback) +{ + m_stateChangedCallback = callback; +} + +bool Mercedes::buildFromJson(const std::string &jsonPayload) +{ + cJSON *root = cJSON_Parse(jsonPayload.c_str()); + if (!root) + { + ESP_LOGE(TAG, "Error parsing JSON payload"); + return false; + } + + m_screens.clear(); + m_currentScreenId = ""; + m_selectedIndex = 0; + while (!m_screenHistory.empty()) + m_screenHistory.pop(); + + cJSON *screens = cJSON_GetObjectItem(root, "screens"); + if (screens && cJSON_IsArray(screens)) + { + int numScreens = cJSON_GetArraySize(screens); + + for (int i = 0; i < numScreens; i++) + { + cJSON *screenItem = cJSON_GetArrayItem(screens, i); + if (!screenItem) + continue; + + MenuScreenDef screenDef; + cJSON *screenId = cJSON_GetObjectItem(screenItem, "id"); + cJSON *screenTitle = cJSON_GetObjectItem(screenItem, "title"); + + if (screenId && cJSON_IsString(screenId)) + screenDef.id = screenId->valuestring; + if (screenTitle && cJSON_IsString(screenTitle)) + screenDef.title = screenTitle->valuestring; + + if (m_currentScreenId.empty() && !screenDef.id.empty()) + { + m_currentScreenId = screenDef.id; + } + + cJSON *items = cJSON_GetObjectItem(screenItem, "items"); + if (items && cJSON_IsArray(items)) + { + int numItems = cJSON_GetArraySize(items); + for (int j = 0; j < numItems; j++) + { + cJSON *item = cJSON_GetArrayItem(items, j); + if (!item) + continue; + + MenuItemDef itemDef; + cJSON *idItem = cJSON_GetObjectItem(item, "id"); + cJSON *typeItem = cJSON_GetObjectItem(item, "type"); + cJSON *labelItem = cJSON_GetObjectItem(item, "label"); + cJSON *actionItem = cJSON_GetObjectItem(item, "actionTopic"); + cJSON *targetItem = cJSON_GetObjectItem(item, "targetScreenId"); + cJSON *persistentItem = cJSON_GetObjectItem(item, "persistent"); + + if (idItem && cJSON_IsString(idItem)) + itemDef.id = idItem->valuestring; + if (typeItem && cJSON_IsString(typeItem)) + itemDef.type = typeItem->valuestring; + if (labelItem && cJSON_IsString(labelItem)) + itemDef.label = labelItem->valuestring; + if (actionItem && cJSON_IsString(actionItem)) + itemDef.actionTopic = actionItem->valuestring; + if (targetItem && cJSON_IsString(targetItem)) + itemDef.targetScreenId = targetItem->valuestring; + if (persistentItem && cJSON_IsBool(persistentItem)) + itemDef.persistent = cJSON_IsTrue(persistentItem); + + cJSON *valueTypeItem = cJSON_GetObjectItem(item, "valueType"); + if (valueTypeItem && cJSON_IsString(valueTypeItem)) + itemDef.valueType = valueTypeItem->valuestring; + + cJSON *valueItem = cJSON_GetObjectItem(item, "value"); + if (valueItem && cJSON_IsBool(valueItem)) + itemDef.toggleValue = cJSON_IsTrue(valueItem); + + cJSON *visibleWhen = cJSON_GetObjectItem(item, "visibleWhen"); + if (visibleWhen) + { + cJSON *whenId = cJSON_GetObjectItem(visibleWhen, "itemId"); + cJSON *whenVal = cJSON_GetObjectItem(visibleWhen, "value"); + if (whenId && cJSON_IsString(whenId)) + itemDef.visibleWhenItemId = whenId->valuestring; + if (whenVal && cJSON_IsString(whenVal)) + itemDef.visibleWhenValue = whenVal->valuestring; + } + + if (itemDef.type == "selection") + { + cJSON *selectionItems = cJSON_GetObjectItem(item, "items"); + if (selectionItems && cJSON_IsArray(selectionItems)) + { + int numSelItems = cJSON_GetArraySize(selectionItems); + for (int k = 0; k < numSelItems; k++) + { + cJSON *selItem = cJSON_GetArrayItem(selectionItems, k); + if (!selItem) + continue; + + MenuSelectionItemDef selDef; + cJSON *valItem = cJSON_GetObjectItem(selItem, "value"); + cJSON *lblItem = cJSON_GetObjectItem(selItem, "label"); + + if (valItem && cJSON_IsString(valItem)) + selDef.value = valItem->valuestring; + if (lblItem && cJSON_IsString(lblItem)) + selDef.label = lblItem->valuestring; + + itemDef.selectionItems.push_back(selDef); + } + } + } + + screenDef.items.push_back(itemDef); + } + } + m_screens[screenDef.id] = screenDef; + } + } + + cJSON_Delete(root); + + // Restore persistent item states from NVS + for (auto &[screenId, screen] : m_screens) + { + for (auto &item : screen.items) + { + if (!item.persistent) + continue; + char val[32] = {}; + if (!nvs_read_item(item, val, sizeof(val))) + continue; + + if (item.type == "toggle") + { + item.toggleValue = (strcmp(val, "true") == 0); + } + else if (item.type == "selection") + { + for (int i = 0; i < static_cast(item.selectionItems.size()); i++) + { + if (item.selectionItems[i].value == val) + { + item.selectionIndex = i; + break; + } + } + } + } + } + + if (m_stateChangedCallback) + m_stateChangedCallback(); + + return true; +} + +// --- Navigation --- + +void Mercedes::handleInput(button_type_t button) +{ + const MenuScreenDef *screen = getCurrentScreen(); + if (!screen || screen->items.empty()) + return; + + switch (button) + { + case BTN_UP: + { + int idx = static_cast(m_selectedIndex); + int count = static_cast(screen->items.size()); + do { + idx = (idx == 0) ? count - 1 : idx - 1; + } while (!isItemVisible(screen->items[idx]) && idx != static_cast(m_selectedIndex)); + m_selectedIndex = static_cast(idx); + if (m_stateChangedCallback) + m_stateChangedCallback(); + break; + } + + case BTN_DOWN: + { + int idx = static_cast(m_selectedIndex); + int count = static_cast(screen->items.size()); + do { + idx = (idx + 1) % count; + } while (!isItemVisible(screen->items[idx]) && idx != static_cast(m_selectedIndex)); + m_selectedIndex = static_cast(idx); + if (m_stateChangedCallback) + m_stateChangedCallback(); + break; + } + + case BTN_LEFT: + case BTN_RIGHT: + adjustCurrentItem(button); + break; + + case BTN_SELECT: + activateCurrentItem(); + break; + + case BTN_BACK: + if (!m_screenHistory.empty()) + { + m_currentScreenId = m_screenHistory.top().first; + m_selectedIndex = m_screenHistory.top().second; + m_screenHistory.pop(); + if (m_stateChangedCallback) + m_stateChangedCallback(); + } + break; + + default: + break; + } +} + +void Mercedes::navigateToScreen(const std::string &screenId) +{ + if (m_screens.find(screenId) == m_screens.end()) + { + ESP_LOGW(TAG, "Target screen '%s' not found", screenId.c_str()); + return; + } + + m_screenHistory.push({m_currentScreenId, m_selectedIndex}); + m_currentScreenId = screenId; + m_selectedIndex = 0; + + const MenuScreenDef &newScreen = m_screens[screenId]; + for (size_t i = 0; i < newScreen.items.size(); i++) + { + if (isItemVisible(newScreen.items[i])) + { + m_selectedIndex = i; + break; + } + } + + if (m_stateChangedCallback) + m_stateChangedCallback(); +} + +void Mercedes::activateCurrentItem() +{ + const MenuScreenDef *screen = getCurrentScreen(); + if (!screen || m_selectedIndex >= screen->items.size()) + return; + + MenuItemDef &item = m_screens[m_currentScreenId].items[m_selectedIndex]; + + if (item.type == "label") + return; + + if (item.type == "submenu") + { + if (!item.targetScreenId.empty()) + navigateToScreen(item.targetScreenId); + } + else if (item.type == "action") + { + if (!item.actionTopic.empty()) + { + action_manager_execute(item.actionTopic.c_str(), "true"); + if (m_actionCallback) + m_actionCallback(item.id, item.actionTopic, "true"); + } + } + else if (item.type == "toggle") + { + bool currentState = item.toggleValue; + if (m_valueProvider) + { + char val[32] = {}; + m_valueProvider(item.id, val, sizeof(val)); + if (val[0] != '\0') + currentState = (strcmp(val, "true") == 0); + } + + bool newState = !currentState; + item.toggleValue = newState; + const char *valStr = newState ? "true" : "false"; + + post_settings_message(item, valStr); + + if (!item.actionTopic.empty()) + { + action_manager_execute(item.actionTopic.c_str(), valStr); + if (m_actionCallback) + m_actionCallback(item.id, item.actionTopic, valStr); + } + + if (m_stateChangedCallback) + m_stateChangedCallback(); + } +} + +void Mercedes::adjustCurrentItem(button_type_t button) +{ + const MenuScreenDef *screen = getCurrentScreen(); + if (!screen || m_selectedIndex >= screen->items.size()) + return; + + MenuItemDef &item = m_screens[m_currentScreenId].items[m_selectedIndex]; + + if (item.type == "selection" && !item.selectionItems.empty()) + { + int count = static_cast(item.selectionItems.size()); + if (button == BTN_LEFT) + item.selectionIndex = (item.selectionIndex == 0) ? count - 1 : item.selectionIndex - 1; + else if (button == BTN_RIGHT) + item.selectionIndex = (item.selectionIndex + 1) % count; + + const std::string &selectedValue = item.selectionItems[item.selectionIndex].value; + + post_settings_message(item, selectedValue); + + if (!item.actionTopic.empty()) + { + action_manager_execute(item.actionTopic.c_str(), selectedValue.c_str()); + if (m_actionCallback) + m_actionCallback(item.id, item.actionTopic, selectedValue); + } + + if (m_stateChangedCallback) + m_stateChangedCallback(); + } +} + +// --- State accessors --- + +const MenuScreenDef *Mercedes::getCurrentScreen() const +{ + auto it = m_screens.find(m_currentScreenId); + if (it == m_screens.end()) + return nullptr; + return &it->second; +} + +size_t Mercedes::getSelectedIndex() const +{ + return m_selectedIndex; +} + +bool Mercedes::canGoBack() const +{ + return !m_screenHistory.empty(); +} + +const ItemValueProvider *Mercedes::getItemValueProvider() const +{ + return m_valueProvider ? &m_valueProvider : nullptr; +} + +bool Mercedes::isItemVisible(const MenuItemDef &item) const +{ + if (item.visibleWhenItemId.empty()) + return true; + + for (const auto &[screenId, screen] : m_screens) + { + for (const auto &other : screen.items) + { + if (other.id != item.visibleWhenItemId) + continue; + + if (m_valueProvider) + { + char val[32] = {}; + m_valueProvider(other.id, val, sizeof(val)); + if (val[0] != '\0') + return strcmp(val, item.visibleWhenValue.c_str()) == 0; + } + + if (other.type == "selection" && !other.selectionItems.empty()) + return other.selectionItems[other.selectionIndex].value == item.visibleWhenValue; + + if (other.type == "toggle") + return (item.visibleWhenValue == "true") == other.toggleValue; + + return false; + } + } + return true; +} + +void Mercedes::updateItemValue(const std::string &id, const std::string &value) +{ + for (auto &[screenId, screen] : m_screens) + { + for (auto &item : screen.items) + { + if (item.id != id) + continue; + + if (item.type == "toggle") + { + item.toggleValue = (value == "true"); + } + else if (item.type == "selection") + { + for (int i = 0; i < static_cast(item.selectionItems.size()); i++) + { + if (item.selectionItems[i].value == value) + { + item.selectionIndex = i; + break; + } + } + } + + if (m_stateChangedCallback) + m_stateChangedCallback(); + return; + } + } +} diff --git a/firmware/components/message-manager/src/message_manager.c b/firmware/components/message-manager/src/message_manager.c index 8dc76d7..17a88cd 100644 --- a/firmware/components/message-manager/src/message_manager.c +++ b/firmware/components/message-manager/src/message_manager.c @@ -1,5 +1,7 @@ #include "message_manager.h" #include "my_mqtt_client.h" +#include "persistence_manager.h" + #include #include #include @@ -7,7 +9,6 @@ #include #include #include -#include #include #include diff --git a/firmware/components/my_mqtt_client/src/my_mqtt_client.c b/firmware/components/my_mqtt_client/src/my_mqtt_client.c index 425fef5..8153e0b 100644 --- a/firmware/components/my_mqtt_client/src/my_mqtt_client.c +++ b/firmware/components/my_mqtt_client/src/my_mqtt_client.c @@ -1,15 +1,15 @@ #include "my_mqtt_client.h" -#include "esp_app_desc.h" -#include "esp_err.h" -#include "esp_interface.h" -#include "esp_log.h" -#include "esp_mac.h" -#include "esp_system.h" -#include "mqtt_client.h" -#include "sdkconfig.h" #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #define DEVICE_TOPIC_MAX_LEN 60 diff --git a/firmware/components/simulator/include/simulator.h b/firmware/components/simulator/include/simulator.h index 62dbe55..61cb546 100644 --- a/firmware/components/simulator/include/simulator.h +++ b/firmware/components/simulator/include/simulator.h @@ -1,6 +1,6 @@ #pragma once -#include "esp_check.h" +#include #include // Configuration structure for the simulation diff --git a/firmware/components/simulator/include/storage.h b/firmware/components/simulator/include/storage.h index 9c67231..183ef46 100644 --- a/firmware/components/simulator/include/storage.h +++ b/firmware/components/simulator/include/storage.h @@ -1,6 +1,6 @@ #pragma once -#include "esp_err.h" +#include #ifdef __cplusplus extern "C" diff --git a/firmware/components/simulator/src/simulator.cpp b/firmware/components/simulator/src/simulator.cpp index 34ba85c..8d2ffbb 100644 --- a/firmware/components/simulator/src/simulator.cpp +++ b/firmware/components/simulator/src/simulator.cpp @@ -1,11 +1,10 @@ #include "simulator.h" - #include "color.h" #include "led_strip_ws2812.h" #include "message_manager.h" #include "persistence_manager.h" -#include "simulator.h" #include "storage.h" + #include #include #include diff --git a/firmware/components/simulator/src/storage.cpp b/firmware/components/simulator/src/storage.cpp index e788f62..ae5e357 100644 --- a/firmware/components/simulator/src/storage.cpp +++ b/firmware/components/simulator/src/storage.cpp @@ -1,9 +1,10 @@ #include "storage.h" -#include "esp_check.h" -#include "esp_log.h" -#include "esp_spiffs.h" #include "simulator.h" + #include +#include +#include +#include #include #include #include diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index a70be95..10052e0 100755 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -7,9 +7,10 @@ idf_component_register(SRCS src/hal/u8g2_esp32_hal.c INCLUDE_DIRS "include" PRIV_REQUIRES + heimdall + hermes mercedes analytics - insa connectivity-manager led-manager persistence-manager diff --git a/firmware/main/include/common.h b/firmware/main/include/common.h index 02f209c..471f319 100644 --- a/firmware/main/include/common.h +++ b/firmware/main/include/common.h @@ -1,6 +1,6 @@ #pragma once -#include "driver/gpio.h" +#include #define BUTTON_UP ((gpio_num_t)CONFIG_BUTTON_UP) #define BUTTON_DOWN ((gpio_num_t)CONFIG_BUTTON_DOWN) diff --git a/firmware/main/include/hal/u8g2_esp32_hal.h b/firmware/main/include/hal/u8g2_esp32_hal.h index 82742a2..0c8da07 100644 --- a/firmware/main/include/hal/u8g2_esp32_hal.h +++ b/firmware/main/include/hal/u8g2_esp32_hal.h @@ -10,13 +10,11 @@ #ifndef U8G2_ESP32_HAL_H_ #define U8G2_ESP32_HAL_H_ -#include "u8g2.h" - -#include "driver/gpio.h" -#include "driver/spi_master.h" -#include "hal/i2c_types.h" - -#include "driver/i2c_master.h" +#include +#include +#include +#include +#include #define U8G2_ESP32_HAL_UNDEFINED GPIO_NUM_NC diff --git a/firmware/main/include/i2c_checker.h b/firmware/main/include/i2c_checker.h index 4f498fa..bf5e3be 100644 --- a/firmware/main/include/i2c_checker.h +++ b/firmware/main/include/i2c_checker.h @@ -1,7 +1,7 @@ #pragma once -#include "driver/gpio.h" -#include "esp_err.h" +#include +#include #define DISPLAY_I2C_ADDRESS 0x3C diff --git a/firmware/main/include/u8g2_mqtt.h b/firmware/main/include/u8g2_mqtt.h index ed1d55b..c9fc276 100644 --- a/firmware/main/include/u8g2_mqtt.h +++ b/firmware/main/include/u8g2_mqtt.h @@ -1,8 +1,8 @@ #pragma once -#include "freertos/FreeRTOS.h" -#include "freertos/queue.h" -#include "freertos/task.h" +#include +#include +#include extern QueueHandle_t display_mqtt_queue; diff --git a/firmware/main/src/app_task.cpp b/firmware/main/src/app_task.cpp index d2cfdc6..a508094 100644 --- a/firmware/main/src/app_task.cpp +++ b/firmware/main/src/app_task.cpp @@ -1,26 +1,26 @@ #include "app_task.h" - #include "analytics.h" #include "button_handling.h" #include "common.h" -#include "common/InactivityTracker.h" #include "hal/u8g2_esp32_hal.h" +#include "heimdall/action_manager.h" +#include "hermes/hermes.h" #include "i2c_checker.h" #include "led_status.h" +#include "mercedes/mercedes.h" #include "message_manager.h" #include "my_mqtt_client.h" #include "persistence_manager.h" #include "simulator.h" #include "u8g2_mqtt.h" -#include "ui/ClockScreenSaver.h" -#include "ui/ScreenSaver.h" -#include "ui/SplashScreen.h" #include "wifi_manager.h" + #include #include #include #include #include +#include #include #include #include @@ -31,27 +31,20 @@ static const char *TAG = "app_task"; u8g2_t u8g2; -uint8_t last_value = 0; -menu_options_t options; uint8_t received_signal; uint64_t last_mqtt_sync = 0; -std::shared_ptr m_widget; -std::vector> m_history; -std::unique_ptr m_inactivityTracker; -// Persistence Manager for C-API 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) +// Display update task - handles I2C transfer asynchronously 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); } @@ -79,70 +72,77 @@ static void setup_screen(void) u8g2_ClearDisplay(&u8g2); } -void setScreen(const std::shared_ptr &screen) +// --- Heimdall button action callbacks --- + +static void on_button_up(const char *) { - if (screen != nullptr) + Mercedes::getInstance().handleInput(BTN_UP); +} +static void on_button_down(const char *) +{ + Mercedes::getInstance().handleInput(BTN_DOWN); +} +static void on_button_left(const char *) +{ + Mercedes::getInstance().handleInput(BTN_LEFT); +} +static void on_button_right(const char *) +{ + Mercedes::getInstance().handleInput(BTN_RIGHT); +} +static void on_button_select(const char *) +{ + Mercedes::getInstance().handleInput(BTN_SELECT); +} +static void on_button_back(const char *) +{ + Mercedes::getInstance().handleInput(BTN_BACK); +} + +static void register_button_actions(void) +{ + action_manager_register("button_up", on_button_up); + action_manager_register("button_down", on_button_down); + action_manager_register("button_left", on_button_left); + action_manager_register("button_right", on_button_right); + action_manager_register("button_select", on_button_select); + action_manager_register("button_back", on_button_back); + ESP_LOGI(TAG, "Button actions registered with Heimdall"); +} + +// --- Physical button handler → Heimdall --- + +static void handle_button(uint8_t button) +{ + hermes_reset_inactivity(); + + switch (button) { - ESP_DIAG_EVENT(TAG, "Screen set: %s", screen->getName()); - m_widget = screen; - m_history.clear(); - m_history.emplace_back(m_widget); - m_widget->onEnter(); + case CONFIG_BUTTON_UP: + action_manager_execute("button_up", NULL); + break; + case CONFIG_BUTTON_DOWN: + action_manager_execute("button_down", NULL); + break; + case CONFIG_BUTTON_LEFT: + action_manager_execute("button_left", NULL); + break; + case CONFIG_BUTTON_RIGHT: + action_manager_execute("button_right", NULL); + break; + case CONFIG_BUTTON_BACK: + action_manager_execute("button_back", NULL); + break; + case CONFIG_BUTTON_SELECT: + action_manager_execute("button_select", NULL); + break; + default: + ESP_LOGE(TAG, "Unhandled button: %u", button); + break; } } -void pushScreen(const std::shared_ptr &screen) -{ - if (screen != nullptr) - { - if (m_widget) - { - m_widget->onPause(); - } - ESP_DIAG_EVENT(TAG, "Screen pushed: %s", screen->getName()); - m_widget = screen; - m_widget->onEnter(); - m_history.emplace_back(m_widget); - } -} - -void popScreen() -{ - if (m_history.size() >= 2) - { - m_history.pop_back(); - if (m_widget) - { - persistence_manager_save(&g_persistence_manager); - m_widget->onExit(); - } - m_widget = m_history.back(); - ESP_DIAG_EVENT(TAG, "Screen popped, now: %s", m_widget->getName()); - m_widget->onResume(); - } -} - -static void init_ui(void) -{ - persistence_manager_init(&g_persistence_manager, "config"); - options = { - .u8g2 = &u8g2, - .setScreen = [](const std::shared_ptr &screen) { setScreen(screen); }, - .pushScreen = [](const std::shared_ptr &screen) { pushScreen(screen); }, - .popScreen = []() { popScreen(); }, - .onButtonClicked = nullptr, - .persistenceManager = &g_persistence_manager, - }; - m_widget = std::make_shared(&options); - m_inactivityTracker = std::make_unique(60000, []() { - auto screensaver = std::make_shared(&options); - options.pushScreen(screensaver); - }); - - u8g2_ClearBuffer(&u8g2); - m_widget->Render(); - u8g2_SendBuffer(&u8g2); -} +// --- Message manager listener --- static void on_message_received(const message_t *msg) { @@ -153,14 +153,18 @@ static void on_message_received(const message_t *msg) if (std::strcmp(msg->data.settings.key, "light_variant") == 0) { - // Schema changed -> force file reload. + char val[8]; + snprintf(val, sizeof(val), "%d", (int)msg->data.settings.value.int_value); + Mercedes::getInstance().updateItemValue("light_variant", val); 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. + char val[8]; + snprintf(val, sizeof(val), "%d", (int)msg->data.settings.value.int_value); + Mercedes::getInstance().updateItemValue("light_mode", val); bool force_reload = (msg->data.settings.type == SETTINGS_TYPE_INT && msg->data.settings.value.int_value == 0); start_simulation_with_reload(force_reload); return; @@ -168,49 +172,12 @@ static void on_message_received(const message_t *msg) if (std::strcmp(msg->data.settings.key, "light_active") == 0) { - // Power on/off does not force reload; simulator reloads only if not loaded yet. + Mercedes::getInstance().updateItemValue("light_active", msg->data.settings.value.bool_value ? "true" : "false"); start_simulation_with_reload(false); } } -static void handle_button(uint8_t button) -{ - m_inactivityTracker->reset(); - - if (m_widget) - { - switch (button) - { - case CONFIG_BUTTON_UP: - m_widget->OnButtonClicked(ButtonType::UP); - break; - - case CONFIG_BUTTON_LEFT: - m_widget->OnButtonClicked(ButtonType::LEFT); - break; - - case CONFIG_BUTTON_RIGHT: - m_widget->OnButtonClicked(ButtonType::RIGHT); - break; - - case CONFIG_BUTTON_DOWN: - m_widget->OnButtonClicked(ButtonType::DOWN); - break; - - case CONFIG_BUTTON_BACK: - m_widget->OnButtonClicked(ButtonType::BACK); - break; - - case CONFIG_BUTTON_SELECT: - m_widget->OnButtonClicked(ButtonType::SELECT); - break; - - default: - ESP_LOGE(TAG, "Unhandled button: %u", button); - break; - } - } -} +// --- Main task --- void app_task(void *args) { @@ -230,10 +197,9 @@ void app_task(void *args) return; } - // Initialize display so that info can be shown setup_screen(); - // Check BACK button and delete settings if necessary (with countdown) + // Factory reset check (hold BACK button for 5 seconds) gpio_config_t io_conf = {}; io_conf.intr_type = GPIO_INTR_DISABLE; io_conf.mode = GPIO_MODE_INPUT; @@ -257,13 +223,9 @@ void app_task(void *args) u8g2_SendBuffer(&u8g2); vTaskDelay(pdMS_TO_TICKS(1000)); if (gpio_get_level(BUTTON_BACK) != 0) - { - // Button released, abort break; - } if (i == 1) { - // After 5 seconds still pressed: perform factory reset u8g2_ClearBuffer(&u8g2); u8g2_DrawStr(&u8g2, 5, 30, "Alle Einstellungen "); u8g2_DrawStr(&u8g2, 5, 45, "werden geloescht..."); @@ -279,48 +241,91 @@ void app_task(void *args) } } + // Initialize subsystems + persistence_manager_init(&g_persistence_manager, "config"); message_manager_init(); - setup_buttons(); - init_ui(); + // Initialize Heimdall button actions + register_button_actions(); + + // Initialize Hermes renderer (60s screensaver timeout) + hermes_init(&u8g2, 60000); + + // Show splash screen immediately + u8g2_ClearBuffer(&u8g2); + hermes_draw(0); + u8g2_SendBuffer(&u8g2); + + // Start network and services wifi_manager_init(); - mqtt_client_start(); - message_manager_register_listener(on_message_received); - start_simulation(); - display_mqtt_queue = xQueueCreate(1, 1024); + // Set up dynamic value provider for label items + { + // Cache MAC suffix once + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + static char mac_suffix[6]; + snprintf(mac_suffix, sizeof(mac_suffix), "%02X%02X", mac[4], mac[5]); + ESP_LOGI(TAG, "Device MAC suffix: %s", mac_suffix); + Mercedes::getInstance().setItemValueProvider([](const std::string &id, char *buf, size_t bufSize) { + if (id == "mac_suffix") + { + strncpy(buf, mac_suffix, bufSize - 1); + buf[bufSize - 1] = '\0'; + } + }); + } + + // Load dynamic menu from SPIFFS + { + FILE *f = fopen("/spiffs/menu.json", "r"); + if (f) + { + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + std::string json(size, '\0'); + fread(&json[0], 1, size, f); + fclose(f); + if (Mercedes::getInstance().buildFromJson(json)) + { + ESP_LOGI(TAG, "Menu loaded from /spiffs/menu.json"); + } + else + { + ESP_LOGE(TAG, "Failed to parse menu.json"); + } + } + else + { + ESP_LOGE(TAG, "Failed to open /spiffs/menu.json"); + } + } + + display_mqtt_queue = xQueueCreate(1, 1024); 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", 4096, nullptr, tskIDLE_PRIORITY + 1, &display_update_task_handle, CONFIG_FREERTOS_NUMBER_OF_CORES - 1); + // Main loop auto oldTime = esp_timer_get_time(); while (true) { + auto currentTime = esp_timer_get_time(); + uint64_t deltaMs = (currentTime - oldTime) / 1000; + oldTime = currentTime; + u8g2_ClearBuffer(&u8g2); + hermes_draw(deltaMs); - if (m_widget != nullptr) - { - auto currentTime = esp_timer_get_time(); - auto delta = currentTime - oldTime; - oldTime = currentTime; - - uint64_t deltaMs = delta / 1000; - - m_widget->Update(deltaMs); - m_widget->Render(); - - m_inactivityTracker->update(deltaMs); - } - - // MQTT + // MQTT display sync auto now = esp_timer_get_time(); if (now - last_mqtt_sync > 1000000) { @@ -329,12 +334,13 @@ void app_task(void *args) last_mqtt_sync = now; } - // Signal display task immediately after render to minimize visible latency. + // Signal display task if (display_update_task_handle != nullptr) { xTaskNotifyGive(display_update_task_handle); } + // Process button input if (xQueueReceive(buttonQueue, &received_signal, pdMS_TO_TICKS(10)) == pdTRUE) { handle_button(received_signal); diff --git a/firmware/main/src/button_handling.c b/firmware/main/src/button_handling.c index 3bceb3d..588ea56 100644 --- a/firmware/main/src/button_handling.c +++ b/firmware/main/src/button_handling.c @@ -1,7 +1,7 @@ #include "button_handling.h" - #include "button_gpio.h" #include "common.h" + #include #include #include diff --git a/firmware/main/src/hal/u8g2_esp32_hal.c b/firmware/main/src/hal/u8g2_esp32_hal.c index 80b4f1e..d57028c 100644 --- a/firmware/main/src/hal/u8g2_esp32_hal.c +++ b/firmware/main/src/hal/u8g2_esp32_hal.c @@ -1,14 +1,12 @@ +#include "hal/u8g2_esp32_hal.h" + +#include +#include +#include +#include #include #include -#include "esp_log.h" -#include "sdkconfig.h" - -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" - -#include "hal/u8g2_esp32_hal.h" - static const char *TAG = "u8g2_hal"; static const unsigned int I2C_TIMEOUT_MS = 1000; diff --git a/firmware/main/src/i2c_checker.c b/firmware/main/src/i2c_checker.c index 9f2519e..e088073 100644 --- a/firmware/main/src/i2c_checker.c +++ b/firmware/main/src/i2c_checker.c @@ -1,10 +1,10 @@ #include "i2c_checker.h" - -#include "driver/i2c_master.h" -#include "esp_insights.h" -#include "esp_log.h" #include "hal/u8g2_esp32_hal.h" +#include +#include +#include + static const char *TAG = "i2c_checker"; static esp_err_t i2c_device_check(i2c_master_bus_handle_t i2c_bus, uint8_t device_address) diff --git a/firmware/main/src/main.cpp b/firmware/main/src/main.cpp index 912dbc0..9a3ebd8 100644 --- a/firmware/main/src/main.cpp +++ b/firmware/main/src/main.cpp @@ -4,6 +4,7 @@ #include "led_strip_ws2812.h" #include "persistence_manager.h" #include "wifi_manager.h" + #include #include #include diff --git a/firmware/main/src/u8g2_mqtt.cpp b/firmware/main/src/u8g2_mqtt.cpp index 2e84f72..9e52a20 100644 --- a/firmware/main/src/u8g2_mqtt.cpp +++ b/firmware/main/src/u8g2_mqtt.cpp @@ -1,7 +1,7 @@ #include "u8g2_mqtt.h" +#include "my_mqtt_client.h" -#include "esp_timer.h" -#include +#include #include #include #include diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index 41f9117..9b6485d 100755 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -36,6 +36,9 @@ CONFIG_ESP32_CORE_DUMP_STACK_SIZE=1024 CONFIG_ESP_RMAKER_DEF_TIMEZONE="Europe/Berlin" +# LWIP +CONFIG_LWIP_LOCAL_HOSTNAME="system-control" + # ESP PSRAM CONFIG_SPIRAM=y diff --git a/firmware/storage/menu.json b/firmware/storage/menu.json index cd1c725..817d700 100644 --- a/firmware/storage/menu.json +++ b/firmware/storage/menu.json @@ -5,19 +5,19 @@ "title": "Hauptmenü", "items": [ { - "id": "lights_control", - "type": "action", + "id": "menu_lights", + "type": "submenu", "label": "Lichtsteuerung", - "actionTopic": "home/lights/toggle" + "targetScreenId": "lights_menu" }, { - "id": "climate_control", - "type": "action", - "label": "Klima Steuerung", - "actionTopic": "home/climate/toggle" + "id": "menu_external", + "type": "submenu", + "label": "externe Geraete", + "targetScreenId": "external_devices_menu" }, { - "id": "settings", + "id": "menu_settings", "type": "submenu", "label": "Einstellungen", "targetScreenId": "settings_menu" @@ -29,37 +29,69 @@ "title": "Lichtsteuerung", "items": [ { - "id": "living_room_light", - "type": "action", - "label": "Wohnzimmer Licht", + "id": "light_active", + "type": "toggle", + "label": "Einschalten", "persistent": true, - "actionTopic": "home/lights/living_room/toggle" + "valueType": "bool", + "actionTopic": "home/lights/activate" }, { "id": "light_mode", "type": "selection", - "label": "Küchen Licht", + "label": "Modus", "persistent": true, + "valueType": "int", "items": [ { - "value": "day", + "value": "1", "label": "Tag" }, { - "value": "night", + "value": "2", "label": "Nacht" }, { - "value": "auto", + "value": "0", "label": "Simulation" } ] }, { - "id": "back_to_main", - "type": "submenu", - "label": "< Zurück zum Hauptmenü", - "targetScreenId": "main_menu" + "id": "light_variant", + "type": "selection", + "label": "Variante", + "persistent": true, + "valueType": "int", + "visibleWhen": { + "itemId": "light_mode", + "value": "0" + }, + "items": [ + { + "value": "1", + "label": "Standard" + }, + { + "value": "2", + "label": "Warm" + }, + { + "value": "3", + "label": "Natur" + } + ] + } + ] + }, + { + "id": "external_devices_menu", + "title": "externe Geräte", + "items": [ + { + "id": "empty", + "type": "label", + "label": "keine Einträge" } ] }, @@ -68,22 +100,15 @@ "title": "Einstellungen", "items": [ { - "id": "wifi_settings", + "id": "ota_update", "type": "action", - "label": "WLAN Einstellungen", - "actionTopic": "home/settings/wifi" + "label": "OTA Einspielen", + "actionTopic": "home/settings/ota_update" }, { - "id": "system_info", - "type": "action", - "label": "Systeminformationen", - "actionTopic": "home/settings/system_info" - }, - { - "id": "back_to_main", - "type": "submenu", - "label": "< Zurück zum Hauptmenü", - "targetScreenId": "main_menu" + "id": "mac_suffix", + "type": "label", + "label": "Device-ID" } ] } diff --git a/firmware/website/.gitignore b/firmware/website/.gitignore new file mode 100644 index 0000000..4a95840 --- /dev/null +++ b/firmware/website/.gitignore @@ -0,0 +1,27 @@ +# SvelteKit +.svelte-kit/ +build/ +dist/ + +# Dependencies +node_modules/ + +# Environment +.env +.env.* +!.env.example + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# IntelliJ / JetBrains +.idea/ +*.iml +*.iws +*.ipr +out/ + +# OS +.DS_Store +Thumbs.db