1. Тема дня

Структура ESP-IDF проекта: как правильно делить код на components, что класть в main, как оформлять CMakeLists.txt, где хранить пины платы, где держать бизнес-логику, а где - драйверы. Предыдущий урок был про архитектурное мышление. Сегодня переводим это в реальную структуру ESP-IDF проекта.

2. Зачем это нужно

Проект уже не маленький:

text
ESP32
 ├─ UART0: CLI / console
 ├─ UART2: Quectel EC25
 ├─ входы фаз светофора
 ├─ питание камеры / радара / Jetson
 ├─ MQTT / TCP / UDP
 ├─ watchdog
 ├─ логи
 ├─ диагностика
 └─ будущие HIL-тесты через Raspberry Pi

Если всё лежит в main.c или в нескольких огромных .c-файлах, появляются проблемы:

  • сложно понять, кто за что отвечает;
  • страшно менять код;
  • сложно писать тесты;
  • тяжело переносить часть логики на STM32;
  • появляются циклические зависимости;
  • CLI напрямую меняет внутренние переменные сервисов;
  • драйверы начинают знать про MQTT, фазы и бизнес-логику.

3. Что такое компонент ESP-IDF

Компонент - отдельный модуль прошивки. Пример структуры:

text
components/
  modem_service/
  phase_detector/
  input_driver/
  transport_service/
  board/

Обычно компонент содержит:

text
component_name/
 ├─ CMakeLists.txt
 ├─ include/
 │   └─ component_name.h
 └─ component_name.c

Минимальный CMakeLists.txt:

cmake
idf_component_register(
    SRCS "modem_service.c"
    INCLUDE_DIRS "include"
)

SRCS - исходные файлы. INCLUDE_DIRS - публичные заголовки. REQUIRES и PRIV_REQUIRES - зависимости.

4. Хорошая структура проекта

Плохой вариант:

text
main/
 ├─ main.c
 ├─ modem.c
 ├─ mqtt.c
 ├─ adc.c
 ├─ cli.c
 ├─ phase.c
 └─ utils.c

Лучше:

text
project/
 ├─ CMakeLists.txt
 ├─ sdkconfig.defaults
 ├─ partitions.csv
 ├─ main/
 │   ├─ CMakeLists.txt
 │   └─ app_main.c
 └─ components/
     ├─ board/
     ├─ app_events/
     ├─ input_driver/
     ├─ phase_detector/
     ├─ modem_service/
     ├─ transport_service/
     ├─ cli_service/
     ├─ health_service/
     └─ power_service/

main должен быть тонким. Его задача - запустить систему, а не содержать всю систему. Плохой app_main.c:

