From cb5bcb070c17f30af3493dd211be1bea13403f98 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Sat, 28 Mar 2026 09:08:56 +0100 Subject: [PATCH] starting data driven (dynamic) menu Signed-off-by: Peter Siegmund --- firmware/components/heimdall/CMakeLists.txt | 4 + .../include/heimdall/action_manager.h | 26 ++ .../heimdall/src/action_manager.cpp | 36 ++ firmware/components/insa/CMakeLists.txt | 4 +- firmware/components/mercedes/CMakeLists.txt | 8 + .../include/mercedes/DynamicMenuBuilder.h | 102 ++++++ .../mercedes/src/DynamicMenuBuilder.cpp | 309 ++++++++++++++++++ firmware/main/CMakeLists.txt | 1 + firmware/storage/menu.json | 91 ++++++ 9 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 firmware/components/heimdall/CMakeLists.txt create mode 100644 firmware/components/heimdall/include/heimdall/action_manager.h create mode 100644 firmware/components/heimdall/src/action_manager.cpp create mode 100644 firmware/components/mercedes/CMakeLists.txt create mode 100644 firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h create mode 100644 firmware/components/mercedes/src/DynamicMenuBuilder.cpp create mode 100644 firmware/storage/menu.json diff --git a/firmware/components/heimdall/CMakeLists.txt b/firmware/components/heimdall/CMakeLists.txt new file mode 100644 index 0000000..885a96d --- /dev/null +++ b/firmware/components/heimdall/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register(SRCS + src/action_manager.cpp + INCLUDE_DIRS "include" +) diff --git a/firmware/components/heimdall/include/heimdall/action_manager.h b/firmware/components/heimdall/include/heimdall/action_manager.h new file mode 100644 index 0000000..17d794d --- /dev/null +++ b/firmware/components/heimdall/include/heimdall/action_manager.h @@ -0,0 +1,26 @@ +#pragma once + +#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") + */ + typedef void (*action_callback_t)(const char *value); + + /** + * @brief Registriert eine Aktion, die über die dynamische UI aufgerufen werden kann + */ + void action_manager_register(const char *action_name, action_callback_t callback); + + /** + * @brief Führt eine registrierte Aktion aus + */ + void action_manager_execute(const char *action_name, const char *value); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/components/heimdall/src/action_manager.cpp b/firmware/components/heimdall/src/action_manager.cpp new file mode 100644 index 0000000..39498e7 --- /dev/null +++ b/firmware/components/heimdall/src/action_manager.cpp @@ -0,0 +1,36 @@ +#include "heimdall/action_manager.h" +#include +#include +#include + +static const char *TAG = "ActionMgr"; + +// Hier speichern wir alle registrierten C-Funktionszeiger +static std::unordered_map s_actions; + +extern "C" void action_manager_register(const char *action_name, action_callback_t callback) +{ + if (action_name && callback) + { + s_actions[action_name] = callback; + ESP_LOGD(TAG, "Aktion registriert: %s", action_name); + } +} + +extern "C" void action_manager_execute(const char *action_name, const char *value) +{ + if (!action_name) + return; + + 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 + it->second(value ? value : ""); + } + else + { + ESP_LOGW(TAG, "Aktion nicht gefunden: %s", action_name); + } +} diff --git a/firmware/components/insa/CMakeLists.txt b/firmware/components/insa/CMakeLists.txt index 3e5360f..91510cd 100644 --- a/firmware/components/insa/CMakeLists.txt +++ b/firmware/components/insa/CMakeLists.txt @@ -14,9 +14,9 @@ idf_component_register(SRCS INCLUDE_DIRS "include" PRIV_REQUIRES u8g2 + message-manager + persistence-manager connectivity-manager led-manager - persistence-manager simulator - message-manager ) diff --git a/firmware/components/mercedes/CMakeLists.txt b/firmware/components/mercedes/CMakeLists.txt new file mode 100644 index 0000000..00f9179 --- /dev/null +++ b/firmware/components/mercedes/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register(SRCS + src/DynamicMenuBuilder.cpp + INCLUDE_DIRS "include" + PRIV_REQUIRES + heimdall + json + insa +) diff --git a/firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h b/firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h new file mode 100644 index 0000000..07459db --- /dev/null +++ b/firmware/components/mercedes/include/mercedes/DynamicMenuBuilder.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include + +/** + * @brief Callback for dynamic menu events + * @param id The String ID of the menu item interacted with + * @param actionTopic The optional MQTT topic or action identifier from the JSON + * @param value The new value (e.g. "true" for toggle, "2" for selection) + */ +using MenuActionCallback = + std::function; + +/** + * @brief Provider callback to fetch real-time state from NVS/OpenThread + * @param id The String ID of the menu item + * @return The current state as string (e.g. "true", "false", or a selection value), or empty string to use JSON + * defaults + */ +using ItemValueProvider = std::function; + +struct MenuSelectionItemDef +{ + std::string value; + std::string label; +}; + +struct MenuItemDef +{ + std::string id; + std::string type; + std::string label; + std::string actionTopic; + std::string targetScreenId; + bool persistent = false; + bool toggleValue = false; + std::vector selectionItems; +}; + +struct MenuScreenDef +{ + std::string id; + std::string title; + std::vector items; +}; + +/** + * @class DynamicMenuBuilder + * @brief A helper class to construct Menus from a JSON payload + */ +class DynamicMenuBuilder +{ + public: + /** + * @brief Get the singleton instance of the DynamicMenuBuilder + */ + static DynamicMenuBuilder &getInstance(); + + // Delete copy and move constructors to enforce singleton pattern + DynamicMenuBuilder(const DynamicMenuBuilder &) = delete; + void operator=(const DynamicMenuBuilder &) = delete; + ~DynamicMenuBuilder() = default; + + /** + * @brief Parses a JSON string and populates the target Menu + * @param jsonPayload The JSON string containing menu definitions + * @return true if successfully parsed and built, false otherwise + */ + bool buildFromJson(const std::string &jsonPayload); + + /** + * @brief Sets the callback to be executed when a dynamic item is triggered + */ + void setActionCallback(MenuActionCallback callback); + + /** + * @brief Sets the provider to fetch real-time states for rendering + */ + void setItemValueProvider(ItemValueProvider provider); + + /** + * @brief Pseudo-rendering: Outputs the current menu to the log + */ + void render(); + + /** + * @brief Simulates pressing an item via String ID + */ + void handleItemPress(const std::string &itemId); + + private: + DynamicMenuBuilder(); // Private constructor + + MenuActionCallback m_actionCallback; // Optional, in case you need callbacks in addition to the ActionManager + ItemValueProvider m_valueProvider; // Fetches real states from external sources (NVS/OpenThread) + + std::map m_screens; + std::string m_currentScreenId; +}; diff --git a/firmware/components/mercedes/src/DynamicMenuBuilder.cpp b/firmware/components/mercedes/src/DynamicMenuBuilder.cpp new file mode 100644 index 0000000..35358ae --- /dev/null +++ b/firmware/components/mercedes/src/DynamicMenuBuilder.cpp @@ -0,0 +1,309 @@ +#include "mercedes/DynamicMenuBuilder.h" +#include "heimdall/action_manager.h" +#include +#include + +static const char *TAG = "DynamicMenu"; + +DynamicMenuBuilder &DynamicMenuBuilder::getInstance() +{ + static DynamicMenuBuilder instance; + return instance; +} + +DynamicMenuBuilder::DynamicMenuBuilder() +{ +} + +void DynamicMenuBuilder::setActionCallback(MenuActionCallback callback) +{ + m_actionCallback = callback; +} + +void DynamicMenuBuilder::setItemValueProvider(ItemValueProvider provider) +{ + m_valueProvider = provider; +} + +bool DynamicMenuBuilder::buildFromJson(const std::string &jsonPayload) +{ + // Parse JSON + cJSON *root = cJSON_Parse(jsonPayload.c_str()); + if (!root) + { + ESP_LOGE(TAG, "Error parsing JSON payload"); + return false; + } + + // Reset previous state + m_screens.clear(); + m_currentScreenId = ""; + + cJSON *screens = cJSON_GetObjectItem(root, "screens"); + if (screens && cJSON_IsArray(screens)) + { + int numScreens = cJSON_GetArraySize(screens); + + for (int i = 0; i < numScreens; i++) + { + cJSON *screenItem = cJSON_GetArrayItem(screens, i); + if (!screenItem) + continue; + + MenuScreenDef screenDef; + cJSON *screenId = cJSON_GetObjectItem(screenItem, "id"); + cJSON *screenTitle = cJSON_GetObjectItem(screenItem, "title"); + + if (screenId && cJSON_IsString(screenId)) + screenDef.id = screenId->valuestring; + if (screenTitle && cJSON_IsString(screenTitle)) + screenDef.title = screenTitle->valuestring; + + // Set the first screen as the start screen + if (m_currentScreenId.empty() && !screenDef.id.empty()) + { + m_currentScreenId = screenDef.id; + } + + cJSON *items = cJSON_GetObjectItem(screenItem, "items"); + if (items && cJSON_IsArray(items)) + { + int numItems = cJSON_GetArraySize(items); + for (int j = 0; j < numItems; j++) + { + cJSON *item = cJSON_GetArrayItem(items, j); + if (!item) + continue; + + MenuItemDef itemDef; + cJSON *idItem = cJSON_GetObjectItem(item, "id"); + cJSON *typeItem = cJSON_GetObjectItem(item, "type"); + cJSON *labelItem = cJSON_GetObjectItem(item, "label"); + cJSON *actionItem = cJSON_GetObjectItem(item, "actionTopic"); + cJSON *targetItem = cJSON_GetObjectItem(item, "targetScreenId"); + cJSON *persistentItem = cJSON_GetObjectItem(item, "persistent"); + + if (idItem && cJSON_IsString(idItem)) + itemDef.id = idItem->valuestring; + if (typeItem && cJSON_IsString(typeItem)) + itemDef.type = typeItem->valuestring; + if (labelItem && cJSON_IsString(labelItem)) + itemDef.label = labelItem->valuestring; + if (actionItem && cJSON_IsString(actionItem)) + itemDef.actionTopic = actionItem->valuestring; + if (targetItem && cJSON_IsString(targetItem)) + itemDef.targetScreenId = targetItem->valuestring; + if (persistentItem && cJSON_IsBool(persistentItem)) + itemDef.persistent = cJSON_IsTrue(persistentItem); + + cJSON *valueItem = cJSON_GetObjectItem(item, "value"); + if (valueItem && cJSON_IsBool(valueItem)) + itemDef.toggleValue = cJSON_IsTrue(valueItem); + + // Parse sub-items for 'selection' type + if (itemDef.type == "selection") + { + cJSON *selectionItems = cJSON_GetObjectItem(item, "items"); + if (selectionItems && cJSON_IsArray(selectionItems)) + { + int numSelItems = cJSON_GetArraySize(selectionItems); + for (int k = 0; k < numSelItems; k++) + { + cJSON *selItem = cJSON_GetArrayItem(selectionItems, k); + if (!selItem) + continue; + + MenuSelectionItemDef selDef; + cJSON *valItem = cJSON_GetObjectItem(selItem, "value"); + cJSON *lblItem = cJSON_GetObjectItem(selItem, "label"); + + if (valItem && cJSON_IsString(valItem)) + selDef.value = valItem->valuestring; + if (lblItem && cJSON_IsString(lblItem)) + selDef.label = lblItem->valuestring; + + itemDef.selectionItems.push_back(selDef); + } + } + } + + screenDef.items.push_back(itemDef); + } + } + m_screens[screenDef.id] = screenDef; + } + } + + // Free RAM (IMPORTANT for cJSON!) + cJSON_Delete(root); + return true; +} + +void DynamicMenuBuilder::render() +{ + if (m_currentScreenId.empty() || m_screens.find(m_currentScreenId) == m_screens.end()) + { + ESP_LOGE(TAG, "No active screen found to render."); + return; + } + + const MenuScreenDef &screen = m_screens[m_currentScreenId]; + + ESP_LOGI(TAG, "==================================="); + ESP_LOGI(TAG, " SCREEN: %s", screen.title.c_str()); + ESP_LOGI(TAG, "-----------------------------------"); + + for (size_t i = 0; i < screen.items.size(); i++) + { + const auto &item = screen.items[i]; + std::string persistentStr = item.persistent ? " [Persistent]" : ""; + + // Fetch real state from NVS/OpenThread if available, otherwise use JSON default + std::string externalState = m_valueProvider ? m_valueProvider(item.id) : ""; + + if (item.type == "submenu") + { + ESP_LOGI(TAG, " - %s%s (-> opens '%s')", item.label.c_str(), persistentStr.c_str(), + item.targetScreenId.c_str()); + } + else if (item.type == "action") + { + ESP_LOGI(TAG, " - %s%s [Action: %s]", item.label.c_str(), persistentStr.c_str(), item.actionTopic.c_str()); + } + else if (item.type == "toggle") + { + bool isOn = item.toggleValue; + if (!externalState.empty()) + isOn = (externalState == "true"); + + ESP_LOGI(TAG, " - %s%s [Toggle: %s]", item.label.c_str(), persistentStr.c_str(), isOn ? "ON" : "OFF"); + } + else if (item.type == "selection") + { + ESP_LOGI(TAG, " - %s%s [Selection]:", item.label.c_str(), persistentStr.c_str()); + for (const auto &sel : item.selectionItems) + { + bool isSelected = (!externalState.empty() && externalState == sel.value); + ESP_LOGI(TAG, " %s %s (Value: %s)", isSelected ? "[x]" : "[ ]", sel.label.c_str(), + sel.value.c_str()); + } + } + else + { + ESP_LOGI(TAG, " - %s%s", item.label.c_str(), persistentStr.c_str()); + } + } + ESP_LOGI(TAG, "==================================="); +} + +void DynamicMenuBuilder::handleItemPress(const std::string &itemId) +{ + if (m_currentScreenId.empty() || m_screens.find(m_currentScreenId) == m_screens.end()) + { + return; + } + + std::string mainItemId = itemId; + std::string subValue; + size_t separatorPos = itemId.find(':'); + if (separatorPos != std::string::npos) + { + mainItemId = itemId.substr(0, separatorPos); + subValue = itemId.substr(separatorPos + 1); + } + + for (const auto &item : m_screens[m_currentScreenId].items) + { + if (item.id == mainItemId) + { + ESP_LOGI(TAG, "-> Handling press for '%s'", item.label.c_str()); + + if (item.type == "submenu") + { + if (!item.targetScreenId.empty() && m_screens.find(item.targetScreenId) != m_screens.end()) + { + m_currentScreenId = item.targetScreenId; + render(); // Render the new submenu! + } + else + { + ESP_LOGW(TAG, "Target screen '%s' not found!", item.targetScreenId.c_str()); + } + } + else if (item.type == "action") + { + if (!item.actionTopic.empty()) + { + ESP_LOGI(TAG, "Calling ActionManager: %s", item.actionTopic.c_str()); + const char *value = "true"; + action_manager_execute(item.actionTopic.c_str(), value); + if (m_actionCallback) + { + m_actionCallback(item.id, item.actionTopic, value); + } + } + } + else if (item.type == "toggle") + { + // Fetch the *actual* current state to determine what the *new* state should be + bool currentState = item.toggleValue; + if (m_valueProvider) + { + std::string val = m_valueProvider(item.id); + if (!val.empty()) + currentState = (val == "true"); + } + + bool newState = !currentState; + const char *valStr = newState ? "true" : "false"; + ESP_LOGI(TAG, "Toggle item '%s' requested flip to %s", item.label.c_str(), valStr); + + if (!item.actionTopic.empty()) + { + action_manager_execute(item.actionTopic.c_str(), valStr); + if (m_actionCallback) + { + m_actionCallback(item.id, item.actionTopic, valStr); + } + } + } + else if (item.type == "selection") + { + if (subValue.empty()) + { + ESP_LOGI(TAG, "Selection item '%s' pressed, opening options.", item.label.c_str()); + // In a real UI, this would open the list of options. + // For our pseudo-UI, we do nothing and wait for a press with a sub-value. + return; + } + + // A choice was made, validate it + bool isValidChoice = false; + for (const auto &sel_item : item.selectionItems) + { + if (sel_item.value == subValue) + { + isValidChoice = true; + break; + } + } + + if (isValidChoice) + { + ESP_LOGI(TAG, "-> Chose option with value '%s' for '%s'", subValue.c_str(), item.label.c_str()); + if (!item.actionTopic.empty()) + { + action_manager_execute(item.actionTopic.c_str(), subValue.c_str()); + if (m_actionCallback) + { + m_actionCallback(item.id, item.actionTopic, subValue); + } + } + } + } + return; // Item found and processed + } + } + + ESP_LOGW(TAG, "Item ID '%s' not found in current screen", mainItemId.c_str()); +} \ No newline at end of file diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index dd5f9fb..a70be95 100755 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register(SRCS src/hal/u8g2_esp32_hal.c INCLUDE_DIRS "include" PRIV_REQUIRES + mercedes analytics insa connectivity-manager diff --git a/firmware/storage/menu.json b/firmware/storage/menu.json new file mode 100644 index 0000000..cd1c725 --- /dev/null +++ b/firmware/storage/menu.json @@ -0,0 +1,91 @@ +{ + "screens": [ + { + "id": "main_menu", + "title": "Hauptmenü", + "items": [ + { + "id": "lights_control", + "type": "action", + "label": "Lichtsteuerung", + "actionTopic": "home/lights/toggle" + }, + { + "id": "climate_control", + "type": "action", + "label": "Klima Steuerung", + "actionTopic": "home/climate/toggle" + }, + { + "id": "settings", + "type": "submenu", + "label": "Einstellungen", + "targetScreenId": "settings_menu" + } + ] + }, + { + "id": "lights_menu", + "title": "Lichtsteuerung", + "items": [ + { + "id": "living_room_light", + "type": "action", + "label": "Wohnzimmer Licht", + "persistent": true, + "actionTopic": "home/lights/living_room/toggle" + }, + { + "id": "light_mode", + "type": "selection", + "label": "Küchen Licht", + "persistent": true, + "items": [ + { + "value": "day", + "label": "Tag" + }, + { + "value": "night", + "label": "Nacht" + }, + { + "value": "auto", + "label": "Simulation" + } + ] + }, + { + "id": "back_to_main", + "type": "submenu", + "label": "< Zurück zum Hauptmenü", + "targetScreenId": "main_menu" + } + ] + }, + { + "id": "settings_menu", + "title": "Einstellungen", + "items": [ + { + "id": "wifi_settings", + "type": "action", + "label": "WLAN Einstellungen", + "actionTopic": "home/settings/wifi" + }, + { + "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" + } + ] + } + ] +} \ No newline at end of file