mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 08:22:25 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f042927680 | ||
|
|
e73ed21307 | ||
|
|
41d32a639b | ||
|
|
1562a4d229 | ||
|
|
c34d3a09c6 | ||
|
|
b37e046a52 | ||
|
|
3246311daa | ||
|
|
659efd5fcb | ||
|
|
bb3f175fa7 | ||
|
|
223022e0a6 | ||
|
|
3b0a1ba339 | ||
|
|
92655743c1 | ||
|
|
5f79e92c14 |
No files matched your search
@@ -1,215 +0,0 @@
|
||||
---
|
||||
name: automations
|
||||
description: Write ESPHome automation building blocks - triggers (on_* keys), actions and conditions. Use when adding or changing an on_* trigger, an action such as my_component.do_something, a condition, a callback registration method (add_on_*_callback), or code that calls build_callback_automation, build_automation, register_*_action or register_*_condition.
|
||||
---
|
||||
|
||||
# Automations: triggers, actions and conditions
|
||||
|
||||
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do
|
||||
something), and **Conditions** (check if something is true). The helpers named here are documented in
|
||||
their docstrings in `esphome/automation.py`.
|
||||
|
||||
## Triggers: callback method (preferred)
|
||||
|
||||
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by
|
||||
using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID`
|
||||
in the schema.
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
for conf in config.get(CONF_ON_STATE, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_state_callback", [(bool, "x")], conf
|
||||
)
|
||||
```
|
||||
|
||||
`build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args
|
||||
as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder<Ts...>`).
|
||||
|
||||
For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
|
||||
```python
|
||||
for conf_key, forwarder in (
|
||||
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
|
||||
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
|
||||
):
|
||||
for conf in config.get(conf_key, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_state_callback", [], conf, forwarder=forwarder
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only fire for
|
||||
some values, pass `params`, `forward` and `when`; the helper then generates a capture-less lambda (stored
|
||||
inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build `forward` from
|
||||
`MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**C++: no trigger class needed.** The callback registration method must be templatized to accept both
|
||||
`std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
protected:
|
||||
// Use CallbackManager when callbacks are always registered (e.g. core components)
|
||||
CallbackManager<void(bool)> state_callback_;
|
||||
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
|
||||
// (nullptr vs empty std::vector) per instance when no callbacks are added
|
||||
// LazyCallbackManager<void(bool)> state_callback_;
|
||||
};
|
||||
```
|
||||
|
||||
Never type a registration method's parameter as `std::function`; that forces a heap allocation for every
|
||||
forwarder struct:
|
||||
```cpp
|
||||
// Bad
|
||||
void add_on_state_callback(std::function<void(bool)> &&callback) {
|
||||
this->state_callback_.add(std::move(callback));
|
||||
}
|
||||
```
|
||||
|
||||
| Type | Idle overhead (32-bit) | When to use |
|
||||
|------|----------------------|-------------|
|
||||
| `CallbackManager<void(Ts...)>` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered |
|
||||
| `LazyCallbackManager<void(Ts...)>` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) |
|
||||
|
||||
Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and forwarder structs through
|
||||
their templatized `add()` method. `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first
|
||||
callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
|
||||
|
||||
## Triggers: Trigger class method
|
||||
|
||||
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state
|
||||
beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
for conf in config.get(CONF_ON_TURN_ON, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
```
|
||||
|
||||
**C++:**
|
||||
```cpp
|
||||
class TurnOnTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
|
||||
parent->add_on_state_callback([this](bool state) {
|
||||
if (state && !this->last_on_)
|
||||
this->trigger();
|
||||
this->last_on_ = state;
|
||||
});
|
||||
}
|
||||
protected:
|
||||
bool last_on_;
|
||||
};
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
```cpp
|
||||
template<typename... Ts> class MyAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit MyAction(MyComponent *parent) : parent_(parent) {}
|
||||
void play(const Ts &...) override { this->parent_->do_something(); }
|
||||
protected:
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and the
|
||||
`@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use
|
||||
`synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`)
|
||||
or store trigger arguments for later use.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them with
|
||||
`register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover the
|
||||
rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a byte
|
||||
payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload stays in
|
||||
flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with `cg.templatable` stays
|
||||
for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
## Conditions
|
||||
|
||||
```cpp
|
||||
template<typename... Ts> class MyCondition : public Condition<Ts...> {
|
||||
public:
|
||||
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
|
||||
bool check(const Ts &...) override { return this->parent_->is_active(); }
|
||||
protected:
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares against
|
||||
config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: breaking-changes
|
||||
description: Decide whether an ESPHome change breaks public API and handle it - what counts as public C++ and Python API, the deprecation window, ESPDEPRECATED for C++, cv.rename_key and deprecation warnings for config keys, and the migration notes a PR needs. Use when removing, renaming or changing the signature of a config option, a public C++ member or a core Python helper.
|
||||
---
|
||||
|
||||
# Public API and breaking changes
|
||||
|
||||
Full policy: https://developers.esphome.io/contributing/code/#public-api-and-breaking-changes
|
||||
|
||||
## What is public
|
||||
|
||||
- **C++ in components:** only features documented at [esphome.io](https://esphome.io) are public API.
|
||||
Undocumented `public` members are internal.
|
||||
- **C++ core and base classes** (`esphome/core/`, `Component`, `Sensor`, etc.): all `public` members are
|
||||
public API.
|
||||
- **Components with global accessors** (`global_api_server`, etc.): all `public` members are public API
|
||||
(except config setters).
|
||||
- **Python:** all documented configuration options are public API. Python code in `esphome/core/` that
|
||||
existing core components use is stable API. Other Python code is internal unless explicitly documented
|
||||
for external component use.
|
||||
- Changing the signature of a lambda that code generation injects is not a breaking change.
|
||||
|
||||
## Policy
|
||||
|
||||
- Aim for a **6-month deprecation window** when possible.
|
||||
- Clean breaks are allowed for signature changes, deep refactorings and resource constraints.
|
||||
- The migration path goes in the PR description (it generates the release notes).
|
||||
- A blog post is required for core/base class changes or significant architectural changes.
|
||||
|
||||
Checklist:
|
||||
- [ ] Clear justification (RAM/flash savings, architectural improvement)
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (`ESPDEPRECATED` for C++)
|
||||
- [ ] Documented migration path in the PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during the deprecation period
|
||||
|
||||
## Deprecating C++
|
||||
|
||||
```cpp
|
||||
// Remove before 2026.6.0
|
||||
ESPDEPRECATED("Use new_method() instead. Removed in 2026.6.0", "2025.12.0")
|
||||
void old_method() { this->new_method(); }
|
||||
```
|
||||
|
||||
## Deprecating config keys
|
||||
|
||||
For a renamed key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for
|
||||
context); it warns and migrates automatically:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
Cover the old syntax with a `validate.*.yaml` test (see the `component-tests` skill), and keep the language
|
||||
schema correct (see the `config-validation` skill).
|
||||
@@ -9,13 +9,11 @@ ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers
|
||||
(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
|
||||
flash are scarce, and code runs unattended for months.
|
||||
|
||||
`AGENTS.md` in the repository root is the contributor guide, and the skills it
|
||||
lists (`component-codegen`, `config-validation`, `automations`,
|
||||
`embedded-memory`, `component-tests`, `breaking-changes`) hold the detail. They
|
||||
are the authority when they disagree with this summary. The developer
|
||||
documentation at https://developers.esphome.io explains the component lifecycle
|
||||
and the reasoning behind these rules. This skill lists the concrete things worth
|
||||
flagging in a review; read the matching skill for the detail behind any item.
|
||||
`AGENTS.md` in the repository root is the full contributor guide and the
|
||||
authority when it disagrees with this summary. The developer documentation at
|
||||
https://developers.esphome.io explains the component lifecycle and the reasoning
|
||||
behind these rules. This skill lists the concrete things worth flagging in a
|
||||
review; read `AGENTS.md` for the detail behind any item.
|
||||
|
||||
Only raise findings that the diff actually introduces or changes. Do not ask for
|
||||
drive-by cleanup of pre-existing code the PR did not touch.
|
||||
@@ -78,10 +76,6 @@ performance nit, because it fragments a small shared heap. Flag:
|
||||
`if (blah := config.get(CONF_BLAH)) is not None:`.
|
||||
- Reuse existing validators from `config_validation.py` (`cv.rename_key`,
|
||||
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling.
|
||||
- A new or changed validator function that wraps, chooses or builds a schema
|
||||
must be decorated with `@schema_extractor(...)` and handle `SCHEMA_EXTRACT`,
|
||||
or `script/build_language_schema.py` silently drops the keys behind it (see
|
||||
the `config-validation` skill).
|
||||
- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in
|
||||
the component's own `.py`, or in `esphome/components/const/__init__.py` when
|
||||
shared. The same constant defined in three or more component files fails CI.
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
---
|
||||
name: component-codegen
|
||||
description: Write the Python side of an ESPHome component - __init__.py and platform files (sensor.py, switch.py, ...), DOMAIN and other metadata, to_code, hub child entities with sub_sensors and friends, pins and referenced components, hub-forwarding entities (ApplyButton, ApplySwitch), uart/i2c/spi constructor helpers, and CORE.data state. Use when creating a component or changing its code generation.
|
||||
---
|
||||
|
||||
# Component code generation
|
||||
|
||||
Read https://developers.esphome.io/architecture/components/ for the component lifecycle and how a
|
||||
component is registered. Schema rules and schema extraction are in the `config-validation` skill;
|
||||
automations (`on_*`, actions, conditions) in the `automations` skill.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
components/[component_name]/
|
||||
├── __init__.py # Component configuration schema and code generation
|
||||
├── [component].h # C++ header file (if needed)
|
||||
├── [component].cpp # C++ implementation (if needed)
|
||||
└── [platform]/ # Platform-specific implementations
|
||||
├── __init__.py # Platform-specific configuration
|
||||
├── [platform].h # Platform C++ header
|
||||
└── [platform].cpp # Platform C++ implementation
|
||||
```
|
||||
|
||||
## Metadata
|
||||
|
||||
- `DOMAIN`: required in every component's `__init__.py`, set to the component's own name as a plain string
|
||||
(e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: list of required components.
|
||||
- `AUTO_LOAD`: components to automatically load. Not a way to put entities under the component's own key;
|
||||
see "Entities of a hub" below.
|
||||
- `CONFLICTS_WITH`: incompatible components.
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance.
|
||||
- `MULTI_CONF`: whether multiple instances are allowed.
|
||||
|
||||
## Basic pattern
|
||||
|
||||
```python
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
|
||||
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_key(config[CONF_KEY]))
|
||||
cg.add(var.set_param(config[CONF_PARAM]))
|
||||
```
|
||||
|
||||
```cpp
|
||||
namespace esphome::my_component {
|
||||
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_key(const char *key) { this->key_ = StringRef(key); }
|
||||
void set_param(int param) { this->param_ = param; }
|
||||
|
||||
protected:
|
||||
StringRef key_;
|
||||
int param_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::my_component
|
||||
```
|
||||
|
||||
When the setter takes the value unchanged, use `cg.add_optional(var.set_blah, config.get(CONF_BLAH))`; it
|
||||
is not awaited, and it skips only `None`, so `0` and `False` are still set. Required, invariant
|
||||
dependencies are constructor arguments to `cg.new_Pvariable()`, not setters.
|
||||
|
||||
## Entity platforms
|
||||
|
||||
**Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
```
|
||||
|
||||
**Binary sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
|
||||
**Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
## Entities of a hub
|
||||
|
||||
A new component's entities go in platform files (`sensor.py`, `binary_sensor.py`, ...) that take the hub's
|
||||
id, never under the hub's own key with their platforms added to `AUTO_LOAD` to make that work. Auto loading
|
||||
a platform because the C++ uses its type is fine. See
|
||||
https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
**Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, `sub_selects` in
|
||||
their domains), adding `parent=hub` for entities that derive from `Parented<T>`, then make one call per
|
||||
key, even when there is only one. A call creates the entity only when its key is configured, passes it to
|
||||
the setter and returns it (or `None`); extra arguments such as `min_value` or `options` go on the call.
|
||||
Always name the setter explicitly on the object that owns it, never with `getattr` and an f-string, and
|
||||
keep that variable short (`var` for the component itself, `hub` for one fetched with `cg.get_variable`) so
|
||||
the calls fit on one line. Loops whose setter also takes an index, such as `set_gate_threshold(x, n)`, stay
|
||||
as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
**Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the
|
||||
state. `publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off
|
||||
and runs the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)` to still
|
||||
drive a state when the restore mode is disabled. A hub that applies its switches' restored states itself
|
||||
reads `sw->get_initial_write_state()`, which already applies `inverted`.
|
||||
|
||||
**Entities that each watch a register field:** one class taking register, shift, mask and match from a
|
||||
Python tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a
|
||||
virtual listener base (a second vptr per entity).
|
||||
|
||||
## Other helpers
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for
|
||||
`set_parent`.
|
||||
- `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`, `spi.new_spi_component`,
|
||||
`modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`) constructs, registers and
|
||||
attaches in one call.
|
||||
- `display.add_writer(var, config)` sets the lambda.
|
||||
- `MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict.
|
||||
- `button.restart_button_schema(cls)` (and `factory_reset_`, `query_`) are button presets.
|
||||
- `fv.get_declaring_config(id)` finds a hub's config in final validation.
|
||||
|
||||
## State during code generation
|
||||
|
||||
Use `CORE.data` for component state that needs to persist during configuration generation, namespaced
|
||||
under the component's `DOMAIN` and held in a `@dataclass`. Avoid module-level mutable globals: they
|
||||
persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec, whereas
|
||||
`CORE.data` is cleared between runs.
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from esphome.core import CORE
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
```
|
||||
|
||||
Don't use flat keys such as `CORE.data["my_component_feature"] = True`. Some components use `TypedDict`
|
||||
instead of a dataclass; both are acceptable.
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
name: component-tests
|
||||
description: Write and run tests for ESPHome components - YAML build tests (test.*.yaml, validate.*.yaml, common.yaml) under tests/components, shared bus packages and CI test grouping, C++ gtest unit tests, and host integration tests including the uart_mock fixture. Use when adding or changing files under tests/, or when a code change needs test coverage.
|
||||
---
|
||||
|
||||
# Component tests
|
||||
|
||||
## C++ unit tests
|
||||
|
||||
gtests live in `tests/components/<component>/*.cpp` and run with `script/cpp_unit_test.py <component>`; see
|
||||
`tests/components/README.md`. Set the loop clock in a test with `LoopBlockingGuard{nullptr, nullptr,
|
||||
now_ms}`; the runner resets it before every test.
|
||||
|
||||
## Integration tests
|
||||
|
||||
`tests/integration/` runs real firmware on the host. Serial components prove timeouts and recovery with the
|
||||
`uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its `responses` match on content every time
|
||||
they see it; to answer a repeated request differently, use an `on_tx` lambda with a static counter and
|
||||
`inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
|
||||
## YAML build tests
|
||||
|
||||
```
|
||||
tests/
|
||||
├── test_build_components/
|
||||
│ └── common/ # Shared bus packages (uart, i2c, spi, etc.)
|
||||
│ ├── uart/ # UART at default baud rate
|
||||
│ ├── uart_115200/ # UART at 115200 baud
|
||||
│ ├── i2c/ # I2C bus
|
||||
│ └── spi/ # SPI bus
|
||||
└── components/[component]/
|
||||
├── common.yaml # Component-only config (no bus definitions)
|
||||
├── test.esp32-idf.yaml # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them with `script/test_build_components`. Use `-c <component>` to test specific components and
|
||||
`-t <target>` for specific platforms.
|
||||
|
||||
- **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs on.
|
||||
A variant per board for platform-identical code only adds CI time.
|
||||
- **Config-only test files (`validate.*.yaml`):** use this prefix when a YAML file only needs to exercise
|
||||
schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with
|
||||
`esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml`: base config-only test
|
||||
- `validate-<variant>.<platform>.yaml`: config-only variant
|
||||
|
||||
Use this for deprecated-syntax migration tests, schema edge cases, or platform-specific validation
|
||||
branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and
|
||||
`validate.*.yaml` files. Validate files never participate in bus grouping; each one runs as its own
|
||||
`esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml`
|
||||
changes, and the component isn't pulled in as a dependency of another changed component), CI skips the
|
||||
compile stage for that component and only runs config validation. This is decided in
|
||||
`script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the
|
||||
`validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
## Grouping with packages
|
||||
|
||||
Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define
|
||||
buses (uart, i2c, spi, modbus) directly in test YAML files**; always use packages from
|
||||
`test_build_components/common/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works; the
|
||||
grouping scripts only understand dict-style packages. Never use list-style packages
|
||||
(`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed
|
||||
by the bus name; the component's `common.yaml` is keyed by the component name (e.g.
|
||||
`cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml - everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !include common.yaml
|
||||
```
|
||||
```yaml
|
||||
# common.yaml - component config only, NO bus definitions
|
||||
my_component:
|
||||
id: my_instance
|
||||
|
||||
sensor:
|
||||
- platform: my_component
|
||||
name: My Sensor
|
||||
```
|
||||
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing
|
||||
CI build time. `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a
|
||||
standalone entity with its own id instead.
|
||||
|
||||
A test that needs a shared component in a particular state (`esp32_ble` security settings, an option that
|
||||
PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
|
||||
To verify that all components can be tested together without ID conflicts or configuration issues:
|
||||
```bash
|
||||
./script/test_component_grouping.py -e config --all
|
||||
```
|
||||
This tests all components in a single build to catch conflicts that might not appear when testing
|
||||
components individually. Use `-e config` for fast configuration validation, or `-e compile` for full
|
||||
compilation testing.
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
name: config-validation
|
||||
description: Write or change ESPHome configuration validation - CONFIG_SCHEMA, cv.Schema, cv.typed_schema, custom validator functions, config keys and CONF_ constants, renamed or deprecated keys, and the language schema that script/build_language_schema.py extracts from them. Use whenever a change touches a component's schema or a validator in config_validation.py, automation.py or a component's .py files.
|
||||
---
|
||||
|
||||
# Configuration validation
|
||||
|
||||
ESPHome validates YAML with `voluptuous` schemas built from `esphome/config_validation.py` (`cv`). The
|
||||
same schemas are also walked by `script/build_language_schema.py`, which produces the JSON language schema
|
||||
that editors and the dashboard use for completion and checking. A validation change is only finished when
|
||||
both still work.
|
||||
|
||||
## Writing schemas
|
||||
|
||||
- **Type hints:** validators are typed like any other function: `def validate_x(config: ConfigType) ->
|
||||
ConfigType:`, with `ConfigType` imported from `esphome.types`.
|
||||
- **One schema per model:** when a component's options depend on a `model` key, use
|
||||
`cv.typed_schema({...}, key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not
|
||||
one schema plus a validator that rejects keys and fills defaults afterwards.
|
||||
- **Reuse existing validators:** before writing a custom validator, check for an existing one in
|
||||
`config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components.
|
||||
For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and
|
||||
reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See
|
||||
how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
- **Common validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0,
|
||||
max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
- **Complex validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
- **Platform-specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`,
|
||||
`cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
- **Framework-specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
- **Schema extensions:**
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.Schema({ ... })
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
- **Constants:** `esphome/const.py` is frozen; do not add new `CONF_` constants there. Define a
|
||||
component-local constant in the component's own `.py`; for a constant shared by multiple components, add
|
||||
it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is
|
||||
defined in three or more component files. Constants used in core files (those not under
|
||||
`esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI check.
|
||||
- **Renamed or removed keys:** see the `breaking-changes` skill for `cv.rename_key` and manual
|
||||
deprecation warnings.
|
||||
- **Config-only tests:** schema edge cases and deprecated-syntax migrations go in `validate.*.yaml` test
|
||||
files (see the `component-tests` skill).
|
||||
|
||||
## Schema extraction
|
||||
|
||||
`script/build_language_schema.py` sets `esphome.schema_extractors.EnableSchemaExtraction = True` before it
|
||||
imports any component, then walks every `CONFIG_SCHEMA` (and platform schema) recursively. It understands
|
||||
`cv.Schema`, `cv.All`, `cv.Any`, dicts of `cv.Required` / `cv.Optional` keys, `.extend()`, `cv.ensure_list`,
|
||||
`cv.typed_schema`, registries and the known `cv` validators. **A plain function it cannot see into**: a
|
||||
custom validator that builds or picks a schema at validation time hides every key behind it, and the
|
||||
dumper silently emits nothing for them. CI only runs the script with `--check`, which does not compare the
|
||||
output, so a lost schema is not caught.
|
||||
|
||||
When a validator function wraps, chooses or builds a schema (rather than checking a single value), make it
|
||||
visible to the dumper:
|
||||
|
||||
1. Decorate it with `@schema_extractor("<type>")` from `esphome.schema_extractors`.
|
||||
2. At the top of the function, return the data for that type when it is called with `SCHEMA_EXTRACT`
|
||||
instead of a config value. Compare with `is` (`value is SCHEMA_EXTRACT`); older code uses `==`.
|
||||
|
||||
```python
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
return _OUTGOING_CONNECTION_SCHEMA
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
```
|
||||
|
||||
The type names the dumper accepts, and what `SCHEMA_EXTRACT` must return for each, are in the
|
||||
`hidden_schemas` branch of `convert()` in `script/build_language_schema.py`. The common ones:
|
||||
|
||||
| Type | Return for `SCHEMA_EXTRACT` | Example |
|
||||
|------|-----------------------------|---------|
|
||||
| `"schema"` | the schema (or a representative one) to walk | `api` `_outgoing_connection_schema`, `restore_state_schema` in `esphome/core/entity_helpers.py`, `model_schema_extractor` in `esphome/components/mipi/__init__.py` |
|
||||
| `"enum"` | the dict of options | `sensor_entity_category` in `esphome/components/sensor/__init__.py` |
|
||||
| `"one_of"` | the list of options | `cv.one_of` |
|
||||
| `"maybe"` | `(validator, key)` for a scalar shorthand | `cv.maybe_simple_value`, `maybe_conf` in `esphome/automation.py` |
|
||||
| `"templatable"` | the inner validator | `cv.templatable` |
|
||||
| `"automation"` | the automation schema | `automation.validate_automation` |
|
||||
|
||||
Points to watch:
|
||||
|
||||
- The dumper matches the function object by `repr`, so the decorated function must be the exact object
|
||||
placed in the schema. In a factory that returns a new closure per call, decorate the inner function (as
|
||||
`restore_state_schema` and `cv.maybe_simple_value` do), not the factory.
|
||||
- The decorator only records anything when extraction is enabled, and that flag is set before imports, so
|
||||
the decorator must run at import time (module level or inside a factory called at module level).
|
||||
- A schema chosen per config value (for example by `model`) has no single answer: return a representative
|
||||
schema, as `model_schema_extractor` does.
|
||||
- Plain value checks (`cv.All(cv.string, my_check)`) need nothing: the dumper still sees the surrounding
|
||||
types and keys.
|
||||
- `cv.Schema`, `cv.typed_schema`, `cv.ensure_list` and the `cv` helpers are already handled; prefer
|
||||
composing them over a custom function, which also avoids the extractor work.
|
||||
|
||||
### Checking the output
|
||||
|
||||
Build the language schema into a scratch directory and look at the component's JSON (it takes about ten
|
||||
seconds):
|
||||
|
||||
```bash
|
||||
python3 script/build_language_schema.py --output-path /tmp/schema
|
||||
python3 -c "import json; print(json.dumps(json.load(open('/tmp/schema/my_component.json')), indent=1))" | less
|
||||
```
|
||||
|
||||
Every key a user can write should appear under `config_vars` with a sensible `type`; compare against the
|
||||
same output from `dev` when changing an existing schema. If the script prints `Unknown extracted schema
|
||||
type` or raises, the decorator type or the value returned for `SCHEMA_EXTRACT` is wrong. Unit tests for
|
||||
the dumper live in `tests/script/test_build_language_schema.py`.
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
name: embedded-memory
|
||||
description: Choose ESPHome C++ containers and storage that avoid heap use and fragmentation on microcontrollers - std::array, StaticVector with cg.slot_counter, FixedVector, std::unique_ptr byte buffers, StringRef, RAMAllocator, and what to avoid (std::vector growth, std::map, std::deque, std::string, new (std::nothrow)). Use when adding or changing C++ members, containers, buffers, strings or registration lists, or when reducing RAM or flash use.
|
||||
---
|
||||
|
||||
# Memory on embedded targets
|
||||
|
||||
ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over
|
||||
time, repeated allocations of different sizes fragment the heap. Failures happen when the largest
|
||||
contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by
|
||||
this.
|
||||
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** Every
|
||||
allocation/deallocation cycle contributes to fragmentation. ESPHome treats runtime heap allocation as a
|
||||
long-term reliability bug, not a performance issue. Helpers that hide allocation (`std::string`,
|
||||
`std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based
|
||||
APIs.
|
||||
|
||||
## Container guidelines
|
||||
|
||||
1. **Compile-time-known sizes:** use `std::array` instead of `std::vector` when size is known at compile
|
||||
time.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code
|
||||
std::vector<int> values;
|
||||
|
||||
// Good - no dynamic allocation
|
||||
std::array<int, MAX_VALUES> values;
|
||||
```
|
||||
Use `cg.add_define("MAX_VALUES", count)` to set the size from Python configuration.
|
||||
|
||||
**For byte buffers:** avoid `std::vector<uint8_t>` unless the buffer needs to grow. Use
|
||||
`std::unique_ptr<uint8_t[]>` instead. It does **not** provide bounds checking or iterator support like
|
||||
`std::vector<uint8_t>`; use it only when you do not need these features.
|
||||
```cpp
|
||||
// Bad - STL overhead for simple byte buffer
|
||||
std::vector<uint8_t> buffer;
|
||||
buffer.resize(256);
|
||||
|
||||
// Good - minimal overhead, single allocation
|
||||
std::unique_ptr<uint8_t[]> buffer = std::make_unique<uint8_t[]>(256);
|
||||
// Or if size is constant:
|
||||
std::array<uint8_t, 256> buffer;
|
||||
```
|
||||
|
||||
2. **Compile-time-known fixed sizes with vector-like API:** use `StaticVector` from
|
||||
`esphome/core/helpers.h` for compile-time fixed size with a `push_back()` interface (no dynamic
|
||||
allocation).
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<ServiceRecord> services;
|
||||
services.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - compile-time fixed size, no dynamic allocation
|
||||
StaticVector<ServiceRecord, MAX_SERVICES> services;
|
||||
services.push_back(record1);
|
||||
```
|
||||
Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that is
|
||||
known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it returns a
|
||||
function that each consumer calls once per slot it will occupy, and after every `to_code` has run it
|
||||
emits the define with the final count. When nothing registers, no define is emitted and the storage plus
|
||||
its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a `MULTI_CONF`
|
||||
component), pass the owning object as the key, `_request_listener_slot(str(hub))`; the define is then
|
||||
the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL`; a late request
|
||||
raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** use `FixedVector` from `esphome/core/helpers.h` when the size is only known at
|
||||
runtime initialization.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<TxtRecord> txt_records;
|
||||
txt_records.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - runtime size, single allocation, no reallocation machinery
|
||||
FixedVector<TxtRecord> txt_records;
|
||||
txt_records.init(record_count); // Initialize with exact size at runtime
|
||||
```
|
||||
This eliminates `_M_realloc_insert` and `_M_default_append` template instantiations (200-500 bytes per
|
||||
instance), needs no upper bound, and is compatible with protobuf code generation when using
|
||||
`[(fixed_vector) = true]`.
|
||||
|
||||
4. **Small datasets (1-16 elements):** use `std::vector` or `std::array` with simple structs instead of
|
||||
`std::map`/`std::set`/`std::unordered_map`.
|
||||
```cpp
|
||||
// Bad - 2KB+ overhead for red-black tree/hash table
|
||||
std::map<std::string, int> small_lookup;
|
||||
std::unordered_map<int, std::string> tiny_map;
|
||||
|
||||
// Good - simple struct with linear search (std::vector is fine)
|
||||
struct LookupEntry {
|
||||
const char *key;
|
||||
int value;
|
||||
};
|
||||
std::vector<LookupEntry> small_lookup = {
|
||||
{"key1", 10},
|
||||
{"key2", 20},
|
||||
{"key3", 30},
|
||||
};
|
||||
// Or std::array if size is compile-time constant:
|
||||
// std::array<LookupEntry, 3> small_lookup = {{ ... }};
|
||||
```
|
||||
Linear search on small datasets is often faster than hashing or tree overhead, but this depends on
|
||||
lookup frequency; in hot paths the O(1) vs O(n) difference may still matter. It is the heavy containers
|
||||
(`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows
|
||||
otherwise.
|
||||
|
||||
5. **Strings set once from configuration:** use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the program,
|
||||
so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a non-owning pointer plus
|
||||
length; it does not copy, and it must only ever refer to storage that outlives it (a string literal, or
|
||||
a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **Avoid `std::deque`:** it allocates in 512-byte blocks regardless of element size, guaranteeing at
|
||||
least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained
|
||||
devices.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** on ESP-IDF exceptions are disabled, so a failed nothrow allocation
|
||||
aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects
|
||||
`std::nothrow`.
|
||||
|
||||
8. **Detection:** look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
- Red-black tree code (`rb_tree`, `_Rb_tree`)
|
||||
- Hash table infrastructure (`unordered_map`, `hash`)
|
||||
|
||||
Callback storage (`CallbackManager` vs `LazyCallbackManager`) and templated registration methods are
|
||||
covered in the `automations` skill.
|
||||
|
||||
## Where to spend effort
|
||||
|
||||
Prioritize container optimization for core components (API, network, logger), widely used components
|
||||
(mdns, wifi, ble), and components causing flash size complaints. Avoiding heap allocation after `setup()`
|
||||
is always required regardless of component type; the priority list is about effort spent on container
|
||||
migration (e.g. `std::vector` to `StaticVector`).
|
||||
@@ -67,9 +67,17 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
automations in the config itself.
|
||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
||||
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
|
||||
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
|
||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
||||
no IP addresses.
|
||||
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
|
||||
|
||||
```
|
||||
> [!NOTE]
|
||||
> Automated testing results
|
||||
```
|
||||
|
||||
Then one short sentence saying what was tested (builds and SHAs, board, what was driven), a results table
|
||||
(build SHA, board and framework, what was driven, counts, result, plus the `dev` control row), short bullets
|
||||
for the findings, and the setup and exact flow in a collapsed `<details>` block. Answer any question asked on
|
||||
the PR directly in the comment. No credentials, SSIDs, IP addresses, port names or other bench details.
|
||||
|
||||
## Flashing by chip family
|
||||
|
||||
@@ -115,7 +123,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame).
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
|
||||
@@ -125,6 +125,11 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
|
||||
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
|
||||
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
|
||||
- Moving a BK7231N device (CB3S) that sits on its fallback AP to another network: join the host's Wi-Fi to
|
||||
`<name>-fallback`, OTA to 192.168.4.1, then rejoin the host's own network. Moving it with `/wifisave` instead can
|
||||
leave it in the captive portal's BLIND_RETRY state (no scan, `Beacon Timeout` every 46 s) until it reboots, and a
|
||||
log-only serial adapter cannot reset it. Rejoining may change the host's address; update a syslog target that
|
||||
points at the old one.
|
||||
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
||||
radio enable 160 to 278 ms, disable about 200 ms.
|
||||
|
||||
@@ -172,6 +177,12 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
|
||||
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
|
||||
a never-saved value.
|
||||
- Thread: nRF52 `openthread:` accepts only `tlv:`; the individual keys (network_key, channel, ...) are rejected at
|
||||
validation. To list nearby networks with channel and PAN ID, call
|
||||
`otThreadDiscover(InstanceLock::acquire().get_instance(), 0, 0xFFFF, false, false, cb, nullptr)` from an
|
||||
`interval:` lambda in an included header; results arrive on the OpenThread thread and log fine.
|
||||
- A wrong network key on a real network's channel and PAN ID makes the device form its own partition as leader on
|
||||
that PAN within about 30 s; take it off Thread at once. Never try guessed datasets against a network in use.
|
||||
|
||||
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||
|
||||
|
||||
@@ -13,18 +13,18 @@ One file per component, named after it:
|
||||
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot
|
||||
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
|
||||
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
|
||||
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
|
||||
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark
|
||||
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
|
||||
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
@@ -36,5 +36,8 @@ One file per component, named after it:
|
||||
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
|
||||
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
|
||||
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
|
||||
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
|
||||
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols per chip, loop cost per strip
|
||||
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
|
||||
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
|
||||
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
|
||||
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
|
||||
@@ -0,0 +1,17 @@
|
||||
# BLE terminate with peripheral latency (#20232)
|
||||
|
||||
`scripts/ble_terminate`: an ESP32-S3 `bluetooth_proxy` (active) over Ethernet as the central (`btproxy.yaml`) and a
|
||||
classic devkit `esp32_ble_server` peer (`btpeer.yaml`). The peer asks for 50 ms / latency 10 / 6 s with
|
||||
`esp_ble_gap_update_conn_params` 4 s after connect; earlier requests are overwritten by the proxy's own "medium
|
||||
conn params" after service discovery. Read the proxy's `Conn params updated` line (VERBOSE, API logs) to confirm;
|
||||
the peer side never logs the event. The proxy only connects to a device its scanner has seen, so `btdrive.py`
|
||||
subscribes to raw advertisements first.
|
||||
|
||||
Measure the peer's own disconnect (`serial_capture.py --ms`) after the client's disconnect: about 0.2 to 0.8 s when
|
||||
the terminate arrived, about 6 s (the supervision timeout) when it was lost. The client side disconnect time is the
|
||||
API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
|
||||
|
||||
`run.sh LABEL ROUNDS` writes `central_bda.hpp` from `PROXY_BT_MAC`, builds and flashes both boards, runs the rounds
|
||||
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
|
||||
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
|
||||
latency and counts as inconclusive).
|
||||
@@ -0,0 +1,12 @@
|
||||
# Fake I2C slave (#21586)
|
||||
|
||||
`scripts/i2c_slave`: a D1 mini running an Arduino `Wire` slave at 0x40 (`i2cslave.yaml` with `i2cslave.hpp`): a fake
|
||||
HDC1080 plus a 256 byte register file, and reads of 0x7E and 0x7F stretch the clock for 0.5 and 5 ms. It needs
|
||||
`libraries: [Wire]` and 160 MHz. With jumper wires only and the internal pull ups it works to about 50 kHz; it cannot
|
||||
keep up with a true 100 kHz hardware master.
|
||||
|
||||
The master (`i2cbk.yaml`, a BK7238 on P26/P24, needs the software I2C bus from that PR) writes a counter, reads it
|
||||
back with a repeated start, probes an absent address and reads both stretch registers every 2 s, logging `RW OK`
|
||||
(the read back matches, the absent address NACKs and both stretched reads succeed) or `RW BAD` with running totals.
|
||||
Control: the same blocks on a classic ESP32 devkit (SDA GPIO21, SCL GPIO22) with hardware I2C at 25 and 50 kHz got
|
||||
every repeated start read right, so failures on a bit banged master at a similar real clock point at the master.
|
||||
@@ -8,3 +8,7 @@
|
||||
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
|
||||
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
|
||||
add effects; restore its own config afterwards.
|
||||
- Loop cost of a strip (#19337): give each strip an `addressable_lambda` effect with `update_interval: 0ms` that
|
||||
changes every pixel, and add `runtime_stats: log_interval: 30s`; compare the main loop and light times per pass,
|
||||
leaving out the first period after boot. The strips need no LEDs attached. A classic ESP32 has 512 RMT symbols in
|
||||
all, so three strips need `rmt_symbols: 128` each.
|
||||
@@ -56,3 +56,18 @@ OTA-only board: copy the device's own config, add the probe include and the step
|
||||
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
|
||||
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
|
||||
client gets it on subscribe) or on the board's serial log port if it has one.
|
||||
|
||||
## RTL8720C crash report on the next boot (#21648)
|
||||
|
||||
`templates/rtl8720c-crash-buttons-dut.yaml` has one API button per case: `trap` (`__builtin_trap()`, usage fault),
|
||||
`bus fault` (store to 0x70000000, imprecise, CFSR 0x400), `wild call` (call to 0xFFFFFFF1, MemManage fault with PC
|
||||
0xFFFFFFF0 and LR in the caller), `isr trap` (a trap from a spare interrupt pended by software, hard fault), `hang`
|
||||
(interrupts off, the watchdog resets it and nothing must be reported) and `restart`. Every boot logs
|
||||
`boot reason N, crash record yes|no|n/a`; `n/a` means the handler is not built, as on a dev control build.
|
||||
|
||||
Flow per build: flash with `flash_rtl87xx_kit.py`, start one `serial_capture.py PORT 600 OUT --released --ms`, then
|
||||
`scripts/libretiny_crash/rtl_drive.py HOST OUTDIR TREE YAML trap restart "bus fault" "wild call" "isr trap" hang restart`.
|
||||
The `restart` after the first crash checks the report shows only once. Time each reset from the serial capture
|
||||
(the ROM fault dump to the next `ROM Version` line), not from the API port: the board is back before an API client
|
||||
notices it went away. Compare the reported PC with the `PC =` line of the ROM dump, and check the decoded lines in the
|
||||
`caseN-*.txt` files. Pass the UF2 by name (`firmware.uf2`): the build directory holds a second identically sized copy.
|
||||
@@ -4,3 +4,10 @@
|
||||
|
||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
||||
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
||||
|
||||
## ItsyBitsy over UF2 when serial DFU fails (#20481)
|
||||
|
||||
Serial DFU to the ItsyBitsy nRF52840 fails about half the time ("Target is not in DFU mode"). Reliable path: a
|
||||
1200 baud touch on the app port, wait for the `ITSY840BOOT` volume, convert `zephyr/zephyr.hex` with the SDK's
|
||||
`zephyr/scripts/build/uf2conv.py -f 0xADA52840 -c` and copy the .uf2 to the volume (the copy ends in an I/O error
|
||||
when the board reboots, which is normal). macOS BLE scans give UUIDs, not MAC addresses.
|
||||
@@ -6,3 +6,51 @@ All overlap orders (a web upload during a native OTA and the reverse) must be re
|
||||
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
|
||||
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
|
||||
`pkill -f "a\|b"` does not alternate; use two pkills.
|
||||
|
||||
## OTA upload timing A/B (#21639, #21641, #21642)
|
||||
|
||||
`scripts/ota_timing/run.sh N PHASE...` times native OTA uploads per build (phase) and per board. It builds every
|
||||
board from `TREE_<PHASE>`, does one warm-up upload per board (it installs the phase's build and is left out of the
|
||||
results), checks over the API that each board reports the compile time from that build's `build_info.json`
|
||||
(`apiinfo.py`), then runs N rounds of every mode on every board. Boards are interleaved per round so they share the
|
||||
same air; put a nearby ESP32 on the same Wi-Fi in `BOARDS` as a network control. `summ.py` prints medians: never
|
||||
judge from single runs, slow Wi-Fi gave 33 to 235 s for the same upload in one night.
|
||||
|
||||
- `otatime.py` drives `espota2.run_ota()` from the tree on `PYTHONPATH` and prints one `RESULT` line with the
|
||||
`Update took` split, the compressed size, whether UDP was used and the time at each tenth of the upload.
|
||||
An upload that leaves no `RESULT` line (board gone, script crash, timeout) is recorded as `rc: -1`, and `summ.py`
|
||||
fails when a board and mode has fewer than N rows or an upload time cannot be read.
|
||||
- Boards with an `api: encryption: key` also encrypt OTA: set `API_KEY` for `otatime.py` and `apiinfo.py`. LibreTiny
|
||||
LN882H listens for OTA on 8820, not 8892; BK72xx and RTL87xx use 8892.
|
||||
- Raw mode leaves `CLIENT_FEATURE_SUPPORTS_DEFLATE` out of the client's offer. Never switch deflate off after the
|
||||
offer (`if deflate and 0:`): once the device accepted deflate it expects a deflate stream.
|
||||
- `-tcp` modes make `espota2._loss_reason` return None, so the client asks to stay on TCP. Clearing
|
||||
`CLIENT_FEATURE_SUPPORTS_UDP` instead desyncs the handshake (`receiving UDP token response: timed out`).
|
||||
- LibreTiny trimmed UF2 (#21642): the CLI sends what `esphome.components.libretiny.uf2.device_ota_uf2()` returns for
|
||||
`firmware.uf2`; that module only exists with #21642, so on older trees the full UF2 goes out. Compare the sizes
|
||||
in the `Compressed to` lines, and rely on the API compile time check to show that the trimmed image booted.
|
||||
|
||||
`receiving chunk result: Unknown error from ESP` is error 0xFF sent by the device, not a dropped connection. The
|
||||
device sends it for a decode or flash write error, and also when no data arrives for 105 s, so on a weak link it
|
||||
usually means a stall. A failure about 105 s after the last progress, or the same error on a raw upload, points to
|
||||
the link; to be sure, capture the device log (`Inflate err`, `Inflate overrun`, `No data received`).
|
||||
|
||||
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409628227 (dev and LibreTiny 1.14.0 on a
|
||||
CB3S), https://github.com/esphome/esphome/pull/21642#issuecomment-6412972562 (trimmed UF2 on CB3S, BK7238 and BW15),
|
||||
https://github.com/esphome/esphome/pull/21641#issuecomment-6412976859 (new decoder on the same three).
|
||||
|
||||
## On-device inflate benchmark (#21639, #21641)
|
||||
|
||||
`scripts/ota_timing/inflate_bench`: `gen_streams.py NAME=IMAGE...` cuts a 128 KB slice from each image and writes
|
||||
`bench_streams.hpp` with raw deflate streams (level 9, 4 KB and 32 KB windows). `bench.hpp` inflates every stream with
|
||||
each ring size at least the encoder window, three times, and logs the best time plus input and output rates, then
|
||||
`BENCHDONE`. `bench.sh CONFIG DEVICE` builds and uploads `bench-esp32s3.yaml` or `bench-bk72xx.yaml`, collects one pass
|
||||
over syslog and fails when a case did not reach the end of its stream.
|
||||
|
||||
- LibreTiny defines `micros` as a macro, so the header calls `(esphome::micros)()`.
|
||||
- Keep the streams near 130 KB on a BK7231N: an image with about 330 KB of extra incompressible data failed its
|
||||
OTA with "Writing OTA data to flash memory failed". Use one image and `--windows 12` there.
|
||||
- `bench.hpp` targets the decoder interface with `dest_limit`. #21641 drops it (the output runs into the window up
|
||||
to `dest`); after that lands, remove the `dest_limit` line.
|
||||
|
||||
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409786224
|
||||
@@ -37,3 +37,44 @@ AP start (the fix in that PR), the first AP start failed on every boot with `esp
|
||||
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
|
||||
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
|
||||
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
|
||||
|
||||
## Reboot against a WPA2/WPA3 PMF access point (#21590)
|
||||
|
||||
- AP: `scripts/wpa3_ap/wdap.yaml` on a classic devkit. ESPHome `wifi: ap:` only, then an on_boot lambda switches it
|
||||
to `WIFI_AUTH_WPA2_WPA3_PSK`, PMF capable, and registers a WIFI_EVENT handler that logs `STA JOIN` and
|
||||
`STA LEAVE ... reason N` (the include header pulls in `esp_wifi.h`). It disables Wi-Fi after 420 s.
|
||||
- AP leave reason 3 means the station sent a deauth; reason 2 means the AP found a stale association when the
|
||||
station came back; several reason 2 before one join are rejected reconnects.
|
||||
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the house
|
||||
network at 0, and reboot 30 s after joining the AP and 150 s after joining the house network. So they move to the
|
||||
AP while it is up and fall back to the house network (where they can be flashed over the air) once its window
|
||||
ends. `run.sh BUILD` flashes the AP, does OTA for the CB3S and a serial flash for the BK7238, then captures one AP
|
||||
window; `an.py` prints each station's join and leave sequence and fails on a rejected reconnect, an AP that did
|
||||
not switch to WPA2/WPA3 (`AP CONFIG FAILED`), a DUT missing from the AP events (`DUT_MACS`) or a BK7238 that never
|
||||
joined the AP.
|
||||
- Gotchas: the LibreTiny build directory has two `.uf2` files, so flash `firmware.uf2`. The BK7238 serial flash
|
||||
sometimes fails to link while the chip is in a reboot loop; retry it 3 or 4 times. OTA fails if the DUT reboots
|
||||
mid-upload; retry.
|
||||
- Finding: BDK 3.0.78 rejects about 2 reconnects per reboot when the station left without a deauth; BDK 3.0.33
|
||||
does not.
|
||||
|
||||
## WISP and ALWAYS_ON AP rig (#20077)
|
||||
|
||||
`scripts/wisp_ap`: the upstream is an ESP32-S3 with Ethernet running a WISP bridge (`wisp-upstream.yaml`) with its AP on
|
||||
10.40.0.1/24 and web buttons `ap off` and `ap on`. Its subnet must not be 192.168.4.0/24, the DUT AP's default, or
|
||||
nothing routes. The DUT is a classic devkit (`dut-wisp.yaml` or `dut-always-on.yaml`); the client is a D1 mini on
|
||||
the DUT's AP (`wispcli.yaml`) logging its DNS server, a name lookup, a GET by name, the AP itself and a LAN file by
|
||||
IP. `run.sh DUT START SECONDS T:STATE...` switches the upstream AP at the given times and `sum.py` groups the client
|
||||
results by phase. Capture the D1 mini without `--reset` (a CH340 floods garbage after an RTS reset). Clients keep
|
||||
the captive portal's DNS answer for its TTL.
|
||||
|
||||
## AP restart with WPA3 mixed and LIGHT power save (#21531)
|
||||
|
||||
`scripts/wifi_restart`: the WISP upstream (`wisp-upstream-wpa3.yaml`) switched to WPA2/WPA3 mixed by a script that
|
||||
runs again after every `ap on`, DUTs (`rebk.yaml`, `recb.yaml` on `re-common.yaml`) with a static IP on the bridge's
|
||||
subnet, a fallback AP, `power_save_mode: LIGHT` and syslog to the host through the NAT. `run.sh BUILD` switches the
|
||||
AP off and on three times; `an.py` fails on a reboot, a crash or a DUT that is not back on syslog within 120 s of an
|
||||
AP start. After 19 minutes the DUTs run `wifi.configure` to the house network; that keeps the static IP, so the next
|
||||
OTA needs the host to have an address on the bridge's subnet as well (an interface alias). The CB3S log is on UART1
|
||||
(TX1), so its configs need `logger: hardware_uart: UART1`. Runners must `pkill` only their own ports; a global pkill
|
||||
stops the other runs' captures.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Xiaomi BLE
|
||||
|
||||
## Xiaomi, ATC and PVVX parsing without the sensors (#21483)
|
||||
|
||||
No Xiaomi devices are needed, and one ESP32 cannot hear its own advertising, so the frames are injected into the
|
||||
tracker as scan results: everything after the radio runs as normal.
|
||||
|
||||
- `scripts/xiaomi_ble/gen.py` writes `frames.hpp` and `expected.json`: plain MiBeacon (0x50), encrypted v5 23 byte
|
||||
with MAC and 19 byte without, real AES-CCM (`cryptography` AESCCM, tag 4, AAD 0x11, nonce = MAC reversed + pid +
|
||||
cnt + 3 byte ext counter), ATC (13 byte, big endian) and PVVX (15 byte, little endian) 0x181A frames, a corrupted
|
||||
tag every other cycle and a counter 0 boot frame. It models the old shared duplicate counter (`base`) and the per
|
||||
device one (`pr`) and prints the expected publish counts.
|
||||
- `xbinject.yaml` (ESP-IDF devkit) includes `frames.hpp`; an `interval:` builds a `BLEScanResult` and calls
|
||||
`global_esp32_ble_tracker->gap_scan_event_handler(r)`. `count.py` counts the `Got Xiaomi` blocks (sensor
|
||||
publishes are not logged at DEBUG) and compares them with `expected.json`.
|
||||
- On ESP32 the tracker's own triggers sit in `listeners_`, which runs before `neutral_listeners_` (the
|
||||
ble_device_base devices), so an `on_ble_service_data_advertise` never sees what a Xiaomi listener did to the
|
||||
shared data. Check that directly: `xb_rewrite_probe(id(xa))` builds an `ESPBTDevice` with `parse_scan_rst()`,
|
||||
calls the component's `parse_device()` and logs the service data before and after (`[probe` lines).
|
||||
- `run.sh inject pr|base` builds, flashes, captures and prints PASS when the counts match that build's model and,
|
||||
on the PR build, the probe's service data is the same before and after `parse_device()`.
|
||||
Base dropped a second device's frame with the same counter, the frame after a bad tag and a first frame with
|
||||
counter 0, and rewrote the shared data in place; the PR fixed all of them.
|
||||
|
||||
## Over the air with a second ESP32
|
||||
|
||||
- Advertiser: an ESP32-S3 with Ethernet (OTA over Ethernet), `xbadv.yaml` with `adv_frames.hpp` from
|
||||
`gen.py --ota`. Per frame: stop advertising, `esp_ble_gap_set_rand_addr`, `config_adv_data_raw`, start
|
||||
non-connectable advertising with `own_addr_type` random, 20 ms interval, 250 ms dwell. Random static addresses
|
||||
need the top two bits set, so these use C4:C1:38 instead of A4. `esp32_ble:` alone brings up the stack. The
|
||||
web_server button starts a run: `curl -X POST -d "" http://<advertiser>/button/start/press` (no body gives 411).
|
||||
Its `failed sends` sensor counts only GAP calls rejected when queued; a frame the controller drops later shows
|
||||
up as missing publishes in `count.py`, the same as radio loss.
|
||||
- Receiver: `xbrecv.yaml` on the devkit, passive scan with window = interval = 100 ms. `run.sh ota pr|base` (with
|
||||
`ADV` set) flashes it, resets it with a capture and presses start 18 s later, so the counter 0 boot frame is the
|
||||
first one it hears.
|
||||
- Each frame arrives about 4 times; the counts matched the injection run exactly apart from one radio loss, so the
|
||||
over the air comparison allows two missing publishes per tag.
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Match each proxy disconnect with the peer's own disconnect line and count the links the peer kept.
|
||||
Usage: an.py DIR LABEL [ROUNDS] reads LABEL-drive.txt, LABEL-peer.txt (serial_capture --ms) and LABEL-proxy.txt
|
||||
(esphome logs, VERBOSE). A round counts only when the last `Conn params updated` the proxy logged since the previous
|
||||
disconnect shows latency=10; otherwise it is inconclusive. Exits 1 when the peer kept a link past 3 s (the terminate
|
||||
was lost), a round was inconclusive, or fewer than ROUNDS rounds completed."""
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, label = Path(sys.argv[1]), sys.argv[2]
|
||||
ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
|
||||
|
||||
def read(name: str) -> list[str]:
|
||||
return (
|
||||
(D / f"{label}-{name}.txt")
|
||||
.read_text(encoding="utf-8", errors="replace")
|
||||
.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def ptime(line: str) -> float | None:
|
||||
"""Seconds since midnight from a leading [HH:MM:SS.mmm] stamp."""
|
||||
m = re.match(r"\[(\d+):(\d+):(\d+)\.(\d+)\]", line)
|
||||
return (
|
||||
int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
|
||||
)
|
||||
|
||||
|
||||
pdisc = [
|
||||
t
|
||||
for line in read("peer")
|
||||
if "PEER DISCONNECT" in line and (t := ptime(line)) is not None
|
||||
]
|
||||
params = [
|
||||
(t, int(m[1]))
|
||||
for line in read("proxy")
|
||||
if "Conn params updated" in line
|
||||
and (m := re.search(r"latency=(\d+)", line))
|
||||
and (t := ptime(line)) is not None
|
||||
]
|
||||
res = []
|
||||
prev = -1.0
|
||||
for line in read("drive"):
|
||||
m = re.search(
|
||||
r"R(\d+) .*DISC_CALL ([\d.]+) DISC_DONE ([\d.]+) \(\+([\d.]+)s\)", line
|
||||
)
|
||||
if not m:
|
||||
if line.startswith("R"):
|
||||
print(" ", line)
|
||||
continue
|
||||
# Both captures stamp local wall clock time; compare as seconds since midnight.
|
||||
t = dt.datetime.fromtimestamp(float(m[2])).astimezone()
|
||||
tc = t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
|
||||
after = [p - tc for p in pdisc if -1 < p - tc < 12]
|
||||
window = [lat for pt, lat in params if prev < pt <= tc]
|
||||
res.append(
|
||||
(
|
||||
int(m[1]),
|
||||
float(m[4]),
|
||||
after[0] if after else None,
|
||||
window[-1] if window else None,
|
||||
)
|
||||
)
|
||||
prev = tc
|
||||
for r, done, pd, lat in res:
|
||||
peer = (
|
||||
f"peer disconnect +{pd:.2f}s" if pd is not None else "peer disconnect not seen"
|
||||
)
|
||||
print(f" R{r:2} central done +{done:.3f}s {peer} latency at disconnect {lat}")
|
||||
late = sum(1 for _, _, pd, lat in res if lat == 10 and (pd is None or pd > 3))
|
||||
inconclusive = sum(1 for *_, lat in res if lat != 10)
|
||||
print(
|
||||
f"{label}: rounds {len(res)}, peer kept the link (>3 s or not seen) {late}, inconclusive {inconclusive}"
|
||||
)
|
||||
failed = []
|
||||
if not res:
|
||||
failed.append("no round completed")
|
||||
if ROUNDS is not None and len(res) < ROUNDS:
|
||||
failed.append(f"only {len(res)} of {ROUNDS} rounds completed")
|
||||
if late:
|
||||
failed.append(f"{late} links kept")
|
||||
if inconclusive:
|
||||
failed.append(
|
||||
f"INCONCLUSIVE: {inconclusive} rounds had no latency=10 update before the disconnect"
|
||||
)
|
||||
print(f"PASS: {label}" if not failed else f"FAIL: {label}: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Connect to the peer through the proxy, read its services, idle, disconnect; print host timestamps per round.
|
||||
Usage: btdrive.py PROXY KEY PEER_MAC ROUNDS IDLE_S"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
|
||||
async def main(host: str, key: str, peer: str, rounds: int, idle: float) -> int:
|
||||
addr = int(peer.replace(":", ""), 16)
|
||||
cli = APIClient(host, 6053, None, noise_psk=key)
|
||||
await cli.connect(login=True)
|
||||
info = await cli.device_info()
|
||||
print(f"proxy {info.name} bt_mac {info.bluetooth_mac_address}", flush=True)
|
||||
seen = 0
|
||||
|
||||
def adv(msg) -> None:
|
||||
nonlocal seen
|
||||
seen += sum(a.address == addr for a in msg.advertisements)
|
||||
|
||||
# The proxy only connects to a device its scanner has seen.
|
||||
cli.subscribe_bluetooth_le_raw_advertisements(adv)
|
||||
await asyncio.sleep(5)
|
||||
print(f"peer advertisements seen in 5 s: {seen}", flush=True)
|
||||
if not seen:
|
||||
print("FAIL: the proxy does not see the peer", flush=True)
|
||||
await cli.disconnect()
|
||||
return 1
|
||||
done = 0
|
||||
for r in range(1, rounds + 1):
|
||||
state: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def cb(
|
||||
connected: bool, mtu: int, err: int, state: asyncio.Queue = state
|
||||
) -> None:
|
||||
state.put_nowait((connected, err, time.time()))
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
cancel = await cli.bluetooth_device_connect(
|
||||
addr, cb, timeout=20, address_type=0, feature_flags=0xFF
|
||||
)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
print(f"R{r} CONNECT ERROR {type(e).__name__}", flush=True)
|
||||
await asyncio.sleep(9)
|
||||
continue
|
||||
try:
|
||||
c, err, t = await asyncio.wait_for(state.get(), 25)
|
||||
except TimeoutError:
|
||||
print(f"R{r} CONNECT TIMEOUT", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
if not c:
|
||||
print(f"R{r} CONNECT FAIL err {err} after {t - t0:.2f}s", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
try:
|
||||
n = len((await cli.bluetooth_gatt_get_services(addr)).services)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
n = f"err {e!r}"
|
||||
await asyncio.sleep(idle)
|
||||
td = time.time()
|
||||
await cli.bluetooth_device_disconnect(addr)
|
||||
try:
|
||||
c2, err2, t2 = await asyncio.wait_for(state.get(), 15)
|
||||
if c2 or not isinstance(n, int):
|
||||
# an unexpected connect state or a failed service read: the round did not run as intended
|
||||
print(
|
||||
f"R{r} BAD ROUND services {n} connected {c2} err {err2}", flush=True
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} DISC_DONE {t2:.3f} (+{t2 - td:.3f}s) err {err2}",
|
||||
flush=True,
|
||||
)
|
||||
done += 1
|
||||
except TimeoutError:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} no disconnect state", flush=True
|
||||
)
|
||||
cancel()
|
||||
await asyncio.sleep(
|
||||
9
|
||||
) # a peer that missed the terminate hits its 6 s supervision timeout in this gap
|
||||
await cli.disconnect()
|
||||
print(f"rounds completed {done} of {rounds}", flush=True)
|
||||
return 0 if done == rounds else 1
|
||||
|
||||
|
||||
sys.exit(
|
||||
asyncio.run(
|
||||
main(
|
||||
sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <cstring>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include "esphome/core/log.h"
|
||||
#include "central_bda.hpp" // written by run.sh from PROXY_BT_MAC
|
||||
|
||||
// 50 ms interval, peripheral latency 10, 6 s supervision timeout
|
||||
inline void btpeer_request_latency() {
|
||||
esp_ble_conn_update_params_t p{};
|
||||
memcpy(p.bda, CENTRAL_BDA, 6);
|
||||
p.min_int = 40;
|
||||
p.max_int = 40;
|
||||
p.latency = 10;
|
||||
p.timeout = 600;
|
||||
esp_err_t err = esp_ble_gap_update_conn_params(&p);
|
||||
ESP_LOGI("peer", "requested interval 50 ms latency 10 timeout 6 s: %d", err);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
esphome:
|
||||
name: btpeer
|
||||
includes:
|
||||
- btpeer.hpp
|
||||
- central_bda.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble: VERBOSE
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 1
|
||||
|
||||
esp32_ble_server:
|
||||
manufacturer: test
|
||||
on_connect:
|
||||
- lambda: ESP_LOGI("peer", "PEER CONNECT conn %u", (unsigned) id);
|
||||
- delay: 4s
|
||||
- lambda: btpeer_request_latency();
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("peer", "PEER DISCONNECT conn %u", (unsigned) id);
|
||||
services:
|
||||
- uuid: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
|
||||
advertise: true
|
||||
characteristics:
|
||||
- uuid: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
|
||||
read: true
|
||||
value: "peer"
|
||||
@@ -0,0 +1,44 @@
|
||||
substitutions:
|
||||
name: btproxy
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: VERBOSE
|
||||
logs:
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
esp32_ble_tracker: DEBUG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh LABEL ROUNDS build both boards, then ROUNDS connect/disconnect rounds through the proxy
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (api_key, base64 Noise key) and run it there.
|
||||
# env: TREE (esphome checkout to build the proxy from), PORT (peer devkit serial port), PROXY (proxy address, flashed
|
||||
# over the air; the first flash of a fresh board goes over USB), PROXY_BT_MAC (the proxy's Bluetooth MAC, printed by
|
||||
# btdrive.py as bt_mac), PEER_MAC (the peer's Bluetooth MAC, base MAC + 2 on a classic ESP32), SC (path to
|
||||
# serial_capture.py), PY (checkout python), PEER_TREE (checkout for the peer, defaults to TREE)
|
||||
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=${2:-12}; T=${TREE:?set TREE}; PT=${PEER_TREE:-$T}
|
||||
P=${PORT:?set PORT}; S3=${PROXY:?set PROXY}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
PEER=${PEER_MAC:?set PEER_MAC}; CM=${PROXY_BT_MAC:?set PROXY_BT_MAC}
|
||||
KEY=$(sed -n 's/^api_key: *"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$D/secrets.yaml")
|
||||
[ -n "$KEY" ] || { echo "FAIL: no api_key in secrets.yaml"; exit 1; }
|
||||
cd "$D" || exit 1
|
||||
printf '#pragma once\n#include <cstdint>\nstatic const uint8_t CENTRAL_BDA[6] = {0x%s};\n' "${CM//:/, 0x}" > central_bda.hpp
|
||||
(cd "$PT" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btpeer.yaml" --device "$P" > "$D/$L-peer-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: peer build or upload failed"; exit 1; }
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: proxy build or upload failed"; exit 1; }
|
||||
SECS=$((N * 26 + 40))
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
$PY "$SC" "$P" $SECS "$D/$L-peer.txt" --ms > /dev/null 2>&1 & CAP=$!
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 exec timeout $((SECS - 10)) $PY -m esphome logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy.txt" 2>&1) & LOGS=$!
|
||||
trap 'kill $CAP $LOGS 2>/dev/null' EXIT
|
||||
sleep 6
|
||||
$PY "$D/btdrive.py" "$S3" "$KEY" "$PEER" "$N" 10 > "$D/$L-drive.txt" 2>&1; RC=$?
|
||||
sleep 10
|
||||
kill $CAP $LOGS 2>/dev/null; wait 2>/dev/null; trap - EXIT
|
||||
[ -s "$D/$L-peer.txt" ] || { tail -3 "$D/$L-drive.txt"; echo "FAIL: peer capture got nothing"; exit 1; }
|
||||
$PY "$D/an.py" "$D" "$L" "$N"; ARC=$?
|
||||
[ "$RC" = 0 ] || { tail -1 "$D/$L-drive.txt"; echo "FAIL: driver rc $RC (not every round completed)"; exit 1; }
|
||||
exit $ARC
|
||||
@@ -0,0 +1,79 @@
|
||||
# BK7238 bit banged master; needs the BK72xx software I2C bus from #21586 (dev rejects the pins).
|
||||
# The ESP32 control is the same i2c, sensor and interval blocks on a devkit (sda GPIO21, scl GPIO22, 25 or 50 kHz).
|
||||
substitutions:
|
||||
frequency: 100kHz
|
||||
|
||||
esphome:
|
||||
name: i2cbk
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
i2c:
|
||||
id: bus
|
||||
sda: P26
|
||||
scl: P24
|
||||
scan: true
|
||||
frequency: ${frequency}
|
||||
timeout: 20ms # clock stretch limit; the bus default (1 ms) is shorter than the slave's 5 ms stretch
|
||||
|
||||
sensor:
|
||||
- platform: hdc1080
|
||||
address: 0x40
|
||||
update_interval: 5s
|
||||
temperature:
|
||||
name: fake temperature
|
||||
humidity:
|
||||
name: fake humidity
|
||||
|
||||
globals:
|
||||
- id: n
|
||||
type: uint8_t
|
||||
initial_value: "0"
|
||||
- id: n_ok
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: n_bad
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 2s
|
||||
then:
|
||||
- lambda: |-
|
||||
uint8_t k = ++id(n);
|
||||
uint8_t w[4] = {0x20, k, (uint8_t) ~k, 0xA5};
|
||||
uint32_t t0 = micros();
|
||||
auto e1 = id(bus)->write(0x40, w, 4);
|
||||
uint8_t reg = 0x20, r[3] = {0, 0, 0};
|
||||
auto e2 = id(bus)->write_readv(0x40, ®, 1, r, 3);
|
||||
uint32_t t1 = micros();
|
||||
uint8_t x;
|
||||
auto e3 = id(bus)->read(0x41, &x, 1);
|
||||
uint8_t s1 = 0x7E, s2 = 0x7F, rr[2];
|
||||
uint32_t t2 = micros();
|
||||
auto e4 = id(bus)->write_readv(0x40, &s1, 1, rr, 2);
|
||||
uint32_t t3 = micros();
|
||||
auto e5 = id(bus)->write_readv(0x40, &s2, 1, rr, 2);
|
||||
uint32_t t4 = micros();
|
||||
// the absent address must NACK and both clock stretched reads must succeed
|
||||
bool ok = e1 == 0 && e2 == 0 && r[0] == k && r[1] == (uint8_t) ~k && r[2] == 0xA5 && e3 != 0 && e4 == 0 &&
|
||||
e5 == 0;
|
||||
if (ok) id(n_ok)++; else id(n_bad)++;
|
||||
ESP_LOGI("t", "RW %s e1 %d e2 %d got %02X %02X %02X want %02X %02X A5 | %u us | absent e %d | stretch500 e %d %u us | stretch5ms e %d %u us | ok %u bad %u",
|
||||
ok ? "OK" : "BAD", e1, e2, r[0], r[1], r[2], k, (uint8_t) ~k, (unsigned) (t1 - t0), e3, e4,
|
||||
(unsigned) (t3 - t2), e5, (unsigned) (t4 - t3), (unsigned) id(n_ok), (unsigned) id(n_bad));
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <Wire.h>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Fake HDC1080 at 0x40: 0x00 temperature (26.0 C), 0x01 humidity (50 %), other registers are a
|
||||
// 256 byte file; reading 0x7E holds SCL for 500 us, reading 0x7F for 5 ms.
|
||||
static volatile uint8_t SL_REGS[256];
|
||||
static volatile uint8_t sl_ptr = 0;
|
||||
static volatile uint32_t sl_rx = 0, sl_tx = 0, sl_stretch = 0;
|
||||
|
||||
static void sl_on_receive(int n) {
|
||||
if (n < 1)
|
||||
return;
|
||||
sl_ptr = Wire.read();
|
||||
uint8_t p = sl_ptr;
|
||||
while (--n > 0 && Wire.available())
|
||||
SL_REGS[p++] = Wire.read();
|
||||
sl_rx++;
|
||||
}
|
||||
|
||||
static void sl_on_request() {
|
||||
uint8_t b[8];
|
||||
uint8_t p = sl_ptr;
|
||||
sl_tx++;
|
||||
if (p == 0x7E || p == 0x7F) {
|
||||
sl_stretch++;
|
||||
delayMicroseconds(p == 0x7E ? 500 : 5000);
|
||||
}
|
||||
for (int i = 0; i < 8; i++)
|
||||
b[i] = SL_REGS[(uint8_t) (p + i)];
|
||||
if (p == 0x00) {
|
||||
b[0] = 0x66;
|
||||
b[1] = 0x66;
|
||||
} else if (p == 0x01) {
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x00;
|
||||
}
|
||||
Wire.write(b, 8);
|
||||
}
|
||||
|
||||
inline void i2cslave_begin() {
|
||||
Wire.begin(4, 5, 0x40);
|
||||
Wire.onReceive(sl_on_receive);
|
||||
Wire.onRequest(sl_on_request);
|
||||
ESP_LOGI("slave", "I2C slave at 0x40 on SDA GPIO4 SCL GPIO5");
|
||||
}
|
||||
|
||||
inline void i2cslave_report() {
|
||||
ESP_LOGI("slave", "rx %u tx %u stretch %u", (unsigned) sl_rx, (unsigned) sl_tx, (unsigned) sl_stretch);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: i2cslave
|
||||
libraries:
|
||||
- Wire
|
||||
includes:
|
||||
- i2cslave.hpp
|
||||
platformio_options:
|
||||
board_build.f_cpu: 160000000L
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: i2cslave_begin();
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: i2cslave_report();
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Press one crash button after another on an RTL8720C test build and keep the decoded API logs of each next boot.
|
||||
Usage: rtl_drive.py HOST OUTDIR TREE YAML BUTTON... (button names from templates/rtl8720c-crash-buttons-dut.yaml)
|
||||
Prints PRESS lines with the wall clock time, so resets can be timed against a serial capture taken with --ms.
|
||||
Each case waits for the board to answer on the API port again, then saves 15 s of `esphome logs` (run from TREE,
|
||||
so the CLI decodes the addresses) as OUTDIR/caseN-BUTTON.txt. The run prints CASE N BUTTON and exits 1 with NO RESET
|
||||
when the API port never closes after the press (the button did not reset the board), NO BOOT when the board is not
|
||||
back within BOOT_DEADLINE_S, and NO LOGS when the log capture never connected."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient
|
||||
|
||||
host, out, tree, yaml = sys.argv[1:5]
|
||||
buttons = sys.argv[5:]
|
||||
BOOT_DEADLINE_S = 120
|
||||
# Every case button resets the board: a fault, the watchdog (hang) or a restart. The watchdog takes about 11 s.
|
||||
RESET_DEADLINE_S = 20
|
||||
|
||||
|
||||
def port_open() -> bool:
|
||||
try:
|
||||
socket.create_connection((host, 6053), 1).close()
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def wait_for_port() -> bool:
|
||||
deadline = time.monotonic() + BOOT_DEADLINE_S
|
||||
while not port_open():
|
||||
if time.monotonic() > deadline:
|
||||
return False
|
||||
time.sleep(1)
|
||||
return True
|
||||
|
||||
|
||||
def wait_for_reset() -> bool:
|
||||
deadline = time.monotonic() + RESET_DEADLINE_S
|
||||
while port_open():
|
||||
if time.monotonic() > deadline:
|
||||
return False
|
||||
time.sleep(0.2)
|
||||
return True
|
||||
|
||||
|
||||
def fail(i: int, name: str, why: str) -> None:
|
||||
print(f"CASE {i} {name} {why}", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def button_keys() -> dict[str, int]:
|
||||
cli = APIClient(host, 6053, None)
|
||||
await cli.connect(login=True)
|
||||
try:
|
||||
entities, _ = await cli.list_entities_services()
|
||||
finally:
|
||||
await cli.disconnect()
|
||||
return {e.name: e.key for e in entities if type(e).__name__ == "ButtonInfo"}
|
||||
|
||||
|
||||
async def press(name: str, key: int) -> None:
|
||||
cli = APIClient(host, 6053, None)
|
||||
await cli.connect(login=True)
|
||||
now = time.time()
|
||||
print(
|
||||
f"PRESS {name} at {time.strftime('%H:%M:%S', time.localtime(now))}.{int(now * 1000) % 1000:03d}",
|
||||
flush=True,
|
||||
)
|
||||
cli.button_command(key)
|
||||
await asyncio.sleep(0.5)
|
||||
# the board resets within about a second, so the disconnect reply may never come
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(cli.disconnect(), 2)
|
||||
|
||||
|
||||
if not wait_for_port():
|
||||
sys.exit("NO BOOT before the first case")
|
||||
# Check every name up front, so a typo in the last case does not waste the crashes before it
|
||||
keys = asyncio.run(button_keys())
|
||||
if missing := [b for b in buttons if b not in keys]:
|
||||
sys.exit(f"Unknown buttons {missing}; the board has {sorted(keys)}")
|
||||
|
||||
for i, name in enumerate(buttons):
|
||||
if not wait_for_port():
|
||||
fail(i, name, "NO BOOT")
|
||||
time.sleep(5)
|
||||
asyncio.run(press(name, keys[name]))
|
||||
if not wait_for_reset():
|
||||
fail(i, name, "NO RESET")
|
||||
time.sleep(20) # boot and Wi-Fi
|
||||
if not wait_for_port():
|
||||
fail(i, name, "NO BOOT")
|
||||
case_file = Path(out) / f"case{i}-{name.replace(' ', '_')}.txt"
|
||||
with case_file.open("w", encoding="utf-8") as fh:
|
||||
subprocess.run(
|
||||
[
|
||||
"timeout",
|
||||
"15",
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"logs",
|
||||
yaml,
|
||||
"--device",
|
||||
host,
|
||||
],
|
||||
cwd=tree,
|
||||
env={**os.environ, "PYTHONPATH": tree, "PYTHONUNBUFFERED": "1"},
|
||||
stdout=fh,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
if "Successful handshake" not in case_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
):
|
||||
fail(i, name, "NO LOGS")
|
||||
print(f"CASE {i} {name} done", flush=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Check over the API that each board runs the expected build.
|
||||
Usage: apiinfo.py HOST[=BUILD_INFO_JSON]...
|
||||
Prints name and compilation time per host. With a build_info.json (from the build directory), the board's
|
||||
compilation time must equal that build's build_time_str. Exits 1 when a host fails or runs another build.
|
||||
env: API_KEY (the boards' api encryption key, when they have one)"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
|
||||
async def info(host: str) -> tuple[str, str]:
|
||||
cli = APIClient(host, 6053, None, noise_psk=os.environ.get("API_KEY"))
|
||||
await cli.connect(login=True)
|
||||
try:
|
||||
i = await cli.device_info()
|
||||
finally:
|
||||
await cli.disconnect()
|
||||
return i.name, i.compilation_time
|
||||
|
||||
|
||||
bad = 0
|
||||
for arg in sys.argv[1:]:
|
||||
host, _, build_info = arg.partition("=")
|
||||
try:
|
||||
name, built = asyncio.run(asyncio.wait_for(info(host), 30))
|
||||
except (APIConnectionError, TimeoutError, OSError) as err:
|
||||
print("API", host, "ERROR", repr(err), flush=True)
|
||||
bad += 1
|
||||
continue
|
||||
want = (
|
||||
json.loads(Path(build_info).read_text(encoding="utf-8"))["build_time_str"]
|
||||
if build_info
|
||||
else None
|
||||
)
|
||||
ok = want is None or built == want
|
||||
bad += not ok
|
||||
print("API", host, name, built, "" if ok else f"MISMATCH want {want}", flush=True)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,42 @@
|
||||
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
|
||||
substitutions:
|
||||
name: inflbench-bk
|
||||
board: cb3s
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- bench_streams.hpp
|
||||
- bench.hpp
|
||||
|
||||
bk72xx:
|
||||
board: ${board}
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 90s
|
||||
then:
|
||||
- delay: 20s
|
||||
- lambda: run_inflate_bench();
|
||||
@@ -0,0 +1,44 @@
|
||||
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
|
||||
substitutions:
|
||||
name: inflbench-s3
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- bench_streams.hpp
|
||||
- bench.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 90s
|
||||
then:
|
||||
- delay: 20s
|
||||
- lambda: run_inflate_bench();
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
// On-device inflate speed for the ESPHome OTA decoder. Targets the decoder interface with dest_limit (dev as of
|
||||
// this writing); once the interface without dest_limit lands (#21641), drop the dest_limit line.
|
||||
#include "esphome/components/esphome/ota/ota_esphome_inflate.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "bench_streams.hpp"
|
||||
|
||||
static uint8_t bench_ring[32768];
|
||||
static OtaInflateState bench_state;
|
||||
|
||||
// LibreTiny defines micros as a macro; the parentheses call the ESPHome function.
|
||||
inline uint32_t bench_now() { return (esphome::micros) (); }
|
||||
|
||||
inline void run_inflate_bench() {
|
||||
unsigned expected = 0;
|
||||
for (const auto &c : BENCH_CASES) {
|
||||
for (unsigned ring : {4096u, 8192u, 16384u, 32768u})
|
||||
expected += ring >= c.enc_window;
|
||||
}
|
||||
ESP_LOGI("bench", "BENCHSTART cases=%u", expected);
|
||||
for (const auto &c : BENCH_CASES) {
|
||||
for (unsigned ring : {4096u, 8192u, 16384u, 32768u}) {
|
||||
if (ring < c.enc_window)
|
||||
continue;
|
||||
uint32_t best = UINT32_MAX;
|
||||
size_t out = 0;
|
||||
int res = 0;
|
||||
for (int rep = 0; rep < 3; rep++) {
|
||||
ota_inflate_init(&bench_state, bench_ring, ring);
|
||||
bench_state.source = c.data;
|
||||
bench_state.source_limit = c.data + c.len;
|
||||
bench_state.source_read_cb = [](OtaInflateState *) -> int { return -1; };
|
||||
out = 0;
|
||||
uint32_t t0 = bench_now();
|
||||
do {
|
||||
bench_state.dest = bench_ring;
|
||||
bench_state.dest_limit = bench_ring + ring;
|
||||
res = ota_inflate(&bench_state);
|
||||
out += bench_state.dest - bench_ring;
|
||||
esphome::App.feed_wdt();
|
||||
} while (res == OTA_INFLATE_OK);
|
||||
uint32_t dt = bench_now() - t0;
|
||||
if (dt < best)
|
||||
best = dt;
|
||||
}
|
||||
ESP_LOGI("bench", "%s enc=%u ring=%u in=%u out=%u/%u res=%d best_us=%u out_kBps=%u in_kBps=%u", c.name,
|
||||
c.enc_window, ring, (unsigned) c.len, (unsigned) out, (unsigned) c.raw, res, (unsigned) best,
|
||||
(unsigned) ((uint64_t) out * 1000 / best), (unsigned) ((uint64_t) c.len * 1000 / best));
|
||||
}
|
||||
}
|
||||
ESP_LOGI("bench", "BENCHDONE");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# usage: bench.sh CONFIG DEVICE build and upload CONFIG (bench-bk72xx or bench-esp32s3) to DEVICE (OTA address or
|
||||
# serial port), then collect one benchmark pass over syslog. Run gen_streams.py in this directory first.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, syslog_ip = this
|
||||
# host's address) and run it there. env: TREE (esphome checkout under test), PY (checkout python), WAIT (seconds to
|
||||
# wait for a pass, default 300)
|
||||
D=$(cd "$(dirname "$0")" && pwd); C=${1:?config name}; DEV=${2:?device}; T=${TREE:?set TREE}
|
||||
PY=${PY:-$T/venv/bin/python}; WAIT=${WAIT:-300}; OUT=$D/$C-syslog.txt
|
||||
[ -f "$D/bench_streams.hpp" ] || { echo "FAIL: run gen_streams.py first"; exit 1; }
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$C.yaml" --device "$DEV" > "$D/$C-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: build or upload failed, see $C-upload.log"; exit 1; }
|
||||
$PY - "$OUT" "$WAIT" << 'PYEOF'
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("0.0.0.0", 5514))
|
||||
s.settimeout(1)
|
||||
end = time.time() + int(sys.argv[2])
|
||||
started = False
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as out:
|
||||
while time.time() < end:
|
||||
try:
|
||||
d, _ = s.recvfrom(2048)
|
||||
except TimeoutError:
|
||||
continue
|
||||
line = d.decode(errors="replace").strip()
|
||||
# keep one whole pass: lines from a pass already under way are dropped
|
||||
started = started or "BENCHSTART" in line
|
||||
if started and "bench" in line:
|
||||
print(line, file=out, flush=True)
|
||||
if "BENCHDONE" in line:
|
||||
break
|
||||
PYEOF
|
||||
grep -q BENCHDONE "$OUT" || { echo "FAIL: no complete pass within $WAIT s"; exit 1; }
|
||||
grep -o 'bench:[0-9]*\]: .*' "$OUT"
|
||||
BAD=$(grep 'best_us=' "$OUT" | grep -v 'res=1 ' | wc -l | tr -d ' ')
|
||||
SHORT=$(grep -o 'out=[0-9]*/[0-9]*' "$OUT" | awk -F'[=/]' '$2 != $3' | wc -l | tr -d ' ')
|
||||
WANT=$(grep -o 'BENCHSTART cases=[0-9]*' "$OUT" | cut -d= -f2); GOT=$(grep -c 'best_us=' "$OUT")
|
||||
[ "$BAD" = 0 ] && [ "$SHORT" = 0 ] && [ "$GOT" = "$WANT" ] && echo "PASS" \
|
||||
|| { echo "FAIL: $BAD cases did not finish, $SHORT short outputs, $GOT of $WANT results (lost syslog lines)"; exit 1; }
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Write bench_streams.hpp: one 128 KB slice per image, raw deflate at level 9 with 4 KB and 32 KB windows.
|
||||
Usage: gen_streams.py NAME=IMAGE[@OFFSET]... [--windows 12,15]
|
||||
Each image gives one case per window. Keep the total near 130 KB on a BK7231N: an image with about 330 KB of extra
|
||||
incompressible data failed its OTA ("Writing OTA data to flash memory failed"); use one image and --windows 12 there."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import zlib
|
||||
|
||||
SLICE = 128 * 1024
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("images", nargs="+")
|
||||
ap.add_argument("--windows", default="12,15")
|
||||
args = ap.parse_args()
|
||||
arrays, cases, total = [], [], 0
|
||||
for spec in args.images:
|
||||
name, _, rest = spec.partition("=")
|
||||
path, _, offset = rest.partition("@")
|
||||
raw = Path(path).read_bytes()[int(offset or 0) :][:SLICE]
|
||||
for wbits in (int(w) for w in args.windows.split(",")):
|
||||
data = zlib.compress(raw, 9, -wbits)
|
||||
total += len(data)
|
||||
var = f"{name}_w{wbits}"
|
||||
arrays.append(
|
||||
f"static const uint8_t {var}[{len(data)}] = {{{','.join(map(str, data))}}};"
|
||||
)
|
||||
cases.append(f'{{"{name}", {var}, sizeof({var}), {len(raw)}u, {1 << wbits}u}}')
|
||||
print(f"{var}: {len(raw)} -> {len(data)} bytes")
|
||||
head = "#pragma once\n#include <cstddef>\n#include <cstdint>\n"
|
||||
case_struct = (
|
||||
"struct BenchCase {\n const char *name;\n const uint8_t *data;\n size_t len;\n size_t raw;\n"
|
||||
" unsigned enc_window;\n};\n"
|
||||
)
|
||||
body = (
|
||||
"\n".join(arrays)
|
||||
+ "\n"
|
||||
+ case_struct
|
||||
+ "static const BenchCase BENCH_CASES[] = {"
|
||||
+ ", ".join(cases)
|
||||
+ "};\n"
|
||||
)
|
||||
Path("bench_streams.hpp").write_text(head + body, encoding="utf-8")
|
||||
print(f"total {total} bytes of streams")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Time one ESPHome OTA upload and print one RESULT json line.
|
||||
Usage: otatime.py HOST IMAGE MODE (MODE: deflate, raw, deflate-tcp or raw-tcp)
|
||||
env: OTA_PORT (default 3232), OTA_PASSWORD, API_KEY (a board with an api encryption key also encrypts OTA),
|
||||
TAG (label stored in the result)
|
||||
Run it with the checkout under test first on PYTHONPATH, so its espota2 is the client being measured.
|
||||
raw: the client leaves deflate out of its feature offer. Never patch the deflate branch off after the offer: once
|
||||
the device accepted deflate it expects a deflate stream. -tcp: the link is reported clean, so the client asks to
|
||||
stay on TCP. Clearing the UDP feature bit instead desyncs the handshake ("receiving UDP token response: timed out")."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import esphome.espota2 as espota
|
||||
|
||||
host, image, mode = sys.argv[1], Path(sys.argv[2]), sys.argv[3]
|
||||
# Assigning a missing attribute would silently do nothing, so a renamed name must stop the run
|
||||
for name in ("CLIENT_FEATURE_SUPPORTS_DEFLATE", "_loss_reason"):
|
||||
if not hasattr(espota, name):
|
||||
sys.exit(f"espota2 in this tree has no {name}; update otatime.py")
|
||||
if mode.startswith("raw"):
|
||||
espota.CLIENT_FEATURE_SUPPORTS_DEFLATE = 0
|
||||
if mode.endswith("-tcp"):
|
||||
espota._loss_reason = lambda *a: None # noqa: SLF001
|
||||
marks: list[tuple[float, float]] = []
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Stands in for the progress bar and records when each fraction went out."""
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
pass
|
||||
|
||||
def update(self, progress: float) -> None:
|
||||
marks.append((time.perf_counter(), progress))
|
||||
|
||||
def done(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
espota.ProgressBar = Recorder
|
||||
lines: list[str] = []
|
||||
|
||||
|
||||
class Keep(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
lines.append(record.getMessage())
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger().addHandler(Keep())
|
||||
t0 = time.perf_counter()
|
||||
rc, _ = espota.run_ota(
|
||||
host,
|
||||
int(os.environ.get("OTA_PORT", "3232")),
|
||||
os.environ.get("OTA_PASSWORD"),
|
||||
image,
|
||||
noise_psk=os.environ.get("API_KEY"),
|
||||
)
|
||||
total = time.perf_counter() - t0
|
||||
deciles = []
|
||||
if marks:
|
||||
start = marks[0][0]
|
||||
for q in range(1, 11):
|
||||
hit = next((t for t, f in marks if f >= q / 10 - 1e-9), None)
|
||||
deciles.append(round(hit - start, 2) if hit is not None else None)
|
||||
result = {
|
||||
"board": os.environ.get("TAG", ""),
|
||||
"mode": mode,
|
||||
"rc": rc,
|
||||
"total": round(total, 2),
|
||||
"took": next((x for x in lines if x.startswith("Update took")), ""),
|
||||
"size": next((x for x in lines if "Compressed to" in x), ""),
|
||||
"udp": any("Using UDP" in x for x in lines),
|
||||
"deciles": deciles,
|
||||
}
|
||||
# The log must agree with the mode, or the row would be counted under the wrong mode
|
||||
if mode.startswith("raw") and result["size"]:
|
||||
result["rc"] = rc = rc or 1
|
||||
print("raw mode upload was compressed anyway", file=sys.stderr)
|
||||
if mode.endswith("-tcp") and result["udp"]:
|
||||
result["rc"] = rc = rc or 1
|
||||
print("-tcp mode upload switched to UDP", file=sys.stderr)
|
||||
print("RESULT", json.dumps(result), flush=True)
|
||||
sys.exit(1 if rc else 0)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh N PHASE... OTA upload timing A/B: per phase build every board, one warm-up upload per board
|
||||
# (installs the phase's build, not timed), an API check that each board booted that build, then N rounds of every
|
||||
# MODE on every board, interleaved so the boards share the same air. summ.py prints medians at the end.
|
||||
# Layout: $D/PHASE/NAME.yaml per phase and board (with its secrets.yaml); outputs go to $D/res.txt.
|
||||
# env: BOARDS ("NAME,HOST,OTA_PORT ..."; add an ESP32 on the same Wi-Fi as a network control), TREE_<PHASE>
|
||||
# (esphome checkout for each phase, e.g. TREE_base, TREE_pr), PY (python with esphome's requirements),
|
||||
# MODES (default "deflate-tcp raw-tcp"), SETTLE (seconds after a board answers again, default 15), API_KEY (when the
|
||||
# boards have an api encryption key; it also encrypts their OTA, so all boards in one run need the same key)
|
||||
D=$(cd "$(dirname "$0")" && pwd); N=${1:?rounds}; shift; [ $# -gt 0 ] || { echo "usage: run.sh N PHASE..."; exit 2; }
|
||||
B=${BOARDS:?set BOARDS}; PY=${PY:?set PY}; MODES=${MODES:-deflate-tcp raw-tcp}; SETTLE=${SETTLE:-15}
|
||||
wait_up() { # wait_up HOST PORT: up to 5 minutes for the OTA port to answer
|
||||
local i
|
||||
for i in $(seq 60); do
|
||||
"$PY" -c "import socket,sys; socket.create_connection((sys.argv[1], int(sys.argv[2])), 2).close()" "$1" "$2" 2>/dev/null \
|
||||
&& { sleep "$SETTLE"; return 0; }
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
up() { # up PHASE NAME HOST PORT MODE TAG OUTFILE; returns 1 unless the upload succeeded
|
||||
local tree=TREE_$1 out=""
|
||||
if wait_up "$3" "$4"; then
|
||||
out=$(TAG=$1-$2-$6 OTA_PORT=$4 PYTHONPATH=${!tree} timeout 900 "$PY" "$D/otatime.py" "$3" \
|
||||
"$(cat "$D/img-$1-$2.txt")" "$5" 2>> "$D/stderr-$1.log" | grep RESULT)
|
||||
else
|
||||
echo "FAIL: $3 not answering on $4"
|
||||
fi
|
||||
# no RESULT line (board gone, otatime.py crashed or timed out): record a failed upload so summ.py counts it
|
||||
[ -n "$out" ] || out="RESULT {\"board\": \"$1-$2-$6\", \"mode\": \"$5\", \"rc\": -1, \"total\": 0, \"took\": \"\", \"size\": \"\", \"udp\": false, \"deciles\": []}"
|
||||
echo "$out" | tee -a "$7"
|
||||
[[ $out == *'"rc": 0,'* ]]
|
||||
}
|
||||
# results from an earlier run would fill in for missing rows: move them aside
|
||||
for f in res.txt warm.txt api.txt; do [ -f "$D/$f" ] && mv "$D/$f" "$D/$f.$(date +%Y%m%d-%H%M%S)"; done
|
||||
for ph in "$@"; do
|
||||
tree=TREE_$ph; [ -n "${!tree}" ] || { echo "FAIL: set $tree"; exit 1; }
|
||||
checks=()
|
||||
for b in $B; do
|
||||
IFS=, read -r n h p <<< "$b"
|
||||
(cd "${!tree}" && PYTHONUNBUFFERED=1 timeout 1500 "$PY" -m esphome compile "$D/$ph/$n.yaml") > "$D/build-$ph-$n.log" 2>&1 \
|
||||
|| { echo "FAIL: build $ph/$n"; exit 1; }
|
||||
# the image the CLI uploads (CORE.firmware_bin): ESP-IDF build, LibreTiny UF2, else the PlatformIO bin
|
||||
bd=$D/$ph/.esphome/build/$n; : > "$D/img-$ph-$n.txt"
|
||||
for f in "$bd/build/$n.bin" "$bd/.pioenvs/$n/firmware.uf2" "$bd/.pioenvs/$n/firmware.bin"; do
|
||||
[ -f "$f" ] && { echo "$f" > "$D/img-$ph-$n.txt"; break; }
|
||||
done
|
||||
[ -s "$D/img-$ph-$n.txt" ] || { echo "FAIL: no firmware image under $bd"; exit 1; }
|
||||
case $(cat "$D/img-$ph-$n.txt") in
|
||||
*.uf2) PYTHONPATH=${!tree} "$PY" "$D/trim_uf2.py" "$(cat "$D/img-$ph-$n.txt")" > "$D/img-$ph-$n.tmp" \
|
||||
&& mv "$D/img-$ph-$n.tmp" "$D/img-$ph-$n.txt" || { echo "FAIL: trim_uf2.py $ph/$n"; exit 1; } ;;
|
||||
esac
|
||||
checks+=("$h=$D/$ph/.esphome/build/$n/build_info.json")
|
||||
done
|
||||
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" deflate-tcp warm "$D/warm.txt" || exit 1; done
|
||||
for b in $B; do IFS=, read -r n h p <<< "$b"; wait_up "$h" "$p"; done
|
||||
"$PY" "$D/apiinfo.py" "${checks[@]}" | tee -a "$D/api.txt"; [ "${PIPESTATUS[0]}" = 0 ] || { echo "FAIL: $ph did not boot everywhere"; exit 1; }
|
||||
for i in $(seq "$N"); do
|
||||
for m in $MODES; do
|
||||
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" "$m" "r$i" "$D/res.txt"; done
|
||||
done
|
||||
done
|
||||
echo "PHASE DONE $ph"
|
||||
done
|
||||
"$PY" "$D/summ.py" "$D/res.txt" "$N"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Median upload time per board and mode from otatime.py RESULT lines.
|
||||
Usage: summ.py RESULTS_FILE [ROUNDS]
|
||||
Exits 1 when any upload failed, an upload time could not be read, or (with ROUNDS) a board and mode has fewer rows."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
rows = [
|
||||
json.loads(line.split("RESULT ", 1)[1])
|
||||
for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
|
||||
if "RESULT " in line
|
||||
]
|
||||
by: dict[tuple[str, str], list[dict]] = {}
|
||||
for r in rows:
|
||||
m = re.search(r"upload ([\d.]+)", r["took"])
|
||||
r["up"] = float(m[1]) if m else None
|
||||
# TAG is PHASE-BOARD-ROUND; group per phase and board
|
||||
by.setdefault((r["board"].rsplit("-", 1)[0], r["mode"]), []).append(r)
|
||||
rounds = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
failed = unparsed = short = 0
|
||||
for (board, mode), v in sorted(by.items()):
|
||||
ok = sorted(r["up"] for r in v if r["rc"] == 0 and r["up"] is not None)
|
||||
fails = sum(r["rc"] != 0 for r in v)
|
||||
failed += fails
|
||||
unparsed += sum(r["rc"] == 0 and r["up"] is None for r in v)
|
||||
short += len(v) < rounds
|
||||
udp = sum(r["udp"] for r in v if r["rc"] == 0)
|
||||
med = statistics.median(ok) if ok else None
|
||||
print(
|
||||
f"{board:24} {mode:12} n={len(v)} fail={fails} udp={udp} median={med} upload s: {ok}"
|
||||
)
|
||||
if not rows:
|
||||
print("FAIL: no results")
|
||||
elif failed or unparsed or short:
|
||||
print(
|
||||
f"FAIL: {failed} failed uploads, {unparsed} without an upload time, {short} groups under {rounds} rows"
|
||||
)
|
||||
else:
|
||||
print("PASS")
|
||||
sys.exit(0 if rows and not (failed or unparsed or short) else 1)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Write the trimmed LibreTiny UF2 the CLI would send (esphome#21642) next to firmware.uf2 and print its path.
|
||||
Usage: trim_uf2.py FIRMWARE_UF2 (run with the phase's checkout on PYTHONPATH)
|
||||
Prints the input path unchanged when the tree has no device_ota_uf2() or there is nothing to drop."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
src = Path(sys.argv[1])
|
||||
try:
|
||||
from esphome.components.libretiny.uf2 import device_ota_uf2
|
||||
except ModuleNotFoundError as err:
|
||||
if err.name != "esphome.components.libretiny.uf2":
|
||||
raise
|
||||
print("no device_ota_uf2() in this tree, sending the full UF2", file=sys.stderr)
|
||||
print(src)
|
||||
sys.exit(0)
|
||||
data = src.read_bytes()
|
||||
if (trimmed := device_ota_uf2(data)) is None:
|
||||
print(src)
|
||||
sys.exit(0)
|
||||
out = src.with_name("firmware.ota.uf2")
|
||||
out.write_bytes(trimmed)
|
||||
print(f"trimmed {len(data)} -> {len(trimmed)} bytes", file=sys.stderr)
|
||||
print(out)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Summarise the AP restart run: alive lines over syslog per DUT, reboots, gaps, recovery after each AP start.
|
||||
Usage: an.py DIR BUILD [RECOVER_S] reads BUILD-marks.txt, BUILD-syslog.txt, BUILD-bk.txt, BUILD-cb.txt (optional)
|
||||
and BUILD-up.txt (the bridge's API logs)
|
||||
Exits 1 when a DUT rebooted, crashed, sent nothing, or had no syslog line within RECOVER_S (default 120) of an
|
||||
AP start, when the BK7238 capture (or a CB3S capture that was started) is empty, or when the bridge did not switch
|
||||
its AP to WPA2/WPA3 mixed after each AP start."""
|
||||
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, b = Path(sys.argv[1]), sys.argv[2]
|
||||
RECOVER = int(sys.argv[3]) if len(sys.argv) > 3 else 120
|
||||
|
||||
|
||||
def read(suffix: str) -> str | None:
|
||||
f = D / f"{b}-{suffix}.txt"
|
||||
return f.read_text(encoding="utf-8", errors="replace") if f.exists() else None
|
||||
|
||||
|
||||
def secs(t: str) -> int:
|
||||
h, m, s = t.split(":")
|
||||
return int(h) * 3600 + int(m) * 60 + int(float(s))
|
||||
|
||||
|
||||
marks = [
|
||||
(secs(line.split()[0]), line.split()[3])
|
||||
for line in read("marks").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
sl = (read("syslog") or "").splitlines()
|
||||
bad = []
|
||||
for dev in ("rebk", "recb"):
|
||||
lines = [line for line in sl if re.search(rf"\b{dev}\b", line)]
|
||||
ups = [
|
||||
(secs(line.split()[0]), int(m[1]))
|
||||
for line in lines
|
||||
if (m := re.search(r"up (\d+) s", line))
|
||||
]
|
||||
if not ups:
|
||||
print(f"== {dev}: no alive lines over syslog")
|
||||
bad.append(f"{dev} silent")
|
||||
continue
|
||||
reboots = sum(1 for (_, u0), (_, u1) in pairwise(ups) if u1 < u0)
|
||||
gaps = [t1 - t0 for (t0, _), (t1, _) in pairwise(ups) if t1 - t0 > 25]
|
||||
print(
|
||||
f"== {dev}: alive lines {len(ups)}, reboots {reboots}, syslog gaps over 25 s: {gaps}"
|
||||
)
|
||||
if reboots:
|
||||
bad.append(f"{dev} rebooted")
|
||||
for t, st in marks:
|
||||
if st != "on":
|
||||
continue
|
||||
after = [x for x in ups if x[0] > t]
|
||||
first = after[0][0] - t if after else None
|
||||
print(
|
||||
f" ap back at t={t % 86400 // 3600:02d}:{t % 3600 // 60:02d}:{t % 60:02d}, first syslog line {first} s later"
|
||||
)
|
||||
if first is None or first > RECOVER:
|
||||
bad.append(f"{dev} not back {RECOVER} s after an AP start")
|
||||
for dev, suffix in (("rebk", "bk"), ("recb", "cb")):
|
||||
if (s := read(suffix)) is None and dev == "recb":
|
||||
continue # the CB3S capture is optional
|
||||
if not s:
|
||||
bad.append(f"{dev} serial capture missing or empty")
|
||||
continue
|
||||
crashes = len(re.findall(r"(?i)hard fault|exception|abort|watchdog", s))
|
||||
connected = len(re.findall(r"\[wifi:\d+\]: Connected", s))
|
||||
print(
|
||||
f"-- {dev} serial: lines {s.count(chr(10))}, overflow {s.count('queue overflow')}, fallback AP starts "
|
||||
f"{s.count('Starting fallback AP')}, connected {connected}, boots {s.count('ESPHome version')}, crashes {crashes}"
|
||||
)
|
||||
if crashes:
|
||||
bad.append(f"{dev} crashed")
|
||||
up = read("up") or ""
|
||||
switches = [
|
||||
secs(m[1]) for m in re.finditer(r"\[(\d+:\d+:\d+)[.\d]*\].*PMF capable: 0", up)
|
||||
]
|
||||
# each AP start needs its own switch between that mark (less a few seconds of clock skew) and the next mark
|
||||
ends = [t for t, _ in marks[1:]] + [float("inf")]
|
||||
missed = [
|
||||
t
|
||||
for (t, st), end in zip(marks, ends, strict=True)
|
||||
if st == "on" and not any(t - 5 <= s < end for s in switches)
|
||||
]
|
||||
starts = sum(1 for _, st in marks if st == "on")
|
||||
print(
|
||||
f"-- bridge: WPA2/WPA3 switches {len(switches)}, AP starts {starts} ({len(missed)} without a switch), "
|
||||
f"failures {up.count('AP CONFIG FAILED')}"
|
||||
)
|
||||
if "AP CONFIG FAILED" in up or missed:
|
||||
bad.append("the bridge did not switch its AP to WPA2/WPA3 mixed after every start")
|
||||
print("PASS" if not bad else "FAIL: " + ", ".join(bad))
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,58 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
manual_ip:
|
||||
static_ip: ${ip}
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
dns1: 10.40.0.1
|
||||
power_save_mode: LIGHT
|
||||
ap:
|
||||
ssid: ${name}-fallback
|
||||
password: !secret fallback_ap_password
|
||||
|
||||
captive_portal:
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
time:
|
||||
- platform: sntp
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: ESP_LOGI("alive", "up %u s", (unsigned) (millis() / 1000));
|
||||
- interval: 30s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: |-
|
||||
static bool done = false;
|
||||
if (done || millis() < 19 * 60 * 1000) return false;
|
||||
done = true;
|
||||
return true;
|
||||
then:
|
||||
- lambda: ESP_LOGI("alive", "GOING HOME");
|
||||
- wifi.configure:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
save: false
|
||||
@@ -0,0 +1,13 @@
|
||||
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
|
||||
substitutions:
|
||||
name: rebk
|
||||
ip: 10.40.0.21
|
||||
|
||||
packages:
|
||||
common: !include re-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,13 @@
|
||||
# CB3S (BK7231N) DUT, flashed over the air by run.sh; its log port is UART1 (TX1)
|
||||
substitutions:
|
||||
name: recb
|
||||
ip: 10.40.0.22
|
||||
|
||||
packages:
|
||||
common: !include re-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: cb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh BUILD OTA the CB3S, serial flash the BK7238, then switch the upstream AP off and on three times
|
||||
# while both DUTs send alive lines over syslog. BUILD is a label (pr, base) for the output files.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wisp_up_ssid,
|
||||
# wisp_up_password, fallback_ap_password, syslog_ip, api_key) and run it there. wisp-upstream-wpa3.yaml runs on the
|
||||
# ethernet ESP32-S3 upstream; syslog_ip is this host's address as the DUTs reach it through the bridge's NAT.
|
||||
# env: TREE (esphome checkout for the DUTs), BK_PORT (BK7238 serial), CB_PORT (CB3S log only serial, optional),
|
||||
# CB_HOST (CB3S address to OTA), UPSTREAM (bridge address), SC (path to serial_capture.py), FLASH_BK (path to
|
||||
# flash_bk72xx.py), LT (LibreTiny penv python), PY (checkout python), UP_TREE (checkout for the bridge's API logs)
|
||||
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
BK=${BK_PORT:?set BK_PORT}; CBH=${CB_HOST:?set CB_HOST}; UP=${UPSTREAM:?set UPSTREAM}; SC=${SC:?set SC}
|
||||
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}; CB=${CB_PORT:-}
|
||||
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
|
||||
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
|
||||
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
|
||||
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
|
||||
# only this run's ports: a global pkill stops other runs' captures
|
||||
pkill -f "[s]erial_capture.py $BK"; [ -n "$CB" ] && pkill -f "[s]erial_capture.py $CB"
|
||||
[ "$(ap on)" = 200 ] || { echo "FAIL: upstream bridge did not take ap on"; exit 1; }
|
||||
cb_upload() { build run --no-logs "$D/recb.yaml" --device "$CBH" > "$D/cb-$b-upload.log" 2>&1; }
|
||||
retry 3 15 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
|
||||
build compile "$D/rebk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
|
||||
UF2=$D/.esphome/build/rebk/.pioenvs/rebk/firmware.uf2
|
||||
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
|
||||
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
|
||||
PIDS=()
|
||||
$PY - > "$D/$b-syslog.txt" 2>&1 << 'PYEOF' &
|
||||
import socket
|
||||
import time
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("0.0.0.0", 5514))
|
||||
s.settimeout(1)
|
||||
end = time.time() + 1250
|
||||
while time.time() < end:
|
||||
try:
|
||||
d, a = s.recvfrom(2048)
|
||||
print(time.strftime("%H:%M:%S"), a[0], d.decode(errors="replace").strip(), flush=True)
|
||||
except TimeoutError:
|
||||
pass
|
||||
PYEOF
|
||||
PIDS+=($!)
|
||||
$PY "$SC" "$BK" 1240 "$D/$b-bk.txt" --released > /dev/null 2>&1 & PIDS+=($!)
|
||||
# an empty file marks the CB3S capture as requested, so an.py fails it if the capture never writes anything
|
||||
[ -n "$CB" ] && { : > "$D/$b-cb.txt"; $PY "$SC" "$CB" 1240 "$D/$b-cb.txt" --released > /dev/null 2>&1 & PIDS+=($!); }
|
||||
(cd "${UP_TREE:-$T}" && PYTHONUNBUFFERED=1 exec timeout 1240 $PY -m esphome logs "$D/wisp-upstream-wpa3.yaml" --device "$UP" > "$D/$b-up.txt" 2>&1) & PIDS+=($!)
|
||||
trap 'kill "${PIDS[@]}" 2>/dev/null' EXIT
|
||||
t0=$(date +%s); : > "$D/$b-marks.txt"
|
||||
for step in 180:off 330:on 480:off 630:on 780:off 930:on; do
|
||||
t=${step%%:*}; st=${step#*:}
|
||||
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
|
||||
code=$(ap "$st")
|
||||
echo "$(date +%H:%M:%S) t=$t ap $st $code" | tee -a "$D/$b-marks.txt"
|
||||
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
|
||||
done
|
||||
wait "${PIDS[@]}"; trap - EXIT
|
||||
$PY "$D/an.py" "$D" "$b"
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_event.h>
|
||||
@@ -0,0 +1,95 @@
|
||||
esphome:
|
||||
name: wisp-upstream
|
||||
includes:
|
||||
- wdap_inc.h
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- script.execute: wpa3_ap
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap_mode: WISP
|
||||
manual_ip:
|
||||
static_ip: 10.40.0.1
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: ap off
|
||||
on_press:
|
||||
- wifi.disable:
|
||||
- platform: template
|
||||
name: ap on
|
||||
on_press:
|
||||
- wifi.enable:
|
||||
- delay: 3s
|
||||
- script.execute: wpa3_ap
|
||||
|
||||
script:
|
||||
- id: wpa3_ap
|
||||
then:
|
||||
- lambda: |-
|
||||
static bool registered = false;
|
||||
wifi_config_t c{};
|
||||
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
|
||||
if (err == ESP_OK) {
|
||||
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
|
||||
c.ap.pmf_cfg.required = false;
|
||||
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
err = esp_wifi_set_config(WIFI_IF_AP, &c);
|
||||
}
|
||||
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
|
||||
wifi_config_t check{};
|
||||
if (err == ESP_OK)
|
||||
err = esp_wifi_get_config(WIFI_IF_AP, &check);
|
||||
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
|
||||
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
|
||||
} else {
|
||||
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
|
||||
}
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
auto *e = (wifi_event_ap_staconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5]);
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
auto *e = (wifi_event_ap_stadisconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X reason %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->reason);
|
||||
}
|
||||
}, nullptr);
|
||||
@@ -0,0 +1,16 @@
|
||||
substitutions:
|
||||
name: wispdut-aoi
|
||||
framework: esp-idf
|
||||
packages:
|
||||
common: !include dut-common.yaml
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
post_connect_roaming: false
|
||||
ap:
|
||||
ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
ap_mode: ALWAYS_ON
|
||||
web_server:
|
||||
port: 80
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: ${framework}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,14 @@
|
||||
substitutions:
|
||||
name: wispdut-wisp
|
||||
framework: esp-idf
|
||||
packages:
|
||||
common: !include dut-common.yaml
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap:
|
||||
ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
ap_mode: WISP
|
||||
captive_portal:
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh DUT START SECONDS T:STATE... e.g. run.sh dut-wisp on 600 120:off 300:on
|
||||
# Builds and flashes DUT.yaml (dut-wisp or dut-always-on) over serial, sets the upstream AP to START, captures the
|
||||
# DUT and the client, and switches the upstream AP at each T seconds; sum.py summarises the client per phase.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wisp_up_ssid, wisp_up_password, dut_ap_ssid,
|
||||
# dut_ap_password, lan_probe_url, api_key) and run it there. wisp-upstream.yaml runs on an ethernet ESP32-S3 and
|
||||
# wispcli.yaml on a D1 mini; flash both once (CLI_FLASH=1 builds and flashes the client too).
|
||||
# env: TREE (esphome checkout for the DUT), PORT (DUT serial), CLI_PORT (client serial), UPSTREAM (upstream bridge
|
||||
# address), SC (path to serial_capture.py), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); n=${1:?dut yaml name}; start=${2:?on or off}; dur=${3:?seconds}; shift 3
|
||||
T=${TREE:?set TREE}; P=${PORT:?set PORT}; CP=${CLI_PORT:?set CLI_PORT}; UP=${UPSTREAM:?set UPSTREAM}
|
||||
SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
|
||||
pkill -f "[s]erial_capture.py ($P|$CP)"
|
||||
[ "$(ap "$start")" = 200 ] || { echo "FAIL: upstream bridge did not take ap $start"; exit 1; }
|
||||
if [ "${CLI_FLASH:-0}" = 1 ]; then
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/wispcli.yaml" --device "$CP" > "$D/wispcli-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: client build or upload"; exit 1; }
|
||||
fi
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$n.yaml" --device "$P" > "$D/$n-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: DUT build or upload"; exit 1; }
|
||||
echo "$(date +%H:%M:%S) t=0 upstream $start start" > "$D/$n-marks.txt" # phase 1 is the start state
|
||||
$PY "$SC" "$P" "$dur" "$D/$n-dut.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
# no --reset on the client: a CH340 D1 mini floods garbage after an RTS reset
|
||||
$PY "$SC" "$CP" "$dur" "$D/$n-cli.txt" > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null' EXIT
|
||||
t0=$(date +%s)
|
||||
for step in "$@"; do
|
||||
t=${step%%:*}; st=${step#*:}
|
||||
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
|
||||
code=$(ap "$st")
|
||||
echo "$(date +%H:%M:%S) t=$t upstream $st $code" | tee -a "$D/$n-marks.txt"
|
||||
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
|
||||
done
|
||||
wait $C1 $C2; trap - EXIT
|
||||
$PY "$D/sum.py" "$D" "$n"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Summarise the client's results per upstream phase and the DUT's connects, AP starts, crashes and boots.
|
||||
Usage: sum.py DIR NAME reads NAME-marks.txt, NAME-cli.txt and NAME-dut.txt
|
||||
Phase N runs from mark N (the first mark is the start state). Exits 1 when the DUT crashed, the DUT capture does not
|
||||
hold exactly one boot (empty or rebooted), the client had no successful result at all, or a phase with the upstream
|
||||
on had no successful name lookup (an answer equal to the gateway is the captive portal and does not count)."""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, n = Path(sys.argv[1]), sys.argv[2]
|
||||
|
||||
|
||||
def read(suffix: str) -> str:
|
||||
return (D / f"{n}-{suffix}.txt").read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
mark_lines = [line.split() for line in read("marks").splitlines() if line.strip()]
|
||||
marks = [m[0] for m in mark_lines]
|
||||
upstream = {
|
||||
i + 1: m[3] for i, m in enumerate(mark_lines)
|
||||
} # "HH:MM:SS t=N upstream on|off CODE"
|
||||
cli = [line for line in read("cli").splitlines() if "[cli" in line]
|
||||
|
||||
|
||||
def phase(t: str) -> int:
|
||||
return sum(1 for m in marks if t >= m)
|
||||
|
||||
|
||||
stat: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
good: dict[int, int] = defaultdict(int) # successful name lookups per phase
|
||||
any_ok = 0
|
||||
for line in cli:
|
||||
m = re.search(r"\]: (NOT CONNECTED|NET .*|NAME \S+|GW \S+|MAC \S+)", line)
|
||||
if not m:
|
||||
continue
|
||||
s = m.group(1)
|
||||
if s.startswith("NET"):
|
||||
dns = re.search(r"dns (\S+)", s).group(1)
|
||||
gw = re.search(r"gw (\S+)", s).group(1)
|
||||
res = s.split("-> ")[1].strip()
|
||||
# the captive portal answers every name with the AP's own address; that is not a working upstream
|
||||
s = f"dns={dns} resolve={'CAPTIVE' if res == gw else res}"
|
||||
if res not in ("FAIL", gw):
|
||||
good[phase(line[1:9])] += 1
|
||||
any_ok += 1
|
||||
elif re.match(r"(NAME|GW|MAC) 200\b", s):
|
||||
any_ok += 1
|
||||
stat[phase(line[1:9])][s] += 1
|
||||
for p in sorted(stat):
|
||||
print(f"phase {p} (upstream {upstream.get(p, '?')}):", dict(stat[p]))
|
||||
dead = [p for p, st in upstream.items() if st == "on" and not good[p]]
|
||||
dut = read("dut")
|
||||
crash = len(re.findall(r"Guru|panic|abort\(", dut))
|
||||
boots = dut.count("ESPHome version")
|
||||
print(
|
||||
"DUT connected:",
|
||||
len(re.findall(r"\[wifi:\d+\]: Connected", dut)),
|
||||
"AP starts:",
|
||||
dut.count("Starting AP"),
|
||||
"crash:",
|
||||
crash,
|
||||
"boots:",
|
||||
boots,
|
||||
)
|
||||
failed = []
|
||||
if not cli:
|
||||
failed.append("empty client log")
|
||||
elif not any_ok:
|
||||
failed.append("the client never got a successful result")
|
||||
if dead:
|
||||
failed.append(f"no successful lookup in upstream on phases {dead}")
|
||||
# run.sh resets the DUT when the capture starts, so a good capture holds exactly one boot
|
||||
if boots != 1:
|
||||
failed.append(f"{boots} DUT boots captured, expected 1")
|
||||
if crash:
|
||||
failed.append(f"{crash} DUT crash lines")
|
||||
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,52 @@
|
||||
esphome:
|
||||
name: wisp-upstream
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap_mode: WISP
|
||||
manual_ip:
|
||||
static_ip: 10.40.0.1
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: ap off
|
||||
on_press:
|
||||
- wifi.disable:
|
||||
- platform: template
|
||||
name: ap on
|
||||
on_press:
|
||||
- wifi.enable:
|
||||
@@ -0,0 +1,77 @@
|
||||
esphome:
|
||||
name: wispcli
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
http_request:
|
||||
timeout: 5s
|
||||
verify_ssl: false
|
||||
|
||||
globals:
|
||||
- id: tick
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 3s
|
||||
then:
|
||||
- lambda: |-
|
||||
id(tick)++;
|
||||
if (id(tick) % 4 != 1) return;
|
||||
if (!WiFi.isConnected()) { ESP_LOGI("cli", "NOT CONNECTED"); return; }
|
||||
IPAddress r;
|
||||
bool ok = WiFi.hostByName("neverssl.com", r, 3000);
|
||||
ESP_LOGI("cli", "NET ip %s gw %s dns %s | neverssl.com -> %s", WiFi.localIP().toString().c_str(),
|
||||
WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP(0).toString().c_str(),
|
||||
ok ? r.toString().c_str() : "FAIL");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 2 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: http://neverssl.com/
|
||||
capture_response: true
|
||||
max_response_buffer_size: 160
|
||||
collect_headers:
|
||||
- location
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "NAME %d loc=%s body=%.60s", response->status_code, response->get_response_header("location").c_str(), body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "NAME error");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 3 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !lambda 'return std::string("http://") + WiFi.gatewayIP().toString().c_str() + "/";'
|
||||
capture_response: true
|
||||
max_response_buffer_size: 160
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "GW %d body=%.60s", response->status_code, body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "GW error");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 0 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !secret lan_probe_url
|
||||
capture_response: true
|
||||
max_response_buffer_size: 64
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "MAC %d body=%.20s", response->status_code, body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "MAC error");
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Summarise the PMF AP's join and leave events per station and the BK7238's joins.
|
||||
Usage: an.py DIR BUILD [AP_SSID] reads DIR/ap-BUILD.txt and DIR/bk-BUILD.txt (AP_SSID defaults to wd-pmf-ap)
|
||||
J = join, L<n> = leave with reason n. Reason 3: the station sent a deauth. Reason 2: the AP found a stale
|
||||
association when the station came back; a run of reason 2 before one join is reconnects the AP rejected.
|
||||
env DUT_MACS: comma separated station MACs that must appear in the AP events (default: at least two stations).
|
||||
Exits 1 on a rejected reconnect, an AP that did not switch to WPA2/WPA3, a missing DUT, or a BK7238 that never
|
||||
joined the AP."""
|
||||
|
||||
from itertools import pairwise
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, b = Path(sys.argv[1]), sys.argv[2]
|
||||
AP_SSID = sys.argv[3] if len(sys.argv) > 3 else "wd-pmf-ap"
|
||||
failed = []
|
||||
ap = (D / f"ap-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if not any("PMF capable: 0" in line for line in ap) or any(
|
||||
"AP CONFIG FAILED" in line for line in ap
|
||||
):
|
||||
failed.append("the AP did not switch to WPA2/WPA3 mixed")
|
||||
ev = [line for line in ap if "STA JOIN" in line or "STA LEAVE" in line]
|
||||
mac_re = re.compile(r"([0-9A-F]{2}(?::[0-9A-F]{2}){5})")
|
||||
macs = sorted({m.group(1) for line in ev if (m := mac_re.search(line))})
|
||||
rejected = 0
|
||||
print(f"== {b}")
|
||||
for m in macs:
|
||||
seq = [
|
||||
"J" if "JOIN" in line else "L" + re.search(r"reason (\d+)", line).group(1)
|
||||
for line in ev
|
||||
if m in line
|
||||
]
|
||||
# every L2 after the first in a run without a join in between is a rejected reconnect
|
||||
rej = sum(1 for prev, cur in pairwise(seq) if prev == cur == "L2")
|
||||
rejected += rej
|
||||
print(" ", m, " ".join(seq), f"(rejected {rej})")
|
||||
if rejected:
|
||||
failed.append(f"{rejected} rejected reconnects")
|
||||
if want := [
|
||||
x.strip().upper() for x in os.environ.get("DUT_MACS", "").split(",") if x.strip()
|
||||
]:
|
||||
if missing := [x for x in want if x not in macs]:
|
||||
failed.append(f"no AP events for {', '.join(missing)}")
|
||||
elif len(macs) < 2:
|
||||
failed.append(f"AP events for {len(macs)} stations, expected both DUTs")
|
||||
bk = (D / f"bk-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
joins = [
|
||||
m.groups() for line in bk if (m := re.search(r"JOINED (.+?) after (\d+) ms", line))
|
||||
]
|
||||
print(" bk7238 joins:", " ".join(f"{s}@{int(t) / 1000:.1f}s" for s, t in joins))
|
||||
print(" bk7238 reboots:", sum("REBOOT on" in line for line in bk))
|
||||
if not any(s == AP_SSID for s, _ in joins):
|
||||
failed.append(f"the BK7238 never joined {AP_SSID}")
|
||||
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh BUILD flash the PMF AP, OTA the CB3S, serial flash the BK7238, then capture one AP window
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wd_ap_password) and
|
||||
# run it there. BUILD is a label (pr, base) for the output files.
|
||||
# env: TREE (esphome checkout for the DUTs), AP_PORT (classic devkit running wdap.yaml), BK_PORT (BK7238 serial),
|
||||
# CB_HOST (CB3S address on the house network), SC (path to serial_capture.py), FLASH_BK (path to flash_bk72xx.py),
|
||||
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), PY (checkout python), DUT_MACS
|
||||
# (optional, comma separated station MACs of both DUTs for an.py)
|
||||
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
AP=${AP_PORT:?set AP_PORT}; BK=${BK_PORT:?set BK_PORT}; CB=${CB_HOST:?set CB_HOST}; SC=${SC:?set SC}
|
||||
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
|
||||
pkill -f "[s]erial_capture.py ($AP|$BK)"
|
||||
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
|
||||
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
|
||||
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
|
||||
build run --no-logs "$D/wdap.yaml" --device "$AP" > "$D/ap-$b-upload.log" 2>&1 || { echo "FAIL: AP build or upload"; exit 1; }
|
||||
# The DUT reboots 150 s after joining the house network; retry when it reboots mid-upload.
|
||||
cb_upload() { build run --no-logs "$D/wdcb.yaml" --device "$CB" > "$D/cb-$b-upload.log" 2>&1; }
|
||||
retry 3 20 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
|
||||
build compile "$D/wdbk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
|
||||
# The build directory has two .uf2 files; firmware.uf2 is the one to flash.
|
||||
UF2=$D/.esphome/build/wdbk/.pioenvs/wdbk/firmware.uf2
|
||||
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
|
||||
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
|
||||
sleep 20
|
||||
# The reset restarts the AP's 420 s window.
|
||||
$PY "$SC" "$AP" 450 "$D/ap-$b.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
sleep 8
|
||||
$PY "$SC" "$BK" 440 "$D/bk-$b.txt" --released > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null' EXIT
|
||||
wait $C1 $C2; trap - EXIT
|
||||
$PY "$D/an.py" "$D" "$b"
|
||||
@@ -0,0 +1,44 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: connect_ms
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: ${ap_ssid}
|
||||
password: !secret wd_ap_password
|
||||
priority: 10
|
||||
- ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
priority: 0
|
||||
on_connect:
|
||||
- lambda: |-
|
||||
id(connect_ms) = millis();
|
||||
char buf[wifi::SSID_BUFFER_SIZE];
|
||||
ESP_LOGI("wd", "JOINED %s after %u ms since boot", wifi::global_wifi_component->wifi_ssid_to(buf), (unsigned) millis());
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: |-
|
||||
if (!wifi::global_wifi_component->is_connected() || id(connect_ms) == 0)
|
||||
return;
|
||||
char buf[wifi::SSID_BUFFER_SIZE];
|
||||
bool on_ap = strcmp(wifi::global_wifi_component->wifi_ssid_to(buf), "${ap_ssid}") == 0;
|
||||
uint32_t up = millis() - id(connect_ms);
|
||||
if (up > (on_ap ? 30000u : 150000u)) {
|
||||
ESP_LOGI("wd", "REBOOT on %s", on_ap ? "ap" : "home");
|
||||
App.safe_reboot();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
substitutions:
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
esphome:
|
||||
name: wdap
|
||||
includes:
|
||||
- wdap_inc.h
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- lambda: |-
|
||||
wifi_config_t c{};
|
||||
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
|
||||
if (err == ESP_OK) {
|
||||
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
|
||||
c.ap.pmf_cfg.required = false;
|
||||
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
c.ap.max_connection = 4;
|
||||
err = esp_wifi_set_config(WIFI_IF_AP, &c);
|
||||
}
|
||||
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
|
||||
wifi_config_t check{};
|
||||
if (err == ESP_OK)
|
||||
err = esp_wifi_get_config(WIFI_IF_AP, &check);
|
||||
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
|
||||
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
|
||||
} else {
|
||||
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
|
||||
}
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
auto *e = (wifi_event_ap_staconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X aid %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, (unsigned) millis());
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
auto *e = (wifi_event_ap_stadisconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X aid %u reason %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, e->reason, (unsigned) millis());
|
||||
}
|
||||
}, nullptr);
|
||||
- delay: 420s
|
||||
- lambda: ESP_LOGI("wdap", "AP WINDOW OVER");
|
||||
- wifi.disable:
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: ${ap_ssid}
|
||||
password: !secret wd_ap_password
|
||||
ap_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_event.h>
|
||||
@@ -0,0 +1,13 @@
|
||||
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
|
||||
substitutions:
|
||||
name: wdbk
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
packages:
|
||||
common: !include wd-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,13 @@
|
||||
# CB3S (BK7231N) DUT, flashed over the air by run.sh
|
||||
substitutions:
|
||||
name: wdcb
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
packages:
|
||||
common: !include wd-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: cb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Count the Xiaomi, ATC and PVVX publishes in a capture and compare them with expected.json from gen.py.
|
||||
Usage: count.py CAPTURE [pr|base] [--allow-loss N] [--probe]
|
||||
--probe (injection run): the PR build must leave the probe frame's service data unchanged.
|
||||
Exits 0 when every count matches the model (or falls short by at most N over the air), 1 otherwise."""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("capture", type=Path)
|
||||
ap.add_argument("build", nargs="?", choices=["pr", "base"])
|
||||
ap.add_argument("--allow-loss", type=int, default=0)
|
||||
ap.add_argument("--probe", action="store_true")
|
||||
a = ap.parse_args()
|
||||
cap, build, loss = a.capture, a.build, a.allow_loss
|
||||
LETTER = {"0A": "A", "0B": "B", "0C": "C", "0D": "D", "0E": "E", "0F": "F", "10": "G"}
|
||||
GOT = re.compile(
|
||||
r"Got (?:Xiaomi \S+|ATC MiThermometer|PVVX MiThermometer) \([AC]4:C1:38:00:00:(\w\w)\)"
|
||||
)
|
||||
|
||||
counts = collections.Counter()
|
||||
cur = None
|
||||
humidity = set()
|
||||
probe = {}
|
||||
for line in cap.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if m := GOT.search(line):
|
||||
cur = LETTER[m.group(1)]
|
||||
continue
|
||||
if m := re.search(r"\[probe.*\b(before|after)\s+([0-9A-F]+)", line):
|
||||
probe[m.group(1)] = m.group(2)
|
||||
if "Humidity:" in line:
|
||||
humidity.add(line.split("Humidity:")[1].strip())
|
||||
if (m := re.search(r"Temperature: (-?[\d.]+)", line)) and cur:
|
||||
t = float(m.group(1))
|
||||
tag = cur
|
||||
if (
|
||||
cur == "A" and t >= 50
|
||||
): # after-bad frames are 60.1 to 64.0, normal ones 22.1 to 26.0
|
||||
tag = "A_after_bad"
|
||||
if cur == "A" and abs(t - 33.3) < 0.01:
|
||||
tag = "A_probe" # xb_rewrite_probe's own parse, not part of the model
|
||||
if cur == "D" and abs(t - 11.1) < 0.01:
|
||||
tag = "D_boot"
|
||||
counts[tag] += 1
|
||||
cur = None
|
||||
print(cap.name, dict(sorted(counts.items())))
|
||||
print(" humidity values", len(humidity))
|
||||
if not counts:
|
||||
print("FAIL: no Xiaomi publishes in the capture")
|
||||
sys.exit(1)
|
||||
if build is None:
|
||||
sys.exit(0)
|
||||
expected = json.loads(Path("expected.json").read_text(encoding="utf-8"))[build]
|
||||
bad = 0
|
||||
for tag in sorted((set(expected) | set(counts)) - {"A_probe"}):
|
||||
want, got = expected.get(tag, 0), counts.get(tag, 0)
|
||||
ok = want - loss <= got <= want
|
||||
bad += not ok
|
||||
print(f" {tag:12} want {want:3} got {got:3} {'ok' if ok else 'DIFF'}")
|
||||
if a.probe:
|
||||
before, after = probe.get("before"), probe.get("after")
|
||||
print(f" probe before {before}\n probe after {after}")
|
||||
if before is None or after is None:
|
||||
print(" probe lines missing")
|
||||
bad += 1
|
||||
elif build == "pr" and before != after:
|
||||
print(" the listener rewrote the shared service data")
|
||||
bad += 1
|
||||
print(
|
||||
f"{'PASS' if not bad else 'FAIL'}: {build} model {'matches' if not bad else f'has {bad} failed checks'}"
|
||||
)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Generate MiBeacon, ATC and PVVX advertisements, a C++ header that sends them and the expected publish counts.
|
||||
Usage: gen.py [--ota]
|
||||
Without --ota: frames.hpp for xbinject.yaml (scan results injected into the tracker, A4:C1:38 addresses).
|
||||
With --ota: adv_frames.hpp for xbadv.yaml (a second ESP32 advertises them, C4:C1:38 random static addresses).
|
||||
Both write expected.json ({"base": {...}, "pr": {...}}) for count.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
|
||||
|
||||
OTA = "--ota" in sys.argv[1:]
|
||||
CYCLES = 40
|
||||
K1 = bytes.fromhex("6c0d1a3e5b7f29c4a18e90d2f3b46a57")
|
||||
K2 = bytes.fromhex("0f1e2d3c4b5a69788796a5b4c3d2e1f0")
|
||||
# Random static addresses need the top two bits set, so the over the air run uses C4 instead of A4.
|
||||
PREFIX = "C4C138" if OTA else "A4C138"
|
||||
MACS = {
|
||||
k: f"{PREFIX}0000{i + 10:02X}" for i, k in enumerate("ABCDEFG")
|
||||
} # ...0A to ...10
|
||||
MAC = {k: bytes.fromhex(v) for k, v in MACS.items()}
|
||||
MIDX = {k: i for i, k in enumerate(MACS)}
|
||||
|
||||
|
||||
def mibeacon_plain(dev, pid, cnt, obj):
|
||||
return (
|
||||
bytes([0x50, 0x20])
|
||||
+ struct.pack("<H", pid)
|
||||
+ bytes([cnt])
|
||||
+ MAC[dev][::-1]
|
||||
+ obj
|
||||
)
|
||||
|
||||
|
||||
def mibeacon_enc(dev, pid, cnt, obj, key, short, corrupt=False):
|
||||
ext = bytes([cnt, 0x00, 0x00])
|
||||
if short:
|
||||
hdr = bytes([0x48, 0x59]) + struct.pack("<H", pid) + bytes([cnt])
|
||||
else:
|
||||
hdr = (
|
||||
bytes([0x58, 0x58]) + struct.pack("<H", pid) + bytes([cnt]) + MAC[dev][::-1]
|
||||
)
|
||||
nonce = MAC[dev][::-1] + hdr[2:5] + ext
|
||||
ct = AESCCM(key, tag_length=4).encrypt(nonce, obj, b"\x11")
|
||||
body, tag = ct[:-4], ct[-4:]
|
||||
if corrupt:
|
||||
tag = bytes([tag[0] ^ 0xFF]) + tag[1:]
|
||||
out = hdr + body + ext + tag
|
||||
assert len(out) == (19 if short else 18 + len(obj)), len(out)
|
||||
return out
|
||||
|
||||
|
||||
def t16(v): # 0x1004 temperature object
|
||||
return bytes([0x04, 0x10, 0x02]) + struct.pack("<h", round(v * 10))
|
||||
|
||||
|
||||
def th(t, h): # 0x100D temperature + humidity
|
||||
return bytes([0x0D, 0x10, 0x04]) + struct.pack("<hh", round(t * 10), round(h * 10))
|
||||
|
||||
|
||||
frames = [] # (dev, kind 0 = FE95 / 1 = 181A, data, tag for the expectation)
|
||||
frames.append(("D", 0, mibeacon_plain("D", 0x0347, 0, t16(11.1)), "D_boot"))
|
||||
steps = [len(frames)]
|
||||
for k in range(1, CYCLES + 1):
|
||||
frames.append(("B", 0, mibeacon_plain("B", 0x01AA, k, th(20 + k / 10, 40)), "B"))
|
||||
frames.append(("C", 0, mibeacon_plain("C", 0x0098, k, t16(21 + k / 10)), "C"))
|
||||
frames.append(
|
||||
("A", 0, mibeacon_enc("A", 0x055B, k + 100, t16(22 + k / 10), K1, False), "A")
|
||||
)
|
||||
if k % 2 == 0:
|
||||
bad = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False, True)
|
||||
frames.append(("A", 0, bad, "A_bad"))
|
||||
good = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False)
|
||||
frames.append(("A", 0, good, "A_after_bad"))
|
||||
frames.append(
|
||||
("E", 0, mibeacon_enc("E", 0x1203, k + 50, th(23 + k / 10, 50), K2, True), "E")
|
||||
)
|
||||
frames.append(("D", 0, mibeacon_plain("D", 0x0347, k + 200, t16(24 + k / 10)), "D"))
|
||||
atc = (
|
||||
MAC["F"]
|
||||
+ struct.pack(">h", round((26 + k / 10) * 10))
|
||||
+ bytes([45, 90])
|
||||
+ struct.pack(">H", 2950)
|
||||
)
|
||||
frames.append(("F", 1, atc + bytes([k]), "F"))
|
||||
pv = (
|
||||
MAC["G"][::-1]
|
||||
+ struct.pack("<hHH", round((27 + k / 10) * 100), 4600, 3010)
|
||||
+ bytes([88, k, 0])
|
||||
)
|
||||
frames.append(("G", 1, pv, "G"))
|
||||
steps.append(len(frames))
|
||||
|
||||
|
||||
def simulate(pr):
|
||||
"""Base keeps one counter shared by every device and checks it before the tag; the PR keeps one per device."""
|
||||
last_shared = 0
|
||||
last = {}
|
||||
pub = {}
|
||||
for dev, kind, data, tag in frames:
|
||||
if kind == 1:
|
||||
cnt = data[12 if dev == "F" else 13]
|
||||
if last.get(("x", dev)) == cnt:
|
||||
continue
|
||||
last["x", dev] = cnt
|
||||
elif pr:
|
||||
if last.get(dev) == data[4] or tag == "A_bad":
|
||||
continue
|
||||
last[dev] = data[4]
|
||||
else:
|
||||
if last_shared == data[4]:
|
||||
continue
|
||||
last_shared = data[4]
|
||||
if tag == "A_bad":
|
||||
continue
|
||||
pub[tag] = pub.get(tag, 0) + 1
|
||||
return pub
|
||||
|
||||
|
||||
expected = {"base": simulate(False), "pr": simulate(True)}
|
||||
print("base", expected["base"])
|
||||
print("pr ", expected["pr"])
|
||||
Path("expected.json").write_text(json.dumps(expected), encoding="utf-8")
|
||||
|
||||
|
||||
def c_rows(name, rows):
|
||||
out = [f"static const uint8_t {name}[{len(rows)}][{len(rows[0][0])}] = {{"]
|
||||
out += [
|
||||
" {" + ",".join(f"0x{b:02X}" for b in row) + "}," + (f" // {c}" if c else "")
|
||||
for row, c in rows
|
||||
]
|
||||
return "\n".join([*out, "};", ""])
|
||||
|
||||
|
||||
mac_rows = [(MAC[k], "") for k in MACS]
|
||||
frame_rows = [
|
||||
((bytes([MIDX[d], kind, len(data)]) + data).ljust(28, b"\0"), tag)
|
||||
for d, kind, data, tag in frames
|
||||
]
|
||||
|
||||
INJECT = """
|
||||
inline void xb_fill(esphome::esp32_ble::BLEScanResult &r, const uint8_t *mac, bool fe95, const uint8_t *data,
|
||||
uint8_t len) {
|
||||
memcpy(r.bda, mac, 6);
|
||||
r.ble_addr_type = 0;
|
||||
r.rssi = -50;
|
||||
uint8_t n = 0;
|
||||
r.ble_adv[n++] = 2;
|
||||
r.ble_adv[n++] = 0x01;
|
||||
r.ble_adv[n++] = 0x06;
|
||||
r.ble_adv[n++] = len + 3;
|
||||
r.ble_adv[n++] = 0x16;
|
||||
r.ble_adv[n++] = fe95 ? 0x95 : 0x1A;
|
||||
r.ble_adv[n++] = fe95 ? 0xFE : 0x18;
|
||||
memcpy(r.ble_adv + n, data, len);
|
||||
r.adv_data_len = n + len;
|
||||
r.scan_rsp_len = 0;
|
||||
r.search_evt = ESP_GAP_SEARCH_INQ_RES_EVT;
|
||||
}
|
||||
|
||||
inline void xb_inject(size_t first, size_t last) {
|
||||
for (size_t i = first; i < last; i++) {
|
||||
const uint8_t *row = XB_FRAMES[i];
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
xb_fill(r, XB_MACS[row[0]], row[1] == 0, row + 3, row[2]);
|
||||
esphome::esp32_ble_tracker::global_esp32_ble_tracker->gap_scan_event_handler(r);
|
||||
}
|
||||
}
|
||||
|
||||
inline void xb_hex(const char *label, const std::vector<uint8_t> &v) {
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < v.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", v[i]);
|
||||
ESP_LOGI("probe", "%s %s", label, buf);
|
||||
}
|
||||
|
||||
// Parse one encrypted frame with the listener directly and show whether it rewrote the shared service data.
|
||||
inline void xb_rewrite_probe(esphome::ble_device_base::ESPBTDeviceListener *listener) {
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
xb_fill(r, XB_MACS[0], true, XB_PROBE, sizeof(XB_PROBE));
|
||||
esphome::ble_device_base::ESPBTDevice device;
|
||||
device.parse_scan_rst(r);
|
||||
xb_hex("before", device.get_service_datas()[0].data);
|
||||
bool ok = listener->parse_device(device);
|
||||
ESP_LOGI("probe", "parse_device returned %d", ok);
|
||||
xb_hex("after ", device.get_service_datas()[0].data);
|
||||
}
|
||||
"""
|
||||
|
||||
ADVERTISE = """
|
||||
// Sends where a GAP call returned an error when it was queued. Failures the controller reports later in the GAP
|
||||
// completion events are not counted; those frames show up as missing publishes in count.py.
|
||||
static uint32_t af_failed = 0;
|
||||
|
||||
// Returns false (and counts it) when the address, the data or the start was rejected.
|
||||
inline bool af_send(int i) {
|
||||
const uint8_t *row = AF_FRAMES[i];
|
||||
uint8_t adv[31];
|
||||
uint8_t n = 0;
|
||||
adv[n++] = 2;
|
||||
adv[n++] = 0x01;
|
||||
adv[n++] = 0x06;
|
||||
adv[n++] = row[2] + 3;
|
||||
adv[n++] = 0x16;
|
||||
adv[n++] = row[1] == 0 ? 0x95 : 0x1A;
|
||||
adv[n++] = row[1] == 0 ? 0xFE : 0x18;
|
||||
memcpy(adv + n, row + 3, row[2]);
|
||||
n += row[2];
|
||||
esp_ble_gap_stop_advertising(); // fails harmlessly when nothing is advertising yet
|
||||
uint8_t addr[6];
|
||||
memcpy(addr, AF_MACS[row[0]], 6);
|
||||
esp_err_t e1 = esp_ble_gap_set_rand_addr(addr);
|
||||
esp_err_t e2 = esp_ble_gap_config_adv_data_raw(adv, n);
|
||||
esp_ble_adv_params_t p{};
|
||||
p.adv_int_min = 0x20; // 20 ms
|
||||
p.adv_int_max = 0x20;
|
||||
p.adv_type = ADV_TYPE_NONCONN_IND;
|
||||
p.own_addr_type = BLE_ADDR_TYPE_RANDOM;
|
||||
p.channel_map = ADV_CHNL_ALL;
|
||||
p.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY;
|
||||
esp_err_t e3 = esp_ble_gap_start_advertising(&p);
|
||||
if (e1 == ESP_OK && e2 == ESP_OK && e3 == ESP_OK)
|
||||
return true;
|
||||
af_failed++;
|
||||
ESP_LOGW("af", "frame %d send failed: rand_addr %d adv_data %d start %d", i, e1, e2, e3);
|
||||
return false;
|
||||
}
|
||||
|
||||
inline void af_stop() { esp_ble_gap_stop_advertising(); }
|
||||
"""
|
||||
|
||||
if OTA:
|
||||
head = '#pragma once\n#include <esp_gap_ble_api.h>\n#include <cstring>\n#include "esphome/core/log.h"\n'
|
||||
body = c_rows("AF_MACS", mac_rows) + c_rows("AF_FRAMES", frame_rows)
|
||||
body += f"static constexpr int AF_COUNT = {len(frames)};\n" + ADVERTISE
|
||||
Path("adv_frames.hpp").write_text(head + body, encoding="utf-8")
|
||||
else:
|
||||
head = '#pragma once\n#include <cstring>\n#include "esphome/components/esp32_ble/ble_scan_result.h"\n'
|
||||
probe = mibeacon_enc("A", 0x055B, 0xF5, t16(33.3), K1, False)
|
||||
body = c_rows("XB_MACS", mac_rows) + c_rows("XB_FRAMES", frame_rows)
|
||||
body += (
|
||||
f"static const uint16_t XB_STEPS[{len(steps)}] = {{"
|
||||
+ ",".join(map(str, steps))
|
||||
+ "};\n"
|
||||
)
|
||||
body += f"static constexpr size_t XB_NUM_STEPS = {len(steps)};\n"
|
||||
body += (
|
||||
"static const uint8_t XB_PROBE[] = {"
|
||||
+ ",".join(f"0x{b:02X}" for b in probe)
|
||||
+ "};\n"
|
||||
)
|
||||
Path("frames.hpp").write_text(head + body + INJECT, encoding="utf-8")
|
||||
print("frames", len(frames), "longest", max(len(d) for _, _, d, _ in frames))
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh inject|ota pr|base build and flash the receiver, capture its serial log, compare the counts
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, api_key) and run it there.
|
||||
# inject: xbinject.yaml injects the frames into its own tracker. ota: xbrecv.yaml listens to xbadv.yaml, which must
|
||||
# already run on a second ESP32 (python gen.py --ota, then flash xbadv.yaml once).
|
||||
# env: TREE (esphome checkout to build from), PORT (receiver serial port), SC (path to serial_capture.py),
|
||||
# PY (checkout python), ADV (advertiser address, ota only)
|
||||
D=$(cd "$(dirname "$0")" && pwd); MODE=${1:?inject or ota}; B=${2:?pr or base}; T=${TREE:?set TREE}
|
||||
P=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
cd "$D" || exit 1
|
||||
if [ "$MODE" = ota ]; then
|
||||
A=${ADV:?set ADV}; Y=xbrecv; SECS=130; LOSS=2
|
||||
$PY gen.py --ota > gen.txt || { echo "gen.py failed"; exit 1; }
|
||||
else
|
||||
Y=xbinject; SECS=80; LOSS=0; PROBE=--probe
|
||||
$PY gen.py > gen.txt || { echo "gen.py failed"; exit 1; }
|
||||
fi
|
||||
OUT=$D/$MODE-$B; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$Y.yaml" --device "$P" > "$OUT/upload.log" 2>&1) \
|
||||
|| { echo "FAIL: build or upload failed, see $OUT/upload.log"; exit 1; }
|
||||
$PY "$SC" "$P" $SECS "$OUT/cap.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
if [ "$MODE" = ota ]; then
|
||||
# 18 s lets the receiver boot and start scanning, so the counter 0 boot frame is the first one it hears
|
||||
sleep 18
|
||||
CODE=$(curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$A/button/start/press")
|
||||
[ "$CODE" = 200 ] || { echo "FAIL: advertiser start button returned $CODE"; exit 1; }
|
||||
fi
|
||||
wait $CAP; trap - EXIT
|
||||
[ -s "$OUT/cap.txt" ] || { echo "FAIL: serial capture got nothing"; exit 1; }
|
||||
CRASH=$(grep -acE 'Guru|abort\(\)|Backtrace:' "$OUT/cap.txt")
|
||||
$PY count.py "$OUT/cap.txt" "$B" --allow-loss $LOSS $PROBE; RC=$?
|
||||
if [ "$MODE" = ota ]; then
|
||||
# the advertiser counts frames whose GAP calls failed; those are not radio loss
|
||||
FAILED=$(curl -s -m 5 "http://$A/sensor/failed%20sends" | $PY -c "import json,sys; print(int(json.load(sys.stdin)['value']))")
|
||||
echo "advertiser failed sends: ${FAILED:-unknown}"
|
||||
[ "$FAILED" = 0 ] || RC=1
|
||||
fi
|
||||
echo "crash lines: $CRASH"
|
||||
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
|
||||
@@ -0,0 +1,76 @@
|
||||
esphome:
|
||||
name: xbadv
|
||||
includes:
|
||||
- adv_frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
esp32_ble:
|
||||
|
||||
globals:
|
||||
- id: af_idx
|
||||
type: int
|
||||
initial_value: "-1"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: start
|
||||
on_press:
|
||||
- lambda: |-
|
||||
id(af_idx) = 0;
|
||||
af_failed = 0;
|
||||
// an unfinished sequence reads as unknown, not as the 0 from an earlier run
|
||||
id(failed_sends).publish_state(NAN);
|
||||
ESP_LOGI("af", "sequence start");
|
||||
|
||||
interval:
|
||||
- interval: 250ms
|
||||
then:
|
||||
- lambda: |-
|
||||
int i = id(af_idx);
|
||||
if (i < 0)
|
||||
return;
|
||||
if (i >= AF_COUNT) {
|
||||
af_stop();
|
||||
id(af_idx) = -1;
|
||||
ESP_LOGI("af", "sequence done, %u failed sends", (unsigned) af_failed);
|
||||
id(failed_sends).publish_state(af_failed);
|
||||
return;
|
||||
}
|
||||
af_send(i);
|
||||
id(af_idx) = i + 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: failed_sends
|
||||
name: failed sends
|
||||
accuracy_decimals: 0
|
||||
update_interval: never
|
||||
@@ -0,0 +1,107 @@
|
||||
substitutions:
|
||||
name: xbinject
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
globals:
|
||||
- id: xb_step
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
esp32_ble_tracker:
|
||||
on_ble_service_data_advertise:
|
||||
- mac_address: A4:C1:38:00:00:0A
|
||||
service_uuid: FE95
|
||||
then:
|
||||
- lambda: |-
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
|
||||
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
|
||||
|
||||
interval:
|
||||
- interval: 1s
|
||||
startup_delay: 15s
|
||||
then:
|
||||
- lambda: |-
|
||||
if (id(xb_step) + 1 >= (int) XB_NUM_STEPS) {
|
||||
if (id(xb_step) + 1 == (int) XB_NUM_STEPS) {
|
||||
ESP_LOGI("xb", "INJECT DONE");
|
||||
xb_rewrite_probe(id(xa));
|
||||
id(xb_step)++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
int s = id(xb_step);
|
||||
ESP_LOGI("xb", "inject step %d frames %u..%u", s, XB_STEPS[s], XB_STEPS[s + 1]);
|
||||
if (s == 0)
|
||||
xb_inject(0, XB_STEPS[0]);
|
||||
xb_inject(XB_STEPS[s], XB_STEPS[s + 1]);
|
||||
id(xb_step)++;
|
||||
|
||||
sensor:
|
||||
- platform: xiaomi_lywsd03mmc
|
||||
id: xa
|
||||
mac_address: A4:C1:38:00:00:0A
|
||||
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
|
||||
temperature:
|
||||
name: "xA lywsd03mmc temp"
|
||||
- platform: xiaomi_lywsdcgq
|
||||
mac_address: A4:C1:38:00:00:0B
|
||||
temperature:
|
||||
name: "xB lywsdcgq temp"
|
||||
humidity:
|
||||
name: "xB lywsdcgq hum"
|
||||
- platform: xiaomi_hhccjcy01
|
||||
mac_address: A4:C1:38:00:00:0C
|
||||
temperature:
|
||||
name: "xC hhccjcy01 temp"
|
||||
- platform: xiaomi_cgg1
|
||||
mac_address: A4:C1:38:00:00:0D
|
||||
temperature:
|
||||
name: "xD cgg1 temp"
|
||||
- platform: xiaomi_xmwsdj04mmc
|
||||
mac_address: A4:C1:38:00:00:0E
|
||||
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
|
||||
temperature:
|
||||
name: "xE xmwsdj04mmc temp"
|
||||
humidity:
|
||||
name: "xE xmwsdj04mmc hum"
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:00:00:0F
|
||||
temperature:
|
||||
name: "xF atc temp"
|
||||
humidity:
|
||||
name: "xF atc hum"
|
||||
battery_voltage:
|
||||
name: "xF atc volt"
|
||||
- platform: pvvx_mithermometer
|
||||
mac_address: A4:C1:38:00:00:10
|
||||
temperature:
|
||||
name: "xG pvvx temp"
|
||||
humidity:
|
||||
name: "xG pvvx hum"
|
||||
battery_voltage:
|
||||
name: "xG pvvx volt"
|
||||
@@ -0,0 +1,83 @@
|
||||
substitutions:
|
||||
name: xbrecv
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 100ms
|
||||
active: false
|
||||
on_ble_service_data_advertise:
|
||||
- mac_address: C4:C1:38:00:00:0A
|
||||
service_uuid: FE95
|
||||
then:
|
||||
- lambda: |-
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
|
||||
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
|
||||
|
||||
sensor:
|
||||
- platform: xiaomi_lywsd03mmc
|
||||
mac_address: C4:C1:38:00:00:0A
|
||||
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
|
||||
temperature:
|
||||
name: "xA lywsd03mmc temp"
|
||||
- platform: xiaomi_lywsdcgq
|
||||
mac_address: C4:C1:38:00:00:0B
|
||||
temperature:
|
||||
name: "xB lywsdcgq temp"
|
||||
humidity:
|
||||
name: "xB lywsdcgq hum"
|
||||
- platform: xiaomi_hhccjcy01
|
||||
mac_address: C4:C1:38:00:00:0C
|
||||
temperature:
|
||||
name: "xC hhccjcy01 temp"
|
||||
- platform: xiaomi_cgg1
|
||||
mac_address: C4:C1:38:00:00:0D
|
||||
temperature:
|
||||
name: "xD cgg1 temp"
|
||||
- platform: xiaomi_xmwsdj04mmc
|
||||
mac_address: C4:C1:38:00:00:0E
|
||||
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
|
||||
temperature:
|
||||
name: "xE xmwsdj04mmc temp"
|
||||
humidity:
|
||||
name: "xE xmwsdj04mmc hum"
|
||||
- platform: atc_mithermometer
|
||||
mac_address: C4:C1:38:00:00:0F
|
||||
temperature:
|
||||
name: "xF atc temp"
|
||||
humidity:
|
||||
name: "xF atc hum"
|
||||
battery_voltage:
|
||||
name: "xF atc volt"
|
||||
- platform: pvvx_mithermometer
|
||||
mac_address: C4:C1:38:00:00:10
|
||||
temperature:
|
||||
name: "xG pvvx temp"
|
||||
humidity:
|
||||
name: "xG pvvx hum"
|
||||
battery_voltage:
|
||||
name: "xG pvvx volt"
|
||||
@@ -0,0 +1,64 @@
|
||||
# RTL8720C (BW15) crash report test build (#21648): one API button per fault, driven by
|
||||
# scripts/libretiny_crash/rtl_drive.py. Each boot logs the reboot reason and whether a crash record was found.
|
||||
substitutions:
|
||||
name: rtlcrash
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
// "n/a" when the handler is not built (a dev control build), so it is not read as a lost record
|
||||
const char *rec = "n/a";
|
||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||
rec = esphome::libretiny::crash_handler_has_data() ? "yes" : "no";
|
||||
#endif
|
||||
ESP_LOGI("probe", "boot reason %d, crash record %s", (int) lt_get_reboot_reason(), rec);
|
||||
|
||||
rtl87xx:
|
||||
board: bw15
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: trap
|
||||
on_press:
|
||||
- lambda: __builtin_trap();
|
||||
- platform: template
|
||||
name: bus fault
|
||||
on_press:
|
||||
- lambda: '*(volatile uint32_t *) 0x70000000 = 1;'
|
||||
- platform: template
|
||||
name: wild call
|
||||
on_press:
|
||||
- lambda: ((void (*)()) 0xFFFFFFF1)();
|
||||
- platform: template
|
||||
name: isr trap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
auto *vt = (uint32_t *) *(volatile uint32_t *) 0xE000ED08;
|
||||
vt[16 + 11] = (uint32_t) +[]() { __builtin_trap(); };
|
||||
*(volatile uint32_t *) 0xE000E100 = 1u << 11;
|
||||
*(volatile uint32_t *) 0xE000E200 = 1u << 11;
|
||||
- platform: template
|
||||
name: hang
|
||||
on_press:
|
||||
- lambda: |-
|
||||
__disable_irq();
|
||||
while (true) {
|
||||
}
|
||||
- platform: restart
|
||||
name: restart
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -154,14 +154,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+36
-25
@@ -34,7 +34,7 @@ jobs:
|
||||
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -73,17 +73,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
# 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
|
||||
# only from the package list and version, kept in the apt-packages action.
|
||||
# In ci-status needs so a broken seed fails dev.
|
||||
# only from the package list and version; keep both identical in every
|
||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
||||
if: github.event_name == 'push'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
- 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:
|
||||
name: Determine which jobs to run
|
||||
@@ -413,13 +412,17 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. libsdl2-dev is needed by the headless display tests, which
|
||||
# capture screenshots.
|
||||
# path. Packages and version must match seed-apt-cache exactly.
|
||||
# libsdl2-dev is needed by the headless display tests, which capture
|
||||
# screenshots.
|
||||
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
|
||||
id: python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore integration library cache
|
||||
@@ -438,7 +441,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -627,7 +630,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
|
||||
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -715,9 +718,13 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
if: matrix.pio_cache_key == 'tidyhost'
|
||||
# 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
|
||||
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
|
||||
id: restore-python
|
||||
@@ -1104,16 +1111,20 @@ jobs:
|
||||
- name: List components
|
||||
run: echo ${{ matrix.batch.components }}
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install apt packages (cached)
|
||||
# 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
|
||||
# the cold path. The action has no --no-install-recommends; same
|
||||
# package set this job used before #17463.
|
||||
# the cold path. Packages and version must match seed-apt-cache
|
||||
# exactly. The action has no --no-install-recommends; same package
|
||||
# set this job used before #17463.
|
||||
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
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1446,7 +1457,7 @@ jobs:
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
@@ -1455,7 +1466,7 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -126,12 +126,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -206,13 +206,13 @@ jobs:
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.17.2
|
||||
rev: v0.17.1
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -54,19 +54,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each.
|
||||
* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes.
|
||||
|
||||
* **Skills:** detailed recipes live in skills under `.agents/skills/` (also reachable as `.claude/skills/` and
|
||||
`.github/skills/`). Load the matching skill before doing that kind of work; agents without skill support
|
||||
should read the `SKILL.md` file directly.
|
||||
* `component-codegen`: component files and metadata, `to_code`, hub child entities, codegen helpers,
|
||||
`CORE.data` state.
|
||||
* `config-validation`: schemas, validators, `CONF_` constants, and keeping schema extraction working.
|
||||
* `automations`: triggers, actions, conditions and callback registration.
|
||||
* `embedded-memory`: container and string choices that avoid heap use after `setup()`.
|
||||
* `component-tests`: YAML build tests, bus packages and CI grouping, C++ unit tests, integration tests.
|
||||
* `breaking-changes`: what is public API, deprecation patterns and migration notes.
|
||||
* `pr-workflow`, `code-review`, `hardware-test`, `display-test`: creating PRs, reviewing them, and testing
|
||||
on real boards and panels.
|
||||
|
||||
* **Formatting:**
|
||||
* **Python:** Uses `ruff` for linting and formatting. Configuration is in `pyproject.toml`.
|
||||
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
|
||||
@@ -239,6 +226,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
|
||||
resend and give-up logic handles it; never advance on an answer that was only logged.
|
||||
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
|
||||
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
|
||||
listener base (a second vptr per entity).
|
||||
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
|
||||
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
|
||||
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
|
||||
@@ -289,28 +279,398 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
};
|
||||
```
|
||||
|
||||
* **Component Code Generation:** every component's `__init__.py` defines `DOMAIN` (its own name, checked by
|
||||
CI). Hub entities go in platform files (`sensor.py`, ...) that take the hub's id, not under the hub's own
|
||||
key. File layout, metadata, the `to_code` pattern, `sub_sensors` and the other codegen helpers are in the
|
||||
`component-codegen` skill.
|
||||
* **Component Structure:**
|
||||
* **Standard Files:**
|
||||
```
|
||||
components/[component_name]/
|
||||
├── __init__.py # Component configuration schema and code generation
|
||||
├── [component].h # C++ header file (if needed)
|
||||
├── [component].cpp # C++ implementation (if needed)
|
||||
└── [platform]/ # Platform-specific implementations
|
||||
├── __init__.py # Platform-specific configuration
|
||||
├── [platform].h # Platform C++ header
|
||||
└── [platform].cpp # Platform C++ implementation
|
||||
```
|
||||
|
||||
* **Automations (Triggers, Actions, Conditions):** prefer `build_callback_automation()` over a `Trigger`
|
||||
class, and `register_apply_action` / `register_apply_condition` over a C++ class that only forwards to its
|
||||
parent. Callback registration methods are always `template<typename F>`, never `std::function`. Details
|
||||
and examples are in the `automations` skill.
|
||||
* **Component Metadata:**
|
||||
- `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
|
||||
string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: List of required components
|
||||
- `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's
|
||||
own key; see "Entities of a hub" below.
|
||||
- `CONFLICTS_WITH`: Incompatible components
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance
|
||||
- `MULTI_CONF`: Whether multiple instances are allowed
|
||||
|
||||
* **Code Generation & Common Patterns:**
|
||||
* **Configuration Schema Pattern:**
|
||||
```python
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
|
||||
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_key(config[CONF_KEY]))
|
||||
cg.add(var.set_param(config[CONF_PARAM]))
|
||||
```
|
||||
|
||||
* **C++ Class Pattern:**
|
||||
```cpp
|
||||
namespace esphome::my_component {
|
||||
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_key(const std::string &key) { this->key_ = key; }
|
||||
void set_param(int param) { this->param_ = param; }
|
||||
|
||||
protected:
|
||||
std::string key_;
|
||||
int param_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::my_component
|
||||
```
|
||||
|
||||
* **Common Component Examples:**
|
||||
- **Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
```
|
||||
|
||||
- **Binary Sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
|
||||
- **Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
- **Entities of a hub:** a new component's entities go in platform files (`sensor.py`,
|
||||
`binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms
|
||||
added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is
|
||||
fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
- **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`,
|
||||
`sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented<T>`,
|
||||
then make one call per key, even when there is only one. A call creates the entity only when its key
|
||||
is configured, passes it to the setter and returns it (or `None`); extra arguments such as
|
||||
`min_value` or `options` go on the call. Always name the setter explicitly on the object that owns
|
||||
it, never with `getattr` and an f-string, and keep that variable short (`var` for the component
|
||||
itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose
|
||||
setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`.
|
||||
|
||||
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
|
||||
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
|
||||
the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which
|
||||
already applies `inverted`.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
constructs, registers and attaches in one call; `display.add_writer(var, config)` sets the lambda;
|
||||
`MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict; `button.restart_button_schema(cls)` (and
|
||||
`factory_reset_`, `query_`) are button presets; `fv.get_declaring_config(id)` finds a hub's config in
|
||||
final validation.
|
||||
|
||||
* **Automations (Triggers, Actions, Conditions):**
|
||||
|
||||
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
|
||||
|
||||
* **Triggers -- Callback method (preferred):**
|
||||
|
||||
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
for conf in config.get(CONF_ON_STATE, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_state_callback", [(bool, "x")], conf
|
||||
)
|
||||
```
|
||||
|
||||
`build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder<Ts...>`).
|
||||
|
||||
For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
|
||||
```python
|
||||
for conf_key, forwarder in (
|
||||
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
|
||||
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
|
||||
):
|
||||
for conf in config.get(conf_key, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_state_callback", [], conf, forwarder=forwarder
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only
|
||||
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
|
||||
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
|
||||
`forward` from `MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
protected:
|
||||
// Use CallbackManager when callbacks are always registered (e.g. core components)
|
||||
CallbackManager<void(bool)> state_callback_;
|
||||
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
|
||||
// (nullptr vs empty std::vector) per instance when no callbacks are added
|
||||
// LazyCallbackManager<void(bool)> state_callback_;
|
||||
};
|
||||
```
|
||||
|
||||
* **Triggers -- Trigger class method:**
|
||||
|
||||
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
for conf in config.get(CONF_ON_TURN_ON, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
```
|
||||
|
||||
**C++:**
|
||||
```cpp
|
||||
class TurnOnTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
|
||||
parent->add_on_state_callback([this](bool state) {
|
||||
if (state && !this->last_on_)
|
||||
this->trigger();
|
||||
this->last_on_ = state;
|
||||
});
|
||||
}
|
||||
protected:
|
||||
bool last_on_;
|
||||
};
|
||||
```
|
||||
|
||||
* **Actions:**
|
||||
```cpp
|
||||
template<typename... Ts> class MyAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit MyAction(MyComponent *parent) : parent_(parent) {}
|
||||
void play(const Ts &...) override { this->parent_->do_something(); }
|
||||
protected:
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and
|
||||
the `@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them
|
||||
with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
|
||||
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
|
||||
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
|
||||
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
* **Conditions:**
|
||||
```cpp
|
||||
template<typename... Ts> class MyCondition : public Condition<Ts...> {
|
||||
public:
|
||||
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
|
||||
bool check(const Ts &...) override { return this->parent_->is_active(); }
|
||||
protected:
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares
|
||||
against config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
|
||||
|
||||
* **Configuration Validation:** follow the `config-validation` skill. In short: one `cv.typed_schema` per
|
||||
model, reuse validators from `config_validation.py`, and never add `CONF_` constants to the frozen
|
||||
`esphome/const.py`.
|
||||
* **Schema extraction:** whenever config validation code is added or changed, make sure
|
||||
`script/build_language_schema.py` still extracts the full schema. The dumper cannot see inside a plain
|
||||
function, so a validator function that wraps, chooses or builds a schema must be decorated with
|
||||
`@schema_extractor(...)` and return the schema data when called with `SCHEMA_EXTRACT` (both from
|
||||
`esphome.schema_extractors`). CI does not catch a missing schema; build the schema and check the
|
||||
component's JSON as the `config-validation` skill describes.
|
||||
* **Configuration Validation:**
|
||||
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
|
||||
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
|
||||
that rejects keys and fills defaults afterwards.
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
* **Schema Extensions:**
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.Schema({ ... })
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -330,12 +690,71 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
* **Component tests:** YAML build tests live in `tests/components/<component>/` and run with
|
||||
`script/test_build_components -c <component>`. Never define buses directly in test YAML: include the
|
||||
shared packages from `tests/test_build_components/common/` with dict-style `packages:`. Test file naming,
|
||||
`validate.*.yaml` config-only tests, CI grouping, C++ unit tests and integration tests are in the
|
||||
`component-tests` skill.
|
||||
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
|
||||
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
|
||||
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
|
||||
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
|
||||
`responses` match on content every time they see it; to answer a repeated request differently, use an
|
||||
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
|
||||
on. A variant per board for platform-identical code only adds CI time.
|
||||
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
|
||||
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
|
||||
```
|
||||
tests/
|
||||
├── test_build_components/
|
||||
│ └── common/ # Shared bus packages (uart, i2c, spi, etc.)
|
||||
│ ├── uart/ # UART at default baud rate
|
||||
│ ├── uart_115200/ # UART at 115200 baud
|
||||
│ ├── i2c/ # I2C bus
|
||||
│ └── spi/ # SPI bus
|
||||
└── components/[component]/
|
||||
├── common.yaml # Component-only config (no bus definitions)
|
||||
├── test.esp32-idf.yaml # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
|
||||
|
||||
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml` — base config-only test
|
||||
- `validate-<variant>.<platform>.yaml` — config-only variant
|
||||
|
||||
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !include common.yaml
|
||||
```
|
||||
```yaml
|
||||
# common.yaml — component config only, NO bus definitions
|
||||
my_component:
|
||||
id: my_instance
|
||||
|
||||
sensor:
|
||||
- platform: my_component
|
||||
name: My Sensor
|
||||
```
|
||||
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
|
||||
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
|
||||
|
||||
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
|
||||
```bash
|
||||
./script/test_component_grouping.py -e config --all
|
||||
```
|
||||
This tests all components in a single build to catch conflicts that might not appear when testing components individually. Use `-e config` for fast configuration validation, or `-e compile` for full compilation testing.
|
||||
* **Debugging and Troubleshooting:**
|
||||
* **Debug Tools:**
|
||||
- `esphome config <file>.yaml` to validate configuration.
|
||||
@@ -349,7 +768,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
## 7. Specific Instructions for AI Collaboration
|
||||
|
||||
* **Contribution Workflow (Pull Request Process):** the `pr-workflow` skill walks through these steps.
|
||||
* **Contribution Workflow (Pull Request Process):**
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
@@ -369,15 +788,236 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Configuration Design:** Aim for simplicity with sensible defaults, while allowing for advanced customization.
|
||||
* **Embedded Systems Optimization:** ESPHome targets resource-constrained microcontrollers. Be mindful of flash size and RAM usage.
|
||||
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** ESP devices run for
|
||||
months with small shared heaps, and repeated allocations fragment them until a large allocation fails; we
|
||||
have seen field crashes caused by this. ESPHome treats runtime heap allocation as a long-term reliability
|
||||
bug, not a performance issue. Prefer `std::array`, `StaticVector` (sized with `cg.slot_counter()` for
|
||||
registration lists), `FixedVector` and `StringRef` over growing `std::vector`, `std::map`, `std::deque`
|
||||
and `std::string`. The `embedded-memory` skill has the full container guidelines.
|
||||
**Why Heap Allocation Matters:**
|
||||
|
||||
* **State Management:** keep code generation state in `CORE.data`, namespaced under the component's
|
||||
`DOMAIN` in a `@dataclass`, never in module-level mutable globals (see the `component-codegen` skill).
|
||||
ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over time, repeated allocations of different sizes fragment the heap. Failures happen when the largest contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by this.
|
||||
|
||||
**Heap allocation after `setup()` should be avoided unless absolutely unavoidable.** Every allocation/deallocation cycle contributes to fragmentation. ESPHome treats runtime heap allocation as a long-term reliability bug, not a performance issue. Helpers that hide allocation (`std::string`, `std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based APIs.
|
||||
|
||||
**STL Container Guidelines:**
|
||||
|
||||
ESPHome runs on embedded systems with limited resources. Choose containers carefully:
|
||||
|
||||
1. **Compile-time-known sizes:** Use `std::array` instead of `std::vector` when size is known at compile time.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code
|
||||
std::vector<int> values;
|
||||
|
||||
// Good - no dynamic allocation
|
||||
std::array<int, MAX_VALUES> values;
|
||||
```
|
||||
Use `cg.add_define("MAX_VALUES", count)` to set the size from Python configuration.
|
||||
|
||||
**For byte buffers:** Avoid `std::vector<uint8_t>` unless the buffer needs to grow. Use `std::unique_ptr<uint8_t[]>` instead.
|
||||
|
||||
> **Note:** `std::unique_ptr<uint8_t[]>` does **not** provide bounds checking or iterator support like `std::vector<uint8_t>`. Use it only when you do not need these features and want minimal overhead.
|
||||
|
||||
```cpp
|
||||
// Bad - STL overhead for simple byte buffer
|
||||
std::vector<uint8_t> buffer;
|
||||
buffer.resize(256);
|
||||
|
||||
// Good - minimal overhead, single allocation
|
||||
std::unique_ptr<uint8_t[]> buffer = std::make_unique<uint8_t[]>(256);
|
||||
// Or if size is constant:
|
||||
std::array<uint8_t, 256> buffer;
|
||||
```
|
||||
|
||||
2. **Compile-time-known fixed sizes with vector-like API:** Use `StaticVector` from `esphome/core/helpers.h` for compile-time fixed size with `push_back()` interface (no dynamic allocation).
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<ServiceRecord> services;
|
||||
services.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - compile-time fixed size, no dynamic allocation
|
||||
StaticVector<ServiceRecord, MAX_SERVICES> services;
|
||||
services.push_back(record1);
|
||||
```
|
||||
Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration.
|
||||
Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that
|
||||
is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it
|
||||
returns a function that each consumer calls once per slot it will occupy, and after every
|
||||
`to_code` has run it emits the define with the final count. When nothing registers, no define is
|
||||
emitted and the storage plus its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a
|
||||
`MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`;
|
||||
the define is then the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late
|
||||
request raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
std::vector<TxtRecord> txt_records;
|
||||
txt_records.reserve(5); // Still includes reallocation machinery
|
||||
|
||||
// Good - runtime size, single allocation, no reallocation machinery
|
||||
FixedVector<TxtRecord> txt_records;
|
||||
txt_records.init(record_count); // Initialize with exact size at runtime
|
||||
```
|
||||
**Benefits:**
|
||||
- Eliminates `_M_realloc_insert`, `_M_default_append` template instantiations (saves 200-500 bytes per instance)
|
||||
- Single allocation, no upper bound needed
|
||||
- No reallocation overhead
|
||||
- Compatible with protobuf code generation when using `[(fixed_vector) = true]` option
|
||||
|
||||
4. **Small datasets (1-16 elements):** Use `std::vector` or `std::array` with simple structs instead of `std::map`/`std::set`/`std::unordered_map`.
|
||||
```cpp
|
||||
// Bad - 2KB+ overhead for red-black tree/hash table
|
||||
std::map<std::string, int> small_lookup;
|
||||
std::unordered_map<int, std::string> tiny_map;
|
||||
|
||||
// Good - simple struct with linear search (std::vector is fine)
|
||||
struct LookupEntry {
|
||||
const char *key;
|
||||
int value;
|
||||
};
|
||||
std::vector<LookupEntry> small_lookup = {
|
||||
{"key1", 10},
|
||||
{"key2", 20},
|
||||
{"key3", 30},
|
||||
};
|
||||
// Or std::array if size is compile-time constant:
|
||||
// std::array<LookupEntry, 3> small_lookup = {{ ... }};
|
||||
```
|
||||
Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise.
|
||||
|
||||
5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the
|
||||
program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a
|
||||
non-owning pointer plus length; it does not copy, and it must only ever refer to storage that
|
||||
outlives it (a string literal, or a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`.
|
||||
|
||||
8. **Detection:** Look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
- Red-black tree code (`rb_tree`, `_Rb_tree`)
|
||||
- Hash table infrastructure (`unordered_map`, `hash`)
|
||||
|
||||
**Prioritize optimization effort for:**
|
||||
- Core components (API, network, logger)
|
||||
- Widely-used components (mdns, wifi, ble)
|
||||
- Components causing flash size complaints
|
||||
|
||||
Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`).
|
||||
|
||||
**Callback Managers:**
|
||||
|
||||
ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method.
|
||||
|
||||
| Type | Idle overhead (32-bit) | When to use |
|
||||
|------|----------------------|-------------|
|
||||
| `CallbackManager<void(Ts...)>` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered |
|
||||
| `LazyCallbackManager<void(Ts...)>` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) |
|
||||
|
||||
`LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
|
||||
|
||||
**Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature:
|
||||
```cpp
|
||||
// Bad -- forces heap allocation for forwarder structs
|
||||
void add_on_state_callback(std::function<void(bool)> &&callback) {
|
||||
this->state_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
// Good -- accepts any callable without forcing std::function wrapping
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
```
|
||||
|
||||
* **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals.
|
||||
|
||||
**Bad Pattern (Module-Level Globals):**
|
||||
```python
|
||||
# Don't do this - state persists between compilation runs
|
||||
_component_state = []
|
||||
_use_feature = None
|
||||
|
||||
|
||||
def enable_feature():
|
||||
global _use_feature
|
||||
_use_feature = True
|
||||
```
|
||||
|
||||
**Bad Pattern (Flat Keys):**
|
||||
```python
|
||||
# Don't do this - keys should be namespaced under component domain
|
||||
MY_FEATURE_KEY = "my_component_feature"
|
||||
CORE.data[MY_FEATURE_KEY] = True
|
||||
```
|
||||
|
||||
**Good Pattern (dataclass):**
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from esphome.core import CORE
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
|
||||
|
||||
def add_item(item: str) -> None:
|
||||
_get_data().items.append(item)
|
||||
```
|
||||
|
||||
If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs.
|
||||
|
||||
**Why this matters:**
|
||||
- Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec
|
||||
- `CORE.data` automatically clears between runs
|
||||
- Namespacing under `DOMAIN` prevents key collisions between components
|
||||
- `@dataclass` provides type safety and cleaner attribute access
|
||||
|
||||
* **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys.
|
||||
|
||||
@@ -388,11 +1028,57 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
## 8. Public API and Breaking Changes
|
||||
|
||||
* Only features documented at [esphome.io](https://esphome.io) are public API for components; all `public`
|
||||
members of `esphome/core/` and base classes are public API. Aim for a 6-month deprecation window
|
||||
(`ESPDEPRECATED` in C++, `cv.rename_key(..., removed_in=...)` for config keys) and document the migration
|
||||
path in the PR description. The `breaking-changes` skill has the full policy, checklist and patterns.
|
||||
* **Public C++ API:**
|
||||
* **Components**: Only documented features at [esphome.io](https://esphome.io) are public API. Undocumented `public` members are internal.
|
||||
* **Core/Base Classes** (`esphome/core/`, `Component`, `Sensor`, etc.): All `public` members are public API.
|
||||
* **Components with Global Accessors** (`global_api_server`, etc.): All `public` members are public API (except config setters).
|
||||
|
||||
* **Public Python API:**
|
||||
* All documented configuration options at [esphome.io](https://esphome.io) are public API.
|
||||
* Python code in `esphome/core/` actively used by existing core components is considered stable API.
|
||||
* Other Python code is internal unless explicitly documented for external component use.
|
||||
|
||||
* **Breaking Changes Policy:**
|
||||
* Aim for **6-month deprecation window** when possible
|
||||
* Clean breaks allowed for: signature changes, deep refactorings, resource constraints
|
||||
* Must document migration path in PR description (generates release notes)
|
||||
* Blog post required for core/base class changes or significant architectural changes
|
||||
* Full details: https://developers.esphome.io/contributing/code/#public-api-and-breaking-changes
|
||||
|
||||
* **Breaking Change Checklist:**
|
||||
- [ ] Clear justification (RAM/flash savings, architectural improvement)
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++)
|
||||
- [ ] Documented migration path in PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during deprecation period
|
||||
|
||||
* **Deprecation Pattern (C++):**
|
||||
```cpp
|
||||
// Remove before 2026.6.0
|
||||
ESPDEPRECATED("Use new_method() instead. Removed in 2026.6.0", "2025.12.0")
|
||||
void old_method() { this->new_method(); }
|
||||
```
|
||||
|
||||
* **Deprecation Pattern (Python):**
|
||||
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
## 9. English Language
|
||||
|
||||
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
|
||||
|
||||
@@ -439,7 +439,6 @@ esphome/components/modbus_controller/select/* @martgras @stegm
|
||||
esphome/components/modbus_controller/sensor/* @martgras
|
||||
esphome/components/modbus_controller/switch/* @martgras
|
||||
esphome/components/modbus_controller/text_sensor/* @martgras
|
||||
esphome/components/modbus_monitor/* @Bascht74
|
||||
esphome/components/modbus_server/* @exciton
|
||||
esphome/components/modem/* @oarcher
|
||||
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
|
||||
|
||||
+16
-21
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Protocol
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.build_helpers.native import native_backend
|
||||
from esphome.build_helpers.native import analysis_backend, native_backend
|
||||
from esphome.const import (
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
@@ -1305,7 +1305,10 @@ def upload_program(
|
||||
f"{option_string} is only supported with the esphome OTA platform; "
|
||||
"the web_server OTA path can only update the firmware image."
|
||||
)
|
||||
return _upload_via_web_server(config, network_devices, _app_ota_image(args))
|
||||
binary = CORE.firmware_bin
|
||||
if getattr(args, "file", None) is not None:
|
||||
binary = Path(args.file)
|
||||
return _upload_via_web_server(config, network_devices, binary)
|
||||
|
||||
return _upload_via_native_api(config, network_devices, args)
|
||||
|
||||
@@ -1361,16 +1364,6 @@ def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
|
||||
return next(iter(esphome_ota_items(config)), None)
|
||||
|
||||
|
||||
def _app_ota_image(args: ArgsProtocol) -> Path:
|
||||
"""The file an app update sends: ``--file`` or the build output, as the
|
||||
target platform's ota_image hook picks it."""
|
||||
binary = CORE.firmware_bin
|
||||
if (file := getattr(args, "file", None)) is not None:
|
||||
binary = Path(file)
|
||||
ota_image = platform_hooks.get_platform_hook(CORE.target_platform, "ota_image")
|
||||
return ota_image(binary) if ota_image is not None else binary
|
||||
|
||||
|
||||
def _upload_via_native_api(
|
||||
config: ConfigType, network_devices: list[str], args: ArgsProtocol
|
||||
) -> tuple[int, str | None]:
|
||||
@@ -1420,27 +1413,27 @@ def _upload_via_native_api(
|
||||
f"flash a build with the option enabled, and then retry {option_string}."
|
||||
)
|
||||
|
||||
binary = CORE.firmware_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_APP
|
||||
file = getattr(args, "file", None)
|
||||
if getattr(args, "partition_table", False):
|
||||
# Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access.
|
||||
# The device-side handshake also rejects this with "Device only supports app updates",
|
||||
# but checking here surfaces the misconfiguration before opening a network connection.
|
||||
check_partition_access("--partition-table")
|
||||
binary = Path(file) if file is not None else CORE.partition_table_bin
|
||||
binary = CORE.partition_table_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
|
||||
elif getattr(args, "bootloader", False):
|
||||
check_partition_access("--bootloader")
|
||||
if (
|
||||
file is None
|
||||
getattr(args, "file", None) is None
|
||||
and (native := native_backend())
|
||||
and (hint := getattr(native, "missing_image_hint", lambda: None)())
|
||||
):
|
||||
raise EsphomeError(hint)
|
||||
binary = Path(file) if file is not None else CORE.bootloader_bin
|
||||
binary = CORE.bootloader_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
|
||||
else:
|
||||
binary = _app_ota_image(args)
|
||||
if getattr(args, "file", None) is not None:
|
||||
binary = Path(args.file)
|
||||
|
||||
if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE:
|
||||
_validate_partition_table_binary(binary)
|
||||
@@ -2138,7 +2131,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
analysis_toolchain = native_backend()
|
||||
analysis_toolchain = analysis_backend()
|
||||
if analysis_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
@@ -2147,9 +2140,11 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
if hasattr(analysis_toolchain, "check_analysis_supported"):
|
||||
if (
|
||||
check_supported := getattr(analysis_toolchain, "check_analysis_supported", None)
|
||||
) is not None:
|
||||
# Raises with the reason; before the compile, not after it
|
||||
analysis_toolchain.check_analysis_supported()
|
||||
check_supported()
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
|
||||
@@ -25,7 +25,6 @@ from esphome.framework_helpers import (
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -155,47 +154,6 @@ def has_discovered_components() -> bool:
|
||||
return bool(get_available_components())
|
||||
|
||||
|
||||
_KEY_WARNED_BUILD_FLAGS = "warned_build_flags"
|
||||
|
||||
|
||||
def _lexed_build_flags() -> list[str]:
|
||||
"""Shell-lex the build flag entries as the other generators do, so an
|
||||
entry holding several flags is classified flag by flag. Warns once per
|
||||
entry about a flag that native ESP-IDF builds do not pass on."""
|
||||
warned: set[tuple[str, str]] = CORE.data.setdefault(KEY_ESP32, {}).setdefault(
|
||||
_KEY_WARNED_BUILD_FLAGS, set()
|
||||
)
|
||||
flags: list[str] = []
|
||||
for entry in sorted(CORE.build_flags):
|
||||
tokens = lex_build_flags(entry, "esphome build_flags")
|
||||
for token in tokens:
|
||||
if (
|
||||
(token, entry) not in warned
|
||||
and not get_project_compile_flags([token])
|
||||
and not get_project_link_flags([token])
|
||||
):
|
||||
warned.add((token, entry))
|
||||
_LOGGER.warning(
|
||||
"Build flag '%s'%s is ignored: native ESP-IDF builds only "
|
||||
"pass -D, -W and -Wl, flags",
|
||||
token,
|
||||
"" if token == entry else f" in '{entry}'",
|
||||
)
|
||||
flags += tokens
|
||||
return flags
|
||||
|
||||
|
||||
def _cmake_args(flags: list[str]) -> list[str]:
|
||||
"""Quote each flag as a CMake argument so quotes, backslashes and $
|
||||
survive."""
|
||||
return [
|
||||
'"{}"'.format(
|
||||
flag.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
||||
)
|
||||
for flag in flags
|
||||
]
|
||||
|
||||
|
||||
def _cmake_quote(value: str) -> str:
|
||||
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
|
||||
whitespace, quotes, and '$', so only backslashes need escaping."""
|
||||
@@ -336,24 +294,18 @@ def get_project_cmakelists(
|
||||
# esphome__micro-mp3) rather than just src/. Required so suppressions
|
||||
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
|
||||
# third-party components we don't author.
|
||||
# CMake keeps only the first copy of a repeated option; drop all but the
|
||||
# last copy instead so the last definition wins, as on the command line.
|
||||
project_compile_opts = list(
|
||||
reversed(
|
||||
dict.fromkeys(reversed(get_project_compile_flags(_lexed_build_flags())))
|
||||
)
|
||||
)
|
||||
project_compile_opts = get_project_compile_flags()
|
||||
extra_compile_options = "\n".join(
|
||||
f"idf_build_set_property(COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(project_compile_opts)
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
|
||||
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
|
||||
# -Wno-volatile is passed on a C compile.
|
||||
cxx_compile_options = "\n".join(
|
||||
f"idf_build_set_property(CXX_COMPILE_OPTIONS {flag} APPEND)"
|
||||
for flag in _cmake_args(get_project_cxx_compile_flags())
|
||||
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in get_project_cxx_compile_flags()
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
@@ -509,7 +461,7 @@ def get_component_cmakelists() -> str:
|
||||
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
|
||||
# emitted project-wide via idf_build_set_property in
|
||||
# get_project_cmakelists so they reach every component, not just src/.
|
||||
link_opts = _cmake_args(get_project_link_flags(_lexed_build_flags()))
|
||||
link_opts = get_project_link_flags()
|
||||
link_opts_str = "\n ".join(link_opts) if link_opts else ""
|
||||
src_cmake = "\n".join(CORE.data[KEY_ESP32].get(KEY_SRC_CMAKE, []))
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ def _split_command(command: str) -> list[str]:
|
||||
CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW
|
||||
CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
|
||||
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
|
||||
argc = ctypes.c_int(0)
|
||||
argc = ctypes.c_int()
|
||||
argv = CommandLineToArgvW(command, ctypes.byref(argc))
|
||||
if not argv: # pragma: no cover
|
||||
raise ctypes.WinError()
|
||||
@@ -296,10 +296,10 @@ def load_or_build_idedata(
|
||||
) -> dict | None:
|
||||
"""Return idedata for a compile_commands.json build, cached on mtime.
|
||||
|
||||
Shared by the native toolchains. Returns None when the compile DB
|
||||
doesn't exist yet (nothing was built). ``launcher`` is the
|
||||
compiler-launcher path (ccache) the build was generated with, if any;
|
||||
commands in the compile DB are prefixed with it.
|
||||
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
|
||||
when the compile DB doesn't exist yet (nothing was built). ``launcher``
|
||||
is the compiler-launcher path (ccache) the build was generated with, if
|
||||
any; commands in the compile DB are prefixed with it.
|
||||
"""
|
||||
if not compile_commands.is_file():
|
||||
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
|
||||
|
||||
@@ -22,7 +22,6 @@ NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
("host", Toolchain.HOST): "esphome.host.toolchain",
|
||||
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +39,24 @@ def native_backend() -> ModuleType | None:
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
# Binutils and the linked image for memory analysis, for toolchains that build
|
||||
# without PlatformIO but have no native build backend (which supplies them)
|
||||
ANALYSIS_TOOLCHAIN_MODULES = {
|
||||
("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain",
|
||||
}
|
||||
|
||||
|
||||
def analysis_backend() -> ModuleType | None:
|
||||
"""The module giving objdump, readelf and the ELF of a non-PlatformIO build.
|
||||
|
||||
None means PlatformIO's idedata supplies them (or nothing can).
|
||||
"""
|
||||
if (native := native_backend()) is not None:
|
||||
return native
|
||||
module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain))
|
||||
return importlib.import_module(module_path) if module_path else None
|
||||
|
||||
|
||||
def warn_ignored_platformio_options(consumed: Collection[str]) -> None:
|
||||
"""Warn for component-added platformio options a native build drops.
|
||||
|
||||
|
||||
@@ -33,35 +33,9 @@ _CACHE_VERSION = 1
|
||||
_LAMBDA_KEY = "__esphome_lambda__"
|
||||
|
||||
|
||||
_CACHE_SUFFIX = ".validated.json"
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}{_CACHE_SUFFIX}"
|
||||
|
||||
|
||||
def invalidate_compiled_config() -> None:
|
||||
"""Drop every cache in the storage dir after a change to a file the
|
||||
mtime check cannot see, such as secrets.yaml or an include; other
|
||||
configurations may share that file."""
|
||||
storage = compiled_config_path(CORE.config_filename).parent
|
||||
try:
|
||||
caches = [p for p in storage.iterdir() if p.name.endswith(_CACHE_SUFFIX)]
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as err:
|
||||
raise EsphomeError(
|
||||
f"Could not list the validated config caches in {storage}: {err}"
|
||||
) from err
|
||||
for path in caches:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
# A stale cache would present the old key on the next upload
|
||||
raise EsphomeError(
|
||||
f"Could not remove the validated config cache {path}: {err}"
|
||||
) from err
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
|
||||
@@ -297,7 +297,9 @@ void ADE7880::init_device_() {
|
||||
void ADE7880::reset_device_() {
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
ESP_LOGD(TAG, "Reset device using RESET pin");
|
||||
pulse_reset(this->reset_pin_, {.low_ms = 1});
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(1);
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Reset device using SWRST command");
|
||||
this->write_u16_register16_(CONFIG, CONFIG_SWRST);
|
||||
|
||||
@@ -253,9 +253,8 @@ template<typename... Ts> class APIRespondAction final : public Action<Ts...> {
|
||||
#endif
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
// Extract call_id from first argument - it's always first for optional/only/status modes.
|
||||
// By reference: array args are non-copyable FixedVector views.
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
// Extract call_id from first argument - it's always first for optional/only/status modes
|
||||
auto args = std::make_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
|
||||
bool success = this->success_.value(x...);
|
||||
@@ -309,7 +308,7 @@ template<typename... Ts> class APIUnregisterServiceCallAction final : public Act
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
// Extract call_id from first argument - same convention as APIRespondAction
|
||||
auto args = std::forward_as_tuple(x...);
|
||||
auto args = std::make_tuple(x...);
|
||||
uint32_t call_id = std::get<0>(args);
|
||||
if (call_id != 0) {
|
||||
this->parent_->unregister_active_action_call(call_id);
|
||||
|
||||
@@ -16,7 +16,13 @@ constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a,
|
||||
}
|
||||
|
||||
void AXS15231Touchscreen::setup() {
|
||||
pulse_reset(this->reset_pin_, {.low_ms = 5, .high_after_ms = 10});
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
delay(10);
|
||||
}
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
|
||||
this->interrupt_pin_->setup();
|
||||
|
||||
@@ -78,7 +78,3 @@ async def pin_to_code(config: ConfigType) -> MockObj:
|
||||
# Called by writer.py; delegates to the shared libretiny implementation.
|
||||
def copy_files() -> None:
|
||||
libretiny.copy_files()
|
||||
|
||||
|
||||
# Called by the OTA upload; the shared libretiny implementation.
|
||||
ota_image = libretiny.device_ota_image
|
||||
@@ -45,8 +45,9 @@ void CopyCover::control(const cover::CoverCall &call) {
|
||||
auto position = call.get_position();
|
||||
if (position.has_value())
|
||||
call2.set_position(*position);
|
||||
if (call.get_toggle().has_value())
|
||||
call2.set_command_toggle();
|
||||
auto tilt2 = call.get_tilt();
|
||||
if (tilt2.has_value())
|
||||
call2.set_tilt(*tilt2);
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,4 @@ void CopyLock::control(const lock::LockCall &call) {
|
||||
call2.perform();
|
||||
}
|
||||
|
||||
void CopyLock::open_latch() { this->source_->open(); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -13,7 +13,6 @@ class CopyLock final : public lock::Lock, public Component {
|
||||
|
||||
protected:
|
||||
void control(const lock::LockCall &call) override;
|
||||
void open_latch() override;
|
||||
|
||||
lock::Lock *source_;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,12 @@ ESPHOME_LOG_TAG(TAG, "cst226.touchscreen");
|
||||
|
||||
void CST226Touchscreen::setup() {
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(true);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
this->set_timeout(30, [this] { this->continue_setup_(); });
|
||||
} else {
|
||||
this->continue_setup_();
|
||||
|
||||
@@ -43,7 +43,9 @@ void CST328Touchscreen::setup() {
|
||||
}
|
||||
|
||||
void CST328Touchscreen::reset_device_() {
|
||||
pulse_reset(this->reset_pin_, {.low_ms = 5});
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); });
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,12 @@ void CST816Touchscreen::continue_setup_() {
|
||||
|
||||
void CST816Touchscreen::setup() {
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(true);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
this->set_timeout(30, [this] { this->continue_setup_(); });
|
||||
} else {
|
||||
this->continue_setup_();
|
||||
|
||||
@@ -10,7 +10,14 @@ static constexpr uint32_t REREAD_TIMEOUT_ID = 1;
|
||||
static constexpr uint32_t REREAD_DELAY_MS = 20;
|
||||
|
||||
void CST9220Touchscreen::setup() {
|
||||
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 10});
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(true);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(10);
|
||||
this->reset_pin_->digital_write(true);
|
||||
}
|
||||
// Wait for the controller to leave its bootloader before talking to it.
|
||||
this->set_timeout(30, [this] { this->continue_setup_(); });
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ class DemoCover final : public cover::Cover, public Component {
|
||||
}
|
||||
if (call.get_stop()) {
|
||||
this->cancel_timeout(MOVE_TIMEOUT_ID);
|
||||
this->current_operation = cover::COVER_OPERATION_IDLE;
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
|
||||
@@ -42,7 +42,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_LINE_READER")
|
||||
await uart.new_uart_component(config)
|
||||
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
namespace esphome::dfrobot_sen0395 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395");
|
||||
static constexpr char ASCII_CR = 0x0D;
|
||||
static constexpr char ASCII_LF = 0x0A;
|
||||
static constexpr float DET_RANGE_STEP_M = 0.15f;
|
||||
static constexpr float LATENCY_STEP_S = 0.025f;
|
||||
// Sent after a reset with no line end
|
||||
static constexpr char PROMPT[] PROGMEM = "leapMMW:/>";
|
||||
|
||||
// What each CommandType cannot do while the sensor runs; empty for commands that do not need it stopped
|
||||
PROGMEM_STRING_TABLE(BusyActions, "", "configure range config", "configure output latency",
|
||||
@@ -121,9 +121,9 @@ bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
|
||||
return false; // Command not done yet
|
||||
}
|
||||
|
||||
if (const char *message = this->read_message_()) {
|
||||
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
|
||||
if (!this->handle_reply_(cmd, message))
|
||||
if (this->read_message_()) {
|
||||
if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
|
||||
if (!this->handle_reply_(cmd))
|
||||
return false; // Command not done yet
|
||||
this->read_message_(); // Skip the prompt
|
||||
return true;
|
||||
@@ -146,13 +146,12 @@ bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
|
||||
}
|
||||
|
||||
void DfrobotSen0395Component::read_state_() {
|
||||
const char *message = this->read_message_();
|
||||
if (message == nullptr)
|
||||
if (!this->read_message_())
|
||||
return;
|
||||
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
|
||||
if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
|
||||
this->set_detected_(false);
|
||||
this->set_active(true);
|
||||
} else if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
|
||||
} else if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
|
||||
this->set_detected_(true);
|
||||
this->set_active(true);
|
||||
}
|
||||
@@ -207,7 +206,8 @@ void DfrobotSen0395Component::format_command(const Command &cmd, char *buf, size
|
||||
}
|
||||
}
|
||||
|
||||
bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *message) {
|
||||
bool DfrobotSen0395Component::handle_reply_(const Command &cmd) {
|
||||
const char *message = this->read_buffer_;
|
||||
auto is = [message](ESPHOME_PGM_P text) { return ESPHOME_strcmp_P(message, text) == 0; };
|
||||
|
||||
// Commands with their own replies
|
||||
@@ -230,7 +230,7 @@ bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *mess
|
||||
}
|
||||
return true;
|
||||
case CommandType::COMMAND_TYPE_RESET_SYSTEM:
|
||||
if (!is(PROMPT))
|
||||
if (!is(ESPHOME_PSTR("leapMMW:/>")))
|
||||
return false;
|
||||
ESP_LOGI(TAG, "Restarted sensor.");
|
||||
return true;
|
||||
@@ -300,24 +300,34 @@ bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *mess
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *DfrobotSen0395Component::read_message_() {
|
||||
bool DfrobotSen0395Component::read_message_() {
|
||||
while (this->available()) {
|
||||
uint8_t byte;
|
||||
this->read_byte(&byte);
|
||||
|
||||
if (this->read_pos_ == MMWAVE_READ_BUFFER_LENGTH)
|
||||
this->read_pos_ = 0;
|
||||
|
||||
ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte);
|
||||
|
||||
if (byte == ASCII_CR)
|
||||
continue;
|
||||
if (byte >= 0x7F)
|
||||
byte = '?'; // needs to be valid utf8 string for log functions.
|
||||
const char *line = this->line_reader_.feed(byte);
|
||||
if (line == nullptr && byte == '>' && this->line_reader_.length() == sizeof(PROMPT) - 1) {
|
||||
line = this->line_reader_.partial();
|
||||
this->line_reader_.reset();
|
||||
this->read_buffer_[this->read_pos_] = byte;
|
||||
|
||||
if (this->read_pos_ == 9 && byte == '>')
|
||||
this->read_buffer_[++this->read_pos_] = ASCII_LF;
|
||||
|
||||
if (this->read_buffer_[this->read_pos_] == ASCII_LF) {
|
||||
this->read_buffer_[this->read_pos_] = 0;
|
||||
this->read_pos_ = 0;
|
||||
ESP_LOGV(TAG, "Message: %s", this->read_buffer_);
|
||||
return true; // Full message in buffer
|
||||
}
|
||||
if (line != nullptr) {
|
||||
ESP_LOGV(TAG, "Message: %s", line);
|
||||
return line;
|
||||
}
|
||||
this->line_reader_.log_if_overflowed(TAG);
|
||||
this->read_pos_++;
|
||||
}
|
||||
return nullptr;
|
||||
return false; // No full message yet
|
||||
}
|
||||
|
||||
void DfrobotSen0395Component::set_detected_(bool detected) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/line_reader.h"
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
@@ -15,7 +14,7 @@
|
||||
|
||||
namespace esphome::dfrobot_sen0395 {
|
||||
|
||||
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 253; // largest LineReader size with a one byte position
|
||||
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 255;
|
||||
static constexpr uint8_t COMMAND_QUEUE_SIZE = 20;
|
||||
|
||||
// Keep in sync with the BusyActions table in dfrobot_sen0395.cpp
|
||||
@@ -122,7 +121,8 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
|
||||
bool led_active_{false};
|
||||
bool uart_presence_active_{false};
|
||||
bool start_after_boot_{false};
|
||||
LineReader<MMWAVE_READ_BUFFER_LENGTH> line_reader_;
|
||||
char read_buffer_[MMWAVE_READ_BUFFER_LENGTH];
|
||||
size_t read_pos_{0};
|
||||
bool cmd_sent_{false};
|
||||
uint8_t retries_left_{2};
|
||||
StaticRingBuffer<Command, COMMAND_QUEUE_SIZE> cmd_queue_;
|
||||
@@ -131,13 +131,12 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
|
||||
int16_t det_range_steps_[8]{};
|
||||
uint16_t latency_steps_[2]{};
|
||||
|
||||
/// The next complete line, valid until the next call, or nullptr
|
||||
const char *read_message_();
|
||||
bool read_message_();
|
||||
void read_state_();
|
||||
/// Run the command at the front of the queue; true when it is done
|
||||
bool run_command_(const Command &cmd, uint32_t now);
|
||||
/// Handle a reply to a sent command; true when the command is done
|
||||
bool handle_reply_(const Command &cmd, const char *message);
|
||||
bool handle_reply_(const Command &cmd);
|
||||
|
||||
void set_detected_(bool detected);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core, pins
|
||||
@@ -35,8 +34,6 @@ from esphome.types import ConfigType
|
||||
DOMAIN = "display"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
display_ns = cg.esphome_ns.namespace("display")
|
||||
Display = display_ns.class_("Display", cg.PollingComponent)
|
||||
DisplayBuffer = display_ns.class_("DisplayBuffer", Display)
|
||||
@@ -105,21 +102,13 @@ BASIC_DISPLAY_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("1s"))
|
||||
|
||||
|
||||
def _validate_test_card(config: ConfigType) -> ConfigType:
|
||||
def _validate_test_card(config):
|
||||
if (
|
||||
config.get(CONF_SHOW_TEST_CARD, False)
|
||||
and (update_interval := config.get(CONF_UPDATE_INTERVAL)) is not None
|
||||
and update_interval.total_milliseconds == SCHEDULER_DONT_RUN
|
||||
and config.get(CONF_UPDATE_INTERVAL, False) == SCHEDULER_DONT_RUN
|
||||
):
|
||||
# Only a warning: something else can still update the display, for example
|
||||
# component.update, and the test card is drawn then.
|
||||
display_id = config.get(CONF_ID)
|
||||
_LOGGER.warning(
|
||||
"%s`%s: True` is used with `%s: never`, so the test card is only drawn "
|
||||
"when something updates the display (for example component.update)",
|
||||
f"{display_id.id}: " if display_id is not None and display_id.id else "",
|
||||
CONF_SHOW_TEST_CARD,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
raise cv.Invalid(
|
||||
f"`{CONF_SHOW_TEST_CARD}: True` cannot be used with `{CONF_UPDATE_INTERVAL}: never` because this combination will not show a test_card."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user