dynamic menu

- also component renaming

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-03-28 16:57:15 +01:00
parent cb5bcb070c
commit 32ea23906f
61 changed files with 2815 additions and 1885 deletions
@@ -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.
+2 -2
View File
@@ -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
+44
View File
@@ -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
@@ -1,6 +1,7 @@
#include "analytics.h"
#include "esp_insights.h"
#include "esp_rmaker_utils.h"
#include <esp_insights.h>
#include <esp_rmaker_utils.h>
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");
File diff suppressed because it is too large Load Diff
@@ -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
)
@@ -0,0 +1,16 @@
#pragma once
#include <cJSON.h>
#include <esp_http_server.h>
#include <stdint.h>
#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);
@@ -0,0 +1,223 @@
#include "bifrost/api_handlers.h"
#include "bifrost/api_handlers_util.h"
#include "heimdall/action_manager.h"
#include <cJSON.h>
#include <esp_http_server.h>
#include <esp_log.h>
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;
}
@@ -0,0 +1,207 @@
#include "bifrost/api_handlers.h"
#include "bifrost/api_handlers_util.h"
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <string.h>
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\"}");
}
@@ -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 <cJSON.h>
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <string.h>
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\"}");
}
@@ -0,0 +1,139 @@
#include "bifrost/api_handlers.h"
#include "bifrost/api_handlers_util.h"
#include <esp_log.h>
#include <esp_wifi.h>
#include <string.h>
#include <sys/stat.h>
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;
}
@@ -0,0 +1,83 @@
#include "bifrost/api_handlers_util.h"
#include <esp_heap_caps.h>
#include <string.h>
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";
}
@@ -0,0 +1,203 @@
#include "bifrost/api_handlers.h"
#include "bifrost/api_handlers_util.h"
#include "persistence_manager.h"
#include <cJSON.h>
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <esp_wifi.h>
#include <string.h>
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;
}
@@ -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 <esp_http_server.h>
#include <esp_log.h>
#include <mdns.h>
@@ -1,12 +1,12 @@
#include "common.h"
#include <cJSON.h>
#include <stdbool.h>
#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 <cJSON.h>
#include <stdbool.h>
#include <string.h>
#include <time.h>
@@ -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);
@@ -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 <esp_http_server.h>
#include <esp_log.h>
#include <message_manager.h>
#include <string.h>
static const char *TAG = "websocket_handler";
@@ -12,5 +12,5 @@ idf_component_register(SRCS
esp_insights
analytics
led-manager
api-server
bifrost
)
@@ -1,7 +1,7 @@
#include "ble/ble_scanner.h"
#include "ble/ble_device.h"
#include "led_status.h"
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
@@ -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 <arpa/inet.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
@@ -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 <esp_event.h>
#include <esp_insights.h>
#include <esp_log.h>
@@ -11,12 +13,10 @@
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <freertos/task.h>
#include <led_status.h>
#include <lwip/err.h>
#include <lwip/sys.h>
#include <mdns.h>
#include <nvs_flash.h>
#include <persistence_manager.h>
#include <sdkconfig.h>
#include <string.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);
@@ -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
@@ -1,11 +1,11 @@
#include "heimdall/action_manager.h"
#include <esp_log.h>
#include <string>
#include <unordered_map>
static const char *TAG = "ActionMgr";
// Hier speichern wir alle registrierten C-Funktionszeiger
static std::unordered_map<std::string, action_callback_t> 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);
}
}
@@ -0,0 +1,8 @@
idf_component_register(SRCS
src/screensaver/clock_screensaver.cpp
src/hermes.cpp
INCLUDE_DIRS "include"
REQUIRES
mercedes
u8g2
)
@@ -0,0 +1,30 @@
#pragma once
#include <u8g2.h>
#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
@@ -0,0 +1,30 @@
#pragma once
#include "hermes/screensaver/screensaver.h"
#include <u8g2.h>
#include <cstdint>
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);
};
@@ -0,0 +1,12 @@
#pragma once
#include <cstdint>
class Screensaver
{
public:
virtual ~Screensaver() = default;
virtual void init() = 0;
virtual void draw(uint64_t dt) = 0;
};
+268
View File
@@ -0,0 +1,268 @@
#include "hermes/hermes.h"
#include "hermes/screensaver/clock_screensaver.h"
#include "hermes/screensaver/screensaver.h"
#include "mercedes/mercedes.h"
#include <esp_log.h>
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<int>(total);
if (thumbHeight < 3)
thumbHeight = 3;
int thumbY = trackY + static_cast<int>(current) * (trackHeight - thumbHeight) / static_cast<int>(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<int>(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;
}
}
@@ -0,0 +1,77 @@
#include "hermes/screensaver/clock_screensaver.h"
#include <ctime>
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);
}
@@ -1,6 +1,6 @@
#pragma once
#include "SDL3/SDL_render.h"
#include <SDL3/SDL_render.h>
#include <cstdint>
+5 -3
View File
@@ -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
)
@@ -1,102 +0,0 @@
#pragma once
#include <functional>
#include <map>
#include <string>
#include <vector>
/**
* @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<void(const std::string &id, const std::string &actionTopic, const std::string &value)>;
/**
* @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<std::string(const std::string &id)>;
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<MenuSelectionItemDef> selectionItems;
};
struct MenuScreenDef
{
std::string id;
std::string title;
std::vector<MenuItemDef> 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<std::string, MenuScreenDef> m_screens;
std::string m_currentScreenId;
};
@@ -0,0 +1,146 @@
#pragma once
#include "heimdall/button_type.h"
#include <functional>
#include <map>
#include <stack>
#include <string>
#include <vector>
/**
* @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<void(const std::string &id, const std::string &actionTopic, const std::string &value)>;
/**
* @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<void(const std::string &id, char *buf, size_t bufSize)>;
/**
* @brief Callback notified when menu state changes (screen switch, selection move, value change)
*/
using MenuStateChangedCallback = std::function<void()>;
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<MenuSelectionItemDef> selectionItems;
std::string visibleWhenItemId;
std::string visibleWhenValue;
};
struct MenuScreenDef
{
std::string id;
std::string title;
std::vector<MenuItemDef> 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<std::string, MenuScreenDef> m_screens;
std::string m_currentScreenId;
size_t m_selectedIndex = 0;
std::stack<std::pair<std::string, size_t>> m_screenHistory;
};
@@ -1,309 +0,0 @@
#include "mercedes/DynamicMenuBuilder.h"
#include "heimdall/action_manager.h"
#include <cJSON.h>
#include <esp_log.h>
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());
}
@@ -0,0 +1,530 @@
#include "mercedes/mercedes.h"
#include "heimdall/action_manager.h"
#include "message_manager.h"
#include "persistence_manager.h"
#include <cJSON.h>
#include <cstdlib>
#include <cstring>
#include <esp_log.h>
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<int>(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<int>(m_selectedIndex);
int count = static_cast<int>(screen->items.size());
do {
idx = (idx == 0) ? count - 1 : idx - 1;
} while (!isItemVisible(screen->items[idx]) && idx != static_cast<int>(m_selectedIndex));
m_selectedIndex = static_cast<size_t>(idx);
if (m_stateChangedCallback)
m_stateChangedCallback();
break;
}
case BTN_DOWN:
{
int idx = static_cast<int>(m_selectedIndex);
int count = static_cast<int>(screen->items.size());
do {
idx = (idx + 1) % count;
} while (!isItemVisible(screen->items[idx]) && idx != static_cast<int>(m_selectedIndex));
m_selectedIndex = static_cast<size_t>(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<int>(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<int>(item.selectionItems.size()); i++)
{
if (item.selectionItems[i].value == value)
{
item.selectionIndex = i;
break;
}
}
}
if (m_stateChangedCallback)
m_stateChangedCallback();
return;
}
}
}
@@ -1,5 +1,7 @@
#include "message_manager.h"
#include "my_mqtt_client.h"
#include "persistence_manager.h"
#include <esp_app_desc.h>
#include <esp_log.h>
#include <esp_mac.h>
@@ -7,7 +9,6 @@
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <persistence_manager.h>
#include <sdkconfig.h>
#include <string.h>
@@ -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 <cJSON.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 <esp_timer.h>
#include <mqtt_client.h>
#include <sdkconfig.h>
#include <sys/time.h>
#define DEVICE_TOPIC_MAX_LEN 60
@@ -1,6 +1,6 @@
#pragma once
#include "esp_check.h"
#include <esp_check.h>
#include <stdint.h>
// Configuration structure for the simulation
@@ -1,6 +1,6 @@
#pragma once
#include "esp_err.h"
#include <esp_err.h>
#ifdef __cplusplus
extern "C"
@@ -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 <esp_heap_caps.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
@@ -1,9 +1,10 @@
#include "storage.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_spiffs.h"
#include "simulator.h"
#include <errno.h>
#include <esp_check.h>
#include <esp_log.h>
#include <esp_spiffs.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once
#include "driver/gpio.h"
#include <driver/gpio.h>
#define BUTTON_UP ((gpio_num_t)CONFIG_BUTTON_UP)
#define BUTTON_DOWN ((gpio_num_t)CONFIG_BUTTON_DOWN)
+5 -7
View File
@@ -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 <driver/gpio.h>
#include <driver/i2c_master.h>
#include <driver/spi_master.h>
#include <hal/i2c_types.h>
#include <u8g2.h>
#define U8G2_ESP32_HAL_UNDEFINED GPIO_NUM_NC
+2 -2
View File
@@ -1,7 +1,7 @@
#pragma once
#include "driver/gpio.h"
#include "esp_err.h"
#include <driver/gpio.h>
#include <esp_err.h>
#define DISPLAY_I2C_ADDRESS 0x3C
+3 -3
View File
@@ -1,8 +1,8 @@
#pragma once
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
extern QueueHandle_t display_mqtt_queue;
+146 -140
View File
@@ -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 <cstring>
#include <driver/i2c.h>
#include <esp_diagnostics.h>
#include <esp_insights.h>
#include <esp_log.h>
#include <esp_mac.h>
#include <esp_task_wdt.h>
#include <esp_timer.h>
#include <sdkconfig.h>
@@ -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<Widget> m_widget;
std::vector<std::shared_ptr<Widget>> m_history;
std::unique_ptr<InactivityTracker> 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<Widget> &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<Widget> &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<Widget> &screen) { setScreen(screen); },
.pushScreen = [](const std::shared_ptr<Widget> &screen) { pushScreen(screen); },
.popScreen = []() { popScreen(); },
.onButtonClicked = nullptr,
.persistenceManager = &g_persistence_manager,
};
m_widget = std::make_shared<SplashScreen>(&options);
m_inactivityTracker = std::make_unique<InactivityTracker>(60000, []() {
auto screensaver = std::make_shared<ClockScreenSaver>(&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)
{
u8g2_ClearBuffer(&u8g2);
if (m_widget != nullptr)
{
auto currentTime = esp_timer_get_time();
auto delta = currentTime - oldTime;
uint64_t deltaMs = (currentTime - oldTime) / 1000;
oldTime = currentTime;
uint64_t deltaMs = delta / 1000;
u8g2_ClearBuffer(&u8g2);
hermes_draw(deltaMs);
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);
+1 -1
View File
@@ -1,7 +1,7 @@
#include "button_handling.h"
#include "button_gpio.h"
#include "common.h"
#include <driver/gpio.h>
#include <esp_err.h>
#include <esp_insights.h>
+6 -8
View File
@@ -1,14 +1,12 @@
#include "hal/u8g2_esp32_hal.h"
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <sdkconfig.h>
#include <stdio.h>
#include <string.h>
#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;
+4 -4
View File
@@ -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 <driver/i2c_master.h>
#include <esp_insights.h>
#include <esp_log.h>
static const char *TAG = "i2c_checker";
static esp_err_t i2c_device_check(i2c_master_bus_handle_t i2c_bus, uint8_t device_address)
+1
View File
@@ -4,6 +4,7 @@
#include "led_strip_ws2812.h"
#include "persistence_manager.h"
#include "wifi_manager.h"
#include <ble_manager.h>
#include <driver/gpio.h>
#include <esp_event.h>
+2 -2
View File
@@ -1,7 +1,7 @@
#include "u8g2_mqtt.h"
#include "my_mqtt_client.h"
#include "esp_timer.h"
#include <my_mqtt_client.h>
#include <esp_timer.h>
#include <stdint.h>
#include <string.h>
#include <u8g2.h>
+3
View File
@@ -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
+58 -33
View File
@@ -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"
}
]
}
+27
View File
@@ -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