Compare commits

..
Author SHA1 Message Date
J. Nick Koston a2f765a733 Keep a gdb failure from replacing the test's own error
Decode gdb's output with replacement characters and treat an OSError like
a timeout.
2026-10-26 13:58:52 -10:00
J. Nick Koston 3a62bd2a4e Give the debug re-run symbols for custom-flag fixtures and stripped OTA binaries
Fixtures with their own build_flags now get -g too, and the OTA tests pass
their unstripped build so gdb reads its symbols.
2026-10-26 13:33:09 -10:00
J. Nick Koston 7dd3a6f451 Backtrace an early crash, keep the re-run going without core dumps
The died-immediately path now prints the backtrace before raising, the
core setup only warns when it fails, and the re-run's per-test timeout
sits under the step limit.
2026-10-26 13:22:49 -10:00
J. Nick Koston a7b5429032 Revert "Temporary: crash a fixture at boot to check the CI backtrace"
This reverts commit 3f31aeeccf.
2026-10-26 13:20:17 -10:00
J. Nick Koston 3f31aeeccf Temporary: crash a fixture at boot to check the CI backtrace 2026-10-26 13:05:35 -10:00
J. Nick Koston b8c26bea20 [ci] Build integration tests without debug symbols, re-run failures with them
Debug symbols double the object size and nothing reads them on a passing
run. A failed bucket re-runs its failed tests with -g, core dumps and gdb,
so a crash prints a backtrace.
2026-10-26 13:03:43 -10:00
93 changed files with 1403 additions and 3181 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 (ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
flash are scarce, and code runs unattended for months. flash are scarce, and code runs unattended for months.
`AGENTS.md` in the repository root is the contributor guide, and the skills it `AGENTS.md` in the repository root is the full contributor guide and the
lists (`component-codegen`, `config-validation`, `automations`, authority when it disagrees with this summary. The developer documentation at
`embedded-memory`, `component-tests`, `breaking-changes`) hold the detail. They https://developers.esphome.io explains the component lifecycle and the reasoning
are the authority when they disagree with this summary. The developer behind these rules. This skill lists the concrete things worth flagging in a
documentation at https://developers.esphome.io explains the component lifecycle review; read `AGENTS.md` for the detail behind any item.
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.
Only raise findings that the diff actually introduces or changes. Do not ask for 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. 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:`. `if (blah := config.get(CONF_BLAH)) is not None:`.
- Reuse existing validators from `config_validation.py` (`cv.rename_key`, - Reuse existing validators from `config_validation.py` (`cv.rename_key`,
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling. `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 - `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 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. 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`).
-76
View File
@@ -1,76 +0,0 @@
name: Install apt packages (cached)
description: >-
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
only from the package list and version, so both live here and nowhere else.
runs:
using: "composite"
steps:
- name: Keep a failed install out of the cache
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
# already on PATH: this one refreshes the lists and retries when the install fails and,
# if that fails too, removes the folder the action would save. Only a cache miss installs.
shell: bash
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
#!/bin/bash
if [[ " $* " != *" install "* ]]; then
exec apt-get "$@"
fi
touch /tmp/apt-fast-standin-ran
apt-get "$@" && exit 0
apt-get update -qq >&2
# The action pins each package to the version the stale lists named
args=()
for arg in "$@"; do
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
done
apt-get "${args[@]}" && exit 0
# The action writes our output to install.log inside the folder it saves
log="$(readlink -f "/proc/$$/fd/1")"
if [ "$(basename "${log}")" = install.log ]; then
rm -rf "$(dirname "${log}")"
else
touch /tmp/apt-fast-cleanup-missed
fi
exit 1
EOF
sudo chmod +x /usr/local/bin/apt-fast
- name: Install apt packages (cached)
id: apt
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
# release calls apt-fast and where it writes install.log.
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
# 1.3 drops the empty caches saved before the step above existed
version: 1.3
- name: Check the packages are installed
shell: bash
env:
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
run: |
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
exit 1
fi
if [ -e /tmp/apt-fast-cleanup-missed ]; then
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
exit 1
fi
# The action restores files without registering them with dpkg
for cmd in ccache sdl2-config; do
if ! command -v "${cmd}" > /dev/null; then
echo "::error::${cmd} is missing after the cached install"
exit 1
fi
done
if [ ! -f /usr/include/mbedtls/version.h ]; then
echo "::error::libmbedtls-dev is missing after the cached install"
exit 1
fi
+56 -18
View File
@@ -73,17 +73,16 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release # PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived # pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version, kept in the apt-packages action. # only from the package list and version; keep both identical in every
# In ci-status needs so a broken seed fails dev. # step that restores it. In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push' if: github.event_name == 'push'
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github
- name: Install apt packages (cached) - name: Install apt packages (cached)
uses: ./.github/actions/apt-packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
determine-jobs: determine-jobs:
name: Determine which jobs to run name: Determine which jobs to run
@@ -413,10 +412,14 @@ jobs:
- name: Install apt packages (cached) - name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt # ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold # (mirror outages cannot hang the job); the timeout bounds the cold
# path. libsdl2-dev is needed by the headless display tests, which # path. Packages and version must match seed-apt-cache exactly.
# capture screenshots. # libsdl2-dev is needed by the headless display tests, which capture
# screenshots.
timeout-minutes: 10 timeout-minutes: 10
uses: ./.github/actions/apt-packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Set up Python 3.13 - name: Set up Python 3.13
id: python id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
@@ -459,6 +462,7 @@ jobs:
- name: Register matcher - name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json" run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests - name: Run integration tests
id: integration
env: env:
# JSON array of test paths; parsed into a bash array below to avoid # JSON array of test paths; parsed into a bash array below to avoid
# shell word-splitting / glob hazards. # shell word-splitting / glob hazards.
@@ -474,6 +478,32 @@ jobs:
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile # A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \ pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
--junitxml=junit-integration.xml "${test_files[@]}" --junitxml=junit-integration.xml "${test_files[@]}"
- name: Re-run the failed tests with debug symbols
# The first run builds without -g. A crash in the re-run prints a gdb
# backtrace of its core; exit 1 keeps the job red when the re-run passes.
if: failure() && steps.integration.outcome == 'failure'
timeout-minutes: 30
env:
ESPHOME_INTEGRATION_DEBUG_SYMBOLS: "1"
ESPHOME_INTEGRATION_CORE_DIR: /tmp/esphome-cores
run: |
. venv/bin/activate
# The image's package lists usually suffice; refresh them only if not
if ! command -v gdb >/dev/null &&
! sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 apt-get install -y -qq gdb; then
sudo timeout -k 15 300 apt-get update -qq &&
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 apt-get install -y -qq gdb ||
echo "::warning::gdb install failed; no backtraces"
fi
{
mkdir -p "$ESPHOME_INTEGRATION_CORE_DIR" &&
echo "$ESPHOME_INTEGRATION_CORE_DIR/core.%p" | sudo tee /proc/sys/kernel/core_pattern &&
ulimit -c unlimited
} || echo "::warning::core dumps not enabled; no backtraces"
# Under the step limit, so a hung test still reports before Actions kills the step
pytest -vv --no-cov --tb=native -n auto --dist worksteal --timeout=600 \
--lf --lfnf=none tests/integration || true
exit 1
- name: Upload test artifacts - name: Upload test artifacts
# Tests that compare rendered output write the image they actually got here, so a # Tests that compare rendered output write the image they actually got here, so a
# failure can be looked at without reproducing the whole build locally. # failure can be looked at without reproducing the whole build locally.
@@ -715,9 +745,13 @@ jobs:
- name: Install apt packages (cached) - name: Install apt packages (cached)
if: matrix.pio_cache_key == 'tidyhost' if: matrix.pio_cache_key == 'tidyhost'
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev # libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
# by dsmr and dlms_meter. # by dsmr and dlms_meter. Packages and version must match seed-apt-cache
# exactly.
timeout-minutes: 10 timeout-minutes: 10
uses: ./.github/actions/apt-packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Restore Python - name: Restore Python
id: restore-python id: restore-python
@@ -1104,16 +1138,20 @@ jobs:
- name: List components - name: List components
run: echo ${{ matrix.batch.components }} run: echo ${{ matrix.batch.components }}
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install apt packages (cached) - name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt, # A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds # so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. The action has no --no-install-recommends; same # the cold path. Packages and version must match seed-apt-cache
# package set this job used before #17463. # exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
timeout-minutes: 10 timeout-minutes: 10
uses: ./.github/actions/apt-packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
+734 -48
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. `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. * 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:** * **Formatting:**
* **Python:** Uses `ruff` for linting and formatting. Configuration is in `pyproject.toml`. * **Python:** Uses `ruff` for linting and formatting. Configuration is in `pyproject.toml`.
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`. * **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
@@ -239,6 +226,9 @@ file does, and it is the authority when they disagree. The most useful starting
end of `setup()` when the entities need a state at boot. 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 * **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. 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 * **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`. 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 * **Model-specific code** sits behind a define the Python side emits only for configs that use that model
@@ -289,28 +279,398 @@ file does, and it is the authority when they disagree. The most useful starting
}; };
``` ```
* **Component Code Generation:** every component's `__init__.py` defines `DOMAIN` (its own name, checked by * **Component Structure:**
CI). Hub entities go in platform files (`sensor.py`, ...) that take the hub's id, not under the hub's own * **Standard Files:**
key. File layout, metadata, the `to_code` pattern, `sub_sensors` and the other codegen helpers are in the ```
`component-codegen` skill. 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` * **Component Metadata:**
class, and `register_apply_action` / `register_apply_condition` over a C++ class that only forwards to its - `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
parent. Callback registration methods are always `template<typename F>`, never `std::function`. Details string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
and examples are in the `automations` skill. `from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
metadata constants. CI (`lint_component_domain`) fails without it.
- `DEPENDENCIES`: List of required components
- `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's
own key; see "Entities of a hub" below.
- `CONFLICTS_WITH`: Incompatible components
- `CODEOWNERS`: GitHub usernames responsible for maintenance
- `MULTI_CONF`: Whether multiple instances are allowed
* **Code Generation & Common Patterns:**
* **Configuration Schema Pattern:**
```python
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_KEY, CONF_ID
DOMAIN = "my_component"
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
my_component_ns = cg.esphome_ns.namespace("my_component")
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_key(config[CONF_KEY]))
cg.add(var.set_param(config[CONF_PARAM]))
```
* **C++ Class Pattern:**
```cpp
namespace esphome::my_component {
class MyComponent : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
void set_key(const std::string &key) { this->key_ = key; }
void set_param(int param) { this->param_ = param; }
protected:
std::string key_;
int param_{0};
};
} // namespace esphome::my_component
```
* **Common Component Examples:**
- **Sensor:**
```python
from esphome.components import sensor
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
cv.polling_component_schema("60s")
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
```
- **Binary Sensor:**
```python
from esphome.components import binary_sensor
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
```
- **Switch:**
```python
from esphome.components import switch
CONFIG_SCHEMA = switch.switch_schema().extend({...})
async def to_code(config):
var = await switch.new_switch(config)
```
- **Entities of a hub:** a new component's entities go in platform files (`sensor.py`,
`binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms
added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is
fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
```yaml
# Bad - entities under the hub's key
my_hub:
connected:
name: Connected
# Good - the hub, then one platform entry that holds its entities
my_hub:
id: hub1
binary_sensor:
- platform: my_hub
my_hub_id: hub1 # optional with a single hub
connected:
name: Connected
```
- **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`,
`sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented<T>`,
then make one call per key, even when there is only one. A call creates the entity only when its key
is configured, passes it to the setter and returns it (or `None`); extra arguments such as
`min_value` or `options` go on the call. Always name the setter explicitly on the object that owns
it, never with `getattr` and an f-string, and keep that variable short (`var` for the component
itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose
setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are.
```python
# my_hub/sensor.py
async def to_code(config):
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
# my_hub/button.py
async def to_code(config):
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
buttons = button.sub_buttons(config, parent=hub)
await buttons(CONF_RESTART, hub.set_restart_button)
```
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`.
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
the body only when turned on. Never use it for a new entity; an action is a button.
```python
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
```
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
to still drive a state when the restore mode is disabled.
A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which
already applies `inverted`.
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
constructs, registers and attaches in one call; `display.add_writer(var, config)` sets the lambda;
`MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict; `button.restart_button_schema(cls)` (and
`factory_reset_`, `query_`) are button presets; `fv.get_declaring_config(id)` finds a hub's config in
final validation.
* **Automations (Triggers, Actions, Conditions):**
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
* **Triggers -- Callback method (preferred):**
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
**Python:**
```python
from esphome import automation
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(bool, "x")], conf
)
```
`build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder<Ts...>`).
For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
```python
for conf_key, forwarder in (
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=forwarder
)
```
When the callback's parameters differ from the automation's arguments, or the trigger should only
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
`forward` from `MockObj` calls, not f-strings of C++:
```python
parent = automation.parent_ref(var)
index = cg.RawExpression("index")
await automation.build_callback_automation(
var,
"add_on_state_callback",
[(cg.StringRef, "x"), (cg.size_t, "i")],
conf,
params=[(cg.size_t, "index")],
forward=[cg.StringRef(parent.option_at(index)), index],
)
```
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
```cpp
class MyComponent : public Component {
public:
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
protected:
// Use CallbackManager when callbacks are always registered (e.g. core components)
CallbackManager<void(bool)> state_callback_;
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
// (nullptr vs empty std::vector) per instance when no callbacks are added
// LazyCallbackManager<void(bool)> state_callback_;
};
```
* **Triggers -- Trigger class method:**
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
**Python:**
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
}
)
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
```
**C++:**
```cpp
class TurnOnTrigger : public Trigger<> {
public:
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
parent->add_on_state_callback([this](bool state) {
if (state && !this->last_on_)
this->trigger();
this->last_on_ = state;
});
}
protected:
bool last_on_;
};
```
* **Actions:**
```cpp
template<typename... Ts> class MyAction : public Action<Ts...> {
public:
explicit MyAction(MyComponent *parent) : parent_(parent) {}
void play(const Ts &...) override { this->parent_->do_something(); }
protected:
MyComponent *parent_;
};
```
Register it without writing a builder:
```python
automation.register_simple_action(
"my_component.do_something", MyAction, schema, synchronous=True
)
```
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and
the `@automation.register_action(...)` decorator only when the builder must also set fields.
Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
**Actions that only forward templatable values to their parent need no C++ class.** Register them
with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
```python
automation.register_apply_action(
"my_component.set_gains",
schema,
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
)
```
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
* **Conditions:**
```cpp
template<typename... Ts> class MyCondition : public Condition<Ts...> {
public:
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
bool check(const Ts &...) override { return this->parent_->is_active(); }
protected:
MyComponent *parent_;
};
```
Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`;
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
**Conditions that only test their parent need no C++ class either.** Register them with
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares
against config values.
```python
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
automation.register_apply_condition(
"my_component.state_is",
schema,
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
)
```
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. * **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`. 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 * **Configuration Validation:**
model, reuse validators from `config_validation.py`, and never add `CONF_` constants to the frozen * **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
`esphome/const.py`. key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
* **Schema extraction:** whenever config validation code is added or changed, make sure that rejects keys and fills defaults afterwards.
`script/build_language_schema.py` still extracts the full schema. The dumper cannot see inside a plain * **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`.
function, so a validator function that wraps, chooses or builds a schema must be decorated with * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
`@schema_extractor(...)` and return the schema data when called with `SCHEMA_EXTRACT` (both from * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
`esphome.schema_extractors`). CI does not catch a missing schema; build the schema and check the * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
component's JSON as the `config-validation` skill describes. * **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 ## 5. Key Files & Entrypoints
@@ -330,12 +690,71 @@ file does, and it is the authority when they disagree. The most useful starting
* **Testing:** * **Testing:**
* **Python:** Run unit tests with `pytest`. * **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis. * **C++:** Use `clang-tidy` for static analysis.
* **Component tests:** YAML build tests live in `tests/components/<component>/` and run with * **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
`script/test_build_components -c <component>`. Never define buses directly in test YAML: include the `script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
shared packages from `tests/test_build_components/common/` with dict-style `packages:`. Test file naming, with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
`validate.*.yaml` config-only tests, CI grouping, C++ unit tests and integration tests are in the * **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
`component-tests` skill. 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:** * **Debugging and Troubleshooting:**
* **Debug Tools:** * **Debug Tools:**
- `esphome config <file>.yaml` to validate configuration. - `esphome config <file>.yaml` to validate configuration.
@@ -349,7 +768,7 @@ file does, and it is the authority when they disagree. The most useful starting
## 7. Specific Instructions for AI Collaboration ## 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). 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. 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. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
@@ -369,15 +788,236 @@ file does, and it is the authority when they disagree. The most useful starting
* **Configuration Design:** Aim for simplicity with sensible defaults, while allowing for advanced customization. * **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. * **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 **Why Heap Allocation Matters:**
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.
* **State Management:** keep code generation state in `CORE.data`, namespaced under the component's 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.
`DOMAIN` in a `@dataclass`, never in module-level mutable globals (see the `component-codegen` skill).
**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. * **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys.
@@ -388,11 +1028,57 @@ file does, and it is the authority when they disagree. The most useful starting
## 8. Public API and Breaking Changes ## 8. Public API and Breaking Changes
* Only features documented at [esphome.io](https://esphome.io) are public API for components; all `public` * **Public C++ API:**
members of `esphome/core/` and base classes are public API. Aim for a 6-month deprecation window * **Components**: Only documented features at [esphome.io](https://esphome.io) are public API. Undocumented `public` members are internal.
(`ESPDEPRECATED` in C++, `cv.rename_key(..., removed_in=...)` for config keys) and document the migration * **Core/Base Classes** (`esphome/core/`, `Component`, `Sensor`, etc.): All `public` members are public API.
path in the PR description. The `breaking-changes` skill has the full policy, checklist and patterns. * **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 ## 9. English Language
The project uses English for non-code content. When drafting documentation, code comments, commit messages, The project uses English for non-code content. When drafting documentation, code comments, commit messages,
-1
View File
@@ -439,7 +439,6 @@ esphome/components/modbus_controller/select/* @martgras @stegm
esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/sensor/* @martgras
esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/switch/* @martgras
esphome/components/modbus_controller/text_sensor/* @martgras esphome/components/modbus_controller/text_sensor/* @martgras
esphome/components/modbus_monitor/* @Bascht74
esphome/components/modbus_server/* @exciton esphome/components/modbus_server/* @exciton
esphome/components/modem/* @oarcher esphome/components/modem/* @oarcher
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
+6 -4
View File
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Protocol
# cause them to be loaded before external components are processed, resulting # cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one. # in the built-in version being used instead of the external component one.
from esphome import const, platform_hooks 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 ( from esphome.const import (
ARGUMENT_HELP_DEVICE, ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION, BUNDLE_EXTENSION,
@@ -2138,7 +2138,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
# Refuse an unsupported toolchain before paying for a full compile # 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: if analysis_toolchain is None and not CORE.using_toolchain_platformio:
_LOGGER.error( _LOGGER.error(
"analyze-memory is not supported with the '%s' toolchain on %s; " "analyze-memory is not supported with the '%s' toolchain on %s; "
@@ -2147,9 +2147,11 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
CORE.target_platform, CORE.target_platform,
) )
return 1 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 # 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) # Always compile to ensure fresh data (fast if no changes - just relinks)
exit_code = write_cpp(config) exit_code = write_cpp(config)
+5 -5
View File
@@ -93,7 +93,7 @@ def _split_command(command: str) -> list[str]:
CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW
CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
argc = ctypes.c_int(0) argc = ctypes.c_int()
argv = CommandLineToArgvW(command, ctypes.byref(argc)) argv = CommandLineToArgvW(command, ctypes.byref(argc))
if not argv: # pragma: no cover if not argv: # pragma: no cover
raise ctypes.WinError() raise ctypes.WinError()
@@ -296,10 +296,10 @@ def load_or_build_idedata(
) -> dict | None: ) -> dict | None:
"""Return idedata for a compile_commands.json build, cached on mtime. """Return idedata for a compile_commands.json build, cached on mtime.
Shared by the native toolchains. Returns None when the compile DB Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
doesn't exist yet (nothing was built). ``launcher`` is the when the compile DB doesn't exist yet (nothing was built). ``launcher``
compiler-launcher path (ccache) the build was generated with, if any; is the compiler-launcher path (ccache) the build was generated with, if
commands in the compile DB are prefixed with it. any; commands in the compile DB are prefixed with it.
""" """
if not compile_commands.is_file(): if not compile_commands.is_file():
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
+18 -1
View File
@@ -22,7 +22,6 @@ NATIVE_TOOLCHAIN_MODULES = {
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
("host", Toolchain.HOST): "esphome.host.toolchain", ("host", Toolchain.HOST): "esphome.host.toolchain",
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
} }
@@ -40,6 +39,24 @@ def native_backend() -> ModuleType | None:
return importlib.import_module(module_path) return importlib.import_module(module_path)
# Binutils and the linked image for memory analysis, for toolchains that build
# without PlatformIO but have no native build backend (which supplies them)
ANALYSIS_TOOLCHAIN_MODULES = {
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
}
def analysis_backend() -> ModuleType | None:
"""The module giving objdump, readelf and the ELF of a non-PlatformIO build.
None means PlatformIO's idedata supplies them (or nothing can).
"""
if (native := native_backend()) is not None:
return native
module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain))
return importlib.import_module(module_path) if module_path else None
def warn_ignored_platformio_options(consumed: Collection[str]) -> None: def warn_ignored_platformio_options(consumed: Collection[str]) -> None:
"""Warn for component-added platformio options a native build drops. """Warn for component-added platformio options a native build drops.
@@ -1,7 +1,6 @@
import logging import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components.const import CONF_ON_NUMERIC_COMPARISON_REQUEST
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
from esphome.components.ota import ( from esphome.components.ota import (
BASE_OTA_SCHEMA, BASE_OTA_SCHEMA,
@@ -13,7 +12,10 @@ from esphome.components.ota import (
) )
from esphome.components.zephyr import zephyr_data from esphome.components.zephyr import zephyr_data
from esphome.components.zephyr.const import KEY_BOOTLOADER from esphome.components.zephyr.const import KEY_BOOTLOADER
from esphome.components.zephyr_ble_server import DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN from esphome.components.zephyr_ble_server import (
CONF_ON_NUMERIC_COMPARISON_REQUEST,
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
)
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
from esphome.core import CoroPriority, coroutine_with_priority from esphome.core import CoroPriority, coroutine_with_priority
+2 -1
View File
@@ -5,7 +5,6 @@ from esphome import automation
from esphome.automation import maybe_simple_id from esphome.automation import maybe_simple_id
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
from esphome.components.esp32_ble import BTLoggers from esphome.components.esp32_ble import BTLoggers
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
@@ -102,8 +101,10 @@ BLERemoveBondAction = ble_client_ns.class_(
) )
CONF_PASSKEY = "passkey" CONF_PASSKEY = "passkey"
CONF_ACCEPT = "accept"
CONF_ON_PASSKEY_REQUEST = "on_passkey_request" CONF_ON_PASSKEY_REQUEST = "on_passkey_request"
CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification" CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification"
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
CONF_AUTO_CONNECT = "auto_connect" CONF_AUTO_CONNECT = "auto_connect"
MULTI_CONF = True MULTI_CONF = True
-2
View File
@@ -8,7 +8,6 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ACCEPT = "accept"
CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_AVERAGING = "adc_averaging"
CONF_ADC_TIME = "adc_time" CONF_ADC_TIME = "adc_time"
CONF_ALLOWED_IPS = "allowed_ips" CONF_ALLOWED_IPS = "allowed_ips"
@@ -59,7 +58,6 @@ CONF_MAX_PACKET_SIZE = "max_packet_size"
CONF_MODEM = "modem" CONF_MODEM = "modem"
CONF_MODULATION = "modulation" CONF_MODULATION = "modulation"
CONF_NOX_INDEX = "nox_index" CONF_NOX_INDEX = "nox_index"
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
CONF_ON_PACKET = "on_packet" CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive" CONF_ON_RECEIVE = "on_receive"
CONF_ON_SCAN_END = "on_scan_end" CONF_ON_SCAN_END = "on_scan_end"
+4 -105
View File
@@ -25,86 +25,16 @@ extern "C" {
#include <freertos/FreeRTOSConfig.h> #include <freertos/FreeRTOSConfig.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <cstring>
#ifdef USE_ARDUINO #ifdef USE_ARDUINO
// Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c). // Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c).
// Without this, esp_bt_controller_init() fails with ESP_ERR_INVALID_STATE. // Without this, esp_bt_controller_init() fails with ESP_ERR_INVALID_STATE.
extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-naming) extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-naming)
#endif #endif
#if !defined(CONFIG_BT_CONTROLLER_DISABLED) && \
(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3))
// The ESP32-C3 and ESP32-S3 share one controller (lib_esp32c3_family, RivieraWaves link layer).
#define USE_ESP32_BLE_TERMINATE_PATCH
// Controller internals, reached through r_ip_funcs_p: a table btdm_controller_init() copies into RAM.
extern "C" {
extern void *r_ip_funcs_p;
const char *btdm_controller_get_compile_version();
uint8_t r_lld_con_terminate_max_evt_update(uint8_t link_id, uint8_t acked);
uint8_t *r_lld_con_cntl_pkt_info_get();
}
#endif
namespace esphome::esp32_ble { namespace esphome::esp32_ble {
ESPHOME_LOG_TAG(TAG, "esp32_ble"); ESPHOME_LOG_TAG(TAG, "esp32_ble");
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
// The controller ends its terminate procedure after 6 connection events without an acknowledgement and reports
// the link closed. A peer using peripheral latency listens only every (latency + 1) events, so it can miss all
// six LL_TERMINATE_IND: it keeps the link and searches for the central at full receive current until its own
// supervision timeout. The Core spec keeps the procedure going until the acknowledgement or the supervision
// timeout; this does that, by holding the controller's no-acknowledgement count below its limit. A peer that
// never answers still ends the link at the supervision timeout.
static constexpr size_t TERMINATE_MAX_EVT_SLOT = 0x7dc / sizeof(void *);
static constexpr uint8_t TERMINATE_MAX_EVT = 5; // the controller gives up once its count exceeds this
static uint8_t IRAM_ATTR terminate_until_acked(uint8_t link_id, uint8_t acked) {
// Per link: [2] terminate in progress, [3] connection events without an acknowledgement.
uint8_t *info = r_lld_con_cntl_pkt_info_get() + link_id * 4;
if (!acked && info[2] != 0 && info[3] >= TERMINATE_MAX_EVT)
info[3] = TERMINATE_MAX_EVT - 1;
return r_lld_con_terminate_max_evt_update(link_id, acked);
}
// The controller builds whose r_lld_con_terminate_max_evt_update and r_lld_con_cntl_pkt_info_get the wrapper was
// checked against. The table slot alone does not prove the pkt_info layout, so an unknown build is left alone.
// btdm_controller_get_compile_version() is what ESP-IDF logs at init as "BT controller compile version [...]".
// Recheck on every ESP-IDF bump: add the new build once both functions are confirmed unchanged, or drop the patch once
// espressif/esp-idf#19189 is fixed.
static const char *const TERMINATE_PATCH_CONTROLLER_BUILDS[] = {
"51d9dfd", // ESP-IDF 5.5.5-5.5.6, Arduino 3.3.10-3.3.12
"b7de11e", // ESP-IDF 5.5.4, Arduino 3.3.8-3.3.9: both functions identical to 51d9dfd's
};
static void patch_terminate_until_acked() {
auto **table = static_cast<void **>(r_ip_funcs_p);
if (table == nullptr) {
ESP_LOGW(TAG, "Controller layout not recognised; disconnects keep the controller's 6-event limit");
return;
}
const char *build = btdm_controller_get_compile_version();
bool known_build = false;
for (const char *known : TERMINATE_PATCH_CONTROLLER_BUILDS)
known_build = known_build || strcmp(build, known) == 0;
if (!known_build) {
// Expected after an ESP-IDF bump, and nothing a user can act on
ESP_LOGD(TAG,
"Controller build %s not checked for the terminate patch; disconnects keep the controller's 6-event limit",
build);
return;
}
if (table[TERMINATE_MAX_EVT_SLOT] == reinterpret_cast<void *>(&terminate_until_acked))
return; // already patched: the controller stayed enabled across a setup
if (table[TERMINATE_MAX_EVT_SLOT] != reinterpret_cast<void *>(&r_lld_con_terminate_max_evt_update)) {
ESP_LOGW(TAG, "Controller build %s has an unexpected terminate handler; terminate patch skipped", build);
return;
}
table[TERMINATE_MAX_EVT_SLOT] = reinterpret_cast<void *>(&terminate_until_acked);
}
#endif
#ifdef CONFIG_BT_CONTROLLER_DISABLED #ifdef CONFIG_BT_CONTROLLER_DISABLED
// Bringing up the remote BT controller issues synchronous RPCs to the // Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog // co-processor with 5 second response timeouts, and the default task watchdog
@@ -293,9 +223,6 @@ bool ESP32BLE::ble_setup_() {
return false; return false;
} }
} }
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
patch_terminate_until_acked();
#endif
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
#else #else
@@ -722,30 +649,20 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
// never answer an update, and without this the link silently stays on the // never answer an update, and without this the link silently stays on the
// old parameters (visible only as unexplained supervision timeouts). // old parameters (visible only as unexplained supervision timeouts).
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: { case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: {
// A peer can keep asking for parameters it then rejects: warn once per peer, repeats are
// verbose until an update succeeds or it disconnects.
if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) { if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(param->update_conn_params.bda, mac_s); format_mac_addr_upper(param->update_conn_params.bda, mac_s);
if (global_ble->conn_param_failed_slot_(param->update_conn_params.bda) != nullptr) { ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status);
ESP_LOGV(TAG, "[%s] Conn param update failed again, status=%d", mac_s, param->update_conn_params.status); }
} else {
ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status);
static constexpr esp_bd_addr_t FREE{};
uint8_t *slot = global_ble->conn_param_failed_slot_(FREE);
if (slot != nullptr)
memcpy(slot, param->update_conn_params.bda, sizeof(esp_bd_addr_t));
}
} else {
global_ble->forget_conn_param_failure_(param->update_conn_params.bda);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
else {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(param->update_conn_params.bda, mac_s); format_mac_addr_upper(param->update_conn_params.bda, mac_s);
ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s, ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s,
param->update_conn_params.conn_int, param->update_conn_params.latency, param->update_conn_params.conn_int, param->update_conn_params.latency,
param->update_conn_params.timeout); param->update_conn_params.timeout);
#endif
} }
#endif
return; return;
} }
@@ -767,8 +684,6 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
#ifdef USE_ESP32_BLE_SERVER #ifdef USE_ESP32_BLE_SERVER
void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param) { esp_ble_gatts_cb_param_t *param) {
if (event == ESP_GATTS_DISCONNECT_EVT)
global_ble->forget_conn_param_failure_(param->disconnect.remote_bda);
enqueue_ble_event(event, gatts_if, param); enqueue_ble_event(event, gatts_if, param);
// Wake up main loop to process GATT event immediately // Wake up main loop to process GATT event immediately
App.wake_loop_threadsafe(); App.wake_loop_threadsafe();
@@ -778,28 +693,12 @@ void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gat
#ifdef USE_ESP32_BLE_CLIENT #ifdef USE_ESP32_BLE_CLIENT
void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) { esp_ble_gattc_cb_param_t *param) {
if (event == ESP_GATTC_DISCONNECT_EVT)
global_ble->forget_conn_param_failure_(param->disconnect.remote_bda);
enqueue_ble_event(event, gattc_if, param); enqueue_ble_event(event, gattc_if, param);
// Wake up main loop to process GATT event immediately // Wake up main loop to process GATT event immediately
App.wake_loop_threadsafe(); App.wake_loop_threadsafe();
} }
#endif #endif
uint8_t *ESP32BLE::conn_param_failed_slot_(const uint8_t *bda) {
for (auto &slot : this->conn_param_failed_bdas_) {
if (memcmp(slot, bda, sizeof(esp_bd_addr_t)) == 0)
return slot;
}
return nullptr;
}
void ESP32BLE::forget_conn_param_failure_(const uint8_t *bda) {
uint8_t *slot = this->conn_param_failed_slot_(bda);
if (slot != nullptr)
memset(slot, 0, sizeof(esp_bd_addr_t));
}
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
// The running stack owns the address (on hosted controllers it lives in // The running stack owns the address (on hosted controllers it lives in
// the remote chip's efuse); null before init becomes all-zero. // the remote chip's efuse); null before init becomes all-zero.
-5
View File
@@ -174,9 +174,6 @@ class ESP32BLE final : public Component {
// Other non-ACTIVE states (e.g. OFF, DISABLED) are currently treated as no-ops. // Other non-ACTIVE states (e.g. OFF, DISABLED) are currently treated as no-ops.
void __attribute__((noinline)) loop_handle_state_transition_not_active_(); void __attribute__((noinline)) loop_handle_state_transition_not_active_();
uint8_t *conn_param_failed_slot_(const uint8_t *bda);
void forget_conn_param_failure_(const uint8_t *bda);
bool ble_setup_(); bool ble_setup_();
bool ble_dismantle_(); bool ble_dismantle_();
void request_state_(bool enable); void request_state_(bool enable);
@@ -247,8 +244,6 @@ class ESP32BLE final : public Component {
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef) esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef)
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
bool enable_on_boot_{}; // 1 byte bool enable_on_boot_{}; // 1 byte
// Peers already warned about a failed conn param update, one per connection; all-zero is free
esp_bd_addr_t conn_param_failed_bdas_[USE_ESP32_BLE_MAX_CONNECTIONS]{};
#ifdef USE_ESP32_BLE_ADVERTISING #ifdef USE_ESP32_BLE_ADVERTISING
uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising
#endif #endif
+12 -9
View File
@@ -7,6 +7,7 @@ from esphome.components.network import (
add_use_address, add_use_address,
get_network_priority, get_network_priority,
get_priority_interfaces_from_full_config, get_priority_interfaces_from_full_config,
ip_address_literal,
require_ipv4, require_ipv4,
) )
from esphome.config_helpers import ( from esphome.config_helpers import (
@@ -238,6 +239,7 @@ MANUAL_IP_SCHEMA = cv.Schema(
) )
EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component)
ManualIP = ethernet_ns.struct("ManualIP")
def _is_framework_spi_polling_mode_supported() -> bool: def _is_framework_spi_polling_mode_supported() -> bool:
@@ -623,10 +625,15 @@ def _final_validate_spi(config: ConfigType) -> None:
) )
def _manual_ip_table(config: ConfigType) -> list[int]: def manual_ip(config: ConfigType) -> cg.StructInitializer:
"""Pack manual_ip in the ETHERNET_MANUAL_IP layout.""" return cg.StructInitializer(
keys = (CONF_STATIC_IP, CONF_GATEWAY, CONF_SUBNET, CONF_DNS1, CONF_DNS2) ManualIP,
return [octet for key in keys for octet in config[key].packed] ("static_ip", ip_address_literal(config[CONF_STATIC_IP])),
("gateway", ip_address_literal(config[CONF_GATEWAY])),
("subnet", ip_address_literal(config[CONF_SUBNET])),
("dns1", ip_address_literal(config[CONF_DNS1])),
("dns2", ip_address_literal(config[CONF_DNS2])),
)
def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: def phy_register(address: int, value: int, page: int) -> cg.StructInitializer:
@@ -678,11 +685,7 @@ async def to_code(config: ConfigType) -> None:
if CONF_MANUAL_IP in config: if CONF_MANUAL_IP in config:
cg.add_define("USE_ETHERNET_MANUAL_IP") cg.add_define("USE_ETHERNET_MANUAL_IP")
cg.extern_progmem_array( cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP])))
"esphome::ethernet::ETHERNET_MANUAL_IP",
cg.uint8,
_manual_ip_table(config[CONF_MANUAL_IP]),
)
# Add compile-time define for PHY types with specific code # Add compile-time define for PHY types with specific code
if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]):
@@ -10,15 +10,6 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non-
EthernetComponent::EthernetComponent() { global_eth_component = this; } EthernetComponent::EthernetComponent() { global_eth_component = this; }
#ifdef USE_ETHERNET_MANUAL_IP
ManualIP load_manual_ip() {
uint8_t b[ETHERNET_MANUAL_IP_SIZE];
progmem_memcpy(b, ETHERNET_MANUAL_IP, sizeof(b));
auto ip = [&b](size_t i) { return network::IPAddress(b[i], b[i + 1], b[i + 2], b[i + 3]); };
return ManualIP{ip(0), ip(4), ip(8), ip(12), ip(16)};
}
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS #ifdef USE_ETHERNET_IP_STATE_LISTENERS
void EthernetComponent::notify_ip_state_listeners_() { void EthernetComponent::notify_ip_state_listeners_() {
auto ips = this->get_ip_addresses(); auto ips = this->get_ip_addresses();
@@ -103,14 +103,6 @@ struct ManualIP {
network::IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default. network::IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default.
}; };
#ifdef USE_ETHERNET_MANUAL_IP
/// The configured static IP, gateway, subnet, dns1 and dns2 as 4 octets each, generated by codegen
/// (components/ethernet/__init__.py) and kept in flash.
static constexpr size_t ETHERNET_MANUAL_IP_SIZE = 5 * 4;
extern const uint8_t ETHERNET_MANUAL_IP[ETHERNET_MANUAL_IP_SIZE] PROGMEM;
ManualIP load_manual_ip();
#endif
struct PHYRegister { struct PHYRegister {
uint32_t address; uint32_t address;
uint32_t value; uint32_t value;
@@ -159,6 +151,9 @@ class EthernetComponent final : public Component {
#endif #endif
void set_type(EthernetType type) { this->type_ = type; } void set_type(EthernetType type) { this->type_ = type; }
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; }
#endif
void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; } void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; }
network::IPAddresses get_ip_addresses(); network::IPAddresses get_ip_addresses();
@@ -338,6 +333,9 @@ class EthernetComponent final : public Component {
#endif // USE_RP2 #endif // USE_RP2
// Common members // Common members
#ifdef USE_ETHERNET_MANUAL_IP
optional<ManualIP> manual_ip_{};
#endif
uint32_t connect_begin_; uint32_t connect_begin_;
// Group all uint8_t types together (enums and bools) // Group all uint8_t types together (enums and bools)
@@ -763,7 +763,9 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
event_name = "ETH connected"; event_name = "ETH connected";
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP) #if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
global_eth_component->notify_ip_state_listeners_(); if (global_eth_component->manual_ip_.has_value()) {
global_eth_component->notify_ip_state_listeners_();
}
#endif #endif
#if USE_NETWORK_IPV6 #if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the // Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
@@ -887,13 +889,19 @@ void EthernetComponent::start_connect_() {
ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err));
} }
esp_netif_ip_info_t info{}; esp_netif_ip_info_t info;
#ifdef USE_ETHERNET_MANUAL_IP #ifdef USE_ETHERNET_MANUAL_IP
const ManualIP manual_ip = load_manual_ip(); if (this->manual_ip_.has_value()) {
info.ip = manual_ip.static_ip; info.ip = this->manual_ip_->static_ip;
info.gw = manual_ip.gateway; info.gw = this->manual_ip_->gateway;
info.netmask = manual_ip.subnet; info.netmask = this->manual_ip_->subnet;
} else
#endif #endif
{
info.ip.addr = 0;
info.gw.addr = 0;
info.netmask.addr = 0;
}
esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT; esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT;
@@ -911,32 +919,35 @@ void EthernetComponent::start_connect_() {
ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
#ifdef USE_ETHERNET_MANUAL_IP #ifdef USE_ETHERNET_MANUAL_IP
// Set DNS through esp_netif so the servers are stored in the netif's own if (this->manual_ip_.has_value()) {
// dns[] array; raw dns_setserver() would be lost when the default-route // Set DNS through esp_netif so the servers are stored in the netif's own
// arbitration re-applies the default netif's DNS. // dns[] array; raw dns_setserver() would be lost when the default-route
// Log-only on failure: the link still has a working IP/gateway, so degraded // arbitration re-applies the default netif's DNS.
// name resolution does not justify marking the whole component failed. // Log-only on failure: the link still has a working IP/gateway, so degraded
esp_netif_dns_info_t dns{}; // name resolution does not justify marking the whole component failed.
if (manual_ip.dns1.is_set()) { esp_netif_dns_info_t dns{};
dns.ip = manual_ip.dns1; if (this->manual_ip_->dns1.is_set()) {
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns); dns.ip = this->manual_ip_->dns1;
if (err != ESP_OK) { err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns);
ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err)); if (err != ESP_OK) {
ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err));
}
} }
} if (this->manual_ip_->dns2.is_set()) {
if (manual_ip.dns2.is_set()) { dns.ip = this->manual_ip_->dns2;
dns.ip = manual_ip.dns2; err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns);
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns); if (err != ESP_OK) {
if (err != ESP_OK) { ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err));
ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err)); }
} }
} } else
#else
err = esp_netif_dhcpc_start(this->eth_netif_);
if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) {
ESPHL_ERROR_CHECK(err, "DHCPC start error");
}
#endif #endif
{
err = esp_netif_dhcpc_start(this->eth_netif_);
if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) {
ESPHL_ERROR_CHECK(err, "DHCPC start error");
}
}
#if USE_NETWORK_IPV6 #if USE_NETWORK_IPV6
// Attempt to create IPv6 link-local address // Attempt to create IPv6 link-local address
// We MUST attempt this here, not just in finish_connect_(), because with // We MUST attempt this here, not just in finish_connect_(), because with
@@ -56,12 +56,15 @@ void EthernetComponent::setup() {
// Set hostname before begin() so the LWIP netif gets it // Set hostname before begin() so the LWIP netif gets it
this->eth_->hostname(App.get_name().c_str()); this->eth_->hostname(App.get_name().c_str());
// Configure static IP (must be done before begin()) // Configure static IP if set (must be done before begin())
#ifdef USE_ETHERNET_MANUAL_IP #ifdef USE_ETHERNET_MANUAL_IP
{ if (this->manual_ip_.has_value()) {
const ManualIP manual_ip = load_manual_ip(); IPAddress ip(this->manual_ip_->static_ip);
this->eth_->config(IPAddress(manual_ip.static_ip), IPAddress(manual_ip.gateway), IPAddress(manual_ip.subnet), IPAddress gateway(this->manual_ip_->gateway);
IPAddress(manual_ip.dns1), IPAddress(manual_ip.dns2)); IPAddress subnet(this->manual_ip_->subnet);
IPAddress dns1(this->manual_ip_->dns1);
IPAddress dns2(this->manual_ip_->dns2);
this->eth_->config(ip, gateway, subnet, dns1, dns2);
} }
#endif #endif
@@ -287,17 +290,20 @@ void EthernetComponent::start_connect_() {
// Hostname is already set in setup() via LwipIntf::setHostname() // Hostname is already set in setup() via LwipIntf::setHostname()
#ifdef USE_ETHERNET_MANUAL_IP #ifdef USE_ETHERNET_MANUAL_IP
// Static IP was already configured before begin() in setup() if (this->manual_ip_.has_value()) {
// Set DNS servers // Static IP was already configured before begin() in setup()
const ManualIP manual_ip = load_manual_ip(); // Set DNS servers
LwIPLock lock; LwIPLock lock;
if (manual_ip.dns1.is_set()) { if (this->manual_ip_->dns1.is_set()) {
ip_addr_t d = manual_ip.dns1; ip_addr_t d;
dns_setserver(0, &d); d = this->manual_ip_->dns1;
} dns_setserver(0, &d);
if (manual_ip.dns2.is_set()) { }
ip_addr_t d = manual_ip.dns2; if (this->manual_ip_->dns2.is_set()) {
dns_setserver(1, &d); ip_addr_t d;
d = this->manual_ip_->dns2;
dns_setserver(1, &d);
}
} }
#endif #endif
} }
@@ -289,12 +289,8 @@ using socket_t = int;
#elif defined(CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN) && defined(__APPLE__) #elif defined(CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN) && defined(__APPLE__)
#include <TargetConditionals.h> #include <TargetConditionals.h>
#if TARGET_OS_OSX #if TARGET_OS_OSX
// The precompiled header brings in ArduinoJson.h, whose `using namespace ArduinoJson` makes MacTypes.h's
// global Ptr ambiguous with ArduinoJson's Ptr class; give the macOS one another name here
#define Ptr esphome_mactypes_Ptr
#include <CoreFoundation/CoreFoundation.h> #include <CoreFoundation/CoreFoundation.h>
#include <Security/Security.h> #include <Security/Security.h>
#undef Ptr
#endif // TARGET_OS_OSX #endif // TARGET_OS_OSX
#endif // _WIN32 #endif // _WIN32
+2 -27
View File
@@ -1,4 +1,5 @@
#include "ld2410s.h" #include "ld2410s.h"
#include "ld2410s_number.h"
#include <cstring> #include <cstring>
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
@@ -39,11 +40,6 @@ static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_
#ifdef USE_LD2410S_PARAMETERS #ifdef USE_LD2410S_PARAMETERS
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B}; static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter"); static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
#ifdef USE_NUMBER
// Module units to number units: 0.7 m gates, seconds, tenths of a hertz (number.py uses the same)
static constexpr float PARAMETER_SCALES[] PROGMEM = {0.7f, 0.7f, 1.0f, 0.1f, 0.1f, 1.0f};
static_assert(sizeof(PARAMETER_SCALES) / sizeof(PARAMETER_SCALES[0]) == PARAMETER_COUNT, "one scale per parameter");
#endif
#endif #endif
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words // The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
static constexpr uint8_t VERSION_TYPE_SIZE = 4; static constexpr uint8_t VERSION_TYPE_SIZE = 4;
@@ -140,27 +136,6 @@ bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
this->parameters_dirty_ = true; // written by loop() once no sequence is running this->parameters_dirty_ = true; // written by loop() once no sequence is running
return true; return true;
} }
#ifdef USE_NUMBER
void LD2410SComponent::control_number(number::Number *number, float value) {
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != number)
continue;
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / PARAMETER_SCALES[i]); // the schema keeps every parameter within a byte
if (this->set_parameter(static_cast<Parameter>(i), raw))
number->publish_state(raw * PARAMETER_SCALES[i]);
return;
}
}
#endif
#ifdef USE_SELECT
void LD2410SComponent::control_response_speed(select::Select *select, size_t index) {
if (this->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
select->publish_state(index);
}
#endif
#endif #endif
void LD2410SComponent::restart_init_(uint32_t at) { void LD2410SComponent::restart_init_(uint32_t at) {
@@ -527,7 +502,7 @@ void LD2410SComponent::publish_parameters_() {
#ifdef USE_NUMBER #ifdef USE_NUMBER
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != nullptr) { if (this->numbers_[i] != nullptr) {
this->numbers_[i]->publish_state(this->parameters_[i] * PARAMETER_SCALES[i]); this->numbers_[i]->publish_raw(this->parameters_[i]);
} }
} }
#endif #endif
+6 -10
View File
@@ -14,9 +14,6 @@
#ifdef USE_LD2410S_VERSION #ifdef USE_LD2410S_VERSION
#include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/text_sensor/text_sensor.h"
#endif #endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/number/number.h"
#endif
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) #if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/select/select.h" #include "esphome/components/select/select.h"
#endif #endif
@@ -42,6 +39,10 @@ static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAM
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10}; static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS); static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
class LD2410SNumber;
#endif
class LD2410SComponent final : public Component, public uart::UARTDevice { class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_BINARY_SENSOR #ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(target) SUB_BINARY_SENSOR(target)
@@ -66,14 +67,9 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_LD2410S_PARAMETERS #ifdef USE_LD2410S_PARAMETERS
bool set_parameter(Parameter parameter, uint8_t value); bool set_parameter(Parameter parameter, uint8_t value);
#ifdef USE_NUMBER #ifdef USE_NUMBER
void set_number(Parameter parameter, number::Number *number) { void set_number(Parameter parameter, LD2410SNumber *number) {
this->numbers_[static_cast<uint8_t>(parameter)] = number; this->numbers_[static_cast<uint8_t>(parameter)] = number;
} }
/// Writes the number's parameter and publishes the step the module got.
void control_number(number::Number *number, float value);
#endif
#ifdef USE_SELECT
void control_response_speed(select::Select *select, size_t index);
#endif #endif
#endif #endif
@@ -113,7 +109,7 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#endif #endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) #if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
number::Number *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
#endif #endif
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
@@ -0,0 +1,32 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/number/number.h"
namespace esphome::ld2410s {
/// Edits one common parameter; scale converts the module's units to the number's.
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
public:
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
protected:
void control(float value) override {
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
if (this->parent_->set_parameter(this->parameter_, raw))
this->publish_raw(raw);
}
const Parameter parameter_;
const float scale_;
};
} // namespace esphome::ld2410s
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/select/select.h"
namespace esphome::ld2410s {
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
protected:
void control(size_t index) override {
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
this->publish_state(index);
}
};
} // namespace esphome::ld2410s
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
+15 -9
View File
@@ -16,7 +16,7 @@ from esphome.const import (
) )
from esphome.types import ConfigType from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
DEPENDENCIES = ["ld2410s"] DEPENDENCIES = ["ld2410s"]
@@ -26,8 +26,9 @@ CONF_MIN_DISTANCE = "min_distance"
CONF_NO_DELAY = "no_delay" CONF_NO_DELAY = "no_delay"
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency" CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz; LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
# PARAMETER_SCALES in ld2410s.cpp converts with the same factors
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
GATE_WIDTH_M = 0.7 GATE_WIDTH_M = 0.7
FREQUENCY_SCALE = 0.1 FREQUENCY_SCALE = 0.1
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M} _DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
@@ -36,7 +37,7 @@ _FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema: def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
return number.number_schema( return number.number_schema(
number.ApplyNumber, LD2410SNumber,
device_class=device_class, device_class=device_class,
entity_category=ENTITY_CATEGORY_CONFIG, entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=unit, unit_of_measurement=unit,
@@ -71,19 +72,22 @@ async def to_code(config: ConfigType) -> None:
await numbers( await numbers(
CONF_MAX_DISTANCE, CONF_MAX_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE), partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
apply="control_number(entity, value)", Parameter.PARAMETER_MAX_DISTANCE,
GATE_WIDTH_M,
**_DISTANCE_RANGE, **_DISTANCE_RANGE,
) )
await numbers( await numbers(
CONF_MIN_DISTANCE, CONF_MIN_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE), partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
apply="control_number(entity, value)", Parameter.PARAMETER_MIN_DISTANCE,
GATE_WIDTH_M,
**_DISTANCE_RANGE, **_DISTANCE_RANGE,
) )
await numbers( await numbers(
CONF_NO_DELAY, CONF_NO_DELAY,
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY), partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
apply="control_number(entity, value)", Parameter.PARAMETER_NO_DELAY,
1,
min_value=1, min_value=1,
max_value=120, max_value=120,
step=1, step=1,
@@ -91,12 +95,14 @@ async def to_code(config: ConfigType) -> None:
await numbers( await numbers(
CONF_STATUS_REPORTING_FREQUENCY, CONF_STATUS_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY), partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
apply="control_number(entity, value)", Parameter.PARAMETER_STATUS_FREQUENCY,
FREQUENCY_SCALE,
**_FREQUENCY_RANGE, **_FREQUENCY_RANGE,
) )
await numbers( await numbers(
CONF_DISTANCE_REPORTING_FREQUENCY, CONF_DISTANCE_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY), partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
apply="control_number(entity, value)", Parameter.PARAMETER_DISTANCE_FREQUENCY,
FREQUENCY_SCALE,
**_FREQUENCY_RANGE, **_FREQUENCY_RANGE,
) )
+5 -6
View File
@@ -4,16 +4,18 @@ import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
DEPENDENCIES = ["ld2410s"] DEPENDENCIES = ["ld2410s"]
CONF_RESPONSE_SPEED = "response_speed" CONF_RESPONSE_SPEED = "response_speed"
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
CONFIG_SCHEMA = { CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Required(CONF_RESPONSE_SPEED): select.select_schema( cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
select.ApplySelect, LD2410SSelect,
entity_category=ENTITY_CATEGORY_CONFIG, entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:speedometer", icon="mdi:speedometer",
), ),
@@ -25,8 +27,5 @@ async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2410S_ID]) hub = await cg.get_variable(config[CONF_LD2410S_ID])
selects = select.sub_selects(config, parent=hub) selects = select.sub_selects(config, parent=hub)
await selects( await selects(
CONF_RESPONSE_SPEED, CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
hub.set_response_speed_select,
options=["Normal", "Fast"],
apply="control_response_speed(entity, index)",
) )
+2 -6
View File
@@ -45,7 +45,6 @@ from .const import (
FAMILY_COMPONENT, FAMILY_COMPONENT,
FAMILY_FRIENDLY, FAMILY_FRIENDLY,
FAMILY_RTL8710B, FAMILY_RTL8710B,
FAMILY_RTL8720C,
KEY_BOARD, KEY_BOARD,
KEY_COMPONENT, KEY_COMPONENT,
KEY_COMPONENT_DATA, KEY_COMPONENT_DATA,
@@ -60,8 +59,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Fault handlers the crash handler wraps (BK7238, BK7231N) # Fault handlers the crash handler wraps (BK7238, BK7231N)
BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt") BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt")
# Families with a crash handler; the RTL8720C installs it at run time (no wraps).
CRASH_HANDLER_FAMILIES = (FAMILY_BK7231N, FAMILY_BK7238, FAMILY_RTL8720C)
CODEOWNERS = ["@kuba2k2"] CODEOWNERS = ["@kuba2k2"]
AUTO_LOAD = ["preferences"] AUTO_LOAD = ["preferences"]
@@ -557,11 +554,10 @@ async def component_to_code(config):
# RAM-executable output section and prints a post-link placement summary. # RAM-executable output section and prints a post-link placement summary.
if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX:
cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"])
if config[CONF_FAMILY] in CRASH_HANDLER_FAMILIES: if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
# Crash handler (crash_handler.cpp) # Crash handler (crash_handler.cpp, patch_noinit.py.script)
cg.add_define("USE_LIBRETINY_CRASH_HANDLER") cg.add_define("USE_LIBRETINY_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER")
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
for trap in BK_TRAP_HANDLERS: for trap in BK_TRAP_HANDLERS:
cg.add_build_flag(f"-Wl,--wrap={trap}") cg.add_build_flag(f"-Wl,--wrap={trap}")
cg.add_platformio_option("extra_scripts", ["pre:patch_noinit.py"]) cg.add_platformio_option("extra_scripts", ["pre:patch_noinit.py"])
+82 -225
View File
@@ -1,12 +1,8 @@
#ifdef USE_LIBRETINY #ifdef USE_BK72XX
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#ifdef USE_LIBRETINY_CRASH_HANDLER #ifdef USE_LIBRETINY_CRASH_HANDLER
#if !defined(USE_BK72XX) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)
#error "The LibreTiny crash handler has no fault capture for this chip family"
#endif
#include "crash_handler.h" #include "crash_handler.h"
#include "core.h" #include "core.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -17,7 +13,6 @@
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#ifdef USE_BK72XX
extern "C" { extern "C" {
#include <arm_mcu_pub.h> // MCU_REG_BACKUP_SP_* #include <arm_mcu_pub.h> // MCU_REG_BACKUP_SP_*
#include <intc.h> // struct arm_registers #include <intc.h> // struct arm_registers
@@ -44,138 +39,28 @@ extern char _itcmcode_ram_begin[];
extern char _itcmcode_ram_end[]; extern char _itcmcode_ram_end[];
} }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_BK72XX
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
// Bounds of the code in flash (XIP) and in RAM (linker symbols, rtl8710c_ram.ld).
extern char __xip_code_text_start__[];
extern char __xip_code_text_end__[];
extern char __ram_entry_text_start__[];
extern char __ram_code_text_end__[];
}
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_LIBRETINY_VARIANT_RTL8720C
namespace esphome::libretiny { namespace esphome::libretiny {
ESPHOME_LOG_TAG(TAG, "libretiny.crash"); ESPHOME_LOG_TAG(TAG, "libretiny.crash");
// 0xDEAD sentinel, then the record layout version. // 0xDEAD sentinel, then the record layout version.
static constexpr uint32_t CRASH_MAGIC_V2 = 0xDEAD0002; static constexpr uint32_t CRASH_MAGIC_V1 = 0xDEAD0001;
// BK72xx: the ARM9 exception taken.
static constexpr uint32_t CRASH_REASON_UNDEFINED = 1; static constexpr uint32_t CRASH_REASON_UNDEFINED = 1;
static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2; static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2;
static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3; static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3;
// RTL8720C: the Cortex-M exception number (3 to 7) plus this offset. PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort");
static constexpr uint32_t CRASH_REASON_CORTEX_M_OFFSET = 1;
PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort",
"Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault");
static constexpr size_t MAX_BACKTRACE = 8;
// Words scanned above sp for return addresses.
static constexpr size_t STACK_SCAN_WORDS = 64;
struct CrashRecord {
uint32_t magic;
uint32_t reason;
uint32_t pc;
uint32_t lr;
uint32_t sp;
uint32_t fault_status; // Cortex-M CFSR, 0 on BK72xx
uint32_t fault_address; // Cortex-M BFAR or MMFAR when valid, else 0
uint32_t backtrace[MAX_BACKTRACE];
};
#ifdef USE_BK72XX
// Kept in ITCM, see patch_noinit.py.script.
#define CRASH_RECORD_SECTION ".noinit"
#else
// rtl8710c_ram.ld keeps .ram.bss.noinit* out of the startup zeroing.
#define CRASH_RECORD_SECTION ".ram.bss.noinit.esphome_crash"
#endif
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
__attribute__((section(CRASH_RECORD_SECTION))) static CrashRecord s_record;
static bool s_record_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_record_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() {
if (!s_record_read) {
s_record_read = true;
s_record_valid = s_record.magic == CRASH_MAGIC_V2;
// Clear the magic so the record is reported once.
s_record.magic = 0;
}
return s_record_valid;
}
// One ESP_LOGE per line: the CLI decodes per line and partial output still shows.
void crash_handler_log() {
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
ESP_LOGE(TAG, " Reason: %s", LOG_STR_ARG(CrashReasonStrings::get_log_str(s_record.reason, 0)));
if (s_record.fault_status != 0) {
ESP_LOGE(TAG, " CFSR: 0x%08" PRIX32 " Address: 0x%08" PRIX32, s_record.fault_status, s_record.fault_address);
}
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc);
ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr);
ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp);
uint8_t count = 0;
for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) {
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", count, s_record.backtrace[count]);
}
// addr2line hint with every address
char hint[160];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32,
s_record.pc, s_record.lr);
for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) {
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]);
}
ESP_LOGE(TAG, "%s", hint);
}
// --- Crash time: exception mode, a few dozen bytes of stack: no printing, no large locals.
static inline bool is_code_addr(uint32_t val) {
uint32_t addr = val & ~1u; // clear the Thumb bit of a return address
#ifdef USE_BK72XX
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
#else
return (addr >= (uint32_t) __xip_code_text_start__ && addr < (uint32_t) __xip_code_text_end__) ||
(addr >= (uint32_t) __ram_entry_text_start__ && addr < (uint32_t) __ram_code_text_end__);
#endif
}
// Return addresses above sp give the backtrace; then the magic marks the record complete.
static void finish_record(uint32_t sp, uint32_t sram_start, uint32_t sram_end) {
uint32_t count = 0;
if (sp >= sram_start && sp < sram_end && (sp & 3) == 0) {
auto *scan = (const uint32_t *) sp; // NOLINT(performance-no-int-to-ptr)
auto *end = (const uint32_t *) sram_end; // NOLINT(performance-no-int-to-ptr)
for (size_t i = 0; i < STACK_SCAN_WORDS && scan < end && count < MAX_BACKTRACE; i++, scan++) {
uint32_t val = *scan;
if (is_code_addr(val) && val != s_record.lr && (val & ~1u) != s_record.pc)
s_record.backtrace[count++] = val;
}
}
if (count < MAX_BACKTRACE)
s_record.backtrace[count] = 0; // terminator, the reader stops at the first 0
s_record.magic = CRASH_MAGIC_V2;
}
#ifdef USE_BK72XX
// With Wi-Fi up the boot-time watchdog no longer fires: any interrupts-off spin hangs until power is // With Wi-Fi up the boot-time watchdog no longer fires: any interrupts-off spin hangs until power is
// cycled (BK7238 and BK7231N; cause not in the open SDK). Re-armed here, long enough for the SDK's // cycled (BK7238 and BK7231N; cause not in the open SDK). Re-armed here, long enough for the SDK's
// register dump when sdk_silent is off. // register dump when sdk_silent is off.
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 500; static constexpr uint32_t CRASH_REBOOT_WDT_MS = 500;
static constexpr size_t MAX_BACKTRACE = 8;
// Words scanned above sp for return addresses.
static constexpr size_t STACK_SCAN_WORDS = 64;
// SRAM: 192 KB on the BK7231N, 256 KB on the BK7238. // SRAM: 192 KB on the BK7231N, 256 KB on the BK7238.
static constexpr uint32_t SRAM_START = 0x00400000; static constexpr uint32_t SRAM_START = 0x00400000;
#ifdef USE_LIBRETINY_VARIANT_BK7231N #ifdef USE_LIBRETINY_VARIANT_BK7231N
@@ -194,6 +79,65 @@ static constexpr uint32_t CPSR_MODE_UND = 0x1B;
static constexpr uint32_t CPSR_MODE_SYS = 0x1F; static constexpr uint32_t CPSR_MODE_SYS = 0x1F;
static constexpr uint32_t CPSR_THUMB = 1u << 5; static constexpr uint32_t CPSR_THUMB = 1u << 5;
struct CrashRecord {
uint32_t magic;
uint32_t reason;
uint32_t pc;
uint32_t lr;
uint32_t sp;
uint32_t backtrace[MAX_BACKTRACE];
};
// Kept in ITCM, see patch_noinit.py.script.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
__attribute__((section(".noinit"))) static CrashRecord s_record;
static bool s_record_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_record_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() {
if (!s_record_read) {
s_record_read = true;
s_record_valid = s_record.magic == CRASH_MAGIC_V1;
// Clear the magic so the record is reported once.
s_record.magic = 0;
}
return s_record_valid;
}
// One ESP_LOGE per line: the CLI decodes per line and partial output still shows.
void crash_handler_log() {
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
ESP_LOGE(TAG, " Reason: %s", LOG_STR_ARG(CrashReasonStrings::get_log_str(s_record.reason, 0)));
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc);
ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr);
ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp);
uint8_t count = 0;
for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) {
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", count, s_record.backtrace[count]);
}
// addr2line hint with every address
char hint[160];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32,
s_record.pc, s_record.lr);
for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) {
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]);
}
ESP_LOGE(TAG, "%s", hint);
}
// --- Crash time: exception mode, interrupts masked, a few dozen bytes of stack: no printing, no
// large locals.
static inline bool is_code_addr(uint32_t val) {
uint32_t addr = val & ~1u; // clear the Thumb bit of a return address
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
}
// PUSH_ALL_ARM_REG (boot_handlers.S) saves every mode's registers at the arm_mcu_pub.h addresses // PUSH_ALL_ARM_REG (boot_handlers.S) saves every mode's registers at the arm_mcu_pub.h addresses
// before bk_trap_*; lr (r14) follows sp (r13). // before bk_trap_*; lr (r14) follows sp (r13).
struct SavedBank { struct SavedBank {
@@ -230,9 +174,21 @@ static void record_crash(arm_registers *regs, uint32_t reason, uint32_t pc_offse
s_record.pc = regs->pc - pc_offset; s_record.pc = regs->pc - pc_offset;
s_record.lr = lr; s_record.lr = lr;
s_record.sp = sp; s_record.sp = sp;
s_record.fault_status = 0;
s_record.fault_address = 0; // Return addresses above sp give the backtrace.
finish_record(sp, SRAM_START, SRAM_END); uint32_t count = 0;
if (sp >= SRAM_START && sp < SRAM_END && (sp & 3) == 0) {
auto *scan = (const uint32_t *) sp; // NOLINT(performance-no-int-to-ptr)
auto *end = (const uint32_t *) SRAM_END; // NOLINT(performance-no-int-to-ptr)
for (size_t i = 0; i < STACK_SCAN_WORDS && scan < end && count < MAX_BACKTRACE; i++, scan++) {
uint32_t val = *scan;
if (is_code_addr(val) && val != lr && (val & ~1u) != s_record.pc)
s_record.backtrace[count++] = val;
}
}
if (count < MAX_BACKTRACE)
s_record.backtrace[count] = 0; // terminator, the reader stops at the first 0
s_record.magic = CRASH_MAGIC_V1;
lt_wdt_enable(CRASH_REBOOT_WDT_MS); lt_wdt_enable(CRASH_REBOOT_WDT_MS);
} }
@@ -255,106 +211,7 @@ extern "C" void __wrap_bk_trap_dabt(arm_registers *regs) {
} }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_BK72XX
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
// The SDK points the fault vectors (3 HardFault to 7 SecureFault) of its RAM vector table (VTOR) at a
// ROM handler that prints a register dump and then spins. crash_handler_install() puts
// esphome_fault_entry() in front of it, as the SDK's own CONFIG_MIIO patch (app_start.c) does.
static_assert(LT_HW_WATCHDOG, "the crash reboot relies on the hardware watchdog");
// The ROM dump takes about 0.4 s at 115200 baud; without the re-arm the reset waits for the 10 s
// watchdog from arch_init().
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 1000;
static constexpr uint32_t FIRST_FAULT_VECTOR = 3;
static constexpr uint32_t FAULT_VECTOR_COUNT = 5;
// Architectural System Control Block registers (ARMv8-M).
static constexpr uintptr_t SCB_VTOR = 0xE000ED08;
static constexpr uintptr_t SCB_CFSR = 0xE000ED28;
static constexpr uintptr_t SCB_MMFAR = 0xE000ED34;
static constexpr uintptr_t SCB_BFAR = 0xE000ED38;
static constexpr uint32_t CFSR_MMARVALID = 1u << 7;
static constexpr uint32_t CFSR_BFARVALID = 1u << 15;
// DTCM, where the app's stacks and heap live (rtl8710c_ram.ld).
static constexpr uint32_t SRAM_START = 0x10000000;
static constexpr uint32_t SRAM_END = 0x1003FA00;
// Exception frame: r0-r3, r12, lr, pc, xPSR (the RTL8720C has no FPU, so never the extended frame).
static constexpr uint32_t FRAME_LR = 5;
static constexpr uint32_t FRAME_PC = 6;
static constexpr uint32_t FRAME_XPSR = 7;
static constexpr uint32_t FRAME_WORDS = 8;
static constexpr uint32_t XPSR_STACK_ALIGNED = 1u << 9;
// The ROM handlers fault_entry() continues into, by vector.
static uint32_t s_rom_fault_vectors[FAULT_VECTOR_COUNT]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static inline uint32_t read_reg(uintptr_t addr) {
return *(const volatile uint32_t *) addr; // NOLINT(performance-no-int-to-ptr)
}
// Returns the ROM handler to continue into. pc, lr and sp stay 0 when the frame is outside DTCM.
extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *frame, uint32_t vector) {
uint32_t cfsr = read_reg(SCB_CFSR);
uint32_t address = 0;
if (cfsr & CFSR_BFARVALID) {
address = read_reg(SCB_BFAR);
} else if (cfsr & CFSR_MMARVALID) {
address = read_reg(SCB_MMFAR);
}
s_record.reason = vector + CRASH_REASON_CORTEX_M_OFFSET;
s_record.fault_status = cfsr;
s_record.fault_address = address;
auto frame_addr = (uint32_t) frame;
uint32_t sp = 0;
if (frame_addr >= SRAM_START && frame_addr <= SRAM_END - FRAME_WORDS * 4 && (frame_addr & 3) == 0) {
s_record.pc = frame[FRAME_PC];
s_record.lr = frame[FRAME_LR];
sp = frame_addr + FRAME_WORDS * 4 + ((frame[FRAME_XPSR] & XPSR_STACK_ALIGNED) ? 4 : 0);
} else {
s_record.pc = 0;
s_record.lr = 0;
}
s_record.sp = sp;
finish_record(sp, SRAM_START, SRAM_END);
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
return s_rom_fault_vectors[vector - FIRST_FAULT_VECTOR];
}
// Fault vector: calls esphome_record_fault(frame, exception number), then jumps to the ROM handler it returns
// with r0-r3 and lr restored. Asm because C would push onto MSP before the frame is found, and the ROM handler
// reads EXC_RETURN from lr.
extern "C" __attribute__((naked, used)) void esphome_fault_entry() {
__asm volatile("push {r0-r3, r4, lr} \n" // r4 only keeps the stack 8-byte aligned
"tst lr, #4 \n"
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"it eq \n"
"addeq r0, r0, #24 \n" // MSP: skip the six words pushed above
"mrs r1, ipsr \n"
"ubfx r1, r1, #0, #9 \n" // exception number, 3 to 7
"bl esphome_record_fault \n"
"mov r12, r0 \n" // ROM handler address
"pop {r0-r3, r4, lr} \n"
"bx r12 \n");
}
void crash_handler_install() {
auto *vectors = (uint32_t *) read_reg(SCB_VTOR); // NOLINT(performance-no-int-to-ptr)
for (uint32_t i = 0; i < FAULT_VECTOR_COUNT; i++) {
s_rom_fault_vectors[i] = vectors[FIRST_FAULT_VECTOR + i];
vectors[FIRST_FAULT_VECTOR + i] = (uint32_t) &esphome_fault_entry;
}
}
#endif // USE_LIBRETINY_VARIANT_RTL8720C
} // namespace esphome::libretiny } // namespace esphome::libretiny
#endif // USE_LIBRETINY_CRASH_HANDLER #endif // USE_LIBRETINY_CRASH_HANDLER
#endif // USE_LIBRETINY #endif // USE_BK72XX
+2 -7
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#ifdef USE_LIBRETINY #ifdef USE_BK72XX
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
@@ -14,12 +14,7 @@ void crash_handler_log();
/// Returns true if crash data was found this boot, reading it first if needed. /// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data(); bool crash_handler_has_data();
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
/// Put the crash recorder in front of the SDK's fault handlers.
void crash_handler_install();
#endif
} // namespace esphome::libretiny } // namespace esphome::libretiny
#endif // USE_LIBRETINY_CRASH_HANDLER #endif // USE_LIBRETINY_CRASH_HANDLER
#endif // USE_LIBRETINY #endif // USE_BK72XX
@@ -156,7 +156,6 @@ COMPONENT_CODEOWNERS = {
# Extra imports per component; the CLI looks up process_stacktrace on the platform package. # Extra imports per component; the CLI looks up process_stacktrace on the platform package.
COMPONENT_EXTRA_IMPORTS = { COMPONENT_EXTRA_IMPORTS = {
"bk72xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401", "bk72xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
"rtl87xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
} }
-4
View File
@@ -1,7 +1,6 @@
#ifdef USE_LIBRETINY #ifdef USE_LIBRETINY
#include "core.h" #include "core.h"
#include "crash_handler.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "preferences.h" #include "preferences.h"
@@ -28,9 +27,6 @@ namespace esphome {
void arch_init() { void arch_init() {
libretiny::setup_preferences(); libretiny::setup_preferences();
lt_wdt_enable(10000L); lt_wdt_enable(10000L);
#if defined(USE_LIBRETINY_CRASH_HANDLER) && defined(USE_LIBRETINY_VARIANT_RTL8720C)
libretiny::crash_handler_install();
#endif
#ifdef USE_BK72XX #ifdef USE_BK72XX
// BK72xx SDK creates the main Arduino task at priority 3, which is lower than // BK72xx SDK creates the main Arduino task at priority 3, which is lower than
// all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop
+1 -32
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine
import logging import logging
from typing import Any, Literal, NamedTuple from typing import Any, Literal, NamedTuple
from esphome import automation, pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import uart from esphome.components import uart
from esphome.components.const import CONF_ROLE from esphome.components.const import CONF_ROLE
@@ -170,37 +170,6 @@ def reject_broadcast_options_for_unicast(
return validator return validator
# Raw PDU; valid only during the automation.
PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
_request_on_request_slot = cg.slot_counter("MODBUS_ON_REQUEST_COUNT")
async def register_on_request_automation(hub: MockObj, config: ConfigType) -> None:
"""Run an automation for every request the hub reads (server) or sends (client)."""
_request_on_request_slot(str(hub))
await automation.build_callback_automation(
hub,
"add_on_request_callback",
[(cg.uint8, "address"), (PDU_SPAN, "request")],
config,
)
_request_on_response_slot = cg.slot_counter("MODBUS_ON_RESPONSE_COUNT")
async def register_on_response_automation(hub: MockObj, config: ConfigType) -> None:
"""Run an automation for every response the hub gets (client), or sends or sees from another device (server)."""
_request_on_response_slot(str(hub))
await automation.build_callback_automation(
hub,
"add_on_response_callback",
[(cg.uint8, "address"), (PDU_SPAN, "request"), (PDU_SPAN, "response")],
config,
)
def reject_inapplicable_command_options( def reject_inapplicable_command_options(
pdu_key: str, pdu_key: str,
) -> Callable[[ConfigType], ConfigType]: ) -> Callable[[ConfigType], ConfigType]:
+11 -47
View File
@@ -319,22 +319,13 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
// Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends // Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends
// a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked. // a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked.
// This requires copying the frame data out of the rx buffer beforehand. // This requires copying the frame data to a local buffer beforehand.
const uint16_t pdu_len = frame_length - 3; // less the address byte and the CRC uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
#ifdef MODBUS_ON_RESPONSE_COUNT uint16_t data_len = frame_length - 2 - data_offset;
uint8_t *pdu_buffer = this->request_; uint8_t data_buffer[MAX_FRAME_SIZE] = {};
this->request_len_ = static_cast<uint8_t>(pdu_len); std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len);
#else std::span<const uint8_t> data(data_buffer, data_len);
uint8_t pdu_buffer[MAX_PDU_SIZE];
#endif
std::memcpy(pdu_buffer, this->rx_buffer_.data() + 1, pdu_len);
const std::span<const uint8_t> pdu(pdu_buffer, pdu_len);
const std::span<const uint8_t> data = pdu.subspan(1); // after the function code
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
#ifdef MODBUS_ON_REQUEST_COUNT
// Before a device here answers, so the monitor sees the bus in order.
this->request_callback_.call(address, pdu);
#endif
if (address == BROADCAST_ADDRESS) { if (address == BROADCAST_ADDRESS) {
// Keep the unicast response buffers out of the broadcast call chain. // Keep the unicast response buffers out of the broadcast call chain.
@@ -398,27 +389,15 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address, ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address,
us_since_send(this->last_modbus_byte_, this->last_send_)); us_since_send(this->last_modbus_byte_, this->last_send_));
} }
#ifdef MODBUS_ON_RESPONSE_COUNT
// After the terminal, so the handler can queue the same request again
this->response_callback_.call(address, cmd->frame.pdu(), pdu);
#endif
} }
void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) { void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t>) {
if (this->find_device_(address) != nullptr) { if (this->find_device_(address) != nullptr) {
ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address);
} }
if (this->expecting_peer_response_ == address) { if (this->expecting_peer_response_ == address) {
ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address); ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address);
#ifdef MODBUS_ON_RESPONSE_COUNT
if (helpers::pdu_function_code(pdu) == this->request_[0]) {
this->response_callback_.call(address, std::span<const uint8_t>(this->request_, this->request_len_), pdu);
} else {
ESP_LOGV(TAG, "Response from peer %" PRIu8 " has function 0x%02X, request had 0x%02X; not paired", address,
pdu[0], this->request_[0]);
}
#endif
} else { } else {
ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address); ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address);
} }
@@ -869,13 +848,9 @@ void ModbusClientHub::send_next_frame_() {
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected"); ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected");
cmd->complete_broadcast(); cmd->complete_broadcast();
this->sweep_needed_ = true; this->sweep_needed_ = true;
} else { return;
this->waiting_for_response_ = true;
} }
#ifdef MODBUS_ON_REQUEST_COUNT this->waiting_for_response_ = true;
// After complete_broadcast(), so a broadcast queued from the handler is not merged into this one.
this->request_callback_.call(cmd->frame.address(), cmd->frame.pdu());
#endif
} }
void ModbusClientHub::dump_config() { void ModbusClientHub::dump_config() {
@@ -1243,7 +1218,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1); this->deferred_payload_len_ - 1);
this->deferred_payload_len_ = 0; this->deferred_payload_len_ = 0;
if (!this->send_reply_(frame)) { if (!this->send_frame_(frame)) {
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked"); ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
} }
}); });
@@ -1251,22 +1226,11 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
} }
ModbusFrame frame(payload[0], payload + 1, len - 1); ModbusFrame frame(payload[0], payload + 1, len - 1);
if (!this->send_reply_(frame)) { if (!this->send_frame_(frame)) {
ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay"); ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay");
} }
} }
bool ModbusServerHub::send_reply_(const ModbusFrame &frame) {
if (!this->send_frame_(frame))
return false;
#ifdef MODBUS_ON_RESPONSE_COUNT
// Only once on the wire: a reply that is dropped is not reported.
this->response_callback_.call(frame.address(), std::span<const uint8_t>(this->request_, this->request_len_),
frame.pdu());
#endif
return true;
}
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
size_t bytes = this->rx_buffer_.size(); size_t bytes = this->rx_buffer_.size();
if (bytes_to_clear > 0 && bytes >= bytes_to_clear) if (bytes_to_clear > 0 && bytes >= bytes_to_clear)
-29
View File
@@ -1,7 +1,6 @@
#pragma once #pragma once
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/uart/uart.h" #include "esphome/components/uart/uart.h"
#include "esphome/components/modbus/modbus_definitions.h" #include "esphome/components/modbus/modbus_definitions.h"
@@ -14,7 +13,6 @@
#include <vector> #include <vector>
#include <deque> #include <deque>
#include <optional> #include <optional>
#include <utility>
namespace esphome::modbus { namespace esphome::modbus {
@@ -60,19 +58,6 @@ class Modbus : public uart::UARTDevice, public Component {
virtual bool tx_blocked(); virtual bool tx_blocked();
void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; }
#ifdef MODBUS_ON_REQUEST_COUNT
/// Every request read (server, before a device answers) or sent (client, retries included); PDU valid in the call.
template<typename F> void add_on_request_callback(F &&callback) {
this->request_callback_.add(std::forward<F>(callback));
}
#endif
#ifdef MODBUS_ON_RESPONSE_COUNT
/// Every response received (client) or sent or seen (server); PDUs valid during the call. A server pairs a peer's
/// reply with the last request by address and function code only.
template<typename F> void add_on_response_callback(F &&callback) {
this->response_callback_.add(std::forward<F>(callback));
}
#endif
protected: protected:
void receive_bytes_(); void receive_bytes_();
@@ -101,13 +86,6 @@ class Modbus : public uart::UARTDevice, public Component {
GPIOPin *flow_control_pin_{nullptr}; GPIOPin *flow_control_pin_{nullptr};
std::vector<uint8_t> rx_buffer_; std::vector<uint8_t> rx_buffer_;
#ifdef MODBUS_ON_REQUEST_COUNT
StaticCallbackManager<MODBUS_ON_REQUEST_COUNT, void(uint8_t, std::span<const uint8_t>)> request_callback_;
#endif
#ifdef MODBUS_ON_RESPONSE_COUNT
StaticCallbackManager<MODBUS_ON_RESPONSE_COUNT, void(uint8_t, std::span<const uint8_t>, std::span<const uint8_t>)>
response_callback_;
#endif
}; };
class ModbusClientDevice; class ModbusClientDevice;
@@ -382,18 +360,11 @@ class ModbusServerHub : public Modbus {
uint16_t number_of_registers, const RegisterValues &registers, uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len); std::span<uint8_t> response_buffer, uint16_t &response_len);
void send_raw_(const uint8_t *payload, uint16_t len); void send_raw_(const uint8_t *payload, uint16_t len);
// Every reply of this hub goes on the wire here, so on_response sees each one once, exceptions included.
bool send_reply_(const ModbusFrame &frame);
// Sends and logs the exception reply when status holds one; returns true if the request was rejected. // Sends and logs the exception reply when status holds one; returns true if the request was rejected.
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status); bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0}; uint8_t expecting_peer_response_{0};
#ifdef MODBUS_ON_RESPONSE_COUNT
// The last request, for on_response with its reply from a device here or another one.
uint8_t request_len_{0};
uint8_t request_[MAX_PDU_SIZE];
#endif
std::vector<ModbusServerDevice *> devices_; std::vector<ModbusServerDevice *> devices_;
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
+1 -1
View File
@@ -72,7 +72,7 @@ ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first, # Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler. # then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
_ADDRESS_ARG = (cg.uint8, "address") _ADDRESS_ARG = (cg.uint8, "address")
_PDU_SPAN = modbus.PDU_SPAN _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit # The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
# (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result. # (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result.
@@ -1,38 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import CONF_ON_RESPONSE
from esphome.types import ConfigType
CODEOWNERS = ["@Bascht74"]
DEPENDENCIES = ["modbus"]
DOMAIN = "modbus_monitor"
MULTI_CONF = True
CONF_ON_REQUEST = "on_request"
_HANDLER = automation.validate_synchronous_automation(
"modbus_monitor handlers", "the request/response data", single=True
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.Modbus),
cv.Optional(CONF_ON_REQUEST): _HANDLER,
cv.Optional(CONF_ON_RESPONSE): _HANDLER,
}
),
cv.has_at_least_one_key(CONF_ON_REQUEST, CONF_ON_RESPONSE),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
if on_request := config.get(CONF_ON_REQUEST):
await modbus.register_on_request_automation(hub, on_request)
if on_response := config.get(CONF_ON_RESPONSE):
await modbus.register_on_response_automation(hub, on_response)
-5
View File
@@ -1302,9 +1302,4 @@ def run_compile(args, config: ConfigType) -> bool:
if not run_command_ok(genpkg_cmd, env=env, stream_output=True): if not run_command_ok(genpkg_cmd, env=env, stream_output=True):
raise EsphomeError("Failed to create the DFU package") raise EsphomeError("Failed to create the DFU package")
from esphome.build_helpers.idedata import warn_if_idedata_missing
from .toolchain import get_idedata
warn_if_idedata_missing(get_idedata)
return True return True
+1 -19
View File
@@ -1,8 +1,4 @@
"""The native build backend of the nRF52 sdk-nrf toolchain. """Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis."""
The build itself runs from the platform's ``run_compile`` hook; this module
gives the binutils, the linked image and idedata of that build.
"""
from pathlib import Path from pathlib import Path
@@ -30,17 +26,3 @@ def get_elf_path() -> Path:
if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2): if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2):
return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf") return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf")
return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf") return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf")
def get_idedata() -> dict | None:
"""Derive idedata from the app's compile_commands.json, next to its
``zephyr/`` output directory. Returns None if nothing has been built yet."""
from esphome.build_helpers.idedata import load_or_build_idedata
elf = get_elf_path()
# No launcher: Zephyr's CMake leaves ccache out of the compile database
return load_or_build_idedata(
elf.parent.parent / "compile_commands.json",
elf,
CORE.relative_internal_path("idedata", f"{CORE.name}.json"),
)
@@ -12,7 +12,7 @@ void PipsolarOutput::write_state(float state) {
if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) { if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) {
ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state); ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state);
this->parent_->queue_command(tmp); this->parent_->queue_command(std::string(tmp));
} else { } else {
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp); ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
} }
+3 -9
View File
@@ -224,23 +224,17 @@ bool Pipsolar::send_next_poll_() {
return false; return false;
} }
void Pipsolar::write_switch(bool state, const char *on, const char *off) { void Pipsolar::queue_command(const std::string &command) {
const char *command = state ? on : off;
if (command != nullptr)
this->queue_command(command);
}
void Pipsolar::queue_command(const char *command) {
uint8_t next_position = command_queue_position_; uint8_t next_position = command_queue_position_;
for (uint8_t i = 0; i < COMMAND_QUEUE_LENGTH; i++) { for (uint8_t i = 0; i < COMMAND_QUEUE_LENGTH; i++) {
uint8_t testposition = (next_position + i) % COMMAND_QUEUE_LENGTH; uint8_t testposition = (next_position + i) % COMMAND_QUEUE_LENGTH;
if (command_queue_[testposition].empty()) { if (command_queue_[testposition].empty()) {
command_queue_[testposition] = command; command_queue_[testposition] = command;
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command, testposition); ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command.c_str(), testposition);
return; return;
} }
} }
ESP_LOGD(TAG, "Command queue full dropping command: %s", command); ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str());
} }
void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) { void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) {
+1 -4
View File
@@ -194,10 +194,7 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent {
PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI) PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI)
PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI) PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI)
void queue_command(const char *command); void queue_command(const std::string &command);
void queue_command(const std::string &command) { this->queue_command(command.c_str()); }
// A switch write; off is nullptr when the inverter has no off command
void write_switch(bool state, const char *on, const char *off);
void setup() override; void setup() override;
void loop() override; void loop() override;
void dump_config() override; void dump_config() override;
+24 -58
View File
@@ -1,14 +1,10 @@
from collections.abc import Callable
from typing import NamedTuple
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import switch from esphome.components import switch
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ICON_POWER from esphome.const import ICON_POWER
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType from esphome.types import ConfigType
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns
DEPENDENCIES = ["uart"] DEPENDENCIES = ["uart"]
@@ -20,55 +16,21 @@ CONF_INPUT_VOLTAGE_RANGE = "input_voltage_range"
CONF_PV_OK_CONDITION_FOR_PARALLEL = "pv_ok_condition_for_parallel" CONF_PV_OK_CONDITION_FOR_PARALLEL = "pv_ok_condition_for_parallel"
CONF_PV_POWER_BALANCE = "pv_power_balance" CONF_PV_POWER_BALANCE = "pv_power_balance"
class _PipSwitch(NamedTuple):
setter: Callable[[MockObj], MockObj]
on: str
off: str | None # None when the inverter has no off command
# Each switch's hub setter and its on and off commands
TYPES = { TYPES = {
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: _PipSwitch( CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: ("POP00", None),
lambda hub: hub.set_output_source_priority_utility_switch, CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: ("POP01", None),
"POP00", CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: ("POP02", None),
None, CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: ("POP03", None),
), CONF_INPUT_VOLTAGE_RANGE: ("PGR01", "PGR00"),
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: _PipSwitch( CONF_PV_OK_CONDITION_FOR_PARALLEL: ("PPVOKC1", "PPVOKC0"),
lambda hub: hub.set_output_source_priority_solar_switch, CONF_PV_POWER_BALANCE: ("PSPB1", "PSPB0"),
"POP01",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: _PipSwitch(
lambda hub: hub.set_output_source_priority_battery_switch,
"POP02",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: _PipSwitch(
lambda hub: hub.set_output_source_priority_hybrid_switch,
"POP03",
None,
),
CONF_INPUT_VOLTAGE_RANGE: _PipSwitch(
lambda hub: hub.set_input_voltage_range_switch,
"PGR01",
"PGR00",
),
CONF_PV_OK_CONDITION_FOR_PARALLEL: _PipSwitch(
lambda hub: hub.set_pv_ok_condition_for_parallel_switch,
"PPVOKC1",
"PPVOKC0",
),
CONF_PV_POWER_BALANCE: _PipSwitch(
lambda hub: hub.set_pv_power_balance_switch,
"PSPB1",
"PSPB0",
),
} }
PipsolarSwitch = pipsolar_ns.class_("PipsolarSwitch", switch.Switch, cg.Component)
PIPSWITCH_SCHEMA = switch.switch_schema( PIPSWITCH_SCHEMA = switch.switch_schema(
switch.ApplySwitch, icon=ICON_POWER, block_inverted=True PipsolarSwitch, icon=ICON_POWER, block_inverted=True
) ).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
{cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES} {cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES}
@@ -76,11 +38,15 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_PIPSOLAR_ID]) paren = await cg.get_variable(config[CONF_PIPSOLAR_ID])
switches = switch.sub_switches(config, parent=hub)
# The hub polls the inverter and publishes each switch's state for type, (on, off) in TYPES.items():
for key, sw in TYPES.items(): if type in config:
off_arg = "nullptr" if sw.off is None else f'"{sw.off}"' conf = config[type]
await switches( var = await switch.new_switch(conf)
key, sw.setter(hub), apply=f'write_switch(state, "{sw.on}", {off_arg})' await cg.register_component(var, conf)
) cg.add(getattr(paren, f"set_{type}_switch")(var))
cg.add(var.set_parent(paren))
cg.add(var.set_on_command(on))
if off is not None:
cg.add(var.set_off_command(off))
@@ -0,0 +1,17 @@
#include "pipsolar_switch.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::pipsolar {
ESPHOME_LOG_TAG(TAG, "pipsolar.switch");
void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); }
void PipsolarSwitch::write_state(bool state) {
const char *command = state ? this->on_command_ : this->off_command_;
if (command != nullptr) {
this->parent_->queue_command(command);
}
}
} // namespace esphome::pipsolar
@@ -0,0 +1,26 @@
#pragma once
#include "../pipsolar.h"
#include "esphome/components/switch/switch.h"
#include "esphome/core/component.h"
namespace esphome::pipsolar {
class Pipsolar;
class PipsolarSwitch final : public switch_::Switch, public Component {
public:
void set_parent(Pipsolar *parent) { this->parent_ = parent; }
void set_on_command(const char *command) { this->on_command_ = command; }
void set_off_command(const char *command) { this->off_command_ = command; }
/// Prevent accidental use of std::string which would dangle
void set_on_command(const std::string &command) = delete;
void set_off_command(const std::string &command) = delete;
void dump_config() override;
protected:
void write_state(bool state) override;
const char *on_command_{nullptr};
const char *off_command_{nullptr};
Pipsolar *parent_;
};
} // namespace esphome::pipsolar
-1
View File
@@ -29,7 +29,6 @@ from esphome.components.libretiny.const import (
) )
from esphome.core import CORE from esphome.core import CORE
from esphome.cpp_generator import MockObj from esphome.cpp_generator import MockObj
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
from esphome.types import ConfigType from esphome.types import ConfigType
from .boards import RTL87XX_BOARD_PINS, RTL87XX_BOARDS from .boards import RTL87XX_BOARD_PINS, RTL87XX_BOARDS
@@ -1,6 +1,7 @@
#include "ota_web_server.h" #include "ota_web_server.h"
#ifdef USE_WEBSERVER_OTA #ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -8,37 +9,73 @@
#include "esphome/components/captive_portal/captive_portal.h" #include "esphome/components/captive_portal/captive_portal.h"
#endif #endif
#if USE_ESP32
using PlatformString = std::string;
#elif USE_ARDUINO
using PlatformString = String;
#endif
namespace esphome::web_server { namespace esphome::web_server {
ESPHOME_LOG_TAG(TAG, "web_server.ota"); ESPHOME_LOG_TAG(TAG, "web_server.ota");
bool WebServerOTAComponent::canHandle(AsyncWebServerRequest *request) const { class OTARequestHandler : public AsyncWebHandler {
if (request->method() != HTTP_POST) public:
return false; OTARequestHandler(WebServerOTAComponent *parent) : parent_(parent) {}
// Check if this is an OTA update request void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data,
size_t len, bool final) override;
bool canHandle(AsyncWebServerRequest *request) const override {
if (request->method() != HTTP_POST)
return false;
// Check if this is an OTA update request
#ifdef USE_ESP32 #ifdef USE_ESP32
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
bool is_ota_request = request->url_to(url_buf) == "/update"; bool is_ota_request = request->url_to(url_buf) == "/update";
#else #else
bool is_ota_request = request->url() == ESPHOME_F("/update"); bool is_ota_request = request->url() == ESPHOME_F("/update");
#endif #endif
#if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL) #if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL)
// IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component // IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
// Captive portal can still perform OTA updates - check if request is from active captive portal // Captive portal can still perform OTA updates - check if request is from active captive portal
// Note: global_captive_portal is the standard way components communicate in ESPHome // Note: global_captive_portal is the standard way components communicate in ESPHome
return is_ota_request && captive_portal::global_captive_portal != nullptr && return is_ota_request && captive_portal::global_captive_portal != nullptr &&
captive_portal::global_captive_portal->is_active(); captive_portal::global_captive_portal->is_active();
#elif defined(USE_WEBSERVER_OTA_DISABLED) #elif defined(USE_WEBSERVER_OTA_DISABLED)
// OTA disabled for web_server and no captive portal compiled in // OTA disabled for web_server and no captive portal compiled in
return false; return false;
#else #else
// OTA enabled for web_server // OTA enabled for web_server
return is_ota_request; return is_ota_request;
#endif #endif
} }
void WebServerOTAComponent::report_ota_progress_(AsyncWebServerRequest *request) { // NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() const override { return false; }
protected:
void report_ota_progress_(AsyncWebServerRequest *request);
void schedule_ota_reboot_();
void ota_init_(const char *filename);
/// Abort and drop the backend, releasing the lock.
void abort_session_();
/// Reply "busy" without touching the running upload.
void refuse_(AsyncWebServerRequest *request);
uint32_t last_ota_progress_{0};
uint32_t ota_read_length_{0};
WebServerOTAComponent *parent_;
bool ota_success_{false};
private:
ota::OTABackendPtr ota_backend_{nullptr};
/// Request that owns ota_backend_.
const AsyncWebServerRequest *ota_request_{nullptr};
const AsyncWebServerRequest *refused_request_{nullptr};
};
void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
const uint32_t now = millis(); const uint32_t now = millis();
if (now - this->last_ota_progress_ > 1000) { if (now - this->last_ota_progress_ > 1000) {
float percentage = 0.0f; float percentage = 0.0f;
@@ -54,28 +91,28 @@ void WebServerOTAComponent::report_ota_progress_(AsyncWebServerRequest *request)
} }
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
// Report progress - use notify_state_deferred_ since we're in web server task // Report progress - use notify_state_deferred_ since we're in web server task
this->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0); this->parent_->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0);
#endif #endif
this->last_ota_progress_ = now; this->last_ota_progress_ = now;
} }
} }
void WebServerOTAComponent::schedule_ota_reboot_() { void OTARequestHandler::schedule_ota_reboot_() {
ESP_LOGI(TAG, "OTA update successful!"); ESP_LOGI(TAG, "OTA update successful!");
this->set_timeout(100, []() { this->parent_->set_timeout(100, []() {
ESP_LOGI(TAG, "Performing OTA reboot now"); ESP_LOGI(TAG, "Performing OTA reboot now");
App.safe_reboot(); App.safe_reboot();
}); });
} }
void WebServerOTAComponent::ota_init_(const char *filename) { void OTARequestHandler::ota_init_(const char *filename) {
ESP_LOGI(TAG, "OTA Update Start: %s", filename); ESP_LOGI(TAG, "OTA Update Start: %s", filename);
this->ota_read_length_ = 0; this->ota_read_length_ = 0;
this->ota_success_ = false; this->ota_success_ = false;
} }
void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index,
uint8_t *data, size_t len, bool final) { uint8_t *data, size_t len, bool final) {
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
// First byte of a new upload: index==0 with actual data. (web_server_idf // First byte of a new upload: index==0 with actual data. (web_server_idf
@@ -93,7 +130,8 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
if (!this->ota_backend_) { if (!this->ota_backend_) {
ESP_LOGE(TAG, "Failed to create OTA backend"); ESP_LOGE(TAG, "Failed to create OTA backend");
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN)); this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f,
static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN));
#endif #endif
return; return;
} }
@@ -115,14 +153,14 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
// Notify OTA started - use notify_state_deferred_ since we're in web server task // Notify OTA started - use notify_state_deferred_ since we're in web server task
this->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0);
#endif #endif
if (error_code != ota::OTA_RESPONSE_OK) { if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGE(TAG, "OTA begin failed: %d", error_code); ESP_LOGE(TAG, "OTA begin failed: %d", error_code);
this->ota_backend_.reset(); this->ota_backend_.reset();
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code)); this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif #endif
return; return;
} }
@@ -150,7 +188,7 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
this->ota_backend_.reset(); this->ota_backend_.reset();
this->ota_request_ = nullptr; this->ota_request_ = nullptr;
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code)); this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif #endif
return; return;
} }
@@ -171,7 +209,7 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
this->ota_success_ = true; this->ota_success_ = true;
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
// Report completion before reboot - use notify_state_deferred_ since we're in web server task // Report completion before reboot - use notify_state_deferred_ since we're in web server task
this->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0); this->parent_->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0);
#endif #endif
this->schedule_ota_reboot_(); this->schedule_ota_reboot_();
} else { } else {
@@ -179,7 +217,7 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
// Reset the platform updater before the lock is released. // Reset the platform updater before the lock is released.
this->ota_backend_->abort(); this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
this->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code)); this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif #endif
} }
this->ota_backend_.reset(); this->ota_backend_.reset();
@@ -187,16 +225,16 @@ void WebServerOTAComponent::handleUpload(AsyncWebServerRequest *request, const P
} }
} }
void WebServerOTAComponent::abort_session_() { void OTARequestHandler::abort_session_() {
this->ota_backend_->abort(); this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
this->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
#endif #endif
this->ota_backend_.reset(); this->ota_backend_.reset();
this->ota_request_ = nullptr; this->ota_request_ = nullptr;
} }
void WebServerOTAComponent::refuse_(AsyncWebServerRequest *request) { void OTARequestHandler::refuse_(AsyncWebServerRequest *request) {
this->refused_request_ = request; this->refused_request_ = request;
request->onDisconnect([this, request]() { request->onDisconnect([this, request]() {
if (this->refused_request_ == request) if (this->refused_request_ == request)
@@ -204,7 +242,7 @@ void WebServerOTAComponent::refuse_(AsyncWebServerRequest *request) {
}); });
} }
void WebServerOTAComponent::handleRequest(AsyncWebServerRequest *request) { void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) {
AsyncWebServerResponse *response; AsyncWebServerResponse *response;
// Use the ota_success_ flag to determine the actual result // Use the ota_success_ flag to determine the actual result
const bool refused = request == this->refused_request_; const bool refused = request == this->refused_request_;
@@ -239,7 +277,8 @@ void WebServerOTAComponent::setup() {
return; return;
} }
base->add_handler(this); // The handler lives for the life of the process; WebServerBase never destroys its server
base->add_handler(new OTARequestHandler(this)); // NOLINT
} }
void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); } void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); }
@@ -3,44 +3,20 @@
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#ifdef USE_WEBSERVER_OTA #ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend_factory.h" #include "esphome/components/ota/ota_backend.h"
#include "esphome/components/web_server_base/web_server_base.h" #include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
namespace esphome::web_server { namespace esphome::web_server {
class WebServerOTAComponent final : public AsyncWebHandler, public ota::OTAComponent { class WebServerOTAComponent final : public ota::OTAComponent {
public: public:
void setup() override; void setup() override;
void dump_config() override; void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data,
size_t len, bool final) override;
bool canHandle(AsyncWebServerRequest *request) const override;
// NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() const override { return false; }
protected: protected:
void report_ota_progress_(AsyncWebServerRequest *request); friend class OTARequestHandler;
void schedule_ota_reboot_();
void ota_init_(const char *filename);
/// Abort and drop the backend, releasing the lock.
void abort_session_();
/// Reply "busy" without touching the running upload.
void refuse_(AsyncWebServerRequest *request);
uint32_t last_ota_progress_{0};
uint32_t ota_read_length_{0};
bool ota_success_{false};
private:
ota::OTABackendPtr ota_backend_{nullptr};
/// Request that owns ota_backend_.
const AsyncWebServerRequest *ota_request_{nullptr};
const AsyncWebServerRequest *refused_request_{nullptr};
}; };
} // namespace esphome::web_server } // namespace esphome::web_server
@@ -2,7 +2,6 @@ from dataclasses import dataclass
from esphome import automation from esphome import automation
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
from esphome.components.nrf52.framework import ( from esphome.components.nrf52.framework import (
bluetooth_west_projects, bluetooth_west_projects,
include_west_project, include_west_project,
@@ -55,6 +54,8 @@ def request_ble_l2cap_mtu(l2cap_mtu: int) -> None:
zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server")
BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component)
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
CONF_ACCEPT = "accept"
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
cv.Schema( cv.Schema(
+3 -3
View File
@@ -37,9 +37,9 @@ class ErrorFormat(StrEnum):
# Toolchains that drive their build natively and never read platformio.ini. # Toolchains that drive their build natively and never read platformio.ini.
NATIVE_TOOLCHAINS = frozenset( # SDK_NRF is absent on purpose: the zephyr backend keeps consuming
{Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST, Toolchain.SDK_NRF} # platformio_options.
) NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST})
class Platform(StrEnum): class Platform(StrEnum):
+3 -5
View File
@@ -1171,14 +1171,12 @@ class EsphomeCore:
def add_build_unflag(self, build_unflag: str) -> None: def add_build_unflag(self, build_unflag: str) -> None:
# No warning for using_toolchain_arduino: the native ESP8266 build # No warning for using_toolchain_arduino: the native ESP8266 build
# honors build_unflags (token-level, matching PlatformIO). # honors build_unflags (token-level, matching PlatformIO).
if self.using_toolchain_esp_idf or self.using_toolchain_sdk_nrf: if self.using_toolchain_esp_idf:
# The native ESP-IDF and nRF52 build generators do not consume # The native ESP-IDF build generator does not consume build_unflags
# build_unflags
_LOGGER.warning( _LOGGER.warning(
"Build unflag %s is ignored when building with the native " "Build unflag %s is ignored when building with the native "
"'%s' toolchain", "ESP-IDF toolchain",
build_unflag, build_unflag,
self.toolchain.value,
) )
self.build_unflags.add(build_unflag) self.build_unflags.add(build_unflag)
_LOGGER.debug("Adding build unflag: %s", build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag)
+1 -3
View File
@@ -190,8 +190,6 @@
#define USE_MICS_5524_DATASHEET #define USE_MICS_5524_DATASHEET
#define USE_MICRONOVA_WRITER #define USE_MICRONOVA_WRITER
#define MK2PVROUTER_LISTENER_COUNT 1 #define MK2PVROUTER_LISTENER_COUNT 1
#define MODBUS_ON_REQUEST_COUNT 1
#define MODBUS_ON_RESPONSE_COUNT 1
#define NFC_TAG_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1
#define PN532_BINARY_SENSOR_COUNT 1 #define PN532_BINARY_SENSOR_COUNT 1
#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1
@@ -778,7 +776,7 @@
#endif #endif
#ifdef USE_LIBRETINY #ifdef USE_LIBRETINY
#if defined(USE_BK72XX) || defined(USE_LIBRETINY_VARIANT_RTL8720C) #ifdef USE_BK72XX
#define USE_LIBRETINY_CRASH_HANDLER #define USE_LIBRETINY_CRASH_HANDLER
#endif #endif
#define USE_BK72XX_BLE #define USE_BK72XX_BLE
-1
View File
@@ -67,7 +67,6 @@ STACKTRACE_GATES: Final[dict[str, str]] = {
), ),
PLATFORM_RP2: _CRASH_HANDLER_GATE, PLATFORM_RP2: _CRASH_HANDLER_GATE,
PLATFORM_BK72XX: _CRASH_HANDLER_GATE, PLATFORM_BK72XX: _CRASH_HANDLER_GATE,
PLATFORM_RTL87XX: _CRASH_HANDLER_GATE,
PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:", PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:",
} }
+1 -1
View File
@@ -152,7 +152,7 @@ def _addr2line(tool: str, elf: Path, addr: str) -> str:
def process_crash_handler_line( def process_crash_handler_line(
config: ConfigType, line: str, backtrace_state: bool config: ConfigType, line: str, backtrace_state: bool
) -> bool: ) -> bool:
"""Decode a crash handler report with addr2line; the process_stacktrace hook of rp2, bk72xx and rtl87xx.""" """Decode a crash handler report with addr2line; the process_stacktrace hook of rp2 and bk72xx."""
if CRASH_HANDLER_RE.search(line): if CRASH_HANDLER_RE.search(line):
_LOGGER.error("Crash detected - decoding addresses") _LOGGER.error("Crash detected - decoding addresses")
return True return True
-1
View File
@@ -57,7 +57,6 @@ PLATFORM_BUILD_SECONDS = {
"esp32-idf": 55, "esp32-idf": 55,
"esp32-p4-idf": 55, "esp32-p4-idf": 55,
"ln882x-ard": 55, "ln882x-ard": 55,
"rtl8720c-ard": 55,
"esp32-s3-idf": 60, "esp32-s3-idf": 60,
"bk72xx-ard": 70, "bk72xx-ard": 70,
"bk7238-ard": 70, "bk7238-ard": 70,
@@ -1,18 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
ethernet:
type: LAN8720
mdc_pin: GPIO23
mdio_pin: GPIO18
clk:
mode: CLK_EXT_IN
pin: GPIO0
manual_ip:
static_ip: 192.168.1.50
gateway: 192.168.1.1
subnet: 255.255.255.0
dns1: 1.1.1.1
@@ -1,29 +0,0 @@
"""The manual IP table codegen emits must match the ETHERNET_MANUAL_IP layout in ethernet_component.h."""
from collections.abc import Callable
from pathlib import Path
def test_manual_ip_table_layout(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Twenty octets: static IP, gateway, subnet, dns1, dns2 (unset DNS is zero)."""
main_cpp = generate_main(component_config_path("manual_ip.yaml"))
expected = (
"192, 168, 1, 50, 192, 168, 1, 1, 255, 255, 255, 0, 1, 1, 1, 1, 0, 0, 0, 0"
)
assert (
f"esphome::ethernet::ETHERNET_MANUAL_IP[] PROGMEM = {{{expected}}};" in main_cpp
)
assert "set_manual_ip(" not in main_cpp
def test_no_manual_ip_emits_no_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("rmii_defaults.yaml"))
assert "ETHERNET_MANUAL_IP" not in main_cpp
@@ -1,5 +0,0 @@
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
@@ -1,5 +0,0 @@
esphome:
name: test
rtl87xx:
board: bw15
@@ -1,4 +1,4 @@
"""The crash handler is built for BK7238, BK7231N (both with an ITCM linker region) and RTL8720C.""" """Only BK7238 and BK7231N, the BK chips with an ITCM linker region, get the crash handler."""
from __future__ import annotations from __future__ import annotations
@@ -12,14 +12,12 @@ from esphome.core import CORE
@pytest.mark.parametrize( @pytest.mark.parametrize(
("config", "enabled", "bk_hooks"), ("config", "enabled"),
[ [
("bk7238.yaml", True, True), ("bk7238.yaml", True),
("bk72xx.yaml", True, True), # BK7231N ("bk72xx.yaml", True), # BK7231N
("bk7231t.yaml", False, False), ("bk7231t.yaml", False),
("rtl8720c.yaml", True, False), # installed at run time ("ln882x.yaml", False),
("rtl8710b.yaml", False, False),
("ln882x.yaml", False, False),
], ],
) )
def test_crash_handler_family_gate( def test_crash_handler_family_gate(
@@ -27,14 +25,13 @@ def test_crash_handler_family_gate(
component_config_path: Callable[[str], Path], component_config_path: Callable[[str], Path],
config: str, config: str,
enabled: bool, enabled: bool,
bk_hooks: bool,
) -> None: ) -> None:
generate_main(component_config_path(config)) generate_main(component_config_path(config))
defines = {define.name for define in CORE.defines} defines = {define.name for define in CORE.defines}
assert ("USE_LIBRETINY_CRASH_HANDLER" in defines) is enabled assert ("USE_LIBRETINY_CRASH_HANDLER" in defines) is enabled
assert ("USE_CRASH_HANDLER" in defines) is enabled assert ("USE_CRASH_HANDLER" in defines) is enabled
for trap in BK_TRAP_HANDLERS: for trap in BK_TRAP_HANDLERS:
assert (f"-Wl,--wrap={trap}" in CORE.build_flags) is bk_hooks assert (f"-Wl,--wrap={trap}" in CORE.build_flags) is enabled
assert ( assert (
"pre:patch_noinit.py" in CORE.platformio_options["extra_scripts"] "pre:patch_noinit.py" in CORE.platformio_options["extra_scripts"]
) is bk_hooks ) is enabled
@@ -1,22 +0,0 @@
---
esphome:
name: test
logger:
esp32:
board: nodemcu-32s
uart:
- id: only_uart
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 9600
modbus:
- id: only_hub
uart_id: only_uart
modbus_monitor:
- on_request:
- logger.log: "sent"
@@ -1,41 +0,0 @@
"""Tests for the modbus_monitor component."""
import pytest
from esphome import config_validation as cv
from esphome.components import modbus_monitor
from tests.component_tests.helpers import get_define_value
DIR = "tests/component_tests/modbus_monitor"
def test_callbacks_are_added_to_their_hubs(generate_main) -> None:
main_cpp = generate_main(f"{DIR}/test_modbus_monitor.yaml")
assert main_cpp.count("server_hub->add_on_request_callback(") == 2
assert main_cpp.count("client_hub->add_on_request_callback(") == 1
assert "plain_hub->add_on_request_callback(" not in main_cpp
# Sized for the hub with the most blocks.
assert get_define_value("MODBUS_ON_REQUEST_COUNT") == "2"
# The client hub's block has only on_response.
assert main_cpp.count("client_hub->add_on_response_callback(") == 1
assert "server_hub->add_on_response_callback(" not in main_cpp
assert get_define_value("MODBUS_ON_RESPONSE_COUNT") == "1"
def test_hub_is_found_without_modbus_id(generate_main) -> None:
main_cpp = generate_main(f"{DIR}/test_default_id.yaml")
assert "only_hub->add_on_request_callback(" in main_cpp
assert get_define_value("MODBUS_ON_REQUEST_COUNT") == "1"
def test_nothing_is_compiled_in_without_a_block(generate_main) -> None:
main_cpp = generate_main(f"{DIR}/test_no_monitor.yaml")
assert "add_on_request_callback(" not in main_cpp
assert "add_on_response_callback(" not in main_cpp
assert get_define_value("MODBUS_ON_REQUEST_COUNT") is None
assert get_define_value("MODBUS_ON_RESPONSE_COUNT") is None
def test_empty_block_is_rejected() -> None:
with pytest.raises(cv.Invalid, match="on_request"):
modbus_monitor.CONFIG_SCHEMA({"modbus_id": "hub"})
@@ -1,46 +0,0 @@
---
esphome:
name: test
logger:
esp32:
board: nodemcu-32s
uart:
- id: server_uart
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 9600
- id: client_uart
tx_pin: GPIO18
rx_pin: GPIO19
baud_rate: 9600
- id: plain_uart
tx_pin: GPIO21
rx_pin: GPIO22
baud_rate: 9600
modbus:
- id: server_hub
uart_id: server_uart
role: server
- id: client_hub
uart_id: client_uart
- id: plain_hub
uart_id: plain_uart
modbus_monitor:
- modbus_id: server_hub
on_request:
- lambda: |-
ESP_LOGD("test", "Request to %u, function 0x%02X", address, request[0]);
- modbus_id: server_hub
on_request:
- logger.log: "second"
- modbus_id: client_hub
on_request:
- logger.log: "sent"
- modbus_id: client_hub
on_response:
- logger.log: "received"
@@ -1,17 +0,0 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
uart:
- id: only_uart
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 9600
modbus:
- id: only_hub
uart_id: only_uart
role: server
@@ -1,18 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
@@ -1,19 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
restore_mode: DISABLED
@@ -1,45 +0,0 @@
"""Code generation tests for the enabled switch.
The client stays stopped until the hub has an enabled state: codegen sets it when
no enabled switch is configured, and the switch answers from its restore otherwise.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
GenerateMain = Callable[[str | Path], str]
ConfigPath = Callable[[str], Path]
def test_without_switch_codegen_enables_the_client(
generate_main: GenerateMain, component_config_path: ConfigPath
) -> None:
"""With no enabled switch, nothing else would start the client."""
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "->set_enabled(true);" in main_cpp
@pytest.mark.parametrize(
("config", "restore_mode"),
[
("switch_enabled.yaml", "SWITCH_RESTORE_DEFAULT_ON"),
("switch_enabled_disabled_restore.yaml", "SWITCH_RESTORE_DISABLED"),
],
)
def test_switch_answers_the_hub(
generate_main: GenerateMain,
component_config_path: ConfigPath,
config: str,
restore_mode: str,
) -> None:
"""The hub waits for the switch's restore instead of being enabled by codegen."""
main_cpp = generate_main(component_config_path(config))
assert "->set_enabled(true);" not in main_cpp
assert f"sendspin_enabled->set_restore_mode(switch_::{restore_mode});" in main_cpp
assert "App.register_switch(sendspin_enabled," in main_cpp
@@ -1,13 +0,0 @@
logger:
level: VERBOSE
esphome:
on_boot:
- lambda: |-
int x = 100;
x = clamp(x, 50, 90);
assert(x == 90);
x = clamp_at_least(x, 95);
assert(x == 95);
x = clamp_at_most(x, 40);
assert(x == 40);
-14
View File
@@ -1,14 +0,0 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# Code generation sizes the callback storage; the gtests add up to two on_request and two on_response per hub.
cg.add_define("MODBUS_ON_REQUEST_COUNT", 2)
cg.add_define("MODBUS_ON_RESPONSE_COUNT", 2)
# A MULTI_CONF component gets no entry in the host build, so its to_code would never run.
manifest.multi_conf = False
manifest.to_code = to_code_testing
-3
View File
@@ -8,9 +8,6 @@
namespace esphome::modbus::testing { namespace esphome::modbus::testing {
// Copies a PDU so a test can compare it after the span is gone.
inline std::vector<uint8_t> vec(std::span<const uint8_t> pdu) { return {pdu.begin(), pdu.end()}; }
// A UART that discards all writes, for tests that never inspect the wire. // A UART that discards all writes, for tests that never inspect the wire.
class NullUART : public uart::UARTComponent { class NullUART : public uart::UARTComponent {
public: public:
@@ -1,246 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Drives the send step, the timeout and the sweep without waiting for real time.
class MonitorClientHub : public ModbusClientHub {
public:
void send_next_for_test() {
this->send_next_frame_();
this->sweep_();
}
void timeout_for_test() {
this->expire_waiting_();
this->sweep_();
}
bool waiting() const { return this->waiting_for_response_; }
void receive_for_test(uint8_t address, std::span<const uint8_t> pdu) {
this->process_modbus_server_frame(address, pdu);
}
size_t entries() const { return this->tx_buffer_.size(); }
bool tx_blocked() override { return this->blocked || this->ModbusClientHub::tx_blocked(); }
bool blocked{false};
};
// Asks for one retry when no reply comes.
class RetryOnceDevice : public ModbusClientDevice {
public:
using ModbusClientDevice::ModbusClientDevice;
bool on_no_response(std::span<const uint8_t> request_pdu) override { return this->retries_++ == 0; }
int retries_{0};
};
struct Request {
uint8_t address;
std::vector<uint8_t> pdu;
};
struct Response {
uint8_t address;
std::vector<uint8_t> request;
std::vector<uint8_t> response;
};
// Notes how many requests the monitor had seen each time on_sent runs.
class SentDevice : public ModbusClientDevice {
public:
SentDevice(ModbusClientHub *hub, uint8_t address, const std::vector<Request> &seen)
: ModbusClientDevice(hub, address), seen_(seen) {}
void on_sent(std::span<const uint8_t> request_pdu) override { this->seen_at_sent.push_back(this->seen_.size()); }
std::vector<size_t> seen_at_sent;
protected:
const std::vector<Request> &seen_;
};
// Notes how many responses the monitor had seen each time on_response or on_error runs.
class ResponseDevice : public ModbusClientDevice {
public:
ResponseDevice(ModbusClientHub *hub, uint8_t address, const std::vector<Response> &paired)
: ModbusClientDevice(hub, address), paired_(paired) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->paired_at_terminal.push_back(this->paired_.size());
}
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override {
this->paired_at_terminal.push_back(this->paired_.size());
}
std::vector<size_t> paired_at_terminal;
protected:
const std::vector<Response> &paired_;
};
class MonitorClient : public ::testing::Test {
protected:
void SetUp() override {
this->hub_.set_uart_parent(&this->uart_);
this->hub_.setup();
this->hub_.add_on_request_callback(
[this](uint8_t address, std::span<const uint8_t> pdu) { this->seen_.push_back({address, vec(pdu)}); });
this->hub_.add_on_response_callback(
[this](uint8_t address, std::span<const uint8_t> request, std::span<const uint8_t> response) {
this->paired_.push_back({address, vec(request), vec(response)});
});
}
NullUART uart_;
MonitorClientHub hub_;
std::vector<Request> seen_;
std::vector<Response> paired_;
};
constexpr uint8_t READ[] = {0x03, 0x01, 0x00, 0x00, 0x02};
constexpr uint8_t WRITE[] = {0x06, 0x00, 0x10, 0x00, 0x01};
} // namespace
TEST_F(MonitorClient, SeenWhenSentNotWhenQueued) {
ASSERT_TRUE(this->hub_.queue_pdu(0x02, READ));
EXPECT_TRUE(this->seen_.empty());
this->hub_.send_next_for_test();
ASSERT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(this->seen_[0].address, 0x02);
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
EXPECT_TRUE(this->hub_.waiting());
}
// The monitor runs at the end of the send step: after the hub has set its state and after the device's on_sent.
TEST_F(MonitorClient, RequestIsSeenAfterOnSent) {
bool waiting_at_request = false;
this->hub_.add_on_request_callback(
[this, &waiting_at_request](uint8_t, std::span<const uint8_t>) { waiting_at_request = this->hub_.waiting(); });
SentDevice device(&this->hub_, 0x02, this->seen_);
ASSERT_TRUE(device.queue_pdu(READ));
this->hub_.send_next_for_test();
EXPECT_TRUE(waiting_at_request);
ASSERT_EQ(device.seen_at_sent.size(), 1u);
EXPECT_EQ(device.seen_at_sent[0], 0u);
}
TEST_F(MonitorClient, RetryIsSeenAgain) {
RetryOnceDevice device(&this->hub_, 0x02);
ASSERT_TRUE(device.queue_pdu(READ));
this->hub_.send_next_for_test();
this->hub_.timeout_for_test(); // no reply: the device asks for a retry
this->hub_.send_next_for_test();
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[0].pdu, this->seen_[1].pdu);
}
TEST_F(MonitorClient, BroadcastIsSeen) {
ASSERT_TRUE(this->hub_.queue_pdu(BROADCAST_ADDRESS, WRITE));
this->hub_.send_next_for_test();
ASSERT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(this->seen_[0].address, BROADCAST_ADDRESS);
EXPECT_FALSE(this->hub_.waiting());
}
// The broadcast is already complete when the callback runs, so the same request queued from the
// callback is a new entry and is sent, not merged into the finished one.
TEST_F(MonitorClient, RequestQueuedFromTheCallbackIsSent) {
bool queued = false;
this->hub_.add_on_request_callback([this, &queued](uint8_t address, std::span<const uint8_t> pdu) {
if (!queued)
queued = this->hub_.queue_pdu(address, pdu);
});
ASSERT_TRUE(this->hub_.queue_pdu(BROADCAST_ADDRESS, WRITE));
this->hub_.send_next_for_test();
EXPECT_TRUE(queued);
EXPECT_EQ(this->hub_.entries(), 1u);
this->hub_.send_next_for_test();
EXPECT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->hub_.entries(), 0u);
}
// The entry is consumed before the monitor runs, so the same write queued from on_response is accepted.
TEST_F(MonitorClient, SameRequestQueuedFromOnResponseIsAccepted) {
ModbusClientDevice device(&this->hub_, 0x02);
bool queued = false;
this->hub_.add_on_response_callback(
[&device, &queued](uint8_t, std::span<const uint8_t> request, std::span<const uint8_t>) {
if (!queued)
queued = device.queue_pdu(request);
});
ASSERT_TRUE(device.queue_pdu(WRITE));
this->hub_.send_next_for_test();
this->hub_.receive_for_test(0x02, WRITE); // a write single register reply echoes the request
EXPECT_TRUE(queued);
}
TEST_F(MonitorClient, NotSentIsNotSeen) {
this->hub_.blocked = true;
ASSERT_TRUE(this->hub_.queue_pdu(0x02, READ));
this->hub_.send_next_for_test();
EXPECT_TRUE(this->seen_.empty());
}
// A normal reply and an exception reply are both reported with their request.
TEST_F(MonitorClient, ReplyIsSeenWithItsRequest) {
const std::vector<uint8_t> replies[] = {{0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B}, {0x83, 0x02}};
ModbusClientDevice device(&this->hub_, 0x02);
for (const auto &reply : replies) {
ASSERT_TRUE(device.queue_pdu(READ));
this->hub_.send_next_for_test();
this->hub_.receive_for_test(0x02, reply);
this->hub_.send_next_for_test(); // nothing to send: sweeps the answered entry
}
ASSERT_EQ(this->paired_.size(), 2u);
for (size_t i = 0; i < 2; i++) {
EXPECT_EQ(this->paired_[i].address, 0x02);
EXPECT_EQ(this->paired_[i].request, vec(READ));
EXPECT_EQ(this->paired_[i].response, replies[i]);
}
}
// The monitor runs after the device's on_response and on_error, for a normal and an exception reply.
TEST_F(MonitorClient, ResponseIsSeenAfterTheDevice) {
const std::vector<uint8_t> replies[] = {{0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B}, {0x83, 0x02}};
ResponseDevice device(&this->hub_, 0x02, this->paired_);
for (const auto &reply : replies) {
ASSERT_TRUE(device.queue_pdu(READ));
this->hub_.send_next_for_test();
this->hub_.receive_for_test(0x02, reply);
this->hub_.send_next_for_test(); // nothing to send: sweeps the answered entry
}
ASSERT_EQ(device.paired_at_terminal.size(), 2u);
EXPECT_EQ(device.paired_at_terminal[0], 0u);
EXPECT_EQ(device.paired_at_terminal[1], 1u);
EXPECT_EQ(this->paired_.size(), 2u);
}
// A request queued on the hub with no device still has its reply reported, as a raw send with a monitor needs.
TEST_F(MonitorClient, ReplyToADeviceLessRequestIsSeen) {
const std::vector<uint8_t> reply = {0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B};
ASSERT_TRUE(this->hub_.queue_pdu(0x02, READ));
this->hub_.send_next_for_test();
this->hub_.receive_for_test(0x02, reply);
this->hub_.send_next_for_test();
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].address, 0x02);
EXPECT_EQ(this->paired_[0].request, vec(READ));
EXPECT_EQ(this->paired_[0].response, reply);
EXPECT_EQ(this->hub_.entries(), 0u);
}
// A reply from another address interrupts the wait: the expected reply after it is ignored and the wire stays blocked.
TEST_F(MonitorClient, ReplyAfterAnInterruptionIsNotSeen) {
ModbusClientDevice device(&this->hub_, 0x02);
ASSERT_TRUE(device.queue_pdu(READ));
this->hub_.send_next_for_test();
const uint8_t reply[] = {0x03, 0x04, 0x00, 0x2A, 0x00, 0x2B};
this->hub_.receive_for_test(0x05, reply);
this->hub_.receive_for_test(0x02, reply);
EXPECT_TRUE(this->paired_.empty());
EXPECT_TRUE(this->hub_.waiting());
}
} // namespace esphome::modbus::testing
@@ -1,410 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
#include "esphome/components/modbus/modbus_helpers.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::modbus::testing {
namespace {
// Answers register reads with one fixed value.
class RegisterDevice : public ModbusServerDevice {
public:
explicit RegisterDevice(uint8_t address) { this->set_address(address); }
ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers,
RegisterValues &registers) override {
for (uint16_t i = 0; i < number_of_registers; i++)
registers.push_back(0x1234);
return std::nullopt;
}
};
struct Request {
uint8_t address;
std::vector<uint8_t> pdu;
size_t written_before; // bytes the hub had written when the callback ran
};
struct Response {
uint8_t address;
std::vector<uint8_t> request;
std::vector<uint8_t> response;
size_t written_before; // bytes the hub had written when the callback ran
};
// Lets the bus fall silent past the frame timeout without waiting for real time, and holds replies back as if
// the bus were busy.
class QuietServerHub : public ModbusServerHub {
public:
void go_quiet() { this->last_modbus_byte_ -= 1000000; }
bool tx_blocked() override { return this->busy || this->Modbus::tx_blocked(); }
bool reply_held() const { return this->deferred_payload_len_ != 0; }
// DEFERRED_SEND_TIMEOUT_ID in modbus.cpp
void cancel_held_reply() { this->cancel_timeout(static_cast<uint32_t>(0)); }
bool busy{false};
protected:
// No wait before a send, so a held reply is due at once and the tests need not sleep.
int32_t tx_delay_remaining() override { return 0; }
};
class MonitorServer : public ::testing::Test {
protected:
void SetUp() override {
this->hub_.set_uart_parent(&this->uart_);
this->hub_.setup();
this->hub_.add_on_request_callback([this](uint8_t address, std::span<const uint8_t> pdu) {
this->seen_.push_back({address, {pdu.begin(), pdu.end()}, this->uart_.written.size()});
});
this->hub_.add_on_response_callback(
[this](uint8_t address, std::span<const uint8_t> request, std::span<const uint8_t> response) {
this->paired_.push_back({address, vec(request), vec(response), this->uart_.written.size()});
});
}
void receive_(uint8_t address, std::span<const uint8_t> pdu) {
this->uart_.inject_frame(address, pdu);
this->hub_.loop();
}
// A gap on the wire longer than the frame timeout: a partial frame in the buffer is dropped.
void pause_() {
this->hub_.go_quiet();
this->hub_.loop();
}
InjectableUART uart_;
QuietServerHub hub_;
std::vector<Request> seen_;
std::vector<Response> paired_;
};
constexpr uint8_t READ[] = {0x03, 0x00, 0x10, 0x00, 0x01};
constexpr uint8_t READ_REPLY[] = {0x03, 0x02, 0x00, 0x2A};
constexpr uint8_t LOCAL_READ_REPLY[] = {0x03, 0x02, 0x12, 0x34}; // RegisterDevice's answer to READ
constexpr size_t LOCAL_READ_REPLY_FRAME = 1 + sizeof(LOCAL_READ_REPLY) + 2; // with address and CRC
constexpr uint8_t READ_TWO[] = {0x03, 0x00, 0xCA, 0x00, 0x02}; // two registers from 202
constexpr uint8_t READ_TWO_REPLY[] = {0x03, 0x04, 0x10, 0x04, 0xFF, 0x30};
constexpr uint8_t WRITE_1[] = {0x06, 0x00, 0x10, 0x00, 0x01};
constexpr uint8_t WRITE_2[] = {0x06, 0x00, 0x10, 0x00, 0x02};
} // namespace
// A request to a local device is seen too, before the device answers.
TEST_F(MonitorServer, ServedRequestIsSeenBeforeTheReply) {
RegisterDevice device(0x02);
this->hub_.register_device(&device);
this->receive_(0x02, READ);
ASSERT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(this->seen_[0].address, 0x02);
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
EXPECT_EQ(this->seen_[0].written_before, 0u);
EXPECT_EQ(this->uart_.written.size(), LOCAL_READ_REPLY_FRAME);
}
// A reply of a local device is reported with its request once it is on the wire, an exception reply too.
TEST_F(MonitorServer, OwnReplyIsSeenWithItsRequest) {
RegisterDevice device(0x02);
this->hub_.register_device(&device);
const uint8_t read_none[] = {0x03, 0x00, 0x10, 0x00, 0x00}; // zero registers
const uint8_t illegal_value[] = {0x83, 0x03};
this->receive_(0x02, READ);
this->receive_(0x02, read_none);
ASSERT_EQ(this->seen_.size(), 2u);
ASSERT_EQ(this->paired_.size(), 2u);
EXPECT_EQ(this->paired_[0].address, 0x02);
EXPECT_EQ(this->paired_[0].request, vec(READ));
EXPECT_EQ(this->paired_[0].response, vec(LOCAL_READ_REPLY));
EXPECT_EQ(this->paired_[0].written_before, LOCAL_READ_REPLY_FRAME);
EXPECT_EQ(this->paired_[1].address, 0x02);
EXPECT_EQ(this->paired_[1].request, vec(read_none));
EXPECT_EQ(this->paired_[1].response, vec(illegal_value));
EXPECT_EQ(this->paired_[1].written_before, this->uart_.written.size());
}
// The second request arrives while the hub still waits for the first one's reply.
TEST_F(MonitorServer, UnansweredRequestsAreSeenButNotPaired) {
this->receive_(0x05, READ); // absent device
this->receive_(0x06, READ_TWO);
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[0].address, 0x05);
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
EXPECT_EQ(this->seen_[1].address, 0x06);
EXPECT_EQ(this->seen_[1].pdu, vec(READ_TWO));
EXPECT_TRUE(this->paired_.empty());
}
// A single write and its reply have the same shape.
TEST_F(MonitorServer, PeerWriteReplyIsNotSeen) {
this->receive_(0x05, WRITE_1);
this->receive_(0x05, WRITE_1); // the other device's reply
this->receive_(0x05, WRITE_2);
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[0].pdu, vec(WRITE_1));
EXPECT_EQ(this->seen_[1].pdu, vec(WRITE_2));
}
// Known gap: a write and its reply have the same shape, so the next write is taken for a missing reply.
TEST_F(MonitorServer, UnansweredWriteIsPairedWithTheNextWrite) {
this->receive_(0x05, WRITE_1);
this->receive_(0x05, WRITE_2); // no reply came; this request is taken for it
this->receive_(0x05, WRITE_1);
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[0].pdu, vec(WRITE_1));
EXPECT_EQ(this->seen_[1].pdu, vec(WRITE_1));
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].request, vec(WRITE_1));
EXPECT_EQ(this->paired_[0].response, vec(WRITE_2));
}
// Any frame with the shape of a reply ends the wait, so a write to another address is not seen either.
TEST_F(MonitorServer, WriteToAnotherAddressWithoutReplyIsTakenForTheReply) {
this->receive_(0x05, WRITE_1);
this->receive_(0x06, WRITE_2); // no reply came from 0x05; this request is taken for it
this->receive_(0x06, WRITE_1);
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[1].address, 0x06);
EXPECT_EQ(this->seen_[1].pdu, vec(WRITE_1));
}
// Known gap: a read from 0x0300-0x03FF has the shape of a 3-byte read reply, so it is taken for a missing reply.
TEST_F(MonitorServer, UnansweredReadIsPairedWithAReadOf0x03xx) {
const uint8_t read_0x0320[] = {0x03, 0x03, 0x20, 0x00, 0x01};
this->receive_(0x05, READ);
this->receive_(0x05, read_0x0320);
EXPECT_EQ(this->seen_.size(), 1u);
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].request, vec(READ));
EXPECT_EQ(this->paired_[0].response, vec(read_0x0320));
}
// A single write echo is identical to its request, so one seen with no reply expected (the hub started
// mid-exchange) is taken for a request. The next request replaces it.
TEST_F(MonitorServer, WriteEchoSeenFirstIsTakenForARequest) {
this->receive_(0x05, WRITE_1); // the echo; its request was missed
this->receive_(0x05, READ);
this->receive_(0x05, READ_REPLY);
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[0].address, 0x05);
EXPECT_EQ(this->seen_[0].pdu, vec(WRITE_1));
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].request, vec(READ));
}
// A broadcast is never answered, so it is seen but never paired, not even with a reply-shaped frame from address 0.
TEST_F(MonitorServer, BroadcastIsSeenButNeverPaired) {
this->receive_(BROADCAST_ADDRESS, READ);
this->receive_(BROADCAST_ADDRESS, READ_REPLY);
ASSERT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(this->seen_[0].address, BROADCAST_ADDRESS);
EXPECT_EQ(this->seen_[0].pdu, vec(READ));
EXPECT_TRUE(this->paired_.empty());
}
TEST_F(MonitorServer, EveryCallbackRuns) {
int second = 0;
this->hub_.add_on_request_callback([&second](uint8_t, std::span<const uint8_t>) { second++; });
this->receive_(0x05, READ);
EXPECT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(second, 1);
}
// The largest register write: 123 registers, a PDU of 252 bytes.
TEST_F(MonitorServer, LargestRequestIsSeenWhole) {
std::vector<uint8_t> pdu = {0x10, 0x00, 0x00, 0x00, 123, 246};
for (int i = 0; i < 246; i++)
pdu.push_back(static_cast<uint8_t>(i));
this->receive_(0x05, pdu);
ASSERT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(this->seen_[0].pdu, pdu);
}
TEST_F(MonitorServer, UnknownFunctionIsSeen) {
const uint8_t pdu[] = {0x49, 0x02, 0xAA, 0xBB};
this->receive_(0x05, pdu);
ASSERT_EQ(this->seen_.size(), 1u);
EXPECT_EQ(this->seen_[0].pdu, vec(pdu));
}
// The reply of another device is reported raw with the request it answers, which holds the start address
// the reply lacks.
TEST_F(MonitorServer, PeerResponseIsSeenWithItsRequest) {
this->receive_(0x05, READ_TWO);
this->receive_(0x05, READ_TWO_REPLY);
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].address, 0x05);
EXPECT_EQ(this->paired_[0].request, vec(READ_TWO));
EXPECT_EQ(this->paired_[0].response, vec(READ_TWO_REPLY));
EXPECT_EQ(helpers::get_data<uint16_t>(this->paired_[0].request.data(), 1), 202);
const auto payload = helpers::server_pdu_payload(this->paired_[0].response);
ASSERT_EQ(payload.size(), 4u);
EXPECT_EQ(helpers::get_data<uint16_t>(payload.data(), 0), 0x1004);
}
// Only the requests are seen, and each reply is paired with its own request.
TEST_F(MonitorServer, TwoExchangesInARowArePairedSeparately) {
this->receive_(0x05, READ);
this->receive_(0x05, READ_REPLY);
this->receive_(0x06, READ_TWO);
this->receive_(0x06, READ_TWO_REPLY);
ASSERT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->seen_[0].address, 0x05);
EXPECT_EQ(this->seen_[1].address, 0x06);
ASSERT_EQ(this->paired_.size(), 2u);
EXPECT_EQ(this->paired_[0].address, 0x05);
EXPECT_EQ(this->paired_[0].request, vec(READ));
EXPECT_EQ(this->paired_[1].address, 0x06);
EXPECT_EQ(this->paired_[1].request, vec(READ_TWO));
EXPECT_EQ(this->paired_[1].response, vec(READ_TWO_REPLY));
}
// The PDUs are passed on raw, so every function code pairs, exception replies included.
TEST_F(MonitorServer, EveryFunctionCodeIsPaired) {
struct Case {
const char *name;
std::vector<uint8_t> request;
std::vector<uint8_t> response;
};
const std::vector<Case> cases{
{"write single register", {0x06, 0x04, 0x4D, 0x00, 0x01}, {0x06, 0x04, 0x4D, 0x00, 0x01}},
{"write multiple registers", {0x10, 0x04, 0x4D, 0x00, 0x01, 0x02, 0x00, 0x01}, {0x10, 0x04, 0x4D, 0x00, 0x01}},
{"read coils", {0x01, 0x00, 0x13, 0x00, 0x09}, {0x01, 0x02, 0xCD, 0x01}},
{"read discrete inputs", {0x02, 0x00, 0xC4, 0x00, 0x16}, {0x02, 0x03, 0xAC, 0xDB, 0x35}},
{"read/write multiple",
{0x17, 0x00, 0xC8, 0x00, 0x01, 0x01, 0x2C, 0x00, 0x01, 0x02, 0x00, 0x07},
{0x17, 0x02, 0x12, 0x34}},
{"exception", {0x03, 0x00, 0xCA, 0x00, 0x02}, {0x83, 0x02}},
};
for (const Case &c : cases) {
this->paired_.clear();
this->receive_(0x05, c.request);
this->receive_(0x05, c.response);
ASSERT_EQ(this->paired_.size(), 1u) << c.name;
EXPECT_EQ(this->paired_[0].request, c.request) << c.name;
EXPECT_EQ(this->paired_[0].response, c.response) << c.name;
}
}
TEST_F(MonitorServer, RequestIsPairedWithItsFirstResponseOnly) {
this->receive_(0x05, READ);
this->receive_(0x05, READ_REPLY);
this->receive_(0x05, READ_REPLY);
EXPECT_EQ(this->paired_.size(), 1u);
}
// RTU is half duplex: a new request replaces an unanswered one.
TEST_F(MonitorServer, UnansweredPollDoesNotCaptureTheNextDevicesReply) {
this->receive_(0x05, READ); // absent device
this->receive_(0x06, READ_TWO);
this->receive_(0x06, READ_TWO_REPLY);
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].address, 0x06);
EXPECT_EQ(this->paired_[0].request, vec(READ_TWO));
}
TEST_F(MonitorServer, ResponseFromAnotherAddressIsNotPaired) {
this->receive_(0x05, READ);
this->receive_(0x07, READ_REPLY);
EXPECT_TRUE(this->paired_.empty());
}
TEST_F(MonitorServer, ResponseWithAnotherFunctionCodeIsNotPaired) {
const uint8_t input_reply[] = {0x04, 0x02, 0x00, 0x01};
this->receive_(0x05, READ);
this->receive_(0x05, input_reply);
EXPECT_TRUE(this->paired_.empty());
}
// A read reply with no request before it (the hub started mid-exchange) is too short for a request and is
// dropped after the frame timeout; the next exchange pairs normally.
TEST_F(MonitorServer, ResponseSeenFirstIsDropped) {
this->receive_(0x05, READ_REPLY);
this->pause_();
EXPECT_TRUE(this->seen_.empty());
EXPECT_TRUE(this->paired_.empty());
this->receive_(0x05, READ);
this->receive_(0x05, READ_REPLY);
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].request, vec(READ));
}
// With no device of its own the hub never transmits.
TEST_F(MonitorServer, ListenerNeverTransmits) {
this->receive_(0x05, READ);
this->receive_(0x05, READ_REPLY);
this->receive_(0x05, WRITE_1);
EXPECT_EQ(this->seen_.size(), 2u);
EXPECT_EQ(this->paired_.size(), 1u);
EXPECT_TRUE(this->uart_.written.empty());
}
namespace {
// Replies held back by a busy bus go out from a timer, which needs the App scheduler.
class MonitorServerHeld : public MonitorServer {
protected:
void SetUp() override {
// The test main does not construct App as generated code does.
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
// Writable buffers, since a build with a MAC suffix writes it into the name
static char name[] = "test_modbus";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
MonitorServer::SetUp();
this->hub_.register_device(&this->device_);
this->hub_.busy = true;
}
void TearDown() override {
// App outlives each test, so drop a timer still pointing at this hub.
this->hub_.cancel_held_reply();
run_timers();
}
static void run_timers() { App.scheduler.call(millis()); }
RegisterDevice device_{0x02};
};
} // namespace
TEST_F(MonitorServerHeld, HeldReplyIsSeenOnceSent) {
this->receive_(0x02, READ);
ASSERT_TRUE(this->hub_.reply_held());
EXPECT_TRUE(this->paired_.empty());
this->hub_.busy = false;
run_timers();
EXPECT_FALSE(this->hub_.reply_held());
EXPECT_EQ(this->uart_.written.size(), LOCAL_READ_REPLY_FRAME);
ASSERT_EQ(this->paired_.size(), 1u);
EXPECT_EQ(this->paired_[0].request, vec(READ));
EXPECT_EQ(this->paired_[0].response, vec(LOCAL_READ_REPLY));
}
// The next frame arrived first, so the client has moved on: the reply is dropped and not reported.
TEST_F(MonitorServerHeld, ReplyDroppedForTheNextFrameIsNotSeen) {
this->receive_(0x02, READ);
ASSERT_TRUE(this->hub_.reply_held());
this->receive_(0x05, READ);
EXPECT_FALSE(this->hub_.reply_held());
this->hub_.busy = false;
run_timers();
EXPECT_EQ(this->seen_.size(), 2u);
EXPECT_TRUE(this->uart_.written.empty());
EXPECT_TRUE(this->paired_.empty());
}
TEST_F(MonitorServerHeld, ReplyStillBlockedWhenDueIsNotSeen) {
this->receive_(0x02, READ);
ASSERT_TRUE(this->hub_.reply_held());
run_timers();
EXPECT_FALSE(this->hub_.reply_held());
EXPECT_TRUE(this->uart_.written.empty());
EXPECT_TRUE(this->paired_.empty());
}
} // namespace esphome::modbus::testing
@@ -1,12 +0,0 @@
modbus_monitor:
- modbus_id: modbus_server_bus
on_request:
- lambda: |-
ESP_LOGD("modbus_monitor", "Request to %u, function 0x%02X, %u bytes", address, request[0],
(unsigned) request.size());
- on_request:
- logger.log: "Second monitor on the same hub"
on_response:
- lambda: |-
ESP_LOGD("modbus_monitor", "Response from %u to function 0x%02X, %u bytes", address, request[0],
(unsigned) response.size());
@@ -1,3 +0,0 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml
modbus_monitor: !include common.yaml
+52 -3
View File
@@ -16,6 +16,7 @@ import re
import shutil import shutil
import signal import signal
import socket import socket
import subprocess
import sys import sys
import tempfile import tempfile
import time import time
@@ -28,6 +29,7 @@ import pytest_asyncio
import esphome.config import esphome.config
from esphome.core import CORE from esphome.core import CORE
from esphome.helpers import ( from esphome.helpers import (
get_bool_env,
get_usable_cpu_count, get_usable_cpu_count,
read_file, read_file,
rmtree, rmtree,
@@ -39,6 +41,7 @@ from esphome.host.toolchain import get_elf_path
from .const import ( from .const import (
API_CONNECTION_TIMEOUT, API_CONNECTION_TIMEOUT,
DEFAULT_API_PORT, DEFAULT_API_PORT,
GDB_TIMEOUT,
LOCALHOST, LOCALHOST,
PORT_POLL_INTERVAL, PORT_POLL_INTERVAL,
PORT_WAIT_TIMEOUT, PORT_WAIT_TIMEOUT,
@@ -80,6 +83,13 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# CI caches parts of this path; keep in sync with ci.yml integration-tests. # CI caches parts of this path; keep in sync with ci.yml integration-tests.
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests" INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
# Opt-in -g and gdb core backtraces; CI enables both only to re-run failed tests
DEBUG_SYMBOLS = get_bool_env("ESPHOME_INTEGRATION_DEBUG_SYMBOLS")
CORE_DIR = os.environ.get("ESPHOME_INTEGRATION_CORE_DIR")
_CRASH_SIGNALS = frozenset(
{signal.SIGABRT, signal.SIGBUS, signal.SIGFPE, signal.SIGILL, signal.SIGSEGV}
)
def _get_build_env() -> dict[str, str]: def _get_build_env() -> dict[str, str]:
"""Environment for an ``esphome compile`` subprocess.""" """Environment for an ``esphome compile`` subprocess."""
@@ -227,9 +237,12 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
" build_flags:\n" " build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n' ' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts'
' - "-g" # Add debug symbols', + ('\n - "-g" # Add debug symbols' if DEBUG_SYMBOLS else ""),
) )
elif DEBUG_SYMBOLS and " build_flags:\n" in content:
# A fixture with its own flags still gets symbols for a debug re-run
content = content.replace(" build_flags:\n", ' build_flags:\n - "-g"\n', 1)
# Replace external component path placeholder if present # Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content: if "EXTERNAL_COMPONENT_PATH" in content:
@@ -772,16 +785,46 @@ async def _read_stream_lines(
line_callback(decoded_line.rstrip()) line_callback(decoded_line.rstrip())
def _print_crash_backtrace(binary_path: Path, returncode: int, pid: int) -> None:
"""Print a gdb backtrace from the core of a binary that died from a crash signal."""
if CORE_DIR is None or -returncode not in _CRASH_SIGNALS:
return
core = Path(CORE_DIR) / f"core.{pid}"
if not core.is_file() or (gdb := shutil.which("gdb")) is None:
print(f"No core or gdb for the crash of {binary_path} (looked for {core})")
return
try:
result = subprocess.run(
[gdb, "-batch", "-iex", "set debuginfod enabled off"]
+ ["-ex", "thread apply all bt", str(binary_path), str(core)],
capture_output=True,
text=True,
errors="replace",
timeout=GDB_TIMEOUT,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as err:
# Best effort: never replace the test's own failure
print(f"gdb could not read {core}: {err}")
return
print(f"--- Backtrace of {binary_path} ({core}) ---")
print(result.stdout + result.stderr)
@asynccontextmanager @asynccontextmanager
async def run_binary( async def run_binary(
binary_path: Path, binary_path: Path,
line_callback: Callable[[str], None] | None = None, line_callback: Callable[[str], None] | None = None,
symbols_path: Path | None = None,
) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]: ) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]:
"""Run a binary under a PTY, capture log output, and clean up on exit. """Run a binary under a PTY, capture log output, and clean up on exit.
Yields the running ``Process`` and a live list of captured log lines. Yields the running ``Process`` and a live list of captured log lines.
No port wait -- callers that need that should use No port wait -- callers that need that should use
``run_binary_and_wait_for_port``.""" ``run_binary_and_wait_for_port``.
``symbols_path`` is the unstripped build when ``binary_path`` is a stripped
copy; a crash backtrace reads its symbols from it.
"""
# Create a pseudo-terminal to make the binary think it's running interactively # Create a pseudo-terminal to make the binary think it's running interactively
# This is needed because the ESPHome host logger checks isatty() # This is needed because the ESPHome host logger checks isatty()
controller_fd, device_fd = pty.openpty() controller_fd, device_fd = pty.openpty()
@@ -810,6 +853,9 @@ async def run_binary(
) )
if process.returncode is not None: if process.returncode is not None:
_print_crash_backtrace(
symbols_path or binary_path, process.returncode, process.pid
)
raise RuntimeError( raise RuntimeError(
f"Process died immediately with return code {process.returncode}. " f"Process died immediately with return code {process.returncode}. "
"Ensure the binary is valid and can run successfully." "Ensure the binary is valid and can run successfully."
@@ -851,6 +897,9 @@ async def run_binary(
# Last resort: SIGKILL # Last resort: SIGKILL
process.kill() process.kill()
await process.wait() await process.wait()
_print_crash_backtrace(
symbols_path or binary_path, process.returncode, process.pid
)
@asynccontextmanager @asynccontextmanager
+1
View File
@@ -19,3 +19,4 @@ KEY_ACTIVATION_DELAY = 0.5 # seconds
# Process shutdown timeouts # Process shutdown timeouts
SIGINT_TIMEOUT = 5.0 # seconds SIGINT_TIMEOUT = 5.0 # seconds
SIGTERM_TIMEOUT = 2.0 # seconds SIGTERM_TIMEOUT = 2.0 # seconds
GDB_TIMEOUT = 60.0 # seconds, reading a crashed binary's core
@@ -74,22 +74,6 @@ uart_mock:
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
# Then the response speed Fast: the same values with speed 10, acknowledged
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00,
0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
# Then no delay 20 s, rejected with ack 1: the component reads the module's values back
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x14, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00,
0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01]
injections: injections:
# Short frame, state 2 (target), distance 300 -> presence on # Short frame, state 2 (target), distance 300 -> presence on
- delay: 100ms - delay: 100ms
@@ -153,14 +137,6 @@ number:
name: "Max Distance" name: "Max Distance"
no_delay: no_delay:
name: "No Delay" name: "No Delay"
status_reporting_frequency:
name: "Status Reporting Frequency"
select:
- platform: ld2410s
ld2410s_id: ld2410s_dev
response_speed:
name: "Response Speed"
button: button:
- platform: template - platform: template
@@ -92,19 +92,6 @@ binary_sensor:
has_target: has_target:
name: "Has Target" name: "Has Target"
# Commanded before the module answered: the changes are ignored and nothing is published
number:
- platform: ld2410s
ld2410s_id: ld2410s_dev
max_distance:
name: "Max Distance"
select:
- platform: ld2410s
ld2410s_id: ld2410s_dev
response_speed:
name: "Response Speed"
button: button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
+34 -13
View File
@@ -141,9 +141,10 @@ async def _build(
write_yaml_config: ConfigWriter, write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction, compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket], reserved_tcp_port: tuple[int, socket.socket],
) -> tuple[int, int, Path]: ) -> tuple[int, int, Path, Path]:
"""Reserve an OTA port, compile the fixture with it, and release both """Reserve an OTA port, compile the fixture with it, and release both
ports right before the binary is started.""" ports right before the binary is started. Returns the stripped binary to
run and upload, then the unstripped build for crash backtraces."""
api_port, api_socket = reserved_tcp_port api_port, api_socket = reserved_tcp_port
with _reserve_port() as (ota_port, ota_socket): with _reserve_port() as (ota_port, ota_socket):
config_path = await write_yaml_config( config_path = await write_yaml_config(
@@ -152,11 +153,11 @@ async def _build(
binary_path = await compile_esphome(config_path) binary_path = await compile_esphome(config_path)
api_socket.close() api_socket.close()
ota_socket.close() ota_socket.close()
return api_port, ota_port, await _strip_debug(binary_path) return api_port, ota_port, await _strip_debug(binary_path), binary_path
async def _strip_debug(binary_path: Path) -> Path: async def _strip_debug(binary_path: Path) -> Path:
"""The fixtures build with -g; every upload sends the image, so drop the """A debug re-run builds with -g; every upload sends the image, so drop the
debug info (GNU and Apple strip both take -S).""" debug info (GNU and Apple strip both take -S)."""
if (strip := shutil.which("strip")) is None: if (strip := shutil.which("strip")) is None:
_LOGGER.warning( _LOGGER.warning(
@@ -207,6 +208,7 @@ class _Device:
api_port: int api_port: int
ota_port: int ota_port: int
binary_path: Path binary_path: Path
symbols_path: Path
proc: asyncio.subprocess.Process | None = None proc: asyncio.subprocess.Process | None = None
waiter: LineWaiter = field(default_factory=LineWaiter) waiter: LineWaiter = field(default_factory=LineWaiter)
@@ -302,7 +304,9 @@ async def test_host_ota_self_update(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
) )
) )
async with run_binary(dev.binary_path, line_callback=dev.on_log) as ( async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (
proc, proc,
_lines, _lines,
): ):
@@ -341,7 +345,9 @@ async def test_host_ota_deflate(
"""A stream that inflates past the size the client announced.""" """A stream that inflates past the size the client announced."""
return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs) return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as ( async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (
proc, proc,
_lines, _lines,
): ):
@@ -422,7 +428,9 @@ async def test_host_ota_encrypted(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
) )
) )
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (proc, lines):
dev.proc = proc dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await asyncio.get_running_loop().run_in_executor( await asyncio.get_running_loop().run_in_executor(
@@ -458,7 +466,9 @@ async def test_host_ota_encrypted_safe_mode(
) )
# The api port never opens in safe mode, so wait for the log line instead # The api port never opens in safe mode, so wait for the log line instead
force_safe_mode(DEVICE_NAME) force_safe_mode(DEVICE_NAME)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (proc, lines):
dev.proc = proc dev.proc = proc
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
@@ -485,7 +495,9 @@ async def test_host_ota_api_key_offer_with_password(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
) )
) )
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (proc, lines):
dev.proc = proc dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await _wait_for_line(lines, "Encryption: offered") await _wait_for_line(lines, "Encryption: offered")
@@ -535,7 +547,9 @@ async def test_host_ota_provisioned_api_key(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
) )
) )
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (proc, lines):
dev.proc = proc dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await _wait_for_line(lines, "once the api key is provisioned") await _wait_for_line(lines, "once the api key is provisioned")
@@ -570,7 +584,9 @@ async def test_host_ota_provisioned_api_key_safe_mode(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
) )
) )
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (proc, _lines):
dev.proc = proc dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await _provision_key(dev, api_client_connected) await _provision_key(dev, api_client_connected)
@@ -579,7 +595,9 @@ async def test_host_ota_provisioned_api_key_safe_mode(
# exits, so safe mode takes a second start # exits, so safe mode takes a second start
force_safe_mode(DEVICE_NAME) force_safe_mode(DEVICE_NAME)
key = PROVISIONING_PSK.decode() key = PROVISIONING_PSK.decode()
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): async with run_binary(
dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log
) as (proc, lines):
dev.proc = proc dev.proc = proc
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
@@ -611,7 +629,10 @@ async def test_host_ota_rejects_garbage(
bogus_path = integration_test_dir / "bogus.bin" bogus_path = integration_test_dir / "bogus.bin"
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
async with run_binary(dev.binary_path) as (proc, _lines): async with run_binary(dev.binary_path, symbols_path=dev.symbols_path) as (
proc,
_lines,
):
dev.proc = proc dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
pid_before = proc.pid pid_before = proc.pid
+20 -109
View File
@@ -5,15 +5,9 @@ test_uart_mock_ld2410s:
switch and config-mode end go out in order, each after the previous one is switch and config-mode end go out in order, each after the previous one is
acknowledged, then the component logs "Setup done" and clears its warning; acknowledged, then the component logs "Setup done" and clears its warning;
the version and parameter answers are parsed and logged the version and parameter answers are parsed and logged
1a. The numbers show the values read at init in their own units (gates of 0.7 m, 1b. Setting the max distance number to 7 m writes all six parameters (gate 10
seconds, tenths of a hertz) and the response speed select shows Normal
1b. Setting the max distance number to 7.3 m writes all six parameters (gate 10
plus the values read at init) inside a configuration-mode session, and the plus the values read at init) inside a configuration-mode session, and the
number reports the 7 m the module got, not 7.3 m number reports 7 m
1c. Selecting the Fast response speed writes speed 10 with the same values and
the select reports Fast
1d. Setting no delay to 20 s reports 20 s at once; the module rejects the write,
so the values are read back and every entity shows the module's own value again
2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard
data frame data frame
3. Resync - a noise byte or a partial header directly before a short frame 3. Resync - a noise byte or a partial header directly before a short frame
@@ -22,8 +16,6 @@ test_uart_mock_ld2410s:
dropped without publishing, and the short frame after each still publishes dropped without publishing, and the short frame after each still publishes
test_uart_mock_ld2410s_recovery (the mock never answers the init commands): test_uart_mock_ld2410s_recovery (the mock never answers the init commands):
0. A number or select changed before the module answered logs that the change
is ignored and publishes nothing
1. Config-mode start is resent after the acknowledgement timeout 1. Config-mode start is resent after the acknowledgement timeout
2. Once the retries run out, config-mode end is sent and the component warns 2. Once the retries run out, config-mode end is sent and the component warns
that it is starting over that it is starting over
@@ -44,19 +36,9 @@ test_uart_mock_ld2410s_link_lost:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
import re import re
from aioesphomeapi import ( from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState
APIClient,
ButtonInfo,
EntityInfo,
EntityState,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
)
import pytest import pytest
from .log_utils import LineWaiter from .log_utils import LineWaiter
@@ -84,20 +66,12 @@ INIT_TX = [
OUTPUT_MODE_TX, OUTPUT_MODE_TX,
CONFIG_END_TX, CONFIG_END_TX,
] ]
# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5
PARAMETERS_WRITE_TX = (
def _parameters_write_tx(no_delay: int, speed: int) -> str: "FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:"
"""The 0x0070 write with max gate 10, min gate 0 and both frequencies 80.""" "0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01"
values = [(0x05, 10), (0x0A, 0), (0x06, no_delay), (0x02, 80), (0x0C, 80)] )
body = "".join(f"{key:02X}:00:{value:02X}:00:00:00:" for key, value in values) WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX]
return f"FD:FC:FB:FA:26:00:70:00:{body}0B:00:{speed:02X}:00:00:00:04:03:02:01"
WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(10, 5), CONFIG_END_TX]
# The same values with response speed 10 (Fast)
SPEED_WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(10, 10), CONFIG_END_TX]
# No delay 20 s; the fixture rejects it
REJECTED_WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(20, 10), CONFIG_END_TX]
# Logged once the version and parameter answers are parsed # Logged once the version and parameter answers are parsed
PARSED_ANSWERS = [ PARSED_ANSWERS = [
"Firmware 1.2.3", "Firmware 1.2.3",
@@ -143,20 +117,13 @@ def _tx_frame(line: str) -> str | None:
return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip() return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip()
async def _start_scenario( async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None:
client: APIClient, """Subscribe the collector past the initial states, then start the mock scenario."""
collector: SensorStateCollector,
on_state: Callable[[EntityState], None] | None = None,
) -> list[EntityInfo]:
"""Subscribe past the initial states, then start the mock scenario; returns the entities."""
entities, _ = await client.list_entities_services() entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities) collector.build_key_mapping(entities)
await subscribe_after_initial_states( await subscribe_after_initial_states(client, entities, on_state=collector.on_state)
client, entities, on_state=on_state or collector.on_state
)
start_btn = require_entity(entities, "start_scenario", ButtonInfo) start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key) client.button_command(start_btn.key)
return entities
async def _wait_for_presence( async def _wait_for_presence(
@@ -189,15 +156,12 @@ async def test_uart_mock_ld2410s(
collector = SensorStateCollector( collector = SensorStateCollector(
sensor_names=["distance"], binary_sensor_names=["has_target"] sensor_names=["distance"], binary_sensor_names=["has_target"]
) )
number_states: dict[int, list[float]] = {} number_states: list[float] = []
select_states: list[str] = []
def on_state(state: EntityState) -> None: def on_state(state: EntityState) -> None:
collector.on_state(state) collector.on_state(state)
if isinstance(state, NumberState): if isinstance(state, NumberState) and state.key == max_distance_key:
number_states.setdefault(state.key, []).append(state.state) number_states.append(state.state)
if isinstance(state, SelectState):
select_states.append(state.state)
all_presence = collector.add_waiter( all_presence = collector.add_waiter(
lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE)
@@ -227,28 +191,16 @@ async def test_uart_mock_ld2410s(
collector.build_key_mapping(entities) collector.build_key_mapping(entities)
max_distance_key = require_entity(entities, "max_distance", NumberInfo).key max_distance_key = require_entity(entities, "max_distance", NumberInfo).key
sub = await subscribe_after_initial_states(client, entities, on_state=on_state) sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
# The values read from the module: gate 16, 10 s, 80 tenths of a hertz, speed 5 # The values read from the module: gate 16 and 10 s
no_delay_key = require_entity(entities, "no_delay", NumberInfo).key no_delay_key = require_entity(entities, "no_delay", NumberInfo).key
frequency_key = require_entity(
entities, "status_reporting_frequency", NumberInfo
).key
speed_key = require_entity(entities, "response_speed", SelectInfo).key
initial = sub.initial_states initial = sub.initial_states
assert initial[max_distance_key].state == pytest.approx(11.2) assert initial[max_distance_key].state == pytest.approx(11.2)
assert initial[no_delay_key].state == pytest.approx(10) assert initial[no_delay_key].state == pytest.approx(10)
assert initial[frequency_key].state == pytest.approx(8.0)
assert initial[speed_key].state == "Normal"
# 7.3 m is between gates 10 and 11: gate 10 is written and 7 m reported client.number_command(max_distance_key, 7.0)
client.number_command(max_distance_key, 7.3)
await waiter.wait_count(TX_LINE, 9, 5.0, "Parameter write not sent") await waiter.wait_count(TX_LINE, 9, 5.0, "Parameter write not sent")
assert tx_frames()[6:9] == WRITE_TX, tx_frames() assert tx_frames()[6:9] == WRITE_TX, tx_frames()
assert number_states[max_distance_key] == [pytest.approx(7.0)] assert number_states == [pytest.approx(7.0)]
client.select_command(speed_key, "Fast")
await waiter.wait_count(TX_LINE, 12, 5.0, "Response speed write not sent")
assert tx_frames()[9:12] == SPEED_WRITE_TX, tx_frames()
assert select_states == ["Fast"]
start_btn = require_entity(entities, "start_scenario", ButtonInfo) start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key) client.button_command(start_btn.key)
@@ -266,29 +218,7 @@ async def test_uart_mock_ld2410s(
# A target 300 cm away, then none: the distance follows the presence # A target 300 cm away, then none: the distance follows the presence
assert collector.sensor_states["distance"][:2] == [300, 0] assert collector.sensor_states["distance"][:2] == [300, 0]
# Data frames must not restart the init sequence # Data frames must not restart the init sequence
assert len(tx_frames()) == 12, tx_frames() assert len(tx_frames()) == 9, tx_frames()
# A rejected write leaves configuration mode and reads the module's values back
read_back = waiter.expect("Parameters: gates 0 to 16, no delay 10 s")
client.number_command(no_delay_key, 20)
await wait_or_fail(read_back, 5.0, "Values not read back", tx_frames=tx_frames)
async def republished() -> None:
while (
len(number_states.get(no_delay_key, ())) < 2
or len(number_states.get(max_distance_key, ())) < 2
or len(select_states) < 2
):
await asyncio.sleep(0.01)
await wait_or_fail(republished(), 5.0, "Read-back values not published")
assert tx_frames()[12:15] == REJECTED_WRITE_TX, tx_frames()
assert number_states[no_delay_key] == [pytest.approx(20), pytest.approx(10)]
assert number_states[max_distance_key] == [
pytest.approx(7.0),
pytest.approx(11.2),
]
assert select_states == ["Fast", "Normal"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -304,14 +234,10 @@ async def test_uart_mock_ld2410s_recovery(
gave_up = loop.create_future() gave_up = loop.create_future()
oversized_dropped = loop.create_future() oversized_dropped = loop.create_future()
footer_mismatches: list[str] = [] footer_mismatches: list[str] = []
ignored_changes = 0
def line_callback(line: str) -> None: def line_callback(line: str) -> None:
nonlocal ignored_changes
if (frame := _tx_frame(line)) is not None: if (frame := _tx_frame(line)) is not None:
tx_frames.append((loop.time(), frame)) tx_frames.append((loop.time(), frame))
if "Parameters not read yet, change ignored" in line:
ignored_changes += 1
if "No acknowledgement for command 00FF, resending" in line and not ( if "No acknowledgement for command 00FF, resending" in line and not (
resent.done() resent.done()
): ):
@@ -341,25 +267,12 @@ async def test_uart_mock_ld2410s_recovery(
>= len(EXPECTED_RECOVERY_PRESENCE) >= len(EXPECTED_RECOVERY_PRESENCE)
) )
) )
setting_states: list[EntityState] = []
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, (NumberState, SelectState)):
setting_states.append(state)
async with ( async with (
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
entities = await _start_scenario(client, collector, on_state) await _start_scenario(client, collector)
# The module never answered, so both changes are refused and nothing is published
client.number_command(
require_entity(entities, "max_distance", NumberInfo).key, 7.0
)
client.select_command(
require_entity(entities, "response_speed", SelectInfo).key, "Fast"
)
await _wait_for_presence(collector, all_presence) await _wait_for_presence(collector, all_presence)
await wait_or_fail( await wait_or_fail(
asyncio.gather(resent, gave_up), asyncio.gather(resent, gave_up),
@@ -373,8 +286,6 @@ async def test_uart_mock_ld2410s_recovery(
# Recovery shows in the published states alone; the footer log below is # Recovery shows in the published states alone; the footer log below is
# only a cross-check that needs the fixture's VERBOSE logger level # only a cross-check that needs the fixture's VERBOSE logger level
assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE
assert ignored_changes == 2
assert setting_states == []
assert oversized_dropped.done(), "Oversized length field was not dropped" assert oversized_dropped.done(), "Oversized length field was not dropped"
assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX] assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX]
@@ -1,15 +0,0 @@
esphome:
name: componenttestesprtl8720c
friendly_name: $component_name
rtl87xx:
board: bw15
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file
@@ -138,12 +138,3 @@ def test_sd140_v7_dfu_accepts_all_s140_7_softdevices(
cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0])
sd_req = cmd[cmd.index("--sd-req") + 1].split(",") sd_req = cmd[cmd.index("--sd-req") + 1].split(",")
assert sd_req == ["0x00CA", "0x0100", "0x0123"] assert sd_req == ["0x00CA", "0x0100", "0x0123"]
def test_successful_build_writes_idedata(build_dir: Path, run_cmd: Mock) -> None:
"""The idedata cache is written after the build, as on the other native toolchains."""
with patch(
"esphome.components.nrf52.toolchain.get_idedata", return_value={}
) as get_idedata:
_build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd)
get_idedata.assert_called_once_with()
@@ -1,37 +0,0 @@
"""The nrf52 sdk-nrf native backend: idedata from the app's compile database."""
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.components.nrf52 import toolchain
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE
@pytest.mark.parametrize(
("version", "app_dir"),
[(cv.Version(2, 9, 2), ("zephyr",)), (cv.Version(2, 9, 1), ())],
)
def test_get_idedata_reads_the_app_compile_database(
tmp_path: Path, version: cv.Version, app_dir: tuple[str, ...]
) -> None:
"""The compile database sits in the app build dir of the SDK's layout."""
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
with (
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: version}}),
patch(
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"prog_path": "x"},
) as load,
):
assert toolchain.get_idedata() == {"prog_path": "x"}
app = CORE.relative_pioenvs_path("livingroom", *app_dir)
compile_commands, elf, _cache = load.call_args.args
assert compile_commands == app / "compile_commands.json"
assert elf == app / "zephyr" / "zephyr.elf"
-30
View File
@@ -1718,36 +1718,6 @@ def test_add_library_str(
assert libraries[0].repository == repository assert libraries[0].repository == repository
@pytest.mark.asyncio
async def test_add_platformio_options_sdk_nrf(
caplog: pytest.LogCaptureFixture,
) -> None:
"""sdk-nrf is a native toolchain: build_flags/lib_deps/lib_ignore are
honored and everything else warns instead of going to platformio.ini."""
CORE.toolchain = Toolchain.SDK_NRF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "nrf52",
KEY_TARGET_FRAMEWORK: "zephyr",
}
await config._add_platformio_options(
{
"build_flags": ["-DSINGLE_FLAG"],
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
"lib_ignore": "libsodium",
"board_build.f_flash": "80000000L",
}
)
assert "-DSINGLE_FLAG" in CORE.build_flags
assert "ArduinoJson" in CORE.platformio_libraries
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
assert (
"esphome->platformio_options->board_build.f_flash is ignored when "
"building with the native 'sdk-nrf' toolchain" in caplog.text
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_platformio_options_native_idf( async def test_add_platformio_options_native_idf(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
+7 -11
View File
@@ -996,7 +996,7 @@ class TestEsphomeCore:
target.toolchain = const.Toolchain.PLATFORMIO target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_native_toolchain is False assert target.using_native_toolchain is False
target.toolchain = const.Toolchain.SDK_NRF target.toolchain = const.Toolchain.SDK_NRF
assert target.using_native_toolchain is True assert target.using_native_toolchain is False
def test_using_toolchain_host(self, target): def test_using_toolchain_host(self, target):
"""The host's only toolchain: the system compiler driven by ninja.""" """The host's only toolchain: the system compiler driven by ninja."""
@@ -1021,24 +1021,20 @@ class TestEsphomeCore:
assert "Wire" in target.platformio_libraries assert "Wire" in target.platformio_libraries
@pytest.mark.parametrize( def test_add_build_unflag__warns_on_native_idf_toolchain(
"toolchain", [const.Toolchain.ESP_IDF, const.Toolchain.SDK_NRF] self, target, caplog: pytest.LogCaptureFixture
)
def test_add_build_unflag__warns_on_native_cmake_toolchains(
self, target, caplog: pytest.LogCaptureFixture, toolchain: const.Toolchain
) -> None: ) -> None:
"""Build unflags are not consumed by the native IDF and nRF52 build """Build unflags are not consumed by the native IDF build generator,
generators, so adding one on those toolchains warns; PlatformIO stays so adding one on that toolchain warns; PlatformIO stays silent."""
silent."""
target.toolchain = const.Toolchain.PLATFORMIO target.toolchain = const.Toolchain.PLATFORMIO
target.add_build_unflag("-fno-rtti") target.add_build_unflag("-fno-rtti")
assert "ignored" not in caplog.text assert "ignored" not in caplog.text
target.toolchain = toolchain target.toolchain = const.Toolchain.ESP_IDF
target.add_build_unflag("-fno-exceptions") target.add_build_unflag("-fno-exceptions")
assert ( assert (
"Build unflag -fno-exceptions is ignored when building with the " "Build unflag -fno-exceptions is ignored when building with the "
f"native '{toolchain.value}' toolchain" in caplog.text "native ESP-IDF toolchain" in caplog.text
) )
# The unflag is still recorded either way. # The unflag is still recorded either way.
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
+15 -20
View File
@@ -1932,28 +1932,23 @@ def test_start_udp_probe_succeeds(mock_socket: Mock) -> None:
def test_udp_channel_pings_when_all_held() -> None: def test_udp_channel_pings_when_all_held() -> None:
"""With every message in flight already held, it asks for responses instead, """With every message in flight already held, it asks for responses instead,
at most once per resend timeout.""" at most once per resend timeout."""
sock = Mock() device = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# A hand-driven clock, so a slow runner cannot pass the resend timeout between pumps device.bind(("127.0.0.1", 0))
now = [100.0] device.settimeout(1.0)
with ( sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
patch.object(espota2, "time", Mock(monotonic=lambda: now[0])), sock.connect(device.getsockname())
patch("select.select", return_value=([], [], [])), channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
): channel.sendall(b"x")
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") channel._queue[0].held = True
channel.sendall(b"x") with patch.object(channel, "_receive"):
channel._queue[0].held = True
channel._pump() channel._pump()
channel._pump() channel._pump()
pinged_at, rto = now[0], channel._rto() assert device.recv(2048)[0] == espota2.UDP_MSG_PING
now[0] = pinged_at + rto - 0.01 device.settimeout(0.1)
channel._pump() with pytest.raises(TimeoutError):
pings_before = [c.args[0][0] for c in sock.send.call_args_list] device.recv(2048)
now[0] = pinged_at + rto channel.close()
channel._pump() device.close()
assert pings_before == [espota2.UDP_MSG_PING]
assert [c.args[0][0] for c in sock.send.call_args_list] == [
espota2.UDP_MSG_PING
] * 2
def test_udp_channel_ignores_foreign_datagrams() -> None: def test_udp_channel_ignores_foreign_datagrams() -> None:
+9 -24
View File
@@ -117,9 +117,9 @@ from esphome.const import (
PLATFORM_ESP32, PLATFORM_ESP32,
PLATFORM_ESP8266, PLATFORM_ESP8266,
PLATFORM_HOST, PLATFORM_HOST,
PLATFORM_LN882X,
PLATFORM_NRF52, PLATFORM_NRF52,
PLATFORM_RP2, PLATFORM_RP2,
PLATFORM_RTL87XX,
ErrorFormat, ErrorFormat,
Toolchain, Toolchain,
) )
@@ -6304,7 +6304,7 @@ def test_run_miniterm_no_stacktrace_analyzer(
"""Platforms without a stacktrace analyzer log an info and stream anyway.""" """Platforms without a stacktrace analyzer log an info and stream anyway."""
mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END]) mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_LN882X} CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_RTL87XX}
config = { config = {
CONF_LOGGER: { CONF_LOGGER: {
CONF_BAUD_RATE: 115200, CONF_BAUD_RATE: 115200,
@@ -7643,11 +7643,10 @@ def test_upload_program_ota_bootloader_skip_tree_names_the_flag(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("toolchain", "pio_project_written"), ("toolchain", "pio_project_written"),
[ [
# The native toolchains generate their project at compile time, so # The native toolchain generates its project at compile time, so
# write_cpp_file must not write a platformio.ini; the default # write_cpp_file must not write a platformio.ini; the default
# toolchain writes the PlatformIO project files. # toolchain writes the PlatformIO project files.
(Toolchain.ARDUINO, False), (Toolchain.ARDUINO, False),
(Toolchain.SDK_NRF, False),
(None, True), (None, True),
], ],
) )
@@ -7705,6 +7704,7 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
[ [
(PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
(PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
# No native build backend, but its binutils and ELF are known
(PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"), (PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"),
], ],
) )
@@ -7806,27 +7806,10 @@ def test_command_analyze_memory_missing_binutils_fails_by_name(
def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None: def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None:
"""A non-native, non-platformio toolchain errors out cleanly.""" """A non-native, non-platformio toolchain errors out cleanly."""
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path) setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
CORE.toolchain = Toolchain.SDK_NRF CORE.toolchain = Toolchain.SDK_NRF
# Every toolchain has a backend today; drop sdk-nrf's to stand in for one assert command_idedata(MagicMock(), CORE.config) == 1
# that does not
with patch("esphome.__main__.native_backend", return_value=None):
assert command_idedata(MagicMock(), CORE.config) == 1
def test_command_idedata_sdk_nrf_prints_json(
tmp_path: Path, capsys: CaptureFixture
) -> None:
"""Under the sdk-nrf toolchain, idedata comes from the nrf52 backend."""
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path)
CORE.toolchain = Toolchain.SDK_NRF
data = {"cxx_path": "arm-zephyr-eabi-g++", "prog_path": "/build/zephyr.elf"}
with patch("esphome.components.nrf52.toolchain.get_idedata", return_value=data):
assert command_idedata(MagicMock(), CORE.config) == 0
assert json.loads(capsys.readouterr().out) == data
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -7961,7 +7944,9 @@ def test_command_analyze_memory_unsupported_toolchain(
# Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for # Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for
# one that does not # one that does not
with patch("esphome.__main__.native_backend", return_value=None): with patch.dict(
"esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True
):
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
assert result == 1 assert result == 1
+2 -2
View File
@@ -15,7 +15,7 @@ from unittest.mock import Mock
import pytest import pytest
from esphome import platform_hooks from esphome import platform_hooks
from esphome.const import PLATFORM_ESP32, PLATFORM_LN882X, Platform from esphome.const import PLATFORM_ESP32, PLATFORM_RTL87XX, Platform
def test_no_unregistered_platform_exposes_a_hook() -> None: def test_no_unregistered_platform_exposes_a_hook() -> None:
@@ -165,7 +165,7 @@ def test_get_stacktrace_handler_reports_missing_analyzer(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
caplog.set_level("INFO", logger="esphome.platform_hooks") caplog.set_level("INFO", logger="esphome.platform_hooks")
assert platform_hooks.get_stacktrace_handler(PLATFORM_LN882X) is None assert platform_hooks.get_stacktrace_handler(PLATFORM_RTL87XX) is None
assert "no compatible analyzer" in caplog.text assert "no compatible analyzer" in caplog.text
# A capability gap is ordinary; it must not warn. # A capability gap is ordinary; it must not warn.
assert not any(r.levelno >= logging.WARNING for r in caplog.records) assert not any(r.levelno >= logging.WARNING for r in caplog.records)
+1 -7
View File
@@ -18,7 +18,6 @@ from esphome.const import (
PLATFORM_BK72XX, PLATFORM_BK72XX,
PLATFORM_ESP32, PLATFORM_ESP32,
PLATFORM_ESP8266, PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_NRF52, PLATFORM_NRF52,
PLATFORM_RP2, PLATFORM_RP2,
PLATFORM_RTL87XX, PLATFORM_RTL87XX,
@@ -67,10 +66,6 @@ CRASH_SAMPLES: dict[str, dict[str, list[str]]] = {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"], "state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x0001614A (fault location)"], "addresses": ["PC: 0x0001614A (fault location)"],
}, },
PLATFORM_RTL87XX: {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x9B0073A4 (fault location)"],
},
PLATFORM_NRF52: { PLATFORM_NRF52: {
"state_markers": ["Last crash:"], "state_markers": ["Last crash:"],
"addresses": [ "addresses": [
@@ -236,7 +231,6 @@ DECODER_PATTERNS: dict[str, list[str]] = {
], ],
PLATFORM_RP2: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"], PLATFORM_RP2: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_BK72XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"], PLATFORM_BK72XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_RTL87XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"], PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"],
} }
@@ -554,7 +548,7 @@ def test_processor_registry_miss_disables_at_construction(
caplog.set_level("INFO", logger="esphome.platform_hooks") caplog.set_level("INFO", logger="esphome.platform_hooks")
with patch.object(stacktrace.platform_hooks, "import_module") as mock_import: with patch.object(stacktrace.platform_hooks, "import_module") as mock_import:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_LN882X) processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_RTL87XX)
processor.process_line("PC: 0x40104960") processor.process_line("PC: 0x40104960")
mock_import.assert_not_called() mock_import.assert_not_called()