ESPHome 架构与设计思路深度解析
Contents
ESPHome 架构与设计思路深度解析
概述
ESPHome 是一个通过 YAML 配置自动生成 C++ 固件的 IoT 框架,专为 ESP32/ESP8266 等 MCU 设计,与 Home Assistant 深度集成。其核心思想是:用户编写 YAML 配置 → Python 代码生成器产出 C++ 源码 → 交叉编译后烧录到 MCU。这种"配置即代码"的模式让不熟悉嵌入式开发的用户也能快速构建智能家居设备。
ESPHome 的架构可以分为两大子系统:
- Python 侧代码生成器 — 运行在开发机/服务器上,解析 YAML、验证配置、调度代码生成、输出
main.cpp - C++ 侧运行时框架 — 运行在 MCU 上,提供组件生命周期管理、调度器、自动化框架、Native API 通信等基础设施
本文基于 ESPHome 源码,深入剖析其设计思路和实现细节。
一、整体架构总览
| |
二、Python 侧代码生成器
2.1 核心流程
| |
2.2 Python 侧的 async 从何而来
ESPHome 的 Python 侧使用了一套自建的伪协程系统,而非 Python 标准库的 asyncio 事件循环。这套系统定义在 esphome/coroutine.py 中。
核心组件包括:
FakeEventLoop:模拟事件循环,使用优先队列(heapq)调度任务,而非真正的异步 I/O@coroutine装饰器:将普通函数/生成器函数转为 ESPHome 协程@coroutine_with_priority(priority):带优先级的协程装饰器CoroPriority枚举:定义代码生成阶段的执行优先级
coroutine.py 开头的文档注释清晰地解释了设计动机:
The Problem: When running the code generation, components can depend on variables being registered. For example, an i2c-based sensor would need the i2c bus component to first be declared before the codegen can emit code using that variable (or otherwise the C++ won’t compile).
ESPHome’s codegen system solves this by using coroutine-like methods. When a component depends on a variable, it waits for it using
await cg.get_variable(). If the variable hasn’t been registered yet, control will be yielded back to another component until the variable is registered. This leads to a topological sort, solving the dependency problem.Importantly, ESPHome only uses the coroutine syntax, no actual asyncio event loop is running in the background. This is so that we can ensure the order of execution is constant for the same YAML configuration, thus main.cpp only has to be recompiled if the configuration actually changes.
2.3 为什么代码生成器需要 async
核心原因有三个:
1) 依赖解析与拓扑排序
代码生成的核心问题是组件间存在依赖关系。例如:
- 一个 I2C 传感器需要先声明 I2C 总线变量
- 一个 GPIO 扩展器上的引脚需要先声明扩展器本身
await cg.get_variable(id) 的工作方式:
- 如果目标变量已注册,立即返回
- 如果未注册,
yield回FakeEventLoop,让其他协程继续执行 - 其他协程注册了该变量后,被阻塞的协程恢复执行
这本质上是一个协作式调度,实现了组件间的拓扑排序。
2) 确定性输出
ESPHome 故意不使用真正的 asyncio 事件循环。FakeEventLoop.flush_tasks() 的执行是确定性的——相同的 YAML 配置永远产生相同的 main.cpp。这使得 ESPHome 可以判断配置是否真的发生了变化,避免不必要的重新编译。
3) 优先级控制
CoroPriority 定义了从 EARLY_INIT(1100) 到 FINAL(-1000) 的优先级层次:
| 优先级 | 值 | 示例组件 |
|---|---|---|
| EARLY_INIT | 1100 | logger |
| PLATFORM | 1000 | esp32, esp8266, rp2040 |
| NETWORK | 201 | network |
| NETWORK_TRANSPORT | 200 | async_tcp |
| DIAGNOSTICS | 90 | esp32_ble_tracker |
| STATUS | 80 | status_led |
| WEB_SERVER_BASE | 65 | web_server_base |
| CAPTIVE_PORTAL | 64 | captive_portal |
| COMMUNICATION | 60 | wifi, ethernet |
| NETWORK_SERVICES | 55 | api, ota |
| OTA_UPDATES | 54 | ota |
| WEB_SERVER_OTA | 52 | web_server (OTA) |
| PREFERENCES | 51 | preferences |
| APPLICATION | 50 | 各实体基类 (sensor, switch, light…) |
| WEB | 40 | web_server |
| AUTOMATION | 30 | automation |
| BUS | 1 | i2c |
| COMPONENT | 0 | 默认优先级 |
| LATE | -100 | globals |
| WORKAROUNDS | -999 | 组件兼容性补丁 |
| FINAL | -1000 | add_includes, 平台定义 |
高优先级的协程先执行,确保基础设施(如平台初始化、网络、总线)在依赖它们的组件之前完成代码生成。
2.4 代码生成引擎 (cpp_generator.py)
cpp_generator.py 是一个精巧的C++ 代码模板引擎,核心设计是 MockObj——一个用 Python 对象模拟 C++ 表达式的系统:
| |
MockObj 通过 Python 的魔术方法实现了运算符重载:
| |
Pvariable() 函数尤其重要——对于 new 表达式,它使用placement new将对象分配到静态存储中,避免在嵌入式设备上产生堆碎片:
| |
其中 {component_ns} 是从类型中提取的组件命名空间(如 sensor、logger),{id} 是变量 ID。例如 my_sensor 的存储名为 sensor__my_sensor__pstorage。
2.5 CORE 全局状态对象
esphome/core/__init__.py 中的 CORE 对象是代码生成期间的全局上下文,保存了所有中间状态:
CORE.config— 解析后的 YAML 配置CORE.data— 组件间共享的临时数据CORE.cpp_global_section— 全局 C++ 代码段CORE.defines— 条件编译宏定义CORE.variables— 已注册的变量映射CORE.component_ids— 组件 ID 追踪
CORE.add() 将表达式添加到 setup() 函数体中,CORE.add_global() 添加到全局作用域。
三、C++ 侧运行时框架
3.1 核心基类体系
| |
3.2 各基类的作用
Component — 组件生命周期
Component 是所有 MCU 端组件的基类,定义了统一的生命周期:
- CONSTRUCTION → SETUP → LOOP → LOOP_DONE (或 FAILED)
setup()只执行一次,按setup_priority排序loop()在主循环中反复调用- 组件可以通过
disable_loop()退出循环以节省 CPU
setup_priority 命名空间定义了 C++ 端的初始化优先级,与 Python 端的 CoroPriority 对应:
| 优先级 | 值 | 含义 |
|---|---|---|
| BUS | 1000 | 通信总线 (I2C/SPI) |
| IO | 900 | GPIO 扩展器 |
| HARDWARE | 800 | 硬件组件 |
| DATA | 600 | 直连传感器 (默认) |
| PROCESSOR | 400 | 数据处理器 (display) |
| BLUETOOTH | 350 | 蓝牙组件 |
| AFTER_BLUETOOTH | 300 | 蓝牙后初始化 |
| WIFI | 250 | WiFi |
| ETHERNET | 250 | 以太网 |
| BEFORE_CONNECTION | 220 | 连接前初始化 |
| AFTER_WIFI | 200 | WiFi 后初始化 |
| AFTER_CONNECTION | 100 | 连接建立后 |
| LATE | -100 | 延迟初始化 |
PollingComponent — 轮询组件
对于周期性检查状态的传感器,PollingComponent 封装了定时逻辑:
- 开发者只需实现
update()方法 - 框架自动通过
set_interval()按配置的update_interval调用
EntityBase — 实体属性
EntityBase 是所有"实体"(sensor, switch, light 等)的属性基类,管理:
- 名称 (
name_)、对象 ID 哈希 (object_id_hash_) - 设备类别 (
device_class_idx_)、单位 (uom_idx_)、图标 (icon_idx_) — 使用索引而非字符串,节省 RAM internal、disabled_by_default、entity_category等标志 — 位域打包为 1 字节configure_entity_()方法:被代码生成器调用,一次性设置所有属性
StatefulEntityBase<T> — 有状态实体
模板化的有状态实体基类,管理状态变化和回调分发。注意:并非所有实体类型都继承 StatefulEntityBase,例如 Sensor 直接继承 EntityBase 并自行管理 float state,而 BinarySensor 则继承 StatefulEntityBase<bool>:
BinarySensor : StatefulEntityBase<bool>TextSensor : StatefulEntityBase<std::string>Sensor : EntityBase(自行管理float state,未使用StatefulEntityBase<float>)
回调分为两级:
state_callbacks_:仅在新值有效且非首次设置(或配置了触发首次)时触发full_state_callbacks_:每次变化都触发,携带optional<T>旧值和新值
Controller — 状态观察者
Controller 是观察者模式的基类,通过 X-macro 从 entity_types.h 生成虚方法:
| |
APIServer 和 WebServer 都继承 Controller,重写这些虚方法来推送状态更新。
Application — 全局管理器
Application 是全局单例(App),管理所有组件和实体的注册与调度。其核心设计:
- 分区向量:
looping_components_分为[active | inactive]两段,避免循环时检查标志 - 编译期检测:
HasLoopOverride<T>通过 SFINAE 检测T是否重写了loop() - 模板化注册:
register_component_<T>()在编译期决定组件是否参与循环
| |
3.3 X-macro 实体类型系统
ESPHome 使用 X-macro 技术(entity_types.h)来消除大量重复代码。该文件被多次 include,每次定义不同的宏来生成不同的代码:
| |
在 application.h 中用于生成注册方法:
| |
在 controller.h 中生成虚方法,在 entity_base.h 中生成查找表索引等。
3.4 Scheduler — 调度器
Scheduler(esphome/core/scheduler.h)是 MCU 端的定时任务管理器,提供:
set_timeout()— 一次性定时set_interval()— 周期性定时defer()— 延迟到下一个 loop() 执行cancel_timeout()/cancel_interval()— 取消定时
调度器在每次 Application::loop() 中被调用,按时间戳排序执行到期的任务。这提供了类似 JavaScript 的 setTimeout/setInterval 语义,但不保证精确时序——因为嵌入式主循环可能被阻塞。
3.5 Automation 框架
ESPHome 的自动化系统(esphome/core/automation.h + esphome/core/base_automation.h)是声明式的触发-动作系统,用于实现事件驱动的控制逻辑。
3.5.1 核心架构
| |
核心类:
Trigger<Ts...>:触发器,持有Automation*指针,trigger()激活绑定的动作链Automation<Ts...>:绑定 Trigger 和 ActionList,trigger()转发到ActionList::play()ActionList<Ts...>:管理 Action 单向链表,play()调用链头的play_complex()Action<Ts...>:动作基类,通过next_指针形成链表
3.5.2 Action 的三个核心方法
| |
三个方法的职责:
| 方法 | 调用者 | 职责 |
|---|---|---|
play_complex() | 上一个 Action 的 play_next_() 或 ActionList::play() | 运行计数管理 + 调度 play() 和 play_next_() |
play() | play_complex() | 执行动作的具体逻辑(纯虚函数,子类必须实现) |
play_next_() | play_complex() | 检查运行状态,将参数透传给链中下一个 Action |
3.5.3 调用链的形成过程
1. 构建阶段(setup 时由代码生成器构建):
| |
2. 运行时调用链:
| |
Trigger::trigger()→Automation::trigger()→ActionList::play()这三层转发全部被ESPHOME_ALWAYS_INLINE(__attribute__((always_inline)))强制内联,编译后等价于直接调用第一个 Action 的play_complex()。
3. 参数透传:触发器参数 Ts... 完整无损地从链头传到链尾,每次都是 const 引用传递。对于需要延迟调用的场景(如 DelayAction),参数被按值捕获到 lambda 中,因为原始引用可能在延迟后失效。
3.5.4 同步动作 vs 异步动作
模式 A:同步动作(最常见)— 只实现 play(),不重写 play_complex()
基类的 play_complex() 在 play() 返回后立即调用 play_next_(),动作链顺序执行:
| |
模式 B:异步/复合动作 — 重写 play_complex(),延迟调用 play_next_()
这些动作不能同步完成,需要在某个条件满足后才继续链:
| |
| |
3.5.5 ContinuationAction — 子链回到主链的桥梁
分支动作(If/While/Repeat)的子链末尾都有一个 ContinuationAction,其 play() 调用父动作的 play_next_(),使控制流从子链无缝回到主链:
| |
IfAction 的链结构:
| |
WhileAction 的循环机制:子链末尾的 WhileLoopContinuation 检查条件——条件为 true 则重新执行子链,为 false 则调用 parent_->play_next_() 退出循环:
| |
RepeatAction 的参数注入:子链类型是 ActionList<uint32_t, Ts...>,每次迭代将当前迭代号作为第一个参数传入,子链中的动作可以使用这个迭代号。
3.5.6 异步实现机制
ESPHome 不使用 coroutine/yield,异步通过两种机制实现:
| 机制 | 使用者 | 原理 |
|---|---|---|
| Scheduler 定时器 | DelayAction | play_complex() 注册定时器 → 等待 → 定时器回调 play_next_() |
| Component::loop() 轮询 | WaitUntilAction, ScriptWaitAction | play_complex() 存入队列 + enable_loop() → 每 loop() 检查条件 → 条件满足时 play_next_tuple_() + disable_loop() |
WaitUntilAction 继承 Component 以使用 loop() 机制,使用队列存储等待中的参数(支持并发),按需启用/禁用 loop 以避免空转开销:
| |
3.5.7 num_running_ 并发控制
num_running_ 计数器支持同一动作链的多次并行触发。例如,一个按钮的 on_press 触发器被快速连按两次:
| |
每次 play_next_() 递减计数,stop_complex() 直接将 num_running_ 置 0 中止所有实例。DelayAction 使用 skip_cancel 参数控制并行实例的行为——当 num_running_ > 1 时,新延迟不会取消正在运行的旧延迟。
3.5.8 TemplatableValue / TemplatableFn — 4 字节优化
TEMPLATABLE_VALUE(type, name) 宏为 Action 子类生成"可模板化"的值成员,支持常量值或 lambda 表达式:
| |
TemplatableStorage 根据类型自动选择存储方式:
- Trivially copyable 类型(int, float, bool 等)→
TemplatableFn:仅 4 字节,只存函数指针 - 非 trivial 类型(std::string 等)→
TemplatableValue:8 字节,存值或函数指针
Python 代码生成器将常量包装为无状态 lambda(可转为函数指针),避免 std::function 的 32 字节开销。LightControlAction 更进一步,将所有字段设置打包成单个 ApplyFn 函数指针,无论配置了多少字段,Action 对象始终只占 8 字节。
3.5.9 TriggerForwarder — 回调式自动化构建
新式自动化构建不再创建 Trigger 对象,而是直接将 Automation 注册为回调:
| |
特化前向器提供条件触发:
TriggerOnTrueForwarder:仅当 bool 参数为 true 时触发TriggerOnFalseForwarder:仅当 bool 参数为 false 时触发
四、Native API 通信
4.1 架构概览
ESPHome 与 Home Assistant 通过 Native API 通信(TCP 端口 6053),使用自定义的 Protobuf 协议。API 组件位于 esphome/components/api/。
| |
4.2 APIServer — 服务端入口
APIServer 继承 Component 和 Controller(条件编译时还继承 camera::CameraListener):
| |
当实体状态变化时,APIServer 的 on_*_update() 回调被触发,将新状态推送到所有订阅了该实体的客户端。
4.3 APIConnection — 客户端连接
APIConnection 代表一个与 Home Assistant 的连接,负责:
- 消息读写:基于帧协议的异步消息收发
- 实体发现:响应
list_entities_*请求,发送实体的名称、类型、属性 - 状态订阅:响应
subscribe_states,在状态变化时推送 - 命令下发:处理
switch_command、light_command等控制命令 - 日志流:支持
subscribe_logs实时推送日志 - 蓝牙代理:支持 HA 通过 ESPHome 代理蓝牙设备
- Voice Assistant:语音助手支持
连接数受 MAX_API_CONNECTIONS 限制,该值由 Python 代码生成器根据目标平台动态定义(ESP8266 默认 4,ESP32 默认 5,Host 默认 8),使用 std::array<unique_ptr<APIConnection>, N> 管理。
4.4 自定义 Protobuf 编解码
ESPHome 没有使用 Google protobuf 库(太重),而是实现了自己的轻量级编解码器(proto.h):
- 支持 varint、zigzag 编码
- 自动生成的
api_pb2.h/.cpp:消息序列化/反序列化 - 自动生成的
api_pb2_service.h/.cpp:服务端/客户端 stub
script/api_protobuf/api_protobuf.py 从 api.proto 生成这些文件。
4.5 帧层协议与加密
帧层协议(api_frame_helper.h)负责消息分帧和批处理:
- PlaintextFrameHelper:明文传输
- NoiseFrameHelper:使用 Noise Protocol Framework 加密
加密配置通过 YAML 中的 api.encryption.key 设置,PSK 存储在非易失性存储中。
4.6 api.proto 中的核心 RPC 方法
| |
五、多 MCU 平台支持
5.1 HAL 硬件抽象层
ESPHome 通过 HAL(Hardware Abstraction Layer)支持不同的 MCU 平台。核心思路是编译期条件分发:
| |
每个平台组件(如 esphome/components/esp32/)提供:
hal.h:平台特定的内联函数(delayMicroseconds(),arch_feed_wdt(),millis(),micros()等)__init__.py:Python 侧的平台注册、构建配置、GPIO schema- 平台特定的代码(如 ESP32 的 BLE、RMT 外设支持)
5.2 条件编译 (defines.h)
ESPHome 大量使用条件编译宏来裁剪固件大小。defines.h 由 Python 代码生成器根据 YAML 配置动态生成:
| |
这使得未使用的功能完全不占 Flash/RAM,对于资源受限的 ESP8266 尤为重要。
5.3 GPIO 抽象
GPIOPin / InternalGPIOPin 是引脚抽象基类:
| |
每个平台实现自己的 InternalGPIOPin,GPIO 扩展器(如 PCF8574、MCP23017)实现 GPIOPin。
Python 侧的引脚系统通过 pins.py 中的 PIN_SCHEMA_REGISTRY 注册各平台的引脚 schema,代码生成时调用 gpio_pin_expression() 自动选择正确的实现。
5.4 组件平台模式
ESPHome 的组件采用平台模式实现多硬件支持。以传感器为例:
| |
Python 侧:
sensor/__init__.py:定义传感器基类的CONFIG_SCHEMAsensor/dht/__init__.py:定义 DHT 平台的 schema 和to_code()
C++ 侧:
sensor/sensor.h:Sensor基类(继承EntityBase,自行管理float state)dht/dht.h:DHTSensor继承Sensor+PollingComponent,实现update()读取硬件
5.5 唤醒系统
不同平台的睡眠/唤醒机制不同,ESPHome 通过平台特定的唤醒实现处理:
| |
Application::loop() 末尾通过 esphome::internal::wakeable_delay(delay_time) 进入低功耗等待,可以被:
- 定时器到期
- 外部中断
- 其他线程/ISR 的
wake_loop_threadsafe()唤醒
5.6 持久化存储 (Preferences)
ESPHome 通过 Preferences 系统实现断电后状态恢复,采用分层架构:
| |
ESPPreferenceObject — 类型安全包装器
| |
通过模板将任意 trivially_copyable 类型序列化为字节流,内部只持有 PreferenceBackend* 指针。
Key 生成机制
EntityBase::make_entity_preference<T>(version) 生成持久化对象,key 计算方式:
| |
object_id:YAML 中的实体名称device_id:设备标识(多设备时区分)version:硬编码随机常量(如 Fan 的0x71700ABB),修改存储结构时更改,旧数据自动失效
ESP32 实现:基于 NVS
ESP32 使用 ESP-IDF 的 NVS(Non-Volatile Storage),调用以下 ESP-IDF 函数:
| 函数 | 用途 |
|---|---|
nvs_flash_init() | 初始化 NVS 分区 |
nvs_open("esphome", NVS_READWRITE, &handle) | 打开 “esphome” 命名空间 |
nvs_get_blob(handle, key, data, &len) | 读取二进制数据 |
nvs_set_blob(handle, key, data, len) | 写入二进制数据 |
nvs_commit(handle) | 提交写入 |
nvs_flash_erase() | 擦除整个 NVS 分区(损坏/重置时) |
核心设计 — 延迟写入:save() 不直接写 flash,而是追加到 s_pending_save 内存向量。同一 key 多次 save 只保留最新值。sync() 时通过 is_changed_() 先读取旧值 memcmp 比较,仅写入真正变化的数据,最大限度减少 flash 写入。
| |
NVS 自带磨损均衡和校验,ESP32 上 in_flash 参数被忽略(全部走 NVS)。
ESP8266 实现:RTC 内存 + Flash 扇区
ESP8266 采用双存储架构,远比 ESP32 复杂:
- RTC 用户内存(
0x60001200):128 个 32-bit word(512 字节)- 深度睡眠后保留,断电后丢失
- Normal 区域仅 78 words(312 字节),空间极其有限
- Flash 扇区:SPIFFS 之后的整扇区
- 断电后保留
- 通过
spi_flash_erase_sector()/spi_flash_write()操作 - 无磨损均衡,整扇区擦除+重写
每个偏好数据附带 CRC word(type 参数参与计算),load 时校验。ESP32 无需此机制(NVS 内部有校验)。
两平台差异对比
| 特性 | ESP32 (NVS) | ESP8266 (RTC+Flash) |
|---|---|---|
| 存储后端 | NVS(自带磨损均衡+校验) | 手动管理 RTC + Flash |
| save() 语义 | 延迟(内存缓冲) | RTC: 立即; Flash: 写 RAM 缓冲 |
| 变更检测 | memcmp 旧值,跳过未变化数据 | Flash: dirty flag; RTC: 无条件写 |
| Flash 写入 | 单键值对(nvs_set_blob) | 整扇区擦除+重写 |
| 空间限制 | NVS 分区(16-24KB) | RTC: 312B; Flash: 256-512B |
Sync 机制
IntervalSyncer 组件(优先级 BUS)负责定时同步:
- 默认每 60 秒
sync()一次(可通过preferences.flash_write_interval配置) - 设为
0s时,每次loop()都 sync(编译宏USE_PREFERENCES_SYNC_EVERY_LOOP) - 关机时额外 sync(
on_shutdown),确保数据不丢失
哪些对象需要持久化
需要断电恢复状态的实体才使用持久化,典型包括:
| 组件 | 存储结构 | 大小 | 说明 |
|---|---|---|---|
| Switch | bool | 1B | 开关状态 |
| Fan | FanRestoreState | ~8B | 风速、方向、振荡 |
| Light | LightStateRTCState | ~44B | 亮度、色温、颜色、效果 |
| Cover | CoverRestoreState | ~8B | 位置、倾斜角 |
| Number | 平台相关 | 4-8B | 数值设定(如 LD2450 的存在超时) |
不需要持久化的实体:
- Sensor / BinarySensor:实时读取硬件值,无需恢复
- TextSensor:动态生成文本
- Button:瞬时动作,无状态
- Select:部分实现使用,部分不使用
5.7 构建系统
ESPHome 支持两种构建后端:
- PlatformIO(ESP8266、部分 ESP32 Arduino)—
esphome/build_gen/platformio.py - ESP-IDF CMake(ESP32 Arduino/ESP-IDF)—
esphome/build_gen/espidf.py
Python 代码生成器根据目标平台生成相应的构建文件。
六、典型组件结构
以 binary_sensor 为例,展示一个组件的完整结构:
| |
Python 侧 (__init__.py):
| |
C++ 侧 (binary_sensor.h):
| |
注意 BinarySensor 只继承 StatefulEntityBase<bool>,不继承 Component。轮询逻辑由具体的平台实现类(如 GPIOBinarySensor)通过 PollingComponent 单独提供。
6.1 实战分析:LD2450 毫米波雷达组件
LD2450 是一个典型的复杂组件,展示了 ESPHome 组件架构的多种模式。它是一个 24GHz 毫米波人体存在传感器,通过 UART 通信,支持多目标追踪和区域检测。
组件结构
| |
依赖共享基类 ld24xx/ld24xx.h,提供 SensorWithDedup<T> 去重传感器模板和辅助宏。
UART 通信协议
LD2450 使用自定义二进制协议,两种帧格式:
命令帧:FD FC FB FA + 长度 + 命令 + 参数 + 04 03 02 01
数据帧(周期性):AA FF 03 00 + 3×8字节目标数据 + 55 CC
| |
通信流程:send_command_() → readline_() 逐字节解析 → handle_periodic_data_() / handle_ack_data_()。大部分命令需要先进入配置模式(CMD_ENABLE_CONF)。
提供的实体类型
| 类型 | 数量 | 说明 |
|---|---|---|
| Binary Sensor | 3 | 有目标/移动目标/静止目标(带存在超时) |
| Sensor | 6+9×3+9×3=60 | 全局计数 + 每目标坐标/速度/角度/距离 + 每区域计数 |
| Text Sensor | 2+3 | 版本/MAC + 每目标方向 |
| Number | 1+3×4=13 | 存在超时 + 3区域×4坐标 |
| Select | 2 | 波特率 / 区域类型 |
| Switch | 2 | 蓝牙 / 多目标模式 |
| Button | 2 | 恢复出厂 / 重启 |
区域检测系统
最多 3 个矩形区域,每个区域由对角点 (x1,y1)-(x2,y2) 定义:
- Detection 模式:仅检测区域内的目标
- Filter 模式:过滤掉区域内的目标
- Disabled:区域禁用
count_targets_in_zone_() 在 handle_periodic_data_() 中对每个目标判断是否在区域内,使用严格不等号。
持久化存储
LD2450 仅持久化一个值:presence_timeout(存在超时,默认 5 秒)。
| |
区域坐标、蓝牙状态、多目标模式等不持久化——雷达硬件自身有内部存储,重启后从硬件重新读取。
设计亮点
- 条件编译:所有实体类型通过
#ifdef USE_xxx裁剪,未使用的实体零开销 - 去重机制:
SensorWithDedup<T>+Deduplicator<T>,避免重复发布相同值 - 存在超时:Binary Sensor 不立即清除状态,等待可配置超时后才切换 OFF
- 自动帧同步:UART 解析器通过帧尾检测实现自动重新同步
- 懒加载回调:
LazyCallbackManager<void()>仅在注册on_data自动化时才分配内存
七、代码生成的 main.cpp 结构
writer.py 生成的 main.cpp 结构如下:
| |
esphome.h 是自动生成的超级头文件,include 了所有用到的组件头文件。
八、命令行接口 (CLI)
ESPHome 的所有功能通过命令行驱动,CLI 定义在 esphome/__main__.py 中,使用 argparse 的 subparsers 机制注册子命令。
8.1 全局选项
所有子命令共享以下选项:
| 选项 | 短选项 | 说明 |
|---|---|---|
--verbose | -v | 启用详细日志(等价于 --log-level DEBUG) |
--quiet | -q | 禁用所有日志(等价于 --log-level CRITICAL) |
--log-level | -l | 设置日志级别:DEBUG、INFO、WARNING、ERROR、CRITICAL |
--substitution | -s | 添加 YAML 替换,格式:-s key value,可多次使用 |
--toolchain | 选择编译工具链:platformio 或 esp-idf,覆盖 YAML 中的设置 | |
--version | 打印版本号并退出 |
相关环境变量:
| 环境变量 | 用途 |
|---|---|
ESPHOME_VERBOSE | 默认启用 verbose |
ESPHOME_LOG_LEVEL | 默认日志级别 |
ESPHOME_UPLOAD_SPEED | esptool 默认上传速度(回退值 460800) |
ESPHOME_SERIAL_LOGGING_RESET | 默认启用串口日志前重置设备 |
8.2 常用命令
run — 编译 + 上传 + 日志(最常用)
| |
这是最常用的命令,执行完整流程:生成 C++ → 编译 → 上传 → 显示日志。
| 选项 | 说明 |
|---|---|
--device | 手动指定上传目标(串口/IP/OTA),可多次指定用于回退 |
--upload_speed | 覆盖上传速度 |
--no-logs | 上传成功后不启动日志查看 |
--no-states | 不显示实体状态变化 |
-r / --reset | 串口日志前重置设备 |
--ota-platform | OTA 平台:esphome(默认)或 web_server |
--device 特殊值:
- 串口路径:
/dev/ttyUSB0、COM3— 通过 esptool 串口刷写 OTA— 从配置自动解析(mDNS/DNS/MQTT)- IP 地址 / mDNS 主机名 — 网络 OTA 上传
MQTT— 通过 MQTT 发现 IPBOOTSEL— RP2040 BOOTSEL 模式(通过 picotool)
| |
compile — 编译固件
| |
| 选项 | 说明 |
|---|---|
--only-generate | 仅生成 C++ 源代码,不编译 |
| |
upload — 上传固件
| |
| 选项 | 说明 |
|---|---|
--device | 指定上传目标(同 run) |
--upload_speed | 覆盖上传速度 |
--file | 手动指定二进制文件路径 |
--ota-platform | esphome 或 web_server |
--partition-table | 通过 OTA 上传分区表(需 allow_partition_access: true) |
--bootloader | 通过 OTA 上传引导加载程序(需 allow_partition_access: true) |
上传路径选择:
- 串口:ESP32/ESP8266 用
esptool,RP2040/LibreTiny 用 PlatformIO - BOOTSEL:用
picotool load -v -x <elf> - 网络 OTA:
esphome平台 → 原生 API 挑战-响应认证(更安全,默认)web_server平台 → HTTP Basic 认证
esptool 默认上传速度为 460800,失败后自动回退 115200。
logs — 查看日志
| |
| 选项 | 短选项 | 说明 |
|---|---|---|
--device | 指定日志来源(同 run) | |
--reset | -r | 串口日志前重置设备 |
--no-states | 不显示实体状态变化 |
日志来源优先级:
- 串口:直接读取串口数据,波特率从配置的
logger.baud_rate读取 - API:通过原生 API 连接获取日志(默认订阅状态变化)
- MQTT:最后的回退方案
| |
config — 验证并输出配置
| |
| 选项 | 说明 |
|---|---|
--show-secrets | 在输出中显示密码/密钥(默认隐藏) |
8.3 其他命令
| 命令 | 说明 |
|---|---|
wizard <config> | 交互式 4 步设置向导(设备名 → 平台 → 开发板 → WiFi) |
version | 打印版本号 |
clean <config> | 清理构建文件 |
clean-all | 清理所有构建和平台文件 |
clean-mqtt <config> | 清理 MQTT 保留消息 |
dashboard <dir> | 启动 Web 仪表盘(默认端口 6052) |
rename <config> <name> | 重命名设备(修改 YAML + 重新编译上传) |
idedata <config> | 输出 PlatformIO IDE 数据(仅 PlatformIO 工具链) |
analyze-memory <config> | 组件级内存分析(使用 objdump + readelf) |
bundle <config> | 创建自包含配置打包文件 (.esphomebundle) |
update-all <dir> | 编译+上传目录下所有 YAML 配置 |
vscode <config> | VSCode 集成模式(stdin/stdout JSON 验证协议) |
discover <config> | 通过 MQTT 发现设备 |
config-hash <config> | 计算配置哈希值 |
8.4 Dashboard 命令
| |
| 选项 | 默认值 | 说明 |
|---|---|---|
--port | 6052 | HTTP 端口 |
--address | 0.0.0.0 | 绑定地址 |
--username | 认证用户名 | |
--password | 认证密码(也可从 $PASSWORD 环境变量读取) | |
--open-ui | 自动在浏览器中打开 |
| |
8.5 典型工作流
| |
九、工具链安装与路径管理
ESPHome 支持两种编译工具链:PlatformIO 和 ESP-IDF,通过 --toolchain 参数或 YAML 中的 esphome.toolchain: 配置选择,默认为 PlatformIO。首次编译时,ESPHome 会自动下载安装所需工具链。
9.1 PlatformIO 工具链
安装方式
PlatformIO 作为 Python 依赖通过 pip 安装(版本固定在 requirements.txt 中:platformio==6.1.19),安装 ESPHome 时自动安装。首次编译时,PlatformIO 会自动下载目标平台的编译器、框架等。
默认存储路径
| |
- Linux/macOS:
~/.platformio - Windows:
%USERPROFILE%\.platformio
自定义选项
| 环境变量 | 说明 | 默认值 |
|---|---|---|
PLATFORMIO_BUILD_DIR | 构建输出目录 | <build_path>/.pioenvs |
PLATFORMIO_LIBDEPS_DIR | 库依赖目录 | <build_path>/.piolibdeps |
注意:PlatformIO 的
core_dir(~/.platformio)不可自定义,因为其设置文件appstate.json存储在 core_dir 中。但可以自定义其下的platforms/、packages/、cache/子目录(Docker 环境中通过PLATFORMIO_PLATFORMS_DIR、PLATFORMIO_PACKAGES_DIR、PLATFORMIO_CACHE_DIR控制)。
9.2 ESP-IDF 工具链
安装方式
ESP-IDF 工具链由 ESPHome 自行管理,不依赖 PlatformIO。首次使用 ESP-IDF 工具链编译时,check_esp_idf_install() 自动执行:
- 框架下载:从 GitHub 镜像下载 ESP-IDF tar.xz 包并解压
- 工具安装:通过
idf_tools.py install安装交叉编译器(xtensa-esp-elf)、cmake、ninja 等 - Python 环境:创建独立 venv 并安装 IDF Python 依赖
默认存储路径
| |
自定义选项
| 环境变量 | 说明 | 默认值 |
|---|---|---|
ESPHOME_ESP_IDF_PREFIX | ESP-IDF 工具根目录 | <data_dir>/idf |
ESPHOME_IDF_FRAMEWORK_MIRRORS | 框架下载镜像 URL 列表 | GitHub esphome-libs 镜像 |
ESP_IDF_CONSTRAINTS_MIRRORS | pip 约束文件 URL | dl.espressif.com |
IDF_PATH | 已安装的 IDF 路径(若已设置则跳过安装) | 由 ESPHome 自动设置 |
IDF_TOOLS_PATH | IDF 工具安装根目录 | 同 ESPHOME_ESP_IDF_PREFIX |
镜像 URL 支持模板替换:{VERSION}、{MAJOR}、{MINOR}、{PATCH}、{EXTRA}。
| |
9.3 ESPHome 数据目录
CORE.data_dir 是 ESPHome 的核心数据目录,控制所有中间文件的存储位置:
| 环境 | 路径 |
|---|---|
| 本地开发(默认) | <config_dir>/.esphome/ |
| 自定义 | $ESPHOME_DATA_DIR |
| Home Assistant 插件 | /data/ |
| Docker | /config/.esphome/ 或 $ESPHOME_DATA_DIR |
| |
9.4 完整路径结构示例
以本地开发环境为例,YAML 配置文件为 /home/user/mydevice.yaml:
| |
9.5 编译流程
| |
PlatformIO runner 对 PlatformIO 做了两项重要补丁:
- patch_structhash():避免结构哈希变化导致完整重建,改用 mtime 检查
- patch_file_downloader():为包下载添加指数退避重试(5 次)
ESP-IDF runner 使 isatty() 返回 True 以获取 TTY 格式的进度输出,并过滤嘈杂的 IDF/CMake/Ninja 输出。
十、设计哲学总结
10.1 配置即代码
YAML 配置 → C++ 固件 的全自动转换,用户无需编写任何 C++ 代码。代价是灵活性受限于组件开发者提供的 schema。
10.2 编译期裁剪
通过条件编译宏(defines.h)和模板元编程(HasLoopOverride<T>、StaticVector),ESPHome 在编译期就确定了组件数量和类型,避免了运行时开销,使固件在资源受限的 MCU 上也能高效运行。
10.3 确定性代码生成
Python 侧的伪协程系统保证相同 YAML 总是生成相同的 C++ 代码,使得增量编译成为可能。
10.4 观察者模式
Controller 基类 + EntityBase 的回调系统构成了经典的观察者模式。实体状态变化时自动通知 APIServer、WebServer 等控制器,无需组件代码显式推送。
10.5 X-macro 代码生成
C++ 侧使用 X-macro 技术消除实体类型相关的重复代码(注册方法、控制器回调、计数宏等),而 Python 侧也有对应的 entity_helpers.py 生成字符串查找表。
10.6 嵌入式友好的内存管理
- Placement new:避免堆碎片
- StaticVector:编译期固定大小的向量
- StringRef:零拷贝字符串引用
- 位域打包:
EntityFlags仅占 1 字节 - PROGMEM:ESP8266 上字符串存储在 Flash 中
十一、关键文件索引
| 文件 | 作用 |
|---|---|
esphome/coroutine.py | 伪协程调度系统 |
esphome/cpp_generator.py | C++ 表达式/语句生成引擎 |
esphome/cpp_helpers.py | 组件注册、GPIO 表达式等辅助 |
esphome/cpp_types.py | C++ 类型声明(MockObj) |
esphome/core/__init__.py | CORE 全局状态对象、ID 类 |
esphome/writer.py | main.cpp 写入、源文件复制 |
esphome/config.py | YAML 配置加载与验证 |
esphome/automation.py | 自动化框架的 Python 侧注册 |
esphome/loader.py | 组件动态加载 |
esphome/__main__.py | CLI 命令行接口(所有子命令定义) |
esphome/core/component.h | Component / PollingComponent 基类 |
esphome/core/entity_base.h | EntityBase / StatefulEntityBase 基类 |
esphome/core/controller.h | Controller 观察者基类 |
esphome/core/application.h | Application 全局管理器 |
esphome/core/automation.h | Trigger / Action / Automation / ActionList / TemplatableFn |
esphome/core/base_automation.h | DelayAction / IfAction / WhileAction / RepeatAction / WaitUntilAction / ContinuationAction |
esphome/core/scheduler.h | 定时任务调度器 |
esphome/core/helpers.h | CallbackManager、StaticVector 等工具 |
esphome/core/hal.h | 硬件抽象层分发 |
esphome/core/entity_types.h | X-macro 实体类型定义 |
esphome/core/defines.h | 条件编译宏(自动生成) |
esphome/components/api/ | Native API 完整实现 |
esphome/components/api/api.proto | Protobuf 服务定义 |
esphome/platformio/toolchain.py | PlatformIO 编译调用 |
esphome/platformio/runner.py | PlatformIO 补丁(structhash、重试) |
esphome/espidf/framework.py | ESP-IDF 框架下载与安装 |
esphome/espidf/toolchain.py | ESP-IDF 编译调用 |
esphome/espidf/runner.py | ESP-IDF 输出过滤 |
esphome/core/preference_backend.h | PreferenceBackend 接口 + ESPPreferenceObject + PreferencesMixin |
esphome/core/preferences.h | 平台分发头文件 |
esphome/components/esp32/preferences.cpp | ESP32 NVS 后端实现 |
esphome/components/esp8266/preferences.cpp | ESP8266 RTC+Flash 后端实现 |
esphome/components/preferences/syncer.h | IntervalSyncer 定时同步 |
Author synodriver
LastMod 2026-05-23