30 KiB
Описание компонента zh_menu
Общие сведения
zh_menu — это компонент для ESP-IDF, реализующий иерархическое многоуровневое меню с поддержкой вложенных подменю, конфигурируемых обратных вызовов и абстрактных команд навигации, независимых от конкретного устройства ввода (кнопок, энкодера, команд интерфейса).
Компонент управляет стеком уровней меню, обрабатывает команды навигации в фоновой задаче FreeRTOS и рассылает состояние через пользовательские колбэки, позволяя внешнему коду рендерить меню на дисплее или в консоли.
Основные возможности
- Вложенные подменю — поддержка глубины вложенности до 8 уровней (
ZH_MENU_MAX_DEPTH) с автоматическим стеком навигации. - Абстрактные команды навигации —
ZH_MENU_ENTER,ZH_MENU_SHOW,ZH_MENU_UP,ZH_MENU_DOWN,ZH_MENU_SELECT,ZH_MENU_BACKне привязаны к конкретным устройствам ввода. - Настраиваемые обратные вызовы —
enter_cb,exit_cbиchange_cbдля реакции на вход в меню, выход из него и изменение текущей позиции. - Выбираемые и простые элементы — флаг
selectableразделяет элементы, входящие в режим ручной корректировки, и элементы с одноразовым действием. - Циклическая навигация — опциональный режим зацикливания при перемещении
UP/DOWNпо элементам. - Viewport scrolling — при заданном
viewport_rowsотображается только часть элементов; выделенный элемент всегда виден, индикаторы↑/↓(определяются пользователем вchange_cb) показывают скрытые элементы сверху/снизу. Возвращается черезzh_menu_get_view(). - Минимальный расход памяти — динамически выделяется только один хэндл; узлы меню (
zh_menu_node_t) не копируются и используют пользовательскую память. - Встроенная статистика — счётчик переполнений очереди обработки и watermark свободного стека задачи.
Внутреннее устройство
Структура zh_menu_handle_t
Внутренняя структура хэндла меню (opaque-тип, доступен только через API компонента):
| Поле | Тип | Описание |
|---|---|---|
menu_stack |
zh_menu_node_t *[ZH_MENU_MAX_DEPTH] |
Стек пройденных уровней меню для навигации ZH_MENU_BACK |
current_menu |
zh_menu_node_t * |
Указатель на текущий активный уровень меню |
root |
zh_menu_node_t * |
Корневой узел дерева меню, установленный при инициализации |
change_cb |
void (*)(zh_menu_node_t *, uint8_t) |
Обратный вызов при изменении текущей позиции |
enter_cb |
void (*)(void *) |
Обратный вызов при входе в меню (ZH_MENU_ENTER) |
exit_cb |
void (*)(void *) |
Обратный вызов при выходе из меню (ZH_MENU_BACK с deepest level) |
loop |
bool |
Флаг циклической навигации по элементам |
menu_depth |
uint8_t |
Текущая глубина вложенности (0 — корневой уровень) |
current_index |
uint8_t |
Индекс выделенного элемента на текущем уровне |
viewport_rows |
uint8_t |
Количество видимых строк на экране (0 = показать все) |
scroll_offset |
uint8_t |
Индекс первой видимой строки в окне прокрутки |
is_activated |
bool |
Флаг активации меню (true после ZH_MENU_ENTER) |
is_selected |
bool |
Флаг состояния выбора (true — режим корректировки выбираемого элемента) |
lock |
portMUX_TYPE |
Спинлок для защиты полей от одновременного доступа |
Структура узла меню zh_menu_node_t
Описывает один элемент меню, определённый пользователем в статическом массиве:
| Поле | Тип | Описание |
|---|---|---|
label |
const char * |
Отображаемое имя элемента |
submenu |
struct zh_menu_node_t * |
Указатель на массив вложенных элементов или NULL |
item_numbers |
uint8_t |
Количество элементов в массиве submenu |
item_cb |
void (*)(void *) |
Обратный вызов при выборе элемента |
selectable |
bool |
Флаг: элемент может входить в режим ручной корректировки |
Пользователь обязан обеспечить время жизни массива узлов меню на протяжении всего времени работы меню — компонент не копирует узлы.
Конфигурация инициализации zh_menu_init_config_t
Передаётся в zh_menu_init() для настройки меню:
| Поле | Тип | Описание |
|---|---|---|
root |
zh_menu_node_t * |
Корневой узел дерева меню (must not be NULL) |
change_cb |
void (*)(zh_menu_node_t *, uint8_t) |
Колбэк изменения позиции (может быть NULL) |
enter_cb |
void (*)(void *) |
Колбэк входа в меню (может быть NULL) |
exit_cb |
void (*)(void *) |
Колбэк выхода из меню (может быть NULL) |
loop |
bool |
Флаг циклической навигации |
stack_size |
uint16_t |
Размер стека задачи обработки (минимум configMINIMAL_STACK_SIZE) |
task_priority |
uint8_t |
Приоритет задачи обработки (минимум 1) |
queue_size |
uint8_t |
Размер очереди команд (минимум 1) |
viewport_rows |
uint8_t |
Количество видимых строк на экране (0 = показать все) |
Статистика zh_menu_stats_t
Возвращается zh_menu_get_stats() для мониторинга:
| Поле | Тип | Описание |
|---|---|---|
queue_overflow_error |
uint32_t |
Счётчик переполнений очереди команд |
min_stack_size |
uint32_t |
Минимальный остаток свободного стека задачи (watermark) |
Видимое окно меню zh_menu_view_t
Возвращается zh_menu_get_view() для рендеринга только той части меню, которая помещается на экране:
| Поле | Тип | Описание |
|---|---|---|
first_visible |
uint8_t |
Индекс первой строки для отображения |
visible_count |
uint8_t |
Количество строк для отображения (может быть меньше viewport_rows) |
Заголовок (метка текущего узла меню) всегда отображается отдельно и не входит в окно прокрутки.
Задача обработки и очередь
Компонент создаёт фоновую задачу FreeRTOS (zh_menu) и очередь сообщений (_queue_handle), которые разделяются между всеми инициализированными меню. Параметры (приоритет, размер стека, размер очереди) берутся из конфигурации первого инициализированного меню и применяются ко всем последующим. При деинициализации последнего меню задача и очередь удаляются.
Очередь передаёт команды навигации из контекста вызова публичных функций в фоновую задачу:
| Поле | Тип | Описание |
|---|---|---|
command |
zh_menu_navigate_t |
Команда навигации |
handle |
zh_menu_handle_t * |
Указатель на хэндл меню |
arg_size |
uint32_t |
Размер аргумента в байтах |
arg |
uint8_t[ZH_MENU_ARG_MAX_SIZE] |
Буфер аргумента, передаваемого в пользовательские колбэки |
API (интерфейс)
Инициализация и освобождение
| Функция | Описание |
|---|---|
zh_menu_init() |
Создаёт хэндл меню, проверяет конфигурацию, выделяет память и готовит внутреннее состояние. Меню переходит в неактивное состояние до отправки команды ZH_MENU_ENTER. Требует, чтобы входной *handle указывал на NULL. |
zh_menu_deinit() |
Освобождает память хэндла и устанавливает указатель в NULL. При деинициализации последнего меню удаляет общую задачу обработки и очередь. |
Команды навигации
| Функция | Описание |
|---|---|
zh_menu_set() |
Отправляет команду навигации (ZH_MENU_ENTER, ZH_MENU_SHOW, ZH_MENU_UP, ZH_MENU_DOWN, ZH_MENU_SELECT, ZH_MENU_BACK) в фоновую задачу обработки. Невалидные команды в текущем состоянии игнорируются без ошибки. |
Получение состояния
| Функция | Описание |
|---|---|
zh_menu_get() |
Заполняет структуру zh_menu_status_t флагами активности меню и состояния выбора текущего элемента. |
zh_menu_get_view() |
Возвращает видимое окно текущего уровня меню (zh_menu_view_t): индекс первой строки и количество отображаемых строк. Вызывайте из change_cb для рендеринга только той части меню, которая помещается на экране. |
Статистика
| Функция | Описание |
|---|---|
zh_menu_get_stats() |
Возвращает read-only указатель на глобальную структуру статистики zh_menu_stats_t. Указатель действителен до вызова zh_menu_reset_stats(). |
zh_menu_reset_stats() |
Обнуляет все счётчики статистики: переполнения очереди и watermark стека. |
Типы
| Элемент | Описание |
|---|---|
zh_menu_handle_t |
Opaque-хэндл меню, представляющий состояние меню. Все операции выполняются через указатель на этот тип. |
zh_menu_navigate_t |
Перечисление команд навигации: ZH_MENU_ENTER, ZH_MENU_SHOW, ZH_MENU_UP, ZH_MENU_DOWN, ZH_MENU_SELECT, ZH_MENU_BACK, ZH_MENU_MAX. |
zh_menu_node_t |
Узел дерева меню, определённый пользователем в статическом массиве. |
zh_menu_init_config_t |
Конфигурация инициализации: корневой узел, колбэки, флаг циклической навигации, параметры задачи, размер viewport. |
zh_menu_status_t |
Текущее состояние меню: is_active и is_selected. |
zh_menu_view_t |
Видимое окно текущего уровня меню: first_visible и visible_count. |
zh_menu_stats_t |
Статистика: queue_overflow_error и min_stack_size. |
ZH_MENU_INIT_CONFIG_DEFAULT() |
Макрос для инициализации конфигурации по умолчанию: приоритет 1, стек configMINIMAL_STACK_SIZE, размер очереди 1, viewport_rows = 0 (показать все). |
Потокобезопасность
- Спинлок (
portMUX_TYPE) — защищает поля внутреннего хэндла (current_menu,current_index,is_activated,is_selectedи другие) от одновременного доступа из фоновой задачи обработки и публичных функций API. Вzh_menu_set()иzh_menu_get()доступ к полям обернут вtaskENTER_CRITICAL()/taskEXIT_CRITICAL(). - Очередь FreeRTOS — команды навигации передаются из пользовательского контекста в фоновую задачу через очередь
xQueueSend()с таймаутом 10 мс. Переполнение очереди фиксируется счётчикомqueue_overflow_error. - Фоновая задача — вся логика навигации и вызовы пользовательских колбэков (
change_cb,enter_cb,exit_cb,item_cb) выполняются в отдельной задаче FreeRTOS, что исключает блокировки в вызывающем коде. Колбэки вызываются вне критической секции. - Разделяемые ресурсы — задача обработки и очередь общие для всех инициализированных меню; их создание и удаление синхронизируется счётчиком
_menu_counter.
Публичные функции не защищены мьютексом между вызовами из разных задач — их следует вызывать из одной задачи или обеспечивать внешнюю синхронизацию.
Алгоритмы
Viewport Scrolling
Когда viewport_rows > 0 и количество элементов на текущем уровне превышает viewport_rows, включается прокрутка:
- Инициализация — при
ZH_MENU_ENTER, входе в подменю, возвратеZH_MENU_BACKи деактивацииscroll_offsetсбрасывается в 0. - Корректировка окна — при каждом изменении
current_index(командыUP/DOWN) вызывается_zh_menu_adjust_scroll():- Если выделенный элемент выше видимого окна (
current_index < scroll_offset), окно сдвигается вверх:scroll_offset = current_index. - Если выделенный элемент ниже видимого окна (
current_index >= scroll_offset + viewport_rows), окно сдвигается вниз:scroll_offset = current_index - viewport_rows + 1. - Окно не может выходить за пределы списка:
scroll_offsetне может быть большеtotal - viewport_rows.
- Если выделенный элемент выше видимого окна (
- Получение окна —
zh_menu_get_view()возвращаетfirst_visible = scroll_offsetиvisible_count— количество строк, помещающихся в окне (не большеviewport_rowsи не больше оставшихся элементов). - Рендеринг — пользовательский код в
change_cbвызываетzh_menu_get_view()и отображает только видимые строки. Индикаторы↑/↓определяются пользователем:↑— еслиscroll_offset > 0,↓— еслиfirst_visible + visible_count < total.
Навигация по уровням меню
При каждой команде навигации фоновая задача выполняет следующие шаги:
- Чтение команды из очереди и извлечение хэндла меню.
taskENTER_CRITICAL()— блокировка спинлока для чтения текущего состояния.- Выполнение перехода по
switch (command):ZH_MENU_ENTER— установкаis_activated = true,current_menu = root,current_index = 0, очистка стека. Вызовenter_cb.ZH_MENU_SHOW— принудительный вызовchange_cbдля рендеринга текущего состояния (только если меню активно и элемент не в режиме выбора).ZH_MENU_UP/ZH_MENU_DOWN— инкремент/декрементcurrent_indexс учётом циклической навигации (loop). При циклическом режиме переход с первого на последний элемент и наоборот.ZH_MENU_SELECT— если элементselectable, вход в режим выбора (is_selected = true); если элемент имеетsubmenu, спуск в подменю (сохранение текущего уровня вmenu_stack); вызовitem_cb.ZH_MENU_BACK— если в режиме выбора, выход из него; если есть предыдущий уровень в стеке, возврат (menu_depth--, восстановлениеcurrent_menuизmenu_stack); если стек пуст, деактивация меню и вызовexit_cb.
taskEXIT_CRITICAL()— разблокировка спинлока.- Вызов накопленных колбэков вне критической секции.
Режим выбора (is_selected)
Когда пользователь выделяет элемент с флагом selectable и отправляет ZH_MENU_SELECT, меню переходит в режим выбора:
- В этом режиме команды
UP,DOWN,SHOW,ENTERигнорируются. - Допустимы только
ZH_MENU_SELECT(повторный вызовitem_cb) иZH_MENU_BACK(выход из режима выбора). - Это позволяет использовать энкодер для ручной корректировки значения элемента без вмешательства навигации по меню.
Примеры использования
Базовый пример: создание меню с энкодером
#include "zh_menu.h"
#include "zh_encoder.h" // https://github.com/aZholtikov/zh_encoder
#define ENCODER_NUMBER 0x01
zh_encoder_handle_t *encoder_handle = NULL;
zh_menu_handle_t *menu_handle = NULL;
void zh_encoder_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data);
void zh_menu_enter_callback(void *arg);
void zh_menu_exit_callback(void *arg);
void zh_menu_change_callback(zh_menu_node_t *node, uint8_t index);
void zh_menu_selectable_callback(void *arg);
void zh_menu_simple_callback(void *args);
// Определение дерева меню
zh_menu_node_t main_menu = {
.label = "Main menu",
.submenu = (zh_menu_node_t[]){
{
.label = "Settings",
.submenu = (zh_menu_node_t[]){
{
.label = "Brightness",
.item_cb = &zh_menu_selectable_callback,
.selectable = true,
},
{
.label = "Volume",
.item_cb = &zh_menu_selectable_callback,
.selectable = true,
},
{
.label = "Back",
.item_cb = NULL, // нет колбэка для возврата
},
},
.item_numbers = 3,
},
{
.label = "Info",
.submenu = (zh_menu_node_t[]){
{
.label = "Version",
.item_cb = &zh_menu_simple_callback,
},
{
.label = "Back",
.item_cb = NULL,
},
},
.item_numbers = 2,
},
{
.label = "Exit",
.item_cb = NULL,
},
},
.item_numbers = 3,
};
void app_main(void)
{
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_event_handler_instance_register(
ZH_ENCODER, ESP_EVENT_ANY_ID, &zh_encoder_event_handler, NULL, NULL));
// Инициализация энкодера
zh_encoder_init_config_t encoder_config = ZH_ENCODER_INIT_CONFIG_DEFAULT();
encoder_config.a_gpio_number = GPIO_NUM_4;
encoder_config.b_gpio_number = GPIO_NUM_16;
encoder_config.s_gpio_number = GPIO_NUM_15;
encoder_config.encoder_number = ENCODER_NUMBER;
zh_encoder_init(&encoder_config, &encoder_handle);
// Инициализация меню
zh_menu_init_config_t menu_config = ZH_MENU_INIT_CONFIG_DEFAULT();
menu_config.root = &main_menu;
menu_config.loop = true;
menu_config.enter_cb = &zh_menu_enter_callback;
menu_config.exit_cb = &zh_menu_exit_callback;
menu_config.change_cb = &zh_menu_change_callback;
ESP_ERROR_CHECK(zh_menu_init(&menu_config, &menu_handle));
for (;;) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
// По завершении работы: zh_menu_deinit(&menu_handle);
}
// Обработчик событий энкодера
void zh_encoder_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
(void)arg;
zh_menu_status_t status = {0};
zh_menu_get(&menu_handle, &status);
if (event_id == ZH_BUTTON_EVENT) {
zh_encoder_button_event_on_isr_t *btn = event_data;
if (btn->button_status == true) {
if (!status.is_active) {
zh_menu_set(&menu_handle, ZH_MENU_ENTER, NULL, 0);
} else if (!status.is_selected) {
zh_menu_set(&menu_handle, ZH_MENU_SELECT, NULL, 0);
} else {
zh_menu_set(&menu_handle, ZH_MENU_BACK, NULL, 0);
}
}
} else if (event_id == ZH_ENCODER_EVENT && status.is_active) {
zh_encoder_event_on_isr_t *enc = event_data;
if (status.is_selected) {
zh_menu_set(&menu_handle, ZH_MENU_SELECT, enc, sizeof(zh_encoder_event_on_isr_t));
} else {
zh_menu_navigate_t cmd = enc->encoder_status ? ZH_MENU_UP : ZH_MENU_DOWN;
zh_menu_set(&menu_handle, cmd, NULL, 0);
}
}
}
void zh_menu_enter_callback(void *arg)
{
(void)arg;
printf("Menu activated.\n");
}
void zh_menu_exit_callback(void *arg)
{
(void)arg;
printf("Menu deactivated.\n");
}
void zh_menu_change_callback(zh_menu_node_t *node, uint8_t index)
{
printf("\n=== %s ===\n", node->label);
for (uint8_t i = 0; i < node->item_numbers; ++i) {
printf("%c %s\n", i == index ? '>' : ' ', node->submenu[i].label);
}
fflush(stdout);
}
void zh_menu_selectable_callback(void *arg)
{
if (arg != NULL) {
zh_encoder_event_on_isr_t *enc = arg;
printf("Adjustable value changed by encoder %d: %0.0f\n",
enc->encoder_number, enc->encoder_position);
}
}
void zh_menu_simple_callback(void *args)
{
(void)args;
printf("Simple item action performed.\n");
}
Мониторинг статистики
#include "zh_menu.h"
zh_menu_handle_t *menu_handle = NULL;
void app_main(void)
{
zh_menu_init_config_t config = ZH_MENU_INIT_CONFIG_DEFAULT();
config.root = /* корневой узел меню */ NULL;
zh_menu_init(&config, &menu_handle);
for (;;) {
const zh_menu_stats_t *stats = zh_menu_get_stats();
if (stats->queue_overflow_error > 0) {
printf("Queue overflow: %lu\n", (unsigned long)stats->queue_overflow_error);
}
if (stats->min_stack_size < 512) {
printf("Low stack: %lu\n", (unsigned long)stats->min_stack_size);
}
zh_menu_reset_stats();
vTaskDelay(pdMS_TO_TICKS(60000));
}
// По завершении работы: zh_menu_deinit(&menu_handle);
}
Viewport Scrolling
#include "zh_menu.h"
zh_menu_handle_t *menu_handle = NULL;
void zh_menu_change_callback(zh_menu_node_t *node, uint8_t index)
{
zh_menu_view_t view = {0};
zh_menu_get_view(&menu_handle, &view);
printf("\n=== %s ===\n", node->label);
// Индикатор прокрутки вверх
if (view.first_visible > 0) {
printf(" ↑\n");
}
// Отображение только видимых строк
for (uint8_t i = 0; i < view.visible_count; ++i) {
uint8_t item_idx = view.first_visible + i;
printf("%c %s\n", item_idx == index ? '>' : ' ', node->submenu[item_idx].label);
}
// Индикатор прокрутки вниз
if (view.first_visible + view.visible_count < node->item_numbers) {
printf(" ↓\n");
}
fflush(stdout);
}
void app_main(void)
{
zh_menu_init_config_t menu_config = ZH_MENU_INIT_CONFIG_DEFAULT();
menu_config.root = &main_menu;
menu_config.viewport_rows = 5; // Показывать по 5 строк
menu_config.change_cb = &zh_menu_change_callback;
ESP_ERROR_CHECK(zh_menu_init(&menu_config, &menu_handle));
// ...
}
Логирование
Компонент использует ESP-IDF логирование с тегом "zh_menu". Сообщения об ошибках включают имя файла, номер строки и имя кода ошибки.
esp_log_level_set("zh_menu", ESP_LOG_ERROR);
Ошибки
| Код | Описание |
|---|---|
ESP_OK |
Операция выполнена успешно |
ESP_ERR_INVALID_ARG |
NULL-аргументы, неверные параметры конфигурации (приоритет, размер стека, размер очереди, корневой узел), команда >= ZH_MENU_MAX, размер аргумента превышает лимит |
ESP_ERR_INVALID_STATE |
Меню по входному хэндлу уже инициализировано (*handle != NULL) |
ESP_ERR_NO_MEM |
Не удалось выделить память под хэндл меню |
ESP_FAIL |
Ошибка инициализации ресурсов или задачи обработки |
Ограничения
- Максимальная глубина вложенности — 8 уровней — ограничена константой
ZH_MENU_MAX_DEPTH; при достижении лимита спуск в подменю не выполняется для предотвращения переполнения стека. - Задача и очередь общие для всех меню — параметры задачи (приоритет, размер стека, размер очереди) берутся из конфигурации первого инициализированного меню и применяются ко всем последующим; для последующих меню они игнорируются.
- Узлы меню не копируются — пользователь обязан обеспечить время жизни массива
zh_menu_node_tна протяжении всего времени работы меню. - Ёмкость очереди по умолчанию равна 1 — при быстрой навигации возможны потери команд; факт потери фиксируется счётчиком
queue_overflow_error. - Колбэки вызываются вне критической секции — пользовательские функции (
change_cb,enter_cb,exit_cb,item_cb) не должны выполнять длительные блокирующие операции. - Размер аргумента ограничен 128 байтами — буфер
argв очереди команд имеет фиксированный размерZH_MENU_ARG_MAX_SIZE. - Публичные функции не защищены мьютексом — их следует вызывать из одной задачи или обеспечивать внешнюю синхронизацию при доступе из нескольких контекстов.
- Viewport_rows = 0 означает «показать все» — при
viewport_rows == 0прокрутка отключена иzh_menu_get_view()возвращает все элементы меню.
Требования для сборки
- ESP-IDF версии 5.0 или выше — задачи FreeRTOS, очереди, спинлоки.
- FreeRTOS — фоновая задача обработки команд меню.
Установка
cd ваш_проект/components
git clone https://github.com/aZholtikov/zh_menu
#include "zh_menu.h"