starting data driven (dynamic) menu

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-03-28 09:08:56 +01:00
parent dcc9dae23c
commit cb5bcb070c
9 changed files with 579 additions and 2 deletions
@@ -0,0 +1,4 @@
idf_component_register(SRCS
src/action_manager.cpp
INCLUDE_DIRS "include"
)
@@ -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
@@ -0,0 +1,36 @@
#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)
{
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);
}
}
+2 -2
View File
@@ -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
)
@@ -0,0 +1,8 @@
idf_component_register(SRCS
src/DynamicMenuBuilder.cpp
INCLUDE_DIRS "include"
PRIV_REQUIRES
heimdall
json
insa
)
@@ -0,0 +1,102 @@
#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,309 @@
#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());
}
+1
View File
@@ -7,6 +7,7 @@ idf_component_register(SRCS
src/hal/u8g2_esp32_hal.c
INCLUDE_DIRS "include"
PRIV_REQUIRES
mercedes
analytics
insa
connectivity-manager
+91
View File
@@ -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"
}
]
}
]
}