mirror of
https://github.com/esphome/esphome.git
synced 2026-10-28 00:42:11 +00:00
Compare commits
386
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
746775a4e5 | ||
|
|
d7770afa9a | ||
|
|
d0d1fb6088 | ||
|
|
af8542c7b9 | ||
|
|
205ef9407f | ||
|
|
d552396650 | ||
|
|
34bbe618e3 | ||
|
|
a101d57476 | ||
|
|
60bda810d0 | ||
|
|
195cb3b2b4 | ||
|
|
8529b7a59c | ||
|
|
4e63342350 | ||
|
|
3784a3d56a | ||
|
|
848d1ac4e8 | ||
|
|
6c79b57a49 | ||
|
|
c3348789eb | ||
|
|
6ea4d976c2 | ||
|
|
d96e758916 | ||
|
|
ebd5a8bb18 | ||
|
|
6ac63ac495 | ||
|
|
7d5bfb8fa3 | ||
|
|
4cd4ddb981 | ||
|
|
4ea26ed65f | ||
|
|
747921faa8 | ||
|
|
f8056c3a30 | ||
|
|
85c241391e | ||
|
|
a09e2e303e | ||
|
|
822bd4363b | ||
|
|
9cccb42c6f | ||
|
|
81d451aa63 | ||
|
|
c3506bd7b9 | ||
|
|
449c511606 | ||
|
|
0ec8e58772 | ||
|
|
1ee8762006 | ||
|
|
c865b551ee | ||
|
|
6e416952c8 | ||
|
|
96b8bae876 | ||
|
|
3b1bf1dcba | ||
|
|
72221aa0d3 | ||
|
|
1e8f084741 | ||
|
|
70448afb87 | ||
|
|
6ba8862ad8 | ||
|
|
ca69cd06e9 | ||
|
|
c965c5ee70 | ||
|
|
97bbe58c8c | ||
|
|
6595df0cab | ||
|
|
73894b2490 | ||
|
|
9bef20d8dc | ||
|
|
0e440500a2 | ||
|
|
badfa84fe0 | ||
|
|
e686ecf76e | ||
|
|
64fb4e06b5 | ||
|
|
cb82c9031a | ||
|
|
ca74ab9873 | ||
|
|
86fc740b65 | ||
|
|
450bb52f65 | ||
|
|
1cc6c9d1dd | ||
|
|
ed3615ec69 | ||
|
|
a5f25ebe2d | ||
|
|
f188d0c450 | ||
|
|
7a4775de3d | ||
|
|
d4064d3f3d | ||
|
|
72c429bb0f | ||
|
|
a9f970ca67 | ||
|
|
1a50e1394a | ||
|
|
ae1f912a5f | ||
|
|
916798184d | ||
|
|
1e49616bc5 | ||
|
|
c66f71bb96 | ||
|
|
8db0774d67 | ||
|
|
f735c4634e | ||
|
|
fe125726ee | ||
|
|
e05f2fc74e | ||
|
|
4e68f1d324 | ||
|
|
98706c594b | ||
|
|
633c2b7f71 | ||
|
|
516511b346 | ||
|
|
f72dc7c329 | ||
|
|
f1695ac0b0 | ||
|
|
24c64a8efd | ||
|
|
d3638336ae | ||
|
|
53bffb560b | ||
|
|
2af39e6643 | ||
|
|
533a3afebd | ||
|
|
ddb7a6d9aa | ||
|
|
ae99accace | ||
|
|
474667d1a8 | ||
|
|
dbc93bb133 | ||
|
|
5486ef400f | ||
|
|
bd723f75af | ||
|
|
efba19ea3a | ||
|
|
6c68985c14 | ||
|
|
7be8a7c390 | ||
|
|
eb8cf139d7 | ||
|
|
ab6ebac628 | ||
|
|
6cb622417a | ||
|
|
960316abd6 | ||
|
|
87341dc136 | ||
|
|
cfe5809654 | ||
|
|
9c54f96520 | ||
|
|
0cc5b372fb | ||
|
|
f7c8cba8b7 | ||
|
|
79a22e9a9a | ||
|
|
103023737d | ||
|
|
6b6b70a8b6 | ||
|
|
d312bf1b73 | ||
|
|
0d2f8c0e88 | ||
|
|
2f8b50c5af | ||
|
|
c4b598be3e | ||
|
|
0cd16df16f | ||
|
|
a680974da2 | ||
|
|
78f0b1ceba | ||
|
|
c5a841e11e | ||
|
|
365965f9bb | ||
|
|
16011d7fce | ||
|
|
4486dc53d8 | ||
|
|
8d5e3ce534 | ||
|
|
9ea4d35a50 | ||
|
|
5cceddfbf4 | ||
|
|
ff6a37a052 | ||
|
|
6990092be0 | ||
|
|
9220c79a51 | ||
|
|
215fcd4f5d | ||
|
|
918e3b3a58 | ||
|
|
347eb3902e | ||
|
|
177820083f | ||
|
|
b0a0524091 | ||
|
|
93b6388cb9 | ||
|
|
8677844629 | ||
|
|
c1a9c8f2f1 | ||
|
|
f35e45febf | ||
|
|
9f47b5c7f8 | ||
|
|
58b6779dbe | ||
|
|
bd881b025f | ||
|
|
c41b1f8f0f | ||
|
|
87b20ffdbb | ||
|
|
a3f38a9d4d | ||
|
|
76491691f5 | ||
|
|
6791dc7750 | ||
|
|
db76f0b1cd | ||
|
|
09f7b71efd | ||
|
|
fd54bf7a07 | ||
|
|
011497c447 | ||
|
|
31bfdcb316 | ||
|
|
42f50ac3bc | ||
|
|
3edbfb47b9 | ||
|
|
b622a59d74 | ||
|
|
0bfdbb948e | ||
|
|
840f3216b8 | ||
|
|
e257b279d5 | ||
|
|
125bd8ccd0 | ||
|
|
cce3511112 | ||
|
|
836b3220a2 | ||
|
|
49f2610595 | ||
|
|
b49f3953f9 | ||
|
|
de9420a625 | ||
|
|
ae127f7db8 | ||
|
|
26ba2cc234 | ||
|
|
068fe3b7ce | ||
|
|
db6c956e55 | ||
|
|
c95ce587c0 | ||
|
|
3540ac4d1f | ||
|
|
180d7b31d8 | ||
|
|
3cf79df27c | ||
|
|
2beb2de8a1 | ||
|
|
9e457b11ba | ||
|
|
ee41748744 | ||
|
|
08d4d59b1f | ||
|
|
e4c0ef7c95 | ||
|
|
26ee68652f | ||
|
|
fa88c9218b | ||
|
|
44e27c08d8 | ||
|
|
f431cdfb0c | ||
|
|
ba488eab6e | ||
|
|
f21183c160 | ||
|
|
60ca75baad | ||
|
|
b668cb1e50 | ||
|
|
ab7d0ca17d | ||
|
|
43f10a7cfe | ||
|
|
d5e755e407 | ||
|
|
cf67610d43 | ||
|
|
bd55bdcd6c | ||
|
|
f42d1ac645 | ||
|
|
5198c37cd9 | ||
|
|
d9fcc292e2 | ||
|
|
8f8e615ee7 | ||
|
|
f91ad8d43c | ||
|
|
465a8b0386 | ||
|
|
9e692b6772 | ||
|
|
1fd4025a0e | ||
|
|
03b8bd7941 | ||
|
|
0e2c906fce | ||
|
|
d260265cf5 | ||
|
|
2388c3313d | ||
|
|
e717a21bce | ||
|
|
d47a510b84 | ||
|
|
a16d0c48a0 | ||
|
|
8541104d87 | ||
|
|
579c5a5226 | ||
|
|
a027785fbf | ||
|
|
d7f5976525 | ||
|
|
4c8de264fd | ||
|
|
2d37ad6836 | ||
|
|
d2f6b389d4 | ||
|
|
875b2076c0 | ||
|
|
c21660251c | ||
|
|
995acb94b4 | ||
|
|
24b7eefaad | ||
|
|
2349e0be00 | ||
|
|
63349801b7 | ||
|
|
afdfa7b298 | ||
|
|
400407b135 | ||
|
|
22b32b194a | ||
|
|
6dcd989a7f | ||
|
|
2016884210 | ||
|
|
d3f4f44c7b | ||
|
|
6df15be714 | ||
|
|
a03fe3c38e | ||
|
|
afc8c51f93 | ||
|
|
ef7ed69f9d | ||
|
|
ec32802662 | ||
|
|
433d8046b1 | ||
|
|
8ecb981656 | ||
|
|
8c990573d7 | ||
|
|
bdec0c1485 | ||
|
|
70e85d939d | ||
|
|
25b3c2820a | ||
|
|
ef53e675ff | ||
|
|
10b569491d | ||
|
|
71b1b58e8d | ||
|
|
4ab67d867f | ||
|
|
a2a46944ab | ||
|
|
537d1643cb | ||
|
|
07744bb53e | ||
|
|
c5d69ce22b | ||
|
|
21fb25a45f | ||
|
|
a208cf2264 | ||
|
|
a57dff1ea1 | ||
|
|
1f8c762f83 | ||
|
|
a339f2cec6 | ||
|
|
bbb978f8a0 | ||
|
|
e6b4b80ff8 | ||
|
|
e17c205863 | ||
|
|
485ce83703 | ||
|
|
0bb4097de4 | ||
|
|
90a90455ed | ||
|
|
893b60e7a0 | ||
|
|
978dbf9b41 | ||
|
|
b8df87350f | ||
|
|
146107c93f | ||
|
|
fe6f19a6f5 | ||
|
|
6d989d60aa | ||
|
|
263256b548 | ||
|
|
97fb058d60 | ||
|
|
ebb170e3e2 | ||
|
|
c307754c62 | ||
|
|
b60fc37cbc | ||
|
|
55940fac30 | ||
|
|
6dbd140922 | ||
|
|
4da3706c55 | ||
|
|
4a1b837ddb | ||
|
|
1488ca32b4 | ||
|
|
5e0e32552b | ||
|
|
809bc71649 | ||
|
|
7bec8aeb3d | ||
|
|
2897963905 | ||
|
|
ace4651476 | ||
|
|
b8d35764e7 | ||
|
|
afcf345264 | ||
|
|
19ce03f4bf | ||
|
|
83f460e964 | ||
|
|
e964a4d11a | ||
|
|
29a928ed5a | ||
|
|
2cbc1bc02d | ||
|
|
701e5746b0 | ||
|
|
9d30bcc721 | ||
|
|
acb89a8676 | ||
|
|
08d231991b | ||
|
|
253db1e0d5 | ||
|
|
bb07d04c90 | ||
|
|
3c1e53628f | ||
|
|
d515e8b2e3 | ||
|
|
49d4b83dd2 | ||
|
|
e04dbc1881 | ||
|
|
4c62713eb2 | ||
|
|
b3633fe53c | ||
|
|
b6fdccfc65 | ||
|
|
1a11424475 | ||
|
|
f35395b584 | ||
|
|
6d110fd981 | ||
|
|
7c69d1b63f | ||
|
|
1546ff022c | ||
|
|
c2911a51bd | ||
|
|
db16d86ee2 | ||
|
|
b401a8bf17 | ||
|
|
051978212c | ||
|
|
971035d6d1 | ||
|
|
b90e1e43a3 | ||
|
|
fc6accb8d9 | ||
|
|
ade01b2c71 | ||
|
|
b415f86847 | ||
|
|
8fa9932033 | ||
|
|
c464ef0cc4 | ||
|
|
6f091e4cf2 | ||
|
|
4a9409e299 | ||
|
|
53357c92f8 | ||
|
|
ed882741bc | ||
|
|
f994fbde04 | ||
|
|
a72adc7573 | ||
|
|
55a62993c8 | ||
|
|
31a43f13d7 | ||
|
|
3c06d41c6a | ||
|
|
3be29eab35 | ||
|
|
202087f919 | ||
|
|
9ed59c29e2 | ||
|
|
d9548d142c | ||
|
|
34ef64186e | ||
|
|
e8a8d6cf95 | ||
|
|
1babcbf371 | ||
|
|
f9ca09ab30 | ||
|
|
ff56667929 | ||
|
|
ebdd9fe07e | ||
|
|
5a4025ee12 | ||
|
|
2b024114cf | ||
|
|
a1316e1bfa | ||
|
|
9beb41e2ff | ||
|
|
b9f973ba7f | ||
|
|
3d42180dcb | ||
|
|
8262e336cc | ||
|
|
616100803f | ||
|
|
8fb9dba023 | ||
|
|
c57a2b9f73 | ||
|
|
9f2be8f195 | ||
|
|
aa6c06ac03 | ||
|
|
2caf6eb6d0 | ||
|
|
5f90fb501b | ||
|
|
dfe87daa40 | ||
|
|
29cb1e9039 | ||
|
|
831f10fdeb | ||
|
|
8a2358d0e6 | ||
|
|
f416e1edd4 | ||
|
|
8156de9613 | ||
|
|
89c2cdb592 | ||
|
|
cb57148178 | ||
|
|
dcfad5c439 | ||
|
|
e08cbc5661 | ||
|
|
2b183be544 | ||
|
|
6a18470ccb | ||
|
|
e9aaf84ee5 | ||
|
|
69785f56f6 | ||
|
|
61b52c0a34 | ||
|
|
54c4cc81c5 | ||
|
|
02ea1b2696 | ||
|
|
16023df4fd | ||
|
|
0fd72f74f1 | ||
|
|
4254d76e5c | ||
|
|
7605958db5 | ||
|
|
35ea89859b | ||
|
|
fd7a0d3679 | ||
|
|
dc37e06143 | ||
|
|
9226e03496 | ||
|
|
a4fff7738b | ||
|
|
4242bcb084 | ||
|
|
8a88e97497 | ||
|
|
d586e20a31 | ||
|
|
e6161e64b1 | ||
|
|
431843c27e | ||
|
|
a1da3d6b90 | ||
|
|
930429061a | ||
|
|
afceb91d90 | ||
|
|
7d741361a3 | ||
|
|
9c70ffd826 | ||
|
|
dac6a9c8cc | ||
|
|
a3a4144e4f | ||
|
|
29e9ae008d | ||
|
|
c92c4d7f99 | ||
|
|
5bcc3f42e9 | ||
|
|
d6aa5ba14c | ||
|
|
59c7178910 | ||
|
|
e55741a26b | ||
|
|
0bdc14154b | ||
|
|
1d6f839ee7 | ||
|
|
2544518aca | ||
|
|
70e56df630 | ||
|
|
7d62aae996 | ||
|
|
c12baaa337 |
No files matched your search
@@ -0,0 +1,215 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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,11 +9,13 @@ 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 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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
@@ -76,6 +78,10 @@ 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.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
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, and `publish="on_success"` publishes only when the hub call returns true (a write the device can
|
||||
refuse). `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.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -80,6 +88,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
|
||||
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
|
||||
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
|
||||
| BW12 and other AmebaZ boards (RTL8710BX, rtl87xx) on a plain USB-UART | `~/.platformio/penv/.libretiny/bin/python -m ltchiptool flash write -B 115200 -b 115200 -t 120 -d <port> <uf2>`, started first, then strap by hand: CEN to GND, TX2 to GND, release CEN, release TX2. Without `-B 115200` the system data read at 1.5 Mbaud fails on CH340s (`Data read error at 0x9000`) and nothing is written. A 1 MB image takes about 80 s | `serial_capture.py`; a reset pulse does not reach CEN on a plain adapter, so a capture starts mid-run |
|
||||
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner |
|
||||
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
|
||||
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
|
||||
@@ -111,11 +120,11 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
||||
or exits 1 when a build, flash or port step failed.
|
||||
|
||||
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
|
||||
(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
|
||||
(server with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
|
||||
(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), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header,
|
||||
capture decoder, RTL8720C ROM dump versus report matcher), `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, frame stream harness with presence hold and link loss drivers, BT MAC driver), `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), `gpio_hold/` (ISR pin hold self test with ESP32 and S3 configs), `ble_event_inject/` (GAP and GATTC event injector header and proxy package), `light_sleep_wake/` (LEDC wave and edge timer for light sleep wakeup on a C3), `manual_ip/` (station and AP manual IP configs, DNS probe and runners), `web_handlers/` (late handler, web_server, captive_portal and prometheus variants, web OTA runner), `bk72xx_flash/` (flash readback with retries, per partition diff of two dumps), `ble_server_security/` (secure GATT server and pairing client configs, driver, summary and runner), `prometheus/` (datetime metrics configs and driver).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
@@ -132,7 +141,21 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
|
||||
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
|
||||
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
|
||||
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
|
||||
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`.
|
||||
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`,
|
||||
`online-image-s3-eth-dut.yaml`,
|
||||
and the directories `store_yaml/` (package with secrets one directory up, per-board files),
|
||||
`http_request_update/` (manifest URL setter shapes, per-board files), `modem/` (modem only and modem plus
|
||||
Wi-Fi boot checks), `ble_midi/` (fake BLE MIDI instrument and the client), `http_request_action/` (request
|
||||
action package with per-board files and an OTA flash variant) `uart_sim/` (simulator header, common package, per-build parts and run files) and `remote_base/` (common
|
||||
package, transmit script, per-board templates and the ESP8266 software sender).
|
||||
|
||||
## When the PR keeps moving
|
||||
|
||||
Record every head in the stack (`gh pr view N --json headRefOid`), build, and recheck before flashing; if anything
|
||||
moved, wait five minutes and check again so the test runs on a settled head. When a head moved only by merging dev,
|
||||
compare the PR's own diff (`git diff $(git merge-base OLD origin/dev) OLD` against the same for NEW); if it is
|
||||
identical and the dev commits do not touch the code under test, earlier results still describe it, but rebuild so the
|
||||
comment quotes the exact commits. Fetch `origin dev` before computing a merge base.
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
|
||||
while Wi-Fi is off).
|
||||
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
|
||||
- A board left in the ROM bootloader after an upload (BOOT held, or the USB JTAG reset landing in download mode
|
||||
again) starts its app without a replug through `esptool --port <port> --after watchdog-reset chip-id`; flash a
|
||||
minimal Wi-Fi plus API image first when recovering from a crash-looping one.
|
||||
|
||||
## RP2040 / RP2350 (Pico W, Pico 2 W)
|
||||
|
||||
@@ -83,6 +86,8 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
|
||||
Fault dumps print PC, LR and R0 to R3.
|
||||
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
|
||||
- The `rtw_is_adapter_up` bus fault after `Disabling AP` happens on LibreTiny 1.13.0 and 1.14.0 alike
|
||||
(2 of 4 and 3 of 3 runs in #21531); run at least four rounds per build before blaming a PR for it.
|
||||
|
||||
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
|
||||
|
||||
@@ -113,9 +118,33 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception
|
||||
handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit`
|
||||
placed there after `.itcm.code` works for a crash record.
|
||||
- Pick the board that matches the module's flash layout. A NiceMCU XH-WB3S (BK7238) needs `board: xh-wb3s` (plain
|
||||
Beken layout, RF calibration at 0x1E0000). Built as `generic-bk7238-tuya` (calibration at 0x1E3000, download
|
||||
partition over 0x1E0000) the firmware boots and may even join a known network, but scans come back empty and its
|
||||
AP is invisible to clients, with no warning in the log. If scans are empty, check the layout first.
|
||||
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
|
||||
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
|
||||
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
|
||||
- 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.
|
||||
|
||||
## BW12 and other AmebaZ boards (RTL8710BX, LibreTiny rtl87xx)
|
||||
|
||||
- Download mode is a hand strap: CEN to GND, TX2 to GND, release CEN, release TX2. A plain USB-UART
|
||||
does not drive CEN, so neither ltchiptool nor `serial_capture.py --reset` can reset the chip.
|
||||
- ltchiptool links at 1.5 Mbaud and reads the system data at 0x9000 at that rate before it writes at
|
||||
115200. A CH340 drops bytes there (`Data read error at 0x9000`, `Timed out waiting for Quiet-Handshake`)
|
||||
and the run gives up without writing. `-B 115200 -b 115200` moves the link to 115200 first and works.
|
||||
Start the command with `-t 120` so it is already polling while the board is strapped.
|
||||
- After a failed run the ROM may sit at another baud rate and no longer link; strap again.
|
||||
- The device builder frontend's AmebaZ flasher follows ltchiptool (link and system read at 1.5 Mbaud),
|
||||
so it can hit the same read failure on the same adapters.
|
||||
|
||||
## BW16 kit (RTL8720DN, AmebaD)
|
||||
|
||||
- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while
|
||||
@@ -160,6 +189,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
|
||||
|
||||
|
||||
@@ -4,24 +4,27 @@ Each flow names the pull requests it was used on, so their result tables show wh
|
||||
|
||||
One file per component, named after it:
|
||||
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client, store_yaml recovery over Noise or plaintext
|
||||
- [`ble_midi.md`](ble_midi.md): BLE MIDI client against a fake ESP32 instrument, every packet shape and the send actions
|
||||
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
|
||||
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
|
||||
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
|
||||
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||
- [`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, presence hold and link loss on a frame stream, BT MAC readback
|
||||
- [`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
|
||||
- [`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
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot with a ROM dump matcher, BK7238 BLE bond store safety with a full flash backup and per partition diff
|
||||
- [`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
|
||||
- [`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): upload without a configuration and the refusal checks, 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
|
||||
@@ -31,3 +34,16 @@ One file per component, named after it:
|
||||
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
|
||||
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
|
||||
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
|
||||
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
|
||||
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
|
||||
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols 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
|
||||
- [`gpio_hold.md`](gpio_hold.md): GPIO hold through the ISR pin API, pad read back with no wiring, per chip pin notes
|
||||
- [`ble_event_inject.md`](ble_event_inject.md): made up GAP and GATTC events fed into the ESP32 BLE handlers from a button
|
||||
- [`light_sleep_gpio_wake.md`](light_sleep_gpio_wake.md): GPIO wakeup from auto light sleep with an LEDC wave that keeps running in sleep
|
||||
- [`manual_ip_and_web_handlers.md`](manual_ip_and_web_handlers.md): station, AP and Ethernet manual IP, web handlers added late and web OTA with web_server, captive_portal or prometheus
|
||||
- [`ble_server_security.md`](ble_server_security.md): esp32_ble_server pairing, encrypted and MITM characteristics, subscription before and after pairing, bond reuse per ESP-IDF version
|
||||
- [`prometheus.md`](prometheus.md): date, time and datetime metrics in UTC and another time zone
|
||||
@@ -40,3 +40,19 @@ length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSIN
|
||||
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
|
||||
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
|
||||
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
|
||||
|
||||
## store_yaml recovery over the native API (#16445)
|
||||
|
||||
`scripts/api/syrecover.py HOST KEY OUTDIR [ORIGDIR]` (KEY `-` for plaintext) builds the client library's
|
||||
`APINoiseFrameHelper` around a stub `APIConnection` subclass (the compiled helper type checks the connection, and
|
||||
`ConnectionParams` needs every field), sends Hello, Authentication (never answered without a password) and
|
||||
`DeviceCapabilitiesRequest`, then `GetYamlRequest` (161), collects `GetYamlResponse` (162) frames by field number
|
||||
(1 data, 2 done, 3 total_size, 4 encoding) until done, requests again, decompresses with `compression.zstd` (Python 3.14, or the `zstandard` package) and
|
||||
unpacks the envelope with the tree's `unpack_envelope` (run with `PYTHONPATH` on that tree). Configs in
|
||||
`templates/store_yaml/`: entry files in `boards/`, a package one directory above them with `!secret` and an inline
|
||||
OTA password,
|
||||
so the redaction and the `..` paths are exercised. Repetitive YAML compresses into one chunk even at 22 KB; a 6 KB
|
||||
include of random base64 lines with `include_secrets: true` gives four chunks (1390 byte packets). Rough edge at
|
||||
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
|
||||
`secrets.yaml` beside it; a copy next to the package works around it.
|
||||
Results: https://github.com/esphome/esphome/pull/16445#issuecomment-6335435397
|
||||
@@ -0,0 +1,20 @@
|
||||
# Injecting BLE GAP and GATTC events (#21698)
|
||||
|
||||
Some BLE paths (repeated connection parameter update failures, a disconnect in the middle of them) are hard to get
|
||||
from a real peer on demand. `scripts/ble_event_inject` feeds made up events straight into the ESP32 BLE handlers.
|
||||
|
||||
- `ESP32BLE::gap_event_handler` and `gattc_event_handler` are protected statics and the class is `final`.
|
||||
`cp_inject.hpp` reaches them through explicit template instantiation, where access checks do not apply. Its
|
||||
`cpinject::run()` sends six labelled steps (`STEP1` to `STEP6`): four peers failing five times each, a success
|
||||
followed by more failures, and disconnects between failures.
|
||||
- `cp-inject.yaml` is a package for a copy of a proxy config: it adds the include, a VERBOSE logger with the busy
|
||||
tags back at DEBUG, and an `Inject conn param events` button. Press it with `scripts/api_button.py` while a
|
||||
log capture (`esphome logs ... --device <ip>` under `timeout`) runs, then count the warning and verbose lines per
|
||||
step on the PR and on dev.
|
||||
- The handlers push into a queue and event pool that take a single writer, normally the Bluetooth task. Injecting
|
||||
from the main task while real events arrive can corrupt them, so the button stops the scan, waits for it to end,
|
||||
injects and starts it again. Run it with no active proxy connections (no Home Assistant BLE client holding one).
|
||||
- Fake disconnects use `gattc_if` 0xFE: every client and proxy connection filters on its own `gattc_if_`, so the
|
||||
queued event reaches only the shared handler. Do not use 0xFF (`ESP_GATT_IF_NONE`), which every client accepts.
|
||||
- The fake peers (`C0:FF:EE:00:00:0n`) are never connected, so a live proxy keeps serving its real connections;
|
||||
put it back on its own config afterwards.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ble_midi
|
||||
|
||||
## BLE MIDI client against a fake instrument (#18508)
|
||||
|
||||
No MIDI hardware needed: `templates/ble_midi/midiperi.yaml` turns a second ESP32 (an S3 with ethernet, so the
|
||||
API stays reachable while BLE is busy) into a BLE MIDI peripheral with `esp32_ble_server`: the MIDI service
|
||||
advertised, the MIDI characteristic with read, write, write without response and notify, an `on_write` that logs
|
||||
the bytes, buttons that notify hand built packets (channel messages, two messages in one packet, running status,
|
||||
SysEx in one packet, split over two, oversize, cut short, clock, aftertouch, orphan data bytes, a 50 packet burst)
|
||||
and a restart button for the disconnect test. `midicli.yaml` is the component test config on a classic devkit with
|
||||
`wifi:`, `api:`, the peripheral's BLE address in the `peri_mac` substitution, given on the command line with
|
||||
`-s peri_mac <mac>` (the base MAC plus 2, printed by `esp32_ble` in `dump_config`), `on_sysex` and `on_message` lambdas that print the bytes, debug sensors and out of range send
|
||||
buttons. Drive both over the plaintext API with `scripts/modem/mdrive.py HOST LOGFILE press:NAME wait:S` 3 s
|
||||
apart and compare the peripheral's `TX` and `RX` lines with the client's decoded lines. Gotcha:
|
||||
`ble_server.characteristic.set_value` latches the value of its first play and re-applies it before every notify,
|
||||
so the peripheral script sets the value from a lambda (`id(midi_io).set_value(std::vector<uint8_t>(pkt));
|
||||
id(midi_io).notify();`). At VERBOSE the client logs four lines per message, so a burst shows an
|
||||
`esp32_ble took a long time` of a couple of seconds that is the serial port, not the parser.
|
||||
Results: https://github.com/esphome/esphome/pull/18508#issuecomment-6348272717
|
||||
@@ -0,0 +1,26 @@
|
||||
# esp32_ble_server pairing and encrypted characteristics (#17562)
|
||||
|
||||
`scripts/ble_server_security`: a classic ESP32 devkit runs the secure GATT server (`server.yaml`) and a second ESP32
|
||||
(an S3 here) runs `ble_client` as the central (`client.yaml`). The server has a `read_encrypt` battery level
|
||||
(`99.9`), a plain characteristic, a `write_encrypt` characteristic that logs what it receives, a `read_encrypt_mitm`
|
||||
characteristic and a `read_encrypt` characteristic that notifies every 2 s. `run.sh TAG IO AUTH` builds both with the
|
||||
pairing mode as substitutions, pairs once, reconnects twice, and `summary.py` prints PASS or FAIL. The server needs
|
||||
`esp32_ble_server` security from #17562 or later: point `SERVER_TREE` at that checkout (the client builds from
|
||||
`TREE`, dev is fine). `summary.py` needs each refused read to show an `Error reading char` reply, so a run where the
|
||||
client never connected fails instead of passing.
|
||||
|
||||
- `ble_client` never starts pairing on its own (a phone does, after an `insufficient authentication` reply); the
|
||||
client's `Pair` button calls `BLEClientBase::pair()`. Read errors show as `Error reading char at handle N, status=5`
|
||||
(not authenticated: no bond) or `status=15` (not encrypted: bond known, link not encrypted yet).
|
||||
- The `ble_client` text sensor subscribes once, at connect, before any pairing, so a protected characteristic refuses
|
||||
it. The `Subscribe` button writes the CCCD again after pairing; `ble_client` drops its service list after discovery,
|
||||
so it writes the characteristic handle + 1 (where ESP-IDF's GATT server puts the CCCD).
|
||||
- Numeric comparison (`display_yes_no` with `sc_mitm_bond`) tells a fresh pairing from a bond reuse: the
|
||||
`numeric comparison` line only appears on a fresh pairing. Just Works cannot show it.
|
||||
- ESP-IDF 5.5.6 drops the bond on the second disconnect on both roles (`Deleting unpaired device`), so that reconnect
|
||||
pairs from scratch; 5.5.5 keeps it (`-s idf 5.5.5`).
|
||||
- The server keeps its bonds in NVS. One holding an authenticated bond for the client refuses a Just Works re-pairing
|
||||
silently; erase the devkit's flash before switching from nc to jw.
|
||||
- `summary.py` failed both modes at 58636914ab: `read_encrypt_mitm` was readable with no pairing, because
|
||||
`BLEService::create_characteristic()` took an 8 bit `esp_gatt_char_prop_t` and dropped the MITM bits (1 << 8 and
|
||||
1 << 9). Check the generated `create_characteristic` call and the property type when a flag above bit 7 is added.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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).
|
||||
|
||||
Controller build check: ESP-IDF 5.5.6 ships `libbtdm_app.a` byte for byte the same as 5.5.5 for esp32c3 and esp32s3
|
||||
(build 51d9dfd). Without hardware:
|
||||
|
||||
```bash
|
||||
strings -a <idf>/components/bt/controller/lib_esp32c3_family/<chip>/libbtdm_app.a | grep -E '^[0-9a-f]{7}$'
|
||||
```
|
||||
|
||||
On a board: a button lambda logging `btdm_controller_get_compile_version()` (declared `extern "C"`) over the API,
|
||||
since the ESP-IDF init line only reaches the UART.
|
||||
@@ -0,0 +1,13 @@
|
||||
# GPIO expanders
|
||||
|
||||
## Interrupt driven base class (#21469, #21468)
|
||||
|
||||
Any device with a PCF8574 input expander whose INT line goes to an ESP32 GPIO works; the inputs should change
|
||||
during the test (end switches on a gate or cover are ideal). Before flashing, check with `nm` that
|
||||
`gpio_expander::interrupt_pin_isr` lands in IRAM (0x4008xxxx on a classic ESP32). Then confirm `dump_config` lists
|
||||
the interrupt pin and the input states match reality, and that the `pcf8574` entry is absent from the runtime stats
|
||||
while idle (its loop is disabled). Drive the inputs (for a cover, `cover_command(position=1.0)` and then `0.0` over
|
||||
the API, waiting for each end state): every input edge should log, `pcf8574` should show a small count in that
|
||||
minute's stats and vanish in the next. For a device that cannot be reached physically, boot the same code on a
|
||||
bench board first and keep a merge base image built as the fallback; an ESP32 bootloader without rollback support
|
||||
leaves only `safe_mode` to recover a bad image.
|
||||
@@ -0,0 +1,17 @@
|
||||
# ESP32 GPIO hold through the ISR pin API (#21576)
|
||||
|
||||
`scripts/gpio_hold/hold_test.hpp` builds `ESP32InternalGPIOPin`s by hand, calls `to_isr()` and reads each pad back
|
||||
with `gpio_get_level`: 20 ISR writes, a raw `gpio_set_level` control (a held pad must not change, an unheld one must)
|
||||
and ISR `pin_mode` pulls (input must drop to `NONE` between pull changes, because the ISR path only applies pulls when
|
||||
`FLAG_INPUT` toggles). It needs no wiring: unconnected pins read the pulls. Each pin runs held and unheld, then the
|
||||
round logs `ROUND n PASS failures=0` or `FAIL`.
|
||||
|
||||
- `USE_GPIO_HOLD` comes from any pin with `hold_state: true`, so the configs add a `gpio` output on a spare pin.
|
||||
- `gpio-hold-esp32.yaml` (classic devkit) runs three rounds 5 s after boot and has a `rerun` button;
|
||||
`gpio-hold-esp32s3.yaml` (Waveshare ESP32-S3-ETH, where most pins go to the W5500, TF card, camera, WS2812 or
|
||||
USB) runs a round every 15 s.
|
||||
- C3: ESP-IDF 5.5 has `SOC_RTCIO_PIN_COUNT` 0, so `rtc_gpio_is_valid_gpio()` is false on every pin and only the
|
||||
digital hold path runs.
|
||||
- C6 (Waveshare ESP32-C6-GEEK, native USB): LP pads GPIO0 to 7 are RTC IO with hold. GPIO1, 2, 7, 20, 23 read high
|
||||
and GPIO6 low whatever the pull (LCD, TF card or resistors); clean pins are 0, 3, 4, 5, 18, 19, 21, 22. Dev
|
||||
already holds LP pads through `gpio_hal_hold_en`, so the PR and dev look the same there. H2 is untested.
|
||||
@@ -0,0 +1,48 @@
|
||||
# http_request
|
||||
|
||||
## Update manifest URL changed at run time (#21373)
|
||||
|
||||
`scripts/http_request/manifest_server.py PORT [DELAY]` serves `stable.json`, `beta.json` and `slow.json` (answered
|
||||
after DELAY s) with both chip families and logs every request. `templates/http_request_update/upd-common.yaml`
|
||||
(secret `manifest_base` is the server URL) has the update entity on the stable manifest, buttons for every
|
||||
`set_source_url` shape (`std::string` local, literal, temporary, `.c_str()` of a local, the current URL back in),
|
||||
`Check Then Swap` (ESP32 task path: check the slow manifest and replace the URL in the same lambda), `Arm Swap In
|
||||
Check` plus a `logger: on_message` lambda that replaces the URL from inside the inline check (ESP8266; the log line
|
||||
from inside `on_message` is swallowed, judge by the next check's result), and a `Heap` button. `upd-base-common.yaml`
|
||||
keeps only the shapes that compile on a tree with the bare pointer setter (for the base build, copy a board file
|
||||
and point its `common` include at `../upd-base-common.yaml` with a different `name:`); `boards/upd8266.yaml` and `boards/upd32.yaml` are the
|
||||
per-board files, one directory below the package their `../` include points at. Drive with `scripts/http_request/updrive.py HOST KEY LOGFILE press:NAME wait:S`, which prints every
|
||||
update state (latest version, summary). The ESP8266 Arduino client gets `Error reading manifest: -1` on roughly a
|
||||
quarter of the fetches from this Python server on every build, so count checks against the base before blaming a
|
||||
PR. `http_request` on the ESP8266 needs `verify_ssl: false` even for plain http URLs.
|
||||
Results: https://github.com/esphome/esphome/pull/21373#issuecomment-6337544933
|
||||
|
||||
## Non-blocking request actions (#21486)
|
||||
|
||||
`scripts/http_request/hsrv.py PORT` serves `/fast`, `/slow?d=S`, `/big` and `/chunked` (`n` bytes at `rate` B/s,
|
||||
deterministic bytes `(i*7+3)&0xFF`), `/post` (echoes length and byte sum), `/redirect` and `/hang`.
|
||||
`templates/http_request_action/hr-common.yaml` (substitutions `name`, `bufsize`; per-board files `hr32.yaml` and
|
||||
`hr66.yaml`, `-s name hr32base` on the command line for the base build; `hrota32.yaml` adds the `otaflash` action
|
||||
for `otadrive.py`) has API actions `req_get`, `req_post`, `burst` (N runs of a `mode: parallel` script on one action),
|
||||
`cancel` (`mode: single` script run twice, then `script.stop`) and `reboot_during`, and logs
|
||||
`RESULT n status len sum took maxgap`, where maxgap is the longest main loop gap during the request (an
|
||||
`interval: 1ms` lambda plus the time since its last pass, so a fully blocking request counts too). Drive with
|
||||
`scripts/http_request/hrdrive.py IP http://HOST:PORT LABEL [https]` (`ONLY=case,case` and `ROUNDS=n` env; the
|
||||
`cross` case runs two actions on one component). `burstonly.py IP URL N` checks concurrency against the server's
|
||||
completion times, `reboottest.py IP URL` times `App.safe_reboot()` with requests in flight, `updcheck.py IP LABEL N`
|
||||
runs N update checks over the API (for real device configs that use `update: platform: http_request`) and
|
||||
`otadrive.py` triggers `ota.http_request.flash` (the API variable is `sum`; `md5` is a reserved variable name).
|
||||
`execute_service` must be awaited on current aioesphomeapi or nothing is sent. Keep the ESP32 `bufsize` at 32 KB or
|
||||
less: the action copies the body into a `std::string` while the buffer is still held, and 80 KB aborts with out of
|
||||
memory on dev and on the PR alike. `http_request` refuses BK72xx and RTL87xx at validation.
|
||||
|
||||
## OTA request cases with basic auth (#21556)
|
||||
|
||||
`scripts/http_request/ota_auth/`: `ho-common.yaml` (API actions for `ota.http_request.flash` with `md5_url`, `md5`
|
||||
and basic auth, `flash()` with no URL, an invalid URL, the `md5_computed()`/`md5_expected()` getters), `fwsrv.py`
|
||||
(serves a www dir, `/auth/` needs `otauser:otapass`), `hodrive.py` (11 cases, reconnects after each good flash) and
|
||||
`run.sh BUILD BOARD` (compiles from `TREE`, serial flash, serves that exact image; `TREE`, `P32` or `P66`, `SC` and `HOST_IP` in
|
||||
the environment). On the D1 mini the authenticated MD5 read fails
|
||||
with `Error reading MD5: -1` on dev too, and stored credentials carry over to the next flash on both builds.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21556#issuecomment-6381067561
|
||||
@@ -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.
|
||||
@@ -40,3 +40,29 @@ least 24 selects per build and read the raw bytes with `uart: debug: direction:
|
||||
id) before blaming the parser; the API log stream drops lines under that dump volume, so some misses stay
|
||||
unclassified.
|
||||
Results: https://github.com/esphome/esphome/pull/21357#issuecomment-6333244886
|
||||
|
||||
## Frame stream harness: presence hold and link loss (#21733, #21734)
|
||||
|
||||
`scripts/ld2450/stream-harness.yaml` is a loopback harness that feeds the hub one periodic frame every 100 ms from a
|
||||
mode global set by API buttons: no target, target 1 approaching, silent, or 12 garbage bytes per tick (what a radar
|
||||
at another baud rate looks like). `has_target` has `filters: []` so the hub's own hold is what gets timed, and every
|
||||
change logs `presence raw ON|OFF at <millis>`. Add `stream-connected.yaml` as a second package on builds whose ld2450
|
||||
has the link watchdog (`connected` binary sensor).
|
||||
|
||||
- `holddrive.py HOST OUT`: for `presence_timeout` 5 and 0, three seconds of target frames, then empty frames; prints
|
||||
the ms from the press to the off line, then sets 0 and 20 and reads the number back after a restart. On dev 0
|
||||
still held 5 s and snapped back to 5; with #21733 it clears on the next empty frame (under 200 ms) and stays 0.
|
||||
- `linkdrive.py HOST OUT`: steps through target frames, 10 s of silence, empty frames, 10 s of garbage, target
|
||||
frames, then the hub's radar restart followed by 3.5 s and by 15 s of silence; prints every state change and link
|
||||
log line with its time. Dev freezes every entity through silence and garbage. Run against a real radar too: a
|
||||
radar restart and a Bluetooth toggle must not report a loss (the Apollo R PRO-1 ETH did not).
|
||||
- A freshly booted ESP8266 delays API commands sent in its first seconds and then runs them back to back; the
|
||||
drivers wait 8 s after connecting before timed presses.
|
||||
|
||||
## BT MAC sensors (#21731)
|
||||
|
||||
`scripts/ld2450/macdrive.py HOST OUT` reads every LD24xx BT MAC, Bluetooth and firmware entity, turns each Bluetooth
|
||||
switch off, then on, and prints the values after each step. The MAC sensors are often `disabled_by_default` in
|
||||
vendor configs but still publish over the API. On the Apollo R PRO-1 ETH both radars (LD2450 fw 2.04, LD2412 fw
|
||||
1.26) have the multicast bit set in the first MAC byte, which dev showed as `unknown`. A radar MAC identifies a
|
||||
device: quote only its first byte in PR text.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Addressable LED strips (#21447, #21445)
|
||||
|
||||
- To exercise a failed strip allocation, use a classic ESP32 without PSRAM with `num_leds: 65535` and
|
||||
`channel_colors: GRBW` (262 kB, bigger than any internal block). `num_leds` is uint16 in the drivers, larger
|
||||
values wrap. On dev such a strip boot-loops the board.
|
||||
- Never force allocation failures by hogging RAM instead: Wi-Fi then aborts in `operator new` or the loop task
|
||||
trips the task watchdog.
|
||||
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
|
||||
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
|
||||
add effects; restore its own config afterwards.
|
||||
- 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,46 @@ 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.
|
||||
|
||||
Regression rerun of the same flow (#21728): `scripts/libretiny_crash/rtl_rom_vs_report.py CAPTURE` lists, for each ROM
|
||||
fault dump in a `--ms` capture, the ROM's CFSR and PC, the ms to the next `ROM Version` banner and the Reason, CFSR, PC
|
||||
and SP the next boot reported, so two builds compare line by line. The ROM lines start with a bare CR; split the
|
||||
capture on newlines only. The imprecise bus fault's PC moves between builds (it is wherever the core was when the
|
||||
fault was raised); compare it with that run's ROM dump, not with another build. SP stays the same per fault (8 word
|
||||
frame, no FPU). On the BK7238 time the reboot from the button press to the next `BK7238_1.0.14` banner (0.6 to 0.7 s).
|
||||
|
||||
## BK7238 BLE and flash sector safety (#21735)
|
||||
|
||||
The Beken BLE bond store reads its partition at boot and erases that sector when the CRC does not match; before
|
||||
LibreTiny 1.14 the partition was unmapped and pointed at sector 0, erasing the bootloader (#18646). To prove a change
|
||||
like this safe on a board that cannot be recovered without a dump:
|
||||
|
||||
1. Take a full backup first: `scripts/bk72xx_flash/readback.sh full-before` (ltchiptool family `beken-7238`, about
|
||||
190 s for 2 MB). Keep it until the board is back on a known build.
|
||||
2. Serial flash a build without the feature, then deliver the feature build by OTA (the path users take). Use
|
||||
`bk72xx: framework: sdk_silent: none` with `logger: hardware_uart: UART1`, so the SDK's own lines (bootloader
|
||||
banner, `CRC fail, remove bonded info`) show up in the capture.
|
||||
3. Soak with Wi-Fi up, OTA again from the new build, pulse CEN a few times (`serial_capture.py --reset`), and check
|
||||
each boot comes back through the bootloader.
|
||||
4. Read the whole chip again and run `scripts/bk72xx_flash/partdiff.py full-before.bin full-after.bin
|
||||
<libretiny>/boards/_base/beken-72xx.json`: bootloader, calibration and userdata must be unchanged; app, download
|
||||
and one KVS sector change normally.
|
||||
|
||||
Check what already sits in the target partition before the first boot: on an XH-WB3S that shipped with Tuya firmware,
|
||||
0x1E1000 (the `ble_bonding` partition of the generic Beken layout) held the Tuya `diff2ya` record (magic `ba dc cd
|
||||
ab`), which the bond store erased on its first boot. Nothing depended on it there, but say so in the results.
|
||||
@@ -0,0 +1,16 @@
|
||||
# GPIO wakeup from auto light sleep (#21074)
|
||||
|
||||
`scripts/light_sleep_wake` needs no wiring. LEDC on `LEDC_USE_RC_FAST_CLK` with
|
||||
`sleep_mode = LEDC_SLEEP_MODE_KEEP_ALIVE` keeps toggling GPIO10 at 2 Hz through light sleep, and an
|
||||
`ESP32InternalGPIOPin` interrupt (`ANY_EDGE`) on the same pad timestamps every edge in an IRAM handler.
|
||||
`wakeprobe::report()` logs every 30 s: edges seen and expected, the shortest and longest gap, how many edges came
|
||||
more than 20 ms off the half period, and the SLEEP share from `esp_pm_dump_locks()` (needs `profiling: true`).
|
||||
|
||||
- Native ESP-IDF builds need `include_builtin_idf_components: [esp_driver_ledc]`.
|
||||
- After `ledc_channel_config` call `gpio_input_enable(pin)`, never `gpio_set_direction(INPUT_OUTPUT)`: that routes the
|
||||
pad back to a plain GPIO output, the wave stops and the pad reads a constant 1.
|
||||
- The C3 shares one LEDC clock across timers, so a board whose own config drives LEDs through `ledc` needs a test
|
||||
config without them. Logging over the API works with `logger: baud_rate: 0`.
|
||||
- Read the reports over the API with `esphome logs ... --device <ip>` under `timeout`. Run PR, dev, then the PR
|
||||
again. On a C3 bulb dev caught every edge but handled 8 of 845 more than 20 ms late
|
||||
(up to 45 ms), the PR none; both slept 57 to 58% of the time.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Manual IP and web handler flows (#21708, #21709, #21711, #21712)
|
||||
|
||||
## Station manual IP (`scripts/manual_ip/run-sta.sh`)
|
||||
|
||||
Set `static_ip` to the board's own DHCP lease so nothing collides, and DNS2 to a server DHCP does not hand out
|
||||
(9.9.9.9), so the dump and `dns_getserver()` prove the manual values are in use. A missing SSID with its own
|
||||
`manual_ip` comes first, so the real network is a later table row and inherits the top level `manual_ip`. The probe
|
||||
logs both DNS servers and resolves a name every 20 s; `ipaddr_ntoa` uses one static buffer, so it uses
|
||||
`ipaddr_ntoa_r` to log two addresses in one line. PASS needs the address, both DNS servers, a lookup and a ping.
|
||||
|
||||
## AP manual IP (`scripts/manual_ip/run-ap.sh`)
|
||||
|
||||
The DUT has only a missing SSID, `ap_timeout: 10s` and `captive_portal:`, so its AP comes up on 10.42.7.1; the other
|
||||
board joins as a client and fetches `http://10.42.7.1/` every 10 s. A D1 mini AP drops some client requests on dev
|
||||
too, so compare failure counts with a dev run and do not expect zero.
|
||||
|
||||
## Ethernet manual IP
|
||||
|
||||
Same idea on an Ethernet board: its lease as `manual_ip` plus an outside DNS2 and the DNS probe from
|
||||
`scripts/manual_ip/dnsprobe.hpp`; restart it over the API and check again.
|
||||
|
||||
## Web handlers (`scripts/web_handlers/run.sh`)
|
||||
|
||||
`webbase.yaml` adds a GET /probe handler from `on_boot` at priority -100 (the shape of an external component adding
|
||||
a handler late) and takes a variant: `ws` (web_server), `cp` (captive_portal only, started on the station network by
|
||||
a button calling `global_captive_portal->start()`) or `prom` (captive_portal and prometheus). The runner fetches
|
||||
/probe and /, then uploads the same image through web OTA (`curl -F update=@<bin> http://ip/update`; ESP-IDF
|
||||
`build/<name>.bin`, not `firmware.ota.bin`; ESP8266 `.pioenvs/<name>/firmware.bin`) and checks over the API that the
|
||||
board is back on that build. On dev too, prometheus GET / gets no answer on ESP32 and 404 on ESP8266.
|
||||
|
||||
A background `(cd dir && cmd) &` does not change the parent shell's directory: give capture scripts absolute paths.
|
||||
@@ -0,0 +1,18 @@
|
||||
# modem
|
||||
|
||||
## PPPoS modem component with no modem attached (#6721)
|
||||
|
||||
A boot check on a classic ESP32 devkit with the modem UART pins left open. `templates/modem/modem-common.yaml` is
|
||||
the package (`api:`, debug `loop_time` and `free` sensors, uptime, buttons for `modem.enable`, `modem.disable` and
|
||||
`modem.send_at`, a 20 s alive log); `modemo.yaml` is the modem only build with `reboot_timeout: 2min` and the
|
||||
component test config's `on_boot` block at `priority: -100` (earlier, `modem.send_at` runs before the modem's
|
||||
`setup()` and logs `Modem DCE not ready`); `modemw.yaml` adds `wifi:` with `reboot_timeout: 0s` so the API stays
|
||||
reachable. Expect `Modem not responding at 115200 baud` once per pass with each pass blocking the loop about 1 s,
|
||||
the reboot at the configured timeout (`SW_CPU_RESET`), and a `modem.disable` with no modem answering to block
|
||||
the loop about 37 s (`Failed to leave data mode`, then the radio off timeout) without tripping the 30 s watchdog
|
||||
floor because the component feeds it in between. Drive the Wi-Fi build with
|
||||
`scripts/modem/mdrive.py HOST LOGFILE list press:NAME wait:S` (plaintext API; prints sensor and text sensor
|
||||
states, saves the device log). Before 8cc8df4a1f a modem only config with `api:` failed validation because a
|
||||
config dependent `AUTO_LOAD` added `network` after the dependency check; an explicit `network:` block was the
|
||||
workaround. Results: https://github.com/esphome/esphome/pull/6721#issuecomment-6347395159 and
|
||||
https://github.com/esphome/esphome/pull/6721#issuecomment-6348520416
|
||||
@@ -51,3 +51,31 @@ context rejects a CA that lacks a `keyUsage` extension (`CA cert does not includ
|
||||
on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one
|
||||
`Last esp-tls error: 0x8008` on every build, TLS or not. Results:
|
||||
https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827
|
||||
|
||||
## MAC suffix topics (#9315)
|
||||
|
||||
`scripts/mqtt/mac_suffix/run.sh X BUILD` compiles (from `TREE`) and flashes variant X (a defaults, b custom `topic_prefix`, c explicit topics,
|
||||
d no suffix) of BUILD (`pr` or `base`) on an ESP32 and a D1 mini, records the broker with
|
||||
`mosquitto_sub -F '@H:@M:@S r=%r %t %p'`, presses Restart through the `cmd_t` found in the discovery payloads, then
|
||||
holds both chips in the bootloader for 40 s (`esptool --after no-reset chip-id`) so the broker sends the will; a
|
||||
plain reset only gives "session taken over" and no will. The two boards' configs live in separate folders because
|
||||
variants a to c share a `name` (the MAC suffix tells them apart), and the same name in one folder shares a build
|
||||
directory. A D1 mini CH340 can garble the serial capture after an RTS reset, so read its topics from the broker.
|
||||
`esphome logs --device MQTT` on a current tree needs paho-mqtt 2.x; an older venv with 1.6.1 fails, so use a venv
|
||||
made from the tree's requirements.txt. A Restart press never delivered the shutdown message on any build tested.
|
||||
|
||||
## Shutdown message delivery (#21566)
|
||||
|
||||
`scripts/mqtt/shutdown/`: `sd-common.yaml` sets birth/will/shutdown payloads `hello`/`dead`/`bye` so the shutdown
|
||||
message and the will cannot be confused, and takes `restart`, `disable` and `sleep` on `<name>/cmd`.
|
||||
`sdrun.sh V N` (`TREE`, `P32` and `P66` in the environment) compiles and flashes the ESP32 and D1 mini, restarts each N times, disables MQTT once and deep sleeps the ESP32 three
|
||||
times (never the D1 mini: without the GPIO16 to RST wire it never wakes); `match.py OUT` counts a `bye` within 5 s of
|
||||
each command. Dev: ESP32 0/10 restarts, D1 mini 1 to 3/10. Put the broker address (`broker_ip`) in the secrets.yaml next to
|
||||
the included package, and quote `"${name}/status"` inside flow mappings.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6381448061
|
||||
|
||||
Retest at c9cf3c2813 (shutdown message sent at QoS 1, teardown waits for the broker's ack): 10/10 restarts on both
|
||||
boards and 3/3 deep sleeps, against 1/10, 3/10 and 0/3 on base. An earlier 100 ms hold gave 3/10, 6/10 and 2/3, so
|
||||
judge a delivery fix by counts over 10 or more restarts, never one or two presses.
|
||||
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6382076613
|
||||
@@ -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.
|
||||
@@ -18,3 +18,47 @@ ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout
|
||||
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
|
||||
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
|
||||
A case expected as `any` passes on `finished` or `error`, never on no outcome.
|
||||
|
||||
## PNG decode time limit (#21364, #21391)
|
||||
|
||||
The same server also serves any plain file in its www directory by name (404 for a missing file or a path
|
||||
outside it). `scripts/online_image/make_big_pngs.py <www>` (after `make_images.py`) writes a 320x240 gradient (618 B, also as `ok.png` so the misbehaviour cases carry a
|
||||
real multi-row PNG), a flat 640x480 PNG (1.6 KB for 307200 pixels), 231 KB of noise and a JPEG. The device is
|
||||
`templates/online-image-dut.yaml` with `type: BINARY` on the main image (a 640x480 RGB565 does not fit a classic
|
||||
ESP32 without PSRAM) and `debug` loop time and heap sensors at 2 s; the 256 byte buffer image stays RGB565, so do
|
||||
not point big images at it (out of memory on every build). `scripts/online_image/pngdrive.py HOST KEY LOGFILE
|
||||
BASEURL LABEL` (default case set `png`; `CASES=short` for the six case list) calls the actions, waits for the
|
||||
finished or error line and reports the case time, the highest loop time and the lowest free heap. The per pass `online_image.image took a long time` values come from
|
||||
the serial capture and only print on a new maximum, so read the first fetch of each image. The pass that decodes a
|
||||
whole buffer is the decoder cost (before the change 510 ms gradient and 1980 ms flat, after it 77 and 140 ms); for
|
||||
the noisy PNG the longest pass is the blocking 64 KB read on both builds (0.7 to 3.7 s). Alternate PR, base, PR
|
||||
for a timing claim.
|
||||
|
||||
For a replacement PR, compare against the parent PR head, not dev. `CASES=noise` fetches the noisy PNG three times
|
||||
(run it right after a boot, alternating builds); `CASES=end` runs flat and gradient as plain, `noclen_`, `chunked_`,
|
||||
`half_` and `stall_` through the prefixed server cases below.
|
||||
|
||||
## JPEG decoded as it downloads (#21342)
|
||||
|
||||
`oi_server.py` serves prefixed cases on any file: `half_<file>` (first half, matching Content-Length),
|
||||
`noclen_<file>` (close delimited), `chunked_<file>` (1 KB chunks), `stall_<file>` (a third, then the socket held
|
||||
40 s). `scripts/online_image/make_jpegs.py <www>` writes noise, gradient, 12 KB EXIF and progressive JPEGs. Device:
|
||||
`templates/online-image-dut.yaml` with `type: BINARY` on both entities and `buffer_size: 4096` on the second;
|
||||
`CASES=jpeg scripts/online_image/pngdrive.py HOST KEY LOGFILE BASEURL LABEL` runs the JPEG list; every case set
|
||||
prints case time, highest loop time and lowest free heap. Before the change a JPEG needs a contiguous block of its file size (233 KB never
|
||||
allocates on a classic ESP32, and after a few failures 69 KB does not either until a reboot); after it the 64 KB
|
||||
buffer stays 64 KB and 4 KB is enough for a baseline JPEG. The first pass still reads up to `buffer_size` in one
|
||||
blocking read, so the 64 KB entity blocks 1.7 to 4 s and the 4 KB one 272 ms at most; judge the decode budget on
|
||||
the small buffer entity. Already on dev: a JPEG without Content-Length (plain or chunked) ends with `Could not open
|
||||
image for decoding: 4` then `Invalid type`.
|
||||
|
||||
## BMP and QOI decode time limit (#21392)
|
||||
|
||||
Use a wired board (`templates/online-image-s3-eth-dut.yaml`: Waveshare ESP32-S3-ETH, W5500, PSRAM, `type: RGB565`)
|
||||
rather than a classic ESP32 on Wi-Fi: with a weak link the reads block up to the 4.5 s timeout and the per case loop
|
||||
numbers are noise. `scripts/online_image/make_bmp_qoi.py <www>` writes 1 bit BMP 640x480, 8 and 24 bit BMP 320x240,
|
||||
QOI 640x480 one colour (all runs) and checkerboard, QOI 320x240 gradient and noise. `pngdrive.py` with
|
||||
`CASES=bmpqoi` runs each as plain, `noclen_`, `chunked_`, `half_` plus stalls. The `loop_time` sensor sits at 75
|
||||
to 160 ms even for a 16x16 image, so for decode passes use `scripts/online_image/firstpass.py HOST KEY BASEURL
|
||||
LABEL IMAGE...`: it reboots the device before each fetch and reports that fetch's `took a long time` maximum. A
|
||||
`half_` file with matching Content-Length reports `download finished` with "not caching it" on dev too.
|
||||
@@ -6,3 +6,64 @@ 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
|
||||
|
||||
## Upload without a configuration (#21738)
|
||||
|
||||
`esphome upload --file IMAGE --device IP` with no YAML picks the OTA port from the image header and takes the password
|
||||
from `--ota-password` or `ESPHOME_OTA_PASSWORD` and the key from `ESPHOME_OTA_KEY`. Test each chip family with its own
|
||||
current image (`build/firmware.ota.bin` for ESP-IDF, `.pioenvs/<name>/firmware.bin` for ESP8266, `firmware.uf2` for
|
||||
LibreTiny) and check the build over the API; switch one board between two builds to show a new image lands.
|
||||
|
||||
Point every case that should be refused before connecting at an unroutable address (192.0.2.1), never at a board: a
|
||||
refusal prints no `Connecting to` line, and an image that slips through cannot reach a device. An ESP32-S3
|
||||
`firmware.factory.bin` slipped through on 2026-10-27 (its bootloader header at offset 0 looks like an app), the device
|
||||
reported `OTA successful` and its bootloader rejected the slot at boot; send the board its proper app image afterwards.
|
||||
A factory image has the partition table magic `AA 50` at 0x8000 and the app at 0x10000.
|
||||
@@ -0,0 +1,9 @@
|
||||
# prometheus
|
||||
|
||||
## Date, time and datetime metrics (#9941)
|
||||
|
||||
`scripts/prometheus/prom-datetime.yaml` is a D1 mini with `web_server`, `prometheus` and three optimistic template
|
||||
datetime entities; `prom-datetime-tz.yaml` adds an sntp time component with `HST10` (the zone applies without a sync).
|
||||
`datetime_metrics.py HOST` prints the rows of `/metrics`, sets the entities over the API to 2040 values (past 2038 on
|
||||
purpose) and prints them again with their UTC reading. Dates are local midnight in ms, times ms since midnight,
|
||||
datetimes ms since the epoch, so a datetime set as an instant reads the same in both zones.
|
||||
@@ -0,0 +1,30 @@
|
||||
# remote_base / remote_receiver
|
||||
|
||||
## Loopback on one pin, no IR hardware (#21560 to #21563)
|
||||
|
||||
`templates/remote_base/`: `rb-common.yaml` (API action `tx_all`, a dumper registered from `on_boot` through
|
||||
`register_dumper()` standing in for an external component), `rb-txscript.yaml` (14 `remote_transmitter.transmit_*`
|
||||
frames with `tx <proto>` log markers), `rb32.tmpl` and `rb66.tmpl` (`@V@` is the build label).
|
||||
`scripts/remote_base/run.sh LABEL B N` (`TREE`, `P32` or `P66`, `SC` in the environment) writes the run file from the
|
||||
template, builds and flashes it from `TREE` (`esphome run --no-logs`), finds the IP and runs `rbdrive.py IP N`, which segments the API log by the
|
||||
markers and prints what each sent frame decoded as.
|
||||
|
||||
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
|
||||
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
|
||||
where no primary matched.
|
||||
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
|
||||
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
|
||||
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
|
||||
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
|
||||
- API log lines can drop under bursts; rerun with more rounds before calling a shortfall.
|
||||
- `register_dumper()` returns nothing, so the config logs `ext dumper register called`, not whether it stuck. On dev
|
||||
the list has a fixed size and a dumper beyond it is dropped with `No dumper slot` (ERROR); judge by that line and by
|
||||
`ext dumper saw` counts in the `rbdrive.py` table, which only appear when the dumper is really registered.
|
||||
`rbdrive.py` exits 1 when the external dumper saw no frame, so a build that rejects the registration fails the
|
||||
drive; set `EXPECT_REGISTER=1` on `run.sh` to also fail the run on any `No dumper slot` line.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
|
||||
|
||||
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
|
||||
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
|
||||
only the last line sees "Failed to upload" first, so rerun before blaming the build.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Simulated UART sensors on a classic ESP32 (#21449, #21446)
|
||||
|
||||
No sensor needed. `templates/uart_sim/sim.hpp` (included with `esphome: includes:`) has frame builders for wts01,
|
||||
ds1603l, sm300d2 and ufm01, `sim::loopback(id(uart))` (ESP-IDF `uart_set_loop_back` on the component's own UART),
|
||||
`sim::send()` that logs the bytes, and `sim::probe`, a SEN0465 responder polled from a 10 ms interval. The run files
|
||||
`fra.yaml`, `frb.yaml` and `frc.yaml` set the name and include `fr-common.yaml` plus their `fr*-parts.yaml` as
|
||||
packages; copy them with a different name (`frabase` and so on) for the base build.
|
||||
|
||||
- Push-style sensors: put the component on a UART, switch it to loopback in `on_boot`, and inject frames from
|
||||
template buttons (`fra-parts.yaml`, `frb-parts.yaml`). ufm01 enters its active stream by itself when a 1 s
|
||||
interval streams frames during its 2 s startup wait.
|
||||
- Request/response sensors: cross-wire two UARTs through the GPIO matrix (UART1 tx 25 rx 26, UART2 tx 26 rx 25,
|
||||
`allow_other_uses: true` on all four pins); the second UART runs the simulated device (`frc-parts.yaml`).
|
||||
A select sets the probe's misbehaviour (tail, FF tail, silent, split, wrong gas).
|
||||
- Polled readers: run `update()` from a script and log `micros()` around it to measure blocking
|
||||
(sm300d2 on dev blocks about 104 ms on an empty buffer).
|
||||
- The logger stays on UART0, so three builds cover five components. Drive with `scripts/uart_sim/frdrive.py HOST
|
||||
LOG press:NAME sel:NAME=OPT num:NAME=V wait:S`, or `scripts/uart_sim/run.sh NAME TREE SECONDS OPS...` (build
|
||||
and flash from TREE with `esphome run --no-logs`, capture, drive; `PORT` and `SC` in the environment).
|
||||
@@ -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,221 @@
|
||||
"""Pull the store_yaml blob off a device over the native API (plaintext or Noise) and unpack it.
|
||||
|
||||
usage: syrecover.py HOST KEY OUTDIR [ORIGDIR] (KEY of - means a plaintext API)
|
||||
Prints the capability flag, chunk count, advertised and received sizes, encoding, the envelope's files, and
|
||||
when ORIGDIR is given compares each recovered file with ORIGDIR/<path>.
|
||||
Run with PYTHONPATH pointing at the esphome tree that has store_yaml (for unpack_envelope). Needs Python 3.14
|
||||
(compression.zstd) or the zstandard package.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
from aioesphomeapi._frame_helper.noise import APINoiseFrameHelper
|
||||
from aioesphomeapi._frame_helper.plain_text import APIPlaintextFrameHelper
|
||||
from aioesphomeapi.connection import APIConnection, ConnectionParams
|
||||
from aioesphomeapi.zeroconf import ZeroconfManager
|
||||
|
||||
from esphome.components.store_yaml import unpack_envelope
|
||||
|
||||
try:
|
||||
from compression import zstd
|
||||
except ImportError:
|
||||
try:
|
||||
import zstandard as zstd
|
||||
except ImportError:
|
||||
sys.exit("needs Python 3.14 (compression.zstd) or the zstandard package")
|
||||
|
||||
HOST, KEY, OUT = sys.argv[1], sys.argv[2], Path(sys.argv[3])
|
||||
ORIG = Path(sys.argv[4]) if len(sys.argv) > 4 else None
|
||||
HELLO, HELLO_RESP, CONNECT, CONNECT_RESP = 1, 2, 3, 4
|
||||
DISCONNECT_REQ = 5
|
||||
FATAL = -1 # queued by report_fatal_error so a waiting expect() stops at once
|
||||
CAPS_REQ, CAPS_RESP = 149, 150
|
||||
GET_YAML_REQ, GET_YAML_RESP = 161, 162
|
||||
|
||||
|
||||
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
|
||||
value = shift = 0
|
||||
while True:
|
||||
b = buf[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
if not b & 0x80:
|
||||
return value, pos
|
||||
shift += 7
|
||||
|
||||
|
||||
def fields(payload: bytes) -> dict[int, list]:
|
||||
out: dict[int, list] = {}
|
||||
pos = 0
|
||||
while pos < len(payload):
|
||||
key, pos = decode_varint(payload, pos)
|
||||
fnum, wt = key >> 3, key & 7
|
||||
if wt == 0:
|
||||
v, pos = decode_varint(payload, pos)
|
||||
elif wt == 2:
|
||||
ln, pos = decode_varint(payload, pos)
|
||||
v = payload[pos : pos + ln]
|
||||
pos += ln
|
||||
elif wt == 5:
|
||||
v = payload[pos : pos + 4]
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"wire type {wt}")
|
||||
out.setdefault(fnum, []).append(v)
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue[tuple[int, bytes]] = asyncio.Queue()
|
||||
errors: list[Exception] = []
|
||||
|
||||
class Shim(APIConnection):
|
||||
"""Only the two callbacks the frame helper uses; the real connection logic is bypassed."""
|
||||
|
||||
def process_packet(self, msg_type_proto, data) -> None:
|
||||
queue.put_nowait((msg_type_proto, bytes(data)))
|
||||
|
||||
def report_fatal_error(self, err: Exception) -> None:
|
||||
errors.append(err)
|
||||
queue.put_nowait((FATAL, b""))
|
||||
|
||||
params = ConnectionParams(
|
||||
addresses=[HOST],
|
||||
port=6053,
|
||||
password=None,
|
||||
client_info="syrecover",
|
||||
keepalive=15,
|
||||
zeroconf_manager=ZeroconfManager(),
|
||||
noise_psk=None if KEY == "-" else KEY,
|
||||
expected_name=None,
|
||||
expected_mac=None,
|
||||
timezone=None,
|
||||
provide_time=True,
|
||||
)
|
||||
shim = Shim(params, None, False, HOST)
|
||||
if KEY == "-":
|
||||
helper = APIPlaintextFrameHelper(
|
||||
connection=shim, client_info="syrecover", log_name=HOST
|
||||
)
|
||||
else:
|
||||
helper = APINoiseFrameHelper(
|
||||
connection=shim,
|
||||
noise_psk=KEY,
|
||||
expected_name=None,
|
||||
expected_mac=None,
|
||||
client_info="syrecover",
|
||||
log_name=HOST,
|
||||
)
|
||||
await loop.create_connection(lambda: helper, HOST, 6053)
|
||||
try:
|
||||
await asyncio.wait_for(helper.ready_future, 10)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sys.exit(f"handshake failed: {exc!r} {errors}")
|
||||
print(f"transport {'plaintext' if KEY == '-' else 'noise'}: handshake done")
|
||||
|
||||
async def expect(msg_type: int, timeout: float = 10) -> bytes:
|
||||
while True:
|
||||
t, d = await asyncio.wait_for(queue.get(), timeout)
|
||||
if t == msg_type:
|
||||
return d
|
||||
if t == FATAL:
|
||||
sys.exit(
|
||||
f"transport error while waiting for message {msg_type}: {errors}"
|
||||
)
|
||||
if t == DISCONNECT_REQ:
|
||||
sys.exit(
|
||||
f"device closed the connection while waiting for message {msg_type}"
|
||||
)
|
||||
print(f"skipping message type {t} while waiting for {msg_type}")
|
||||
|
||||
hello = api_pb2.HelloRequest(
|
||||
client_info="syrecover", api_version_major=1, api_version_minor=10
|
||||
)
|
||||
helper.write_packets([(HELLO, hello.SerializeToString())], False)
|
||||
await expect(HELLO_RESP)
|
||||
# Sent like the client library does; a device without a password never answers it
|
||||
helper.write_packets(
|
||||
[(CONNECT, api_pb2.AuthenticationRequest().SerializeToString())], False
|
||||
)
|
||||
helper.write_packets(
|
||||
[(CAPS_REQ, api_pb2.DeviceCapabilitiesRequest().SerializeToString())], False
|
||||
)
|
||||
caps = fields(await expect(CAPS_RESP))
|
||||
supported = None
|
||||
if 7 in caps:
|
||||
supported = fields(caps[7][0]).get(1) == [1]
|
||||
print(f"capabilities: store_yaml field present={7 in caps} supported={supported}")
|
||||
|
||||
t0 = time.monotonic()
|
||||
helper.write_packets([(GET_YAML_REQ, b"")], False)
|
||||
chunks: list[bytes] = []
|
||||
total = encoding = None
|
||||
done = False
|
||||
while not done:
|
||||
f = fields(await expect(GET_YAML_RESP, 20))
|
||||
if 1 in f:
|
||||
chunks.append(f[1][0])
|
||||
done = f.get(2) == [1]
|
||||
if 3 in f:
|
||||
total = f[3][0]
|
||||
if 4 in f:
|
||||
encoding = f[4][0].decode()
|
||||
dt = time.monotonic() - t0
|
||||
compressed = b"".join(chunks)
|
||||
print(f"chunks {len(chunks)} sizes {[len(c) for c in chunks]}")
|
||||
print(
|
||||
f"received {len(compressed)} bytes, advertised {total}, encoding {encoding!r}, {dt:.2f} s"
|
||||
)
|
||||
# a second request right after: the device must answer again, not stay stuck
|
||||
helper.write_packets([(GET_YAML_REQ, b"")], False)
|
||||
again = 0
|
||||
done = False
|
||||
while not done:
|
||||
f = fields(await expect(GET_YAML_RESP, 20))
|
||||
again += len(f.get(1, [b""])[0])
|
||||
done = f.get(2) == [1]
|
||||
print(f"second request: {again} bytes")
|
||||
helper.close()
|
||||
if errors:
|
||||
sys.exit(f"transport errors: {errors}")
|
||||
if encoding != "zstd":
|
||||
sys.exit(f"unexpected encoding {encoding!r}, expected 'zstd'")
|
||||
if total is not None and len(compressed) != total:
|
||||
sys.exit(f"received {len(compressed)} bytes but the device advertised {total}")
|
||||
if again != len(compressed):
|
||||
sys.exit(
|
||||
f"the second request returned {again} bytes, the first {len(compressed)}"
|
||||
)
|
||||
envelope = zstd.decompress(compressed)
|
||||
files = unpack_envelope(envelope)
|
||||
print(
|
||||
f"envelope {len(envelope)} bytes, {len(files)} files, ratio {len(compressed) / len(envelope):.2f}"
|
||||
)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for rel, content in files.items():
|
||||
target = OUT / rel.replace("../", "up_/")
|
||||
if not target.resolve().is_relative_to(OUT.resolve()):
|
||||
print("skipping a path outside the output directory:", rel)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
note = ""
|
||||
if ORIG is not None:
|
||||
orig = (ORIG / rel).resolve()
|
||||
if orig.exists():
|
||||
note = (
|
||||
"IDENTICAL to original"
|
||||
if orig.read_bytes() == content
|
||||
else f"DIFFERS from original ({orig.stat().st_size} bytes)"
|
||||
)
|
||||
else:
|
||||
note = "no original"
|
||||
print(f" {rel}: {len(content)} bytes {note}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Compare two full flash dumps of a BK72xx board per partition of its LibreTiny board layout.
|
||||
|
||||
usage: partdiff.py BEFORE.bin AFTER.bin LAYOUT.json
|
||||
LAYOUT.json is a board base file from the LibreTiny platform (for example boards/_base/beken-72xx.json); its
|
||||
"flash" map ("name": "0xSTART+0xLENGTH") names the partitions. Prints the 4 KB sectors that changed in each one,
|
||||
plus any changed sector outside the map.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
before, after = (pathlib.Path(p).read_bytes() for p in sys.argv[1:3])
|
||||
if len(before) != len(after):
|
||||
sys.exit(
|
||||
f"dump sizes differ: {len(before)} and {len(after)} bytes; compare two whole chip reads"
|
||||
)
|
||||
parts = []
|
||||
for name, span in json.loads(pathlib.Path(sys.argv[3]).read_text(encoding="utf-8"))[
|
||||
"flash"
|
||||
].items():
|
||||
start, length = (int(x, 16) for x in span.split("+"))
|
||||
parts.append((start, start + length, name))
|
||||
parts.sort()
|
||||
if (end := max(e for _, e, _ in parts)) > len(before):
|
||||
sys.exit(
|
||||
f"the layout reaches {end:#x} but the dumps are {len(before):#x} bytes; read the whole chip"
|
||||
)
|
||||
SECTOR = 0x1000
|
||||
covered = set()
|
||||
for start, end, name in parts:
|
||||
changed = [
|
||||
a
|
||||
for a in range(start, end, SECTOR)
|
||||
if before[a : a + SECTOR] != after[a : a + SECTOR]
|
||||
]
|
||||
covered.update(range(start, end, SECTOR))
|
||||
listed = " ".join(hex(a) for a in changed[:4]) if 0 < len(changed) <= 4 else ""
|
||||
print(
|
||||
f"{name:12} {len(changed):4} of {(end - start) // SECTOR:4} sectors changed {listed}"
|
||||
)
|
||||
other = [
|
||||
a
|
||||
for a in range(0, len(before), SECTOR)
|
||||
if a not in covered and before[a : a + SECTOR] != after[a : a + SECTOR]
|
||||
]
|
||||
print(
|
||||
f"{'unmapped':12} {len(other):4} sectors changed {' '.join(hex(a) for a in other[:8])}"
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# usage: readback.sh TAG [START LENGTH] read flash from a BK72xx over its adapter into TAG.bin (whole chip by default)
|
||||
# env: PORT (adapter), FAMILY (ltchiptool family, default beken-7238), FLASH_BK (path to flash_bk72xx.py),
|
||||
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), SIZE (whole chip bytes, default
|
||||
# 2097152). A whole 2 MB chip takes about 190 s. The read must come back at the expected size.
|
||||
T=${1:?tag}; P=${PORT:?set PORT}; FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
|
||||
ARGS=(); [ -n "$2" ] && ARGS=(-s "$2" -l "${3:?length}")
|
||||
WANT=$(( ${3:-${SIZE:-2097152}} ))
|
||||
: > "$T.log"
|
||||
ok=
|
||||
for i in 1 2 3; do
|
||||
echo "### try $i" >> "$T.log"
|
||||
rm -f "$T.bin"
|
||||
timeout 900 "$LT" "$FB" flash read -d "$P" "${ARGS[@]}" "${FAMILY:-beken-7238}" "$T.bin" >> "$T.log" 2>&1 &&
|
||||
[ "$(wc -c < "$T.bin" | tr -d ' ')" = "$WANT" ] && ok=1 && break
|
||||
sleep 3
|
||||
done
|
||||
[ -n "$ok" ] || { echo "FAIL: read $T (expected $WANT bytes, see $T.log)"; exit 1; }
|
||||
shasum -a 256 "$T.bin"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Add to a copy of a bluetooth_proxy config: packages: {cp_inject: !include cp-inject.yaml}
|
||||
esphome:
|
||||
includes:
|
||||
- cp_inject.hpp
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble_tracker: DEBUG
|
||||
bluetooth_proxy: DEBUG
|
||||
bluetooth_connection: DEBUG
|
||||
api: DEBUG
|
||||
api.service: DEBUG
|
||||
api.connection: DEBUG
|
||||
api.socket: DEBUG
|
||||
scheduler: DEBUG
|
||||
sensor: DEBUG
|
||||
component: DEBUG
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Inject conn param events
|
||||
# The handlers push into a queue that takes one writer only, the Bluetooth task. Stop the scan (and keep the
|
||||
# proxy free of real connections) so nothing else writes while the button does.
|
||||
on_press:
|
||||
- esp32_ble_tracker.stop_scan:
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: return !esp32_ble_tracker::global_esp32_ble_tracker->scan_running();
|
||||
timeout: 5s
|
||||
- delay: 1s
|
||||
- lambda: |-
|
||||
if (esp32_ble_tracker::global_esp32_ble_tracker->scan_running()) {
|
||||
ESP_LOGE("cpinject", "scan still running after 5 s, nothing injected");
|
||||
return;
|
||||
}
|
||||
cpinject::run();
|
||||
- esp32_ble_tracker.start_scan:
|
||||
continuous: true
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
// Feeds fabricated conn param update and disconnect events into ESP32BLE's static handlers.
|
||||
// The event queue takes a single writer: call run() only while scanning is stopped and no connection is active.
|
||||
#include <cstring>
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace cpinject {
|
||||
using GapFn = void (*)(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *);
|
||||
using GattcFn = void (*)(esp_gattc_cb_event_t, esp_gatt_if_t, esp_ble_gattc_cb_param_t *);
|
||||
GapFn steal_gap();
|
||||
GattcFn steal_gattc();
|
||||
// Explicit instantiation ignores access checks, which exposes the protected static handlers.
|
||||
template<GapFn F> struct GapThief {
|
||||
friend GapFn steal_gap() { return F; }
|
||||
};
|
||||
template<GattcFn F> struct GattcThief {
|
||||
friend GattcFn steal_gattc() { return F; }
|
||||
};
|
||||
template struct GapThief<&esphome::esp32_ble::ESP32BLE::gap_event_handler>;
|
||||
template struct GattcThief<&esphome::esp32_ble::ESP32BLE::gattc_event_handler>;
|
||||
|
||||
static const char *const TAG = "cpinject";
|
||||
|
||||
inline void peer(uint8_t n, uint8_t *bda) {
|
||||
const uint8_t base[6] = {0xC0, 0xFF, 0xEE, 0x00, 0x00, 0x00};
|
||||
memcpy(bda, base, 6);
|
||||
bda[5] = n;
|
||||
}
|
||||
|
||||
inline void update(uint8_t n, int status, int times) {
|
||||
for (int i = 0; i < times; i++) {
|
||||
esp_ble_gap_cb_param_t p{};
|
||||
peer(n, p.update_conn_params.bda);
|
||||
p.update_conn_params.status = (esp_bt_status_t) status;
|
||||
p.update_conn_params.conn_int = 24;
|
||||
p.update_conn_params.timeout = 400;
|
||||
steal_gap()(ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT, &p);
|
||||
}
|
||||
}
|
||||
|
||||
inline void disconnect(uint8_t n) {
|
||||
esp_ble_gattc_cb_param_t p{};
|
||||
peer(n, p.disconnect.remote_bda);
|
||||
p.disconnect.reason = ESP_GATT_CONN_TERMINATE_PEER_USER;
|
||||
p.disconnect.conn_id = 0xEE;
|
||||
// 0xFE matches no registered client, so the queued event is ignored by every connection.
|
||||
steal_gattc()(ESP_GATTC_DISCONNECT_EVT, 0xFE, &p);
|
||||
}
|
||||
|
||||
inline void run() {
|
||||
ESP_LOGI(TAG, "STEP1 peers 1-4 fail 5x each (3 slots)");
|
||||
for (uint8_t n = 1; n <= 4; n++)
|
||||
update(n, 6, 5);
|
||||
ESP_LOGI(TAG, "STEP2 peer 1 succeeds, then fails 3x");
|
||||
update(1, 0, 1);
|
||||
update(1, 6, 3);
|
||||
ESP_LOGI(TAG, "STEP3 peer 2 disconnects, then fails 3x");
|
||||
disconnect(2);
|
||||
update(2, 6, 3);
|
||||
ESP_LOGI(TAG, "STEP4 peer 4 fails 3x, slots still full");
|
||||
update(4, 6, 3);
|
||||
ESP_LOGI(TAG, "STEP5 peer 3 disconnects, peer 4 fails 3x");
|
||||
disconnect(3);
|
||||
update(4, 6, 3);
|
||||
ESP_LOGI(TAG, "STEP6 done");
|
||||
}
|
||||
} // namespace cpinject
|
||||
@@ -0,0 +1,155 @@
|
||||
# Waveshare ESP32-S3 (any ESP32 with BLE) as the central. run.sh passes name, io, auth, idf and server (the
|
||||
# server's BLE MAC from its esp32_ble dump) with -s. ble_client never pairs by itself: the Pair button calls pair().
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: ${idf}
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble_tracker: DEBUG
|
||||
api: DEBUG
|
||||
api.connection: DEBUG
|
||||
api.service: DEBUG
|
||||
api.socket: DEBUG
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
wifi: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble:
|
||||
io_capability: ${io}
|
||||
auth_req_mode: ${auth}
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
ble_client:
|
||||
- mac_address: ${server}
|
||||
id: cli
|
||||
on_connect:
|
||||
- logger.log: "CLI connected"
|
||||
on_disconnect:
|
||||
- logger.log: "CLI disconnected"
|
||||
on_numeric_comparison_request:
|
||||
- logger.log:
|
||||
format: "CLI numeric comparison %06u"
|
||||
args: ["(unsigned) passkey"]
|
||||
- ble_client.numeric_comparison_reply:
|
||||
id: cli
|
||||
accept: true
|
||||
|
||||
text_sensor:
|
||||
- platform: ble_client
|
||||
ble_client_id: cli
|
||||
id: secure_value
|
||||
name: Secure value
|
||||
service_uuid: "180F"
|
||||
characteristic_uuid: "2A19"
|
||||
update_interval: never
|
||||
on_value:
|
||||
- logger.log:
|
||||
format: "CLI secure read: %s"
|
||||
args: ["x.c_str()"]
|
||||
- platform: ble_client
|
||||
ble_client_id: cli
|
||||
id: open_value
|
||||
name: Open value
|
||||
service_uuid: 5ec50000-0000-4000-8000-00000000f000
|
||||
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f001
|
||||
update_interval: never
|
||||
on_value:
|
||||
- logger.log:
|
||||
format: "CLI open read: %s"
|
||||
args: ["x.c_str()"]
|
||||
|
||||
- platform: ble_client
|
||||
ble_client_id: cli
|
||||
id: mitm_value
|
||||
name: Mitm value
|
||||
service_uuid: 5ec50000-0000-4000-8000-00000000f000
|
||||
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f004
|
||||
update_interval: never
|
||||
on_value:
|
||||
- logger.log:
|
||||
format: "CLI mitm read: %s"
|
||||
args: ["x.c_str()"]
|
||||
- platform: ble_client
|
||||
ble_client_id: cli
|
||||
id: notify_value
|
||||
name: Notify value
|
||||
service_uuid: 5ec50000-0000-4000-8000-00000000f000
|
||||
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f003
|
||||
notify: true
|
||||
on_value:
|
||||
- logger.log:
|
||||
format: "CLI notification: %s"
|
||||
args: ["x.c_str()"]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Subscribe
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// ble_client drops its service list after discovery; ESP-IDF's GATT server puts the CCCD right after
|
||||
// the characteristic value, so write handle + 1
|
||||
uint16_t chr = id(notify_value)->handle;
|
||||
if (chr == 0) { ESP_LOGW("CLI", "notify characteristic handle unknown"); return; }
|
||||
esp_ble_gattc_register_for_notify(id(cli)->get_gattc_if(), id(cli)->get_remote_bda(), chr);
|
||||
uint8_t on[2] = {1, 0};
|
||||
auto err = esp_ble_gattc_write_char_descr(id(cli)->get_gattc_if(), id(cli)->get_conn_id(), chr + 1, 2, on,
|
||||
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
ESP_LOGI("CLI", "CCCD write to handle %u (char %u) sent, err %d", chr + 1, chr, err);
|
||||
- platform: template
|
||||
name: Pair
|
||||
on_press:
|
||||
- lambda: 'ESP_LOGI("CLI", "pair() returned %d", (int) id(cli)->pair());'
|
||||
- platform: template
|
||||
name: Read secure
|
||||
on_press:
|
||||
- component.update: secure_value
|
||||
- platform: template
|
||||
name: Read mitm
|
||||
on_press:
|
||||
- component.update: mitm_value
|
||||
- platform: template
|
||||
name: Read open
|
||||
on_press:
|
||||
- component.update: open_value
|
||||
- platform: template
|
||||
name: Write secure
|
||||
on_press:
|
||||
- ble_client.ble_write:
|
||||
id: cli
|
||||
service_uuid: 5ec50000-0000-4000-8000-00000000f000
|
||||
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f002
|
||||
value: [0x68, 0x65, 0x6C, 0x6C, 0x6F]
|
||||
- platform: template
|
||||
name: Disconnect
|
||||
on_press:
|
||||
- ble_client.disconnect: cli
|
||||
- platform: template
|
||||
name: Connect
|
||||
on_press:
|
||||
- ble_client.connect: cli
|
||||
- platform: template
|
||||
name: Remove bond
|
||||
on_press:
|
||||
- ble_client.remove_bond:
|
||||
id: cli
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Press the client's buttons in order and log every client line about BLE security.
|
||||
|
||||
usage: drive.py CLIENT_HOST OUT STEP... STEP: a button name, or wait:SECONDS env: API_KEY
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo, LogLevel
|
||||
|
||||
HOST, OUT, STEPS = sys.argv[1], sys.argv[2], sys.argv[3:]
|
||||
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
|
||||
PATTERN = re.compile(
|
||||
r"CLI|ble_client|ble_text_sensor|esp32_ble_client|[Aa]uth|[Ee]ncrypt|[Bb]ond|Error|status=|pair"
|
||||
)
|
||||
|
||||
|
||||
def note(s: str) -> None:
|
||||
line = f"{time.strftime('%H:%M:%S')} {s}"
|
||||
print(line)
|
||||
LOG.write(line + "\n")
|
||||
LOG.flush()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
|
||||
for attempt in range(40):
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
break
|
||||
except Exception as err: # noqa: BLE001
|
||||
last_err = err
|
||||
note(f"connect attempt {attempt + 1} failed: {err!r}")
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
raise last_err
|
||||
ents, _ = await c.list_entities_services()
|
||||
btn = {e.name: e.key for e in ents if isinstance(e, ButtonInfo)}
|
||||
|
||||
def on_log(m) -> None:
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if PATTERN.search(t):
|
||||
note("LOG " + t.strip().replace("\n", " | "))
|
||||
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
for step in STEPS:
|
||||
if step.startswith("wait:"):
|
||||
await asyncio.sleep(float(step[5:]))
|
||||
continue
|
||||
note(f"PRESS {step}")
|
||||
c.button_command(btn[step])
|
||||
await asyncio.sleep(4)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh TAG IO AUTH build and flash the server (serial) and the client (OTA), then pair once and reconnect twice
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password) and run it there.
|
||||
# IO/AUTH: none bond (Just Works) or display_yes_no sc_mitm_bond (numeric comparison, MITM).
|
||||
# env: TREE (esphome checkout for the client), SERVER_TREE (checkout with esp32_ble_server security, #17562 or
|
||||
# later; default TREE), SERVER_PORT (devkit serial port), CLIENT_HOST (client address), SERVER_BLE_MAC (from the
|
||||
# server's esp32_ble dump), SC (path to serial_capture.py), IDF (default recommended), PY (default $TREE/venv/bin/python)
|
||||
# A server keeps its bonds in NVS and refuses a weaker re-pairing: erase its flash before switching from nc to jw.
|
||||
D=$(cd "$(dirname "$0")" && pwd); G=${1:?tag}; IO=${2:?io}; AUTH=${3:?auth}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
P=${SERVER_PORT:?set SERVER_PORT}; C=${CLIENT_HOST:?set CLIENT_HOST}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}; SC=${SC:?set SC}
|
||||
E=(-s io "$IO" -s auth "$AUTH" -s idf "${IDF:-recommended}")
|
||||
ST=${SERVER_TREE:-$T}
|
||||
build() { local t=$1; shift; (cd "$t" && PYTHONUNBUFFERED=1 timeout 1800 $PY -m esphome "$@"); }
|
||||
ok=
|
||||
for i in 1 2 3; do
|
||||
build "$ST" -s name "bss-$G" "${E[@]}" run --no-logs "$D/server.yaml" --device "$P" > "$D/server-$G-upload.log" 2>&1 && ok=1 && break
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$ok" ] || { echo "FAIL: server build or flash (does SERVER_TREE have #17562?)"; exit 1; }
|
||||
build "$T" -s name "bsc-$G" "${E[@]}" -s server "$MAC" run --no-logs "$D/client.yaml" --device "$C" > "$D/client-$G-upload.log" 2>&1 ||
|
||||
{ echo "FAIL: client build or upload"; exit 1; }
|
||||
rm -f "$D/server-$G.txt"
|
||||
$PY "$SC" "$P" 330 "$D/server-$G.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
sleep 25
|
||||
R=("Disconnect" "wait:3" "Connect" "wait:10" "Pair" "wait:5" "Read secure" "Read mitm")
|
||||
$PY "$D/drive.py" "$C" "$D/client-$G.txt" "wait:6" "Remove bond" "Disconnect" "wait:3" "Connect" "wait:10" \
|
||||
"Read secure" "Read mitm" "Write secure" "Subscribe" "wait:8" "Pair" "wait:6" "Read secure" "Read mitm" "Write secure" \
|
||||
"Subscribe" "wait:10" "${R[@]}" "${R[@]}" > /dev/null || { echo "FAIL: driver"; exit 1; }
|
||||
wait $CAP; trap - EXIT
|
||||
MODE=jw; [ "$IO" = none ] || MODE=nc
|
||||
$PY "$D/summary.py" "$MODE" "$D/client-$G.txt" "$D/server-$G.txt"
|
||||
@@ -0,0 +1,85 @@
|
||||
# Classic ESP32 devkit as the secure GATT server. run.sh passes name, io (none or display_yes_no),
|
||||
# auth (bond or sc_mitm_bond) and idf (recommended, or a version such as 5.5.5) with -s.
|
||||
# Needs esp32_ble_server security from #17562 (read_encrypt, numeric comparison); build it with SERVER_TREE.
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: ${idf}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble:
|
||||
io_capability: ${io}
|
||||
auth_req_mode: ${auth}
|
||||
|
||||
esp32_ble_server:
|
||||
id: srv
|
||||
on_connect:
|
||||
- logger.log: "SRV client connected"
|
||||
on_disconnect:
|
||||
- logger.log: "SRV client disconnected"
|
||||
on_numeric_comparison_request:
|
||||
- logger.log:
|
||||
format: "SRV numeric comparison %06u"
|
||||
args: ["(unsigned) passkey"]
|
||||
- ble_server.numeric_comparison_reply:
|
||||
id: srv
|
||||
address: !lambda return address;
|
||||
accept: true
|
||||
services:
|
||||
- uuid: "180F"
|
||||
characteristics:
|
||||
- uuid: "2A19"
|
||||
read: true
|
||||
read_encrypt: true
|
||||
value:
|
||||
data: "99.9"
|
||||
- uuid: 5ec50000-0000-4000-8000-00000000f000
|
||||
characteristics:
|
||||
- uuid: 5ec50000-0000-4000-8000-00000000f001
|
||||
read: true
|
||||
value:
|
||||
data: "open"
|
||||
- uuid: 5ec50000-0000-4000-8000-00000000f002
|
||||
write: true
|
||||
write_encrypt: true
|
||||
on_write:
|
||||
- lambda: 'ESP_LOGI("SRV", "secure write received: %s", std::string(x.begin(), x.end()).c_str());'
|
||||
- uuid: 5ec50000-0000-4000-8000-00000000f004
|
||||
read: true
|
||||
read_encrypt_mitm: true
|
||||
value:
|
||||
data: "mitm"
|
||||
- uuid: 5ec50000-0000-4000-8000-00000000f003
|
||||
id: secure_notify
|
||||
read: true
|
||||
read_encrypt: true
|
||||
notify: true
|
||||
value:
|
||||
data: "n0"
|
||||
|
||||
interval:
|
||||
- interval: 2s
|
||||
then:
|
||||
- lambda: |-
|
||||
static int n = 0;
|
||||
char buf[12];
|
||||
snprintf(buf, sizeof(buf), "n%d", ++n);
|
||||
id(secure_notify)->set_value((const uint8_t *) buf, strlen(buf));
|
||||
- ble_server.characteristic.notify:
|
||||
id: secure_notify
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Judge one run.sh run from the client log (drive.py output) and the server's serial capture.
|
||||
|
||||
usage: summary.py MODE CLIENT_LOG SERVER_CAPTURE MODE: jw (Just Works) or nc (numeric comparison, MITM)
|
||||
Before the first Pair: the encrypted and MITM reads must each be tried and refused (an `Error reading char` reply),
|
||||
no notification may arrive and no encrypted write may reach the server. After it: the encrypted read returns 99.9,
|
||||
the write reaches the server, the second Subscribe brings notifications, and the MITM-only characteristic is readable
|
||||
only after an nc pairing. Prints the reconnect pattern and PASS or FAIL.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
MODE = sys.argv[1]
|
||||
cli = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").splitlines()
|
||||
srv = re.sub(
|
||||
r"\x1b\[[0-9;]*m",
|
||||
"",
|
||||
pathlib.Path(sys.argv[3]).read_bytes().decode(errors="replace"),
|
||||
)
|
||||
pair_at = next((i for i, line in enumerate(cli) if "PRESS Pair" in line), None)
|
||||
if pair_at is None:
|
||||
sys.exit("FAIL: no Pair step in the client log")
|
||||
before, after = cli[:pair_at], cli[pair_at:]
|
||||
subs = [i for i, line in enumerate(after) if "PRESS Subscribe" in line]
|
||||
after_sub = after[subs[0] :] if subs else []
|
||||
|
||||
|
||||
def count(lines: list[str], pat: str) -> int:
|
||||
return sum(1 for line in lines if re.search(pat, line))
|
||||
|
||||
|
||||
def outcome(lines: list[str], button: str, ok_pat: str) -> str:
|
||||
"""'ok', 'refused' or 'none' for the first press of BUTTON.
|
||||
|
||||
The window runs to the next read, pair or connection step, since a reply can come after a write press. A value
|
||||
wins over an error line: the error can belong to another characteristic read in the same window.
|
||||
"""
|
||||
start = next((i for i, line in enumerate(lines) if f"PRESS {button}" in line), None)
|
||||
if start is None:
|
||||
return "none"
|
||||
window = []
|
||||
for line in lines[start + 1 :]:
|
||||
if re.search(r"PRESS (Read|Pair|Disconnect|Connect|Subscribe|Remove)", line):
|
||||
break
|
||||
window.append(line)
|
||||
if any(re.search(ok_pat, line) for line in window):
|
||||
return "ok"
|
||||
return "refused" if any("Error reading char" in line for line in window) else "none"
|
||||
|
||||
|
||||
secure_before = outcome(before, "Read secure", r"secure read: 9")
|
||||
mitm_before = outcome(before, "Read mitm", r"mitm read: m")
|
||||
mitm_after = outcome(after, "Read mitm", r"mitm read: m")
|
||||
checks = {
|
||||
f"encrypted read refused before pairing ({secure_before})": secure_before
|
||||
== "refused",
|
||||
f"MITM read refused before pairing ({mitm_before})": mitm_before == "refused",
|
||||
"no notifications before pairing": count(before, r"notification: n") == 0,
|
||||
"encrypted read after pairing": outcome(after, "Read secure", r"secure read: 99\.9")
|
||||
== "ok",
|
||||
"encrypted write reached the server": "secure write received" in srv,
|
||||
"notifications after subscribing paired": count(after_sub, r"notification: n") > 0,
|
||||
}
|
||||
if MODE == "nc":
|
||||
checks[f"MITM read after nc pairing ({mitm_after})"] = mitm_after == "ok"
|
||||
else:
|
||||
checks[f"MITM read still refused after Just Works ({mitm_after})"] = (
|
||||
mitm_after == "refused"
|
||||
)
|
||||
for name, ok in checks.items():
|
||||
print(f"{'ok ' if ok else 'FAIL'} {name}")
|
||||
print(
|
||||
f"server: {srv.count('Authentication complete')} authentications, {srv.count('SRV numeric comparison')} numeric "
|
||||
f"comparisons, {srv.count('Deleting unpaired device')} bonds dropped by the stack"
|
||||
)
|
||||
print("PASS" if all(checks.values()) else "FAIL")
|
||||
sys.exit(0 if all(checks.values()) else 1)
|
||||
@@ -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,45 @@
|
||||
# Classic ESP32 devkit: RTC pins 25 26 27 32 33 and digital pins 19 21 22 23, three rounds 5 s after boot.
|
||||
esphome:
|
||||
name: gpio-hold-esp32
|
||||
includes:
|
||||
- hold_test.hpp
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 5s
|
||||
- lambda: |-
|
||||
static const int pins[] = {25, 26, 27, 32, 33, 19, 21, 22, 23};
|
||||
for (int r = 1; r <= 3; r++) holdtest::run(pins, sizeof(pins) / sizeof(pins[0]), r);
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
# Turns on USE_GPIO_HOLD.
|
||||
output:
|
||||
- platform: gpio
|
||||
id: held_out
|
||||
pin:
|
||||
number: GPIO4
|
||||
hold_state: true
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: rerun
|
||||
on_press:
|
||||
- lambda: |-
|
||||
static const int pins[] = {25, 26, 27, 32, 33, 19, 21, 22, 23};
|
||||
holdtest::run(pins, sizeof(pins) / sizeof(pins[0]), 9);
|
||||
@@ -0,0 +1,41 @@
|
||||
# Waveshare ESP32-S3-ETH: RTC pins 16 17 and digital pin 43 are free; a round every 15 s.
|
||||
esphome:
|
||||
name: gpio-hold-esp32s3
|
||||
includes:
|
||||
- hold_test.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
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
|
||||
|
||||
# Turns on USE_GPIO_HOLD.
|
||||
output:
|
||||
- platform: gpio
|
||||
id: held_out
|
||||
pin:
|
||||
number: GPIO44
|
||||
hold_state: true
|
||||
|
||||
interval:
|
||||
- interval: 15s
|
||||
then:
|
||||
- lambda: |-
|
||||
static int round = 0;
|
||||
static const int pins[] = {16, 17, 43};
|
||||
holdtest::run(pins, sizeof(pins) / sizeof(pins[0]), ++round);
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
#include "esphome/components/esp32/gpio.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// Drives held pads through the ISR pin API and reads the pad back.
|
||||
namespace holdtest {
|
||||
using esphome::esp32::ESP32InternalGPIOPin;
|
||||
namespace gpio = esphome::gpio;
|
||||
static const char *const TAG = "holdtest";
|
||||
|
||||
// One pin and one ISR argument block per GPIO and hold setting, made on first use and reused by later rounds,
|
||||
// so a test that repeats forever does not use up the heap.
|
||||
struct PinPair {
|
||||
ESP32InternalGPIOPin *pin{nullptr};
|
||||
esphome::ISRInternalGPIOPin isr;
|
||||
};
|
||||
static PinPair pairs[2][GPIO_NUM_MAX];
|
||||
|
||||
static PinPair &get(int num, bool hold) {
|
||||
PinPair &pp = pairs[hold][num];
|
||||
if (pp.pin == nullptr) {
|
||||
pp.pin = new ESP32InternalGPIOPin(); // NOLINT
|
||||
pp.pin->set_pin(static_cast<gpio_num_t>(num));
|
||||
pp.pin->set_flags(gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | (hold ? gpio::FLAG_HOLD : gpio::FLAG_NONE));
|
||||
pp.isr = pp.pin->to_isr();
|
||||
}
|
||||
pp.pin->setup(); // the previous round left the pad unheld with no mode
|
||||
return pp;
|
||||
}
|
||||
|
||||
static int lvl(int num) {
|
||||
esphome::delayMicroseconds(50);
|
||||
return gpio_get_level(static_cast<gpio_num_t>(num));
|
||||
}
|
||||
|
||||
// Returns failures for one pin.
|
||||
static int run_pin(int num, bool hold) {
|
||||
int fail = 0;
|
||||
auto &isr = get(num, hold).isr;
|
||||
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_OUTPUT);
|
||||
int wr_ok = 0;
|
||||
for (int i = 0; i < 20; i++) {
|
||||
bool v = i & 1;
|
||||
isr.digital_write(v);
|
||||
if (lvl(num) == (int) v) {
|
||||
wr_ok++;
|
||||
} else {
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
// Control: a raw register write without a hold refresh must not reach a held pad.
|
||||
int before = lvl(num);
|
||||
gpio_set_level(static_cast<gpio_num_t>(num), !before);
|
||||
int ctrl = lvl(num);
|
||||
bool ctrl_ok = hold ? (ctrl == before) : (ctrl != before);
|
||||
if (!ctrl_ok)
|
||||
fail++;
|
||||
isr.digital_write(before); // back in sync
|
||||
|
||||
// Pulls through the ISR pin_mode: input must toggle in the flags diff to apply them.
|
||||
int pd1, pu, pd2;
|
||||
isr.pin_mode(gpio::FLAG_NONE);
|
||||
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN);
|
||||
pd1 = lvl(num);
|
||||
isr.pin_mode(gpio::FLAG_NONE);
|
||||
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP);
|
||||
pu = lvl(num);
|
||||
isr.pin_mode(gpio::FLAG_NONE);
|
||||
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN);
|
||||
pd2 = lvl(num);
|
||||
if (pd1 != 0 || pu != 1 || pd2 != 0)
|
||||
fail++;
|
||||
ESP_LOGI(TAG, "GPIO%d hold=%d rtc=%d write %d/20 control %s pulls pd=%d pu=%d pd=%d -> %s", num, hold,
|
||||
(int) rtc_gpio_is_valid_gpio(static_cast<gpio_num_t>(num)), wr_ok, ctrl_ok ? "ok" : "BAD", pd1, pu, pd2,
|
||||
fail ? "FAIL" : "PASS");
|
||||
// Leave the pad free for the next run.
|
||||
if (hold)
|
||||
gpio_hold_dis(static_cast<gpio_num_t>(num));
|
||||
isr.pin_mode(gpio::FLAG_NONE);
|
||||
return fail;
|
||||
}
|
||||
|
||||
static void run(const int *pins, int n, int round) {
|
||||
int fail = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
fail += run_pin(pins[i], true);
|
||||
fail += run_pin(pins[i], false);
|
||||
}
|
||||
ESP_LOGI(TAG, "ROUND %d %s failures=%d", round, fail ? "FAIL" : "PASS", fail);
|
||||
}
|
||||
} // namespace holdtest
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Run the burst action once and print each queued request as it completes.
|
||||
Usage: burstonly.py IP URL N"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, URL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
t0 = time.monotonic()
|
||||
done = []
|
||||
errors = []
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if "BURSTRESP status=200 " in t:
|
||||
done.append(t)
|
||||
elif "BURSTRESP" in t or "BURSTERR" in t:
|
||||
errors.append(t)
|
||||
if "hrt" in t or "Too many" in t:
|
||||
print(f"{time.monotonic() - t0:6.2f} {t[:120]}", flush=True)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
|
||||
await asyncio.sleep(1)
|
||||
t0 = time.monotonic()
|
||||
await c.execute_service(s["burst"], {"url": URL, "count": N})
|
||||
await asyncio.sleep(14)
|
||||
await c.disconnect()
|
||||
print(
|
||||
f"{len(done)} of {N} burst requests answered, {len(errors)} failed", flush=True
|
||||
)
|
||||
return 0 if len(done) >= N else 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Drive the http_request action test device over the API and collect its hrt log lines.
|
||||
Usage: hrdrive.py DEVICE_IP SERVER_BASE LABEL [https]"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, BASE, LABEL = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
HTTPS = len(sys.argv) > 4
|
||||
CASES = [
|
||||
("get", "fast", BASE + "/fast"),
|
||||
("get", "slow2s", BASE + "/slow?d=2"),
|
||||
("get", "big70k", BASE + "/big?n=70000&rate=20000"),
|
||||
("get", "chunked70k", BASE + "/chunked?n=70000&rate=20000"),
|
||||
("post", "post500", BASE + "/post"),
|
||||
("get", "redirect", BASE + "/redirect"),
|
||||
("get", "hang", BASE + "/hang?d=20"),
|
||||
("get", "unreachable", "http://192.0.2.1:81/"),
|
||||
]
|
||||
if HTTPS:
|
||||
CASES += [
|
||||
("get", "https_get", "https://www.howsmyssl.com/a/check"),
|
||||
("post", "https_post", "https://httpbin.org/post"),
|
||||
]
|
||||
|
||||
ONLY = os.environ.get("ONLY")
|
||||
if ONLY:
|
||||
CASES = [c for c in CASES if c[1] in ONLY.split(",")]
|
||||
ROUNDS = int(os.environ.get("ROUNDS", "2"))
|
||||
# cases whose request must fail (past the 8 s timeout, or an address that never answers)
|
||||
EXPECT_ERROR = {"hang", "unreachable"}
|
||||
EXTRAS = not ONLY
|
||||
|
||||
|
||||
timeouts: list[str] = []
|
||||
mismatches: list[str] = []
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
_, services = await c.list_entities_services()
|
||||
svc = {s.name: s for s in services}
|
||||
lines = []
|
||||
|
||||
def on_log(m):
|
||||
t = (
|
||||
m.message.decode(errors="replace")
|
||||
if isinstance(m.message, bytes)
|
||||
else m.message
|
||||
)
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
|
||||
if (
|
||||
"[hrt" in t
|
||||
or "http_request" in t
|
||||
or "Script" in t
|
||||
or "already running" in t
|
||||
):
|
||||
lines.append(t)
|
||||
print(f"{LABEL} {t}", flush=True)
|
||||
|
||||
def bad_burst(ln: str) -> bool:
|
||||
return "BURSTERR" in ln or ("BURSTRESP" in ln and "status=200 " not in ln)
|
||||
|
||||
def mismatch(what: str, why: str) -> None:
|
||||
print(f"{LABEL} {what} MISMATCH: {why}", flush=True)
|
||||
mismatches.append(what)
|
||||
|
||||
async def wait_until(done, secs: float, what: str) -> None:
|
||||
"""Poll done() until it holds; print a TIMEOUT marker when the deadline passes first."""
|
||||
end = time.monotonic() + secs
|
||||
while not done():
|
||||
if time.monotonic() >= end:
|
||||
print(f"{LABEL} {what} TIMEOUT", flush=True)
|
||||
timeouts.append(what)
|
||||
return
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1)
|
||||
for rnd in range(1, ROUNDS + 1):
|
||||
for kind, name, url in CASES:
|
||||
n0 = len(lines)
|
||||
print(f"{LABEL} == {name} round {rnd}", flush=True)
|
||||
if kind == "get":
|
||||
await c.execute_service(svc["req_get"], {"url": url})
|
||||
else:
|
||||
await c.execute_service(
|
||||
svc["req_post"], {"url": url, "payload": "x" * 500}
|
||||
)
|
||||
await wait_until(
|
||||
lambda n0=n0: any("RESULT" in ln for ln in lines[n0:]),
|
||||
40,
|
||||
f"{name} round {rnd}",
|
||||
)
|
||||
result = next((ln for ln in lines[n0:] if "RESULT" in ln), None)
|
||||
if result is not None and (
|
||||
("status=200 " not in result)
|
||||
if name not in EXPECT_ERROR
|
||||
else (" error " not in result)
|
||||
):
|
||||
mismatch(f"{name} round {rnd}", result)
|
||||
await asyncio.sleep(1.5)
|
||||
if not EXTRAS:
|
||||
continue
|
||||
print(f"{LABEL} == burst round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["burst"], {"url": BASE + "/slow?d=1", "count": 6})
|
||||
await wait_until(
|
||||
lambda n0=n0: (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 6
|
||||
),
|
||||
30,
|
||||
f"burst round {rnd}",
|
||||
)
|
||||
if any(bad_burst(ln) for ln in lines[n0:]):
|
||||
mismatch(f"burst round {rnd}", "a burst request failed or was not 200")
|
||||
await asyncio.sleep(2)
|
||||
print(f"{LABEL} == cancel round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["cancel"], {"url": BASE + "/slow?d=2"})
|
||||
await asyncio.sleep(9)
|
||||
if not any("CANCEL stopped running=0" in ln for ln in lines[n0:]):
|
||||
mismatch(
|
||||
f"cancel round {rnd}", "script.stop did not leave the script stopped"
|
||||
)
|
||||
print(f"{LABEL} == cross round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["req_get"], {"url": BASE + "/slow?d=2&tag=cross"})
|
||||
await c.execute_service(
|
||||
svc["burst"], {"url": BASE + "/slow?d=1&tag=crossburst", "count": 3}
|
||||
)
|
||||
await wait_until(
|
||||
lambda n0=n0: (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 3
|
||||
and any("RESULT" in ln for ln in lines[n0:])
|
||||
),
|
||||
30,
|
||||
f"cross round {rnd}",
|
||||
)
|
||||
if any(
|
||||
bad_burst(ln) or ("RESULT" in ln and "status=200 " not in ln)
|
||||
for ln in lines[n0:]
|
||||
):
|
||||
mismatch(f"cross round {rnd}", "a request failed")
|
||||
await asyncio.sleep(2)
|
||||
await c.disconnect()
|
||||
if timeouts:
|
||||
print(f"{LABEL} TIMEOUTS: {', '.join(timeouts)}", flush=True)
|
||||
if mismatches:
|
||||
print(f"{LABEL} MISMATCHES: {', '.join(mismatches)}", flush=True)
|
||||
return 1 if timeouts or mismatches else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Test server for http_request actions: deterministic bodies, slow and chunked responses."""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
def body_bytes(n):
|
||||
return bytes((i * 7 + 3) & 0xFF for i in range(n))
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *a):
|
||||
sys.stdout.write(f"{time.time():.3f} {self.client_address[0]} {fmt % a}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def _q(self):
|
||||
u = urlparse(self.path)
|
||||
return u.path, {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
|
||||
def _send(self, data, code=200, ctype="application/octet-stream"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
got = self.rfile.read(n)
|
||||
self._send(
|
||||
b"posted %d %d" % (len(got), sum(got) & 0xFFFFFFFF), ctype="text/plain"
|
||||
)
|
||||
|
||||
def do_GET(self):
|
||||
path, q = self._q()
|
||||
if path == "/fast":
|
||||
self._send(b"hello", ctype="text/plain")
|
||||
elif path == "/slow":
|
||||
time.sleep(float(q.get("d", "2")))
|
||||
self._send(b"slow ok", ctype="text/plain")
|
||||
elif path == "/redirect":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/fast")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
elif path == "/hang":
|
||||
time.sleep(float(q.get("d", "30")))
|
||||
elif path in ("/big", "/chunked"):
|
||||
n = int(q.get("n", "70000"))
|
||||
rate = float(q.get("rate", "20000"))
|
||||
data = body_bytes(n)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
if path == "/big":
|
||||
self.send_header("Content-Length", str(n))
|
||||
else:
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
step = 1000
|
||||
for i in range(0, n, step):
|
||||
part = data[i : i + step]
|
||||
if path == "/big":
|
||||
self.wfile.write(part)
|
||||
else:
|
||||
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
|
||||
self.wfile.flush()
|
||||
time.sleep(len(part) / rate)
|
||||
if path == "/chunked":
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
else:
|
||||
self._send(b"nope", 404)
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", int(sys.argv[1])), H).serve_forever()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Serve update manifests: stable.json, beta.json and slow.json (answered after a delay); logs every request."""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
DELAY = float(sys.argv[2]) if len(sys.argv) > 2 else 4.0
|
||||
|
||||
|
||||
def manifest(name: str, version: str) -> bytes:
|
||||
builds = [
|
||||
{
|
||||
"chipFamily": fam,
|
||||
"ota": {
|
||||
"path": f"/{name}.bin",
|
||||
"md5": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"summary": f"{name} channel",
|
||||
},
|
||||
}
|
||||
for fam in ("ESP8266", "ESP32")
|
||||
]
|
||||
return json.dumps(
|
||||
{"name": f"Bench {name}", "version": version, "builds": builds}
|
||||
).encode()
|
||||
|
||||
|
||||
DOCS = {
|
||||
"/stable.json": manifest("stable", "1.0.0"),
|
||||
"/beta.json": manifest("beta", "2.0.0-beta1"),
|
||||
"/slow.json": manifest("slow", "3.0.0-slow"),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
body = DOCS.get(self.path)
|
||||
print(
|
||||
f"{time.strftime('%H:%M:%S')} {self.client_address[0]} GET {self.path} -> {404 if body is None else 200}",
|
||||
flush=True,
|
||||
)
|
||||
if body is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
if self.path == "/slow.json":
|
||||
time.sleep(DELAY)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args) -> None: # quiet default log
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: ho32base
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: ho66base
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Serve a www dir for ota.http_request tests; /auth/ needs basic auth otauser:otapass.
|
||||
Usage: fwsrv.py WWW PORT"""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import http.server
|
||||
import sys
|
||||
|
||||
WWW, PORT = sys.argv[1], int(sys.argv[2])
|
||||
GOOD = "Basic " + base64.b64encode(b"otauser:otapass").decode()
|
||||
|
||||
|
||||
class H(http.server.SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/auth/") and self.headers.get("Authorization") != GOOD:
|
||||
self.send_response(401)
|
||||
self.send_header("WWW-Authenticate", 'Basic realm="ota"')
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(
|
||||
f"{self.address_string()} auth={'Authorization' in self.headers} {fmt % args}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
http.server.ThreadingHTTPServer(
|
||||
("0.0.0.0", PORT), functools.partial(H, directory=WWW)
|
||||
).serve_forever()
|
||||
@@ -0,0 +1,60 @@
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: fl_md5url
|
||||
variables: {url: string, md5url: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5_url: !lambda return md5url;
|
||||
- action: fl_md5
|
||||
variables: {url: string, sum: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5: !lambda return sum;
|
||||
- action: fl_auth
|
||||
variables: {url: string, md5url: string, user: string, pw: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5_url: !lambda return md5url;
|
||||
username: !lambda return user;
|
||||
password: !lambda return pw;
|
||||
- action: fl_nourl
|
||||
then:
|
||||
- lambda: id(hota).flash();
|
||||
- action: fl_badurl
|
||||
then:
|
||||
- lambda: |-
|
||||
id(hota).set_url("not a url");
|
||||
id(hota).flash();
|
||||
- action: getters
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("hot", "getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
|
||||
id(hota).md5_expected().c_str());
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("hot", "boot getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
|
||||
id(hota).md5_expected().c_str());
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
- platform: http_request
|
||||
id: hota
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Drive the ota.http_request cases over the API.
|
||||
Usage: hodrive.py IP BASEURL MD5FILE (BASEURL serves fw.bin, fw.md5, bad.md5, auth/fw.bin, auth/fw.md5)"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
IP, BASE = sys.argv[1:3]
|
||||
SUM = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8").strip()
|
||||
T0 = time.monotonic()
|
||||
lines: list[str] = []
|
||||
failed: list[str] = []
|
||||
|
||||
|
||||
def out(s: str) -> None:
|
||||
print(f"{time.monotonic() - T0:7.2f} {s}", flush=True)
|
||||
|
||||
|
||||
async def connect() -> tuple[aioesphomeapi.APIClient, dict]:
|
||||
last: Exception | None = None
|
||||
for _ in range(90):
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
except (
|
||||
aioesphomeapi.InvalidAuthAPIError,
|
||||
aioesphomeapi.InvalidEncryptionKeyAPIError,
|
||||
aioesphomeapi.RequiresEncryptionAPIError,
|
||||
):
|
||||
raise # a key or password problem never fixes itself
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
i = await c.device_info()
|
||||
out(f"connected {i.name} {i.compilation_time}")
|
||||
_, svcs = await c.list_entities_services()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if re.search(r"\[hot|http_request|ota|OTA|MD5|md5", t):
|
||||
lines.append(t)
|
||||
out(t[:170])
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
return c, {x.name: x for x in svcs}
|
||||
raise SystemExit(f"device never came back (last error {last!r})")
|
||||
|
||||
|
||||
async def case(c, s, name, args, want, timeout=60.0):
|
||||
out(f"=== {name} {args} (want {want})")
|
||||
start = len(lines)
|
||||
await c.execute_service(s[name], args)
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if any(re.search(want, x) for x in lines[start:]):
|
||||
out(f"RESULT {name} PASS")
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
out(f"RESULT {name} FAIL (no '{want}')")
|
||||
failed.append(name)
|
||||
return False
|
||||
|
||||
|
||||
async def flash_ok(c, s, name, args):
|
||||
"""A good flash: wait for the device to drop and come back."""
|
||||
out(f"=== {name} {args} (want reboot)")
|
||||
start = len(lines)
|
||||
await c.execute_service(s[name], args)
|
||||
end = time.monotonic() + 120
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and c._connection is not None
|
||||
and c._connection.is_connected
|
||||
): # noqa: SLF001
|
||||
await asyncio.sleep(0.5)
|
||||
# a good flash needs both the device's own success line and the connection dropping for the reboot
|
||||
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
|
||||
logged = any(
|
||||
re.search(r"(?i)update complete|rebooting|successful", x) for x in lines[start:]
|
||||
)
|
||||
if not dropped:
|
||||
out(f"{name}: the device never dropped the connection within 120 s")
|
||||
ok = logged and dropped
|
||||
await c.disconnect()
|
||||
await asyncio.sleep(5)
|
||||
c, s = await connect()
|
||||
out(f"RESULT {name} {'PASS' if ok else 'FAIL'}")
|
||||
if not ok:
|
||||
failed.append(name)
|
||||
return c, s
|
||||
|
||||
|
||||
async def main():
|
||||
c, s = await connect()
|
||||
u, m = f"{BASE}/fw.bin", f"{BASE}/fw.md5"
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await case(c, s, "fl_nourl", {}, r"URL not set")
|
||||
await case(c, s, "fl_badurl", {}, r"URL not set")
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await case(c, s, "fl_md5", {"url": u, "sum": "0" * 32}, r"MD5 mismatch")
|
||||
await case(c, s, "getters", {}, r"getters computed=")
|
||||
await case(
|
||||
c,
|
||||
s,
|
||||
"fl_auth",
|
||||
{
|
||||
"url": f"{BASE}/auth/fw.bin",
|
||||
"md5url": f"{BASE}/auth/fw.md5",
|
||||
"user": "otauser",
|
||||
"pw": "wrong",
|
||||
},
|
||||
r"HTTP Request failed; URL: \S*/auth/fw\.md5; Code: 401",
|
||||
)
|
||||
c, s = await flash_ok(
|
||||
c,
|
||||
s,
|
||||
"fl_auth",
|
||||
{
|
||||
"url": f"{BASE}/auth/fw.bin",
|
||||
"md5url": f"{BASE}/auth/fw.md5",
|
||||
"user": "otauser",
|
||||
"pw": "otapass",
|
||||
},
|
||||
)
|
||||
c, s = await flash_ok(c, s, "fl_md5url", {"url": u, "md5url": m})
|
||||
c, s = await flash_ok(c, s, "fl_md5", {"url": u, "sum": SUM})
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await c.disconnect()
|
||||
out(
|
||||
f"SUMMARY {'all cases passed' if not failed else 'FAILED: ' + ', '.join(failed)}"
|
||||
)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: ho32pr
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: ho66pr
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# run.sh BUILD(pr|base) BOARD(32|66) : serial flash ho<BOARD><BUILD>, serve its own image, drive the cases
|
||||
# Copy this directory to a scratch directory (secrets.yaml next to ho-common.yaml) and run it there.
|
||||
# env: TREE (esphome checkout for BUILD), P32 or P66 (serial port of the board), SC (path to serial_capture.py),
|
||||
# HOST_IP (this host's address as the device reaches it), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); V=$1; B=$2; N=ho$B$V; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
SC=${SC:?set SC}; HOST=${HOST_IP:?set HOST_IP}
|
||||
if [ "$B" = 32 ]; then P=${P32:?set P32}; else P=${P66:?set P66}; fi
|
||||
OUT=$D/out-$V-$B; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome compile "$D/$V/$B/$N.yaml" > "$OUT/compile.log" 2>&1) || { echo "compile failed"; exit 1; }
|
||||
(cd "$T" && $PY -m esphome upload "$D/$V/$B/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "upload failed"; exit 1; }
|
||||
# the exact image of this config, just compiled, never one found elsewhere
|
||||
if [ "$B" = 32 ]; then IMG=$D/$V/$B/.esphome/build/$N/build/firmware.ota.bin
|
||||
else IMG=$D/$V/$B/.esphome/build/$N/.pioenvs/$N/firmware.bin; fi
|
||||
[ -f "$IMG" ] || { echo "no image for $N at $IMG"; exit 1; }
|
||||
echo "image $IMG"
|
||||
W=$OUT/www; mkdir -p "$W/auth"; cp "$IMG" "$W/fw.bin"; cp "$IMG" "$W/auth/fw.bin"
|
||||
$PY -c "import hashlib,sys;print(hashlib.md5(open(sys.argv[1],'rb').read()).hexdigest())" "$IMG" > "$W/fw.md5"
|
||||
cp "$W/fw.md5" "$W/auth/fw.md5"; echo 00000000000000000000000000000000 > "$W/bad.md5"
|
||||
$PY "$SC" "$P" 400 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP $SRV 2>/dev/null' EXIT
|
||||
pkill -f "[f]wsrv.py"; $PY "$D/fwsrv.py" "$W" 8093 > "$OUT/server.txt" 2>&1 & SRV=$!
|
||||
for i in $(seq 60); do IP=$(grep -a -o 'IP Address: [0-9.]*' "$OUT/serial.txt" | tail -1 | cut -d' ' -f3); [ -z "$IP" ] && IP=$(python3 -c "import socket;print(socket.gethostbyname('$N.local'))" 2>/dev/null); [ -n "$IP" ] && break; sleep 2; done
|
||||
[ -n "$IP" ] || { echo "device not found"; exit 1; }
|
||||
echo "device up"
|
||||
$PY "$D/hodrive.py" "$IP" "http://$HOST:8093" "$W/fw.md5" > "$OUT/drive.txt" 2>&1; RC=$?
|
||||
kill $SRV; pkill -f "[s]erial_capture.py $P"
|
||||
grep -E 'RESULT|SUMMARY|never came back' "$OUT/drive.txt"; echo "server requests: $(grep -c . "$OUT/server.txt")"
|
||||
exit $RC
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Trigger ota.http_request.flash over the API, optionally during a burst of action requests.
|
||||
Usage: otadrive.py IP FWURL MD5 BASE [burst]"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, FW, SUM, BASE = sys.argv[1:5]
|
||||
BURST = len(sys.argv) > 5
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
i = await c.device_info()
|
||||
print("before:", i.name, i.compilation_time, flush=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
t0 = time.monotonic()
|
||||
seen: list[str] = []
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
seen.append(t)
|
||||
if re.search(r"hrt|http_request|ota|OTA|Too many", t):
|
||||
print(f"{time.monotonic() - t0:7.2f} {t[:170]}", flush=True)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
|
||||
await asyncio.sleep(1)
|
||||
if BURST:
|
||||
await c.execute_service(s["burst"], {"url": BASE + "/slow?d=1", "count": 6})
|
||||
await asyncio.sleep(0.5)
|
||||
await c.execute_service(s["otaflash"], {"url": FW, "sum": SUM})
|
||||
end = time.monotonic() + 45
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and c._connection is not None # noqa: SLF001
|
||||
and c._connection.is_connected # noqa: SLF001
|
||||
):
|
||||
await asyncio.sleep(0.5)
|
||||
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
for _ in range(30):
|
||||
try:
|
||||
c2 = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c2.connect(login=True)
|
||||
i = await c2.device_info()
|
||||
print("after:", i.name, i.compilation_time, flush=True)
|
||||
await c2.disconnect()
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
print("RESULT FAIL: not reachable after the flash", flush=True)
|
||||
return 1
|
||||
if not dropped:
|
||||
print("RESULT FAIL: the device never rebooted", flush=True)
|
||||
return 1
|
||||
if any("OTAFLASH returned" in t for t in seen):
|
||||
print("RESULT FAIL: the flash action returned, the update failed", flush=True)
|
||||
return 1
|
||||
if not any(re.search(r"(?i)update complete|rebooting|successful", t) for t in seen):
|
||||
print("RESULT FAIL: no update complete line before the reboot", flush=True)
|
||||
return 1
|
||||
print("RESULT PASS", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Call reboot_during (a slow request in flight, another waiting, then App.safe_reboot) and time the reboot.
|
||||
Usage: reboottest.py IP URL"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, URL = sys.argv[1], sys.argv[2]
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
t0 = time.monotonic()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if re.search(
|
||||
r"hrt|http_request|safe_mode|shutdown|Rebooting|reboot", t, re.IGNORECASE
|
||||
):
|
||||
print(f"{time.monotonic() - t0:7.2f} {t[:150]}", flush=True)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1)
|
||||
t0 = time.monotonic()
|
||||
await c.execute_service(s["reboot_during"], {"url": URL})
|
||||
dropped = False
|
||||
while time.monotonic() - t0 < 30:
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 1.0)
|
||||
await asyncio.sleep(0.2)
|
||||
except TimeoutError:
|
||||
continue # a slow reply is not a reboot; only a closed connection counts
|
||||
except Exception: # noqa: BLE001
|
||||
dropped = True
|
||||
print(f"{time.monotonic() - t0:7.2f} API connection gone", flush=True)
|
||||
break
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
if not dropped:
|
||||
print(f"{time.monotonic() - t0:7.2f} NO REBOOT within 30 s", flush=True)
|
||||
return 1
|
||||
last: Exception | None = None
|
||||
for _ in range(60):
|
||||
try:
|
||||
c2 = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await asyncio.wait_for(c2.connect(login=True), 3)
|
||||
i = await c2.device_info()
|
||||
print(f"{time.monotonic() - t0:7.2f} back: {i.name}", flush=True)
|
||||
await c2.disconnect()
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(1)
|
||||
print(
|
||||
f"{time.monotonic() - t0:7.2f} NOT BACK after reboot (last error {last!r})",
|
||||
flush=True,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Run N update checks over the API and print the update state plus http_request/update/long-operation log lines.
|
||||
Usage: updcheck.py IP LABEL N"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, LABEL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"{LABEL} device {info.name} {info.esphome_version} {info.compilation_time}",
|
||||
flush=True,
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
upd = next(e for e in ents if isinstance(e, aioesphomeapi.UpdateInfo))
|
||||
states = []
|
||||
|
||||
def on_state(s):
|
||||
if s.key == upd.key:
|
||||
states.append(s)
|
||||
print(
|
||||
f"{LABEL} {time.monotonic() - t0:7.2f} UPDATE in_progress={s.in_progress} current={s.current_version} latest={s.latest_version} title={s.title!r}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def on_log(m):
|
||||
t = (
|
||||
m.message.decode(errors="replace")
|
||||
if isinstance(m.message, bytes)
|
||||
else m.message
|
||||
)
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
|
||||
if re.search(
|
||||
r"http_request|update|took a long time|Guru|abort|heap|Free",
|
||||
t,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
print(f"{LABEL} {time.monotonic() - t0:7.2f} {t[:200]}", flush=True)
|
||||
|
||||
t0 = time.monotonic()
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(3)
|
||||
missing = []
|
||||
for i in range(N):
|
||||
print(f"{LABEL} {time.monotonic() - t0:7.2f} CHECK {i + 1}", flush=True)
|
||||
before = len(states)
|
||||
c.update_command(upd.key, aioesphomeapi.UpdateCommand.CHECK)
|
||||
await asyncio.sleep(25)
|
||||
if not any(not st.in_progress for st in states[before:]):
|
||||
print(f"{LABEL} CHECK {i + 1} got no finished update state", flush=True)
|
||||
missing.append(i + 1)
|
||||
await c.disconnect()
|
||||
print(f"{LABEL} {N - len(missing)} of {N} checks reported a state", flush=True)
|
||||
return 1 if missing else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Drive the http_request update harness over the API: press buttons, print every update state and save the log.
|
||||
usage: updrive.py HOST KEY(- for plaintext) LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SelectState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
UpdateState,
|
||||
)
|
||||
|
||||
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
OPS = sys.argv[4:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
if isinstance(s, TextSensorState):
|
||||
print(
|
||||
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, UpdateState):
|
||||
print(
|
||||
f"{stamp()} UPD {e.name} latest={s.latest_version!r} title={s.title!r} summary={s.release_summary!r} in_progress={s.in_progress}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, BinarySensorState):
|
||||
print(
|
||||
f"{stamp()} BIN {e.name} = {s.state}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, NumberState):
|
||||
print(
|
||||
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SelectState):
|
||||
print(
|
||||
f"{stamp()} SEL {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
print(f"{stamp()} SEN {e.name} = {s.state:.0f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for n in sorted(selects):
|
||||
print("select:", n, selects[n].options)
|
||||
for e in ents:
|
||||
if isinstance(e, TextSensorInfo):
|
||||
print("text_sensor:", e.name)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
if kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
elif kind == "set":
|
||||
name, _, value = rest.partition("=")
|
||||
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||
if name not in numbers:
|
||||
sys.exit(f"no number named {name!r}")
|
||||
print(f"{stamp()} >>> set {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> set {name} = {value}\n")
|
||||
c.number_command(numbers[name].key, float(value))
|
||||
elif kind == "sel":
|
||||
name, _, value = rest.partition("=")
|
||||
if name not in selects:
|
||||
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||
print(f"{stamp()} >>> select {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> select {name} = {value}\n")
|
||||
c.select_command(selects[name].key, value)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.0)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -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,127 @@
|
||||
"""Time the presence hold on stream-harness.yaml and check presence_timeout survives a restart.
|
||||
|
||||
usage: holddrive.py HOST OUT env: API_KEY (when the harness has one)
|
||||
For timeouts 5 and 0: target frames for 3 s, then empty frames; prints the ms from the "mode none" press to the
|
||||
has_target off line (up to one 100 ms frame of that is the harness). Then sets 0 and 20, restarts, reads the number.
|
||||
A missing press line, off line or number state prints NO-DATA and the run exits 1.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo, LogLevel, NumberInfo, NumberState
|
||||
|
||||
HOST, OUT = sys.argv[1], sys.argv[2]
|
||||
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
|
||||
MISSING = []
|
||||
|
||||
|
||||
def note(s: str) -> None:
|
||||
print(s)
|
||||
LOG.write(s + "\n")
|
||||
LOG.flush()
|
||||
|
||||
|
||||
async def session():
|
||||
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
|
||||
for attempt in range(60):
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
break
|
||||
except Exception as err: # noqa: BLE001
|
||||
last_err = err
|
||||
note(f"connect attempt {attempt + 1} failed: {err!r}")
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
raise last_err
|
||||
ents, _ = await c.list_entities_services()
|
||||
btn = {e.name: e.key for e in ents if isinstance(e, ButtonInfo)}
|
||||
num = next(
|
||||
e.key
|
||||
for e in ents
|
||||
if isinstance(e, NumberInfo) and e.name == "Presence Timeout"
|
||||
)
|
||||
st, lines = {}, []
|
||||
|
||||
def on_state(s) -> None:
|
||||
if isinstance(s, NumberState) and s.key == num:
|
||||
st["t"] = s.state
|
||||
|
||||
def on_log(m) -> None:
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if "harness" in t:
|
||||
lines.append(t)
|
||||
LOG.write("LOG " + t + "\n")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(1)
|
||||
if "t" in st:
|
||||
break
|
||||
# a freshly booted ESP8266 delays API commands that arrive too early
|
||||
await asyncio.sleep(8)
|
||||
return c, btn, num, st, lines
|
||||
|
||||
|
||||
def first_ms(lines: list[str], pat: str) -> int | None:
|
||||
for line in lines:
|
||||
if m := re.search(pat + r" at (\d+)", line):
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def number(st: dict, what: str) -> str:
|
||||
if "t" not in st:
|
||||
MISSING.append(what)
|
||||
return "NO-DATA"
|
||||
return f"{st['t']:g}"
|
||||
|
||||
|
||||
async def hold(c, btn, num, st, lines, timeout: int) -> None:
|
||||
c.number_command(num, timeout)
|
||||
await asyncio.sleep(2)
|
||||
if st.get("t") != timeout:
|
||||
# dev snaps 0 back to the saved value; the hold is then timed with what the number reads
|
||||
note(f"HOLD timeout={timeout}: the number reads {st.get('t')}, timing that")
|
||||
c.button_command(btn["Mode target"])
|
||||
await asyncio.sleep(3)
|
||||
lines.clear() # only this run's press and off lines count
|
||||
c.button_command(btn["Mode none"])
|
||||
await asyncio.sleep(timeout + 8)
|
||||
start, off = first_ms(lines, "mode none"), first_ms(lines, "presence raw OFF")
|
||||
if start is None or off is None or off < start:
|
||||
MISSING.append(f"hold {timeout}")
|
||||
note(f"HOLD timeout={timeout}: NO-DATA (press line {start}, off line {off})")
|
||||
else:
|
||||
note(
|
||||
f"HOLD timeout={timeout}: off {off - start} ms after the last target frame"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c, btn, num, st, lines = await session()
|
||||
note(f"START number {number(st, 'start')}")
|
||||
for t in (5, 0):
|
||||
await hold(c, btn, num, st, lines, t)
|
||||
for t in (0, 20):
|
||||
st.pop("t", None)
|
||||
c.number_command(num, t)
|
||||
await asyncio.sleep(2)
|
||||
note(f"SET {t}, number reads {number(st, f'set {t}')}")
|
||||
c.button_command(btn["Restart"])
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
await asyncio.sleep(15)
|
||||
c, btn, num, st, lines = await session()
|
||||
note(f"AFTER RESTART number reads {number(st, f'restart {t}')}")
|
||||
await c.disconnect()
|
||||
if MISSING:
|
||||
note(f"NO-DATA for: {', '.join(MISSING)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Step stream-harness.yaml through frames, silence, garbage and radar restarts; print every state change.
|
||||
|
||||
usage: linkdrive.py HOST OUT env: API_KEY (when the harness has one)
|
||||
Lines are seconds since start: PRESS steps, STATE changes of the ld2450 entities (unknown when missing), and LOG lines
|
||||
about the link or ld2450 warnings.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo, LogLevel
|
||||
|
||||
HOST, OUT = sys.argv[1], sys.argv[2]
|
||||
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
|
||||
T0 = time.time()
|
||||
STEPS = [
|
||||
("Mode none", 8), ("Mode target", 6), ("Mode silent", 10), ("Mode none", 4), ("Mode target", 4),
|
||||
("Mode garbage", 10), ("Mode target", 5),
|
||||
("Radar Restart", 0.3), ("Mode silent", 3.5), ("Mode target", 6),
|
||||
("Radar Restart", 0.3), ("Mode silent", 15), ("Mode none", 4),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def note(s: str) -> None:
|
||||
line = f"{time.time() - T0:7.2f} {s}"
|
||||
print(line)
|
||||
LOG.write(line + "\n")
|
||||
LOG.flush()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
|
||||
await c.connect(login=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
names = {e.key: e.name for e in ents}
|
||||
btn = {e.name: e.key for e in ents if isinstance(e, ButtonInfo)}
|
||||
last = {}
|
||||
|
||||
def on_state(s) -> None:
|
||||
n = names.get(s.key)
|
||||
if n is None or n in btn:
|
||||
return
|
||||
v = (
|
||||
"unknown"
|
||||
if getattr(s, "missing_state", False)
|
||||
else getattr(s, "state", None)
|
||||
)
|
||||
if isinstance(v, float):
|
||||
v = "unknown (nan)" if math.isnan(v) else round(v, 1)
|
||||
if last.get(n) != v:
|
||||
last[n] = v
|
||||
note(f"STATE {n} = {v}")
|
||||
|
||||
def on_log(m) -> None:
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if re.search(r"[Ll]ink|\[W\]\[ld2450", t):
|
||||
note("LOG " + t.strip())
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(3)
|
||||
for name, wait in STEPS:
|
||||
note(f"PRESS {name}")
|
||||
c.button_command(btn[name])
|
||||
await asyncio.sleep(wait)
|
||||
await c.disconnect()
|
||||
# dev freezes entities during silence but still updates them on target frames; nothing at all is a dead stream
|
||||
if not last:
|
||||
note("NO-DATA: no entity state arrived")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Read the LD24xx BT MAC text sensors, turn every LD24xx Bluetooth switch off, then on again.
|
||||
|
||||
usage: macdrive.py HOST OUT env: API_KEY (when the device has one)
|
||||
Works on any device whose MAC, Bluetooth and firmware entities have those words in their names (the BT MAC sensors
|
||||
may be disabled by default; they still publish over the API). Quote at most the first MAC byte in a PR.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, SwitchInfo, TextSensorInfo
|
||||
|
||||
HOST, OUT = sys.argv[1], sys.argv[2]
|
||||
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
|
||||
|
||||
|
||||
def note(s: str) -> None:
|
||||
line = f"{time.strftime('%H:%M:%S')} {s}"
|
||||
print(line)
|
||||
LOG.write(line + "\n")
|
||||
LOG.flush()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
|
||||
await c.connect(login=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
keys = {
|
||||
e.key: e.name
|
||||
for e in ents
|
||||
if isinstance(e, (SwitchInfo, TextSensorInfo))
|
||||
and any(w in e.name for w in ("MAC", "Bluetooth", "Firmware"))
|
||||
}
|
||||
states = {}
|
||||
|
||||
def on_state(s) -> None:
|
||||
if s.key in keys and states.get(keys[s.key]) != getattr(s, "state", None):
|
||||
states[keys[s.key]] = s.state
|
||||
note(f"STATE {keys[s.key]} = {s.state}")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
await asyncio.sleep(8)
|
||||
switches = {
|
||||
e.name: e.key
|
||||
for e in ents
|
||||
if isinstance(e, SwitchInfo) and "Bluetooth" in e.name
|
||||
}
|
||||
macs = [n for n in keys.values() if "MAC" in n]
|
||||
if not macs or not switches:
|
||||
note(
|
||||
f"FAIL: need a MAC text sensor and a Bluetooth switch, found {sorted(keys.values())}"
|
||||
)
|
||||
sys.exit(1)
|
||||
missing = []
|
||||
if silent := [n for n in sorted(keys.values()) if n not in states]:
|
||||
note(f"NO-DATA after 8 s: {', '.join(silent)}")
|
||||
missing += silent
|
||||
note(
|
||||
"SNAP start: "
|
||||
+ "; ".join(f"{n}={states.get(n)}" for n in sorted(keys.values()))
|
||||
)
|
||||
for phase, on in (("off", False), ("on", True)):
|
||||
for name, key in switches.items():
|
||||
note(f"SET {name} {phase}")
|
||||
c.switch_command(key, on)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(20)
|
||||
note(
|
||||
f"SNAP bluetooth {phase}: "
|
||||
+ "; ".join(f"{n}={states.get(n)}" for n in sorted(keys.values()))
|
||||
)
|
||||
missing += [n for n in switches if states.get(n) is not True]
|
||||
await c.disconnect()
|
||||
if missing:
|
||||
note(f"NO-DATA for: {', '.join(sorted(set(missing)))}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
binary_sensor:
|
||||
- platform: ld2450
|
||||
ld2450_id: radar
|
||||
connected:
|
||||
name: Connected
|
||||
@@ -0,0 +1,128 @@
|
||||
# D1 mini, ld2450 on UART0 looped back onto itself, fed one periodic frame every 100 ms from a mode global:
|
||||
# 0 no target, 1 target 1 approaching, 2 silent, 3 garbage bytes (what a radar at another baud rate looks like).
|
||||
# Add `connected: !include stream-connected.yaml` under packages on builds whose ld2450 has the link watchdog.
|
||||
substitutions:
|
||||
name: ld2450-stream
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
// UART0 TX looped back into its own RX so injected frames reach the hub
|
||||
USC0(0) |= (1 << UCLBE);
|
||||
ESP_LOGI("harness", "UART0 loopback on");
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
logger:
|
||||
baud_rate: 0
|
||||
level: DEBUG
|
||||
|
||||
uart:
|
||||
id: radar_uart
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
baud_rate: 256000
|
||||
|
||||
ld2450:
|
||||
id: radar
|
||||
uart_id: radar_uart
|
||||
|
||||
globals:
|
||||
- id: mode
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 100ms
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(mode) == 1;
|
||||
then:
|
||||
- uart.write:
|
||||
id: radar_uart
|
||||
data: [0xAA, 0xFF, 0x03, 0x00, 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x55, 0xCC]
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(mode) == 0;
|
||||
then:
|
||||
- uart.write:
|
||||
id: radar_uart
|
||||
data: [0xAA, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x55, 0xCC]
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(mode) == 3;
|
||||
then:
|
||||
- uart.write:
|
||||
id: radar_uart
|
||||
data: [0x3F, 0x80, 0xF8, 0x00, 0x7E, 0x07, 0xC0, 0x1F, 0xE0, 0x03, 0xF0, 0x0F]
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld2450
|
||||
has_target:
|
||||
name: Has Target
|
||||
# no settle filter, so the hub's own presence hold is what gets timed
|
||||
filters: []
|
||||
on_state:
|
||||
- lambda: 'ESP_LOGI("harness", "presence raw %s at %u", x ? "ON" : "OFF", (unsigned) millis());'
|
||||
|
||||
sensor:
|
||||
- platform: ld2450
|
||||
target_count:
|
||||
name: Target Count
|
||||
target_1:
|
||||
x:
|
||||
name: T1 X
|
||||
|
||||
text_sensor:
|
||||
- platform: ld2450
|
||||
target_1:
|
||||
direction:
|
||||
name: T1 Direction
|
||||
|
||||
number:
|
||||
- platform: ld2450
|
||||
presence_timeout:
|
||||
name: Presence Timeout
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Mode none
|
||||
on_press:
|
||||
- lambda: 'id(mode) = 0; ESP_LOGI("harness", "mode none at %u", (unsigned) millis());'
|
||||
- platform: template
|
||||
name: Mode target
|
||||
on_press:
|
||||
- lambda: 'id(mode) = 1; ESP_LOGI("harness", "mode target at %u", (unsigned) millis());'
|
||||
- platform: template
|
||||
name: Mode silent
|
||||
on_press:
|
||||
- lambda: 'id(mode) = 2; ESP_LOGI("harness", "mode silent at %u", (unsigned) millis());'
|
||||
- platform: template
|
||||
name: Mode garbage
|
||||
on_press:
|
||||
- lambda: 'id(mode) = 3; ESP_LOGI("harness", "mode garbage at %u", (unsigned) millis());'
|
||||
- platform: ld2450
|
||||
ld2450_id: radar
|
||||
restart:
|
||||
name: Radar Restart
|
||||
- platform: restart
|
||||
name: Restart
|
||||
@@ -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,81 @@
|
||||
"""Match each RTL8720C ROM fault dump in a serial capture with the report on the next boot.
|
||||
|
||||
usage: rtl_rom_vs_report.py SERIAL_CAPTURE (taken with serial_capture.py --ms)
|
||||
Per ROM dump: the ROM's CFSR and PC, ms from the dump to the next `ROM Version` banner, and the Reason, CFSR, PC and SP
|
||||
the next boot reported. The ROM lines start with a bare CR, so the capture is split on newlines only.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
RAW = pathlib.Path(sys.argv[1]).read_bytes().decode(errors="replace")
|
||||
LINES = [
|
||||
re.sub(r"\x1b\[[0-9;]*m", "", line) for line in RAW.replace("\r", "").split("\n")
|
||||
]
|
||||
|
||||
|
||||
def stamp(line: str) -> float | None:
|
||||
"""Seconds since midnight from a serial_capture.py --ms timestamp."""
|
||||
m = re.search(r"\[(\d\d):(\d\d):(\d\d\.\d{3})\]", line)
|
||||
return (
|
||||
int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3)) if m else None
|
||||
)
|
||||
|
||||
|
||||
if not any(stamp(line) is not None for line in LINES):
|
||||
sys.exit(
|
||||
"NO-DATA: no [HH:MM:SS.mmm] timestamps; take the capture with serial_capture.py --ms"
|
||||
)
|
||||
dumps = bad = 0
|
||||
i = 0
|
||||
while i < len(LINES):
|
||||
if "Fault Handler:" not in LINES[i]:
|
||||
i += 1
|
||||
continue
|
||||
t0 = stamp(LINES[i])
|
||||
kind = LINES[i + 2].split("]", 1)[-1].strip() if i + 2 < len(LINES) else "?"
|
||||
cfsr = pc = None
|
||||
j = i
|
||||
while j < len(LINES) and "ROM Version" not in LINES[j]:
|
||||
if cfsr is None and "Configurable Fault Status Reg" in LINES[j]:
|
||||
cfsr = LINES[j].split("=")[1].strip()
|
||||
if pc is None and "PC =" in LINES[j]:
|
||||
pc = LINES[j].split("=")[1].strip()
|
||||
j += 1
|
||||
tj = next(
|
||||
(
|
||||
stamp(LINES[x])
|
||||
for x in range(j, min(j + 5, len(LINES)))
|
||||
if stamp(LINES[x]) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
reset = (
|
||||
f"{(tj - t0) * 1000:.0f} ms"
|
||||
if tj is not None and t0 is not None
|
||||
else "no reset seen"
|
||||
)
|
||||
report = {}
|
||||
k = j + 1
|
||||
while (
|
||||
k < len(LINES)
|
||||
and "Fault Handler:" not in LINES[k]
|
||||
and "boot reason" not in LINES[k]
|
||||
):
|
||||
m = re.search(r"(Reason|CFSR|PC|SP):\s+(.*)", LINES[k])
|
||||
if m and "libretiny.crash" in LINES[k]:
|
||||
report.setdefault(m.group(1), m.group(2).split(" (")[0].strip())
|
||||
k += 1
|
||||
dumps += 1
|
||||
missing = [
|
||||
n for n, v in (("ROM CFSR", cfsr), ("ROM PC", pc), ("reset", tj)) if v is None
|
||||
]
|
||||
missing += [k for k in ("Reason", "CFSR", "PC", "SP") if k not in report]
|
||||
bad += bool(missing)
|
||||
flag = f" | MISSING {', '.join(missing)}" if missing else ""
|
||||
print(f"{kind:18} ROM CFSR {cfsr} PC {pc} | reset {reset} | report {report}{flag}")
|
||||
i = j
|
||||
if not dumps:
|
||||
sys.exit("NO-DATA: no ROM fault dump in the capture")
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Any ESP32-C3 with GPIO10 free; drop ESPHome ledc outputs, the C3 shares one LEDC clock across timers.
|
||||
esphome:
|
||||
name: wake-probe-esp32c3
|
||||
includes:
|
||||
- wake_probe.hpp
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: wakeprobe::start();
|
||||
|
||||
esp32:
|
||||
board: esp32-c3-devkitm-1
|
||||
variant: esp32c3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
include_builtin_idf_components:
|
||||
- esp_driver_ledc
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
power_management:
|
||||
- platform: esp32_power_management
|
||||
enable_light_sleep: true
|
||||
profiling: true
|
||||
|
||||
interval:
|
||||
- interval: 30s
|
||||
then:
|
||||
- lambda: wakeprobe::report();
|
||||
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
// LEDC on an RC_FAST clock keeps toggling GPIO10 in light sleep; an ESP32InternalGPIOPin interrupt on the same pad
|
||||
// timestamps every edge.
|
||||
#include <cstdio>
|
||||
#include "driver/ledc.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_pm.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esphome/components/esp32/gpio.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace wakeprobe {
|
||||
static const char *const TAG = "wakeprobe";
|
||||
static constexpr gpio_num_t PIN = GPIO_NUM_10;
|
||||
static volatile uint32_t edges = 0;
|
||||
static volatile int64_t last_us = 0;
|
||||
static volatile int64_t gap_min = INT64_MAX, gap_max = 0;
|
||||
static volatile uint32_t late_edges = 0; // gap more than 20 ms off the half period
|
||||
static int64_t start_us = 0;
|
||||
static uint32_t half_period_us = 0;
|
||||
static uint32_t freq = 0;
|
||||
static esp_err_t err_timer = ESP_OK, err_channel = ESP_OK;
|
||||
|
||||
static void IRAM_ATTR on_edge(void *) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (last_us != 0) {
|
||||
int64_t gap = now - last_us;
|
||||
if (gap < gap_min)
|
||||
gap_min = gap;
|
||||
if (gap > gap_max)
|
||||
gap_max = gap;
|
||||
int64_t off = gap - (int64_t) half_period_us;
|
||||
if (off > 20000 || off < -20000)
|
||||
late_edges = late_edges + 1;
|
||||
}
|
||||
last_us = now;
|
||||
edges = edges + 1;
|
||||
}
|
||||
|
||||
inline void start() {
|
||||
auto *pin = new esphome::esp32::ESP32InternalGPIOPin(); // NOLINT
|
||||
pin->set_pin(PIN);
|
||||
pin->set_flags(esphome::gpio::FLAG_INPUT);
|
||||
pin->setup();
|
||||
pin->attach_interrupt(on_edge, (void *) nullptr, esphome::gpio::INTERRUPT_ANY_EDGE);
|
||||
ledc_timer_config_t t{};
|
||||
t.speed_mode = LEDC_LOW_SPEED_MODE;
|
||||
t.duty_resolution = LEDC_TIMER_14_BIT;
|
||||
t.timer_num = LEDC_TIMER_0;
|
||||
t.freq_hz = 2;
|
||||
t.clk_cfg = LEDC_USE_RC_FAST_CLK;
|
||||
esp_err_t e1 = err_timer = ledc_timer_config(&t);
|
||||
ledc_channel_config_t c{};
|
||||
c.gpio_num = PIN;
|
||||
c.speed_mode = LEDC_LOW_SPEED_MODE;
|
||||
c.channel = LEDC_CHANNEL_0;
|
||||
c.timer_sel = LEDC_TIMER_0;
|
||||
c.duty = 1 << 13;
|
||||
c.sleep_mode = LEDC_SLEEP_MODE_KEEP_ALIVE;
|
||||
esp_err_t e2 = err_channel = ledc_channel_config(&c);
|
||||
// ledc_channel_config makes the pad output only; turn its input back on so the interrupt sees the wave.
|
||||
gpio_input_enable(PIN);
|
||||
freq = ledc_get_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_0);
|
||||
half_period_us = freq ? 500000 / freq : 0;
|
||||
start_us = esp_timer_get_time();
|
||||
ESP_LOGI(TAG, "LEDC timer=%s channel=%s freq=%u Hz half period %u us", esp_err_to_name(e1), esp_err_to_name(e2),
|
||||
(unsigned) freq, (unsigned) half_period_us);
|
||||
}
|
||||
|
||||
inline void report() {
|
||||
if (start_us == 0)
|
||||
return;
|
||||
char lv[21];
|
||||
for (int i = 0; i < 20; i++) {
|
||||
lv[i] = gpio_get_level(PIN) ? '1' : '0';
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
lv[20] = 0;
|
||||
// Elapsed time and edge count are read together, after the 1 s level loop, so they describe the same interval.
|
||||
float secs = (esp_timer_get_time() - start_us) / 1e6f;
|
||||
uint32_t seen = edges;
|
||||
ESP_LOGI(TAG, "LEDC timer=%s channel=%s freq=%u, level over 1 s: %s", esp_err_to_name(err_timer),
|
||||
esp_err_to_name(err_channel), (unsigned) freq, lv);
|
||||
ESP_LOGI(TAG, "%.0f s: edges=%u expected=%.0f gap min=%lld max=%lld us, off by >20 ms: %u", secs, (unsigned) seen,
|
||||
secs * 2 * freq, (long long) gap_min, (long long) gap_max, (unsigned) late_edges);
|
||||
#ifdef CONFIG_PM_PROFILING
|
||||
static char buf[1024];
|
||||
FILE *f = fmemopen(buf, sizeof(buf) - 1, "w");
|
||||
if (f == nullptr) {
|
||||
ESP_LOGW(TAG, "pm dump failed: fmemopen");
|
||||
return;
|
||||
}
|
||||
esp_pm_dump_locks(f);
|
||||
long n = ftell(f);
|
||||
fclose(f);
|
||||
buf[n < 0 ? 0 : n] = 0;
|
||||
bool sleep_seen = false;
|
||||
for (char *line = strtok(buf, "\n"); line != nullptr; line = strtok(nullptr, "\n")) {
|
||||
if (strstr(line, "SLEEP") || strstr(line, "CPU_MAX") || strstr(line, "APB_MAX") || strstr(line, "APB_MIN")) {
|
||||
sleep_seen = sleep_seen || strstr(line, "SLEEP") != nullptr;
|
||||
ESP_LOGI(TAG, "pm %s", line);
|
||||
}
|
||||
}
|
||||
if (!sleep_seen)
|
||||
ESP_LOGW(TAG, "pm dump has no SLEEP line (%ld bytes, buffer %u)", n, (unsigned) sizeof(buf));
|
||||
#else
|
||||
ESP_LOGW(TAG, "pm profiling unavailable: set power_management profiling: true");
|
||||
#endif
|
||||
}
|
||||
} // namespace wakeprobe
|
||||
@@ -0,0 +1,36 @@
|
||||
# AP manual IP: the only network is missing, so the fallback AP starts after 10 s on 10.42.7.1.
|
||||
substitutions:
|
||||
name: mip-ap
|
||||
plat: esp32
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
packages:
|
||||
plat: !include plat-${plat}.yaml
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: mip-nowhere
|
||||
password: nowhere123
|
||||
manual_ip:
|
||||
static_ip: 10.9.9.9
|
||||
gateway: 10.9.9.1
|
||||
subnet: 255.255.255.0
|
||||
dns1: 10.9.9.1
|
||||
ap:
|
||||
ssid: mip-ap-test
|
||||
password: mipaptest1
|
||||
ap_timeout: 10s
|
||||
manual_ip:
|
||||
static_ip: 10.42.7.1
|
||||
gateway: 10.42.7.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
captive_portal:
|
||||
@@ -0,0 +1,36 @@
|
||||
# Joins the DUT's AP and fetches its page every 10 s.
|
||||
substitutions:
|
||||
name: mip-client
|
||||
plat: esp32
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
packages:
|
||||
plat: !include plat-${plat}.yaml
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: mip-ap-test
|
||||
password: mipaptest1
|
||||
reboot_timeout: 0s
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- if:
|
||||
condition: wifi.connected
|
||||
then:
|
||||
- http_request.get:
|
||||
url: http://10.42.7.1/
|
||||
capture_response: false
|
||||
on_response:
|
||||
then:
|
||||
- lambda: ESP_LOGI("client", "GET http://10.42.7.1/ status=%d", response->status_code);
|
||||
on_error:
|
||||
then:
|
||||
- logger.log: "GET http://10.42.7.1/ failed"
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include "esphome/core/log.h"
|
||||
#include "lwip/dns.h"
|
||||
#include "lwip/ip_addr.h"
|
||||
#ifdef USE_ESP8266
|
||||
#include <ESP8266WiFi.h>
|
||||
#else
|
||||
#include "lwip/netdb.h"
|
||||
#include "lwip/inet.h"
|
||||
#endif
|
||||
inline void dns_probe() {
|
||||
char b0[IPADDR_STRLEN_MAX], b1[IPADDR_STRLEN_MAX], res[16] = "-";
|
||||
ipaddr_ntoa_r(dns_getserver(0), b0, sizeof(b0));
|
||||
ipaddr_ntoa_r(dns_getserver(1), b1, sizeof(b1));
|
||||
int ok;
|
||||
#ifdef USE_ESP8266
|
||||
IPAddress ip;
|
||||
ok = WiFi.hostByName("pool.ntp.org", ip);
|
||||
if (ok)
|
||||
strncpy(res, ip.toString().c_str(), sizeof(res) - 1);
|
||||
#else
|
||||
struct addrinfo hints{};
|
||||
hints.ai_family = AF_INET;
|
||||
struct addrinfo *r = nullptr;
|
||||
ok = lwip_getaddrinfo("pool.ntp.org", nullptr, &hints, &r) == 0 && r != nullptr;
|
||||
if (ok) {
|
||||
inet_ntoa_r(((struct sockaddr_in *) r->ai_addr)->sin_addr, res, sizeof(res));
|
||||
lwip_freeaddrinfo(r);
|
||||
}
|
||||
#endif
|
||||
ESP_LOGI("dnsprobe", "dns0=%s dns1=%s lookup=%s %s", b0, b1, ok ? "ok" : "FAIL", res);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
@@ -0,0 +1,2 @@
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# usage: run-ap.sh LABEL the DUT runs the fallback AP on 10.42.7.1, the other board joins it and fetches its page
|
||||
# Copy this directory into a scratch directory and run it there; no secrets are needed.
|
||||
# env: TREE (esphome checkout for the DUT), CLIENT_TREE (checkout for the client, default TREE), DUT_PORT, DUT_PLAT,
|
||||
# CLIENT_PORT, CLIENT_PLAT (esp32 or esp8266), SC (path to serial_capture.py), PY (default $TREE/venv/bin/python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; CT=${CLIENT_TREE:-$T}
|
||||
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; DP=${DUT_PORT:?set DUT_PORT}; CP=${CLIENT_PORT:?set CLIENT_PORT}
|
||||
N=mip-ap-$L; CN=mip-client
|
||||
up() { (cd "$1" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome -s name "$2" -s plat "$3" run --no-logs "$D/$4" \
|
||||
--device "$5" > "$D/$2-upload.log" 2>&1) || { echo "FAIL: $2 build or upload"; exit 1; }; }
|
||||
up "$T" "$N" "${DUT_PLAT:-esp32}" ap.yaml "$DP"
|
||||
up "$CT" "$CN" "${CLIENT_PLAT:-esp8266}" client.yaml "$CP"
|
||||
rm -f "$D/$N-serial.txt" "$D/$CN-$N-serial.txt"
|
||||
$PY "$SC" "$DP" 120 "$D/$N-serial.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
$PY "$SC" "$CP" 120 "$D/$CN-$N-serial.txt" --reset > /dev/null 2>&1 & C2=$!
|
||||
wait $C1; R1=$?; wait $C2; R2=$?
|
||||
strip() { LC_ALL=C sed 's/\x1b\[[0-9;]*m//g' | sort -u -k2; }
|
||||
echo "--- DUT"; LC_ALL=C grep -a -E "Manual IP|Starting fallback|IP Address|Setting up AP|rst cause|Guru|abort|panic" \
|
||||
"$D/$N-serial.txt" | strip | head -8
|
||||
echo "--- client"; LC_ALL=C grep -a -E "IP Address:|Gateway:|Subnet:" "$D/$CN-$N-serial.txt" | strip | head -4
|
||||
ok=$(grep -a -c 'status=200' "$D/$CN-$N-serial.txt"); bad=$(grep -a -c 'GET http://10.42.7.1/ failed' "$D/$CN-$N-serial.txt")
|
||||
echo "client GET ok=$ok failed=$bad"
|
||||
fail=0
|
||||
[ "$R1" = 0 ] && [ -s "$D/$N-serial.txt" ] || { echo "FAIL: DUT capture failed or empty (rc $R1)"; fail=1; }
|
||||
[ "$R2" = 0 ] && [ -s "$D/$CN-$N-serial.txt" ] || { echo "FAIL: client capture failed or empty (rc $R2)"; fail=1; }
|
||||
grep -a -q -E "IP Address: +10\.42\.7\.1\b" "$D/$N-serial.txt" || { echo "FAIL: AP address is not 10.42.7.1"; fail=1; }
|
||||
grep -a -q -E "IP Address: +10\.42\.7\." "$D/$CN-$N-serial.txt" || { echo "FAIL: client never joined"; fail=1; }
|
||||
[ "$ok" -gt 0 ] || { echo "FAIL: no page fetched"; fail=1; }
|
||||
grep -a -q -E "Guru|abort\(\)|panic|[eE]xception \(|Soft WDT reset|wdt reset" "$D/$N-serial.txt" && { echo "FAIL: DUT crash"; fail=1; }
|
||||
[ $fail = 0 ] && echo PASS; exit $fail
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# usage: run-sta.sh LABEL flash sta.yaml, capture 75 s, ping the manual address, check the dump and the DNS probe
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password) and run it there.
|
||||
# env: TREE (esphome checkout), PORT (board serial port), PLAT (esp32 or esp8266), IP (the board's own DHCP lease),
|
||||
# GATEWAY, SUBNET, DNS1 (the network's values), SC (path to serial_capture.py), PY (default $TREE/venv/bin/python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
P=${PORT:?set PORT}; IP=${IP:?set IP}; SC=${SC:?set SC}; N=mip-sta-$L; C=$D/$N-serial.txt
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome -s name "$N" -s plat "${PLAT:-esp32}" -s ip "$IP" \
|
||||
-s gateway "${GATEWAY:?set GATEWAY}" -s subnet "${SUBNET:?set SUBNET}" -s dns1 "${DNS1:?set DNS1}" \
|
||||
run --no-logs "$D/sta.yaml" --device "$P" > "$D/$N-upload.log" 2>&1) || { echo "FAIL: build or upload"; exit 1; }
|
||||
rm -f "$C"
|
||||
$PY "$SC" "$P" 75 "$C" --reset > /dev/null 2>&1 & CAP=$!
|
||||
# ping exits 0 when at least one reply came back, on macOS and Linux alike.
|
||||
sleep 50; PING=$(ping -c 10 -i 0.3 "$IP" 2>&1); PING_RC=$?; wait $CAP; CAPRC=$?
|
||||
LC_ALL=C grep -a -E "Manual IP|IP Address:|Gateway:|Subnet:|DNS1:|DNS2:|dnsprobe|rst cause|Guru|abort|panic" "$C" \
|
||||
| LC_ALL=C sed 's/\x1b\[[0-9;]*m//g' | sort -u -k2 | head -14
|
||||
echo "ping: $(echo "$PING" | grep -E 'transmitted')"
|
||||
fail=0
|
||||
[ "$CAPRC" = 0 ] && [ -s "$C" ] || { echo "FAIL: serial capture failed or empty (rc $CAPRC)"; fail=1; }
|
||||
grep -a -q -E "dns0=$DNS1 dns1=9\.9\.9\.9 lookup=ok" "$C" || { echo "FAIL: DNS servers or lookup"; fail=1; }
|
||||
grep -a -q -E "IP Address: +$IP\b" "$C" || { echo "FAIL: address is not $IP"; fail=1; }
|
||||
[ $PING_RC = 0 ] || { echo "FAIL: no ping reply"; fail=1; }
|
||||
grep -a -q -E "Guru|abort\(\)|panic|[eE]xception \(|Soft WDT reset|wdt reset" "$C" && { echo "FAIL: crash"; fail=1; }
|
||||
[ $fail = 0 ] && echo PASS; exit $fail
|
||||
@@ -0,0 +1,62 @@
|
||||
# Station manual IP. The real network is the second table row and inherits the top level manual_ip; set ip to the
|
||||
# board's own DHCP lease and dns2 to a server DHCP does not hand out, so the logs prove the manual values are used.
|
||||
# run-sta.sh passes name, plat, ip, gateway, subnet and dns1 with -s; dns2 stays 9.9.9.9, which it checks for.
|
||||
substitutions:
|
||||
name: mip-sta
|
||||
plat: esp32
|
||||
ip: 192.168.1.50
|
||||
gateway: 192.168.1.1
|
||||
subnet: 255.255.255.0
|
||||
dns1: 192.168.1.1
|
||||
dns2: 9.9.9.9
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- dnsprobe.hpp
|
||||
|
||||
packages:
|
||||
plat: !include plat-${plat}.yaml
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
api.service: DEBUG
|
||||
api.connection: DEBUG
|
||||
api.socket: DEBUG
|
||||
socket: DEBUG
|
||||
mdns: DEBUG
|
||||
|
||||
wifi:
|
||||
manual_ip:
|
||||
static_ip: ${ip}
|
||||
gateway: ${gateway}
|
||||
subnet: ${subnet}
|
||||
dns1: ${dns1}
|
||||
dns2: ${dns2}
|
||||
networks:
|
||||
- ssid: mip-nowhere
|
||||
password: nowhere123
|
||||
manual_ip:
|
||||
static_ip: 10.9.9.9
|
||||
gateway: 10.9.9.1
|
||||
subnet: 255.255.255.0
|
||||
dns1: 10.9.9.1
|
||||
- ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 20s
|
||||
then:
|
||||
- if:
|
||||
condition: wifi.connected
|
||||
then:
|
||||
- lambda: dns_probe();
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Drive the modem test build over the API: press buttons, print sensor states, save the device log.
|
||||
usage: mdrive.py HOST LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
|
||||
HOST, LOGFILE = sys.argv[1], sys.argv[2]
|
||||
OPS = sys.argv[3:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c = APIClient(HOST, 6053, None)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
if isinstance(s, TextSensorState):
|
||||
print(
|
||||
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
print(f"{stamp()} SEN {e.name} = {s.state:.1f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
log.flush()
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for e in ents:
|
||||
if isinstance(e, (TextSensorInfo, SensorInfo)):
|
||||
print("entity:", type(e).__name__, e.name)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
if kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.0)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions: {name: mqabase, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions: {name: mqbbase, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
topic_prefix: custpre32
|
||||
@@ -0,0 +1,18 @@
|
||||
substitutions: {name: mqcbase, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
log_topic: explicit32/log
|
||||
birth_message:
|
||||
topic: explicit32/avail
|
||||
payload: up
|
||||
will_message:
|
||||
topic: explicit32/avail
|
||||
payload: down
|
||||
shutdown_message:
|
||||
topic: explicit32/avail
|
||||
payload: bye
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions: {name: mqd32base, suffix: "false"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: mqabase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions: {name: mqbbase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
topic_prefix: custpre66
|
||||
@@ -0,0 +1,17 @@
|
||||
substitutions: {name: mqcbase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
log_topic: explicit66/log
|
||||
birth_message:
|
||||
topic: explicit66/avail
|
||||
payload: up
|
||||
will_message:
|
||||
topic: explicit66/avail
|
||||
payload: down
|
||||
shutdown_message:
|
||||
topic: explicit66/avail
|
||||
payload: bye
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: mqd66base, suffix: "false"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,24 @@
|
||||
# Package for esphome#9315, included by the per-variant files under pr/ and base/ (substitutions name, suffix).
|
||||
esphome:
|
||||
name: ${name}
|
||||
name_add_mac_suffix: ${suffix}
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: Restart
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- logger.log: "alive"
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions: {name: mqapr, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
Loaded 100 of 2187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user