.ai/instructions.md
This document provides essential context for AI models interacting with this project. Adhering to these guidelines will ensure consistency and maintain code quality.
voluptuous (for configuration validation), PyYAML (for parsing configuration files), paho-mqtt (for MQTT communication), tornado (for the web server), aioesphomeapi (for the native API).ArduinoJson (for JSON serialization/deserialization), AsyncMqttClient-esphome (for MQTT), ESPAsyncWebServer (for the web server).pip (for Python dependencies), platformio (for C++/PlatformIO dependencies).Overall Architecture: The project follows a code-generation architecture. The Python code parses user-defined YAML configuration files and generates C++ source code. This C++ code is then compiled and flashed to the target microcontroller using PlatformIO.
Directory Structure Philosophy:
/esphome: Contains the core Python source code for the ESPHome application./esphome/components: Contains the individual components that can be used in ESPHome configurations. Each component is a self-contained unit with its own C++ and Python code./tests: Contains all unit and integration tests for the Python code./docker: Contains Docker-related files for building and running ESPHome in a container./script: Contains helper scripts for development and maintenance.Core Architectural Components:
esphome/config*.py): Handles YAML parsing and validation using Voluptuous, schema definitions, and multi-platform configurations.esphome/codegen.py, esphome/cpp_generator.py): Manages Python to C++ code generation, template processing, and build flag management.esphome/components/): Contains modular hardware and software components with platform-specific implementations and dependency management.esphome/core/): Manages the application lifecycle, hardware abstraction, and component registration.esphome/dashboard/): A web-based interface for device configuration, management, and OTA updates.Platform Support:
components/esp32/): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3).components/esp8266/): Espressif ESP8266. Arduino framework only, with memory constraints.components/rp2040/): Raspberry Pi Pico/RP2040. Arduino framework with PIO (Programmable I/O) support.components/libretiny/): Realtek and Beken chips. Supports multiple chip families and auto-generated components.Formatting:
ruff and flake8 for linting and formatting. Configuration is in pyproject.toml.clang-format for formatting. Configuration is in .clang-format.Naming Conventions:
lower_snake_caseUpperCamelCaseUPPER_SNAKE_CASElower_snake_caselower_snake_case_with_trailing_underscore_C++ Field Visibility:
protected: Use protected for most class fields to enable extensibility and testing. Fields should be lower_snake_case_with_trailing_underscore_.private for safety-critical cases: Use private visibility when direct field access could introduce bugs or violate invariants:
// Helper to find matching string in vector and return its pointer
inline const char *vector_find(const std::vector<const char *> &vec, const char *value) {
for (const char *item : vec) {
if (strcmp(item, value) == 0)
return item;
}
return nullptr;
}
class ClimateDevice {
public:
void set_custom_fan_modes(std::initializer_list<const char *> modes) {
this->custom_fan_modes_ = modes;
this->active_custom_fan_mode_ = nullptr; // Reset when modes change
}
bool set_custom_fan_mode(const char *mode) {
// Find mode in supported list and store that pointer (not the input pointer)
const char *validated_mode = vector_find(this->custom_fan_modes_, mode);
if (validated_mode != nullptr) {
this->active_custom_fan_mode_ = validated_mode;
return true;
}
return false;
}
private:
std::vector<const char *> custom_fan_modes_; // Pointers to string literals in flash
const char *active_custom_fan_mode_{nullptr}; // Must point to entry in custom_fan_modes_
};
class Buffer {
public:
void resize(size_t new_size) {
auto new_data = std::make_unique<uint8_t[]>(new_size);
if (this->data_) {
std::memcpy(new_data.get(), this->data_.get(), std::min(this->size_, new_size));
}
this->data_ = std::move(new_data);
this->size_ = new_size; // Must stay in sync with data_
}
private:
std::unique_ptr<uint8_t[]> data_;
size_t size_{0}; // Must match allocated size of data_
};
protected accessor methods: When derived classes need controlled access to private members.C++ Preprocessor Directives:
#define for constants: Using #define for constants is discouraged and should be replaced with const variables or enums.#define only for:
#ifdef, #ifndef)std::array or StaticVector dimensions via cg.add_define())C++ Additional Conventions:
this-> (e.g., this->value_ not value_)using type_t = int; over typedef int type_t;cg.new_Pvariable() or the relevant helper function to create the component, pass these as arguments.
// Good - required invariant dependency as constructor parameter
class SourceTextSensor : public text_sensor::TextSensor, public Component {
public:
explicit SourceTextSensor(text::Text *source) : source_(source) {}
protected:
text::Text *source_;
};
// Bad - required invariant dependency as setter
class SourceTextSensor : public text_sensor::TextSensor, public Component {
public:
void set_source(text::Text *source) { this->source_ = source; }
protected:
text::Text *source_{nullptr};
};
Component Structure:
Standard Files:
components/[component_name]/
├── __init__.py # Component configuration schema and code generation
├── [component].h # C++ header file (if needed)
├── [component].cpp # C++ implementation (if needed)
└── [platform]/ # Platform-specific implementations
├── __init__.py # Platform-specific configuration
├── [platform].h # Platform C++ header
└── [platform].cpp # Platform C++ implementation
Component Metadata:
DEPENDENCIES: List of required componentsAUTO_LOAD: Components to automatically loadCONFLICTS_WITH: Incompatible componentsCODEOWNERS: GitHub usernames responsible for maintenanceMULTI_CONF: Whether multiple instances are allowedCode Generation & Common Patterns:
Configuration Schema Pattern:
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_KEY, CONF_ID
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
my_component_ns = cg.esphome_ns.namespace("my_component")
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_key(config[CONF_KEY]))
cg.add(var.set_param(config[CONF_PARAM]))
C++ Class Pattern:
namespace esphome::my_component {
class MyComponent : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
void set_key(const std::string &key) { this->key_ = key; }
void set_param(int param) { this->param_ = param; }
protected:
std::string key_;
int param_{0};
};
} // namespace esphome::my_component
Common Component Examples:
Sensor:
from esphome.components import sensor
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
Binary Sensor:
from esphome.components import binary_sensor
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
Switch:
from esphome.components import switch
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
async def to_code(config):
var = await switch.new_switch(config)
Automations (Triggers, Actions, Conditions):
Automations have three building blocks: Triggers (fire when something happens), Actions (do something), and Conditions (check if something is true).
Triggers -- Callback method (preferred):
Use build_callback_automation() for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No CONF_TRIGGER_ID in the schema.
Python:
from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(bool, "x")], conf
)
build_callback_automation arguments: parent, callback_method (C++ method name), args (template args as [(type, name)] tuples), config, and optional forwarder (defaults to TriggerForwarder<Ts...>).
For boolean filtering (e.g. on_press/on_release), use built-in forwarders with args=[]:
for conf_key, forwarder in (
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=forwarder
)
C++ -- no trigger class needed. The callback registration method must be templatized to accept both std::function and lightweight forwarder structs (which avoid heap allocation):
class MyComponent : public Component {
public:
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
protected:
// Use CallbackManager when callbacks are always registered (e.g. core components)
CallbackManager<void(bool)> state_callback_;
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
// (nullptr vs empty std::vector) per instance when no callbacks are added
// LazyCallbackManager<void(bool)> state_callback_;
};
Triggers -- Trigger class method:
Use build_automation() with a Trigger<Ts...> subclass only when the forwarder needs mutable state beyond a single Automation* pointer (e.g. edge detection tracking previous state, timing logic).
Python:
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema({
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
})
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
C++:
class TurnOnTrigger : public Trigger<> {
public:
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
parent->add_on_state_callback([this](bool state) {
if (state && !this->last_on_)
this->trigger();
this->last_on_ = state;
});
}
protected:
bool last_on_;
};
Actions:
template<typename... Ts> class MyAction : public Action<Ts...> {
public:
explicit MyAction(MyComponent *parent) : parent_(parent) {}
void play(const Ts &...) override { this->parent_->do_something(); }
protected:
MyComponent *parent_;
};
Register with @automation.register_action("my_component.do_something", MyAction, schema, synchronous=True). Use synchronous=True for actions that run to completion inside play() without deferring. Use synchronous=False if the action may suspend/defer execution (e.g. delay, wait_until, script.wait) or store trigger arguments for later use.
Conditions:
template<typename... Ts> class MyCondition : public Condition<Ts...> {
public:
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
bool check(const Ts &...) override { return this->parent_->is_active(); }
protected:
MyComponent *parent_;
};
Register with @automation.register_condition("my_component.is_active", MyCondition, schema).
Configuration Validation:
cv.int_, cv.float_, cv.string, cv.boolean, cv.int_range(min=0, max=100), cv.positive_int, cv.percentage.cv.All(cv.string, cv.Length(min=1, max=50)), cv.Any(cv.int_, cv.string).cv.only_on(["esp32", "esp8266"]), esp32.only_on_variant(...), cv.only_on_esp32, cv.only_on_esp8266, cv.only_on_rp2040.cv.only_with_framework(...), cv.only_with_arduino, cv.only_with_esp_idf.CONFIG_SCHEMA = cv.Schema({ ... })
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(i2c.i2c_device_schema(0x48))
.extend(spi.spi_device_schema(cs_pin_required=True))
esphome/__main__.py is the main entrypoint for the ESPHome command-line interface.pyproject.toml: Defines the Python project metadata and dependencies.platformio.ini: Configures the PlatformIO build environments for different microcontrollers..pre-commit-config.yaml: Configures the pre-commit hooks for linting and formatting..github/workflows.esphome/core/defines.h: A comprehensive header file containing all #define directives that can be added by components using cg.add_define() in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.requirements_dev.txt.script/run-in-env.py script to execute commands within the project's virtual environment. For example, to run the linter: python3 script/run-in-env.py pre-commit run.Python: Run unit tests with pytest.
C++: Use clang-tidy for static analysis.
Component Tests: YAML-based compilation tests are located in tests/. The structure is as follows:
tests/
├── test_build_components/
│ └── common/ # Shared bus packages (uart, i2c, spi, etc.)
│ ├── uart/ # UART at default baud rate
│ ├── uart_115200/ # UART at 115200 baud
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml
├── test.esp8266-ard.yaml
└── test.rp2040-ard.yaml
Run them using script/test_build_components. Use -c <component> to test specific components and -t <target> for specific platforms.
Test Grouping with Packages: Components that use shared bus packages can be grouped together in CI to reduce build count. Never define buses (uart, i2c, spi, modbus) directly in test YAML files — always use packages from test_build_components/common/:
# test.esp32-idf.yaml — use packages for buses
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
# common.yaml — component config only, NO bus definitions
my_component:
id: my_instance
sensor:
- platform: my_component
name: My Sensor
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
Testing All Components Together: To verify that all components can be tested together without ID conflicts or configuration issues, use:
./script/test_component_grouping.py -e config --all
This tests all components in a single build to catch conflicts that might not appear when testing components individually. Use -e config for fast configuration validation, or -e compile for full compilation testing.
esphome config <file>.yaml to validate configuration.esphome compile <file>.yaml to compile without uploading.PYTHONPATH.Contribution Workflow (Pull Request Process):
dev branch (always use git checkout -b <branch-name> dev to ensure you're branching from dev, not the currently checked out branch).pre-commit to ensure code is compliant.dev branch. The Pull Request title should have a prefix of the component being worked on (e.g., [display] Fix bug, [abc123] Add new component). Update documentation, examples, and add CODEOWNERS entries as needed. Pull requests should always be made using the .github/PULL_REQUEST_TEMPLATE.md template - fill out all sections completely without removing any parts of the template.Documentation Contributions:
esphome/esphome-docs repository.Best Practices:
Component Development: Keep dependencies minimal, provide clear error messages, and write comprehensive docstrings and tests.
Code Generation: Generate minimal and efficient C++ code. Validate all user inputs thoroughly. Support multiple platform variations.
Configuration Design: Aim for simplicity with sensible defaults, while allowing for advanced customization.
Embedded Systems Optimization: ESPHome targets resource-constrained microcontrollers. Be mindful of flash size and RAM usage.
Why Heap Allocation Matters:
ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over time, repeated allocations of different sizes fragment the heap. Failures happen when the largest contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by this.
Heap allocation after setup() should be avoided unless absolutely unavoidable. Every allocation/deallocation cycle contributes to fragmentation. ESPHome treats runtime heap allocation as a long-term reliability bug, not a performance issue. Helpers that hide allocation (std::string, std::to_string, string-returning helpers) are being deprecated and replaced with buffer and view based APIs.
STL Container Guidelines:
ESPHome runs on embedded systems with limited resources. Choose containers carefully:
Compile-time-known sizes: Use std::array instead of std::vector when size is known at compile time.
// Bad - generates STL realloc code
std::vector<int> values;
// Good - no dynamic allocation
std::array<int, MAX_VALUES> values;
Use cg.add_define("MAX_VALUES", count) to set the size from Python configuration.
For byte buffers: Avoid std::vector<uint8_t> unless the buffer needs to grow. Use std::unique_ptr<uint8_t[]> instead.
Note:
std::unique_ptr<uint8_t[]>does not provide bounds checking or iterator support likestd::vector<uint8_t>. Use it only when you do not need these features and want minimal overhead.
// Bad - STL overhead for simple byte buffer
std::vector<uint8_t> buffer;
buffer.resize(256);
// Good - minimal overhead, single allocation
std::unique_ptr<uint8_t[]> buffer = std::make_unique<uint8_t[]>(256);
// Or if size is constant:
std::array<uint8_t, 256> buffer;
Compile-time-known fixed sizes with vector-like API: Use StaticVector from esphome/core/helpers.h for compile-time fixed size with push_back() interface (no dynamic allocation).
// Bad - generates STL realloc code (_M_realloc_insert)
std::vector<ServiceRecord> services;
services.reserve(5); // Still includes reallocation machinery
// Good - compile-time fixed size, no dynamic allocation
StaticVector<ServiceRecord, MAX_SERVICES> services;
services.push_back(record1);
Use cg.add_define("MAX_SERVICES", count) to set the size from Python configuration.
Like std::array but with vector-like API (push_back(), size()) and no STL reallocation code.
Runtime-known sizes: Use FixedVector from esphome/core/helpers.h when the size is only known at runtime initialization.
// Bad - generates STL realloc code (_M_realloc_insert)
std::vector<TxtRecord> txt_records;
txt_records.reserve(5); // Still includes reallocation machinery
// Good - runtime size, single allocation, no reallocation machinery
FixedVector<TxtRecord> txt_records;
txt_records.init(record_count); // Initialize with exact size at runtime
Benefits:
_M_realloc_insert, _M_default_append template instantiations (saves 200-500 bytes per instance)[(fixed_vector) = true] optionSmall datasets (1-16 elements): Use std::vector or std::array with simple structs instead of std::map/std::set/std::unordered_map.
// Bad - 2KB+ overhead for red-black tree/hash table
std::map<std::string, int> small_lookup;
std::unordered_map<int, std::string> tiny_map;
// Good - simple struct with linear search (std::vector is fine)
struct LookupEntry {
const char *key;
int value;
};
std::vector<LookupEntry> small_lookup = {
{"key1", 10},
{"key2", 20},
{"key3", 30},
};
// Or std::array if size is compile-time constant:
// std::array<LookupEntry, 3> small_lookup = {{ ... }};
Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. std::vector with simple structs is usually fine—it's the heavy containers (map, set, unordered_map) that should be avoided for small datasets unless profiling shows otherwise.
Avoid std::deque: It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
Detection: Look for these patterns in compiler output:
alloc, realloc, dealloc in symbol names_M_realloc_insert, _M_default_append (vector reallocation)rb_tree, _Rb_tree)unordered_map, hash)Prioritize optimization effort for:
Note: Avoiding heap allocation after setup() is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from std::vector to StaticVector).
Callback Managers:
ESPHome provides two callback manager types in esphome/core/helpers.h for the observer pattern. Both support std::function, lambdas, and lightweight forwarder structs via their templatized add() method.
| Type | Idle overhead (32-bit) | When to use |
|---|---|---|
CallbackManager<void(Ts...)> | 12 bytes (empty std::vector) | Callbacks are always or almost always registered |
LazyCallbackManager<void(Ts...)> | 4 bytes (nullptr) | Callbacks are often not registered (common case) |
LazyCallbackManager is a drop-in replacement for CallbackManager that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
Important: Registration methods that add to a callback manager must always be templatized to accept both std::function and pointer-sized forwarder structs (used by build_callback_automation). Never use std::function in the method signature:
// Bad -- forces heap allocation for forwarder structs
void add_on_state_callback(std::function<void(bool)> &&callback) {
this->state_callback_.add(std::move(callback));
}
// Good -- accepts any callable without forcing std::function wrapping
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
State Management: Use CORE.data for component state that needs to persist during configuration generation. Avoid module-level mutable globals.
Bad Pattern (Module-Level Globals):
# Don't do this - state persists between compilation runs
_component_state = []
_use_feature = None
def enable_feature():
global _use_feature
_use_feature = True
Bad Pattern (Flat Keys):
# Don't do this - keys should be namespaced under component domain
MY_FEATURE_KEY = "my_component_feature"
CORE.data[MY_FEATURE_KEY] = True
Good Pattern (dataclass):
from dataclasses import dataclass, field
from esphome.core import CORE
DOMAIN = "my_component"
@dataclass
class MyComponentData:
feature_enabled: bool = False
item_count: int = 0
items: list[str] = field(default_factory=list)
def _get_data() -> MyComponentData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = MyComponentData()
return CORE.data[DOMAIN]
def request_feature() -> None:
_get_data().feature_enabled = True
def add_item(item: str) -> None:
_get_data().items.append(item)
If you need a real-world example, search for components that use @dataclass with CORE.data in the codebase. Note: Some components may use TypedDict for dictionary-based storage; both patterns are acceptable depending on your needs.
Why this matters:
CORE.data automatically clears between runsDOMAIN prevents key collisions between components@dataclass provides type safety and cleaner attribute accessSecurity: Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys.
Dependencies & Build System Integration:
requirements*.txt file and pyproject.toml.platformio.ini and use cg.add_library.cg.add_build_flag(...) to add compiler flags.Public C++ API:
public members are internal.esphome/core/, Component, Sensor, etc.): All public members are public API.global_api_server, etc.): All public members are public API (except config setters).Public Python API:
esphome/core/ actively used by existing core components is considered stable API.Breaking Changes Policy:
Breaking Change Checklist:
ESPDEPRECATED macro for C++)Deprecation Pattern (C++):
// Remove before 2026.6.0
ESPDEPRECATED("Use new_method() instead. Removed in 2026.6.0", "2025.12.0")
void old_method() { this->new_method(); }
Deprecation Pattern (Python):
# Remove before 2026.6.0
if CONF_OLD_KEY in config:
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate