Files

30 KiB
Raw Permalink Blame History

Описание компонента zh_menu

Общие сведения

zh_menu — это компонент для ESP-IDF, реализующий иерархическое многоуровневое меню с поддержкой вложенных подменю, конфигурируемых обратных вызовов и абстрактных команд навигации, независимых от конкретного устройства ввода (кнопок, энкодера, команд интерфейса).

Компонент управляет стеком уровней меню, обрабатывает команды навигации в фоновой задаче FreeRTOS и рассылает состояние через пользовательские колбэки, позволяя внешнему коду рендерить меню на дисплее или в консоли.

Основные возможности

  1. Вложенные подменю — поддержка глубины вложенности до 8 уровней (ZH_MENU_MAX_DEPTH) с автоматическим стеком навигации.
  2. Абстрактные команды навигации — ZH_MENU_ENTER, ZH_MENU_SHOW, ZH_MENU_UP, ZH_MENU_DOWN, ZH_MENU_SELECT, ZH_MENU_BACK не привязаны к конкретным устройствам ввода.
  3. Настраиваемые обратные вызовы — enter_cb, exit_cb и change_cb для реакции на вход в меню, выход из него и изменение текущей позиции.
  4. Выбираемые и простые элементы — флаг selectable разделяет элементы, входящие в режим ручной корректировки, и элементы с одноразовым действием.
  5. Циклическая навигация — опциональный режим зацикливания при перемещении UP / DOWN по элементам.
  6. Viewport scrolling — при заданном viewport_rows отображается только часть элементов; выделенный элемент всегда виден, индикаторы ↑ / ↓ (определяются пользователем в change_cb) показывают скрытые элементы сверху/снизу. Возвращается через zh_menu_get_view().
  7. Минимальный расход памяти — динамически выделяется только один хэндл; узлы меню (zh_menu_node_t) не копируются и используют пользовательскую память.
  8. Встроенная статистика — счётчик переполнений очереди обработки и 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 (показать все).

Потокобезопасность

  1. Спинлок (portMUX_TYPE) — защищает поля внутреннего хэндла (current_menu, current_index, is_activated, is_selected и другие) от одновременного доступа из фоновой задачи обработки и публичных функций API. В zh_menu_set() и zh_menu_get() доступ к полям обернут в taskENTER_CRITICAL() / taskEXIT_CRITICAL().
  2. Очередь FreeRTOS — команды навигации передаются из пользовательского контекста в фоновую задачу через очередь xQueueSend() с таймаутом 10 мс. Переполнение очереди фиксируется счётчиком queue_overflow_error.
  3. Фоновая задача — вся логика навигации и вызовы пользовательских колбэков (change_cb, enter_cb, exit_cb, item_cb) выполняются в отдельной задаче FreeRTOS, что исключает блокировки в вызывающем коде. Колбэки вызываются вне критической секции.
  4. Разделяемые ресурсы — задача обработки и очередь общие для всех инициализированных меню; их создание и удаление синхронизируется счётчиком _menu_counter.

Публичные функции не защищены мьютексом между вызовами из разных задач — их следует вызывать из одной задачи или обеспечивать внешнюю синхронизацию.

Алгоритмы

Viewport Scrolling

Когда viewport_rows > 0 и количество элементов на текущем уровне превышает viewport_rows, включается прокрутка:

  1. Инициализация — при ZH_MENU_ENTER, входе в подменю, возврате ZH_MENU_BACK и деактивации scroll_offset сбрасывается в 0.
  2. Корректировка окна — при каждом изменении 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.
  3. Получение окна — zh_menu_get_view() возвращает first_visible = scroll_offset и visible_count — количество строк, помещающихся в окне (не больше viewport_rows и не больше оставшихся элементов).
  4. Рендеринг — пользовательский код в change_cb вызывает zh_menu_get_view() и отображает только видимые строки. Индикаторы ↑ / ↓ определяются пользователем: ↑ — если scroll_offset > 0, ↓ — если first_visible + visible_count < total.

Навигация по уровням меню

При каждой команде навигации фоновая задача выполняет следующие шаги:

  1. Чтение команды из очереди и извлечение хэндла меню.
  2. taskENTER_CRITICAL() — блокировка спинлока для чтения текущего состояния.
  3. Выполнение перехода по 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.
  4. taskEXIT_CRITICAL() — разблокировка спинлока.
  5. Вызов накопленных колбэков вне критической секции.

Режим выбора (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 Ошибка инициализации ресурсов или задачи обработки

Ограничения

  1. Максимальная глубина вложенности — 8 уровней — ограничена константой ZH_MENU_MAX_DEPTH; при достижении лимита спуск в подменю не выполняется для предотвращения переполнения стека.
  2. Задача и очередь общие для всех меню — параметры задачи (приоритет, размер стека, размер очереди) берутся из конфигурации первого инициализированного меню и применяются ко всем последующим; для последующих меню они игнорируются.
  3. Узлы меню не копируются — пользователь обязан обеспечить время жизни массива zh_menu_node_t на протяжении всего времени работы меню.
  4. Ёмкость очереди по умолчанию равна 1 — при быстрой навигации возможны потери команд; факт потери фиксируется счётчиком queue_overflow_error.
  5. Колбэки вызываются вне критической секции — пользовательские функции (change_cb, enter_cb, exit_cb, item_cb) не должны выполнять длительные блокирующие операции.
  6. Размер аргумента ограничен 128 байтами — буфер arg в очереди команд имеет фиксированный размер ZH_MENU_ARG_MAX_SIZE.
  7. Публичные функции не защищены мьютексом — их следует вызывать из одной задачи или обеспечивать внешнюю синхронизацию при доступе из нескольких контекстов.
  8. 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"