mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 08:22:25 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f765a733 | ||
|
|
3a62bd2a4e | ||
|
|
7dd3a6f451 | ||
|
|
a7b5429032 | ||
|
|
3f31aeeccf | ||
|
|
b8c26bea20 |
No files matched your search
@@ -1,215 +0,0 @@
|
||||
---
|
||||
name: automations
|
||||
description: Write ESPHome automation building blocks - triggers (on_* keys), actions and conditions. Use when adding or changing an on_* trigger, an action such as my_component.do_something, a condition, a callback registration method (add_on_*_callback), or code that calls build_callback_automation, build_automation, register_*_action or register_*_condition.
|
||||
---
|
||||
|
||||
# Automations: triggers, actions and conditions
|
||||
|
||||
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do
|
||||
something), and **Conditions** (check if something is true). The helpers named here are documented in
|
||||
their docstrings in `esphome/automation.py`.
|
||||
|
||||
## 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:**
|
||||
```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=[]`:
|
||||
```python
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only fire for
|
||||
some values, pass `params`, `forward` and `when`; the helper then generates a capture-less lambda (stored
|
||||
inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build `forward` from
|
||||
`MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**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):
|
||||
```cpp
|
||||
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_;
|
||||
};
|
||||
```
|
||||
|
||||
Never type a registration method's parameter as `std::function`; that forces a heap allocation for every
|
||||
forwarder struct:
|
||||
```cpp
|
||||
// Bad
|
||||
void add_on_state_callback(std::function<void(bool)> &&callback) {
|
||||
this->state_callback_.add(std::move(callback));
|
||||
}
|
||||
```
|
||||
|
||||
| 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) |
|
||||
|
||||
Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and forwarder structs through
|
||||
their templatized `add()` method. `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.
|
||||
|
||||
## 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:**
|
||||
```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++:**
|
||||
```cpp
|
||||
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
|
||||
|
||||
```cpp
|
||||
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 it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and the
|
||||
`@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
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.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them with
|
||||
`register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover the
|
||||
rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a byte
|
||||
payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload stays in
|
||||
flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with `cg.templatable` stays
|
||||
for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
## Conditions
|
||||
|
||||
```cpp
|
||||
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_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares against
|
||||
config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: breaking-changes
|
||||
description: Decide whether an ESPHome change breaks public API and handle it - what counts as public C++ and Python API, the deprecation window, ESPDEPRECATED for C++, cv.rename_key and deprecation warnings for config keys, and the migration notes a PR needs. Use when removing, renaming or changing the signature of a config option, a public C++ member or a core Python helper.
|
||||
---
|
||||
|
||||
# Public API and breaking changes
|
||||
|
||||
Full policy: https://developers.esphome.io/contributing/code/#public-api-and-breaking-changes
|
||||
|
||||
## What is public
|
||||
|
||||
- **C++ in components:** only features documented at [esphome.io](https://esphome.io) are public API.
|
||||
Undocumented `public` members are internal.
|
||||
- **C++ core and base classes** (`esphome/core/`, `Component`, `Sensor`, etc.): all `public` members are
|
||||
public API.
|
||||
- **Components with global accessors** (`global_api_server`, etc.): all `public` members are public API
|
||||
(except config setters).
|
||||
- **Python:** all documented configuration options are public API. Python code in `esphome/core/` that
|
||||
existing core components use is stable API. Other Python code is internal unless explicitly documented
|
||||
for external component use.
|
||||
- Changing the signature of a lambda that code generation injects is not a breaking change.
|
||||
|
||||
## Policy
|
||||
|
||||
- Aim for a **6-month deprecation window** when possible.
|
||||
- Clean breaks are allowed for signature changes, deep refactorings and resource constraints.
|
||||
- The migration path goes in the PR description (it generates the release notes).
|
||||
- A blog post is required for core/base class changes or significant architectural changes.
|
||||
|
||||
Checklist:
|
||||
- [ ] Clear justification (RAM/flash savings, architectural improvement)
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (`ESPDEPRECATED` for C++)
|
||||
- [ ] Documented migration path in the PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during the deprecation period
|
||||
|
||||
## Deprecating C++
|
||||
|
||||
```cpp
|
||||
// Remove before 2026.6.0
|
||||
ESPDEPRECATED("Use new_method() instead. Removed in 2026.6.0", "2025.12.0")
|
||||
void old_method() { this->new_method(); }
|
||||
```
|
||||
|
||||
## Deprecating config keys
|
||||
|
||||
For a renamed key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for
|
||||
context); it warns and migrates automatically:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```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
|
||||
```
|
||||
Cover the old syntax with a `validate.*.yaml` test (see the `component-tests` skill), and keep the language
|
||||
schema correct (see the `config-validation` skill).
|
||||
@@ -9,13 +9,11 @@ ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers
|
||||
(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
|
||||
flash are scarce, and code runs unattended for months.
|
||||
|
||||
`AGENTS.md` in the repository root is the contributor guide, and the skills it
|
||||
lists (`component-codegen`, `config-validation`, `automations`,
|
||||
`embedded-memory`, `component-tests`, `breaking-changes`) hold the detail. They
|
||||
are the authority when they disagree with this summary. The developer
|
||||
documentation at https://developers.esphome.io explains the component lifecycle
|
||||
and the reasoning behind these rules. This skill lists the concrete things worth
|
||||
flagging in a review; read the matching skill for the detail behind any item.
|
||||
`AGENTS.md` in the repository root is the full contributor guide and the
|
||||
authority when it disagrees with this summary. The developer documentation at
|
||||
https://developers.esphome.io explains the component lifecycle and the reasoning
|
||||
behind these rules. This skill lists the concrete things worth flagging in a
|
||||
review; read `AGENTS.md` for the detail behind any item.
|
||||
|
||||
Only raise findings that the diff actually introduces or changes. Do not ask for
|
||||
drive-by cleanup of pre-existing code the PR did not touch.
|
||||
@@ -78,10 +76,6 @@ performance nit, because it fragments a small shared heap. Flag:
|
||||
`if (blah := config.get(CONF_BLAH)) is not None:`.
|
||||
- Reuse existing validators from `config_validation.py` (`cv.rename_key`,
|
||||
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling.
|
||||
- A new or changed validator function that wraps, chooses or builds a schema
|
||||
must be decorated with `@schema_extractor(...)` and handle `SCHEMA_EXTRACT`,
|
||||
or `script/build_language_schema.py` silently drops the keys behind it (see
|
||||
the `config-validation` skill).
|
||||
- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in
|
||||
the component's own `.py`, or in `esphome/components/const/__init__.py` when
|
||||
shared. The same constant defined in three or more component files fails CI.
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
---
|
||||
name: component-codegen
|
||||
description: Write the Python side of an ESPHome component - __init__.py and platform files (sensor.py, switch.py, ...), DOMAIN and other metadata, to_code, hub child entities with sub_sensors and friends, pins and referenced components, hub-forwarding entities (ApplyButton, ApplySwitch), uart/i2c/spi constructor helpers, and CORE.data state. Use when creating a component or changing its code generation.
|
||||
---
|
||||
|
||||
# Component code generation
|
||||
|
||||
Read https://developers.esphome.io/architecture/components/ for the component lifecycle and how a
|
||||
component is registered. Schema rules and schema extraction are in the `config-validation` skill;
|
||||
automations (`on_*`, actions, conditions) in the `automations` skill.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Metadata
|
||||
|
||||
- `DOMAIN`: required in every component's `__init__.py`, set to the component's own name as a plain string
|
||||
(e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: list of required components.
|
||||
- `AUTO_LOAD`: components to automatically load. Not a way to put entities under the component's own key;
|
||||
see "Entities of a hub" below.
|
||||
- `CONFLICTS_WITH`: incompatible components.
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance.
|
||||
- `MULTI_CONF`: whether multiple instances are allowed.
|
||||
|
||||
## Basic pattern
|
||||
|
||||
```python
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
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]))
|
||||
```
|
||||
|
||||
```cpp
|
||||
namespace esphome::my_component {
|
||||
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_key(const char *key) { this->key_ = StringRef(key); }
|
||||
void set_param(int param) { this->param_ = param; }
|
||||
|
||||
protected:
|
||||
StringRef key_;
|
||||
int param_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::my_component
|
||||
```
|
||||
|
||||
When the setter takes the value unchanged, use `cg.add_optional(var.set_blah, config.get(CONF_BLAH))`; it
|
||||
is not awaited, and it skips only `None`, so `0` and `False` are still set. Required, invariant
|
||||
dependencies are constructor arguments to `cg.new_Pvariable()`, not setters.
|
||||
|
||||
## Entity platforms
|
||||
|
||||
**Sensor:**
|
||||
```python
|
||||
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:**
|
||||
```python
|
||||
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:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
## Entities of a hub
|
||||
|
||||
A new component's entities go in platform files (`sensor.py`, `binary_sensor.py`, ...) that take the hub's
|
||||
id, never under the hub's own key with their platforms added to `AUTO_LOAD` to make that work. Auto loading
|
||||
a platform because the C++ uses its type is fine. See
|
||||
https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
**Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, `sub_selects` in
|
||||
their domains), adding `parent=hub` for entities that derive from `Parented<T>`, then make one call per
|
||||
key, even when there is only one. A call creates the entity only when its key is configured, passes it to
|
||||
the setter and returns it (or `None`); extra arguments such as `min_value` or `options` go on the call.
|
||||
Always name the setter explicitly on the object that owns it, never with `getattr` and an f-string, and
|
||||
keep that variable short (`var` for the component itself, `hub` for one fetched with `cg.get_variable`) so
|
||||
the calls fit on one line. Loops whose setter also takes an index, such as `set_gate_threshold(x, n)`, stay
|
||||
as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
**Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the
|
||||
state. `publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off
|
||||
and runs the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)` to still
|
||||
drive a state when the restore mode is disabled. A hub that applies its switches' restored states itself
|
||||
reads `sw->get_initial_write_state()`, which already applies `inverted`.
|
||||
|
||||
**Entities that each watch a register field:** one class taking register, shift, mask and match from a
|
||||
Python tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a
|
||||
virtual listener base (a second vptr per entity).
|
||||
|
||||
## Other helpers
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for
|
||||
`set_parent`.
|
||||
- `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`, `spi.new_spi_component`,
|
||||
`modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`) constructs, registers and
|
||||
attaches in one call.
|
||||
- `display.add_writer(var, config)` sets the lambda.
|
||||
- `MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict.
|
||||
- `button.restart_button_schema(cls)` (and `factory_reset_`, `query_`) are button presets.
|
||||
- `fv.get_declaring_config(id)` finds a hub's config in final validation.
|
||||
|
||||
## State during code generation
|
||||
|
||||
Use `CORE.data` for component state that needs to persist during configuration generation, namespaced
|
||||
under the component's `DOMAIN` and held in a `@dataclass`. Avoid module-level mutable globals: they
|
||||
persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec, whereas
|
||||
`CORE.data` is cleared between runs.
|
||||
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
Don't use flat keys such as `CORE.data["my_component_feature"] = True`. Some components use `TypedDict`
|
||||
instead of a dataclass; both are acceptable.
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
name: component-tests
|
||||
description: Write and run tests for ESPHome components - YAML build tests (test.*.yaml, validate.*.yaml, common.yaml) under tests/components, shared bus packages and CI test grouping, C++ gtest unit tests, and host integration tests including the uart_mock fixture. Use when adding or changing files under tests/, or when a code change needs test coverage.
|
||||
---
|
||||
|
||||
# Component tests
|
||||
|
||||
## C++ unit tests
|
||||
|
||||
gtests live in `tests/components/<component>/*.cpp` and run with `script/cpp_unit_test.py <component>`; see
|
||||
`tests/components/README.md`. Set the loop clock in a test with `LoopBlockingGuard{nullptr, nullptr,
|
||||
now_ms}`; the runner resets it before every test.
|
||||
|
||||
## Integration tests
|
||||
|
||||
`tests/integration/` runs real firmware on the host. Serial components prove timeouts and recovery with the
|
||||
`uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its `responses` match on content every time
|
||||
they see it; to answer a repeated request differently, use an `on_tx` lambda with a static counter and
|
||||
`inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
|
||||
## YAML build tests
|
||||
|
||||
```
|
||||
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 # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them with `script/test_build_components`. Use `-c <component>` to test specific components and
|
||||
`-t <target>` for specific platforms.
|
||||
|
||||
- **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs on.
|
||||
A variant per board for platform-identical code only adds CI time.
|
||||
- **Config-only test files (`validate.*.yaml`):** use this prefix when a YAML file only needs to exercise
|
||||
schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with
|
||||
`esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml`: base config-only test
|
||||
- `validate-<variant>.<platform>.yaml`: config-only variant
|
||||
|
||||
Use this for deprecated-syntax migration tests, schema edge cases, or platform-specific validation
|
||||
branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and
|
||||
`validate.*.yaml` files. Validate files never participate in bus grouping; each one runs as its own
|
||||
`esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml`
|
||||
changes, and the component isn't pulled in as a dependency of another changed component), CI skips the
|
||||
compile stage for that component and only runs config validation. This is decided in
|
||||
`script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the
|
||||
`validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
## 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/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works; the
|
||||
grouping scripts only understand dict-style packages. Never use list-style packages
|
||||
(`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed
|
||||
by the bus name; the component's `common.yaml` is keyed by the component name (e.g.
|
||||
`cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml - everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !include common.yaml
|
||||
```
|
||||
```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. `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a
|
||||
standalone entity with its own id instead.
|
||||
|
||||
A test that needs a shared component in a particular state (`esp32_ble` security settings, an option that
|
||||
PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
|
||||
To verify that all components can be tested together without ID conflicts or configuration issues:
|
||||
```bash
|
||||
./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.
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
name: config-validation
|
||||
description: Write or change ESPHome configuration validation - CONFIG_SCHEMA, cv.Schema, cv.typed_schema, custom validator functions, config keys and CONF_ constants, renamed or deprecated keys, and the language schema that script/build_language_schema.py extracts from them. Use whenever a change touches a component's schema or a validator in config_validation.py, automation.py or a component's .py files.
|
||||
---
|
||||
|
||||
# Configuration validation
|
||||
|
||||
ESPHome validates YAML with `voluptuous` schemas built from `esphome/config_validation.py` (`cv`). The
|
||||
same schemas are also walked by `script/build_language_schema.py`, which produces the JSON language schema
|
||||
that editors and the dashboard use for completion and checking. A validation change is only finished when
|
||||
both still work.
|
||||
|
||||
## Writing schemas
|
||||
|
||||
- **Type hints:** validators are typed like any other function: `def validate_x(config: ConfigType) ->
|
||||
ConfigType:`, with `ConfigType` imported from `esphome.types`.
|
||||
- **One schema per model:** when a component's options depend on a `model` key, use
|
||||
`cv.typed_schema({...}, key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not
|
||||
one schema plus a validator that rejects keys and fills defaults afterwards.
|
||||
- **Reuse existing validators:** before writing a custom validator, check for an existing one in
|
||||
`config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components.
|
||||
For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and
|
||||
reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See
|
||||
how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
- **Common validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0,
|
||||
max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
- **Complex validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
- **Platform-specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`,
|
||||
`cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
- **Framework-specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
- **Schema extensions:**
|
||||
```python
|
||||
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))
|
||||
```
|
||||
- **Constants:** `esphome/const.py` is frozen; do not add new `CONF_` constants there. Define a
|
||||
component-local constant in the component's own `.py`; for a constant shared by multiple components, add
|
||||
it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is
|
||||
defined in three or more component files. Constants used in core files (those not under
|
||||
`esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI check.
|
||||
- **Renamed or removed keys:** see the `breaking-changes` skill for `cv.rename_key` and manual
|
||||
deprecation warnings.
|
||||
- **Config-only tests:** schema edge cases and deprecated-syntax migrations go in `validate.*.yaml` test
|
||||
files (see the `component-tests` skill).
|
||||
|
||||
## Schema extraction
|
||||
|
||||
`script/build_language_schema.py` sets `esphome.schema_extractors.EnableSchemaExtraction = True` before it
|
||||
imports any component, then walks every `CONFIG_SCHEMA` (and platform schema) recursively. It understands
|
||||
`cv.Schema`, `cv.All`, `cv.Any`, dicts of `cv.Required` / `cv.Optional` keys, `.extend()`, `cv.ensure_list`,
|
||||
`cv.typed_schema`, registries and the known `cv` validators. **A plain function it cannot see into**: a
|
||||
custom validator that builds or picks a schema at validation time hides every key behind it, and the
|
||||
dumper silently emits nothing for them. CI only runs the script with `--check`, which does not compare the
|
||||
output, so a lost schema is not caught.
|
||||
|
||||
When a validator function wraps, chooses or builds a schema (rather than checking a single value), make it
|
||||
visible to the dumper:
|
||||
|
||||
1. Decorate it with `@schema_extractor("<type>")` from `esphome.schema_extractors`.
|
||||
2. At the top of the function, return the data for that type when it is called with `SCHEMA_EXTRACT`
|
||||
instead of a config value. Compare with `is` (`value is SCHEMA_EXTRACT`); older code uses `==`.
|
||||
|
||||
```python
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
return _OUTGOING_CONNECTION_SCHEMA
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
```
|
||||
|
||||
The type names the dumper accepts, and what `SCHEMA_EXTRACT` must return for each, are in the
|
||||
`hidden_schemas` branch of `convert()` in `script/build_language_schema.py`. The common ones:
|
||||
|
||||
| Type | Return for `SCHEMA_EXTRACT` | Example |
|
||||
|------|-----------------------------|---------|
|
||||
| `"schema"` | the schema (or a representative one) to walk | `api` `_outgoing_connection_schema`, `restore_state_schema` in `esphome/core/entity_helpers.py`, `model_schema_extractor` in `esphome/components/mipi/__init__.py` |
|
||||
| `"enum"` | the dict of options | `sensor_entity_category` in `esphome/components/sensor/__init__.py` |
|
||||
| `"one_of"` | the list of options | `cv.one_of` |
|
||||
| `"maybe"` | `(validator, key)` for a scalar shorthand | `cv.maybe_simple_value`, `maybe_conf` in `esphome/automation.py` |
|
||||
| `"templatable"` | the inner validator | `cv.templatable` |
|
||||
| `"automation"` | the automation schema | `automation.validate_automation` |
|
||||
|
||||
Points to watch:
|
||||
|
||||
- The dumper matches the function object by `repr`, so the decorated function must be the exact object
|
||||
placed in the schema. In a factory that returns a new closure per call, decorate the inner function (as
|
||||
`restore_state_schema` and `cv.maybe_simple_value` do), not the factory.
|
||||
- The decorator only records anything when extraction is enabled, and that flag is set before imports, so
|
||||
the decorator must run at import time (module level or inside a factory called at module level).
|
||||
- A schema chosen per config value (for example by `model`) has no single answer: return a representative
|
||||
schema, as `model_schema_extractor` does.
|
||||
- Plain value checks (`cv.All(cv.string, my_check)`) need nothing: the dumper still sees the surrounding
|
||||
types and keys.
|
||||
- `cv.Schema`, `cv.typed_schema`, `cv.ensure_list` and the `cv` helpers are already handled; prefer
|
||||
composing them over a custom function, which also avoids the extractor work.
|
||||
|
||||
### Checking the output
|
||||
|
||||
Build the language schema into a scratch directory and look at the component's JSON (it takes about ten
|
||||
seconds):
|
||||
|
||||
```bash
|
||||
python3 script/build_language_schema.py --output-path /tmp/schema
|
||||
python3 -c "import json; print(json.dumps(json.load(open('/tmp/schema/my_component.json')), indent=1))" | less
|
||||
```
|
||||
|
||||
Every key a user can write should appear under `config_vars` with a sensible `type`; compare against the
|
||||
same output from `dev` when changing an existing schema. If the script prints `Unknown extracted schema
|
||||
type` or raises, the decorator type or the value returned for `SCHEMA_EXTRACT` is wrong. Unit tests for
|
||||
the dumper live in `tests/script/test_build_language_schema.py`.
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
name: embedded-memory
|
||||
description: Choose ESPHome C++ containers and storage that avoid heap use and fragmentation on microcontrollers - std::array, StaticVector with cg.slot_counter, FixedVector, std::unique_ptr byte buffers, StringRef, RAMAllocator, and what to avoid (std::vector growth, std::map, std::deque, std::string, new (std::nothrow)). Use when adding or changing C++ members, containers, buffers, strings or registration lists, or when reducing RAM or flash use.
|
||||
---
|
||||
|
||||
# Memory on embedded targets
|
||||
|
||||
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.
|
||||
|
||||
## Container guidelines
|
||||
|
||||
1. **Compile-time-known sizes:** use `std::array` instead of `std::vector` when size is known at compile
|
||||
time.
|
||||
```cpp
|
||||
// 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. It does **not** provide bounds checking or iterator support like
|
||||
`std::vector<uint8_t>`; use it only when you do not need these features.
|
||||
```cpp
|
||||
// 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;
|
||||
```
|
||||
|
||||
2. **Compile-time-known fixed sizes with vector-like API:** use `StaticVector` from
|
||||
`esphome/core/helpers.h` for compile-time fixed size with a `push_back()` interface (no dynamic
|
||||
allocation).
|
||||
```cpp
|
||||
// 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.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that is
|
||||
known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it returns a
|
||||
function that each consumer calls once per slot it will occupy, and after every `to_code` has run it
|
||||
emits the define with the final count. When nothing registers, no define is emitted and the storage plus
|
||||
its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a `MULTI_CONF`
|
||||
component), pass the owning object as the key, `_request_listener_slot(str(hub))`; the define is then
|
||||
the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL`; a late request
|
||||
raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** use `FixedVector` from `esphome/core/helpers.h` when the size is only known at
|
||||
runtime initialization.
|
||||
```cpp
|
||||
// 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
|
||||
```
|
||||
This eliminates `_M_realloc_insert` and `_M_default_append` template instantiations (200-500 bytes per
|
||||
instance), needs no upper bound, and is compatible with protobuf code generation when using
|
||||
`[(fixed_vector) = true]`.
|
||||
|
||||
4. **Small datasets (1-16 elements):** use `std::vector` or `std::array` with simple structs instead of
|
||||
`std::map`/`std::set`/`std::unordered_map`.
|
||||
```cpp
|
||||
// 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 is often faster than hashing or tree overhead, but this depends on
|
||||
lookup frequency; in hot paths the O(1) vs O(n) difference may still matter. It is the heavy containers
|
||||
(`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows
|
||||
otherwise.
|
||||
|
||||
5. **Strings set once from configuration:** use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the program,
|
||||
so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a non-owning pointer plus
|
||||
length; it does not copy, and it must only ever refer to storage that outlives it (a string literal, or
|
||||
a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **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.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** on ESP-IDF exceptions are disabled, so a failed nothrow allocation
|
||||
aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects
|
||||
`std::nothrow`.
|
||||
|
||||
8. **Detection:** look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
- Red-black tree code (`rb_tree`, `_Rb_tree`)
|
||||
- Hash table infrastructure (`unordered_map`, `hash`)
|
||||
|
||||
Callback storage (`CallbackManager` vs `LazyCallbackManager`) and templated registration methods are
|
||||
covered in the `automations` skill.
|
||||
|
||||
## Where to spend effort
|
||||
|
||||
Prioritize container optimization for core components (API, network, logger), widely used components
|
||||
(mdns, wifi, ble), and components causing flash size complaints. Avoiding heap allocation after `setup()`
|
||||
is always required regardless of component type; the priority list is about effort spent on container
|
||||
migration (e.g. `std::vector` to `StaticVector`).
|
||||
@@ -462,6 +462,7 @@ jobs:
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run integration tests
|
||||
id: integration
|
||||
env:
|
||||
# JSON array of test paths; parsed into a bash array below to avoid
|
||||
# shell word-splitting / glob hazards.
|
||||
@@ -477,6 +478,32 @@ jobs:
|
||||
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
|
||||
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||
- name: Re-run the failed tests with debug symbols
|
||||
# The first run builds without -g. A crash in the re-run prints a gdb
|
||||
# backtrace of its core; exit 1 keeps the job red when the re-run passes.
|
||||
if: failure() && steps.integration.outcome == 'failure'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
ESPHOME_INTEGRATION_DEBUG_SYMBOLS: "1"
|
||||
ESPHOME_INTEGRATION_CORE_DIR: /tmp/esphome-cores
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
# The image's package lists usually suffice; refresh them only if not
|
||||
if ! command -v gdb >/dev/null &&
|
||||
! sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 apt-get install -y -qq gdb; then
|
||||
sudo timeout -k 15 300 apt-get update -qq &&
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 apt-get install -y -qq gdb ||
|
||||
echo "::warning::gdb install failed; no backtraces"
|
||||
fi
|
||||
{
|
||||
mkdir -p "$ESPHOME_INTEGRATION_CORE_DIR" &&
|
||||
echo "$ESPHOME_INTEGRATION_CORE_DIR/core.%p" | sudo tee /proc/sys/kernel/core_pattern &&
|
||||
ulimit -c unlimited
|
||||
} || echo "::warning::core dumps not enabled; no backtraces"
|
||||
# Under the step limit, so a hung test still reports before Actions kills the step
|
||||
pytest -vv --no-cov --tb=native -n auto --dist worksteal --timeout=600 \
|
||||
--lf --lfnf=none tests/integration || true
|
||||
exit 1
|
||||
- name: Upload test artifacts
|
||||
# Tests that compare rendered output write the image they actually got here, so a
|
||||
# failure can be looked at without reproducing the whole build locally.
|
||||
|
||||
@@ -54,19 +54,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each.
|
||||
* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes.
|
||||
|
||||
* **Skills:** detailed recipes live in skills under `.agents/skills/` (also reachable as `.claude/skills/` and
|
||||
`.github/skills/`). Load the matching skill before doing that kind of work; agents without skill support
|
||||
should read the `SKILL.md` file directly.
|
||||
* `component-codegen`: component files and metadata, `to_code`, hub child entities, codegen helpers,
|
||||
`CORE.data` state.
|
||||
* `config-validation`: schemas, validators, `CONF_` constants, and keeping schema extraction working.
|
||||
* `automations`: triggers, actions, conditions and callback registration.
|
||||
* `embedded-memory`: container and string choices that avoid heap use after `setup()`.
|
||||
* `component-tests`: YAML build tests, bus packages and CI grouping, C++ unit tests, integration tests.
|
||||
* `breaking-changes`: what is public API, deprecation patterns and migration notes.
|
||||
* `pr-workflow`, `code-review`, `hardware-test`, `display-test`: creating PRs, reviewing them, and testing
|
||||
on real boards and panels.
|
||||
|
||||
* **Formatting:**
|
||||
* **Python:** Uses `ruff` for linting and formatting. Configuration is in `pyproject.toml`.
|
||||
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
|
||||
@@ -239,6 +226,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
|
||||
resend and give-up logic handles it; never advance on an answer that was only logged.
|
||||
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
|
||||
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
|
||||
listener base (a second vptr per entity).
|
||||
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
|
||||
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
|
||||
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
|
||||
@@ -289,28 +279,398 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
};
|
||||
```
|
||||
|
||||
* **Component Code Generation:** every component's `__init__.py` defines `DOMAIN` (its own name, checked by
|
||||
CI). Hub entities go in platform files (`sensor.py`, ...) that take the hub's id, not under the hub's own
|
||||
key. File layout, metadata, the `to_code` pattern, `sub_sensors` and the other codegen helpers are in the
|
||||
`component-codegen` skill.
|
||||
* **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
|
||||
```
|
||||
|
||||
* **Automations (Triggers, Actions, Conditions):** prefer `build_callback_automation()` over a `Trigger`
|
||||
class, and `register_apply_action` / `register_apply_condition` over a C++ class that only forwards to its
|
||||
parent. Callback registration methods are always `template<typename F>`, never `std::function`. Details
|
||||
and examples are in the `automations` skill.
|
||||
* **Component Metadata:**
|
||||
- `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
|
||||
string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: List of required components
|
||||
- `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's
|
||||
own key; see "Entities of a hub" below.
|
||||
- `CONFLICTS_WITH`: Incompatible components
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance
|
||||
- `MULTI_CONF`: Whether multiple instances are allowed
|
||||
|
||||
* **Code Generation & Common Patterns:**
|
||||
* **Configuration Schema Pattern:**
|
||||
```python
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
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:**
|
||||
```cpp
|
||||
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:**
|
||||
```python
|
||||
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:**
|
||||
```python
|
||||
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:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
- **Entities of a hub:** a new component's entities go in platform files (`sensor.py`,
|
||||
`binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms
|
||||
added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is
|
||||
fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
- **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`,
|
||||
`sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented<T>`,
|
||||
then make one call per key, even when there is only one. A call creates the entity only when its key
|
||||
is configured, passes it to the setter and returns it (or `None`); extra arguments such as
|
||||
`min_value` or `options` go on the call. Always name the setter explicitly on the object that owns
|
||||
it, never with `getattr` and an f-string, and keep that variable short (`var` for the component
|
||||
itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose
|
||||
setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`.
|
||||
|
||||
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
|
||||
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
|
||||
the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which
|
||||
already applies `inverted`.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
constructs, registers and attaches in one call; `display.add_writer(var, config)` sets the lambda;
|
||||
`MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict; `button.restart_button_schema(cls)` (and
|
||||
`factory_reset_`, `query_`) are button presets; `fv.get_declaring_config(id)` finds a hub's config in
|
||||
final validation.
|
||||
|
||||
* **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:**
|
||||
```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=[]`:
|
||||
```python
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only
|
||||
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
|
||||
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
|
||||
`forward` from `MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**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):
|
||||
```cpp
|
||||
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:**
|
||||
```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++:**
|
||||
```cpp
|
||||
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:**
|
||||
```cpp
|
||||
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 it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and
|
||||
the `@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
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.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them
|
||||
with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
|
||||
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
|
||||
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
|
||||
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
* **Conditions:**
|
||||
```cpp
|
||||
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_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares
|
||||
against config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
|
||||
|
||||
* **Configuration Validation:** follow the `config-validation` skill. In short: one `cv.typed_schema` per
|
||||
model, reuse validators from `config_validation.py`, and never add `CONF_` constants to the frozen
|
||||
`esphome/const.py`.
|
||||
* **Schema extraction:** whenever config validation code is added or changed, make sure
|
||||
`script/build_language_schema.py` still extracts the full schema. The dumper cannot see inside a plain
|
||||
function, so a validator function that wraps, chooses or builds a schema must be decorated with
|
||||
`@schema_extractor(...)` and return the schema data when called with `SCHEMA_EXTRACT` (both from
|
||||
`esphome.schema_extractors`). CI does not catch a missing schema; build the schema and check the
|
||||
component's JSON as the `config-validation` skill describes.
|
||||
* **Configuration Validation:**
|
||||
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
|
||||
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
|
||||
that rejects keys and fills defaults afterwards.
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
* **Schema Extensions:**
|
||||
```python
|
||||
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))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -330,12 +690,71 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
* **Component tests:** YAML build tests live in `tests/components/<component>/` and run with
|
||||
`script/test_build_components -c <component>`. Never define buses directly in test YAML: include the
|
||||
shared packages from `tests/test_build_components/common/` with dict-style `packages:`. Test file naming,
|
||||
`validate.*.yaml` config-only tests, CI grouping, C++ unit tests and integration tests are in the
|
||||
`component-tests` skill.
|
||||
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
|
||||
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
|
||||
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
|
||||
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
|
||||
`responses` match on content every time they see it; to answer a repeated request differently, use an
|
||||
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
|
||||
on. A variant per board for platform-identical code only adds CI time.
|
||||
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
|
||||
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
* **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 # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
|
||||
|
||||
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml` — base config-only test
|
||||
- `validate-<variant>.<platform>.yaml` — config-only variant
|
||||
|
||||
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **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/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !include common.yaml
|
||||
```
|
||||
```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.
|
||||
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
|
||||
|
||||
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
|
||||
```bash
|
||||
./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.
|
||||
* **Debugging and Troubleshooting:**
|
||||
* **Debug Tools:**
|
||||
- `esphome config <file>.yaml` to validate configuration.
|
||||
@@ -349,7 +768,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
## 7. Specific Instructions for AI Collaboration
|
||||
|
||||
* **Contribution Workflow (Pull Request Process):** the `pr-workflow` skill walks through these steps.
|
||||
* **Contribution Workflow (Pull Request Process):**
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
@@ -369,15 +788,236 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **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.
|
||||
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** ESP devices run for
|
||||
months with small shared heaps, and repeated allocations fragment them until a large allocation fails; we
|
||||
have seen field crashes caused by this. ESPHome treats runtime heap allocation as a long-term reliability
|
||||
bug, not a performance issue. Prefer `std::array`, `StaticVector` (sized with `cg.slot_counter()` for
|
||||
registration lists), `FixedVector` and `StringRef` over growing `std::vector`, `std::map`, `std::deque`
|
||||
and `std::string`. The `embedded-memory` skill has the full container guidelines.
|
||||
**Why Heap Allocation Matters:**
|
||||
|
||||
* **State Management:** keep code generation state in `CORE.data`, namespaced under the component's
|
||||
`DOMAIN` in a `@dataclass`, never in module-level mutable globals (see the `component-codegen` skill).
|
||||
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:
|
||||
|
||||
1. **Compile-time-known sizes:** Use `std::array` instead of `std::vector` when size is known at compile time.
|
||||
```cpp
|
||||
// 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 like `std::vector<uint8_t>`. Use it only when you do not need these features and want minimal overhead.
|
||||
|
||||
```cpp
|
||||
// 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;
|
||||
```
|
||||
|
||||
2. **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).
|
||||
```cpp
|
||||
// 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.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that
|
||||
is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it
|
||||
returns a function that each consumer calls once per slot it will occupy, and after every
|
||||
`to_code` has run it emits the define with the final count. When nothing registers, no define is
|
||||
emitted and the storage plus its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a
|
||||
`MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`;
|
||||
the define is then the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late
|
||||
request raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization.
|
||||
```cpp
|
||||
// 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:**
|
||||
- Eliminates `_M_realloc_insert`, `_M_default_append` template instantiations (saves 200-500 bytes per instance)
|
||||
- Single allocation, no upper bound needed
|
||||
- No reallocation overhead
|
||||
- Compatible with protobuf code generation when using `[(fixed_vector) = true]` option
|
||||
|
||||
4. **Small datasets (1-16 elements):** Use `std::vector` or `std::array` with simple structs instead of `std::map`/`std::set`/`std::unordered_map`.
|
||||
```cpp
|
||||
// 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.
|
||||
|
||||
5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the
|
||||
program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a
|
||||
non-owning pointer plus length; it does not copy, and it must only ever refer to storage that
|
||||
outlives it (a string literal, or a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **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.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`.
|
||||
|
||||
8. **Detection:** Look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
- Red-black tree code (`rb_tree`, `_Rb_tree`)
|
||||
- Hash table infrastructure (`unordered_map`, `hash`)
|
||||
|
||||
**Prioritize optimization effort for:**
|
||||
- Core components (API, network, logger)
|
||||
- Widely-used components (mdns, wifi, ble)
|
||||
- Components causing flash size complaints
|
||||
|
||||
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:
|
||||
```cpp
|
||||
// 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):**
|
||||
```python
|
||||
# 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):**
|
||||
```python
|
||||
# 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):**
|
||||
```python
|
||||
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:**
|
||||
- Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec
|
||||
- `CORE.data` automatically clears between runs
|
||||
- Namespacing under `DOMAIN` prevents key collisions between components
|
||||
- `@dataclass` provides type safety and cleaner attribute access
|
||||
|
||||
* **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys.
|
||||
|
||||
@@ -388,11 +1028,57 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
## 8. Public API and Breaking Changes
|
||||
|
||||
* Only features documented at [esphome.io](https://esphome.io) are public API for components; all `public`
|
||||
members of `esphome/core/` and base classes are public API. Aim for a 6-month deprecation window
|
||||
(`ESPDEPRECATED` in C++, `cv.rename_key(..., removed_in=...)` for config keys) and document the migration
|
||||
path in the PR description. The `breaking-changes` skill has the full policy, checklist and patterns.
|
||||
* **Public C++ API:**
|
||||
* **Components**: Only documented features at [esphome.io](https://esphome.io) are public API. Undocumented `public` members are internal.
|
||||
* **Core/Base Classes** (`esphome/core/`, `Component`, `Sensor`, etc.): All `public` members are public API.
|
||||
* **Components with Global Accessors** (`global_api_server`, etc.): All `public` members are public API (except config setters).
|
||||
|
||||
* **Public Python API:**
|
||||
* All documented configuration options at [esphome.io](https://esphome.io) are public API.
|
||||
* Python code in `esphome/core/` actively used by existing core components is considered stable API.
|
||||
* Other Python code is internal unless explicitly documented for external component use.
|
||||
|
||||
* **Breaking Changes Policy:**
|
||||
* Aim for **6-month deprecation window** when possible
|
||||
* Clean breaks allowed for: signature changes, deep refactorings, resource constraints
|
||||
* Must document migration path in PR description (generates release notes)
|
||||
* Blog post required for core/base class changes or significant architectural changes
|
||||
* Full details: https://developers.esphome.io/contributing/code/#public-api-and-breaking-changes
|
||||
|
||||
* **Breaking Change Checklist:**
|
||||
- [ ] Clear justification (RAM/flash savings, architectural improvement)
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++)
|
||||
- [ ] Documented migration path in PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during deprecation period
|
||||
|
||||
* **Deprecation Pattern (C++):**
|
||||
```cpp
|
||||
// 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):**
|
||||
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```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
|
||||
```
|
||||
## 9. English Language
|
||||
|
||||
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
|
||||
|
||||
@@ -439,7 +439,6 @@ esphome/components/modbus_controller/select/* @martgras @stegm
|
||||
esphome/components/modbus_controller/sensor/* @martgras
|
||||
esphome/components/modbus_controller/switch/* @martgras
|
||||
esphome/components/modbus_controller/text_sensor/* @martgras
|
||||
esphome/components/modbus_monitor/* @Bascht74
|
||||
esphome/components/modbus_server/* @exciton
|
||||
esphome/components/modem/* @oarcher
|
||||
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
|
||||
|
||||
+6
-4
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Protocol
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.build_helpers.native import native_backend
|
||||
from esphome.build_helpers.native import analysis_backend, native_backend
|
||||
from esphome.const import (
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
@@ -2138,7 +2138,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
analysis_toolchain = native_backend()
|
||||
analysis_toolchain = analysis_backend()
|
||||
if analysis_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
@@ -2147,9 +2147,11 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
if hasattr(analysis_toolchain, "check_analysis_supported"):
|
||||
if (
|
||||
check_supported := getattr(analysis_toolchain, "check_analysis_supported", None)
|
||||
) is not None:
|
||||
# Raises with the reason; before the compile, not after it
|
||||
analysis_toolchain.check_analysis_supported()
|
||||
check_supported()
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
|
||||
@@ -93,7 +93,7 @@ def _split_command(command: str) -> list[str]:
|
||||
CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW
|
||||
CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
|
||||
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
|
||||
argc = ctypes.c_int(0)
|
||||
argc = ctypes.c_int()
|
||||
argv = CommandLineToArgvW(command, ctypes.byref(argc))
|
||||
if not argv: # pragma: no cover
|
||||
raise ctypes.WinError()
|
||||
@@ -296,10 +296,10 @@ def load_or_build_idedata(
|
||||
) -> dict | None:
|
||||
"""Return idedata for a compile_commands.json build, cached on mtime.
|
||||
|
||||
Shared by the native toolchains. Returns None when the compile DB
|
||||
doesn't exist yet (nothing was built). ``launcher`` is the
|
||||
compiler-launcher path (ccache) the build was generated with, if any;
|
||||
commands in the compile DB are prefixed with it.
|
||||
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
|
||||
when the compile DB doesn't exist yet (nothing was built). ``launcher``
|
||||
is the compiler-launcher path (ccache) the build was generated with, if
|
||||
any; commands in the compile DB are prefixed with it.
|
||||
"""
|
||||
if not compile_commands.is_file():
|
||||
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
|
||||
|
||||
@@ -22,7 +22,6 @@ NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
("host", Toolchain.HOST): "esphome.host.toolchain",
|
||||
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +39,24 @@ def native_backend() -> ModuleType | None:
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
# Binutils and the linked image for memory analysis, for toolchains that build
|
||||
# without PlatformIO but have no native build backend (which supplies them)
|
||||
ANALYSIS_TOOLCHAIN_MODULES = {
|
||||
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
|
||||
}
|
||||
|
||||
|
||||
def analysis_backend() -> ModuleType | None:
|
||||
"""The module giving objdump, readelf and the ELF of a non-PlatformIO build.
|
||||
|
||||
None means PlatformIO's idedata supplies them (or nothing can).
|
||||
"""
|
||||
if (native := native_backend()) is not None:
|
||||
return native
|
||||
module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain))
|
||||
return importlib.import_module(module_path) if module_path else None
|
||||
|
||||
|
||||
def warn_ignored_platformio_options(consumed: Collection[str]) -> None:
|
||||
"""Warn for component-added platformio options a native build drops.
|
||||
|
||||
|
||||
@@ -289,12 +289,8 @@ using socket_t = int;
|
||||
#elif defined(CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN) && defined(__APPLE__)
|
||||
#include <TargetConditionals.h>
|
||||
#if TARGET_OS_OSX
|
||||
// The precompiled header brings in ArduinoJson.h, whose `using namespace ArduinoJson` makes MacTypes.h's
|
||||
// global Ptr ambiguous with ArduinoJson's Ptr class; give the macOS one another name here
|
||||
#define Ptr esphome_mactypes_Ptr
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <Security/Security.h>
|
||||
#undef Ptr
|
||||
#endif // TARGET_OS_OSX
|
||||
#endif // _WIN32
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from esphome import automation, pins
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
from esphome.components.const import CONF_ROLE
|
||||
@@ -170,37 +170,6 @@ def reject_broadcast_options_for_unicast(
|
||||
return validator
|
||||
|
||||
|
||||
# Raw PDU; valid only during the automation.
|
||||
PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
_request_on_request_slot = cg.slot_counter("MODBUS_ON_REQUEST_COUNT")
|
||||
|
||||
|
||||
async def register_on_request_automation(hub: MockObj, config: ConfigType) -> None:
|
||||
"""Run an automation for every request the hub reads (server) or sends (client)."""
|
||||
_request_on_request_slot(str(hub))
|
||||
await automation.build_callback_automation(
|
||||
hub,
|
||||
"add_on_request_callback",
|
||||
[(cg.uint8, "address"), (PDU_SPAN, "request")],
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
_request_on_response_slot = cg.slot_counter("MODBUS_ON_RESPONSE_COUNT")
|
||||
|
||||
|
||||
async def register_on_response_automation(hub: MockObj, config: ConfigType) -> None:
|
||||
"""Run an automation for every response the hub gets (client), or sends or sees from another device (server)."""
|
||||
_request_on_response_slot(str(hub))
|
||||
await automation.build_callback_automation(
|
||||
hub,
|
||||
"add_on_response_callback",
|
||||
[(cg.uint8, "address"), (PDU_SPAN, "request"), (PDU_SPAN, "response")],
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def reject_inapplicable_command_options(
|
||||
pdu_key: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
|
||||
@@ -319,22 +319,13 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
|
||||
|
||||
// Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends
|
||||
// a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked.
|
||||
// This requires copying the frame data out of the rx buffer beforehand.
|
||||
const uint16_t pdu_len = frame_length - 3; // less the address byte and the CRC
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
uint8_t *pdu_buffer = this->request_;
|
||||
this->request_len_ = static_cast<uint8_t>(pdu_len);
|
||||
#else
|
||||
uint8_t pdu_buffer[MAX_PDU_SIZE];
|
||||
#endif
|
||||
std::memcpy(pdu_buffer, this->rx_buffer_.data() + 1, pdu_len);
|
||||
const std::span<const uint8_t> pdu(pdu_buffer, pdu_len);
|
||||
const std::span<const uint8_t> data = pdu.subspan(1); // after the function code
|
||||
// This requires copying the frame data to a local buffer beforehand.
|
||||
uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
|
||||
uint16_t data_len = frame_length - 2 - data_offset;
|
||||
uint8_t data_buffer[MAX_FRAME_SIZE] = {};
|
||||
std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len);
|
||||
std::span<const uint8_t> data(data_buffer, data_len);
|
||||
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
// Before a device here answers, so the monitor sees the bus in order.
|
||||
this->request_callback_.call(address, pdu);
|
||||
#endif
|
||||
|
||||
if (address == BROADCAST_ADDRESS) {
|
||||
// Keep the unicast response buffers out of the broadcast call chain.
|
||||
@@ -398,27 +389,15 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
|
||||
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address,
|
||||
us_since_send(this->last_modbus_byte_, this->last_send_));
|
||||
}
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
// After the terminal, so the handler can queue the same request again
|
||||
this->response_callback_.call(address, cmd->frame.pdu(), pdu);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) {
|
||||
void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t>) {
|
||||
if (this->find_device_(address) != nullptr) {
|
||||
ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address);
|
||||
}
|
||||
|
||||
if (this->expecting_peer_response_ == address) {
|
||||
ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address);
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
if (helpers::pdu_function_code(pdu) == this->request_[0]) {
|
||||
this->response_callback_.call(address, std::span<const uint8_t>(this->request_, this->request_len_), pdu);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Response from peer %" PRIu8 " has function 0x%02X, request had 0x%02X; not paired", address,
|
||||
pdu[0], this->request_[0]);
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address);
|
||||
}
|
||||
@@ -869,13 +848,9 @@ void ModbusClientHub::send_next_frame_() {
|
||||
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected");
|
||||
cmd->complete_broadcast();
|
||||
this->sweep_needed_ = true;
|
||||
} else {
|
||||
this->waiting_for_response_ = true;
|
||||
return;
|
||||
}
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
// After complete_broadcast(), so a broadcast queued from the handler is not merged into this one.
|
||||
this->request_callback_.call(cmd->frame.address(), cmd->frame.pdu());
|
||||
#endif
|
||||
this->waiting_for_response_ = true;
|
||||
}
|
||||
|
||||
void ModbusClientHub::dump_config() {
|
||||
@@ -1243,7 +1218,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
|
||||
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
|
||||
this->deferred_payload_len_ - 1);
|
||||
this->deferred_payload_len_ = 0;
|
||||
if (!this->send_reply_(frame)) {
|
||||
if (!this->send_frame_(frame)) {
|
||||
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
|
||||
}
|
||||
});
|
||||
@@ -1251,22 +1226,11 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
|
||||
}
|
||||
|
||||
ModbusFrame frame(payload[0], payload + 1, len - 1);
|
||||
if (!this->send_reply_(frame)) {
|
||||
if (!this->send_frame_(frame)) {
|
||||
ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay");
|
||||
}
|
||||
}
|
||||
|
||||
bool ModbusServerHub::send_reply_(const ModbusFrame &frame) {
|
||||
if (!this->send_frame_(frame))
|
||||
return false;
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
// Only once on the wire: a reply that is dropped is not reported.
|
||||
this->response_callback_.call(frame.address(), std::span<const uint8_t>(this->request_, this->request_len_),
|
||||
frame.pdu());
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
|
||||
size_t bytes = this->rx_buffer_.size();
|
||||
if (bytes_to_clear > 0 && bytes >= bytes_to_clear)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
#include "esphome/components/modbus/modbus_definitions.h"
|
||||
@@ -14,7 +13,6 @@
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace esphome::modbus {
|
||||
|
||||
@@ -60,19 +58,6 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
virtual bool tx_blocked();
|
||||
|
||||
void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; }
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
/// Every request read (server, before a device answers) or sent (client, retries included); PDU valid in the call.
|
||||
template<typename F> void add_on_request_callback(F &&callback) {
|
||||
this->request_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
/// Every response received (client) or sent or seen (server); PDUs valid during the call. A server pairs a peer's
|
||||
/// reply with the last request by address and function code only.
|
||||
template<typename F> void add_on_response_callback(F &&callback) {
|
||||
this->response_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
void receive_bytes_();
|
||||
@@ -101,13 +86,6 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
GPIOPin *flow_control_pin_{nullptr};
|
||||
|
||||
std::vector<uint8_t> rx_buffer_;
|
||||
#ifdef MODBUS_ON_REQUEST_COUNT
|
||||
StaticCallbackManager<MODBUS_ON_REQUEST_COUNT, void(uint8_t, std::span<const uint8_t>)> request_callback_;
|
||||
#endif
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
StaticCallbackManager<MODBUS_ON_RESPONSE_COUNT, void(uint8_t, std::span<const uint8_t>, std::span<const uint8_t>)>
|
||||
response_callback_;
|
||||
#endif
|
||||
};
|
||||
|
||||
class ModbusClientDevice;
|
||||
@@ -382,18 +360,11 @@ class ModbusServerHub : public Modbus {
|
||||
uint16_t number_of_registers, const RegisterValues ®isters,
|
||||
std::span<uint8_t> response_buffer, uint16_t &response_len);
|
||||
void send_raw_(const uint8_t *payload, uint16_t len);
|
||||
// Every reply of this hub goes on the wire here, so on_response sees each one once, exceptions included.
|
||||
bool send_reply_(const ModbusFrame &frame);
|
||||
// Sends and logs the exception reply when status holds one; returns true if the request was rejected.
|
||||
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
|
||||
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
|
||||
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
|
||||
uint8_t expecting_peer_response_{0};
|
||||
#ifdef MODBUS_ON_RESPONSE_COUNT
|
||||
// The last request, for on_response with its reply from a device here or another one.
|
||||
uint8_t request_len_{0};
|
||||
uint8_t request_[MAX_PDU_SIZE];
|
||||
#endif
|
||||
std::vector<ModbusServerDevice *> devices_;
|
||||
|
||||
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
|
||||
|
||||
@@ -72,7 +72,7 @@ ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
|
||||
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
|
||||
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
|
||||
_ADDRESS_ARG = (cg.uint8, "address")
|
||||
_PDU_SPAN = modbus.PDU_SPAN
|
||||
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
|
||||
# (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import modbus
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ON_RESPONSE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bascht74"]
|
||||
DEPENDENCIES = ["modbus"]
|
||||
DOMAIN = "modbus_monitor"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_ON_REQUEST = "on_request"
|
||||
|
||||
|
||||
_HANDLER = automation.validate_synchronous_automation(
|
||||
"modbus_monitor handlers", "the request/response data", single=True
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.Modbus),
|
||||
cv.Optional(CONF_ON_REQUEST): _HANDLER,
|
||||
cv.Optional(CONF_ON_RESPONSE): _HANDLER,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_ON_REQUEST, CONF_ON_RESPONSE),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
|
||||
if on_request := config.get(CONF_ON_REQUEST):
|
||||
await modbus.register_on_request_automation(hub, on_request)
|
||||
if on_response := config.get(CONF_ON_RESPONSE):
|
||||
await modbus.register_on_response_automation(hub, on_response)
|
||||
@@ -1302,9 +1302,4 @@ def run_compile(args, config: ConfigType) -> bool:
|
||||
if not run_command_ok(genpkg_cmd, env=env, stream_output=True):
|
||||
raise EsphomeError("Failed to create the DFU package")
|
||||
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
from .toolchain import get_idedata
|
||||
|
||||
warn_if_idedata_missing(get_idedata)
|
||||
return True
|
||||
@@ -1,8 +1,4 @@
|
||||
"""The native build backend of the nRF52 sdk-nrf toolchain.
|
||||
|
||||
The build itself runs from the platform's ``run_compile`` hook; this module
|
||||
gives the binutils, the linked image and idedata of that build.
|
||||
"""
|
||||
"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -30,17 +26,3 @@ def get_elf_path() -> Path:
|
||||
if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2):
|
||||
return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf")
|
||||
return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf")
|
||||
|
||||
|
||||
def get_idedata() -> dict | None:
|
||||
"""Derive idedata from the app's compile_commands.json, next to its
|
||||
``zephyr/`` output directory. Returns None if nothing has been built yet."""
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
elf = get_elf_path()
|
||||
# No launcher: Zephyr's CMake leaves ccache out of the compile database
|
||||
return load_or_build_idedata(
|
||||
elf.parent.parent / "compile_commands.json",
|
||||
elf,
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.json"),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "ota_web_server.h"
|
||||
#ifdef USE_WEBSERVER_OTA
|
||||
|
||||
#include "esphome/components/ota/ota_backend_factory.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -8,37 +9,73 @@
|
||||
#include "esphome/components/captive_portal/captive_portal.h"
|
||||
#endif
|
||||
|
||||
#if USE_ESP32
|
||||
using PlatformString = std::string;
|
||||
#elif USE_ARDUINO
|
||||
using PlatformString = String;
|
||||
#endif
|
||||
|
||||
namespace esphome::web_server {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "web_server.ota");
|
||||
|
||||
bool WebServerOTAComponent::canHandle(AsyncWebServerRequest *request) const {
|
||||
if (request->method() != HTTP_POST)
|
||||
return false;
|
||||
// Check if this is an OTA update request
|
||||
class OTARequestHandler : public AsyncWebHandler {
|
||||
public:
|
||||
OTARequestHandler(WebServerOTAComponent *parent) : parent_(parent) {}
|
||||
void handleRequest(AsyncWebServerRequest *request) override;
|
||||
void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data,
|
||||
size_t len, bool final) override;
|
||||
bool canHandle(AsyncWebServerRequest *request) const override {
|
||||
if (request->method() != HTTP_POST)
|
||||
return false;
|
||||
// Check if this is an OTA update request
|
||||
#ifdef USE_ESP32
|
||||
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
|
||||
bool is_ota_request = request->url_to(url_buf) == "/update";
|
||||
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
|
||||
bool is_ota_request = request->url_to(url_buf) == "/update";
|
||||
#else
|
||||
bool is_ota_request = request->url() == ESPHOME_F("/update");
|
||||
bool is_ota_request = request->url() == ESPHOME_F("/update");
|
||||
#endif
|
||||
|
||||
#if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL)
|
||||
// IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
|
||||
// Captive portal can still perform OTA updates - check if request is from active captive portal
|
||||
// Note: global_captive_portal is the standard way components communicate in ESPHome
|
||||
return is_ota_request && captive_portal::global_captive_portal != nullptr &&
|
||||
captive_portal::global_captive_portal->is_active();
|
||||
// IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
|
||||
// Captive portal can still perform OTA updates - check if request is from active captive portal
|
||||
// Note: global_captive_portal is the standard way components communicate in ESPHome
|
||||
return is_ota_request && captive_portal::global_captive_portal != nullptr &&
|
||||
captive_portal::global_captive_portal->is_active();
|
||||
#elif defined(USE_WEBSERVER_OTA_DISABLED)
|
||||
// OTA disabled for web_server and no captive portal compiled in
|
||||
return false;
|
||||
// OTA disabled for web_server and no captive portal compiled in
|
||||
return false;
|
||||
#else
|
||||
// OTA enabled for web_server
|
||||
return is_ota_request;
|
||||
// OTA enabled for web_server
|
||||
return is_ota_request;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::report_ota_progress_(AsyncWebServerRequest *request) {
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
bool isRequestHandlerTrivial() const override { return false; }
|
||||
|
||||
protected:
|
||||
void report_ota_progress_(AsyncWebServerRequest *request);
|
||||
void schedule_ota_reboot_();
|
||||
void ota_init_(const char *filename);
|
||||
/// Abort and drop the backend, releasing the lock.
|
||||
void abort_session_();
|
||||
/// Reply "busy" without touching the running upload.
|
||||
void refuse_(AsyncWebServerRequest *request);
|
||||
|
||||
uint32_t last_ota_progress_{0};
|
||||
uint32_t ota_read_length_{0};
|
||||
WebServerOTAComponent *parent_;
|
||||
bool ota_success_{false};
|
||||
|
||||
private:
|
||||
ota::OTABackendPtr ota_backend_{nullptr};
|
||||
/// Request that owns ota_backend_.
|
||||
const AsyncWebServerRequest *ota_request_{nullptr};
|
||||
const AsyncWebServerRequest *refused_request_{nullptr};
|
||||
};
|
||||
|
||||
void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
|
||||
const uint32_t now = millis();
|
||||
if (now - this->last_ota_progress_ > 1000) {
|
||||
float percentage = 0.0f;
|
||||
@@ -54,28 +91,28 @@ void WebServerOTAComponent::report_ota_progress_(AsyncWebServerRequest *request)
|
||||
}
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Report progress - use notify_state_deferred_ since we're in web server task
|
||||
this->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
this->parent_->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
this->last_ota_progress_ = now;
|
||||
}
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::schedule_ota_reboot_() {
|
||||
void OTARequestHandler::schedule_ota_reboot_() {
|
||||
ESP_LOGI(TAG, "OTA update successful!");
|
||||
this->set_timeout(100, []() {
|
||||
this->parent_->set_timeout(100, []() {
|
||||
ESP_LOGI(TAG, "Performing OTA reboot now");
|
||||
App.safe_reboot();
|
||||
});
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::ota_init_(const char *filename) {
|
||||
void OTARequestHandler::ota_init_(const char *filename) {
|
||||
ESP_LOGI(TAG, "OTA Update Start: %s", filename);
|
||||
this->ota_read_length_ = 0;
|
||||
this->ota_success_ = false;
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index,
|
||||
uint8_t *data, size_t len, bool final) {
|
||||
void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index,
|
||||
uint8_t *data, size_t len, bool final) {
|
||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
|
||||
|
||||
// First byte of a new upload: index==0 with actual data. (web_server_idf
|
||||
@@ -93,7 +130,8 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
|
||||
if (!this->ota_backend_) {
|
||||
ESP_LOGE(TAG, "Failed to create OTA backend");
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN));
|
||||
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f,
|
||||
static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN));
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
@@ -115,14 +153,14 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Notify OTA started - use notify_state_deferred_ since we're in web server task
|
||||
this->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0);
|
||||
this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGE(TAG, "OTA begin failed: %d", error_code);
|
||||
this->ota_backend_.reset();
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
|
||||
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
@@ -150,7 +188,7 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
|
||||
this->ota_backend_.reset();
|
||||
this->ota_request_ = nullptr;
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
|
||||
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +209,7 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
|
||||
this->ota_success_ = true;
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Report completion before reboot - use notify_state_deferred_ since we're in web server task
|
||||
this->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0);
|
||||
this->parent_->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0);
|
||||
#endif
|
||||
this->schedule_ota_reboot_();
|
||||
} else {
|
||||
@@ -179,7 +217,7 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
|
||||
// Reset the platform updater before the lock is released.
|
||||
this->ota_backend_->abort();
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
|
||||
this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
|
||||
#endif
|
||||
}
|
||||
this->ota_backend_.reset();
|
||||
@@ -187,16 +225,16 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
|
||||
}
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::abort_session_() {
|
||||
void OTARequestHandler::abort_session_() {
|
||||
this->ota_backend_->abort();
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
|
||||
this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
|
||||
#endif
|
||||
this->ota_backend_.reset();
|
||||
this->ota_request_ = nullptr;
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::refuse_(AsyncWebServerRequest *request) {
|
||||
void OTARequestHandler::refuse_(AsyncWebServerRequest *request) {
|
||||
this->refused_request_ = request;
|
||||
request->onDisconnect([this, request]() {
|
||||
if (this->refused_request_ == request)
|
||||
@@ -204,7 +242,7 @@ void WebServerOTAComponent::refuse_(AsyncWebServerRequest *request) {
|
||||
});
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::handleRequest(AsyncWebServerRequest *request) {
|
||||
void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) {
|
||||
AsyncWebServerResponse *response;
|
||||
// Use the ota_success_ flag to determine the actual result
|
||||
const bool refused = request == this->refused_request_;
|
||||
@@ -239,7 +277,8 @@ void WebServerOTAComponent::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
base->add_handler(this);
|
||||
// The handler lives for the life of the process; WebServerBase never destroys its server
|
||||
base->add_handler(new OTARequestHandler(this)); // NOLINT
|
||||
}
|
||||
|
||||
void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); }
|
||||
|
||||
@@ -3,44 +3,20 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_WEBSERVER_OTA
|
||||
|
||||
#include "esphome/components/ota/ota_backend_factory.h"
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#include "esphome/components/web_server_base/web_server_base.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::web_server {
|
||||
|
||||
class WebServerOTAComponent final : public AsyncWebHandler, public ota::OTAComponent {
|
||||
class WebServerOTAComponent final : public ota::OTAComponent {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
void handleRequest(AsyncWebServerRequest *request) override;
|
||||
void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data,
|
||||
size_t len, bool final) override;
|
||||
bool canHandle(AsyncWebServerRequest *request) const override;
|
||||
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
bool isRequestHandlerTrivial() const override { return false; }
|
||||
|
||||
protected:
|
||||
void report_ota_progress_(AsyncWebServerRequest *request);
|
||||
void schedule_ota_reboot_();
|
||||
void ota_init_(const char *filename);
|
||||
/// Abort and drop the backend, releasing the lock.
|
||||
void abort_session_();
|
||||
/// Reply "busy" without touching the running upload.
|
||||
void refuse_(AsyncWebServerRequest *request);
|
||||
|
||||
uint32_t last_ota_progress_{0};
|
||||
uint32_t ota_read_length_{0};
|
||||
bool ota_success_{false};
|
||||
|
||||
private:
|
||||
ota::OTABackendPtr ota_backend_{nullptr};
|
||||
/// Request that owns ota_backend_.
|
||||
const AsyncWebServerRequest *ota_request_{nullptr};
|
||||
const AsyncWebServerRequest *refused_request_{nullptr};
|
||||
friend class OTARequestHandler;
|
||||
};
|
||||
|
||||
} // namespace esphome::web_server
|
||||
|
||||
+3
-3
@@ -37,9 +37,9 @@ class ErrorFormat(StrEnum):
|
||||
|
||||
|
||||
# Toolchains that drive their build natively and never read platformio.ini.
|
||||
NATIVE_TOOLCHAINS = frozenset(
|
||||
{Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST, Toolchain.SDK_NRF}
|
||||
)
|
||||
# SDK_NRF is absent on purpose: the zephyr backend keeps consuming
|
||||
# platformio_options.
|
||||
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST})
|
||||
|
||||
|
||||
class Platform(StrEnum):
|
||||
|
||||
@@ -1171,14 +1171,12 @@ class EsphomeCore:
|
||||
def add_build_unflag(self, build_unflag: str) -> None:
|
||||
# No warning for using_toolchain_arduino: the native ESP8266 build
|
||||
# honors build_unflags (token-level, matching PlatformIO).
|
||||
if self.using_toolchain_esp_idf or self.using_toolchain_sdk_nrf:
|
||||
# The native ESP-IDF and nRF52 build generators do not consume
|
||||
# build_unflags
|
||||
if self.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build generator does not consume build_unflags
|
||||
_LOGGER.warning(
|
||||
"Build unflag %s is ignored when building with the native "
|
||||
"'%s' toolchain",
|
||||
"ESP-IDF toolchain",
|
||||
build_unflag,
|
||||
self.toolchain.value,
|
||||
)
|
||||
self.build_unflags.add(build_unflag)
|
||||
_LOGGER.debug("Adding build unflag: %s", build_unflag)
|
||||
|
||||
@@ -190,8 +190,6 @@
|
||||
#define USE_MICS_5524_DATASHEET
|
||||
#define USE_MICRONOVA_WRITER
|
||||
#define MK2PVROUTER_LISTENER_COUNT 1
|
||||
#define MODBUS_ON_REQUEST_COUNT 1
|
||||
#define MODBUS_ON_RESPONSE_COUNT 1
|
||||
#define NFC_TAG_LISTENER_COUNT 1
|
||||
#define PN532_BINARY_SENSOR_COUNT 1
|
||||
#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
logger:
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
uart:
|
||||
- id: only_uart
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
baud_rate: 9600
|
||||
|
||||
modbus:
|
||||
- id: only_hub
|
||||
uart_id: only_uart
|
||||
|
||||
modbus_monitor:
|
||||
- on_request:
|
||||
- logger.log: "sent"
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Tests for the modbus_monitor component."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import modbus_monitor
|
||||
from tests.component_tests.helpers import get_define_value
|
||||
|
||||
DIR = "tests/component_tests/modbus_monitor"
|
||||
|
||||
|
||||
def test_callbacks_are_added_to_their_hubs(generate_main) -> None:
|
||||
main_cpp = generate_main(f"{DIR}/test_modbus_monitor.yaml")
|
||||
assert main_cpp.count("server_hub->add_on_request_callback(") == 2
|
||||
assert main_cpp.count("client_hub->add_on_request_callback(") == 1
|
||||
assert "plain_hub->add_on_request_callback(" not in main_cpp
|
||||
# Sized for the hub with the most blocks.
|
||||
assert get_define_value("MODBUS_ON_REQUEST_COUNT") == "2"
|
||||
# The client hub's block has only on_response.
|
||||
assert main_cpp.count("client_hub->add_on_response_callback(") == 1
|
||||
assert "server_hub->add_on_response_callback(" not in main_cpp
|
||||
assert get_define_value("MODBUS_ON_RESPONSE_COUNT") == "1"
|
||||
|
||||
|
||||
def test_hub_is_found_without_modbus_id(generate_main) -> None:
|
||||
main_cpp = generate_main(f"{DIR}/test_default_id.yaml")
|
||||
assert "only_hub->add_on_request_callback(" in main_cpp
|
||||
assert get_define_value("MODBUS_ON_REQUEST_COUNT") == "1"
|
||||
|
||||
|
||||
def test_nothing_is_compiled_in_without_a_block(generate_main) -> None:
|
||||
main_cpp = generate_main(f"{DIR}/test_no_monitor.yaml")
|
||||
assert "add_on_request_callback(" not in main_cpp
|
||||
assert "add_on_response_callback(" not in main_cpp
|
||||
assert get_define_value("MODBUS_ON_REQUEST_COUNT") is None
|
||||
assert get_define_value("MODBUS_ON_RESPONSE_COUNT") is None
|
||||
|
||||
|
||||
def test_empty_block_is_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="on_request"):
|
||||
modbus_monitor.CONFIG_SCHEMA({"modbus_id": "hub"})
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
logger:
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
uart:
|
||||
- id: server_uart
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
baud_rate: 9600
|
||||
- id: client_uart
|
||||
tx_pin: GPIO18
|
||||
rx_pin: GPIO19
|
||||
baud_rate: 9600
|
||||
- id: plain_uart
|
||||
tx_pin: GPIO21
|
||||
rx_pin: GPIO22
|
||||
baud_rate: 9600
|
||||
|
||||
modbus:
|
||||
- id: server_hub
|
||||
uart_id: server_uart
|
||||
role: server
|
||||
- id: client_hub
|
||||
uart_id: client_uart
|
||||
- id: plain_hub
|
||||
uart_id: plain_uart
|
||||
|
||||
modbus_monitor:
|
||||
- modbus_id: server_hub
|
||||
on_request:
|
||||
- lambda: |-
|
||||
ESP_LOGD("test", "Request to %u, function 0x%02X", address, request[0]);
|
||||
- modbus_id: server_hub
|
||||
on_request:
|
||||
- logger.log: "second"
|
||||
- modbus_id: client_hub
|
||||
on_request:
|
||||
- logger.log: "sent"
|
||||
- modbus_id: client_hub
|
||||
on_response:
|
||||
- logger.log: "received"
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
uart:
|
||||
- id: only_uart
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
baud_rate: 9600
|
||||
|
||||
modbus:
|
||||
- id: only_hub
|
||||
uart_id: only_uart
|
||||
role: server
|
||||
@@ -1,14 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# Code generation sizes the callback storage; the gtests add up to two on_request and two on_response per hub.
|
||||
cg.add_define("MODBUS_ON_REQUEST_COUNT", 2)
|
||||
cg.add_define("MODBUS_ON_RESPONSE_COUNT", 2)
|
||||
|
||||
# A MULTI_CONF component gets no entry in the host build, so its to_code would never run.
|
||||
manifest.multi_conf = False
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
// Copies a PDU so a test can compare it after the span is gone.
|
||||
inline std::vector<uint8_t> vec(std::span<const uint8_t> pdu) { return {pdu.begin(), pdu.end()}; }
|
||||
|
||||
// A UART that discards all writes, for tests that never inspect the wire.
|
||||
class NullUART : public uart::UARTComponent {
|
||||
public:
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "common.h"
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Drives the send step, the timeout and the sweep without waiting for real time.
|
||||
class MonitorClientHub : public ModbusClientHub {
|
||||
public:
|
||||
void send_next_for_test() {
|
||||
this->send_next_frame_();
|
||||
this->sweep_();
|
||||
}
|
||||
void timeout_for_test() {
|
||||
this->expire_waiting_();
|
||||
this->sweep_();
|
||||
}
|
||||
bool waiting() const { return this->waiting_for_response_; }
|
||||
void receive_for_test(uint8_t address, std::span<const uint8_t> pdu) {
|
||||
this->process_modbus_server_frame(address, pdu);
|
||||
}
|
||||
size_t entries() const { return this->tx_buffer_.size(); }
|
||||
bool tx_blocked() override { return this->blocked || this->ModbusClientHub::tx_blocked(); }
|
||||
bool blocked{false};
|
||||
};
|
||||
|
||||
// Asks for one retry when no reply comes.
|
||||
class RetryOnceDevice : public ModbusClientDevice {
|
||||
public:
|
||||
using ModbusClientDevice::ModbusClientDevice;
|
||||
bool on_no_response(std::span<const uint8_t> request_pdu) override { return this->retries_++ == 0; }
|
||||
int retries_{0};
|
||||
};
|
||||
|
||||
struct Request {
|
||||
uint8_t address;
|
||||
std::vector<uint8_t> pdu;
|
||||
};
|
||||
|
||||
struct Response {
|
||||
uint8_t address;
|
||||
std::vector<uint8_t> request;
|
||||
std::vector<uint8_t> response;
|
||||
};
|
||||
|
||||
// Notes how many requests the monitor had seen each time on_sent runs.
|
||||
class SentDevice : public ModbusClientDevice {
|
||||
public:
|
||||
SentDevice(ModbusClientHub *hub, uint8_t address, const std::vector<Request> &seen)
|
||||
: ModbusClientDevice(hub, address), seen_(seen) {}
|
||||
void on_sent(std::span<const uint8_t> request_pdu) override { this->seen_at_sent.push_back(this->seen_.size()); }
|
||||
std::vector<size_t> seen_at_sent;
|
||||
|
||||
protected:
|
||||
const std::vector<Request> &seen_;
|
||||
};
|
||||
|
||||
// Notes how many responses the monitor had seen each time on_response or on_error runs.
|
||||
class ResponseDevice : public ModbusClientDevice {
|
||||
public:
|
||||
ResponseDevice(ModbusClientHub *hub, uint8_t address, const std::vector<Response> &paired)
|
||||
: ModbusClientDevice(hub, address), paired_(paired) {}
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
|
||||
this->paired_at_terminal.push_back(this->paired_.size());
|
||||
}
|
||||
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override {
|
||||
this->paired_at_terminal.push_back(this->paired_.size());
|
||||
}
|
||||
std::vector<size_t> paired_at_terminal;
|
||||
|
||||
protected:
|
||||
const std::vector<Response> &paired_;
|
||||
};
|
||||
|
||||
class MonitorClient : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->hub_.set_uart_parent(&this->uart_);
|
||||
this->hub_.setup();
|
||||
this->hub_.add_on_request_callback(
|
||||
[this](uint8_t address, std::span<const uint8_t> pdu) { this->seen_.push_back({address, vec(pdu)}); });
|
||||
this->hub_.add_on_response_callback(
|
||||
[this](uint8_t address, std::span<const uint8_t> request, std::span<const uint8_t> response) {
|
||||
this->paired_.push_back({address, vec(request), vec(response)});
|
||||
});
|
||||
}
|
||||
|
||||
NullUART uart_;
|
||||
MonitorClientHub hub_;
|
||||
std::vector<Request> seen_;
|
||||
std::vector<Response> paired_;
|
||||
};
|
||||
|
||||
constexpr uint8_t READ[] = {0x03, 0x01, 0x00, 0x00, 0x02};
|
||||
constexpr uint8_t WRITE[] = {0x06, 0x00, 0x10, 0x00, 0x01};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_F(MonitorClient, SeenWhenSentNotWhenQueued) {
|
||||
ASSERT_TRUE(this->hub_.queue_pdu(0x02, READ));
|
||||
EXPECT_TRUE(this->seen_.empty());
|
||||
this->hub_.send_next_for_test();
|
||||
ASSERT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(this->seen_[0].address, 0x02);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
|
||||
EXPECT_TRUE(this->hub_.waiting());
|
||||
}
|
||||
|
||||
// The monitor runs at the end of the send step: after the hub has set its state and after the device's on_sent.
|
||||
TEST_F(MonitorClient, RequestIsSeenAfterOnSent) {
|
||||
bool waiting_at_request = false;
|
||||
this->hub_.add_on_request_callback(
|
||||
[this, &waiting_at_request](uint8_t, std::span<const uint8_t>) { waiting_at_request = this->hub_.waiting(); });
|
||||
SentDevice device(&this->hub_, 0x02, this->seen_);
|
||||
ASSERT_TRUE(device.queue_pdu(READ));
|
||||
this->hub_.send_next_for_test();
|
||||
EXPECT_TRUE(waiting_at_request);
|
||||
ASSERT_EQ(device.seen_at_sent.size(), 1u);
|
||||
EXPECT_EQ(device.seen_at_sent[0], 0u);
|
||||
}
|
||||
|
||||
TEST_F(MonitorClient, RetryIsSeenAgain) {
|
||||
RetryOnceDevice device(&this->hub_, 0x02);
|
||||
ASSERT_TRUE(device.queue_pdu(READ));
|
||||
this->hub_.send_next_for_test();
|
||||
this->hub_.timeout_for_test(); // no reply: the device asks for a retry
|
||||
this->hub_.send_next_for_test();
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[0].pdu, this->seen_[1].pdu);
|
||||
}
|
||||
|
||||
TEST_F(MonitorClient, BroadcastIsSeen) {
|
||||
ASSERT_TRUE(this->hub_.queue_pdu(BROADCAST_ADDRESS, WRITE));
|
||||
this->hub_.send_next_for_test();
|
||||
ASSERT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(this->seen_[0].address, BROADCAST_ADDRESS);
|
||||
EXPECT_FALSE(this->hub_.waiting());
|
||||
}
|
||||
|
||||
// The broadcast is already complete when the callback runs, so the same request queued from the
|
||||
// callback is a new entry and is sent, not merged into the finished one.
|
||||
TEST_F(MonitorClient, RequestQueuedFromTheCallbackIsSent) {
|
||||
bool queued = false;
|
||||
this->hub_.add_on_request_callback([this, &queued](uint8_t address, std::span<const uint8_t> pdu) {
|
||||
if (!queued)
|
||||
queued = this->hub_.queue_pdu(address, pdu);
|
||||
});
|
||||
ASSERT_TRUE(this->hub_.queue_pdu(BROADCAST_ADDRESS, WRITE));
|
||||
this->hub_.send_next_for_test();
|
||||
EXPECT_TRUE(queued);
|
||||
EXPECT_EQ(this->hub_.entries(), 1u);
|
||||
this->hub_.send_next_for_test();
|
||||
EXPECT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->hub_.entries(), 0u);
|
||||
}
|
||||
|
||||
// The entry is consumed before the monitor runs, so the same write queued from on_response is accepted.
|
||||
TEST_F(MonitorClient, SameRequestQueuedFromOnResponseIsAccepted) {
|
||||
ModbusClientDevice device(&this->hub_, 0x02);
|
||||
bool queued = false;
|
||||
this->hub_.add_on_response_callback(
|
||||
[&device, &queued](uint8_t, std::span<const uint8_t> request, std::span<const uint8_t>) {
|
||||
if (!queued)
|
||||
queued = device.queue_pdu(request);
|
||||
});
|
||||
ASSERT_TRUE(device.queue_pdu(WRITE));
|
||||
this->hub_.send_next_for_test();
|
||||
this->hub_.receive_for_test(0x02, WRITE); // a write single register reply echoes the request
|
||||
EXPECT_TRUE(queued);
|
||||
}
|
||||
|
||||
TEST_F(MonitorClient, NotSentIsNotSeen) {
|
||||
this->hub_.blocked = true;
|
||||
ASSERT_TRUE(this->hub_.queue_pdu(0x02, READ));
|
||||
this->hub_.send_next_for_test();
|
||||
EXPECT_TRUE(this->seen_.empty());
|
||||
}
|
||||
|
||||
// A normal reply and an exception reply are both reported with their request.
|
||||
TEST_F(MonitorClient, ReplyIsSeenWithItsRequest) {
|
||||
const std::vector<uint8_t> replies[] = {{0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B}, {0x83, 0x02}};
|
||||
ModbusClientDevice device(&this->hub_, 0x02);
|
||||
for (const auto &reply : replies) {
|
||||
ASSERT_TRUE(device.queue_pdu(READ));
|
||||
this->hub_.send_next_for_test();
|
||||
this->hub_.receive_for_test(0x02, reply);
|
||||
this->hub_.send_next_for_test(); // nothing to send: sweeps the answered entry
|
||||
}
|
||||
ASSERT_EQ(this->paired_.size(), 2u);
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
EXPECT_EQ(this->paired_[i].address, 0x02);
|
||||
EXPECT_EQ(this->paired_[i].request, vec(READ));
|
||||
EXPECT_EQ(this->paired_[i].response, replies[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// The monitor runs after the device's on_response and on_error, for a normal and an exception reply.
|
||||
TEST_F(MonitorClient, ResponseIsSeenAfterTheDevice) {
|
||||
const std::vector<uint8_t> replies[] = {{0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B}, {0x83, 0x02}};
|
||||
ResponseDevice device(&this->hub_, 0x02, this->paired_);
|
||||
for (const auto &reply : replies) {
|
||||
ASSERT_TRUE(device.queue_pdu(READ));
|
||||
this->hub_.send_next_for_test();
|
||||
this->hub_.receive_for_test(0x02, reply);
|
||||
this->hub_.send_next_for_test(); // nothing to send: sweeps the answered entry
|
||||
}
|
||||
ASSERT_EQ(device.paired_at_terminal.size(), 2u);
|
||||
EXPECT_EQ(device.paired_at_terminal[0], 0u);
|
||||
EXPECT_EQ(device.paired_at_terminal[1], 1u);
|
||||
EXPECT_EQ(this->paired_.size(), 2u);
|
||||
}
|
||||
|
||||
// A request queued on the hub with no device still has its reply reported, as a raw send with a monitor needs.
|
||||
TEST_F(MonitorClient, ReplyToADeviceLessRequestIsSeen) {
|
||||
const std::vector<uint8_t> reply = {0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B};
|
||||
ASSERT_TRUE(this->hub_.queue_pdu(0x02, READ));
|
||||
this->hub_.send_next_for_test();
|
||||
this->hub_.receive_for_test(0x02, reply);
|
||||
this->hub_.send_next_for_test();
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].address, 0x02);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
EXPECT_EQ(this->paired_[0].response, reply);
|
||||
EXPECT_EQ(this->hub_.entries(), 0u);
|
||||
}
|
||||
|
||||
// A reply from another address interrupts the wait: the expected reply after it is ignored and the wire stays blocked.
|
||||
TEST_F(MonitorClient, ReplyAfterAnInterruptionIsNotSeen) {
|
||||
ModbusClientDevice device(&this->hub_, 0x02);
|
||||
ASSERT_TRUE(device.queue_pdu(READ));
|
||||
this->hub_.send_next_for_test();
|
||||
const uint8_t reply[] = {0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B};
|
||||
this->hub_.receive_for_test(0x05, reply);
|
||||
this->hub_.receive_for_test(0x02, reply);
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
EXPECT_TRUE(this->hub_.waiting());
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
@@ -1,410 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include "common.h"
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
#include "esphome/components/modbus/modbus_helpers.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Answers register reads with one fixed value.
|
||||
class RegisterDevice : public ModbusServerDevice {
|
||||
public:
|
||||
explicit RegisterDevice(uint8_t address) { this->set_address(address); }
|
||||
ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
RegisterValues ®isters) override {
|
||||
for (uint16_t i = 0; i < number_of_registers; i++)
|
||||
registers.push_back(0x1234);
|
||||
return std::nullopt;
|
||||
}
|
||||
};
|
||||
|
||||
struct Request {
|
||||
uint8_t address;
|
||||
std::vector<uint8_t> pdu;
|
||||
size_t written_before; // bytes the hub had written when the callback ran
|
||||
};
|
||||
|
||||
struct Response {
|
||||
uint8_t address;
|
||||
std::vector<uint8_t> request;
|
||||
std::vector<uint8_t> response;
|
||||
size_t written_before; // bytes the hub had written when the callback ran
|
||||
};
|
||||
|
||||
// Lets the bus fall silent past the frame timeout without waiting for real time, and holds replies back as if
|
||||
// the bus were busy.
|
||||
class QuietServerHub : public ModbusServerHub {
|
||||
public:
|
||||
void go_quiet() { this->last_modbus_byte_ -= 1000000; }
|
||||
bool tx_blocked() override { return this->busy || this->Modbus::tx_blocked(); }
|
||||
bool reply_held() const { return this->deferred_payload_len_ != 0; }
|
||||
// DEFERRED_SEND_TIMEOUT_ID in modbus.cpp
|
||||
void cancel_held_reply() { this->cancel_timeout(static_cast<uint32_t>(0)); }
|
||||
bool busy{false};
|
||||
|
||||
protected:
|
||||
// No wait before a send, so a held reply is due at once and the tests need not sleep.
|
||||
int32_t tx_delay_remaining() override { return 0; }
|
||||
};
|
||||
|
||||
class MonitorServer : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->hub_.set_uart_parent(&this->uart_);
|
||||
this->hub_.setup();
|
||||
this->hub_.add_on_request_callback([this](uint8_t address, std::span<const uint8_t> pdu) {
|
||||
this->seen_.push_back({address, {pdu.begin(), pdu.end()}, this->uart_.written.size()});
|
||||
});
|
||||
this->hub_.add_on_response_callback(
|
||||
[this](uint8_t address, std::span<const uint8_t> request, std::span<const uint8_t> response) {
|
||||
this->paired_.push_back({address, vec(request), vec(response), this->uart_.written.size()});
|
||||
});
|
||||
}
|
||||
void receive_(uint8_t address, std::span<const uint8_t> pdu) {
|
||||
this->uart_.inject_frame(address, pdu);
|
||||
this->hub_.loop();
|
||||
}
|
||||
// A gap on the wire longer than the frame timeout: a partial frame in the buffer is dropped.
|
||||
void pause_() {
|
||||
this->hub_.go_quiet();
|
||||
this->hub_.loop();
|
||||
}
|
||||
|
||||
InjectableUART uart_;
|
||||
QuietServerHub hub_;
|
||||
std::vector<Request> seen_;
|
||||
std::vector<Response> paired_;
|
||||
};
|
||||
|
||||
constexpr uint8_t READ[] = {0x03, 0x00, 0x10, 0x00, 0x01};
|
||||
constexpr uint8_t READ_REPLY[] = {0x03, 0x02, 0x00, 0x2A};
|
||||
constexpr uint8_t LOCAL_READ_REPLY[] = {0x03, 0x02, 0x12, 0x34}; // RegisterDevice's answer to READ
|
||||
constexpr size_t LOCAL_READ_REPLY_FRAME = 1 + sizeof(LOCAL_READ_REPLY) + 2; // with address and CRC
|
||||
constexpr uint8_t READ_TWO[] = {0x03, 0x00, 0xCA, 0x00, 0x02}; // two registers from 202
|
||||
constexpr uint8_t READ_TWO_REPLY[] = {0x03, 0x04, 0x10, 0x04, 0xFF, 0x30};
|
||||
constexpr uint8_t WRITE_1[] = {0x06, 0x00, 0x10, 0x00, 0x01};
|
||||
constexpr uint8_t WRITE_2[] = {0x06, 0x00, 0x10, 0x00, 0x02};
|
||||
|
||||
} // namespace
|
||||
|
||||
// A request to a local device is seen too, before the device answers.
|
||||
TEST_F(MonitorServer, ServedRequestIsSeenBeforeTheReply) {
|
||||
RegisterDevice device(0x02);
|
||||
this->hub_.register_device(&device);
|
||||
this->receive_(0x02, READ);
|
||||
ASSERT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(this->seen_[0].address, 0x02);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
|
||||
EXPECT_EQ(this->seen_[0].written_before, 0u);
|
||||
EXPECT_EQ(this->uart_.written.size(), LOCAL_READ_REPLY_FRAME);
|
||||
}
|
||||
|
||||
// A reply of a local device is reported with its request once it is on the wire, an exception reply too.
|
||||
TEST_F(MonitorServer, OwnReplyIsSeenWithItsRequest) {
|
||||
RegisterDevice device(0x02);
|
||||
this->hub_.register_device(&device);
|
||||
const uint8_t read_none[] = {0x03, 0x00, 0x10, 0x00, 0x00}; // zero registers
|
||||
const uint8_t illegal_value[] = {0x83, 0x03};
|
||||
this->receive_(0x02, READ);
|
||||
this->receive_(0x02, read_none);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
ASSERT_EQ(this->paired_.size(), 2u);
|
||||
EXPECT_EQ(this->paired_[0].address, 0x02);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
EXPECT_EQ(this->paired_[0].response, vec(LOCAL_READ_REPLY));
|
||||
EXPECT_EQ(this->paired_[0].written_before, LOCAL_READ_REPLY_FRAME);
|
||||
EXPECT_EQ(this->paired_[1].address, 0x02);
|
||||
EXPECT_EQ(this->paired_[1].request, vec(read_none));
|
||||
EXPECT_EQ(this->paired_[1].response, vec(illegal_value));
|
||||
EXPECT_EQ(this->paired_[1].written_before, this->uart_.written.size());
|
||||
}
|
||||
|
||||
// The second request arrives while the hub still waits for the first one's reply.
|
||||
TEST_F(MonitorServer, UnansweredRequestsAreSeenButNotPaired) {
|
||||
this->receive_(0x05, READ); // absent device
|
||||
this->receive_(0x06, READ_TWO);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[0].address, 0x05);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
|
||||
EXPECT_EQ(this->seen_[1].address, 0x06);
|
||||
EXPECT_EQ(this->seen_[1].pdu, vec(READ_TWO));
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
}
|
||||
|
||||
// A single write and its reply have the same shape.
|
||||
TEST_F(MonitorServer, PeerWriteReplyIsNotSeen) {
|
||||
this->receive_(0x05, WRITE_1);
|
||||
this->receive_(0x05, WRITE_1); // the other device's reply
|
||||
this->receive_(0x05, WRITE_2);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(WRITE_1));
|
||||
EXPECT_EQ(this->seen_[1].pdu, vec(WRITE_2));
|
||||
}
|
||||
|
||||
// Known gap: a write and its reply have the same shape, so the next write is taken for a missing reply.
|
||||
TEST_F(MonitorServer, UnansweredWriteIsPairedWithTheNextWrite) {
|
||||
this->receive_(0x05, WRITE_1);
|
||||
this->receive_(0x05, WRITE_2); // no reply came; this request is taken for it
|
||||
this->receive_(0x05, WRITE_1);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(WRITE_1));
|
||||
EXPECT_EQ(this->seen_[1].pdu, vec(WRITE_1));
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(WRITE_1));
|
||||
EXPECT_EQ(this->paired_[0].response, vec(WRITE_2));
|
||||
}
|
||||
|
||||
// Any frame with the shape of a reply ends the wait, so a write to another address is not seen either.
|
||||
TEST_F(MonitorServer, WriteToAnotherAddressWithoutReplyIsTakenForTheReply) {
|
||||
this->receive_(0x05, WRITE_1);
|
||||
this->receive_(0x06, WRITE_2); // no reply came from 0x05; this request is taken for it
|
||||
this->receive_(0x06, WRITE_1);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[1].address, 0x06);
|
||||
EXPECT_EQ(this->seen_[1].pdu, vec(WRITE_1));
|
||||
}
|
||||
|
||||
// Known gap: a read from 0x0300-0x03FF has the shape of a 3-byte read reply, so it is taken for a missing reply.
|
||||
TEST_F(MonitorServer, UnansweredReadIsPairedWithAReadOf0x03xx) {
|
||||
const uint8_t read_0x0320[] = {0x03, 0x03, 0x20, 0x00, 0x01};
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, read_0x0320);
|
||||
EXPECT_EQ(this->seen_.size(), 1u);
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
EXPECT_EQ(this->paired_[0].response, vec(read_0x0320));
|
||||
}
|
||||
|
||||
// A single write echo is identical to its request, so one seen with no reply expected (the hub started
|
||||
// mid-exchange) is taken for a request. The next request replaces it.
|
||||
TEST_F(MonitorServer, WriteEchoSeenFirstIsTakenForARequest) {
|
||||
this->receive_(0x05, WRITE_1); // the echo; its request was missed
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[0].address, 0x05);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(WRITE_1));
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
}
|
||||
|
||||
// A broadcast is never answered, so it is seen but never paired, not even with a reply-shaped frame from address 0.
|
||||
TEST_F(MonitorServer, BroadcastIsSeenButNeverPaired) {
|
||||
this->receive_(BROADCAST_ADDRESS, READ);
|
||||
this->receive_(BROADCAST_ADDRESS, READ_REPLY);
|
||||
ASSERT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(this->seen_[0].address, BROADCAST_ADDRESS);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
}
|
||||
|
||||
TEST_F(MonitorServer, EveryCallbackRuns) {
|
||||
int second = 0;
|
||||
this->hub_.add_on_request_callback([&second](uint8_t, std::span<const uint8_t>) { second++; });
|
||||
this->receive_(0x05, READ);
|
||||
EXPECT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(second, 1);
|
||||
}
|
||||
|
||||
// The largest register write: 123 registers, a PDU of 252 bytes.
|
||||
TEST_F(MonitorServer, LargestRequestIsSeenWhole) {
|
||||
std::vector<uint8_t> pdu = {0x10, 0x00, 0x00, 0x00, 123, 246};
|
||||
for (int i = 0; i < 246; i++)
|
||||
pdu.push_back(static_cast<uint8_t>(i));
|
||||
this->receive_(0x05, pdu);
|
||||
ASSERT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(this->seen_[0].pdu, pdu);
|
||||
}
|
||||
|
||||
TEST_F(MonitorServer, UnknownFunctionIsSeen) {
|
||||
const uint8_t pdu[] = {0x49, 0x02, 0xAA, 0xBB};
|
||||
this->receive_(0x05, pdu);
|
||||
ASSERT_EQ(this->seen_.size(), 1u);
|
||||
EXPECT_EQ(this->seen_[0].pdu, vec(pdu));
|
||||
}
|
||||
|
||||
// The reply of another device is reported raw with the request it answers, which holds the start address
|
||||
// the reply lacks.
|
||||
TEST_F(MonitorServer, PeerResponseIsSeenWithItsRequest) {
|
||||
this->receive_(0x05, READ_TWO);
|
||||
this->receive_(0x05, READ_TWO_REPLY);
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].address, 0x05);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ_TWO));
|
||||
EXPECT_EQ(this->paired_[0].response, vec(READ_TWO_REPLY));
|
||||
EXPECT_EQ(helpers::get_data<uint16_t>(this->paired_[0].request.data(), 1), 202);
|
||||
const auto payload = helpers::server_pdu_payload(this->paired_[0].response);
|
||||
ASSERT_EQ(payload.size(), 4u);
|
||||
EXPECT_EQ(helpers::get_data<uint16_t>(payload.data(), 0), 0x1004);
|
||||
}
|
||||
|
||||
// Only the requests are seen, and each reply is paired with its own request.
|
||||
TEST_F(MonitorServer, TwoExchangesInARowArePairedSeparately) {
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
this->receive_(0x06, READ_TWO);
|
||||
this->receive_(0x06, READ_TWO_REPLY);
|
||||
ASSERT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->seen_[0].address, 0x05);
|
||||
EXPECT_EQ(this->seen_[1].address, 0x06);
|
||||
ASSERT_EQ(this->paired_.size(), 2u);
|
||||
EXPECT_EQ(this->paired_[0].address, 0x05);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
EXPECT_EQ(this->paired_[1].address, 0x06);
|
||||
EXPECT_EQ(this->paired_[1].request, vec(READ_TWO));
|
||||
EXPECT_EQ(this->paired_[1].response, vec(READ_TWO_REPLY));
|
||||
}
|
||||
|
||||
// The PDUs are passed on raw, so every function code pairs, exception replies included.
|
||||
TEST_F(MonitorServer, EveryFunctionCodeIsPaired) {
|
||||
struct Case {
|
||||
const char *name;
|
||||
std::vector<uint8_t> request;
|
||||
std::vector<uint8_t> response;
|
||||
};
|
||||
const std::vector<Case> cases{
|
||||
{"write single register", {0x06, 0x04, 0x4D, 0x00, 0x01}, {0x06, 0x04, 0x4D, 0x00, 0x01}},
|
||||
{"write multiple registers", {0x10, 0x04, 0x4D, 0x00, 0x01, 0x02, 0x00, 0x01}, {0x10, 0x04, 0x4D, 0x00, 0x01}},
|
||||
{"read coils", {0x01, 0x00, 0x13, 0x00, 0x09}, {0x01, 0x02, 0xCD, 0x01}},
|
||||
{"read discrete inputs", {0x02, 0x00, 0xC4, 0x00, 0x16}, {0x02, 0x03, 0xAC, 0xDB, 0x35}},
|
||||
{"read/write multiple",
|
||||
{0x17, 0x00, 0xC8, 0x00, 0x01, 0x01, 0x2C, 0x00, 0x01, 0x02, 0x00, 0x07},
|
||||
{0x17, 0x02, 0x12, 0x34}},
|
||||
{"exception", {0x03, 0x00, 0xCA, 0x00, 0x02}, {0x83, 0x02}},
|
||||
};
|
||||
for (const Case &c : cases) {
|
||||
this->paired_.clear();
|
||||
this->receive_(0x05, c.request);
|
||||
this->receive_(0x05, c.response);
|
||||
ASSERT_EQ(this->paired_.size(), 1u) << c.name;
|
||||
EXPECT_EQ(this->paired_[0].request, c.request) << c.name;
|
||||
EXPECT_EQ(this->paired_[0].response, c.response) << c.name;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(MonitorServer, RequestIsPairedWithItsFirstResponseOnly) {
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
EXPECT_EQ(this->paired_.size(), 1u);
|
||||
}
|
||||
|
||||
// RTU is half duplex: a new request replaces an unanswered one.
|
||||
TEST_F(MonitorServer, UnansweredPollDoesNotCaptureTheNextDevicesReply) {
|
||||
this->receive_(0x05, READ); // absent device
|
||||
this->receive_(0x06, READ_TWO);
|
||||
this->receive_(0x06, READ_TWO_REPLY);
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].address, 0x06);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ_TWO));
|
||||
}
|
||||
|
||||
TEST_F(MonitorServer, ResponseFromAnotherAddressIsNotPaired) {
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x07, READ_REPLY);
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
}
|
||||
|
||||
TEST_F(MonitorServer, ResponseWithAnotherFunctionCodeIsNotPaired) {
|
||||
const uint8_t input_reply[] = {0x04, 0x02, 0x00, 0x01};
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, input_reply);
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
}
|
||||
|
||||
// A read reply with no request before it (the hub started mid-exchange) is too short for a request and is
|
||||
// dropped after the frame timeout; the next exchange pairs normally.
|
||||
TEST_F(MonitorServer, ResponseSeenFirstIsDropped) {
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
this->pause_();
|
||||
EXPECT_TRUE(this->seen_.empty());
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
}
|
||||
|
||||
// With no device of its own the hub never transmits.
|
||||
TEST_F(MonitorServer, ListenerNeverTransmits) {
|
||||
this->receive_(0x05, READ);
|
||||
this->receive_(0x05, READ_REPLY);
|
||||
this->receive_(0x05, WRITE_1);
|
||||
EXPECT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_TRUE(this->uart_.written.empty());
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Replies held back by a busy bus go out from a timer, which needs the App scheduler.
|
||||
class MonitorServerHeld : public MonitorServer {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// The test main does not construct App as generated code does.
|
||||
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
|
||||
(void) APP_CONSTRUCTED;
|
||||
// Writable buffers, since a build with a MAC suffix writes it into the name
|
||||
static char name[] = "test_modbus";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
MonitorServer::SetUp();
|
||||
this->hub_.register_device(&this->device_);
|
||||
this->hub_.busy = true;
|
||||
}
|
||||
void TearDown() override {
|
||||
// App outlives each test, so drop a timer still pointing at this hub.
|
||||
this->hub_.cancel_held_reply();
|
||||
run_timers();
|
||||
}
|
||||
static void run_timers() { App.scheduler.call(millis()); }
|
||||
|
||||
RegisterDevice device_{0x02};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_F(MonitorServerHeld, HeldReplyIsSeenOnceSent) {
|
||||
this->receive_(0x02, READ);
|
||||
ASSERT_TRUE(this->hub_.reply_held());
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
this->hub_.busy = false;
|
||||
run_timers();
|
||||
EXPECT_FALSE(this->hub_.reply_held());
|
||||
EXPECT_EQ(this->uart_.written.size(), LOCAL_READ_REPLY_FRAME);
|
||||
ASSERT_EQ(this->paired_.size(), 1u);
|
||||
EXPECT_EQ(this->paired_[0].request, vec(READ));
|
||||
EXPECT_EQ(this->paired_[0].response, vec(LOCAL_READ_REPLY));
|
||||
}
|
||||
|
||||
// The next frame arrived first, so the client has moved on: the reply is dropped and not reported.
|
||||
TEST_F(MonitorServerHeld, ReplyDroppedForTheNextFrameIsNotSeen) {
|
||||
this->receive_(0x02, READ);
|
||||
ASSERT_TRUE(this->hub_.reply_held());
|
||||
this->receive_(0x05, READ);
|
||||
EXPECT_FALSE(this->hub_.reply_held());
|
||||
this->hub_.busy = false;
|
||||
run_timers();
|
||||
EXPECT_EQ(this->seen_.size(), 2u);
|
||||
EXPECT_TRUE(this->uart_.written.empty());
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
}
|
||||
|
||||
TEST_F(MonitorServerHeld, ReplyStillBlockedWhenDueIsNotSeen) {
|
||||
this->receive_(0x02, READ);
|
||||
ASSERT_TRUE(this->hub_.reply_held());
|
||||
run_timers();
|
||||
EXPECT_FALSE(this->hub_.reply_held());
|
||||
EXPECT_TRUE(this->uart_.written.empty());
|
||||
EXPECT_TRUE(this->paired_.empty());
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
@@ -1,12 +0,0 @@
|
||||
modbus_monitor:
|
||||
- modbus_id: modbus_server_bus
|
||||
on_request:
|
||||
- lambda: |-
|
||||
ESP_LOGD("modbus_monitor", "Request to %u, function 0x%02X, %u bytes", address, request[0],
|
||||
(unsigned) request.size());
|
||||
- on_request:
|
||||
- logger.log: "Second monitor on the same hub"
|
||||
on_response:
|
||||
- lambda: |-
|
||||
ESP_LOGD("modbus_monitor", "Response from %u to function 0x%02X, %u bytes", address, request[0],
|
||||
(unsigned) response.size());
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml
|
||||
modbus_monitor: !include common.yaml
|
||||
@@ -16,6 +16,7 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -28,6 +29,7 @@ import pytest_asyncio
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import (
|
||||
get_bool_env,
|
||||
get_usable_cpu_count,
|
||||
read_file,
|
||||
rmtree,
|
||||
@@ -39,6 +41,7 @@ from esphome.host.toolchain import get_elf_path
|
||||
from .const import (
|
||||
API_CONNECTION_TIMEOUT,
|
||||
DEFAULT_API_PORT,
|
||||
GDB_TIMEOUT,
|
||||
LOCALHOST,
|
||||
PORT_POLL_INTERVAL,
|
||||
PORT_WAIT_TIMEOUT,
|
||||
@@ -80,6 +83,13 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
|
||||
|
||||
# Opt-in -g and gdb core backtraces; CI enables both only to re-run failed tests
|
||||
DEBUG_SYMBOLS = get_bool_env("ESPHOME_INTEGRATION_DEBUG_SYMBOLS")
|
||||
CORE_DIR = os.environ.get("ESPHOME_INTEGRATION_CORE_DIR")
|
||||
_CRASH_SIGNALS = frozenset(
|
||||
{signal.SIGABRT, signal.SIGBUS, signal.SIGFPE, signal.SIGILL, signal.SIGSEGV}
|
||||
)
|
||||
|
||||
|
||||
def _get_build_env() -> dict[str, str]:
|
||||
"""Environment for an ``esphome compile`` subprocess."""
|
||||
@@ -227,9 +237,12 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
" build_flags:\n"
|
||||
' - "-DDEBUG" # Enable assert() statements\n'
|
||||
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
|
||||
' - "-g" # Add debug symbols',
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts'
|
||||
+ ('\n - "-g" # Add debug symbols' if DEBUG_SYMBOLS else ""),
|
||||
)
|
||||
elif DEBUG_SYMBOLS and " build_flags:\n" in content:
|
||||
# A fixture with its own flags still gets symbols for a debug re-run
|
||||
content = content.replace(" build_flags:\n", ' build_flags:\n - "-g"\n', 1)
|
||||
|
||||
# Replace external component path placeholder if present
|
||||
if "EXTERNAL_COMPONENT_PATH" in content:
|
||||
@@ -772,16 +785,46 @@ async def _read_stream_lines(
|
||||
line_callback(decoded_line.rstrip())
|
||||
|
||||
|
||||
def _print_crash_backtrace(binary_path: Path, returncode: int, pid: int) -> None:
|
||||
"""Print a gdb backtrace from the core of a binary that died from a crash signal."""
|
||||
if CORE_DIR is None or -returncode not in _CRASH_SIGNALS:
|
||||
return
|
||||
core = Path(CORE_DIR) / f"core.{pid}"
|
||||
if not core.is_file() or (gdb := shutil.which("gdb")) is None:
|
||||
print(f"No core or gdb for the crash of {binary_path} (looked for {core})")
|
||||
return
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[gdb, "-batch", "-iex", "set debuginfod enabled off"]
|
||||
+ ["-ex", "thread apply all bt", str(binary_path), str(core)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=GDB_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as err:
|
||||
# Best effort: never replace the test's own failure
|
||||
print(f"gdb could not read {core}: {err}")
|
||||
return
|
||||
print(f"--- Backtrace of {binary_path} ({core}) ---")
|
||||
print(result.stdout + result.stderr)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def run_binary(
|
||||
binary_path: Path,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
symbols_path: Path | None = None,
|
||||
) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]:
|
||||
"""Run a binary under a PTY, capture log output, and clean up on exit.
|
||||
|
||||
Yields the running ``Process`` and a live list of captured log lines.
|
||||
No port wait -- callers that need that should use
|
||||
``run_binary_and_wait_for_port``."""
|
||||
``run_binary_and_wait_for_port``.
|
||||
``symbols_path`` is the unstripped build when ``binary_path`` is a stripped
|
||||
copy; a crash backtrace reads its symbols from it.
|
||||
"""
|
||||
# Create a pseudo-terminal to make the binary think it's running interactively
|
||||
# This is needed because the ESPHome host logger checks isatty()
|
||||
controller_fd, device_fd = pty.openpty()
|
||||
@@ -810,6 +853,9 @@ async def run_binary(
|
||||
)
|
||||
|
||||
if process.returncode is not None:
|
||||
_print_crash_backtrace(
|
||||
symbols_path or binary_path, process.returncode, process.pid
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Process died immediately with return code {process.returncode}. "
|
||||
"Ensure the binary is valid and can run successfully."
|
||||
@@ -851,6 +897,9 @@ async def run_binary(
|
||||
# Last resort: SIGKILL
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_print_crash_backtrace(
|
||||
symbols_path or binary_path, process.returncode, process.pid
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -19,3 +19,4 @@ KEY_ACTIVATION_DELAY = 0.5 # seconds
|
||||
# Process shutdown timeouts
|
||||
SIGINT_TIMEOUT = 5.0 # seconds
|
||||
SIGTERM_TIMEOUT = 2.0 # seconds
|
||||
GDB_TIMEOUT = 60.0 # seconds, reading a crashed binary's core
|
||||
@@ -141,9 +141,10 @@ async def _build(
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> tuple[int, int, Path]:
|
||||
) -> tuple[int, int, Path, Path]:
|
||||
"""Reserve an OTA port, compile the fixture with it, and release both
|
||||
ports right before the binary is started."""
|
||||
ports right before the binary is started. Returns the stripped binary to
|
||||
run and upload, then the unstripped build for crash backtraces."""
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
config_path = await write_yaml_config(
|
||||
@@ -152,11 +153,11 @@ async def _build(
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
return api_port, ota_port, await _strip_debug(binary_path)
|
||||
return api_port, ota_port, await _strip_debug(binary_path), binary_path
|
||||
|
||||
|
||||
async def _strip_debug(binary_path: Path) -> Path:
|
||||
"""The fixtures build with -g; every upload sends the image, so drop the
|
||||
"""A debug re-run builds with -g; every upload sends the image, so drop the
|
||||
debug info (GNU and Apple strip both take -S)."""
|
||||
if (strip := shutil.which("strip")) is None:
|
||||
_LOGGER.warning(
|
||||
@@ -207,6 +208,7 @@ class _Device:
|
||||
api_port: int
|
||||
ota_port: int
|
||||
binary_path: Path
|
||||
symbols_path: Path
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
waiter: LineWaiter = field(default_factory=LineWaiter)
|
||||
|
||||
@@ -302,7 +304,9 @@ async def test_host_ota_self_update(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
@@ -341,7 +345,9 @@ async def test_host_ota_deflate(
|
||||
"""A stream that inflates past the size the client announced."""
|
||||
return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs)
|
||||
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
@@ -422,7 +428,9 @@ async def test_host_ota_encrypted(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
@@ -458,7 +466,9 @@ async def test_host_ota_encrypted_safe_mode(
|
||||
)
|
||||
# The api port never opens in safe mode, so wait for the log line instead
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
|
||||
@@ -485,7 +495,9 @@ async def test_host_ota_api_key_offer_with_password(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_line(lines, "Encryption: offered")
|
||||
@@ -535,7 +547,9 @@ async def test_host_ota_provisioned_api_key(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_line(lines, "once the api key is provisioned")
|
||||
@@ -570,7 +584,9 @@ async def test_host_ota_provisioned_api_key_safe_mode(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _provision_key(dev, api_client_connected)
|
||||
@@ -579,7 +595,9 @@ async def test_host_ota_provisioned_api_key_safe_mode(
|
||||
# exits, so safe mode takes a second start
|
||||
force_safe_mode(DEVICE_NAME)
|
||||
key = PROVISIONING_PSK.decode()
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
async with run_binary(
|
||||
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
|
||||
) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
|
||||
@@ -611,7 +629,10 @@ async def test_host_ota_rejects_garbage(
|
||||
bogus_path = integration_test_dir / "bogus.bin"
|
||||
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
|
||||
|
||||
async with run_binary(dev.binary_path) as (proc, _lines):
|
||||
async with run_binary(dev.binary_path, symbols_path=dev.symbols_path) as (
|
||||
proc,
|
||||
_lines,
|
||||
):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
@@ -138,12 +138,3 @@ def test_sd140_v7_dfu_accepts_all_s140_7_softdevices(
|
||||
cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0])
|
||||
sd_req = cmd[cmd.index("--sd-req") + 1].split(",")
|
||||
assert sd_req == ["0x00CA", "0x0100", "0x0123"]
|
||||
|
||||
|
||||
def test_successful_build_writes_idedata(build_dir: Path, run_cmd: Mock) -> None:
|
||||
"""The idedata cache is written after the build, as on the other native toolchains."""
|
||||
with patch(
|
||||
"esphome.components.nrf52.toolchain.get_idedata", return_value={}
|
||||
) as get_idedata:
|
||||
_build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd)
|
||||
get_idedata.assert_called_once_with()
|
||||
@@ -1,37 +0,0 @@
|
||||
"""The nrf52 sdk-nrf native backend: idedata from the app's compile database."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.nrf52 import toolchain
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "app_dir"),
|
||||
[(cv.Version(2, 9, 2), ("zephyr",)), (cv.Version(2, 9, 1), ())],
|
||||
)
|
||||
def test_get_idedata_reads_the_app_compile_database(
|
||||
tmp_path: Path, version: cv.Version, app_dir: tuple[str, ...]
|
||||
) -> None:
|
||||
"""The compile database sits in the app build dir of the SDK's layout."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
CORE.name = "livingroom"
|
||||
with (
|
||||
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: version}}),
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"prog_path": "x"},
|
||||
) as load,
|
||||
):
|
||||
assert toolchain.get_idedata() == {"prog_path": "x"}
|
||||
|
||||
app = CORE.relative_pioenvs_path("livingroom", *app_dir)
|
||||
compile_commands, elf, _cache = load.call_args.args
|
||||
assert compile_commands == app / "compile_commands.json"
|
||||
assert elf == app / "zephyr" / "zephyr.elf"
|
||||
@@ -1718,36 +1718,6 @@ def test_add_library_str(
|
||||
assert libraries[0].repository == repository
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_platformio_options_sdk_nrf(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""sdk-nrf is a native toolchain: build_flags/lib_deps/lib_ignore are
|
||||
honored and everything else warns instead of going to platformio.ini."""
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "nrf52",
|
||||
KEY_TARGET_FRAMEWORK: "zephyr",
|
||||
}
|
||||
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"build_flags": ["-DSINGLE_FLAG"],
|
||||
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
|
||||
"lib_ignore": "libsodium",
|
||||
"board_build.f_flash": "80000000L",
|
||||
}
|
||||
)
|
||||
|
||||
assert "-DSINGLE_FLAG" in CORE.build_flags
|
||||
assert "ArduinoJson" in CORE.platformio_libraries
|
||||
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
|
||||
assert (
|
||||
"esphome->platformio_options->board_build.f_flash is ignored when "
|
||||
"building with the native 'sdk-nrf' toolchain" in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_platformio_options_native_idf(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
|
||||
@@ -996,7 +996,7 @@ class TestEsphomeCore:
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_native_toolchain is False
|
||||
target.toolchain = const.Toolchain.SDK_NRF
|
||||
assert target.using_native_toolchain is True
|
||||
assert target.using_native_toolchain is False
|
||||
|
||||
def test_using_toolchain_host(self, target):
|
||||
"""The host's only toolchain: the system compiler driven by ninja."""
|
||||
@@ -1021,24 +1021,20 @@ class TestEsphomeCore:
|
||||
|
||||
assert "Wire" in target.platformio_libraries
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"toolchain", [const.Toolchain.ESP_IDF, const.Toolchain.SDK_NRF]
|
||||
)
|
||||
def test_add_build_unflag__warns_on_native_cmake_toolchains(
|
||||
self, target, caplog: pytest.LogCaptureFixture, toolchain: const.Toolchain
|
||||
def test_add_build_unflag__warns_on_native_idf_toolchain(
|
||||
self, target, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Build unflags are not consumed by the native IDF and nRF52 build
|
||||
generators, so adding one on those toolchains warns; PlatformIO stays
|
||||
silent."""
|
||||
"""Build unflags are not consumed by the native IDF build generator,
|
||||
so adding one on that toolchain warns; PlatformIO stays silent."""
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
target.add_build_unflag("-fno-rtti")
|
||||
assert "ignored" not in caplog.text
|
||||
|
||||
target.toolchain = toolchain
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
target.add_build_unflag("-fno-exceptions")
|
||||
assert (
|
||||
"Build unflag -fno-exceptions is ignored when building with the "
|
||||
f"native '{toolchain.value}' toolchain" in caplog.text
|
||||
"native ESP-IDF toolchain" in caplog.text
|
||||
)
|
||||
# The unflag is still recorded either way.
|
||||
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
|
||||
|
||||
@@ -7643,11 +7643,10 @@ def test_upload_program_ota_bootloader_skip_tree_names_the_flag(
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "pio_project_written"),
|
||||
[
|
||||
# The native toolchains generate their project at compile time, so
|
||||
# The native toolchain generates its project at compile time, so
|
||||
# write_cpp_file must not write a platformio.ini; the default
|
||||
# toolchain writes the PlatformIO project files.
|
||||
(Toolchain.ARDUINO, False),
|
||||
(Toolchain.SDK_NRF, False),
|
||||
(None, True),
|
||||
],
|
||||
)
|
||||
@@ -7705,6 +7704,7 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
|
||||
[
|
||||
(PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
|
||||
(PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
|
||||
# No native build backend, but its binutils and ELF are known
|
||||
(PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"),
|
||||
],
|
||||
)
|
||||
@@ -7806,27 +7806,10 @@ def test_command_analyze_memory_missing_binutils_fails_by_name(
|
||||
|
||||
def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None:
|
||||
"""A non-native, non-platformio toolchain errors out cleanly."""
|
||||
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path)
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
|
||||
# Every toolchain has a backend today; drop sdk-nrf's to stand in for one
|
||||
# that does not
|
||||
with patch("esphome.__main__.native_backend", return_value=None):
|
||||
assert command_idedata(MagicMock(), CORE.config) == 1
|
||||
|
||||
|
||||
def test_command_idedata_sdk_nrf_prints_json(
|
||||
tmp_path: Path, capsys: CaptureFixture
|
||||
) -> None:
|
||||
"""Under the sdk-nrf toolchain, idedata comes from the nrf52 backend."""
|
||||
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
data = {"cxx_path": "arm-zephyr-eabi-g++", "prog_path": "/build/zephyr.elf"}
|
||||
|
||||
with patch("esphome.components.nrf52.toolchain.get_idedata", return_value=data):
|
||||
assert command_idedata(MagicMock(), CORE.config) == 0
|
||||
|
||||
assert json.loads(capsys.readouterr().out) == data
|
||||
assert command_idedata(MagicMock(), CORE.config) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -7961,7 +7944,9 @@ def test_command_analyze_memory_unsupported_toolchain(
|
||||
|
||||
# Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for
|
||||
# one that does not
|
||||
with patch("esphome.__main__.native_backend", return_value=None):
|
||||
with patch.dict(
|
||||
"esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True
|
||||
):
|
||||
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
|
||||
|
||||
assert result == 1
|
||||
|
||||
Reference in new issue
Block a user