1. Today's topic
ESP-IDF project structure: how to divide code into components, what belongs in main, how to write CMakeLists.txt, where to keep board pins and business logic, and where drivers belong. The previous lesson introduced architectural thinking. Today we turn that thinking into a real ESP-IDF project structure.
2. Why this matters
The project is already substantial:
ESP32
├─ UART0: CLI / console
├─ UART2: Quectel EC25
├─ traffic-light phase inputs
├─ camera / radar / Jetson power
├─ MQTT / TCP / UDP
├─ watchdog
├─ logs
├─ diagnostics
└─ future HIL tests using Raspberry PiKeeping everything in main.c or a few enormous .c files creates problems:
- it becomes hard to understand who is responsible for what;
- changing code becomes intimidating;
- tests are difficult to write;
- moving some logic to STM32 becomes difficult;
- cyclic dependencies appear;
- the CLI directly changes internal service variables;
- drivers start knowing about MQTT, phases and business logic.
3. What is an ESP-IDF component?
A component is a separate firmware module. Example structure:
components/
modem_service/
phase_detector/
input_driver/
transport_service/
board/A component usually contains:
component_name/
├─ CMakeLists.txt
├─ include/
│ └─ component_name.h
└─ component_name.cA minimal CMakeLists.txt:
idf_component_register(
SRCS "modem_service.c"
INCLUDE_DIRS "include"
)SRCS lists source files. INCLUDE_DIRS declares public headers. REQUIRES and PRIV_REQUIRES declare dependencies.
4. A good project structure
An unsuitable arrangement:
main/
├─ main.c
├─ modem.c
├─ mqtt.c
├─ adc.c
├─ cli.c
├─ phase.c
└─ utils.cA better arrangement:
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/Keep main small. Its job is to start the system, rather than contain the entire system. An unsuitable app_main.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));
}
}A better app_main.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. The board component
Keep pins and board-specific details separate:
components/
board/
CMakeLists.txt
include/
board_pins.h
board_config.h
board.cExample board_pins.h:
#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 33modem_service should not know that EC25 is connected to GPIO17/GPIO16. That belongs in the board configuration.
6. The input_driver component
The input driver should read physical levels without knowing about phases.
#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);The key point: input_driver does not know what a traffic-light phase is. It only knows input levels.
7. The phase_detector component
Write this component in plain C as far as possible:
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);Ideally, phase_detector should not depend on:
#include "driver/gpio.h"
#include "esp_log.h"
#include "mqtt_client.h"This makes it easy to test on a PC, run in CI, connect to HIL and port to STM32.
8. The modem_service component
Think of EC25 as a state machine:
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_ERRORPublic API:
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);Hide AT commands inside the service.
9. The transport_service component
Transport should not know about GPIO:
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 does not know which transport sends the event.
10. REQUIRES and PRIV_REQUIRES
Example:
idf_component_register(
SRCS "transport_service.c"
INCLUDE_DIRS "include"
REQUIRES app_events
PRIV_REQUIRES modem_service esp_timer
)A simplified rule:
- REQUIRES: dependencies exposed through the public header;
- PRIV_REQUIRES: dependencies used only by .c files.
Fewer public dependencies make a project easier to maintain.
11. sdkconfig.defaults
Keep the baseline settings in the repository:
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 is the local configuration result. sdkconfig.defaults is the project's baseline profile.
12. Common mistakes
- leaving all application logic in main;
- creating one enormous device_service component;
- allowing a driver to send MQTT messages;
- creating cyclic dependencies;
- hardcoding GPIO numbers in several places;
- placing internal headers in the public include/ directory.
13. Practical assignment
Create ARCHITECTURE_COMPONENTS.md:
# 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.Then create a minimal components/board component and connect it to main.
14. Brief recap
An ESP-IDF project should consist of small components with clear boundaries:
main/
app_main.c
components/
board/
app_events/
input_driver/
phase_detector/
modem_service/
transport_service/
cli_service/
health_service/
power_service/Exercise
A board revision changes the EC25 UART pins. State where the new pin numbers belong, what modem_service should retain, and how to verify that phase_detector can run on a PC.
Self-check criteria: Identify board configuration, modem_service protocol/state ownership, and at least two platform dependencies excluded from phase_detector.
Show the supplied answer
Change the pins in board configuration consumed by the lower layer. modem_service retains the AT protocol and state machine without hardcoded pins. phase_detector takes values and timestamps through its API without GPIO, MQTT or platform logging includes. Build it separately for a host test with synthetic inputs.