c
void app_main(void)
{
    init_gpio();
    init_uart();
    init_modem();
    while (1) {
        read_inputs();
        detect_phase();
        send_mqtt();
        check_cli();
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

Хороший app_main.c:

c
void app_main(void)
{
    board_init();
    app_events_init();
    input_driver_init();
    phase_detector_init();
    modem_service_init();
    transport_service_init();
    power_service_init();
    cli_service_init();
    health_service_init();
    ESP_LOGI("APP", "System started");
}

5. Компонент board

Пины и особенности платы лучше хранить отдельно:

text
components/
  board/
    CMakeLists.txt
    include/
      board_pins.h
      board_config.h
    board.c

Пример board_pins.h:

c
#pragma once
#define BOARD_UART_CONSOLE_NUM      UART_NUM_0
#define BOARD_UART_CONSOLE_TX_GPIO  1
#define BOARD_UART_CONSOLE_RX_GPIO  3
#define BOARD_UART_MODEM_NUM        UART_NUM_2
#define BOARD_UART_MODEM_TX_GPIO    17
#define BOARD_UART_MODEM_RX_GPIO    16
#define BOARD_SYNC_OUT1_GPIO        25
#define BOARD_SYNC_OUT2_GPIO        14
#define BOARD_SYNC_PPS_GPIO         13
#define BOARD_TEMP_ONEWIRE_GPIO     33

modem_service не должен знать, что EC25 сидит на GPIO17/GPIO16. Это должна знать board- конфигурация.

6. Компонент input_driver

Драйвер входов должен читать физические уровни, но не знать про фазы.

c
#pragma once
#include <stdint.h>
#include <stdbool.h>
typedef enum {
    INPUT_CH_RED = 0,
    INPUT_CH_YELLOW,
    INPUT_CH_GREEN,
    INPUT_CH_COUNT
} input_channel_t;
typedef struct {
    bool level[INPUT_CH_COUNT];
    uint32_t raw_mask;
    int64_t timestamp_us;
} input_snapshot_t;
void input_driver_init(void);
bool input_driver_get_snapshot(input_snapshot_t *out);

Важная мысль: input_driver не знает, что такое “фаза светофора”. Он знает только уровни входов.

7. Компонент phase_detector

Этот компонент желательно писать максимально чистым C:

c
typedef enum {
    TRAFFIC_PHASE_UNKNOWN = 0,
    TRAFFIC_PHASE_OFF,
    TRAFFIC_PHASE_RED,
    TRAFFIC_PHASE_YELLOW,
    TRAFFIC_PHASE_GREEN,
    TRAFFIC_PHASE_CONFLICT
} traffic_phase_t;
typedef struct {
    bool red;
    bool yellow;
    bool green;
    int64_t timestamp_us;
} phase_input_t;
typedef struct {
    traffic_phase_t old_phase;
    traffic_phase_t new_phase;
    int64_t timestamp_us;
    bool changed;
} phase_event_t;
void phase_detector_init(void);
phase_event_t phase_detector_update(const phase_input_t *input);

Идеальный phase_detector не должен зависеть от:

c
#include "driver/gpio.h"
#include "esp_log.h"
#include "mqtt_client.h"

Тогда его легко тестировать на ПК, гонять в CI, подключать к HIL и переносить на STM32.

8. Компонент modem_service

EC25 лучше мыслить как state machine:

text
MODEM_STATE_OFF
MODEM_STATE_BOOTING
MODEM_STATE_AT_SYNC
MODEM_STATE_SIM_CHECK
MODEM_STATE_NETWORK_WAIT
MODEM_STATE_PDP_ACTIVATE
MODEM_STATE_MQTT_OPEN
MODEM_STATE_MQTT_CONNECTED
MODEM_STATE_READY
MODEM_STATE_ERROR

Публичный API:

c
typedef enum {
    MODEM_STATUS_NOT_READY = 0,
    MODEM_STATUS_READY,
    MODEM_STATUS_ERROR
} modem_status_t;
void modem_service_init(void);
modem_status_t modem_service_get_status(void);
bool modem_service_publish(const char *topic, const char *payload);

AT-команды должны быть скрыты внутри сервиса.

9. Компонент transport_service

Транспорт не должен знать про GPIO:

c
typedef enum {
    TRANSPORT_KIND_MQTT,
    TRANSPORT_KIND_TCP,
    TRANSPORT_KIND_UDP
} transport_kind_t;
typedef struct {
    int64_t timestamp_us;
    uint32_t sequence_id;
    uint8_t event_type;
    uint8_t payload[64];
    uint16_t payload_len;
} transport_event_t;
void transport_service_init(void);
bool transport_service_send_event(const transport_event_t *event);

phase_detector не знает, чем отправляется событие.

10. REQUIRES и PRIV_REQUIRES

Пример:

cmake
idf_component_register(
    SRCS "transport_service.c"
    INCLUDE_DIRS "include"
    REQUIRES app_events
    PRIV_REQUIRES modem_service esp_timer
)

Упрощённое правило:

  • REQUIRES - то, что видно через публичный заголовок;
  • PRIV_REQUIRES - то, что нужно только .c-файлам.

Чем меньше публичных зависимостей, тем легче поддерживать проект.

11. sdkconfig.defaults

Полезно хранить базовые настройки в репозитории:

text
CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_TASK_WDT_EN=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"

sdkconfig - локальный результат конфигурации. sdkconfig.defaults - базовый профиль проекта.

12. Типичные ошибки

  • оставлять всю реальную логику в main;
  • делать один огромный компонент device_service;
  • позволять драйверу отправлять MQTT;
  • создавать циклические зависимости;
  • хардкодить GPIO в разных местах;
  • класть внутренние заголовки в публичный include/.

13. Практическое задание

Создай ARCHITECTURE_COMPONENTS.md:

markdown
# ESP-IDF component layout
## main
Only starts the firmware and initializes components.
## components/board
Board-specific pins and hardware revision constants.
## components/app_events
Common event types and event queue.
## components/input_driver
Reads physical traffic-light inputs.
Does not know about phase logic.
## components/phase_detector
Converts stable input levels into traffic phase events.
No ESP-IDF hardware dependencies.
## components/modem_service
Controls Quectel EC25 through UART AT commands.
Owns modem state machine.
## components/transport_service
Sends events through UDP/TCP/MQTT.
Does not read GPIO or ADC.
## Dependency rules
1. Drivers do not depend on services.
2. Services communicate through app_events.
3. Board-specific constants are only in board.
4. Phase detector should be testable on PC.

Затем создай минимальный компонент components/board и подключи его к main.

14. Короткий итог

ESP-IDF проект должен быть набором маленьких компонентов с понятными границами:

text
main/
  app_main.c
components/
  board/
  app_events/
  input_driver/
  phase_detector/
  modem_service/
  transport_service/
  cli_service/
  health_service/
  power_service/
Публичный transport_service.h включает app_events.h, а modem_service.h используется только в transport_service.c. Как объявить зависимости?

Задание

EC25 меняет UART-пины на новой ревизии платы. Укажите, где хранить новые номера пинов, что должно остаться в modem_service и как проверить, что phase_detector переносим на ПК.

Критерии самопроверки: Назовите board, протокол/автомат modem_service и минимум две платформенные зависимости, исключаемые из phase_detector.

Показать ответ автора

Пины меняются в board-конфигурации, которую получает нижний слой. modem_service сохраняет протокол AT и автомат состояний; он не хардкодит пины. phase_detector принимает значения/временные метки через свой API и не включает GPIO, MQTT или платформенное логирование. Соберите его отдельно для host-теста с искусственными входами.