Compare commits

..
1357 changed files with 14032 additions and 42213 deletions

No files matched your search

-215
View File
@@ -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.
-68
View File
@@ -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).
+5 -11
View File
@@ -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.
-240
View File
@@ -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.
-101
View File
@@ -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.
-124
View File
@@ -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`.
-169
View File
@@ -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`).
+3 -14
View File
@@ -111,11 +111,11 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
or exits 1 when a build, flash or port step failed.
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
(server with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame).
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
## Templates in `$SKILL/templates`
@@ -133,20 +133,9 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`,
`online-image-s3-eth-dut.yaml`,
and the directories `store_yaml/` (package with secrets one directory up, per-board files),
`http_request_update/` (manifest URL setter shapes, per-board files), `modem/` (modem only and modem plus
Wi-Fi boot checks), `ble_midi/` (fake BLE MIDI instrument and the client), `http_request_action/` (request
action package with per-board files and an OTA flash variant) `uart_sim/` (simulator header, common package, per-build parts and run files) and `remote_base/` (common
package, transmit script, per-board templates and the ESP8266 software sender).
## When the PR keeps moving
Record every head in the stack (`gh pr view N --json headRefOid`), build, and recheck before flashing; if anything
moved, wait five minutes and check again so the test runs on a settled head. When a head moved only by merging dev,
compare the PR's own diff (`git diff $(git merge-base OLD origin/dev) OLD` against the same for NEW); if it is
identical and the dev commits do not touch the code under test, earlier results still describe it, but rebuild so the
comment quotes the exact commits. Fetch `origin dev` before computing a merge base.
Wi-Fi boot checks) and `ble_midi/` (fake BLE MIDI instrument and the client).
## When something looks wrong
@@ -50,9 +50,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
while Wi-Fi is off).
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
- A board left in the ROM bootloader after an upload (BOOT held, or the USB JTAG reset landing in download mode
again) starts its app without a replug through `esptool --port <port> --after watchdog-reset chip-id`; flash a
minimal Wi-Fi plus API image first when recovering from a crash-looping one.
## RP2040 / RP2350 (Pico W, Pico 2 W)
@@ -86,8 +83,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
Fault dumps print PC, LR and R0 to R3.
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
- The `rtw_is_adapter_up` bus fault after `Disabling AP` happens on LibreTiny 1.13.0 and 1.14.0 alike
(2 of 4 and 3 of 3 runs in #21531); run at least four rounds per build before blaming a PR for it.
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
@@ -118,13 +113,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception
handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit`
placed there after `.itcm.code` works for a crash record.
- Pick the board that matches the module's flash layout. A NiceMCU XH-WB3S (BK7238) needs `board: xh-wb3s` (plain
Beken layout, RF calibration at 0x1E0000). Built as `generic-bk7238-tuya` (calibration at 0x1E3000, download
partition over 0x1E0000) the firmware boots and may even join a known network, but scans come back empty and its
AP is invisible to clients, with no warning in the log. If scans are empty, check the layout first.
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
@@ -10,14 +10,14 @@ One file per component, named after it:
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
- [`online_image.md`](online_image.md): stalled and malformed downloads
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
@@ -34,7 +34,3 @@ One file per component, named after it:
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
@@ -1,13 +0,0 @@
# GPIO expanders
## Interrupt driven base class (#21469, #21468)
Any device with a PCF8574 input expander whose INT line goes to an ESP32 GPIO works; the inputs should change
during the test (end switches on a gate or cover are ideal). Before flashing, check with `nm` that
`gpio_expander::interrupt_pin_isr` lands in IRAM (0x4008xxxx on a classic ESP32). Then confirm `dump_config` lists
the interrupt pin and the input states match reality, and that the `pcf8574` entry is absent from the runtime stats
while idle (its loop is disabled). Drive the inputs (for a cover, `cover_command(position=1.0)` and then `0.0` over
the API, waiting for each end state): every input edge should log, `pcf8574` should show a small count in that
minute's stats and vanish in the next. For a device that cannot be reached physically, boot the same code on a
bench board first and keep a merge base image built as the fallback; an ESP32 bootloader without rollback support
leaves only `safe_mode` to recover a bad image.
@@ -16,33 +16,3 @@ update state (latest version, summary). The ESP8266 Arduino client gets `Error r
quarter of the fetches from this Python server on every build, so count checks against the base before blaming a
PR. `http_request` on the ESP8266 needs `verify_ssl: false` even for plain http URLs.
Results: https://github.com/esphome/esphome/pull/21373#issuecomment-6337544933
## Non-blocking request actions (#21486)
`scripts/http_request/hsrv.py PORT` serves `/fast`, `/slow?d=S`, `/big` and `/chunked` (`n` bytes at `rate` B/s,
deterministic bytes `(i*7+3)&0xFF`), `/post` (echoes length and byte sum), `/redirect` and `/hang`.
`templates/http_request_action/hr-common.yaml` (substitutions `name`, `bufsize`; per-board files `hr32.yaml` and
`hr66.yaml`, `-s name hr32base` on the command line for the base build; `hrota32.yaml` adds the `otaflash` action
for `otadrive.py`) has API actions `req_get`, `req_post`, `burst` (N runs of a `mode: parallel` script on one action),
`cancel` (`mode: single` script run twice, then `script.stop`) and `reboot_during`, and logs
`RESULT n status len sum took maxgap`, where maxgap is the longest main loop gap during the request (an
`interval: 1ms` lambda plus the time since its last pass, so a fully blocking request counts too). Drive with
`scripts/http_request/hrdrive.py IP http://HOST:PORT LABEL [https]` (`ONLY=case,case` and `ROUNDS=n` env; the
`cross` case runs two actions on one component). `burstonly.py IP URL N` checks concurrency against the server's
completion times, `reboottest.py IP URL` times `App.safe_reboot()` with requests in flight, `updcheck.py IP LABEL N`
runs N update checks over the API (for real device configs that use `update: platform: http_request`) and
`otadrive.py` triggers `ota.http_request.flash` (the API variable is `sum`; `md5` is a reserved variable name).
`execute_service` must be awaited on current aioesphomeapi or nothing is sent. Keep the ESP32 `bufsize` at 32 KB or
less: the action copies the body into a `std::string` while the buffer is still held, and 80 KB aborts with out of
memory on dev and on the PR alike. `http_request` refuses BK72xx and RTL87xx at validation.
## OTA request cases with basic auth (#21556)
`scripts/http_request/ota_auth/`: `ho-common.yaml` (API actions for `ota.http_request.flash` with `md5_url`, `md5`
and basic auth, `flash()` with no URL, an invalid URL, the `md5_computed()`/`md5_expected()` getters), `fwsrv.py`
(serves a www dir, `/auth/` needs `otauser:otapass`), `hodrive.py` (11 cases, reconnects after each good flash) and
`run.sh BUILD BOARD` (compiles from `TREE`, serial flash, serves that exact image; `TREE`, `P32` or `P66`, `SC` and `HOST_IP` in
the environment). On the D1 mini the authenticated MD5 read fails
with `Error reading MD5: -1` on dev too, and stored credentials carry over to the next flash on both builds.
Results: https://github.com/esphome/esphome/pull/21556#issuecomment-6381067561
@@ -1,10 +0,0 @@
# Addressable LED strips (#21447, #21445)
- To exercise a failed strip allocation, use a classic ESP32 without PSRAM with `num_leds: 65535` and
`channel_colors: GRBW` (262 kB, bigger than any internal block). `num_leds` is uint16 in the drivers, larger
values wrap. On dev such a strip boot-loops the board.
- Never force allocation failures by hogging RAM instead: Wi-Fi then aborts in `operator new` or the loop task
trips the task watchdog.
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
add effects; restore its own config afterwards.
@@ -51,31 +51,3 @@ context rejects a CA that lacks a `keyUsage` extension (`CA cert does not includ
on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one
`Last esp-tls error: 0x8008` on every build, TLS or not. Results:
https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827
## MAC suffix topics (#9315)
`scripts/mqtt/mac_suffix/run.sh X BUILD` compiles (from `TREE`) and flashes variant X (a defaults, b custom `topic_prefix`, c explicit topics,
d no suffix) of BUILD (`pr` or `base`) on an ESP32 and a D1 mini, records the broker with
`mosquitto_sub -F '@H:@M:@S r=%r %t %p'`, presses Restart through the `cmd_t` found in the discovery payloads, then
holds both chips in the bootloader for 40 s (`esptool --after no-reset chip-id`) so the broker sends the will; a
plain reset only gives "session taken over" and no will. The two boards' configs live in separate folders because
variants a to c share a `name` (the MAC suffix tells them apart), and the same name in one folder shares a build
directory. A D1 mini CH340 can garble the serial capture after an RTS reset, so read its topics from the broker.
`esphome logs --device MQTT` on a current tree needs paho-mqtt 2.x; an older venv with 1.6.1 fails, so use a venv
made from the tree's requirements.txt. A Restart press never delivered the shutdown message on any build tested.
## Shutdown message delivery (#21566)
`scripts/mqtt/shutdown/`: `sd-common.yaml` sets birth/will/shutdown payloads `hello`/`dead`/`bye` so the shutdown
message and the will cannot be confused, and takes `restart`, `disable` and `sleep` on `<name>/cmd`.
`sdrun.sh V N` (`TREE`, `P32` and `P66` in the environment) compiles and flashes the ESP32 and D1 mini, restarts each N times, disables MQTT once and deep sleeps the ESP32 three
times (never the D1 mini: without the GPIO16 to RST wire it never wakes); `match.py OUT` counts a `bye` within 5 s of
each command. Dev: ESP32 0/10 restarts, D1 mini 1 to 3/10. Put the broker address (`broker_ip`) in the secrets.yaml next to
the included package, and quote `"${name}/status"` inside flow mappings.
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6381448061
Retest at c9cf3c2813 (shutdown message sent at QoS 1, teardown waits for the broker's ack): 10/10 restarts on both
boards and 3/3 deep sleeps, against 1/10, 3/10 and 0/3 on base. An earlier 100 ms hold gave 3/10, 6/10 and 2/3, so
judge a delivery fix by counts over 10 or more restarts, never one or two presses.
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6382076613
@@ -18,47 +18,3 @@ ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
A case expected as `any` passes on `finished` or `error`, never on no outcome.
## PNG decode time limit (#21364, #21391)
The same server also serves any plain file in its www directory by name (404 for a missing file or a path
outside it). `scripts/online_image/make_big_pngs.py <www>` (after `make_images.py`) writes a 320x240 gradient (618 B, also as `ok.png` so the misbehaviour cases carry a
real multi-row PNG), a flat 640x480 PNG (1.6 KB for 307200 pixels), 231 KB of noise and a JPEG. The device is
`templates/online-image-dut.yaml` with `type: BINARY` on the main image (a 640x480 RGB565 does not fit a classic
ESP32 without PSRAM) and `debug` loop time and heap sensors at 2 s; the 256 byte buffer image stays RGB565, so do
not point big images at it (out of memory on every build). `scripts/online_image/pngdrive.py HOST KEY LOGFILE
BASEURL LABEL` (default case set `png`; `CASES=short` for the six case list) calls the actions, waits for the
finished or error line and reports the case time, the highest loop time and the lowest free heap. The per pass `online_image.image took a long time` values come from
the serial capture and only print on a new maximum, so read the first fetch of each image. The pass that decodes a
whole buffer is the decoder cost (before the change 510 ms gradient and 1980 ms flat, after it 77 and 140 ms); for
the noisy PNG the longest pass is the blocking 64 KB read on both builds (0.7 to 3.7 s). Alternate PR, base, PR
for a timing claim.
For a replacement PR, compare against the parent PR head, not dev. `CASES=noise` fetches the noisy PNG three times
(run it right after a boot, alternating builds); `CASES=end` runs flat and gradient as plain, `noclen_`, `chunked_`,
`half_` and `stall_` through the prefixed server cases below.
## JPEG decoded as it downloads (#21342)
`oi_server.py` serves prefixed cases on any file: `half_<file>` (first half, matching Content-Length),
`noclen_<file>` (close delimited), `chunked_<file>` (1 KB chunks), `stall_<file>` (a third, then the socket held
40 s). `scripts/online_image/make_jpegs.py <www>` writes noise, gradient, 12 KB EXIF and progressive JPEGs. Device:
`templates/online-image-dut.yaml` with `type: BINARY` on both entities and `buffer_size: 4096` on the second;
`CASES=jpeg scripts/online_image/pngdrive.py HOST KEY LOGFILE BASEURL LABEL` runs the JPEG list; every case set
prints case time, highest loop time and lowest free heap. Before the change a JPEG needs a contiguous block of its file size (233 KB never
allocates on a classic ESP32, and after a few failures 69 KB does not either until a reboot); after it the 64 KB
buffer stays 64 KB and 4 KB is enough for a baseline JPEG. The first pass still reads up to `buffer_size` in one
blocking read, so the 64 KB entity blocks 1.7 to 4 s and the 4 KB one 272 ms at most; judge the decode budget on
the small buffer entity. Already on dev: a JPEG without Content-Length (plain or chunked) ends with `Could not open
image for decoding: 4` then `Invalid type`.
## BMP and QOI decode time limit (#21392)
Use a wired board (`templates/online-image-s3-eth-dut.yaml`: Waveshare ESP32-S3-ETH, W5500, PSRAM, `type: RGB565`)
rather than a classic ESP32 on Wi-Fi: with a weak link the reads block up to the 4.5 s timeout and the per case loop
numbers are noise. `scripts/online_image/make_bmp_qoi.py <www>` writes 1 bit BMP 640x480, 8 and 24 bit BMP 320x240,
QOI 640x480 one colour (all runs) and checkerboard, QOI 320x240 gradient and noise. `pngdrive.py` with
`CASES=bmpqoi` runs each as plain, `noclen_`, `chunked_`, `half_` plus stalls. The `loop_time` sensor sits at 75
to 160 ms even for a 16x16 image, so for decode passes use `scripts/online_image/firstpass.py HOST KEY BASEURL
LABEL IMAGE...`: it reboots the device before each fetch and reports that fetch's `took a long time` maximum. A
`half_` file with matching Content-Length reports `download finished` with "not caching it" on dev too.
@@ -1,30 +0,0 @@
# remote_base / remote_receiver
## Loopback on one pin, no IR hardware (#21560 to #21563)
`templates/remote_base/`: `rb-common.yaml` (API action `tx_all`, a dumper registered from `on_boot` through
`register_dumper()` standing in for an external component), `rb-txscript.yaml` (14 `remote_transmitter.transmit_*`
frames with `tx <proto>` log markers), `rb32.tmpl` and `rb66.tmpl` (`@V@` is the build label).
`scripts/remote_base/run.sh LABEL B N` (`TREE`, `P32` or `P66`, `SC` in the environment) writes the run file from the
template, builds and flashes it from `TREE` (`esphome run --no-logs`), finds the IP and runs `rbdrive.py IP N`, which segments the API log by the
markers and prints what each sent frame decoded as.
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
where no primary matched.
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
- API log lines can drop under bursts; rerun with more rounds before calling a shortfall.
- `register_dumper()` returns nothing, so the config logs `ext dumper register called`, not whether it stuck. On dev
the list has a fixed size and a dumper beyond it is dropped with `No dumper slot` (ERROR); judge by that line and by
`ext dumper saw` counts in the `rbdrive.py` table, which only appear when the dumper is really registered.
`rbdrive.py` exits 1 when the external dumper saw no frame, so a build that rejects the registration fails the
drive; set `EXPECT_REGISTER=1` on `run.sh` to also fail the run on any `No dumper slot` line.
Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
only the last line sees "Failed to upload" first, so rerun before blaming the build.
@@ -1,19 +0,0 @@
# Simulated UART sensors on a classic ESP32 (#21449, #21446)
No sensor needed. `templates/uart_sim/sim.hpp` (included with `esphome: includes:`) has frame builders for wts01,
ds1603l, sm300d2 and ufm01, `sim::loopback(id(uart))` (ESP-IDF `uart_set_loop_back` on the component's own UART),
`sim::send()` that logs the bytes, and `sim::probe`, a SEN0465 responder polled from a 10 ms interval. The run files
`fra.yaml`, `frb.yaml` and `frc.yaml` set the name and include `fr-common.yaml` plus their `fr*-parts.yaml` as
packages; copy them with a different name (`frabase` and so on) for the base build.
- Push-style sensors: put the component on a UART, switch it to loopback in `on_boot`, and inject frames from
template buttons (`fra-parts.yaml`, `frb-parts.yaml`). ufm01 enters its active stream by itself when a 1 s
interval streams frames during its 2 s startup wait.
- Request/response sensors: cross-wire two UARTs through the GPIO matrix (UART1 tx 25 rx 26, UART2 tx 26 rx 25,
`allow_other_uses: true` on all four pins); the second UART runs the simulated device (`frc-parts.yaml`).
A select sets the probe's misbehaviour (tail, FF tail, silent, split, wrong gas).
- Polled readers: run `update()` from a script and log `micros()` around it to measure blocking
(sm300d2 on dev blocks about 104 ms on an empty buffer).
- The logger stays on UART0, so three builds cover five components. Drive with `scripts/uart_sim/frdrive.py HOST
LOG press:NAME sel:NAME=OPT num:NAME=V wait:S`, or `scripts/uart_sim/run.sh NAME TREE SECONDS OPS...` (build
and flash from TREE with `esphome run --no-logs`, capture, drive; `PORT` and `SC` in the environment).
@@ -1,44 +0,0 @@
"""Run the burst action once and print each queued request as it completes.
Usage: burstonly.py IP URL N"""
import asyncio
import re
import sys
import time
import aioesphomeapi
IP, URL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
t0 = time.monotonic()
done = []
errors = []
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if "BURSTRESP status=200 " in t:
done.append(t)
elif "BURSTRESP" in t or "BURSTERR" in t:
errors.append(t)
if "hrt" in t or "Too many" in t:
print(f"{time.monotonic() - t0:6.2f} {t[:120]}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
await asyncio.sleep(1)
t0 = time.monotonic()
await c.execute_service(s["burst"], {"url": URL, "count": N})
await asyncio.sleep(14)
await c.disconnect()
print(
f"{len(done)} of {N} burst requests answered, {len(errors)} failed", flush=True
)
return 0 if len(done) >= N else 1
sys.exit(asyncio.run(main()))
@@ -1,159 +0,0 @@
"""Drive the http_request action test device over the API and collect its hrt log lines.
Usage: hrdrive.py DEVICE_IP SERVER_BASE LABEL [https]"""
import asyncio
import os
import re
import sys
import time
import aioesphomeapi
IP, BASE, LABEL = sys.argv[1], sys.argv[2], sys.argv[3]
HTTPS = len(sys.argv) > 4
CASES = [
("get", "fast", BASE + "/fast"),
("get", "slow2s", BASE + "/slow?d=2"),
("get", "big70k", BASE + "/big?n=70000&rate=20000"),
("get", "chunked70k", BASE + "/chunked?n=70000&rate=20000"),
("post", "post500", BASE + "/post"),
("get", "redirect", BASE + "/redirect"),
("get", "hang", BASE + "/hang?d=20"),
("get", "unreachable", "http://192.0.2.1:81/"),
]
if HTTPS:
CASES += [
("get", "https_get", "https://www.howsmyssl.com/a/check"),
("post", "https_post", "https://httpbin.org/post"),
]
ONLY = os.environ.get("ONLY")
if ONLY:
CASES = [c for c in CASES if c[1] in ONLY.split(",")]
ROUNDS = int(os.environ.get("ROUNDS", "2"))
# cases whose request must fail (past the 8 s timeout, or an address that never answers)
EXPECT_ERROR = {"hang", "unreachable"}
EXTRAS = not ONLY
timeouts: list[str] = []
mismatches: list[str] = []
async def main() -> int:
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
_, services = await c.list_entities_services()
svc = {s.name: s for s in services}
lines = []
def on_log(m):
t = (
m.message.decode(errors="replace")
if isinstance(m.message, bytes)
else m.message
)
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
if (
"[hrt" in t
or "http_request" in t
or "Script" in t
or "already running" in t
):
lines.append(t)
print(f"{LABEL} {t}", flush=True)
def bad_burst(ln: str) -> bool:
return "BURSTERR" in ln or ("BURSTRESP" in ln and "status=200 " not in ln)
def mismatch(what: str, why: str) -> None:
print(f"{LABEL} {what} MISMATCH: {why}", flush=True)
mismatches.append(what)
async def wait_until(done, secs: float, what: str) -> None:
"""Poll done() until it holds; print a TIMEOUT marker when the deadline passes first."""
end = time.monotonic() + secs
while not done():
if time.monotonic() >= end:
print(f"{LABEL} {what} TIMEOUT", flush=True)
timeouts.append(what)
return
await asyncio.sleep(0.2)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(1)
for rnd in range(1, ROUNDS + 1):
for kind, name, url in CASES:
n0 = len(lines)
print(f"{LABEL} == {name} round {rnd}", flush=True)
if kind == "get":
await c.execute_service(svc["req_get"], {"url": url})
else:
await c.execute_service(
svc["req_post"], {"url": url, "payload": "x" * 500}
)
await wait_until(
lambda n0=n0: any("RESULT" in ln for ln in lines[n0:]),
40,
f"{name} round {rnd}",
)
result = next((ln for ln in lines[n0:] if "RESULT" in ln), None)
if result is not None and (
("status=200 " not in result)
if name not in EXPECT_ERROR
else (" error " not in result)
):
mismatch(f"{name} round {rnd}", result)
await asyncio.sleep(1.5)
if not EXTRAS:
continue
print(f"{LABEL} == burst round {rnd}", flush=True)
n0 = len(lines)
await c.execute_service(svc["burst"], {"url": BASE + "/slow?d=1", "count": 6})
await wait_until(
lambda n0=n0: (
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 6
),
30,
f"burst round {rnd}",
)
if any(bad_burst(ln) for ln in lines[n0:]):
mismatch(f"burst round {rnd}", "a burst request failed or was not 200")
await asyncio.sleep(2)
print(f"{LABEL} == cancel round {rnd}", flush=True)
n0 = len(lines)
await c.execute_service(svc["cancel"], {"url": BASE + "/slow?d=2"})
await asyncio.sleep(9)
if not any("CANCEL stopped running=0" in ln for ln in lines[n0:]):
mismatch(
f"cancel round {rnd}", "script.stop did not leave the script stopped"
)
print(f"{LABEL} == cross round {rnd}", flush=True)
n0 = len(lines)
await c.execute_service(svc["req_get"], {"url": BASE + "/slow?d=2&tag=cross"})
await c.execute_service(
svc["burst"], {"url": BASE + "/slow?d=1&tag=crossburst", "count": 3}
)
await wait_until(
lambda n0=n0: (
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 3
and any("RESULT" in ln for ln in lines[n0:])
),
30,
f"cross round {rnd}",
)
if any(
bad_burst(ln) or ("RESULT" in ln and "status=200 " not in ln)
for ln in lines[n0:]
):
mismatch(f"cross round {rnd}", "a request failed")
await asyncio.sleep(2)
await c.disconnect()
if timeouts:
print(f"{LABEL} TIMEOUTS: {', '.join(timeouts)}", flush=True)
if mismatches:
print(f"{LABEL} MISMATCHES: {', '.join(mismatches)}", flush=True)
return 1 if timeouts or mismatches else 0
sys.exit(asyncio.run(main()))
@@ -1,78 +0,0 @@
"""Test server for http_request actions: deterministic bodies, slow and chunked responses."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import sys
import time
from urllib.parse import parse_qs, urlparse
def body_bytes(n):
return bytes((i * 7 + 3) & 0xFF for i in range(n))
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *a):
sys.stdout.write(f"{time.time():.3f} {self.client_address[0]} {fmt % a}\n")
sys.stdout.flush()
def _q(self):
u = urlparse(self.path)
return u.path, {k: v[0] for k, v in parse_qs(u.query).items()}
def _send(self, data, code=200, ctype="application/octet-stream"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
got = self.rfile.read(n)
self._send(
b"posted %d %d" % (len(got), sum(got) & 0xFFFFFFFF), ctype="text/plain"
)
def do_GET(self):
path, q = self._q()
if path == "/fast":
self._send(b"hello", ctype="text/plain")
elif path == "/slow":
time.sleep(float(q.get("d", "2")))
self._send(b"slow ok", ctype="text/plain")
elif path == "/redirect":
self.send_response(302)
self.send_header("Location", "/fast")
self.send_header("Content-Length", "0")
self.end_headers()
elif path == "/hang":
time.sleep(float(q.get("d", "30")))
elif path in ("/big", "/chunked"):
n = int(q.get("n", "70000"))
rate = float(q.get("rate", "20000"))
data = body_bytes(n)
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
if path == "/big":
self.send_header("Content-Length", str(n))
else:
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
step = 1000
for i in range(0, n, step):
part = data[i : i + step]
if path == "/big":
self.wfile.write(part)
else:
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
self.wfile.flush()
time.sleep(len(part) / rate)
if path == "/chunked":
self.wfile.write(b"0\r\n\r\n")
else:
self._send(b"nope", 404)
ThreadingHTTPServer(("0.0.0.0", int(sys.argv[1])), H).serve_forever()
@@ -1,10 +0,0 @@
esphome:
name: ho32base
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
common: !include ../../ho-common.yaml
@@ -1,11 +0,0 @@
esphome:
name: ho66base
esp8266:
board: d1_mini
packages:
common: !include ../../ho-common.yaml
http_request:
verify_ssl: false
@@ -1,32 +0,0 @@
"""Serve a www dir for ota.http_request tests; /auth/ needs basic auth otauser:otapass.
Usage: fwsrv.py WWW PORT"""
import base64
import functools
import http.server
import sys
WWW, PORT = sys.argv[1], int(sys.argv[2])
GOOD = "Basic " + base64.b64encode(b"otauser:otapass").decode()
class H(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/auth/") and self.headers.get("Authorization") != GOOD:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="ota"')
self.send_header("Content-Length", "0")
self.end_headers()
return
super().do_GET()
def log_message(self, fmt, *args):
print(
f"{self.address_string()} auth={'Authorization' in self.headers} {fmt % args}",
flush=True,
)
http.server.ThreadingHTTPServer(
("0.0.0.0", PORT), functools.partial(H, directory=WWW)
).serve_forever()
@@ -1,60 +0,0 @@
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
api:
reboot_timeout: 0s
actions:
- action: fl_md5url
variables: {url: string, md5url: string}
then:
- ota.http_request.flash:
url: !lambda return url;
md5_url: !lambda return md5url;
- action: fl_md5
variables: {url: string, sum: string}
then:
- ota.http_request.flash:
url: !lambda return url;
md5: !lambda return sum;
- action: fl_auth
variables: {url: string, md5url: string, user: string, pw: string}
then:
- ota.http_request.flash:
url: !lambda return url;
md5_url: !lambda return md5url;
username: !lambda return user;
password: !lambda return pw;
- action: fl_nourl
then:
- lambda: id(hota).flash();
- action: fl_badurl
then:
- lambda: |-
id(hota).set_url("not a url");
id(hota).flash();
- action: getters
then:
- lambda: |-
ESP_LOGI("hot", "getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
id(hota).md5_expected().c_str());
esphome:
on_boot:
priority: -100
then:
- lambda: |-
ESP_LOGI("hot", "boot getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
id(hota).md5_expected().c_str());
http_request:
timeout: 10s
ota:
- platform: esphome
- platform: http_request
id: hota
@@ -1,142 +0,0 @@
"""Drive the ota.http_request cases over the API.
Usage: hodrive.py IP BASEURL MD5FILE (BASEURL serves fw.bin, fw.md5, bad.md5, auth/fw.bin, auth/fw.md5)"""
import asyncio
import pathlib
import re
import sys
import time
import aioesphomeapi
if len(sys.argv) != 4:
sys.exit(__doc__)
IP, BASE = sys.argv[1:3]
SUM = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8").strip()
T0 = time.monotonic()
lines: list[str] = []
failed: list[str] = []
def out(s: str) -> None:
print(f"{time.monotonic() - T0:7.2f} {s}", flush=True)
async def connect() -> tuple[aioesphomeapi.APIClient, dict]:
last: Exception | None = None
for _ in range(90):
c = aioesphomeapi.APIClient(IP, 6053, None)
try:
await c.connect(login=True)
except (
aioesphomeapi.InvalidAuthAPIError,
aioesphomeapi.InvalidEncryptionKeyAPIError,
aioesphomeapi.RequiresEncryptionAPIError,
):
raise # a key or password problem never fixes itself
except Exception as exc: # noqa: BLE001
last = exc
await asyncio.sleep(2)
continue
i = await c.device_info()
out(f"connected {i.name} {i.compilation_time}")
_, svcs = await c.list_entities_services()
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if re.search(r"\[hot|http_request|ota|OTA|MD5|md5", t):
lines.append(t)
out(t[:170])
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
return c, {x.name: x for x in svcs}
raise SystemExit(f"device never came back (last error {last!r})")
async def case(c, s, name, args, want, timeout=60.0):
out(f"=== {name} {args} (want {want})")
start = len(lines)
await c.execute_service(s[name], args)
end = time.monotonic() + timeout
while time.monotonic() < end:
if any(re.search(want, x) for x in lines[start:]):
out(f"RESULT {name} PASS")
return True
await asyncio.sleep(0.2)
out(f"RESULT {name} FAIL (no '{want}')")
failed.append(name)
return False
async def flash_ok(c, s, name, args):
"""A good flash: wait for the device to drop and come back."""
out(f"=== {name} {args} (want reboot)")
start = len(lines)
await c.execute_service(s[name], args)
end = time.monotonic() + 120
while (
time.monotonic() < end
and c._connection is not None
and c._connection.is_connected
): # noqa: SLF001
await asyncio.sleep(0.5)
# a good flash needs both the device's own success line and the connection dropping for the reboot
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
logged = any(
re.search(r"(?i)update complete|rebooting|successful", x) for x in lines[start:]
)
if not dropped:
out(f"{name}: the device never dropped the connection within 120 s")
ok = logged and dropped
await c.disconnect()
await asyncio.sleep(5)
c, s = await connect()
out(f"RESULT {name} {'PASS' if ok else 'FAIL'}")
if not ok:
failed.append(name)
return c, s
async def main():
c, s = await connect()
u, m = f"{BASE}/fw.bin", f"{BASE}/fw.md5"
await case(c, s, "getters", {}, r"computed='' expected=''")
await case(c, s, "fl_nourl", {}, r"URL not set")
await case(c, s, "fl_badurl", {}, r"URL not set")
await case(c, s, "getters", {}, r"computed='' expected=''")
await case(c, s, "fl_md5", {"url": u, "sum": "0" * 32}, r"MD5 mismatch")
await case(c, s, "getters", {}, r"getters computed=")
await case(
c,
s,
"fl_auth",
{
"url": f"{BASE}/auth/fw.bin",
"md5url": f"{BASE}/auth/fw.md5",
"user": "otauser",
"pw": "wrong",
},
r"HTTP Request failed; URL: \S*/auth/fw\.md5; Code: 401",
)
c, s = await flash_ok(
c,
s,
"fl_auth",
{
"url": f"{BASE}/auth/fw.bin",
"md5url": f"{BASE}/auth/fw.md5",
"user": "otauser",
"pw": "otapass",
},
)
c, s = await flash_ok(c, s, "fl_md5url", {"url": u, "md5url": m})
c, s = await flash_ok(c, s, "fl_md5", {"url": u, "sum": SUM})
await case(c, s, "getters", {}, r"computed='' expected=''")
await c.disconnect()
out(
f"SUMMARY {'all cases passed' if not failed else 'FAILED: ' + ', '.join(failed)}"
)
return 1 if failed else 0
sys.exit(asyncio.run(main()))
@@ -1,10 +0,0 @@
esphome:
name: ho32pr
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
common: !include ../../ho-common.yaml
@@ -1,11 +0,0 @@
esphome:
name: ho66pr
esp8266:
board: d1_mini
packages:
common: !include ../../ho-common.yaml
http_request:
verify_ssl: false
@@ -1,30 +0,0 @@
#!/bin/bash
# run.sh BUILD(pr|base) BOARD(32|66) : serial flash ho<BOARD><BUILD>, serve its own image, drive the cases
# Copy this directory to a scratch directory (secrets.yaml next to ho-common.yaml) and run it there.
# env: TREE (esphome checkout for BUILD), P32 or P66 (serial port of the board), SC (path to serial_capture.py),
# HOST_IP (this host's address as the device reaches it), PY (checkout python)
D=$(cd "$(dirname "$0")" && pwd); V=$1; B=$2; N=ho$B$V; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
SC=${SC:?set SC}; HOST=${HOST_IP:?set HOST_IP}
if [ "$B" = 32 ]; then P=${P32:?set P32}; else P=${P66:?set P66}; fi
OUT=$D/out-$V-$B; rm -rf "$OUT"; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome compile "$D/$V/$B/$N.yaml" > "$OUT/compile.log" 2>&1) || { echo "compile failed"; exit 1; }
(cd "$T" && $PY -m esphome upload "$D/$V/$B/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "upload failed"; exit 1; }
# the exact image of this config, just compiled, never one found elsewhere
if [ "$B" = 32 ]; then IMG=$D/$V/$B/.esphome/build/$N/build/firmware.ota.bin
else IMG=$D/$V/$B/.esphome/build/$N/.pioenvs/$N/firmware.bin; fi
[ -f "$IMG" ] || { echo "no image for $N at $IMG"; exit 1; }
echo "image $IMG"
W=$OUT/www; mkdir -p "$W/auth"; cp "$IMG" "$W/fw.bin"; cp "$IMG" "$W/auth/fw.bin"
$PY -c "import hashlib,sys;print(hashlib.md5(open(sys.argv[1],'rb').read()).hexdigest())" "$IMG" > "$W/fw.md5"
cp "$W/fw.md5" "$W/auth/fw.md5"; echo 00000000000000000000000000000000 > "$W/bad.md5"
$PY "$SC" "$P" 400 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
trap 'kill $CAP $SRV 2>/dev/null' EXIT
pkill -f "[f]wsrv.py"; $PY "$D/fwsrv.py" "$W" 8093 > "$OUT/server.txt" 2>&1 & SRV=$!
for i in $(seq 60); do IP=$(grep -a -o 'IP Address: [0-9.]*' "$OUT/serial.txt" | tail -1 | cut -d' ' -f3); [ -z "$IP" ] && IP=$(python3 -c "import socket;print(socket.gethostbyname('$N.local'))" 2>/dev/null); [ -n "$IP" ] && break; sleep 2; done
[ -n "$IP" ] || { echo "device not found"; exit 1; }
echo "device up"
$PY "$D/hodrive.py" "$IP" "http://$HOST:8093" "$W/fw.md5" > "$OUT/drive.txt" 2>&1; RC=$?
kill $SRV; pkill -f "[s]erial_capture.py $P"
grep -E 'RESULT|SUMMARY|never came back' "$OUT/drive.txt"; echo "server requests: $(grep -c . "$OUT/server.txt")"
exit $RC
@@ -1,74 +0,0 @@
"""Trigger ota.http_request.flash over the API, optionally during a burst of action requests.
Usage: otadrive.py IP FWURL MD5 BASE [burst]"""
import asyncio
import contextlib
import re
import sys
import time
import aioesphomeapi
IP, FW, SUM, BASE = sys.argv[1:5]
BURST = len(sys.argv) > 5
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
i = await c.device_info()
print("before:", i.name, i.compilation_time, flush=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
t0 = time.monotonic()
seen: list[str] = []
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
seen.append(t)
if re.search(r"hrt|http_request|ota|OTA|Too many", t):
print(f"{time.monotonic() - t0:7.2f} {t[:170]}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
await asyncio.sleep(1)
if BURST:
await c.execute_service(s["burst"], {"url": BASE + "/slow?d=1", "count": 6})
await asyncio.sleep(0.5)
await c.execute_service(s["otaflash"], {"url": FW, "sum": SUM})
end = time.monotonic() + 45
while (
time.monotonic() < end
and c._connection is not None # noqa: SLF001
and c._connection.is_connected # noqa: SLF001
):
await asyncio.sleep(0.5)
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
with contextlib.suppress(Exception):
await c.disconnect()
for _ in range(30):
try:
c2 = aioesphomeapi.APIClient(IP, 6053, None)
await c2.connect(login=True)
i = await c2.device_info()
print("after:", i.name, i.compilation_time, flush=True)
await c2.disconnect()
break
except Exception: # noqa: BLE001
await asyncio.sleep(2)
else:
print("RESULT FAIL: not reachable after the flash", flush=True)
return 1
if not dropped:
print("RESULT FAIL: the device never rebooted", flush=True)
return 1
if any("OTAFLASH returned" in t for t in seen):
print("RESULT FAIL: the flash action returned, the update failed", flush=True)
return 1
if not any(re.search(r"(?i)update complete|rebooting|successful", t) for t in seen):
print("RESULT FAIL: no update complete line before the reboot", flush=True)
return 1
print("RESULT PASS", flush=True)
return 0
sys.exit(asyncio.run(main()))
@@ -1,68 +0,0 @@
"""Call reboot_during (a slow request in flight, another waiting, then App.safe_reboot) and time the reboot.
Usage: reboottest.py IP URL"""
import asyncio
import contextlib
import re
import sys
import time
import aioesphomeapi
IP, URL = sys.argv[1], sys.argv[2]
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
t0 = time.monotonic()
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if re.search(
r"hrt|http_request|safe_mode|shutdown|Rebooting|reboot", t, re.IGNORECASE
):
print(f"{time.monotonic() - t0:7.2f} {t[:150]}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(1)
t0 = time.monotonic()
await c.execute_service(s["reboot_during"], {"url": URL})
dropped = False
while time.monotonic() - t0 < 30:
try:
await asyncio.wait_for(c.device_info(), 1.0)
await asyncio.sleep(0.2)
except TimeoutError:
continue # a slow reply is not a reboot; only a closed connection counts
except Exception: # noqa: BLE001
dropped = True
print(f"{time.monotonic() - t0:7.2f} API connection gone", flush=True)
break
with contextlib.suppress(Exception):
await c.disconnect()
if not dropped:
print(f"{time.monotonic() - t0:7.2f} NO REBOOT within 30 s", flush=True)
return 1
last: Exception | None = None
for _ in range(60):
try:
c2 = aioesphomeapi.APIClient(IP, 6053, None)
await asyncio.wait_for(c2.connect(login=True), 3)
i = await c2.device_info()
print(f"{time.monotonic() - t0:7.2f} back: {i.name}", flush=True)
await c2.disconnect()
return 0
except Exception as exc: # noqa: BLE001
last = exc
await asyncio.sleep(1)
print(
f"{time.monotonic() - t0:7.2f} NOT BACK after reboot (last error {last!r})",
flush=True,
)
return 1
sys.exit(asyncio.run(main()))
@@ -1,66 +0,0 @@
"""Run N update checks over the API and print the update state plus http_request/update/long-operation log lines.
Usage: updcheck.py IP LABEL N"""
import asyncio
import re
import sys
import time
import aioesphomeapi
IP, LABEL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
info = await c.device_info()
print(
f"{LABEL} device {info.name} {info.esphome_version} {info.compilation_time}",
flush=True,
)
ents, _ = await c.list_entities_services()
upd = next(e for e in ents if isinstance(e, aioesphomeapi.UpdateInfo))
states = []
def on_state(s):
if s.key == upd.key:
states.append(s)
print(
f"{LABEL} {time.monotonic() - t0:7.2f} UPDATE in_progress={s.in_progress} current={s.current_version} latest={s.latest_version} title={s.title!r}",
flush=True,
)
def on_log(m):
t = (
m.message.decode(errors="replace")
if isinstance(m.message, bytes)
else m.message
)
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
if re.search(
r"http_request|update|took a long time|Guru|abort|heap|Free",
t,
re.IGNORECASE,
):
print(f"{LABEL} {time.monotonic() - t0:7.2f} {t[:200]}", flush=True)
t0 = time.monotonic()
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(3)
missing = []
for i in range(N):
print(f"{LABEL} {time.monotonic() - t0:7.2f} CHECK {i + 1}", flush=True)
before = len(states)
c.update_command(upd.key, aioesphomeapi.UpdateCommand.CHECK)
await asyncio.sleep(25)
if not any(not st.in_progress for st in states[before:]):
print(f"{LABEL} CHECK {i + 1} got no finished update state", flush=True)
missing.append(i + 1)
await c.disconnect()
print(f"{LABEL} {N - len(missing)} of {N} checks reported a state", flush=True)
return 1 if missing else 0
sys.exit(asyncio.run(main()))
@@ -1,8 +0,0 @@
substitutions: {name: mqabase, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,9 +0,0 @@
substitutions: {name: mqbbase, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre32
@@ -1,18 +0,0 @@
substitutions: {name: mqcbase, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit32/log
birth_message:
topic: explicit32/avail
payload: up
will_message:
topic: explicit32/avail
payload: down
shutdown_message:
topic: explicit32/avail
payload: bye
@@ -1,8 +0,0 @@
substitutions: {name: mqd32base, suffix: "false"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,7 +0,0 @@
substitutions: {name: mqabase, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,8 +0,0 @@
substitutions: {name: mqbbase, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre66
@@ -1,17 +0,0 @@
substitutions: {name: mqcbase, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit66/log
birth_message:
topic: explicit66/avail
payload: up
will_message:
topic: explicit66/avail
payload: down
shutdown_message:
topic: explicit66/avail
payload: bye
@@ -1,7 +0,0 @@
substitutions: {name: mqd66base, suffix: "false"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,24 +0,0 @@
# Package for esphome#9315, included by the per-variant files under pr/ and base/ (substitutions name, suffix).
esphome:
name: ${name}
name_add_mac_suffix: ${suffix}
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
ota:
- platform: esphome
button:
- platform: restart
name: Restart
interval:
- interval: 10s
then:
- logger.log: "alive"
@@ -1,8 +0,0 @@
substitutions: {name: mqapr, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,9 +0,0 @@
substitutions: {name: mqbpr, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre32
@@ -1,18 +0,0 @@
substitutions: {name: mqcpr, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit32/log
birth_message:
topic: explicit32/avail
payload: up
will_message:
topic: explicit32/avail
payload: down
shutdown_message:
topic: explicit32/avail
payload: bye
@@ -1,8 +0,0 @@
substitutions: {name: mqd32pr, suffix: "false"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,7 +0,0 @@
substitutions: {name: mqapr, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,8 +0,0 @@
substitutions: {name: mqbpr, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre66
@@ -1,17 +0,0 @@
substitutions: {name: mqcpr, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit66/log
birth_message:
topic: explicit66/avail
payload: up
will_message:
topic: explicit66/avail
payload: down
shutdown_message:
topic: explicit66/avail
payload: bye
@@ -1,7 +0,0 @@
substitutions: {name: mqd66pr, suffix: "false"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -1,51 +0,0 @@
#!/bin/bash
# run.sh X BUILD : flash variant X (a..d) of BUILD (pr|base) on both boards, record broker traffic, restart, hard reset
# Copy this directory to a scratch directory and run it there; secrets.yaml (wifi_ssid, wifi_password, broker_ip)
# goes next to mq-common.yaml and in every board folder.
# env: TREE (esphome checkout for BUILD), P32 and P66 (ESP32 and ESP8266 serial ports), SC (path to
# serial_capture.py), PY (checkout python)
M=$(cd "$(dirname "$0")" && pwd); X=$1; V=$2; T=${TREE:?set TREE to the checkout under test}
P32=${P32:?set P32}; P66=${P66:?set P66}; SC=${SC:?set SC}
PY=${PY:-$T/venv/bin/python}; OUT=$M/out-$X-$V; mkdir -p $OUT
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"; pkill -f "[m]osquitto_sub -h 127.0.0.1"
# compile one at a time (shared tool caches), then flash both boards at once; upload alone never builds
for b in 32 66; do
(cd $T && $PY -m esphome compile $M/$V/$b/mq${X}$b$V.yaml > $OUT/c$b.log 2>&1) || { echo "compile$b failed, see $OUT/c$b.log"; exit 1; }
done
(cd $T && $PY -m esphome upload $M/$V/32/mq${X}32$V.yaml --device $P32 > $OUT/u32.log 2>&1) & U32=$!
(cd $T && $PY -m esphome upload $M/$V/66/mq${X}66$V.yaml --device $P66 > $OUT/u66.log 2>&1) & U66=$!
wait $U32 || { echo "upload32 failed, see $OUT/u32.log"; exit 1; }
wait $U66 || { echo "upload66 failed, see $OUT/u66.log"; exit 1; }
mosquitto_sub -h 127.0.0.1 -v -t '#' -F '@H:@M:@S r=%r %t %p' > $OUT/broker.txt 2>&1 &
SUB=$!
$PY $SC $P32 170 $OUT/s32.txt --reset > /dev/null 2>&1 &
$PY $SC $P66 170 $OUT/s66.txt > /dev/null 2>&1 &
sleep 60
TOPICS=$(grep "homeassistant/button/" $OUT/broker.txt | grep "\"name\":\"mq${X}" | grep -o '"cmd_t":"[^"]*"' | cut -d'"' -f4 | sort -u)
ERR=0
[ "$(echo "$TOPICS" | grep -c .)" -ge 2 ] || { echo "ERROR: expected a Restart command topic per board, found: ${TOPICS:-none}"; ERR=1; }
for t in $TOPICS; do
mosquitto_pub -h 127.0.0.1 -t "$t" -m PRESS && echo "$(date +%T) press $t" || { echo "$(date +%T) ERROR: publish to $t failed"; ERR=1; }
done
sleep 45
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"; sleep 1
echo "$(date +%T) hold both in the bootloader for 40 s (will test)"
$PY -m esptool --port $P32 --after no-reset chip-id > $OUT/hold32.log 2>&1 & H32=$!
$PY -m esptool --port $P66 --after no-reset chip-id > $OUT/hold66.log 2>&1 & H66=$!
wait $H32 || { echo "ERROR: ESP32 not held in the bootloader, no will expected (see $OUT/hold32.log)"; ERR=1; }
wait $H66 || { echo "ERROR: ESP8266 not held in the bootloader, no will expected (see $OUT/hold66.log)"; ERR=1; }
sleep 40
REL=$(date +%T); echo "$REL release both"
$PY $SC $P32 40 $OUT/s32b.txt --reset > /dev/null 2>&1 &
$PY $SC $P66 40 $OUT/s66b.txt --reset > /dev/null 2>&1 &
sleep 42
kill $SUB
for f in s32 s66; do echo "== $f"; grep -a -E "Topic Prefix|Log Topic|Availability|Client ID" $OUT/$f.txt | head -4 | sed 's/^\[[0-9:]*\]//'; done
echo "== broker (excluding discovery and entity state)"
grep -v -E " homeassistant/| [^ ]*/(sensor|button|binary_sensor)/| esphome/discover/" $OUT/broker.txt | awk '$3 ~ /(debug|log)$/ { if (seen[$3]++) next; print $1, $2, $3, "(log lines:", "see file)"; next } { print }' | head -40
echo "== log line counts per topic"; awk '$3 ~ /(debug|log)$/ {c[$3]++} END {for (t in c) print t, c[t]}' $OUT/broker.txt
# each board must publish its birth message (default "online", explicit variant "up") after the release;
# a missing will is the measured result and does not fail the run
BIRTHS=$(awk -v t="$REL" '$1 >= t && ($4 == "online" || $4 == "up") {print $3}' $OUT/broker.txt | sort -u | grep -c .)
[ "$BIRTHS" -ge 2 ] || { echo "ERROR: $BIRTHS of 2 boards published a birth message after the release"; ERR=1; }
exit $ERR
@@ -1,9 +0,0 @@
substitutions: {name: sd32base}
esphome:
name: sd32base
esp32:
board: esp32dev
framework: {type: esp-idf}
deep_sleep:
id: ds
packages: {c: !include ../sd-common.yaml}
@@ -1,8 +0,0 @@
substitutions: {name: sd66base}
esphome:
name: sd66base
esp8266:
board: d1_mini
deep_sleep:
id: ds
packages: {c: !include ../sd-common.yaml}
@@ -1,33 +0,0 @@
"""match.py OUTDIR: for each event, say whether a bye/dead for that board arrived within 5 s."""
import pathlib
import sys
d = pathlib.Path(sys.argv[1])
def secs(stamp: str) -> int:
h, m, s = (int(x) for x in stamp.split(":"))
return h * 3600 + m * 60 + s
ev = [ln.split() for ln in (d / "events.txt").read_text(encoding="utf-8").splitlines()]
br = [
f
for f in (
ln.split() for ln in (d / "broker.txt").read_text(encoding="utf-8").splitlines()
)
if len(f) >= 3 and f[2] in ("bye", "dead")
]
rows = {}
for ts, kind, b in ev:
hit = [
p
for s, top, p in br
if top.startswith(f"sd{b}") and 0 <= secs(s) - secs(ts) <= 5
]
rows.setdefault((b, kind), []).append(
"bye" if "bye" in hit else (hit[0] if hit else "-")
)
for (b, kind), r in sorted(rows.items()):
print(f"{b} {kind:8s} {r.count('bye')}/{len(r)} bye {' '.join(r)}")
@@ -1,9 +0,0 @@
substitutions: {name: sd32pr}
esphome:
name: sd32pr
esp32:
board: esp32dev
framework: {type: esp-idf}
deep_sleep:
id: ds
packages: {c: !include ../sd-common.yaml}
@@ -1,8 +0,0 @@
substitutions: {name: sd66pr}
esphome:
name: sd66pr
esp8266:
board: d1_mini
deep_sleep:
id: ds
packages: {c: !include ../sd-common.yaml}
@@ -1,40 +0,0 @@
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
discovery: false
birth_message: {topic: "${name}/status", payload: hello}
will_message: {topic: "${name}/status", payload: dead}
shutdown_message: {topic: "${name}/status", payload: bye}
on_message:
- topic: ${name}/cmd
payload: restart
then:
- button.press: restart_btn
- topic: ${name}/cmd
payload: disable
then:
- mqtt.disable:
- delay: 5s
- mqtt.enable:
- topic: ${name}/cmd
payload: sleep
then:
- deep_sleep.enter:
id: ds
sleep_duration: 5s
ota:
- platform: esphome
button:
- platform: restart
id: restart_btn
name: Restart
@@ -1,44 +0,0 @@
#!/bin/bash
# sdrun.sh V(pr|base) [N] : flash both boards, restart each N times over MQTT, then disable, then deep sleep (ESP32)
# Copy this directory to a scratch directory (secrets.yaml with wifi_ssid, wifi_password and broker_ip next to
# sd-common.yaml) and run it there with a local mosquitto on this host.
# env: TREE (esphome checkout for V), P32 and P66 (ESP32 and ESP8266 serial ports), PY (checkout python)
S=$(cd "$(dirname "$0")" && pwd); V=$1; N=${2:-10}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
P32=${P32:?set P32}; P66=${P66:?set P66}; OUT=$S/out-$V; rm -rf "$OUT"; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"
# compile one at a time (shared tool caches), then flash both boards at once; upload alone never builds
for b in 32 66; do
(cd "$T" && $PY -m esphome compile "$S/$V/sd$b$V.yaml" > "$OUT/c$b.log" 2>&1) || { echo "compile$b failed, see $OUT/c$b.log"; exit 1; }
done
(cd "$T" && $PY -m esphome upload "$S/$V/sd32$V.yaml" --device "$P32" > "$OUT/u32.log" 2>&1) & U32=$!
(cd "$T" && $PY -m esphome upload "$S/$V/sd66$V.yaml" --device "$P66" > "$OUT/u66.log" 2>&1) & U66=$!
wait $U32 || { echo "upload32 failed"; exit 1; }; wait $U66 || { echo "upload66 failed"; exit 1; }
mosquitto_sub -h 127.0.0.1 -R -v -t "sd32$V/status" -t "sd66$V/status" -F '@H:@M:@S %t %p' > "$OUT/broker.txt" 2>&1 & SUB=$!
# cmd KIND BOARD: publish the command and record the event only when the publish worked
ERR=0
cmd() {
local ts; ts=$(date +%T)
if mosquitto_pub -h 127.0.0.1 -t "sd$2$V/cmd" -m "$1"; then echo "$ts $1 $2" >> "$OUT/events.txt"
else echo "$ts ERROR: publish $1 to board $2 failed"; ERR=1; fi
}
sleep 40
for b in 32 66; do
grep -q "sd$b$V/status hello" "$OUT/broker.txt" || { echo "ERROR: sd$b$V never sent its birth message"; kill $SUB; exit 1; }
done
for i in $(seq "$N"); do
for b in 32 66; do cmd restart $b; done
sleep 25
done
for b in 32 66; do cmd disable $b; done
sleep 30
for i in 1 2 3; do cmd sleep 32; sleep 30; done
kill -0 $SUB 2>/dev/null || { echo "ERROR: the broker subscriber died, counts are incomplete"; ERR=1; }
kill $SUB 2>/dev/null
for b in 32 66; do echo "sd$b$V: $(grep -c "sd$b$V/status hello" "$OUT/broker.txt") hello, $(grep -c "sd$b$V/status bye" "$OUT/broker.txt") bye, $(grep -c "sd$b$V/status dead" "$OUT/broker.txt") dead"; done
# each restart, the disable (a reconnect) and each ESP32 deep sleep is followed by a new birth message
for b in 32 66; do
want=$((N + 2)); [ $b = 32 ] && want=$((want + 3))
got=$(grep -c "sd$b$V/status hello" "$OUT/broker.txt")
[ "$got" -ge "$want" ] || { echo "ERROR: sd$b$V sent $got birth messages, expected $want (a board did not come back)"; ERR=1; }
done
exit $ERR
@@ -1,115 +0,0 @@
"""Reboot the DUT before each fetch and report the longest online_image loop pass of that one fetch.
usage: firstpass.py HOST KEY BASEURL LABEL IMAGE...
The component warning prints only on a new maximum, so a fresh boot per image gives that image's own maximum.
"""
import asyncio
import contextlib
import re
import sys
import time
from aioesphomeapi import (
APIClient,
ButtonInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
RequiresEncryptionAPIError,
UserService,
)
HOST, KEY, BASE, LABEL = sys.argv[1:5]
IMAGES = sys.argv[5:]
TOOK = re.compile(r"online_image\.image took a long time for an operation \((\d+) ms\)")
OUTCOME = re.compile(r"download (finished|error): img\b")
async def connect() -> tuple[APIClient, dict, int]:
last: Exception | None = None
for _ in range(30):
c = APIClient(HOST, 6053, None, noise_psk=KEY)
try:
await c.connect(login=True)
break
except (
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
RequiresEncryptionAPIError,
):
raise # a wrong key never fixes itself
except Exception as exc: # noqa: BLE001
last = exc
await asyncio.sleep(2)
else:
raise RuntimeError(f"device did not come back (last error {last!r})")
ents, services = await c.list_entities_services()
reboot = next(
(e.key for e in ents if isinstance(e, ButtonInfo) and e.name == "Reboot"), None
)
if reboot is None:
raise RuntimeError("the device has no button named Reboot")
return c, {s.name: s for s in services if isinstance(s, UserService)}, reboot
async def rebooted(c: APIClient, reboot: int) -> bool:
"""Press Reboot and wait up to 15 s for the API connection to drop."""
c.button_command(reboot)
end = time.monotonic() + 15
while time.monotonic() < end:
try:
await asyncio.wait_for(c.device_info(), 1.0)
except TimeoutError:
continue # a slow reply is not a reboot; only a closed connection counts
except Exception: # noqa: BLE001
return True
await asyncio.sleep(0.2)
return False
async def main() -> int:
failed = []
for img in IMAGES:
c, actions, reboot = await connect()
ok = await rebooted(c, reboot)
with contextlib.suppress(Exception):
await c.disconnect()
if not ok:
print(f"{LABEL} {img:22} SKIPPED: no reboot within 15 s", flush=True)
failed.append(img)
continue
await asyncio.sleep(8)
c, actions, reboot = await connect()
lines: list[str] = []
c.subscribe_logs(
lambda m, lines=lines: lines.append(m.message.decode(errors="replace")),
log_level=LogLevel.LOG_LEVEL_DEBUG,
)
await asyncio.sleep(3)
lines.clear()
t0 = time.monotonic()
await c.execute_service(actions["fetch"], {"url": f"{BASE}/{img}"})
outcome = None
while time.monotonic() - t0 < 60 and outcome is None:
await asyncio.sleep(0.2)
outcome = next(
(m.group(1) for ln in lines if (m := OUTCOME.search(ln))), None
)
took = time.monotonic() - t0
await asyncio.sleep(1)
passes = [int(m.group(1)) for ln in lines if (m := TOOK.search(ln))]
print(
f"{LABEL} {img:22} {outcome or 'none':9} {took:5.1f}s longest pass {max(passes) if passes else '<50'} ms {passes}",
flush=True,
)
if (
outcome != "finished"
): # a decode timing is only meaningful for a complete image
failed.append(img)
await c.disconnect()
if failed:
print(f"{LABEL} not finished: {', '.join(failed)}", flush=True)
return 1 if failed else 0
sys.exit(asyncio.run(main()))
@@ -1,32 +0,0 @@
"""Build the large PNGs for the decode time test: a compressible gradient (also saved as ok.png so every server case
carries a multi-row PNG), a flat 640x480 image whose few KB hold 307200 pixels, incompressible noise, and a JPEG."""
from pathlib import Path
import random
import sys
from PIL import Image
www = Path(sys.argv[1])
im = Image.new("RGB", (320, 240))
px = im.load()
for y in range(240):
for x in range(320):
px[x, y] = (x * 255 // 319, y * 255 // 239, 128)
im.save(www / "ok.png", "PNG", optimize=True)
im.save(www / "gradient.png", "PNG", optimize=True)
im.save(www / "gradient.jpg", "JPEG", quality=85)
Image.new("RGB", (640, 480), (40, 90, 200)).save(www / "flat.png", "PNG", optimize=True)
random.seed(1)
noise = Image.new("RGB", (320, 240))
npx = noise.load()
for y in range(240):
for x in range(320):
npx[x, y] = (
random.randrange(256),
random.randrange(256),
random.randrange(256),
)
noise.save(www / "noise.png", "PNG")
for p in sorted(www.iterdir()):
print(p.name, p.stat().st_size)
@@ -1,93 +0,0 @@
"""Write big BMP (1, 8, 24 bit) and QOI test images into a www directory."""
from pathlib import Path
import random
import struct
import sys
from PIL import Image
www = Path(sys.argv[1])
def gradient(w, h):
im = Image.new("RGB", (w, h))
im.putdata(
[
((x * 255) // w, (y * 255) // h, ((x + y) * 255) // (w + h))
for y in range(h)
for x in range(w)
]
)
return im
def checker(w, h):
im = Image.new("1", (w, h))
im.putdata(
[255 if ((x // 16) + (y // 16)) % 2 else 0 for y in range(h) for x in range(w)]
)
return im
checker(640, 480).save(www / "bmp1_640.bmp")
gradient(320, 240).convert("P", palette=Image.ADAPTIVE).save(www / "bmp8_320.bmp")
gradient(320, 240).save(www / "bmp24_320.bmp")
def qoi(im, path):
im = im.convert("RGBA")
w, h = im.size
out = bytearray(b"qoif" + struct.pack(">IIBB", w, h, 4, 0))
idx = [(0, 0, 0, 0)] * 64
prev = (0, 0, 0, 255)
run = 0
px = list(im.getdata())
for i, p in enumerate(px):
if p == prev:
run += 1
if run == 62 or i == len(px) - 1:
out.append(0xC0 | (run - 1))
run = 0
continue
if run:
out.append(0xC0 | (run - 1))
run = 0
h_ = (p[0] * 3 + p[1] * 5 + p[2] * 7 + p[3] * 11) % 64
if idx[h_] == p:
out.append(h_)
else:
idx[h_] = p
if p[3] == prev[3]:
dr, dg, db = (
((p[0] - prev[0] + 128) % 256) - 128,
((p[1] - prev[1] + 128) % 256) - 128,
((p[2] - prev[2] + 128) % 256) - 128,
)
drg, dbg = dr - dg, db - dg
if -2 <= dr <= 1 and -2 <= dg <= 1 and -2 <= db <= 1:
out.append(0x40 | (dr + 2) << 4 | (dg + 2) << 2 | (db + 2))
elif -32 <= dg <= 31 and -8 <= drg <= 7 and -8 <= dbg <= 7:
out += bytes([0x80 | (dg + 32), (drg + 8) << 4 | (dbg + 8)])
else:
out += bytes([0xFE, p[0], p[1], p[2]])
else:
out += bytes([0xFF, *p])
prev = p
out += b"\x00" * 7 + b"\x01"
path.write_bytes(out)
qoi(
Image.new("RGB", (640, 480), (0, 0, 0)).copy(), www / "qoi_flat.qoi"
) # all runs, 62 pixels a byte
qoi(checker(640, 480).convert("RGB"), www / "qoi_check.qoi")
qoi(gradient(320, 240), www / "qoi_grad.qoi")
random.seed(1)
noise = Image.frombytes(
"RGB", (320, 240), bytes(random.getrandbits(8) for _ in range(320 * 240 * 3))
)
qoi(noise, www / "qoi_noise.qoi")
for f in sorted(www.glob("*_*.*")):
print(f.name, f.stat().st_size)
@@ -1,42 +0,0 @@
"""Build the JPEGs for the streaming decode test: noise 320x240 (also ok.jpg) and 640x480, a 640x480 gradient, the
320x240 noise with a 12 KB EXIF segment in front of the frame header, and a progressive one."""
from pathlib import Path
import random
import sys
from PIL import Image
www = Path(sys.argv[1])
random.seed(2)
def noise(w: int, h: int) -> Image.Image:
im = Image.new("RGB", (w, h))
px = im.load()
for y in range(h):
for x in range(w):
px[x, y] = (
random.randrange(256),
random.randrange(256),
random.randrange(256),
)
return im
n320 = noise(320, 240)
n320.save(www / "noise320.jpg", "JPEG", quality=90)
n320.save(www / "ok.jpg", "JPEG", quality=90)
noise(640, 480).save(www / "noise640.jpg", "JPEG", quality=85)
g = Image.new("RGB", (640, 480))
px = g.load()
for y in range(480):
for x in range(640):
px[x, y] = (x * 255 // 639, y * 255 // 479, 128)
g.save(www / "gradient640.jpg", "JPEG", quality=85)
n320.save(
www / "bigexif320.jpg", "JPEG", quality=90, exif=b"Exif\x00\x00" + bytes(12000)
)
n320.save(www / "progressive320.jpg", "JPEG", quality=90, progressive=True)
for p in sorted(www.iterdir()):
print(p.name, p.stat().st_size)
@@ -5,16 +5,7 @@ from pathlib import Path
import sys
import time
WWW = Path(sys.argv[1]).resolve()
NOT_FOUND = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
def www_file(rel: str) -> Path | None:
"""The file under WWW named by a request path, or None if missing or outside WWW."""
file = (WWW / rel).resolve()
return file if file.is_relative_to(WWW) and file.is_file() else None
WWW = Path(sys.argv[1])
PORT = int(sys.argv[2])
LOG = Path(sys.argv[3]).open("a", encoding="utf-8", buffering=1) # noqa: SIM115
@@ -117,34 +108,8 @@ async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str:
if name == "huge": # claims 20 MB so the JPEG buffer resize fails
w.write(headers(ctype, [b"Content-Length: 20000000"]) + body)
return f"claims 20000000, sent {len(body)} bytes"
prefix, _, rest = name.partition("_")
if prefix in ("half", "noclen", "chunked", "stall") and rest:
# 404 when the base file is missing, so a typo never passes as the misbehaviour under test
file = www_file(rest + "." + ext)
else:
prefix, file = "", www_file(path.lstrip("/"))
if file is None:
w.write(NOT_FOUND)
return "404"
data = file.read_bytes()
if prefix == "half": # the first half of a file, Content-Length matching
cut = data[: len(data) // 2]
w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut)
return f"half {len(cut)} of {len(data)} bytes"
if prefix == "noclen": # a file without Content-Length, delimited by close
w.write(headers(ctype, []) + data)
return f"no content-length, full {len(data)} bytes"
if prefix == "chunked": # a file in 1 KB chunks
w.write(headers(ctype, [b"Transfer-Encoding: chunked"]) + chunked(data, 1024))
return f"chunked full {len(data)} bytes"
if prefix == "stall": # claims the full length, sends a third, holds the socket
part = data[: len(data) // 3]
w.write(headers(ctype, [b"Content-Length: %d" % len(data)]) + part)
await w.drain()
await asyncio.sleep(40)
return f"stalled after {len(part)} of {len(data)} bytes"
w.write(headers(ctype, [b"Content-Length: %d" % len(data), *tag]) + data)
return f"file {file.name} {len(data)} bytes"
w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
return "404"
async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
@@ -1,261 +0,0 @@
"""Fetch images through the online_image API actions and report outcome, time, loop time maximum and heap minimum per case.
usage: [CASES=png|noise|end|bmpqoi|short|jpeg] pngdrive.py HOST KEY LOGFILE BASEURL LABEL
"""
import asyncio
import contextlib
import os
import pathlib
import re
import sys
import time
import urllib.error
import urllib.request
from aioesphomeapi import APIClient, LogLevel, SensorInfo, SensorState, UserService
HOST, KEY, LOGFILE, BASE, LABEL = sys.argv[1:6]
CAP_S = 60
OUTCOME = re.compile(r"download (finished|error): (\w+)")
INTEREST = re.compile(
r"took a long time|Error|error|Download ended|No data received|Waiting for more|Decoded|cut off|already being|headers do not fit|buffer_size|Downloading image|fully downloaded|allocat|not caching"
)
PNG = [
("gradient 320x240 png", "fetch", "gradient.png", "finished"),
("gradient again", "fetch", "gradient.png", "finished"),
("gradient again", "fetch", "gradient.png", "finished"),
("flat 640x480 png", "fetch", "flat.png", "finished"),
("flat again", "fetch", "flat.png", "finished"),
("flat again", "fetch", "flat.png", "finished"),
("gradient jpeg", "fetch", "gradient.jpg", "finished"),
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
("ok png 16x16 body = gradient", "fetch", "ok.png", "finished"),
("chunked png", "fetch", "chunked.png", "finished"),
("chunked png cut in half", "fetch", "chunked_cut.png", "any"),
("closed early png", "fetch", "closed_early.png", "error"),
("stalled png", "fetch", "stall.png", "error"),
("slow png", "fetch", "slow.png", "finished"),
("empty png", "fetch", "empty.png", "error"),
("no content-length png", "fetch", "noclen.png", "any"),
("truncated bmp", "fetch", "truncated.bmp", "error"),
("gradient after errors", "fetch", "gradient.png", "finished"),
("gradient refetch 304", "refetch", "", "finished"),
("noise 320x240 png", "fetch", "noise.png", "finished"),
("noise again", "fetch", "noise.png", "finished"),
]
NOISE = [("noise 320x240 png", "fetch", "noise.png", "finished")] * 3
END = [
(f"{pre}{img}", "fetch", f"{pre}{img}", exp)
for img in ("flat.png", "gradient.png")
for pre, exp in (
("", "finished"),
("noclen_", "finished"),
("chunked_", "finished"),
("half_", "any"),
("stall_", "error"),
)
] + [("gradient after", "fetch", "gradient.png", "finished")]
BMPQOI = [
(f"{pre}{img}", "fetch", f"{pre}{img}", exp)
for img in (
"bmp1_640.bmp",
"bmp8_320.bmp",
"bmp24_320.bmp",
"qoi_flat.qoi",
"qoi_check.qoi",
"qoi_grad.qoi",
"qoi_noise.qoi",
)
for pre, exp in (
("", "finished"),
("", "finished"),
("noclen_", "any"),
("chunked_", "any"),
("half_", "error"),
)
] + [
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
("ok qoi 16x16", "fetch", "ok.qoi", "finished"),
("truncated bmp", "fetch", "truncated.bmp", "error"),
("stall_bmp1_640.bmp", "fetch", "stall_bmp1_640.bmp", "error"),
("stall_qoi_flat.qoi", "fetch", "stall_qoi_flat.qoi", "error"),
("qoi_flat after", "fetch", "qoi_flat.qoi", "finished"),
]
SHORT = [
("gradient 320x240 png", "fetch", "gradient.png", "finished"),
("gradient again", "fetch", "gradient.png", "finished"),
("noise 320x240 png", "fetch", "noise.png", "finished"),
("noise again", "fetch", "noise.png", "finished"),
("noise again", "fetch", "noise.png", "finished"),
("gradient after noise", "fetch", "gradient.png", "finished"),
]
JPEG = [
("noise 320x240 jpeg 69 KB", "fetch", "noise320.jpg", "finished"),
("noise 320x240 again", "fetch", "noise320.jpg", "finished"),
("noise 320x240 again", "fetch", "noise320.jpg", "finished"),
("noise 640x480 jpeg 233 KB", "fetch", "noise640.jpg", "finished"),
("noise 640x480 again", "fetch", "noise640.jpg", "finished"),
("gradient 640x480 jpeg 14 KB", "fetch", "gradient640.jpg", "finished"),
("4 KB buffer: noise 320x240", "fetch_small", "noise320.jpg", "finished"),
("4 KB buffer: noise 640x480", "fetch_small", "noise640.jpg", "finished"),
("4 KB buffer: 12 KB exif", "fetch_small", "bigexif320.jpg", "any"),
("64 KB buffer: 12 KB exif", "fetch", "bigexif320.jpg", "finished"),
("progressive jpeg", "fetch", "progressive320.jpg", "any"),
("no content-length", "fetch", "noclen_noise320.jpg", "finished"),
("chunked", "fetch", "chunked_noise320.jpg", "finished"),
("half with matching length", "fetch", "half_noise320.jpg", "error"),
("closed early", "fetch", "closed_early.jpg", "error"),
("stalled after a third", "fetch", "stall_noise320.jpg", "error"),
("empty", "fetch", "empty.jpg", "error"),
("noise 320x240 after errors", "fetch", "noise320.jpg", "finished"),
("refetch 304", "refetch", "", "finished"),
("ok png 16x16", "fetch", "ok.png", "finished"),
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
("4 KB buffer: noise 640x480 again", "fetch_small", "noise640.jpg", "finished"),
("4 KB buffer: half", "fetch_small", "half_noise320.jpg", "error"),
("4 KB buffer: stalled", "fetch_small", "stall_noise320.jpg", "error"),
(
"4 KB buffer: noise 320x240 after errors",
"fetch_small",
"noise320.jpg",
"finished",
),
]
CASE_SETS = {
"png": PNG,
"noise": NOISE,
"end": END,
"bmpqoi": BMPQOI,
"short": SHORT,
"jpeg": JPEG,
}
CASES = CASE_SETS[os.environ.get("CASES", "png")]
def missing_base_files() -> list[str]:
"""Base files behind prefixed cases that the server does not serve (any HTTP error, or no server).
A failed fetch ends as a download error on the device, which would pass the half_ and stall_ cases untested.
"""
builtin = {"chunked_cut", "closed_early", "large_header"}
missing = []
for _, _, path, _ in CASES:
stem = path.rsplit(".", 1)[0]
prefix, _, rest = path.partition("_")
if (
stem in builtin
or prefix not in ("half", "noclen", "chunked", "stall")
or not rest
):
continue
try:
with urllib.request.urlopen(f"{BASE}/{rest}", timeout=10):
pass
except (urllib.error.URLError, OSError) as exc: # HTTPError is a URLError
missing.append(f"{rest} ({exc})")
return sorted(set(missing))
async def main() -> int:
if missing := missing_base_files():
print(
f"server has no {', '.join(missing)}; run the image generators first",
flush=True,
)
return 2
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, services = await c.list_entities_services()
actions = {s.name: s for s in services if isinstance(s, UserService)}
loop_key = next(
e.key for e in ents if isinstance(e, SensorInfo) and e.name == "Loop Time"
)
heap_key = next(
e.key for e in ents if isinstance(e, SensorInfo) and e.name == "Heap Free"
)
state = {"loop": [], "heap": None, "heap_min": None, "lines": []}
def on_state(s) -> None:
if isinstance(s, SensorState) and s.key == loop_key:
state["loop"].append(s.state)
elif isinstance(s, SensorState) and s.key == heap_key:
state["heap"] = s.state
if state["heap_min"] is None or s.state < state["heap_min"]:
state["heap_min"] = s.state
def on_log(msg) -> None:
line = msg.message.decode(errors="replace")
log.write(f"{time.strftime('%H:%M:%S')} {line}\n")
state["lines"].append(line)
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(3)
print(f"{LABEL}: heap at start {state['heap']}")
rows = []
for label, action, path, expect in CASES:
img = "img_small" if action.endswith("_small") else "img"
state["loop"].clear()
state["lines"].clear()
state["heap_min"] = None
args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"}
t0 = time.monotonic()
log.write(f">>> {label} {path}\n")
try:
await c.execute_service(actions[action], args)
except Exception as exc: # noqa: BLE001
print(f" api call failed ({exc!r}); reconnecting", flush=True)
with contextlib.suppress(Exception):
await c.disconnect()
await asyncio.sleep(10)
await c.connect(login=True)
ents, services = await c.list_entities_services()
actions = {s.name: s for s in services if isinstance(s, UserService)}
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(2)
state["lines"].clear()
t0 = time.monotonic()
await c.execute_service(actions[action], args)
outcome = "no outcome"
while time.monotonic() - t0 < CAP_S:
await asyncio.sleep(0.25)
for line in state["lines"]:
m = OUTCOME.search(line)
if m and m.group(2) == img:
outcome = m.group(1)
if outcome != "no outcome":
break
took = time.monotonic() - t0
await asyncio.sleep(4.5) # two loop time reports after the case
notes = [
ln for ln in state["lines"] if INTEREST.search(ln) and "Loop" not in ln
]
loop_max = max(state["loop"]) if state["loop"] else float("nan")
ok = outcome == expect or (expect == "any" and outcome != "no outcome")
rows.append((label, path, expect, outcome, took, loop_max, ok))
print(
f"{'PASS' if ok else 'FAIL'} {label:30s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s loop max {loop_max:7.0f} ms heap min {state['heap_min']}",
flush=True,
)
for n in notes[:8]:
print(f" {n.strip()}", flush=True)
await c.disconnect()
print(
"\nSUMMARY",
"all expectations met"
if all(r[6] for r in rows)
else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[6]),
)
return 0 if all(r[6] for r in rows) else 1
sys.exit(asyncio.run(main()))
@@ -1,75 +0,0 @@
"""Run the tx_all script N times over the API and count what each sent frame decoded as.
Usage: rbdrive.py IP N"""
import asyncio
import collections
import re
import sys
import aioesphomeapi
IP, N = sys.argv[1], int(sys.argv[2])
lines: list[str] = []
async def main() -> None:
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
i = await c.device_info()
print("device", i.name, i.compilation_time, flush=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
def on_log(m):
lines.append(re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace")))
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(1)
complete = 0
for rnd in range(1, N + 1):
start = len(lines)
await c.execute_service(s["tx_all"], {})
for _ in range(200):
if any("tx done" in x for x in lines[start:]):
complete += 1
break
await asyncio.sleep(0.1)
else:
print(f"round {rnd}: no tx done within 20 s", flush=True)
await asyncio.sleep(1)
await c.disconnect()
# per sent protocol: what was decoded
table: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
cur = None
for x in lines:
if m := re.search(r"\]: tx (\w+)$", x):
cur = m[1]
continue
if cur is None or cur == "done":
continue
if m := re.search(r"Received ([A-Za-z0-9_ ]+?):", x):
table[cur][m[1]] += 1
elif "ext dumper saw" in x:
table[cur]["ext"] += 1
print(f"{complete} of {N} rounds complete", flush=True)
for proto, cnt in table.items():
print(
f"{proto:11s} " + ", ".join(f"{k}={v}" for k, v in sorted(cnt.items())),
flush=True,
)
for x in lines:
if "ext dumper saw" not in x and re.search(
r"rbt|error|Error|overflow|Guru|panic", x
):
print(" ", x[:160])
if not any(k != "ext" for cnt in table.values() for k in cnt):
print("nothing decoded", flush=True)
return 1
if not any(cnt["ext"] for cnt in table.values()):
print("the external dumper saw no frame", flush=True)
return 1
return 0 if complete == N else 1
sys.exit(asyncio.run(main()))
@@ -1,24 +0,0 @@
#!/bin/bash
# run.sh LABEL B(32|66) [N] : write rb<B><LABEL> from templates/remote_base on every run, flash it, run rbdrive.py for N rounds
# Copy this directory and templates/remote_base into one scratch directory (secrets.yaml next to rb-common.yaml).
# env: TREE (esphome checkout to build from), P32 or P66 (serial port of the board), SC (path to
# serial_capture.py), PY (checkout python), EXPECT_REGISTER=1 (fail when the dumper registration was rejected)
R=$(cd "$(dirname "$0")" && pwd); V=$1; B=$2; N=${3:-3}; NAME=rb$B$V; T=${TREE:?set TREE}
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
if [ "$B" = 32 ]; then P=${P32:?set P32}; else P=${P66:?set P66}; fi
mkdir -p "$R/$V"; sed "s/@V@/$V/g" "$R/rb$B.tmpl" > "$R/$V/$NAME.yaml" # always from the current template
OUT=$R/out-$V-$B; rm -rf "$OUT"; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome run --no-logs "$R/$V/$NAME.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "build or upload failed"; exit 1; }
$PY "$SC" "$P" 150 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
for i in $(seq 60); do IP=$(grep -a -o 'IP Address: [0-9.]*' "$OUT/serial.txt" 2>/dev/null | tail -1 | cut -d' ' -f3); [ -z "$IP" ] && IP=$(python3 -c "import socket;print(socket.gethostbyname('$NAME.local'))" 2>/dev/null); [ -n "$IP" ] && break; sleep 2; done
[ -n "$IP" ] || { echo "device not found"; exit 1; }
$PY "$R/rbdrive.py" "$IP" "$N" > "$OUT/drive.txt" 2>&1; RC=$?
pkill -f "[s]erial_capture.py $P"
[ -s "$OUT/serial.txt" ] || { echo "serial capture got nothing, crash check invalid"; exit 1; }
CRASH=$(grep -a -c -i 'guru\|panic\|exception\|rst cause' "$OUT/serial.txt")
REJECTED=$(grep -a -c 'No dumper slot' "$OUT/serial.txt")
cat "$OUT/drive.txt"; echo "serial: $(grep -a -c 'ext dumper register called' "$OUT/serial.txt") register calls, $REJECTED rejected, $CRASH crash lines"
if [ "${EXPECT_REGISTER:-0}" = 1 ] && [ "$REJECTED" != 0 ]; then echo "the dumper registration was rejected"; exit 1; fi
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
@@ -1,103 +0,0 @@
"""Drive the FrameReader simulation builds over the API: press buttons, set selects/numbers, print states.
usage: frdrive.py HOST LOGFILE OP [OP ...]
OP: list | press:NAME | sel:NAME=OPTION | num:NAME=VALUE | wait:SECONDS
"""
import asyncio
import math
import pathlib
import sys
import time
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
LogLevel,
NumberInfo,
SelectInfo,
SensorInfo,
SensorState,
)
HOST, LOGFILE = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
SKIP = {"Heap Free", "Loop Time"}
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def main() -> int:
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
c = APIClient(HOST, 6053, None)
await c.connect(login=True)
info = await c.device_info()
print(f"device {info.name} compiled {info.compilation_time}", flush=True)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
def find(kind: type, name: str):
for e in ents:
if isinstance(e, kind) and e.name == name:
return e
sys.exit(f"no {kind.__name__} named {name!r}")
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None or e.name in SKIP:
return
if isinstance(s, SensorState) and isinstance(e, SensorInfo):
v = "nan" if math.isnan(s.state) else f"{s.state:.2f}"
line = f"{stamp()} SEN {e.name} = {v}"
elif isinstance(s, BinarySensorState):
line = f"{stamp()} BIN {e.name} = {s.state}"
else:
return
print(line, flush=True)
log.write(line + "\n")
states.append(line)
def on_log(msg) -> None:
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
log.flush()
states: list[str] = []
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
await asyncio.sleep(2)
states.clear() # count only states published while the ops ran
for op in OPS:
kind, _, rest = op.partition(":")
if kind == "list":
for e in ents:
print(type(e).__name__, e.name)
continue
if kind == "wait":
await asyncio.sleep(float(rest))
continue
line = f"{stamp()} >>> {op}"
print(line, flush=True)
log.write(line + "\n")
if kind == "press":
c.button_command(find(ButtonInfo, rest).key)
elif kind == "sel":
name, _, value = rest.partition("=")
c.select_command(find(SelectInfo, name).key, value)
elif kind == "num":
name, _, value = rest.partition("=")
c.number_command(find(NumberInfo, name).key, float(value))
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(2.5)
await asyncio.sleep(1)
await c.disconnect()
if any(not op.startswith(("list", "wait")) for op in OPS) and not states:
print("no sensor state was published while the ops ran", flush=True)
return 1
return 0
sys.exit(asyncio.run(main()))
@@ -1,19 +0,0 @@
#!/bin/bash
# usage: run.sh NAME TREE SECONDS OP... flash over serial, capture serial, drive over the API
# Copy this, frdrive.py and the templates/uart_sim files into one scratch directory and run it there.
# env: PORT (the devkit serial port), SC (path to serial_capture.py), PY (checkout python); TREE is the esphome
# checkout to build from
D=$(cd "$(dirname "$0")" && pwd); N=$1; T=$2; S=$3; shift 3; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}
(cd $T && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs $D/$N.yaml --device $P > $D/$N-upload.log 2>&1) || { echo "build or upload failed"; exit 1; }
$PY ${SC:?set SC} $P $S $D/$N-cap.txt > $D/$N-cap.err 2>&1 &
CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
UP=0
for i in $(seq 1 40); do $PY -c "import socket;socket.create_connection(('$N.local',6053),2)" 2>/dev/null && { UP=1; break; }; sleep 2; done
[ $UP = 1 ] || { echo "device $N.local not reachable on port 6053"; exit 1; }
cd $D && PYTHONUNBUFFERED=1 timeout $((S - 5)) $PY frdrive.py $N.local $N-api.log "$@" > $N-drive.txt 2>&1; RC=$?; echo "drive rc=$RC"
wait $CAP; CAPRC=$?; trap - EXIT
[ "$CAPRC" = 0 ] || { echo "serial capture failed (rc $CAPRC), crash check invalid"; exit 1; }
CRASH=$(grep -acE 'Guru|abort\(\)|rst:0x' $D/$N-cap.txt)
echo "crash lines: $CRASH"
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
@@ -1,162 +0,0 @@
# Package for the http_request action test (esphome#21486). Needs substitutions: name, bufsize.
esphome:
name: ${name}
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
actions:
- action: req_get
variables:
url: string
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); id(tag)++; ESP_LOGI("hrt", "START %d %s %s", id(tag), "GET", url.c_str());'
- http_request.get:
url: !lambda return url;
capture_response: true
max_response_buffer_size: ${bufsize}
on_response:
then:
- lambda: |-
uint32_t sum = 0;
for (char c : body) sum += (uint8_t) c;
ESP_LOGI("hrt", "RESULT %d status=%d len=%u sum=%u took=%u maxgap=%u head=%.20s", id(tag),
response->status_code, (unsigned) body.size(), (unsigned) sum,
(unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)), body.c_str());
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "RESULT %d error took=%u maxgap=%u", id(tag), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
- lambda: 'ESP_LOGI("hrt", "CHAIN %d after send", id(tag));'
- action: req_post
variables:
url: string
payload: string
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); id(tag)++; ESP_LOGI("hrt", "START %d %s %s", id(tag), "POST", url.c_str());'
- http_request.post:
url: !lambda return url;
body: !lambda return payload;
capture_response: true
max_response_buffer_size: ${bufsize}
on_response:
then:
- lambda: |-
uint32_t sum = 0;
for (char c : body) sum += (uint8_t) c;
ESP_LOGI("hrt", "RESULT %d status=%d len=%u sum=%u took=%u maxgap=%u head=%.20s", id(tag),
response->status_code, (unsigned) body.size(), (unsigned) sum,
(unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)), body.c_str());
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "RESULT %d error took=%u maxgap=%u", id(tag), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
- lambda: 'ESP_LOGI("hrt", "CHAIN %d after send", id(tag));'
- action: burst
variables:
url: string
count: int
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); ESP_LOGI("hrt", "BURST %d", count);'
- repeat:
count: !lambda return count;
then:
- script.execute:
id: burst_script
url: !lambda return url;
- action: cancel
variables:
url: string
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); ESP_LOGI("hrt", "CANCEL start");'
- script.execute:
id: single_script
url: !lambda return url;
- delay: 300ms
- script.execute:
id: single_script
url: !lambda return url;
- delay: 300ms
- script.stop: single_script
- lambda: 'ESP_LOGI("hrt", "CANCEL stopped running=%d", id(single_script).is_running());'
- delay: 4s
- lambda: 'ESP_LOGI("hrt", "CANCEL done maxgap=%u", (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
- action: reboot_during
variables:
url: string
then:
- lambda: 'ESP_LOGI("hrt", "REBOOT_DURING start");'
- script.execute:
id: single_script
url: !lambda return url;
- script.execute:
id: burst_script
url: !lambda return url;
- delay: 500ms
- lambda: 'ESP_LOGI("hrt", "REBOOT_DURING rebooting"); App.safe_reboot();'
script:
- id: burst_script
mode: parallel
max_runs: 10
parameters:
url: string
then:
- http_request.get:
url: !lambda return url;
capture_response: true
max_response_buffer_size: 256
on_response:
then:
- lambda: 'ESP_LOGI("hrt", "BURSTRESP status=%d len=%u took=%u maxgap=%u", response->status_code, (unsigned) body.size(), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "BURSTERR took=%u", (unsigned) (millis() - id(t_start)));'
- id: single_script
parameters:
url: string
then:
- lambda: 'ESP_LOGI("hrt", "SINGLE begin");'
- http_request.get:
url: !lambda return url;
capture_response: true
max_response_buffer_size: 256
on_response:
then:
- lambda: 'ESP_LOGI("hrt", "SINGLERESP status=%d took=%u", response->status_code, (unsigned) (millis() - id(t_start)));'
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "SINGLEERR took=%u", (unsigned) (millis() - id(t_start)));'
- lambda: 'ESP_LOGI("hrt", "SINGLE end");'
http_request:
timeout: 8s
globals:
- id: max_gap
type: uint32_t
initial_value: "0"
- id: last_tick
type: uint32_t
initial_value: "0"
- id: t_start
type: uint32_t
initial_value: "0"
- id: tag
type: int
initial_value: "0"
interval:
- interval: 1ms
then:
- lambda: |-
const uint32_t now = millis();
if (id(last_tick) != 0 && now - id(last_tick) > id(max_gap))
id(max_gap) = now - id(last_tick);
id(last_tick) = now;
ota:
- platform: esphome
@@ -1,7 +0,0 @@
# Run file for the PR build; for the base build pass `-s name hr32base` to esphome (or copy it with that name).
substitutions: {name: hr32pr, bufsize: "32768"}
esp32:
board: esp32dev
framework: {type: esp-idf}
logger:
packages: {hr: !include hr-common.yaml}
@@ -1,8 +0,0 @@
# Run file for the PR build; for the base build pass `-s name hr66base` to esphome (or copy it with that name).
substitutions: {name: hr66pr, bufsize: "8192"}
esp8266:
board: d1_mini
logger:
packages: {hr: !include hr-common.yaml}
http_request:
verify_ssl: false
@@ -1,22 +0,0 @@
# Same as hr32.yaml plus the otaflash action otadrive.py calls; pass -s name hrota32base for the base build.
substitutions: {name: hrota32, bufsize: "32768"}
esp32:
board: esp32dev
framework: {type: esp-idf}
logger:
packages: {hr: !include hr-common.yaml}
ota:
- platform: http_request
id: ota_http
api:
actions:
- action: otaflash
variables:
url: string
sum: string
then:
- lambda: 'ESP_LOGI("hrt", "OTAFLASH start %s", url.c_str());'
- ota.http_request.flash:
url: !lambda return url;
md5: !lambda return sum;
- lambda: 'ESP_LOGI("hrt", "OTAFLASH returned (failed)");'
@@ -1,105 +0,0 @@
esphome:
name: oi-s3eth
friendly_name: oi-s3eth
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
logger:
level: DEBUG
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
actions:
- action: fetch
variables:
url: string
then:
- online_image.set_url:
id: img
url: !lambda 'return url;'
update: false
- component.update: img
- action: refetch
then:
- component.update: img
- action: fetch_small
variables:
url: string
then:
- online_image.set_url:
id: img_small
url: !lambda 'return url;'
update: false
- component.update: img_small
- action: refetch_small
then:
- component.update: img_small
http_request:
timeout: 4.5s
verify_ssl: false
display:
image:
- platform: online_image
id: img
url: http://127.0.0.1/none.bmp
format: AUTO
type: RGB565
update_interval: never
on_download_finished:
logger.log:
format: "download finished: img cached=%d"
args: [cached]
on_error:
logger.log: "download error: img"
- platform: online_image
id: img_small
url: http://127.0.0.1/none.bmp
format: AUTO
type: RGB565
buffer_size: 256
update_interval: never
on_download_finished:
logger.log:
format: "download finished: img_small cached=%d"
args: [cached]
on_error:
logger.log: "download error: img_small"
button:
- platform: restart
id: reboot_btn
name: Reboot
debug:
update_interval: 2s
sensor:
- platform: debug
loop_time:
name: Loop Time
free:
name: Heap Free
@@ -1,33 +0,0 @@
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
api:
reboot_timeout: 0s
actions:
- action: tx_all
then:
- script.execute: tx_all
ota:
- platform: esphome
esphome:
on_boot:
priority: -100
then:
- lambda: |-
// A dumper registered at runtime, the way external components do it from setup()
struct ExtDumper : remote_base::RemoteReceiverDumperBase {
bool dump(remote_base::RemoteReceiveData src) override {
ESP_LOGI("rbt", "ext dumper saw %d", (int) src.size());
return false;
}
};
static ExtDumper ext;
id(rx_all).register_dumper(&ext);
ESP_LOGI("rbt", "ext dumper register called");
@@ -1,46 +0,0 @@
script:
- id: tx_all
then:
- logger.log: "tx nec"
- remote_transmitter.transmit_nec: {address: 0x1234, command: 0x78}
- delay: 400ms
- logger.log: "tx samsung"
- remote_transmitter.transmit_samsung: {data: 0xE0E040BF}
- delay: 400ms
- logger.log: "tx sony"
- remote_transmitter.transmit_sony: {data: 0xA90, nbits: 12}
- delay: 400ms
- logger.log: "tx lg"
- remote_transmitter.transmit_lg: {data: 0x20DF10EF, nbits: 32}
- delay: 400ms
- logger.log: "tx jvc"
- remote_transmitter.transmit_jvc: {data: 0xC5E8}
- delay: 400ms
- logger.log: "tx panasonic"
- remote_transmitter.transmit_panasonic: {address: 0x4004, command: 0x0100BCBD}
- delay: 400ms
- logger.log: "tx rc5"
- remote_transmitter.transmit_rc5: {address: 0x1C, command: 0x05}
- delay: 400ms
- logger.log: "tx rc6"
- remote_transmitter.transmit_rc6: {address: 0x00, command: 0x0C}
- delay: 400ms
- logger.log: "tx dish"
- remote_transmitter.transmit_dish: {address: 1, command: 16}
- delay: 400ms
- logger.log: "tx coolix"
- remote_transmitter.transmit_coolix: {first: 0xB23FE4}
- delay: 400ms
- logger.log: "tx pioneer"
- remote_transmitter.transmit_pioneer: {rc_code_1: 0xA556}
- delay: 400ms
- logger.log: "tx samsung36"
- remote_transmitter.transmit_samsung36: {address: 0x0400, command: 0x000E00FF}
- delay: 400ms
- logger.log: "tx magiquest"
- remote_transmitter.transmit_magiquest: {wand_id: 0x01234567}
- delay: 400ms
- logger.log: "tx raw"
- remote_transmitter.transmit_raw: {code: [3000, -1000, 700, -2900, 1500, -400, 2200, -1700]}
- delay: 400ms
- logger.log: "tx done"
@@ -1,32 +0,0 @@
esphome:
name: rb32@V@
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
common: !include ../rb-common.yaml
txscript: !include ../rb-txscript.yaml
remote_transmitter:
pin:
number: GPIO4
allow_other_uses: true
carrier_duty_percent: 100%
rmt_symbols: 64
remote_receiver:
- id: rx_all
pin:
number: GPIO4
allow_other_uses: true
dump: all
rmt_symbols: 192
- id: rx_nec_raw
pin:
number: GPIO4
allow_other_uses: true
dump: [nec, raw]
rmt_symbols: 192
@@ -1,69 +0,0 @@
esphome:
name: rb66@V@
includes:
- ../softtx.hpp
esp8266:
board: d1_mini
packages:
common: !include ../rb-common.yaml
remote_receiver:
- id: rx_all
pin: GPIO5
dump: all
# the receiver leaves its pin an input; drive it so the software sender reaches the pad
interval:
- interval: 1h
startup_delay: 0s
then:
- lambda: pinMode(5, OUTPUT);
script:
- id: tx_all
then:
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
NECProtocol().encode(&d, {0x1234, 0x78, 1});
softtx::send(d, "nec");
- delay: 400ms
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
SamsungProtocol().encode(&d, {0xE0E040BF, 32});
softtx::send(d, "samsung");
- delay: 400ms
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
SonyProtocol().encode(&d, {0xA90, 12});
softtx::send(d, "sony");
- delay: 400ms
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
LGProtocol().encode(&d, {0x20DF10EF, 32});
softtx::send(d, "lg");
- delay: 400ms
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
JVCProtocol().encode(&d, {0xC5E8});
softtx::send(d, "jvc");
- delay: 400ms
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
RC5Protocol().encode(&d, {0x1C, 0x05});
softtx::send(d, "rc5");
- delay: 400ms
- lambda: |-
remote_base::RemoteTransmitData d;
using namespace remote_base;
d.item(3000, 1000); d.item(700, 2900); d.item(1500, 400); d.mark(2200);
softtx::send(d, "raw");
- delay: 400ms
- logger.log: "tx done"
@@ -1,15 +0,0 @@
#pragma once
// Bit-bang a frame with interrupts left on, so a receiver on the same pin sees every edge.
// The ESP8266 remote_transmitter disables interrupts while it sends, which hides the edges.
#include "esphome/components/remote_base/remote_base.h"
namespace softtx {
inline void send(const esphome::remote_base::RemoteTransmitData &d, const char *name) {
ESP_LOGI("rbt", "tx %s", name);
for (int32_t v : d.get_data()) {
digitalWrite(5, v > 0 ? HIGH : LOW);
delayMicroseconds(v > 0 ? v : -v);
}
digitalWrite(5, LOW);
}
} // namespace softtx
@@ -1,35 +0,0 @@
esp32:
board: esp32dev
framework:
type: esp-idf
esphome:
includes:
- sim.hpp
logger:
level: VERBOSE
logs:
component: DEBUG
api.service: DEBUG
api.connection: DEBUG
scheduler: DEBUG
sensor: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
debug:
update_interval: 10s
sensor:
- platform: debug
free:
name: Heap Free
loop_time:
name: Loop Time
@@ -1,86 +0,0 @@
esphome:
on_boot:
priority: -100
then:
- lambda: |-
sim::loopback(id(u_w));
sim::loopback(id(u_d));
uart:
- id: u_w
tx_pin: GPIO25
rx_pin: GPIO26
baud_rate: 9600
- id: u_d
tx_pin: GPIO32
rx_pin: GPIO33
baud_rate: 9600
sensor:
- platform: wts01
id: wts
name: WTS01 Temperature
uart_id: u_w
accuracy_decimals: 2
filters: []
- platform: ds1603l
name: DS1603L Distance
uart_id: u_d
button:
- platform: template
name: W Good
on_press:
- lambda: sim::send(id(u_w), "W Good 22.18", sim::wts01(22, 18));
- platform: template
name: W Negative
on_press:
- lambda: sim::send(id(u_w), "W Negative -5.50", sim::wts01(0x85, 50));
- platform: template
name: W Extra 55
on_press:
- lambda: sim::send(id(u_w), "W Extra 55 then 23.25", sim::cat({0x55}, sim::wts01(23, 25)));
- platform: template
name: W 55 Breaks Header
on_press:
- lambda: sim::send(id(u_w), "W 55 01 01 then 26.00", sim::cat({0x55, 0x01, 0x01}, sim::wts01(26, 0)));
- platform: template
name: W Bad Checksum
on_press:
- lambda: sim::send(id(u_w), "W bad checksum 30.00", sim::wts01(30, 0, true));
- platform: template
name: W Garbage Then Frame
on_press:
- lambda: sim::send(id(u_w), "W garbage then 24.50", sim::cat({0x01, 0x02, 0x55, 0x01, 0x99}, sim::wts01(24, 50)));
- platform: template
name: W Split
on_press:
- lambda: sim::send(id(u_w), "W split head 27.75", sim::part(sim::wts01(27, 75), 0, 5));
- delay: 300ms
- lambda: sim::send(id(u_w), "W split rest", sim::part(sim::wts01(27, 75), 5, 9));
- platform: template
name: W Two Frames
on_press:
- lambda: sim::send(id(u_w), "W 20.01 then 20.02", sim::cat(sim::wts01(20, 1), sim::wts01(20, 2)));
- platform: template
name: D Good
on_press:
- lambda: sim::send(id(u_d), "D Good 1000", sim::ds1603l(1000));
- platform: template
name: D Misaligned
on_press:
- lambda: sim::send(id(u_d), "D FF 01 then 1234", sim::cat({0xFF, 0x01}, sim::ds1603l(1234)));
- platform: template
name: D Bad Checksum
on_press:
- lambda: sim::send(id(u_d), "D bad checksum 2000", sim::ds1603l(2000, true));
- platform: template
name: D Garbage Then Frame
on_press:
- lambda: sim::send(id(u_d), "D garbage then 500", sim::cat({0x12, 0x34}, sim::ds1603l(500)));
- platform: template
name: D Split
on_press:
- lambda: sim::send(id(u_d), "D split head 777", sim::part(sim::ds1603l(777), 0, 2));
- delay: 300ms
- lambda: sim::send(id(u_d), "D split rest", sim::part(sim::ds1603l(777), 2, 4));
@@ -1,6 +0,0 @@
# Run file for build a; copy it as frabase.yaml with name frabase for the base build.
esphome:
name: fra
packages:
common: !include fr-common.yaml
parts: !include fra-parts.yaml
@@ -1,104 +0,0 @@
esphome:
on_boot:
priority: 200
then:
- lambda: |-
sim::loopback(id(u_s));
sim::loopback(id(u_f));
uart:
- id: u_s
tx_pin: GPIO25
rx_pin: GPIO26
baud_rate: 9600
- id: u_f
tx_pin: GPIO32
rx_pin: GPIO33
baud_rate: 2400
parity: EVEN
sensor:
- platform: sm300d2
id: sm
uart_id: u_s
update_interval: never
co2:
name: SM CO2
temperature:
name: SM Temperature
- platform: ufm01
temperature:
name: UFM Temperature
accumulated_flow:
name: UFM Accumulated Flow
ufm01:
id: ufm
uart_id: u_f
interval:
- interval: 1s
then:
- lambda: id(u_f).write_array(sim::ufm01(0x21, 0x50).data(), 32);
script:
- id: sm_update
then:
- delay: 100ms
- lambda: |-
uint32_t t0 = micros();
id(sm).update();
ESP_LOGI("sim", "sm300d2 update() took %u us", (unsigned) (micros() - t0));
button:
- platform: template
name: S Good
on_press:
- lambda: sim::send(id(u_s), "S Good co2 600 23.4", sim::sm300d2(600, 23, 4));
- script.execute: sm_update
- platform: template
name: S Empty Update
on_press:
- script.execute: sm_update
- platform: template
name: S Wrong Second Header
on_press:
- lambda: sim::send(id(u_s), "S 3C 05 then co2 700", sim::cat({0x3C, 0x05}, sim::sm300d2(700, 24, 0)));
- script.execute: sm_update
- platform: template
name: S Garbage Then Frame
on_press:
- lambda: sim::send(id(u_s), "S garbage then co2 800", sim::cat({0x01, 0x3C, 0x99}, sim::sm300d2(800, 25, 0)));
- script.execute: sm_update
- platform: template
name: S Cut Frame
on_press:
- lambda: sim::send(id(u_s), "S first 10 bytes of co2 900", sim::part(sim::sm300d2(900, 26, 0), 0, 10));
- script.execute: sm_update
- platform: template
name: S Two Frames
on_press:
- lambda: sim::send(id(u_s), "S co2 1001 then 1002", sim::cat(sim::sm300d2(1001, 27, 0), sim::sm300d2(1002, 28, 0)));
- script.execute: sm_update
- delay: 1s
- script.execute: sm_update
- platform: template
name: S Bad Checksum
on_press:
- lambda: |-
auto f = sim::sm300d2(1100, 29, 0);
f[16] += 0x11;
sim::send(id(u_s), "S bad checksum co2 1100", f);
- script.execute: sm_update
- platform: template
name: F Cut Then Frame
on_press:
- lambda: sim::send(id(u_f), "F 3C 32 00 00 then 33.33", sim::cat({0x3C, 0x32, 0x00, 0x00}, sim::ufm01(0x33, 0x33)));
- platform: template
name: F Bad Checksum Then Frame
on_press:
- lambda: sim::send(id(u_f), "F bad checksum 66.66 then 44.44", sim::cat(sim::ufm01(0x66, 0x66, true), sim::ufm01(0x44, 0x44)));
- platform: template
name: F Garbage Then Frame
on_press:
- lambda: sim::send(id(u_f), "F garbage then 55.55", sim::cat({0x16, 0x3C, 0x00}, sim::ufm01(0x55, 0x55)));
@@ -1,6 +0,0 @@
# Run file for build b; copy it as frbbase.yaml with name frbbase for the base build.
esphome:
name: frb
packages:
common: !include fr-common.yaml
parts: !include frb-parts.yaml
@@ -1,64 +0,0 @@
uart:
- id: u_a
tx_pin:
number: GPIO25
allow_other_uses: true
rx_pin:
number: GPIO26
allow_other_uses: true
baud_rate: 9600
- id: u_b
tx_pin:
number: GPIO26
allow_other_uses: true
rx_pin:
number: GPIO25
allow_other_uses: true
baud_rate: 9600
esphome:
on_boot:
priority: 1000
then:
- lambda: sim::probe.u = id(u_b);
interval:
- interval: 10ms
then:
- lambda: sim::probe.poll();
sensor:
- platform: dfrobot_sen0465_uart
id: gas
uart_id: u_a
model: SEN0466
alarm:
threshold: 35
type: high
update_interval: 5s
concentration:
name: CO Concentration
temperature:
name: CO Temperature
select:
- platform: template
name: Probe Mode
optimistic: true
options: ["0 normal", "1 tail", "2 FF tail", "3 silent", "4 split", "5 wrong gas"]
initial_option: "0 normal"
set_action:
- lambda: |-
sim::probe.mode = x.c_str()[0] - '0';
ESP_LOGI("sim", "probe mode %d", sim::probe.mode);
number:
- platform: template
name: Probe Conc
optimistic: true
min_value: 0
max_value: 1000
step: 1
initial_value: 25
set_action:
- lambda: sim::probe.conc = (uint16_t) x;
@@ -1,6 +0,0 @@
# Run file for build c; copy it as frcbase.yaml with name frcbase for the base build.
esphome:
name: frc
packages:
common: !include fr-common.yaml
parts: !include frc-parts.yaml
@@ -1,158 +0,0 @@
#pragma once
// Simulated UART sensors for the FrameReader hardware test: frame builders, UART loopback and a SEN0465 responder.
#include <driver/uart.h>
#include <cstring>
#include <vector>
#include "esphome/components/uart/uart_component_esp_idf.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace sim {
using esphome::uart::IDFUARTComponent;
static const char *const TAG = "sim";
inline void loopback(IDFUARTComponent *u) {
esp_err_t err = uart_set_loop_back((uart_port_t) u->get_hw_serial_number(), true);
if (err != ESP_OK) {
ESP_LOGE(TAG, "UART%u loopback FAILED: %s", u->get_hw_serial_number(), esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "UART%u loopback on", u->get_hw_serial_number());
}
}
inline void send(IDFUARTComponent *u, const char *name, std::vector<uint8_t> bytes) {
char hex[200] = {0};
size_t n = 0;
for (uint8_t b : bytes) {
if (n + 4 > sizeof(hex))
break;
n += snprintf(hex + n, sizeof(hex) - n, "%02X ", b);
}
ESP_LOGI(TAG, ">>> %s: %s", name, hex);
u->write_array(bytes.data(), bytes.size());
}
inline uint8_t sum(const std::vector<uint8_t> &v, size_t from, size_t to) {
uint8_t s = 0;
for (size_t i = from; i < to; i++)
s += v[i];
return s;
}
// wts01: 55 01 01 04 01 11 T Td Ck
inline std::vector<uint8_t> wts01(uint8_t t, uint8_t td, bool bad_ck = false) {
std::vector<uint8_t> f = {0x55, 0x01, 0x01, 0x04, 0x01, 0x11, t, td, 0};
f[8] = sum(f, 0, 8) + (bad_ck ? 1 : 0);
return f;
}
// ds1603l: FF H L Ck
inline std::vector<uint8_t> ds1603l(uint16_t mm, bool bad_ck = false) {
std::vector<uint8_t> f = {0xFF, (uint8_t) (mm >> 8), (uint8_t) mm, 0};
f[3] = sum(f, 0, 3) + (bad_ck ? 1 : 0);
return f;
}
// sm300d2: 3C 02 co2 hcho tvoc pm25 pm10 (16 bit each) temp temp.dec hum hum.dec Ck
inline std::vector<uint8_t> sm300d2(uint16_t co2, uint8_t temp, uint8_t temp_dec) {
std::vector<uint8_t> f = {
0x3C, 0x02, (uint8_t) (co2 >> 8), (uint8_t) co2, 0, 10, 0, 20, 0, 5, 0, 8, temp, temp_dec, 45, 6, 0};
f[16] = sum(f, 0, 16);
return f;
}
// ufm01 active frame, temperature t.tt as BCD (t_int, t_frac)
inline std::vector<uint8_t> ufm01(uint8_t t_int_bcd, uint8_t t_frac_bcd, bool bad_ck = false) {
std::vector<uint8_t> f(32, 0);
f[0] = 0x3C;
f[1] = 0x32;
f[2] = 0x01;
f[4] = 0x14;
f[5] = 0x07;
f[6] = 0x23;
f[7] = 0x01;
f[9] = 0x56; // accumulated flow 0.056
f[15] = 0x0B;
f[16] = 0x50; // flow
f[21] = 0x0C;
f[24] = 0x0D;
f[25] = t_frac_bcd;
f[26] = t_int_bcd;
f[31] = 0x16;
f[30] = sum(f, 0, 30) + (bad_ck ? 1 : 0);
return f;
}
inline std::vector<uint8_t> cat(std::vector<uint8_t> a, const std::vector<uint8_t> &b) {
a.insert(a.end(), b.begin(), b.end());
return a;
}
inline std::vector<uint8_t> part(const std::vector<uint8_t> &v, size_t from, size_t to) {
return std::vector<uint8_t>(v.begin() + from, v.begin() + to);
}
// SEN0465 probe on the other end of a cross-wired UART pair. Modes: 0 normal, 1 tail before the answer,
// 2 tail with an FF before the answer, 3 silent, 4 answer split in two 300 ms apart, 5 wrong gas type.
struct Probe {
IDFUARTComponent *u{nullptr};
int mode{0};
uint8_t req[9];
uint8_t n{0};
std::vector<uint8_t> rest;
uint32_t rest_at{0};
uint16_t conc{25};
static uint8_t ck(const std::vector<uint8_t> &f) { return (uint8_t) (~sum(f, 1, 7) + 1); }
void poll() {
if (!this->rest.empty() && millis() - this->rest_at >= 300) {
send(this->u, "probe answer rest", this->rest);
this->rest.clear();
}
uint8_t c;
while (this->u->available() && this->u->read_byte(&c)) {
if (this->n == 0 && c != 0xFF)
continue;
this->req[this->n++] = c;
if (this->n < 9)
continue;
this->n = 0;
this->answer_(this->req[2]);
}
}
void answer_(uint8_t cmd) {
ESP_LOGI(TAG, "probe got request 0x%02X (mode %d)", cmd, this->mode);
std::vector<uint8_t> f;
if (cmd == 0x88) {
f = {0xFF,
0x88,
(uint8_t) (this->conc >> 8),
(uint8_t) this->conc,
(uint8_t) (this->mode == 5 ? 0x05 : 0x04),
0x00,
0x02,
0x00,
0};
} else {
f = {0xFF, cmd, 0x01, 0, 0, 0, 0, 0, 0};
}
f[8] = ck(f);
switch (this->mode) {
case 1:
send(this->u, "probe tail+answer", cat({0x12, 0x34, 0x56}, f));
break;
case 2:
send(this->u, "probe FF tail+answer", cat({0x12, 0xFF, 0x34}, f));
break;
case 3:
ESP_LOGI(TAG, "probe silent");
break;
case 4:
send(this->u, "probe answer head", part(f, 0, 5));
this->rest = part(f, 5, 9);
this->rest_at = millis();
break;
default:
send(this->u, "probe answer", f);
}
}
};
inline Probe probe;
} // namespace sim
+2 -2
View File
@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
@@ -32,7 +32,7 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+2 -2
View File
@@ -23,13 +23,13 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
+4 -4
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
@@ -97,7 +97,7 @@ jobs:
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -154,14 +154,14 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Log in to the GitHub container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+7 -7
View File
@@ -34,7 +34,7 @@ jobs:
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
@@ -49,7 +49,7 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -422,7 +422,7 @@ jobs:
version: 1.2
- name: Set up Python 3.13
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Restore integration library cache
@@ -441,7 +441,7 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -630,7 +630,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
with:
run: |
. venv/bin/activate
@@ -1457,7 +1457,7 @@ jobs:
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
@@ -1466,7 +1466,7 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{matrix.language}}"
+6 -6
View File
@@ -86,7 +86,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.x"
- name: Build
@@ -118,7 +118,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -126,12 +126,12 @@ jobs:
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Log in to docker hub
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -206,13 +206,13 @@ jobs:
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -71,7 +71,7 @@ jobs:
token: ${{ steps.generate-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
path: lib/home-assistant
- name: Setup Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``prek`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -31,7 +31,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
+1 -1
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.17.2
rev: v0.17.1
hooks:
# Run the linter.
- id: ruff
+733 -52
View File
@@ -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`.
@@ -230,15 +217,17 @@ file does, and it is the authority when they disagree. The most useful starting
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; I2C has `update_register`,
`write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`.
`if (!this->status_comm_result(ok)) return;` sets or clears the warning. A child sensor that skips repeated
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
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 +278,394 @@ 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.
```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.
- **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 +685,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 +763,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 +783,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 +1023,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,
-6
View File
@@ -16,8 +16,6 @@ esphome/components/a01nyub/* @MrSuicideParrot
esphome/components/a02yyuw/* @TH-Braemer
esphome/components/absolute_humidity/* @DAVe3283
esphome/components/ac_dimmer/* @glmnet
esphome/components/adafruit_ble/* @omersiar
esphome/components/adafruit_ble/ota/* @omersiar
esphome/components/adc/* @esphome/core
esphome/components/adc128s102/* @DeerMaximum
esphome/components/addressable_light/* @justfalter
@@ -88,7 +86,6 @@ esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_midi/* @bogdanr
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_connection/* @bdraco @jesserockz
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
@@ -198,7 +195,6 @@ esphome/components/ens160_spi/* @latonita
esphome/components/ens210/* @itn3rd77
esphome/components/epaper_spi/* @esphome/core
esphome/components/es7210/* @kahrendt
esphome/components/es7243/* @Chorty
esphome/components/es7243e/* @kbx81
esphome/components/es8156/* @kbx81
esphome/components/es8311/* @kahrendt @kroimon
@@ -439,7 +435,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
@@ -702,7 +697,6 @@ esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
esphome/components/udp/* @clydebarrow
esphome/components/udp/packet_interface/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
+16 -21
View File
@@ -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,
@@ -1305,7 +1305,10 @@ def upload_program(
f"{option_string} is only supported with the esphome OTA platform; "
"the web_server OTA path can only update the firmware image."
)
return _upload_via_web_server(config, network_devices, _app_ota_image(args))
binary = CORE.firmware_bin
if getattr(args, "file", None) is not None:
binary = Path(args.file)
return _upload_via_web_server(config, network_devices, binary)
return _upload_via_native_api(config, network_devices, args)
@@ -1361,16 +1364,6 @@ def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
return next(iter(esphome_ota_items(config)), None)
def _app_ota_image(args: ArgsProtocol) -> Path:
"""The file an app update sends: ``--file`` or the build output, as the
target platform's ota_image hook picks it."""
binary = CORE.firmware_bin
if (file := getattr(args, "file", None)) is not None:
binary = Path(file)
ota_image = platform_hooks.get_platform_hook(CORE.target_platform, "ota_image")
return ota_image(binary) if ota_image is not None else binary
def _upload_via_native_api(
config: ConfigType, network_devices: list[str], args: ArgsProtocol
) -> tuple[int, str | None]:
@@ -1420,27 +1413,27 @@ def _upload_via_native_api(
f"flash a build with the option enabled, and then retry {option_string}."
)
binary = CORE.firmware_bin
ota_type = espota2.OTA_TYPE_UPDATE_APP
file = getattr(args, "file", None)
if getattr(args, "partition_table", False):
# Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access.
# The device-side handshake also rejects this with "Device only supports app updates",
# but checking here surfaces the misconfiguration before opening a network connection.
check_partition_access("--partition-table")
binary = Path(file) if file is not None else CORE.partition_table_bin
binary = CORE.partition_table_bin
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
elif getattr(args, "bootloader", False):
check_partition_access("--bootloader")
if (
file is None
getattr(args, "file", None) is None
and (native := native_backend())
and (hint := getattr(native, "missing_image_hint", lambda: None)())
):
raise EsphomeError(hint)
binary = Path(file) if file is not None else CORE.bootloader_bin
binary = CORE.bootloader_bin
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
else:
binary = _app_ota_image(args)
if getattr(args, "file", None) is not None:
binary = Path(args.file)
if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE:
_validate_partition_table_binary(binary)
@@ -2138,7 +2131,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 +2140,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)
+3 -33
View File
@@ -1,18 +1,15 @@
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import math
import string
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_ABOVE,
CONF_ALL,
CONF_ANY,
CONF_AUTOMATION_ID,
CONF_BELOW,
CONF_CONDITION,
CONF_COUNT,
CONF_ELSE,
@@ -351,9 +348,8 @@ class ApplyField:
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
is not the right spelling, or the value depends on another entity's config, such as the unit
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
``type_``. An absent key emits nothing.
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
"""
conf_key: str | tuple[str, ...]
@@ -366,25 +362,6 @@ class ApplyField:
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
def _in_range_low(config: ConfigType, value: float) -> str:
return "-INFINITY" if math.isnan(value) else str(cg.safe_exp(value))
def _in_range_high(config: ConfigType, value: float) -> str:
# With no lower bound, an unset upper bound stays NaN so the check is false, as before.
if math.isnan(value):
return "NAN" if math.isnan(config[CONF_ABOVE]) else "INFINITY"
return str(cg.safe_exp(value))
# ``above <= state <= below`` on the parent's float ``state``; register with ``IN_RANGE_DEFAULTS``.
IN_RANGE_CHECK = ApplyCall(
"state >= {} && {parent}->state <= {}",
((CONF_ABOVE, cg.float_, _in_range_low), (CONF_BELOW, cg.float_, _in_range_high)),
)
IN_RANGE_DEFAULTS = {CONF_ABOVE: math.nan, CONF_BELOW: math.nan}
@dataclass(frozen=True)
class ApplyBytes:
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
@@ -713,11 +690,7 @@ async def _render_check(
def register_apply_condition(
name: str,
schema: cv.Schema,
check: str | ApplyCall,
id_key: str = CONF_ID,
defaults: ConfigType | None = None,
name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID
) -> None:
"""Register a condition that is one expression on its parent, with no C++ class.
@@ -726,7 +699,6 @@ def register_apply_condition(
against config values, all of which must be present. Write ``== false`` to negate.
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
``defaults`` fills absent keys at code generation only, so the validated config is unchanged.
Generates one static predicate and an ``ApplyCondition`` templated on it.
"""
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
@@ -741,8 +713,6 @@ def register_apply_condition(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
if defaults:
config = {**defaults, **config}
parent = await _apply_parent(config, id_key)
lambda_args = _apply_lambda_args(args)
values = _apply_values(config, members)
+19 -84
View File
@@ -7,7 +7,6 @@ import re
import textwrap
from esphome.build_helpers import pch
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
from esphome.components.esp32 import (
KEY_SRC_CMAKE,
get_esp32_variant,
@@ -25,7 +24,6 @@ from esphome.framework_helpers import (
get_project_link_flags,
)
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
from esphome.platformio.library import lex_build_flags
_LOGGER = logging.getLogger(__name__)
@@ -155,47 +153,6 @@ def has_discovered_components() -> bool:
return bool(get_available_components())
_KEY_WARNED_BUILD_FLAGS = "warned_build_flags"
def _lexed_build_flags() -> list[str]:
"""Shell-lex the build flag entries as the other generators do, so an
entry holding several flags is classified flag by flag. Warns once per
entry about a flag that native ESP-IDF builds do not pass on."""
warned: set[tuple[str, str]] = CORE.data.setdefault(KEY_ESP32, {}).setdefault(
_KEY_WARNED_BUILD_FLAGS, set()
)
flags: list[str] = []
for entry in sorted(CORE.build_flags):
tokens = lex_build_flags(entry, "esphome build_flags")
for token in tokens:
if (
(token, entry) not in warned
and not get_project_compile_flags([token])
and not get_project_link_flags([token])
):
warned.add((token, entry))
_LOGGER.warning(
"Build flag '%s'%s is ignored: native ESP-IDF builds only "
"pass -D, -W and -Wl, flags",
token,
"" if token == entry else f" in '{entry}'",
)
flags += tokens
return flags
def _cmake_args(flags: list[str]) -> list[str]:
"""Quote each flag as a CMake argument so quotes, backslashes and $
survive."""
return [
'"{}"'.format(
flag.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
)
for flag in flags
]
def _cmake_quote(value: str) -> str:
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
whitespace, quotes, and '$', so only backslashes need escaping."""
@@ -336,24 +293,18 @@ def get_project_cmakelists(
# esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author.
# CMake keeps only the first copy of a repeated option; drop all but the
# last copy instead so the last definition wins, as on the command line.
project_compile_opts = list(
reversed(
dict.fromkeys(reversed(get_project_compile_flags(_lexed_build_flags())))
)
)
project_compile_opts = get_project_compile_flags()
extra_compile_options = "\n".join(
f"idf_build_set_property(COMPILE_OPTIONS {flag} APPEND)"
for flag in _cmake_args(project_compile_opts)
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts
)
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
# -Wno-volatile is passed on a C compile.
cxx_compile_options = "\n".join(
f"idf_build_set_property(CXX_COMPILE_OPTIONS {flag} APPEND)"
for flag in _cmake_args(get_project_cxx_compile_flags())
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
for flag in get_project_cxx_compile_flags()
)
cpp_standard_options = (
@@ -509,7 +460,7 @@ def get_component_cmakelists() -> str:
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/.
link_opts = _cmake_args(get_project_link_flags(_lexed_build_flags()))
link_opts = get_project_link_flags()
link_opts_str = "\n ".join(link_opts) if link_opts else ""
src_cmake = "\n".join(CORE.data[KEY_ESP32].get(KEY_SRC_CMAKE, []))
@@ -564,7 +515,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
# Where CMake puts the .gch of the src component; ccache reads the checksum
# next to it in place of the .gch
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
# Where the CMake gate records its choice
# Where the Windows gate records its choice
_PCH_CHOICE_VAR = "ESPHOME_PCH"
@@ -580,32 +531,16 @@ def _pch_cmake_block() -> str:
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
{headers}
)"""
if pch.pch_forced():
if not pch.pch_needs_gcc_check():
return f"\n# ESPHome precompiled header\n{block}\n"
# Only CMake knows the compiler version before the first configure, and
# the ccache IDF found is the one the build runs
gcc_gate = ""
if pch.pch_needs_gcc_check():
gcc_gate = f"""if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set(_esphome_pch OFF)
endif()
"""
min_ccache = ".".join(map(str, CCACHE_PCH_MIN_VERSION))
# Before the first configure only CMake knows the compiler version
return f"""
# ESPHome precompiled header, unless a check below rules it out
set(_esphome_pch ON)
{gcc_gate}if(CCACHE_ENABLE AND CCACHE_FOUND)
execute_process(COMMAND "${{CCACHE_FOUND}}" --version OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)
if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")
if(CMAKE_MATCH_1 VERSION_LESS {min_ccache})
message(STATUS "ESPHome: ccache ${{CMAKE_MATCH_1}} can reuse stale objects with a precompiled header; compiling without it (upgrade ccache to {min_ccache} or newer, or set ESPHOME_PCH_ENABLE=1 to force)")
set(_esphome_pch OFF)
endif()
endif()
endif()
set({_PCH_CHOICE_VAR} ${{_esphome_pch}} CACHE BOOL "ESPHome precompiled header in use" FORCE)
if(_esphome_pch)
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
else()
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
{textwrap.indent(block, " ")}
endif()
"""
@@ -622,10 +557,10 @@ def write_pch_checksum() -> None:
if not pch.pch_enabled():
return
# Written whatever the gate chose last time: a build tree without that
# choice, or a reconfigure that turns the pch on, must not see a stale sum
if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
pch.log_pch_in_use()
# The gate's choice, cached by configure
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
return
pch.log_pch_in_use()
checksum = pch.pch_checksum(
CORE.relative_src_path(),
pch.PCH_DEFAULT_HEADERS,
+1 -48
View File
@@ -3,68 +3,21 @@ resolution, and default ``CCACHE_*`` values."""
from __future__ import annotations
import functools
import logging
import os
from pathlib import Path
import re
from esphome.framework_helpers import (
strip_win_long_path_prefix,
tool_version_probe,
tool_version_runs,
)
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
_LOGGER = logging.getLogger(__name__)
# Before 4.7 ccache stores a pch consumer whose header is newer than one
# second without that header, so a later build reuses it after the header
# changed (#21621)
CCACHE_PCH_MIN_VERSION = (4, 7)
_VERSION_RE = re.compile(r"ccache version (\d+(?:\.\d+)*)")
@functools.cache
def _ccache_probe(ccache: str) -> str | Exception:
"""``ccache --version``, run once per binary per process."""
return tool_version_probe(ccache)
def _ccache_runs(ccache: str) -> bool:
"""Return True when the ``ccache`` found on PATH actually runs."""
return tool_version_runs(
ccache,
"Ignoring ccache at %s because it failed to run; compiling without ccache",
probe=_ccache_probe,
)
@functools.cache
def _ccache_too_old_for_pch(ccache: str) -> bool:
output = _ccache_probe(ccache)
if not isinstance(output, str) or (found := _VERSION_RE.search(output)) is None:
return False
if tuple(map(int, found[1].split("."))) >= CCACHE_PCH_MIN_VERSION:
return False
_LOGGER.info(
"ccache %s at %s can reuse stale objects with a precompiled header; "
"compiling without it (upgrade ccache to %s or newer, or set "
"ESPHOME_PCH_ENABLE=1 to force)",
found[1],
ccache,
".".join(map(str, CCACHE_PCH_MIN_VERSION)),
)
return True
def ccache_breaks_pch() -> bool:
"""Whether the ccache this build uses is too old for a precompiled header.
ESP-IDF builds check the ccache IDF finds in CMake instead (build_gen.espidf)."""
return (ccache := resolve_ccache_path()) is not None and _ccache_too_old_for_pch(
ccache
)
Loaded 100 of 1357 files, more files were not shown because too many files have changed in this diff. Show more