dynamic menu
- also component renaming Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
+5
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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
+6
@@ -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;
|
||||
}
|
||||
+5
-5
@@ -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>
|
||||
+23
-8
@@ -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);
|
||||
+5
-5
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user