mirror of
https://github.com/esphome/esphome.git
synced 2026-11-10 23:25:58 +00:00
Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c190249188 | ||
|
|
9f50af9d73 | ||
|
|
029199b0ca | ||
|
|
e2b60adc2b | ||
|
|
71d9a995b6 | ||
|
|
9e69326b14 | ||
|
|
942faa87ef | ||
|
|
42cae195f8 | ||
|
|
f85ebd2e21 | ||
|
|
31af32a488 | ||
|
|
279e1dc48f | ||
|
|
dd641ff9d8 | ||
|
|
4d740dc1b4 | ||
|
|
292a646b03 | ||
|
|
429c3586bf | ||
|
|
0929b4d6be | ||
|
|
e24fe74baa | ||
|
|
1daba1b998 | ||
|
|
e7c066b923 | ||
|
|
1add061d94 | ||
|
|
b55460af99 | ||
|
|
f25d66d7df | ||
|
|
ad6b83dce4 | ||
|
|
62d9f9ef26 | ||
|
|
b3e75be525 | ||
|
|
d9797c2efb | ||
|
|
805392e9e2 | ||
|
|
9c350d0bc1 | ||
|
|
5f0e2db058 | ||
|
|
bd75639765 | ||
|
|
a4f844335d | ||
|
|
12117419f6 | ||
|
|
6cdf238568 | ||
|
|
1424c9b74b | ||
|
|
62828cb550 | ||
|
|
6cc96fab0d | ||
|
|
ee60953e67 | ||
|
|
a330714cf6 | ||
|
|
428883ed80 | ||
|
|
62cef67191 | ||
|
|
ba423ff170 | ||
|
|
2610a6a03a | ||
|
|
380660dcdb | ||
|
|
be0550b1e8 | ||
|
|
935befcbe5 | ||
|
|
3325b16c56 | ||
|
|
d9cac9a88f | ||
|
|
86442c8816 | ||
|
|
46f532e2bd | ||
|
|
f1a1502fb6 | ||
|
|
d3738e31f6 | ||
|
|
76d0b989e7 | ||
|
|
f7c91fad0f | ||
|
|
a7b3c34598 | ||
|
|
918fe15eb8 | ||
|
|
26c4f8d40e | ||
|
|
c097fe388e | ||
|
|
21267d7421 | ||
|
|
582adc950a | ||
|
|
613319f984 | ||
|
|
4ad3cc7c0e | ||
|
|
a033260639 | ||
|
|
24f23ba3db | ||
|
|
674d593f75 | ||
|
|
c1771e002b | ||
|
|
5529e87a40 | ||
|
|
54cae78a42 | ||
|
|
e77a3bc54d | ||
|
|
af7f95a01b | ||
|
|
daf48ce4f2 | ||
|
|
3de3f72e7c | ||
|
|
1878a69187 | ||
|
|
032c8d9618 | ||
|
|
36c079de78 | ||
|
|
bdd0a2b3b6 | ||
|
|
857d437739 | ||
|
|
50d7a13e0e | ||
|
|
c7267c1c4a | ||
|
|
f0227926a9 | ||
|
|
cb57120ffc | ||
|
|
adcce19bba | ||
|
|
182c652ed0 | ||
|
|
ff6847edc6 | ||
|
|
3a94d97b87 | ||
|
|
9dd2c8b612 | ||
|
|
1151740669 | ||
|
|
44f12bec7f | ||
|
|
af63d27de6 | ||
|
|
35add805c5 | ||
|
|
f6d6130aca | ||
|
|
4377446ab8 | ||
|
|
76f55870df | ||
|
|
eedad14dea | ||
|
|
7dd35c3217 | ||
|
|
7d1d940983 | ||
|
|
10410aef22 | ||
|
|
e431415841 | ||
|
|
89ac55e92b | ||
|
|
dff21c88c0 | ||
|
|
77e2f3fdf0 | ||
|
|
c9d206f0e5 | ||
|
|
b938575e5f | ||
|
|
3f7373ce35 | ||
|
|
7abf3fb0fb | ||
|
|
0c5e81236b | ||
|
|
a9ba8e9743 | ||
|
|
010e6eb7fc | ||
|
|
3ebcb88c5b | ||
|
|
45161b2e24 | ||
|
|
f7d45afd73 | ||
|
|
5fd9273821 | ||
|
|
ee6fbfe2f3 | ||
|
|
6bb7850ed5 | ||
|
|
672021db0c | ||
|
|
417973ddf2 | ||
|
|
ac24fa4033 | ||
|
|
c641c903cf | ||
|
|
47ff0ebf28 | ||
|
|
d2d5bb623a | ||
|
|
e68344a30c | ||
|
|
fc685ddf96 | ||
|
|
20de263245 | ||
|
|
2c2c9679bc | ||
|
|
abe85a5596 | ||
|
|
38a8e285af | ||
|
|
343a37877b | ||
|
|
b2a10d4db3 | ||
|
|
1268e808f8 | ||
|
|
ad6619c006 | ||
|
|
d8d485d11c | ||
|
|
3c18fee164 | ||
|
|
9f1f52b124 | ||
|
|
fee6a37906 | ||
|
|
8adaf825bd | ||
|
|
d413e5d705 | ||
|
|
3cfe8231c7 | ||
|
|
1c7e952354 | ||
|
|
59c3dd35c0 | ||
|
|
d83411732a | ||
|
|
1ff084be84 | ||
|
|
041da28d53 | ||
|
|
27a724c157 | ||
|
|
5f62432ef0 | ||
|
|
21fb3c4791 | ||
|
|
ebeb3774d3 | ||
|
|
3bb0bc104b | ||
|
|
a9e3e32382 | ||
|
|
e3816ac4bb | ||
|
|
e8415cdd87 | ||
|
|
b8e1699137 | ||
|
|
b5fbd12b00 | ||
|
|
0acba6cc48 | ||
|
|
485b7cc4a2 | ||
|
|
9a357ca9f3 | ||
|
|
b9f598ff71 | ||
|
|
183065c351 | ||
|
|
49ee4914ce | ||
|
|
d9dca1adf8 | ||
|
|
fb4c241bf4 | ||
|
|
bfb9ca984c | ||
|
|
8d6fb44dd6 | ||
|
|
ca12f6e3f3 | ||
|
|
0721604da4 | ||
|
|
849aef0c10 | ||
|
|
8ea8cacd95 | ||
|
|
5e15fef5a8 | ||
|
|
0164f1a431 | ||
|
|
8ad61941f8 | ||
|
|
bff389208f | ||
|
|
3ae0a0174b | ||
|
|
4f0b216b55 | ||
|
|
b614b0344a | ||
|
|
4ad0624e0b | ||
|
|
48e0af4b13 | ||
|
|
0918d63c83 | ||
|
|
b4ac701221 | ||
|
|
995fac044a | ||
|
|
4f9812c55c | ||
|
|
fabeb737b8 | ||
|
|
14b6d7fa13 | ||
|
|
03e08a3f31 | ||
|
|
0ce700bfec | ||
|
|
ad15bf64aa | ||
|
|
ed456ef902 | ||
|
|
6c16a94737 | ||
|
|
83ace37536 | ||
|
|
001f8e91d7 | ||
|
|
f45c627d1e | ||
|
|
9d71ae7a3b | ||
|
|
4342ee1ee7 | ||
|
|
b1065984be | ||
|
|
800e5b034c | ||
|
|
63c7323981 | ||
|
|
19a26d4344 | ||
|
|
915f94e2cc | ||
|
|
981348e2a8 | ||
|
|
1754f895f0 | ||
|
|
72413cf22e | ||
|
|
3c09fea844 | ||
|
|
68d3746d90 | ||
|
|
2c269ec219 |
No files matched your search
@@ -20,6 +20,26 @@ 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.
|
||||
|
||||
## Scope: shared code does not ride a component PR
|
||||
|
||||
Check the file list first. A PR titled for one component (`[ld2450] ...`) must
|
||||
not also change code other components build on: `esphome/core/`, the entity
|
||||
platform components (`esphome/components/sensor/`, `esphome/components/text_sensor/`
|
||||
including its `filter.*`, `binary_sensor/`, `number/`, ...), the buses
|
||||
(`esphome/components/{i2c,spi,uart}/`), `network/`, `socket/`, the shared
|
||||
constants (`esphome/components/const/`, `esphome/const.py`),
|
||||
`esphome/automation.py`, `esphome/cpp_generator.py` or `esphome/config_validation.py`.
|
||||
Flag it and ask for the shared change to move into its own PR, which lands first
|
||||
and is reviewed by that code's owners; the component PR then builds on it. This
|
||||
holds even when the shared change is small or only the component uses it today.
|
||||
The exception is a shared change that must update several components in the same
|
||||
PR so none of them breaks in between (a `[core]` or `[components]` PR that adds a
|
||||
helper and moves its callers at once).
|
||||
|
||||
Not shared code: the component's own platform files (`esphome/components/<c>/sensor.py`,
|
||||
`text_sensor/__init__.py`, ...), its new `USE_*` lines in `esphome/core/defines.h`,
|
||||
and its `CODEOWNERS` entry.
|
||||
|
||||
## Memory and embedded constraints (highest value)
|
||||
|
||||
Heap allocation after `setup()` is treated as a reliability bug, not a
|
||||
@@ -84,6 +104,8 @@ helper; `AGENTS.md` section 4 and the `component-codegen` skill have the detail.
|
||||
| --- | --- |
|
||||
| `if (this->x_sensor_ != nullptr) this->x_sensor_->publish_state(v);` | `PUBLISH_STATE(this->x_sensor_, v);` (`esphome/core/entity_base.h`) |
|
||||
| set warning / `return` / clear warning around a bus result | `if (!this->status_comm_result(ok)) return;` |
|
||||
| set warning / `return` around an earlier step of a multi-step poll | `if (!this->status_comm_check(ok)) return;` |
|
||||
| warning set, but no `status_comm_result` or `status_clear_warning` on the success path | the last step uses `status_comm_result(ok)`, or the warning never clears |
|
||||
| hand-written `set_x_sensor()` plus `x_sensor_` member | `SUB_SENSOR(x)`, `SUB_BINARY_SENSOR`, `SUB_TEXT_SENSOR`, `SUB_SWITCH`, ... |
|
||||
| `if CONF_X in config: s = await sensor.new_sensor(config[CONF_X])` plus setter | `sensors = sensor.sub_sensors(config)` then `await sensors(CONF_X, hub.set_x_sensor)` |
|
||||
| hand-written `{"a": MyEnum.MY_ENUM_A, ...}` dict | `MyEnum.options("a", "b", prefix="MY_ENUM_")` |
|
||||
@@ -129,6 +151,18 @@ helper; `AGENTS.md` section 4 and the `component-codegen` skill have the detail.
|
||||
- Config-only checks use the `validate.*.yaml` prefix; compiled checks use
|
||||
`test.*.yaml`.
|
||||
|
||||
## Naming user-facing functions
|
||||
|
||||
Documented lambda methods, actions and config keys are public API, so a bad name
|
||||
is hard to fix once released. Flag:
|
||||
|
||||
- A boolean where `true` means off (`display_off(true)`): name the positive
|
||||
state, e.g. `set_power(bool on)`.
|
||||
- A name that differs from a sibling component's name for the same operation.
|
||||
|
||||
Before a release, rename outright; after, follow the breaking-change policy
|
||||
below.
|
||||
|
||||
## Breaking changes and public API
|
||||
|
||||
- Base classes under `esphome/core/` and documented config options are public
|
||||
|
||||
@@ -229,6 +229,15 @@ Never a template per bit or a virtual listener base (a second vptr per entity).
|
||||
- `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.
|
||||
- A fixed buffer sized from the config is a file-scope static (BSS, not heap):
|
||||
`buf = cg.static_buffer(config[CONF_ID], cg.uint8, size)`, named after its owner so `esphome analyze-memory`
|
||||
counts it there; pass `buf` to the constructor.
|
||||
A buffer that may live in PSRAM takes `psram=True`: PSRAM builds get a zeroed `psram_buffer<T>(size)` allocation
|
||||
instead, so pass `buf` once and mark the component failed in `setup()` if it is nullptr. `align=4` emits
|
||||
`alignas(4)`, so a word-aligned byte buffer stays `uint8`. `size` may be an expression such as `MyClass.static_("buffer_size")(n)`, which renders
|
||||
`ns::MyClass::buffer_size(n)`.
|
||||
- `cg.static_storage(config[CONF_ID], T, count)` is raw aligned storage for `count` objects as a `std::span<T>`,
|
||||
for `StaticFixedVector::init()` when codegen knows the count.
|
||||
- `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.
|
||||
|
||||
@@ -88,6 +88,9 @@ Components that define buses directly are flagged as "NEEDS MIGRATION" and canno
|
||||
CI build time. `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a
|
||||
standalone entity with its own id instead.
|
||||
|
||||
In a grouped test, set `i2c_id: i2c_bus`, `one_wire_id` and `motion_id` even with one candidate: a merged
|
||||
batch can add a second one ("Too many candidates"). `script/ci-custom.py` checks this.
|
||||
|
||||
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.
|
||||
|
||||
@@ -42,6 +42,8 @@ APIs.
|
||||
// Or if size is constant:
|
||||
std::array<uint8_t, 256> buffer;
|
||||
```
|
||||
When the size comes from the config, declare it with `cg.static_buffer()` in `to_code` and pass the pointer
|
||||
to the constructor, so it lands in BSS (see the `component-codegen` skill).
|
||||
|
||||
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
|
||||
@@ -105,6 +107,8 @@ APIs.
|
||||
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]`.
|
||||
When codegen knows the count, use `StaticFixedVector<T>` and pass `cg.static_storage(config[CONF_ID], T, count)`
|
||||
to its `init()`, so the elements live in BSS instead of the heap.
|
||||
|
||||
4. **Small datasets (1-16 elements):** use `std::vector` or `std::array` with simple structs instead of
|
||||
`std::map`/`std::set`/`std::unordered_map`.
|
||||
|
||||
@@ -38,11 +38,15 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
|
||||
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
|
||||
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
|
||||
When a change moves storage between the heap and static RAM, the static number alone misleads: also log
|
||||
the free heap at the end of setup (`on_boot` at priority -100) over three boots of each build and report
|
||||
the total in use (static RAM plus heap used).
|
||||
6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in
|
||||
the same session and say so in the results.
|
||||
7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is
|
||||
piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or
|
||||
`perl -e 'alarm shift; exec @ARGV' <seconds> <command>` on macOS without it.
|
||||
`perl -e 'alarm shift; exec @ARGV' <seconds> <command>` on macOS without it. Save an exit code with `rc=$?`
|
||||
right after the command: in `echo "$(date +%T) rc=$?"` the command substitution runs first and `$?` reads 0.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -111,6 +115,8 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
||||
and then; retry up to three times.
|
||||
- `api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the
|
||||
recipes drive their test configs with it.
|
||||
- `apiconn.py`: `connect(host, tries)` (API connect and login with retries, `API_KEY` from the env) and `log_text()`
|
||||
(an API log line without ANSI codes), imported by the newer API drivers.
|
||||
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
|
||||
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
|
||||
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
|
||||
@@ -124,7 +130,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header,
|
||||
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), `zephyr_ble_server/` (nRF52 server, looping ESP32 client and UF2 flash and count runner).
|
||||
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/` (`ha_discovery/`, `disable/`, `retained_burst/` and `reconnect/` runners; 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, external wave generator and per-call edge detail probe for peripheral power-down on the P4), `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), `zephyr_ble_server/` (nRF52 server, looping ESP32 client and UF2 flash and count runner), `ble_adv_triggers/` (advertiser with six raw frames, ESP32 and BK72xx receivers with eleven triggers, runner and checker), `sun/` (longitude sweep package, runner and checker), `sensor_filters/` (every filter shape with a fixed input sequence, per-board packages and runner), `led_driver_sniff/` (SPI slave capture header with selftest, config generator, window comparer and runner), `hub_input_sim/` (keypad, wiegand and rdm6300 simulation on a D1 mini, API log runner), `value_range/` (on_value_range sequence with a restart, runner), `ezo_fake_bus/` (fake EZO I2C bus and step config, runner), `twai_self/` (TWAI self-reception header, on_frame config and counter runner), `ota_automations/` (OTA automation package for both OTA platforms, four case runner, repeated web upload runner and per case summary), `output_button/` (pad edge timer header, button package and runner), `homeassistant_button/` (raw plaintext API client for action calls and wizard inputs, package and runner), `uart_button/` (UART0 loopback package, API driver with TX and RX counts, runner), `interval/` (plain and delayed interval package, timing summary and runner), `smp_udp_ota/` (SMP reset fault injector, OTA runner, per mode result check, MCUboot swap limit, running image check and slot blanking UF2, Thread device config), `ld2420_sim/` (fake LD2420 answering the hub's commands on a UART0 loopback, API state recorder, two-build comparer and runner), `nrf52_tcp_probe/` (TCP and UDP socket probe config, build and log check, API check over Thread), `rs485_frame/` (simulated DLE/STX/ETX bus device, frame and gate cases, runner), `hx711/` (fake HXLine truncated
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
@@ -146,8 +152,12 @@ capture decoder, RTL8720C ROM dump versus report matcher), `api_outgoing/` (wake
|
||||
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).
|
||||
action package with per-board files and an OTA flash variant), `led_wire/` (DUT config and header that reads the
|
||||
LED strip pads back with RMT RX and an SPI slave), `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), `i2c_fake/` (fake I2C device
|
||||
headers with their test configs, see `fake_i2c.md`).
|
||||
- `microphone_http_stream/`: base config, fake counter and USB microphone variants and the multi-reader client,
|
||||
described in `reference/recipes/microphone_http_stream.md`.
|
||||
|
||||
## When the PR keeps moving
|
||||
|
||||
@@ -159,6 +169,9 @@ comment quotes the exact commits. Fetch `origin dev` before computing a merge ba
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
- A harness header fails to compile on Arduino or LibreTiny: those frameworks define `millis()` and `micros()` as
|
||||
macros, so call `(esphome::millis)()`, which builds everywhere. Realtek SDK headers also define `OFF`, `ON`
|
||||
and `SUCCESS`.
|
||||
- A capture stopped early or a line is missing: check the timestamps before trusting it.
|
||||
- A script that pipes into `tee` never ends although its work is done: a background command written as
|
||||
`(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the
|
||||
|
||||
@@ -24,6 +24,8 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
row in the PR.
|
||||
- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and
|
||||
stall the link until the broker drops TCP.
|
||||
- `Arduino.h` already declares `_heap_start`; a lambda can use `&_heap_start` directly, and a second `extern` fails.
|
||||
- `GPI` bits 16 to 31 are strapping bits (mask with 0xFFFF). GPIO16 has no interrupt; read `GP16O` for an output.
|
||||
|
||||
## ESP32 classic devkit
|
||||
|
||||
@@ -40,7 +42,11 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys`
|
||||
under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile
|
||||
tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier
|
||||
refuses unsigned images afterwards, so restore with a signed plain build.
|
||||
refuses unsigned images afterwards, so restore with a signed plain build. The full accept and reject case set
|
||||
(several keys, CRC-only edits) is in `recipes/ota.md`.
|
||||
- An ESP32 whose bootloader has no app rollback is protected only by `safe_mode` after a bad OTA. Before sending a
|
||||
change to such a device that nobody can reach by hand, boot the same code on a devkit first and keep a fallback
|
||||
image of the merge base built.
|
||||
|
||||
## ESP32-S3, C3, C6, H2 with native USB
|
||||
|
||||
@@ -49,7 +55,17 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
`Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a
|
||||
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).
|
||||
- The USB console drops a line now and then even when it stays up; do not count events by log lines alone.
|
||||
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry). The
|
||||
Waveshare ESP32-P4-WIFI6 rev1.3 board is such pre-rev3 silicon.
|
||||
- S3, C3 and C6 all enumerate as `303a:1001`; confirm the chip with `esptool --port <port> chip_id` before flashing.
|
||||
- ESP32-C6 test configs: set `wifi: power_save_mode: none`. With the default light sleep, API commands and state
|
||||
updates arrived seconds late and a 1.2 s step check failed at random on base and PR alike (esphome#22339).
|
||||
- An S3 on Arduino with `logger: hardware_uart: USB_CDC` enumerates as TinyUSB CDC (`303a:0009`). If that build
|
||||
crash loops (a FastLED plus NeoPixelBus build aborts in `check_rmt_legacy_driver_conflict`), esptool cannot catch
|
||||
the port: hold BOOT, tap RST, keep BOOT held about 2 s, then flash with `--before no-reset`. A plain RTS hard reset
|
||||
leaves it in the loader on USB JTAG; `--after watchdog-reset` boots the app. Keep Arduino LED tests on the classic
|
||||
ESP32.
|
||||
- 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.
|
||||
@@ -64,6 +80,22 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
joins must wait for `network::is_connected()`.
|
||||
- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time.
|
||||
- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch.
|
||||
- LEAmDNS re-enters `update()` from the receive handler on the Pico too (measured), so an ESP8266-only guard does
|
||||
not cover it.
|
||||
- An image with only `ota: platform: web_server`, built from a tree without the fix for uploads of unknown size
|
||||
(before 2026-10-19), cannot be updated again over the network; see `recipes/ota.md` before flashing one.
|
||||
- RP2350 `panic()` goes through `_exit()` and `__breakpoint()` to a HardFault, and ESPHome's rp2 crash handler
|
||||
reboots within about a second (USB back 1 to 2 s after the panic). A panic during component setup therefore
|
||||
boot-loops before USB CDC ever enumerates, which looks like a dead board: no port and no BOOTSEL drive. Recover
|
||||
with BOOTSEL held on replug, then flash to the `RP2350` volume. To see the crash, trigger it after boot.
|
||||
- A Pico 2 W can sample any of its own GPIOs from a spare PIO block (PIO2) at 25 MHz while another peripheral
|
||||
drives the pin, since PIO reads the pad without taking it. That decodes WS2812-style pulses with no wires or
|
||||
LEDs. Turn a light on at full brightness before probing, or color correction writes zeros. The sample FIFO
|
||||
overruns now and then; count only full captures.
|
||||
- A Pico 2 W also makes a byte-exact fake I2C sensor: a raw RP2350 I2C0 interrupt handler (GP4/GP5) that answers
|
||||
each RD_REQ with one byte from a 256-byte register file, so any read length works (used for BME280/BMP280 in
|
||||
esphome#22236). The ESP32-S3 I2C slave v2 driver is not usable for register emulation: answering from a task or
|
||||
straight from the interrupt both gave shifted multi-byte reads.
|
||||
|
||||
## BW15 kit (RTL8720CF, LibreTiny rtl87xx)
|
||||
|
||||
@@ -94,6 +126,17 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
FPU. For a crash that fires once, guard an `interval` lambda with
|
||||
`!libretiny::crash_handler_has_data()`. For a handler mode (MSP) fault, point vector 16+11 (SDIOD,
|
||||
unused) at a trapping function and set bit 11 in NVIC ISER and ISPR (0xE000E100, 0xE000E200).
|
||||
- OTA slots (esphome#22012): a serial `ltchiptool flash write` fills both OTA1 and OTA2, and the slot flag decides
|
||||
which one boots (`OTA1 USE` or `OTA2 USE` in the ROM banner). Each OTA writes the other slot, so a test that needs
|
||||
the OTA1 to OTA2 direction runs two OTAs in a row. On dev before #22111 a deflate OTA into OTA2 corrupted the
|
||||
image, and the next restart hung at `System_Init1` until a serial reflash.
|
||||
- If the chip still waits in the ROM after a serial flash, pulse CEN to GND once (TX2 floating) to boot the app.
|
||||
`expected ACK` failures at random blocks mid-transfer can come from one USB hub port; another port fixed them.
|
||||
- Its Wi-Fi is slow and drops (esphome#21742): a 300 KB deflate OTA takes 3 to 10 minutes, so give the uploader 30
|
||||
minutes, not 10.
|
||||
- Wrong-MD5 OTA test without changing firmware: monkeypatch `espota2.hashlib.md5` so only the image checksum (data
|
||||
over 64 KB) returns a wrong hexdigest. Give each build its own `name:` and read `device_info().name` after a
|
||||
restart to see which image booted.
|
||||
|
||||
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
|
||||
|
||||
@@ -108,6 +151,8 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
assuming either.
|
||||
- `flash_bk72xx.py` misses the link now and then (`Timed out attempting to link with chip`); retry up to 3
|
||||
times before suspecting the board.
|
||||
- Both flash wrappers pass ltchiptool `-L` with the newest installed LibreTiny platform (`scripts/lt_platform.py`):
|
||||
its default `~/.platformio/platforms/libretiny` can be an older copy that rejects BK7238 UF2s as unsupported.
|
||||
- Crashes: `__builtin_trap()` and a call into unmapped memory both end as an undefined-instruction trap
|
||||
(`bk_misc_get_start_type()` 6, `lt_get_reboot_reason()` Crash); reads, unaligned loads and stores to
|
||||
unmapped addresses never fault on the ARM968. Without Wi-Fi the SDK trap handler spins with interrupts off
|
||||
@@ -138,6 +183,11 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
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.
|
||||
- OTA install: the BK7238 bootloader installs any complete download whose own CRC passes at the next restart, even
|
||||
when ESPHome refused the update; an incomplete download fails `rt_ota_part_fw_verify` (`Verify firmware CRC32
|
||||
... !=`) and the old image stays.
|
||||
- Power save: on the BK7238, `power_save_mode: light` with LibreTiny 1.14.0 gives ping times of 3 to 6 s because
|
||||
the BDK sets a listen interval of 30 beacons when `CFG_LOW_VOLTAGE_PS` is 1 (esphome#19491).
|
||||
|
||||
## BW12 and other AmebaZ boards (RTL8710BX, LibreTiny rtl87xx)
|
||||
|
||||
@@ -172,12 +222,19 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
`uint8_t` before mapping it. Cosmetic. Open networks come out as `WIFI_AUTH_INVALID` (255) on both
|
||||
AmebaZ and AmebaZ2, so `auth != WIFI_AUTH_OPEN` marks them encrypted.
|
||||
- After a BSSID plus channel join, LibreTiny's `set_pscan_channel(ch, false)` leaves the driver with no
|
||||
channel to scan, so the next SSID only join fails at once (no `start auth`). Calling
|
||||
`wifi_set_pscan_chan` with channels 1 to 13 and `PSCAN_ENABLE` each before such a join fixes it and
|
||||
keeps the BSSID fast path (BW15 BSSID join median 3.05 s with it, 4.68 s when joining with channel 0).
|
||||
channel to scan, so the next SSID only join fails at once (no `start auth`), and the first scan after a BSSID
|
||||
join comes back empty (no results in about 62 ms on the BW15). A `wifi_set_pscan_chan` list of channels 1 to 13
|
||||
returns 0 but leaves only the last channel enabled, and re-applying the channel plan does not help either
|
||||
(AmebaZ2 reports plan 0xFF; AmebaZ's `rtw_set_chplan_cmd` rejects plans above 0x58 except 0x7F). What works
|
||||
(esphome#21749, BW15): set the one-channel pscan list yourself and pass channel 0, so the join's own scan
|
||||
rebuilds the full list; setting a placeholder SSID `-` before narrowing (esphome#21754) kept joins as fast as
|
||||
dev with the first scan full on every boot. Not yet confirmed on AmebaZ.
|
||||
- A 4-way handshake that never finishes after association is reported as `RTW_WRONG_PASSWORD` on AmebaZ
|
||||
even with the right password; AmebaZ2 reports a real wrong password as a 4-way handshake timeout.
|
||||
- The driver also posts a SCAN_DONE (number 0) inside every join, on AmebaZ2 too.
|
||||
- RTL8710B GPIO interrupts fire on one edge only, and LibreTiny maps CHANGE to rising. The Arduino `digitalRead` called
|
||||
from an ISR switches the pin back to GPIO and frees its interrupt (esphome#22400, #22403).
|
||||
- The FreeRTOS tick is 1/1024 s, so `millis()` on the tick ran 2.4 % fast (esphome#22404).
|
||||
|
||||
## BW16 kit (RTL8720DN, AmebaD)
|
||||
|
||||
@@ -196,6 +253,15 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short
|
||||
BOOT to GND while the tool connects. OTA afterwards.
|
||||
- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there.
|
||||
- The ESPHome OTA port on LN882x is 8820, not LibreTiny's 8892 (BK72xx, RTL87xx); helper scripts that hardcode
|
||||
8892 get `Connection refused`.
|
||||
- WL2H-U modules with the Tuya flash layout: app at 0x007000 (0x113000 long), OTA at 0x11A000 (0xA8000 long). A
|
||||
full OTA upload over a slow link takes 2 to 14 minutes.
|
||||
- UF2 block order differs per family: LN882H puts the device image first and the flasher image after it, BK72xx
|
||||
puts the flasher image first and the device image last. The LN882H bootloader installs a complete, valid OTA
|
||||
partition even when the upgrade state is still `UPG_STATE_DOWNLOAD_ING` (esphome#22111).
|
||||
- Without serial access to recover, never send a corrupted image; wrong-MD5 and cut-upload tests with valid
|
||||
images are safe.
|
||||
|
||||
## nRF52840
|
||||
|
||||
@@ -207,9 +273,11 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
`dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button
|
||||
re-enters it.
|
||||
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
|
||||
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
|
||||
button P0.18.
|
||||
- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an
|
||||
adafruit-nrfutil, GPREGRET 0x57 works. The running app enumerates as `1915:CAFE` or `239A:0029`. RGB LED
|
||||
active low (red P0.23, green P0.22, blue P0.24), button P0.18.
|
||||
- Both boards reflash without a button once they run a `dfu: true` build: `esphome upload --device <any app CDC
|
||||
port>` on the PCA10059, a 1200 bps open of the log port plus a copy of `zephyr.uf2` to the volume on the ItsyBitsy.
|
||||
- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader 0.9.0 with S140 6.1.1, bootloader port `239a:0051` plus an
|
||||
`ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC
|
||||
port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the
|
||||
1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader
|
||||
@@ -234,9 +302,14 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
|
||||
- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11,
|
||||
MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21.
|
||||
- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45,
|
||||
46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or
|
||||
power-down GPIO exists.
|
||||
- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1 (a 0 ohm link to GPIO1, alternative
|
||||
GPIO16), HREF 2, PCLK 39, D0..D7 = 41, 45, 46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to
|
||||
3.3 V on the board, so no reset or power-down GPIO exists.
|
||||
- It can run from PoE and USB-C at once, so the USB-C can move from the host to a USB device for `usb_uart` or USB
|
||||
host tests while the board stays up; flash and read logs over Ethernet while it is unplugged. In USB host mode
|
||||
the USB-C is the host port after the first flash: log over the API with `logger: hardware_uart: UART0`.
|
||||
- A crash-looping image can wedge its USB console until a BOOT replug. Never force allocation failures on it by
|
||||
hogging RAM: Wi-Fi then aborts in `operator new` or the loop task trips the task watchdog.
|
||||
- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so
|
||||
the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is
|
||||
not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot:
|
||||
@@ -260,3 +333,93 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
- `dns-sd -L <name> _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead.
|
||||
Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host
|
||||
logs `Unexpected RCP reset: RESET_OTHER` and recovers).
|
||||
|
||||
## Building against a patched LibreTiny
|
||||
|
||||
- Clone LibreTiny at the tag ESPHome pins, commit the patch, and set `framework: version: <tag version>` plus
|
||||
`source: file://<clone>` under `bk72xx:` or `rtl87xx:`. PlatformIO installs it as
|
||||
`~/.platformio/platforms/libretiny` (the stock copy is `libretiny@src-<hash>` and is not touched).
|
||||
- A later commit in the clone is not picked up by the next build; patch the installed copy or remove it. Remove
|
||||
`~/.platformio/platforms/libretiny` when done.
|
||||
- BDK headers such as `power_save_pub.h` have no `extern "C"`; wrap them when including them from the Arduino
|
||||
core's C++ files.
|
||||
|
||||
## LibreTiny crash and abort() facts (esphome#21596, #22288)
|
||||
|
||||
- `abort()` on dev: the BK7238 hangs for good (the main loop stops and the watchdog never fires once Wi-Fi is up;
|
||||
recover with an RTS pulse on CEN, `serial_capture.py --reset`). The BW15 (RTL8720CF) restarts with no crash
|
||||
record. The LN882H reboots only after about 18 s through the watchdog (`WDT Reset`), with no record: the SDK's
|
||||
`abort()` and `_exit()` (`components/libc/newlib_stub.c`) and its FreeRTOS `configASSERT` are spin loops. With
|
||||
#22288 all three reboot in 1 to 2 s and log `Reason: Abort`, with the PC at the call site and no `LR:` line.
|
||||
- `assert()`, `std::terminate` and a failed `new` all reach `abort()` (nano libsupc++). `raise(SIGABRT)` returns,
|
||||
because `_kill` is a nosys stub.
|
||||
- `__builtin_trap()` faults on every board. A store to `nullptr` or 0xDEADBEE0 does not fault on the BK7238. On
|
||||
the BW15 a `nullptr` store does not fault, and loads and stores at 0xDEADBEE0 give only imprecise bus faults
|
||||
(CFSR 0x400, no BFARVALID), so testing the fault address path needs another Cortex-M chip.
|
||||
- BK7238 safe mode: each quick crash reboot counts toward it (`Last reset too quick; invoke in N restarts`), so
|
||||
back-to-back crash tests can drop the board into safe mode.
|
||||
- LN882H: the previous crash is replayed to every new API log client until the next crash; that is expected, not a
|
||||
double record. Once, the first `__builtin_trap()` after a fresh OTA took about 12 s to reboot; retries took 2 to
|
||||
3 s and it did not reproduce.
|
||||
- `~/.platformio/packages/toolchain-gccarmnoneeabi` can vanish between a build and the decode (it comes back with
|
||||
the next LibreTiny build). Until then the `arm-none-eabi-addr2line` and objdump of
|
||||
`toolchain-rp2040-earlephilhower` read any ARM ELF.
|
||||
- RTL8720C: a noreturn `bl abort` can be the last instruction of a function, so the raw return address points at
|
||||
padding addr2line cannot place. Decode `(addr & ~1) - 2` to get the call line; #22288 records it that way.
|
||||
- Abort reports (#21596): early heads put LR 0x00000000 first in the address list; later heads drop a zero LR, so
|
||||
an abort report starts with the backtrace.
|
||||
|
||||
## Waveshare ESP32-C6-GEEK
|
||||
|
||||
- Native USB JTAG serial (`303a:1001`), 16 MB flash, 1.14" ST7789 LCD and TF card; the vendor docs give no GPIO
|
||||
numbers. Pins that read a fixed level against the internal pulls: 1, 2, 7, 20, 23 high, 6 low. Free for pad
|
||||
tests: 0, 3, 4, 5, 18, 19, 21, 22 (see `recipes/gpio_hold.md`).
|
||||
|
||||
## Waveshare ESP32-P4-Module-DEV-KIT (USB host)
|
||||
|
||||
- ESP32-P4 rev v1.3, 16 MB flash, console on a CH343 (`1a86:55d3`). Other boards use the same chip for their
|
||||
console, so check with `esptool chip_id`.
|
||||
- USB host for `usb_host`: set the Host/Device jumper to HOST and use the bottom USB-A port nearest the Ethernet
|
||||
jack (the high-speed OTG controller); the other USB-A ports and the USB-C marked "USB" do not reach it. The ports
|
||||
are unlabelled. The port powers the device.
|
||||
- ESP-IDF programs the FIFO split only when a device connects: read `GRXFSIZ`, `GNPTXFSIZ` and `HPTXFSIZ` at
|
||||
`DR_REG_USB2_BASE` + 0x24, 0x28 and 0x100 after enumeration. GHWCFG3 (0x4C, high 16 bits) reads 896 lines. The
|
||||
default split is RX 512, NPTX 256, PTX 128.
|
||||
- Not every P4 board has a USB host port; check before planning a `usb_host` test on one.
|
||||
|
||||
## Gelidus Research PS1 v2.3 (LD2420 presence sensor)
|
||||
|
||||
- ESP32-D0WD-V3 rev 3.1 on a CH340 (`1a86:7523`); `esphome upload --device <port>` works directly.
|
||||
- LD2420 UART: TX GPIO17, RX GPIO16, 115200 (radar firmware v1.6.1). Status LED GPIO23, night light PWM on GPIO25.
|
||||
- The vendor package is at github.com/GelidusResearch/grps (`packages/grps-ld2420.yaml`); copy it locally for tests.
|
||||
- The radar needs a person in its field. Energy frames are `F4 F3 F2 F1 23 00 <presence> <range cm LE16> ...`; a
|
||||
UART `debug:` block on RX shows them.
|
||||
|
||||
## ESP32-C3 Super Mini (native USB, 4 MB XMC flash)
|
||||
|
||||
- Shows up as `303a:1001`; confirm with `esptool --port <port> chip_id`. Board `esp32-c3-devkitm-1`,
|
||||
`logger: hardware_uart: USB_SERIAL_JTAG`. Avoid GPIO2, 8 and 9 (strapping; 8 is the LED, 9 is BOOT).
|
||||
- A 6-pin BME680 breakout (CS, ADDR/MISO, SCL, SDA, GND, VCC) on SDA GPIO6 and SCL GPIO7 with CS and ADDR left
|
||||
open answers at 0x77 through the breakout's pull-ups, and the `bme680` platform reads fine.
|
||||
- BME688 parallel mode never reads in `bme68x_bsec2` on dev: the read timer is re-armed by each 30 ms run before
|
||||
its 41.6 ms delay ends. Entering parallel mode at all needs gas estimates subscribed at `BSEC_SAMPLE_RATE_SCAN`
|
||||
and no IAQ outputs.
|
||||
|
||||
## mDNS responder tests (#22267)
|
||||
|
||||
- When the host's multicast does not reach the device, query its port 5353 directly as legacy unicast (a
|
||||
python-zeroconf packet sent from a normal UDP socket) to dump records or hammer the responder.
|
||||
- On a crowded Wi-Fi channel, back-to-back query loss swung from 0 to 14% between builds for no code reason. Judge
|
||||
responder timing on an Ethernet board.
|
||||
|
||||
## nRF54LM20 DK (PCA10184, on-board J-Link)
|
||||
|
||||
- The power switch must be ON or nothing enumerates. The ESPHome log (uart20) is on the second J-Link VCOM port;
|
||||
the first is unused.
|
||||
- Flash with `esphome upload <yaml> --device JLINK` (sdk-nrf toolchain); the LM20 needs no west `--device` override.
|
||||
The J-Link re-enumerates after the flash, so start the capture after the port comes back.
|
||||
- Reset without halting: JLinkExe `-device NRF54LM20A_M33 -if SWD -autoconnect 1` with `w4 0xE000ED0C 0x05FA0004`
|
||||
(plain `r` and `g` halt and resume, no reboot).
|
||||
- LED0 P1.22, LED1 P1.25; BUTTON0 P1.26, BUTTON1 P1.09, BUTTON2 P1.08, BUTTON3 P0.05 (active low, pull-up). GPIO
|
||||
P1 registers at 0x500D8200 (IN +0x00C, PIN_CNF +0x080), P0 at 0x5010A200.
|
||||
- Binary sensor state changes are not logged at DEBUG; log the states or count `on_press` to see button presses.
|
||||
@@ -11,17 +11,18 @@ One file per component, named after it:
|
||||
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
|
||||
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
|
||||
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
|
||||
- [`ld2420.md`](ld2420.md): hub setup and sensor states with a simulated module on a UART0 loopback (energy and simple mode)
|
||||
- [`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 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, BK7238 OTA versus the board layout and bootloader
|
||||
- [`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, BK7238 OTA versus the board layout and bootloader, pulse counting, RTL8710B edge interrupts and millis timing
|
||||
- [`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, zephyr_ble_server connection slot under reordered callbacks
|
||||
- [`nrf52.md`](nrf52.md): dongle board support, zephyr_ble_server connection slot under reordered callbacks, SMP over UDP OTA over Thread with reset faults, MCUboot swap limit and stuck slot recovery, TCP stack probe
|
||||
- [`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): 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
|
||||
- [`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, OTA automations on two platforms
|
||||
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B, interval startup delay and control from lambdas
|
||||
- [`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, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
|
||||
@@ -29,7 +30,7 @@ One file per component, named after it:
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
|
||||
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls
|
||||
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls, wizard-linked entities on every platform
|
||||
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
|
||||
- [`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
|
||||
@@ -41,9 +42,39 @@ One file per component, named after it:
|
||||
- [`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
|
||||
- [`fake_i2c.md`](fake_i2c.md): fake I2C devices with no chip: in-firmware buses for many drivers, a Pico 2 W BME280/BMP280 target, a DS2482-800 simulator, NACK injection for vl53l0x
|
||||
- [`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
|
||||
- [`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, and with an external wave and peripheral power-down on the ESP32-P4
|
||||
- [`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
|
||||
- [`ble_adv_triggers.md`](ble_adv_triggers.md): BLE service and manufacturer data advertise triggers over the air on the ESP32 and BK72xx trackers, with a second ESP32 advertising known frames
|
||||
- [`sun.md`](sun.md): on_sunrise and on_sunset by sweeping the observer's longitude, one full sweep in about 17 minutes
|
||||
- [`sensor_filters.md`](sensor_filters.md): every sensor, binary_sensor and text_sensor filter shape fed a fixed input sequence, outputs compared bit for bit between builds
|
||||
- [`led_driver_sniff.md`](led_driver_sniff.md): bit-banged LED drivers captured on the ESP32's own pads with SPI slaves through the GPIO matrix, no chips
|
||||
- [`hub_input_sim.md`](hub_input_sim.md): matrix_keypad, wiegand, rdm6300 and key_collector driven from a D1 mini's own pins and UART loopback
|
||||
- [`value_range.md`](value_range.md): sensor and number on_value_range with a fixed sequence and a restart
|
||||
- [`ezo_fake_bus.md`](ezo_fake_bus.md): the ezo component against a fake I2C bus that answers like an EZO circuit
|
||||
- [`twai_self.md`](twai_self.md): esp32_can on_frame with the controller hearing its own transmit pad, no transceiver
|
||||
- [`buttons.md`](buttons.md): output button pad timing, homeassistant button through a raw API client with the wizard input, uart button on the UART0 loopback
|
||||
- [`rs485_frame.md`](rs485_frame.md): DLE/STX/ETX bus on an ESP32-C3 with a cross-wired simulated bus device (frame cases, gate timing)
|
||||
- [`hx711.md`](hx711.md): fake HX711 driven from the other core, every published value matched to the served conversion
|
||||
- [`automation_trace.md`](automation_trace.md): automation codegen A/B, a marker trace of chains, conditions, loops and script modes over the API
|
||||
- [`bluetooth_proxy.md`](bluetooth_proxy.md): held GATT link after a half-open API session, frozen Home Assistant stand-in
|
||||
- [`crash_report.md`](crash_report.md): crash buttons, fetching the previous boot's crash report over the API, per-chip abort facts
|
||||
- [`https_idf.md`](https_idf.md): web_server_idf HTTPS test component, listener switch, stalled TLS clients, bad key
|
||||
- [`mdns.md`](mdns.md): legacy unicast mDNS dump, hammer and latency straight to the device
|
||||
- [`sim_buses.md`](sim_buses.md): PCF8574/HD44780, MAX7219, SPI spy, packet_interface and SN74HC165 simulators, status LED probe, fake Home Assistant, cg.static_buffer probes
|
||||
- [`sigma_delta.md`](sigma_delta.md): pulse density of the sigma_delta speaker pin read inside the ESP32-P4, rtttl note onset and release
|
||||
- [`microphone_http_stream.md`](microphone_http_stream.md): raw PCM over HTTP with a counter fake mic or a USB mic, multi-reader client
|
||||
- [`i2s_audio.md`](i2s_audio.md): I2S microphone fed by an I2S TX channel on the same S3 through three jumpers
|
||||
- [`voice_assistant.md`](voice_assistant.md): late TTS start on the Voice PE with a slow announcement URL
|
||||
- [`mcp251xfd.md`](mcp251xfd.md): MCP2518FD CAN FD over SPI in loopback, forced modes, chip reset and bus-off recovery, TX objects read back from chip RAM, idle SPI cost from a PCNT on CS
|
||||
- [`crc32.md`](crc32.md): crc32 vectors on every platform path and the signed OTA signature block CRC on an ESP32-C3
|
||||
- [`i2c_isr_fault.md`](i2c_isr_fault.md): software INA219 on the master's own pads with seeded bus faults, interrupt storm and stuck bus
|
||||
- [`bmp390.md`](bmp390.md): BMP390/BMP388 register readback, INT data ready rate, start write faults by software NACK or held SDA, and the IDF I2C hang after a held SDA
|
||||
- [`ipv6.md`](ipv6.md): IPv6 link-local and SLAAC boot and reconnect timing on ESP32 Wi-Fi, with an injected esp-netif delay that forces the race
|
||||
- [`mcp23017.md`](mcp23017.md): MCP23017 IOCON aliasing and INT line behavior (open drain, push-pull, polling) on an ESP8266 with PA to PB jumpers
|
||||
- [`pwm_capture.md`](pwm_capture.md): every LEDC PWM period captured on the same ESP32-C6 through RMT receive and PCNT, with and without power save
|
||||
- [`spi.md`](spi.md): SPI bus capture on the same board with no chips (ESP32 SPI3 slave, edge interrupt snapshots on C3, ESP8266 and Pico 2 W), checked and diffed between builds
|
||||
@@ -0,0 +1,24 @@
|
||||
# Automation code generation A/B (#21978 to #21980)
|
||||
|
||||
A change to how actions, conditions or scripts are generated must not change what runs or in which order.
|
||||
`scripts/automation_trace/automation-trace-common.yaml` (a package) logs a marker `M <case> <values>` at every step.
|
||||
It covers:
|
||||
- merged action chains, delays inside chains, nested `if`/`else` with and/or/not/xor, `choose` with `else`;
|
||||
- `while` with and without a delay, nested and dynamic `repeat`, `wait_until` and `for:`;
|
||||
- `single`, `restart`, `queued` and `parallel` scripts with parameters, a self `script.stop` mid-chain;
|
||||
- `on_value_range`, alarm panel triggers, and parallel scripts with `max_runs` 2, 3 and 4 that execute themselves
|
||||
from an `if` branch.
|
||||
|
||||
`run.sh LABEL` writes the per-run file (board, Wi-Fi from `secrets.yaml`, the package), builds and flashes over
|
||||
`PORT`, takes the device address from the serial log and runs `drive.py HOST OUT`. The driver records the boot run,
|
||||
a "Run All" press and API-driven triggers (Chain Button twice, Num 70 and 20, Sw on and off, Sel c and a). The
|
||||
runner keeps the part from `DRIVER press Run All` on in `out-at<LABEL>.trace`; diff dev against each PR head. The
|
||||
boot run is partly missed while the API connects, so it is left out of the diff.
|
||||
|
||||
- Pass: the Run All pass reaches `M done`, no `SHOULD_NOT_RUN` or `NOT_REACHED` marker appears, and the trace is the
|
||||
same on both builds.
|
||||
- The interleaving of the `queued` and `parallel` scripts with later steps is timing dependent, but it was identical
|
||||
across builds and runs.
|
||||
- To check a real device config as well, OTA the PR build and toggle one light and one switch from an API client,
|
||||
returning each to its original state, and compare states and logs with the base build. Get the owner's approval
|
||||
before flashing a device that is in use.
|
||||
@@ -0,0 +1,28 @@
|
||||
# BLE service and manufacturer data advertise triggers (#21822, #21823, #21827)
|
||||
|
||||
`scripts/ble_adv_triggers` checks `on_ble_service_data_advertise` and `on_ble_manufacturer_data_advertise` over the
|
||||
air on the ESP32 and BK72xx trackers.
|
||||
|
||||
- Advertiser: `advtrig.yaml` on any second ESP32 with BLE. Every 250 ms it switches to the next of six raw
|
||||
non-connectable advertisements (`adv_frames.hpp`, random static addresses `C4:C1:38:00:00:21` to `26`):
|
||||
|
||||
| Address | Contents |
|
||||
|---|---|
|
||||
| `...:21` | service data 181C `AA01` and manufacturer data 02E5 `BB01` |
|
||||
| `...:22` | service data 32-bit 12345678 `AA02` |
|
||||
| `...:23` | service data 128-bit 12345678-9ABC-DEF0-1122-334455667788 (wire order) `AA03` |
|
||||
| `...:24` | manufacturer data 181C `BB04` (the service UUID of `...:21`, other data kind) |
|
||||
| `...:25` | two service data 181C entries, `AA05` then `AA06` (only the first may fire) |
|
||||
| `...:26` | the 128-bit UUID in printed byte order, `AA07` (must not match) |
|
||||
|
||||
- Receivers: `rx-esp32.yaml` (classic ESP32, passive scan) and `rx-bk72xx.yaml` include the same eleven triggers
|
||||
(`triggers.yaml`). `rx.hpp` counts each trigger's hits by payload (`AAnn`, `BBnn`, anything else as `other`) and
|
||||
logs the table every 20 s, plus all adverts and those from the advertiser.
|
||||
- `run.sh esp32|bk72xx LABEL` builds, flashes, captures 150 s and runs `check.py`, which prints PASS or FAIL. Run it
|
||||
once per build (dev merge base and each PR head). About 550 hits per trigger on ESP32 and 170 on a BK7238 in that
|
||||
time.
|
||||
- `mac_address: 00:00:00:00:00:00` meant no filter on the ESP32 tracker before #21823 and filters on that address
|
||||
after it (BK72xx always filtered). Pass `ZERO_MAC=any` for an ESP32 build from before #21823.
|
||||
- BK7238: with no `wifi:` in the config the tracker logs `Scan started` but receives nothing at all, on dev too. The
|
||||
BK config therefore joins Wi-Fi.
|
||||
- Results: https://github.com/esphome/esphome/pull/21827#issuecomment-6489703755
|
||||
@@ -18,3 +18,13 @@ from a real peer on demand. `scripts/ble_event_inject` feeds made up events stra
|
||||
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.
|
||||
|
||||
## ThermoPro advertisements as scan results (#22047, #22054)
|
||||
|
||||
`scripts/ble_event_inject/tpinject.hpp` holds the advertisement records from the `thermopro_ble` unit tests (TP357,
|
||||
TP357S, TP393, TP960R, TP970R, TP972S, TP25, plus invalid and 13 byte probe frames) and `tpinject::inject_all()`
|
||||
feeds each one to `global_esp32_ble_tracker->gap_scan_event_handler()` with the device name and the manufacturer
|
||||
data, the way `scripts/xiaomi_ble` does. Frame N comes from `A4:C1:38:00:10:0N` (up to `:10`), so give each
|
||||
`thermopro_ble` sensor that MAC, include the header and call `tpinject::inject_all();` from an `interval:` or a
|
||||
template button. Compare the published states (or the sensor DEBUG lines) on the PR and on dev; no ThermoPro
|
||||
device is needed.
|
||||
@@ -24,3 +24,44 @@ client never connected fails instead of passing.
|
||||
- `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.
|
||||
|
||||
## ESP32 central against the secure server (#17562, #22081)
|
||||
|
||||
`scripts/ble_server_security/esp32central-*` swaps the roles: an ESP32-S3 runs the secure server
|
||||
(`esp32central-srv.yaml`: plain, encrypted, MITM read and write, and an encrypted notify characteristic, numeric
|
||||
comparison accepted automatically) and a classic ESP32 devkit runs `esp32central-cli.yaml` (`ble_client` with
|
||||
`display_yes_no` and `sc_mitm_bond`, text sensors that log every value, a MITM write and a link cycle every 40 s).
|
||||
`bonds.hpp` logs the bond list Bluedroid loaded from NVS on both boards.
|
||||
|
||||
- The server never starts pairing (a peripheral only answers a security request), and `ble_client` does not start
|
||||
it on an insufficient authentication reply (status 5) the way Android does. The client's `on_connect` calls
|
||||
`esp_ble_set_encryption(remote_bda, ESP_BLE_SEC_ENCRYPT_MITM)` instead.
|
||||
- `esp32central-flow.sh TAG` erases both boards, flashes them, captures a fresh pairing with three link cycles
|
||||
(phase A), then resets both together so each loads its bond (phase B), and prints per phase the connects, numeric
|
||||
comparisons (a fresh pairing), MITM reads, server writes and `Deleting unpaired device` lines. It passes when
|
||||
phase B reads the MITM characteristic with no numeric comparison and no `Deleting unpaired device` line.
|
||||
- On ESP-IDF 5.5.6 every other reconnect pairs again (`Deleting unpaired device` on the disconnect after a bonded
|
||||
reconnect), the regression #22081 describes.
|
||||
|
||||
## Bluedroid hex parsing bench (#22081)
|
||||
|
||||
`scripts/bluedroid_hex_bench/`: `hexbench.hpp` times Bluedroid's `sscanf` parsing (`string_to_bdaddr` and the
|
||||
`btc_config_get_bin` loop) against the parsers in #22081's `esp32_ble/bluedroid_hex.h` on the same inputs, valid and
|
||||
invalid, and checks that the outputs match. `run.sh LABEL` (env `TREE`, `PORT`, `SC`, `PY`, `BOARD`) builds
|
||||
`bench.yaml`, flashes, captures 25 s and fails on any mismatch or a missing `DONE`. Classic ESP32: about 7 to 9 times
|
||||
faster for addresses, 6 to 7 times for 16 and 28 byte keys.
|
||||
|
||||
## ble_client on_notify with a real server (#21821)
|
||||
|
||||
`scripts/ble_notify/`: `server.yaml` (ESP32-S3 GATT server; a numeric and a text characteristic notify every 2 s,
|
||||
with uint16 frame and empty notification rounds, set through `id(c)->set_value(...)` and `notify()` in a lambda) and
|
||||
`client.yaml` (classic ESP32 client with two `on_notify` triggers per sensor, one of them delayed, and a link cycle
|
||||
every 60 s). `run.sh LABEL` (env `TREE`, `SERVER_PORT`, `CLIENT_PORT`, `SERVER_BLE_MAC`, `SC`, `PY`) flashes both,
|
||||
captures the client 150 s and counts `on_notify` against `on_value` per sensor.
|
||||
|
||||
- `run.sh` also requires `All clients established, services released`: before #21821 it never appears with a text
|
||||
sensor `on_notify`. The delayed txt `on_notify` may miss only the last notify of the capture.
|
||||
- The server takes one connection. Park any other client that would connect to it with
|
||||
`esptool --after no-reset chip_id` (the board stays in the bootloader).
|
||||
- An ESP32-C6 devkit whose native USB port refuses esptool can be flashed and logged through its UART bridge port
|
||||
with `logger: hardware_uart: UART0`.
|
||||
@@ -0,0 +1,28 @@
|
||||
# bluetooth_proxy
|
||||
|
||||
## Held GATT link after a half-open API session (#21256, #22077)
|
||||
|
||||
When Home Assistant stops answering without closing its socket, the proxy must not keep the BLE link it opened for
|
||||
that session. `scripts/bluetooth_proxy_half_open/` reproduces it with:
|
||||
- `proxy.yaml`: the proxy under test on a classic ESP32 devkit (ESP-IDF, VERBOSE logger with the busy tags at DEBUG,
|
||||
encrypted API). Build it from the tree under test with a `secrets.yaml` (`wifi_ssid`, `wifi_password`, `api_key`).
|
||||
- `peripheral.yaml`: a second ESP32 (an S3 here) as a peripheral that only advertises while nobody is connected
|
||||
(`esp32_ble_server` takes one client by default), with manufacturer data `FF FF 21 56` so the client can find it.
|
||||
- `ha.py`: the Home Assistant stand-in (aioesphomeapi, `client_info` "Home Assistant test"). Roles: `owner` connects
|
||||
to the peripheral through the proxy and idles, `observe` counts the peripheral's adverts and the slot report and can
|
||||
ask for a connect at the end, `swap` connects at once and retries on the first advert. Every role stays subscribed
|
||||
to advertisements: with no subscriber the proxy's loop disconnects every link at once, which hides the bug (an
|
||||
early run failed with `disconnect while backend idle` for that reason).
|
||||
|
||||
`cases.sh PROXY_HOST LABEL` (env `PROXY_PORT`, `PERI_PORT`, `SC`, `PY`, `API_KEY`) captures both boards and runs
|
||||
case C (old client killed, new client after 4 s), case H (old client frozen with `SIGSTOP`, so the socket stays open
|
||||
with no FIN; new client after 4 s, watched 300 s, then it asks to connect and releases) and case N (frozen and nobody
|
||||
reconnects for 200 s). `cases.sh PROXY_HOST LABEL swap` instead repeats H three times with the new client
|
||||
connecting at once (`ha.py ... swap ADDR`) and retrying on the first advert. Both end with PASS or FAIL: the cases
|
||||
fail when an observer exits non-zero, never sees the peripheral advertise (the held link) or the proxy resets, and
|
||||
swap fails when a round never connects. An owner that never got the link also fails (a rig problem, not a result).
|
||||
|
||||
- The proxy drops a frozen session as unresponsive after about 144 s.
|
||||
- Before #22077, case H holds the link for good: 0 adverts, and the address stays in the slot report. With #22077 the
|
||||
link is released in the same millisecond as the `replaces` warning. In case 4 the first request logs `Connection
|
||||
request ignored, state: DISCONNECTING` (connected=false at 0.2 s) and the retry connects at 0.5 to 0.7 s.
|
||||
@@ -0,0 +1,35 @@
|
||||
# BMP390 / BMP388 (bmp3xx_i2c): register readback, data ready rate, start write faults (#21632, #21633)
|
||||
|
||||
Bench: ESP32-S3 devkit (native USB console, ESP-IDF), BMP390 breakout on 3V3, SDA GPIO1, SCL GPIO2, CS and SDO open
|
||||
(address 0x77), INT to GPIO42. Some breakouts answer chip ID 0x50 (the BMP388 value); the register map is the same.
|
||||
|
||||
`scripts/bmp390/`:
|
||||
- `gen.py NAME [--rate 50Hz] [--iir 128x] [--interval 1s] [--fault --kinds ... --probe-delay MS] [--reset-at 40,80]
|
||||
[--open-line] [--bus-reset]` writes `NAME.yaml` (no Wi-Fi).
|
||||
- `bmp390probe.hpp` wraps the sensor's I2C bus (`set_i2c_bus()` from an `on_boot` priority 800 lambda), dumps
|
||||
CHIP_ID, ERR, INT_CTRL, PWR_CTRL, OSR, ODR and CONFIG before and after setup and every 10 s (`REGS` lines), turns on
|
||||
data ready on INT after setup (`INT_CTRL = 0x42`, push-pull, active high: the driver never writes INT_CTRL and its
|
||||
soft reset in setup clears it), counts rising edges (`INT edges=N in M ms`) and logs every pressure value with four
|
||||
decimals (`READ <ms> <hPa>`).
|
||||
- `run.sh NAME SECONDS [TAG]` (env `TREE`, `PORT`, `SC`, `PY`) builds, flashes, captures from a fresh boot and runs
|
||||
`stats.py CAPTURE [SKIP_S]`: read count, cadence, INT rate, detrended noise (21 sample window), repeated values.
|
||||
|
||||
Flows:
|
||||
- Register A/B: the same config on both builds; compare `REGS` after setup and between updates. Forced mode leaves
|
||||
PWR_CTRL at 0x03 (sleep, both sensors enabled), normal mode reads 0x33. ODR codes: 0x00 200 Hz, 0x02 50 Hz, 0x07
|
||||
1.5 Hz, 0x0B 0.1 Hz (period 5 ms << code).
|
||||
- Real output rate: INT edges per 10 s window. Forced mode gives one edge per update.
|
||||
- Settling: the bench cannot make a pressure step, but the soft reset in setup restarts the IIR filter with an
|
||||
offset, so the first readings after boot show its step response (50 Hz with 128x settled in about 8 s).
|
||||
- Reset behind the driver: `--reset-at` writes CMD 0x7E = 0xB6 at those seconds; a driver that handles it logs that
|
||||
the sensor was reset and restores the registers.
|
||||
- Start write faults (`--fault --kinds`, one letter per event at 30 s and then every 20 s): `N` the next start write
|
||||
NACKs in software, `M` every start write NACKs for 5 s, `T` SDA held low through the GPIO matrix for the next start
|
||||
write, `W` the same for every transfer for 5 s, `L` a continuous 5 s hold, `S` a 300 ms hold, `H` and `D` held
|
||||
transfers of the harness only. `PROBE` lines check the bus `--probe-delay` ms later.
|
||||
|
||||
Pitfall: on the S3 with ESP-IDF 5.5.6, any transfer that fails while SDA is routed to a GPIO held low (or routed away
|
||||
with the line floating high) leaves the `i2c_master` driver hung on every build, with or without the sensor: every
|
||||
later transfer logs `I2C software timeout` while the lines read idle high and the saved pad registers are back, and
|
||||
`i2c_master_bus_reset()` does not help. Use the software NACK kinds (`N`, `M`) to test what a component does after a
|
||||
failure and the recovery; use the wire kinds only to show the first failure.
|
||||
@@ -0,0 +1,46 @@
|
||||
# buttons
|
||||
|
||||
Button platforms converted to `button::ApplyButton` change no behaviour on the wire, so each test compares what a
|
||||
press does on dev and on the PR: the pad, the action call or the bytes sent. All three run on a D1 mini with no
|
||||
wiring and no other device.
|
||||
|
||||
## output button pad timing (#21907)
|
||||
|
||||
`scripts/output_button`: two `gpio` outputs on GPIO5 and GPIO4 and three output buttons (500 ms and 1000 ms on
|
||||
GPIO5, 200 ms on GPIO4). `edges.hpp` attaches a CHANGE interrupt to each output pad and stamps every edge with
|
||||
`micros()`; on ESP8266 a pad reads back its own output level. A script runs four cases three times: one press, a second
|
||||
press 250 ms in (the reset restarts, high 750 ms), both outputs at once, and the 1000 ms button then the 500 ms
|
||||
button on the same output 300 ms later (low at 800 ms). `run.sh LABEL` flashes, captures 45 s and prints the edge
|
||||
lines; diff dev against the PR. Each button keeps its own reset timer, so the 1000 ms button's reset fires later on a
|
||||
pad that is already low and shows no edge.
|
||||
Results: https://github.com/esphome/esphome/pull/21907#issuecomment-6516597178
|
||||
|
||||
## homeassistant button through a raw API client (#21908)
|
||||
|
||||
`scripts/homeassistant_button/ha-button-common.yaml` has three `homeassistant` buttons: `button.doorbell`,
|
||||
`input_button.chime`, and one with no `entity_id` that is an `api: wizard:` input. An `on_boot` press runs before
|
||||
any client connects. `habdrive.py HOST` is a plaintext API client that subscribes to Home Assistant actions, presses
|
||||
each button and prints every action call (service and data). It sets the wizard input with `WizardInputSetRequest`
|
||||
(message 158, key = FNV-1 hash of the input's id), first to an `input_button`, then to a `button`, then to an invalid
|
||||
value the device must reject. `run.sh LABEL` flashes, captures and drives it. Expect `button.press` or
|
||||
`input_button.press` per entity, `No entity ID set yet` and no call before the wizard input is set, and `No clients
|
||||
connected` from the boot press.
|
||||
|
||||
- `homeassistant` entities are internal by default, so the client sees no buttons unless they set `internal: false`.
|
||||
- Wait for each reply type instead of a fixed time: the hello reply can take more than a second, and a fixed wait
|
||||
shifts every later reply by one step.
|
||||
- Results: https://github.com/esphome/esphome/pull/21908#issuecomment-6516693220
|
||||
|
||||
## uart button on the UART0 loopback (#21906)
|
||||
|
||||
`scripts/uart_button/uart-button-common.yaml` puts four uart buttons on UART0 of a D1 mini: a 10 byte string, five
|
||||
raw bytes with `0x00` and `0xFF`, the same string again (shares one generated function on the PR), and 64 bytes. It
|
||||
sets the UART0 loopback bit in `on_boot`, so every press comes back on RX, and `uart: debug: direction: BOTH` logs
|
||||
both sides. `run.sh LABEL HOST` flashes by USB and runs `ubdrive.py`, which presses each button 3 times over the API
|
||||
and saves the log. Every `>>>` line must equal the configured payload and its `<<<` line.
|
||||
|
||||
- Set the loopback bit at `on_boot` priority -100. At 800 the UART setup runs afterwards and clears it.
|
||||
- Nothing else reads the bus, so the debugger needs `dummy_receiver: true` to log RX.
|
||||
- UART0 is the bus, so `logger: baud_rate: 0` and logs go over the API. The device address comes from DHCP or mDNS.
|
||||
- An RX line sometimes lands after the next press marker when the log stream lags. That is not a byte difference.
|
||||
- Results: https://github.com/esphome/esphome/pull/21906#issuecomment-6516762337
|
||||
@@ -0,0 +1,26 @@
|
||||
# Crash report over the API (#21596, #22288)
|
||||
|
||||
#21596 lets an API client fetch the crash recorded on the previous boot; #22288 records `abort()` as a crash on
|
||||
LibreTiny. `scripts/crash_report/crash-common.yaml` (a package: Wi-Fi from `secrets.yaml`, plaintext API, OTA,
|
||||
DEBUG logger) adds three buttons: `crash abort`, `crash null store` and `crash div zero`. The per-run file supplies
|
||||
the board and the name. Flash it, then run the drivers (they import `scripts/apiconn.py`, so run them in place or
|
||||
put `scripts/` on `PYTHONPATH`):
|
||||
|
||||
- `drive.py HOST BUTTON_OBJECT_ID` (for example `crash_abort`) reads the capability, presses the button, reconnects
|
||||
after the reboot and fetches the report twice (reason, PC, cause, fault address, build time, per-core backtrace),
|
||||
subscribes to logs with `dump_config` to see what is replayed, and checks the capability from a second client.
|
||||
It reads an earlier report first, so a stale one cannot pass. It exits 1 when the device still answers after the
|
||||
press, does not offer a report after the crash, or the report has no reason or a PC of 0.
|
||||
- `peek.py HOST` prints only the capability and how many replayed `CRASH DETECTED` lines a new log client gets. To
|
||||
only press a button, use `scripts/api_button.py HOST - press "crash abort"`.
|
||||
- Decode the PC and the backtrace with the build's ELF (`addr2line -e firmware.elf`). On RTL8720C a noreturn
|
||||
`bl abort` can be the last instruction of a function, so the return address points at padding: decode
|
||||
`(addr & ~1) - 2` to get the call line.
|
||||
- `__builtin_trap()` faults on every board. A store to `nullptr` does not fault on every chip (BK7238 and RTL8720C
|
||||
ignore it), so the `crash null store` button proves nothing there.
|
||||
- With #22288, `abort()` reboots BK7238, RTL8720C and LN882H boards in 1 to 2 s and logs `Reason: Abort` with the PC
|
||||
at the call site. Before it, the BK7238 hangs until reset, the RTL8720C restarts with no record and the LN882H
|
||||
waits about 18 s for its watchdog.
|
||||
- Quick crash reboots count toward BK72xx safe mode, so space out back-to-back crash tests on those boards.
|
||||
- A device that keeps the previous crash replays it to every new log client until the next crash; that is one
|
||||
record, not two.
|
||||
@@ -0,0 +1,13 @@
|
||||
# crc32 helper and the signed OTA block check (esphome#22398, 2026-11-07)
|
||||
|
||||
`scripts/crc32/crctest.hpp` checks `esphome::crc32()` against Python `zlib.crc32` values: `"123456789"` = `0xCBF43926`,
|
||||
empty input returns the seed, chained calls (4 + 5, 300 one-byte calls, 97-byte chunks from an odd offset), bytes
|
||||
0..255, 32 × 0xFF, 4093 bytes from offsets 0 to 3, and 8 KiB timed with `(esphome::micros)()` (best of five).
|
||||
`common-crc.yaml` runs it at boot and every 20 s and also makes an unqualified `crc32(...)` call from a lambda, which
|
||||
catches a clash with anything the generated `main.cpp` pulls in. Paths and timings seen (8 KiB): ESP32 ROM 512 µs,
|
||||
ESP8266 PROGMEM nybble table 3072 µs, RP2350 932 µs, BW15 (FlashDB) 656 µs, BW12 2000 µs, BK7238 1026 µs.
|
||||
|
||||
The signed OTA signature block check (`block_is_valid()`), the PR's only converted caller, ran the accept and reject
|
||||
cases in `ota.md` (signed OTA with several trusted keys).
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/22398#issuecomment-6716330334 and #issuecomment-6716545831.
|
||||
@@ -0,0 +1,35 @@
|
||||
# EZO circuits without hardware: a fake I2C bus (#21840)
|
||||
|
||||
`scripts/ezo_fake_bus`: the real `ezo` component runs on a classic ESP32, but an `on_boot` priority 900 lambda calls
|
||||
`id(ph).set_i2c_bus(&fakeezo::bus)`. `fake_ezo.hpp` is an `i2c::I2CBus` subclass that answers like an EZO pH circuit
|
||||
and logs every write and read with the ms since the command: `R`, `i`, `Slope,?`, `Cal,?`, `T,?`, `L,?`, `Name,?`,
|
||||
and test replies `pend` (254 twice), `bad` (2), `nodata` (255), `nak` (read NACK) and `full31` (32 bytes with no
|
||||
terminator), and `sNN` (an NN-character reply, terminated when the read has room). `ezo.yaml` runs the steps:
|
||||
command bursts larger than the queue, custom commands, calibration, LED, sleep and address changes, and the error
|
||||
replies.
|
||||
|
||||
- The same trick works for any I2C device whose driver only needs write and read: swap the bus pointer, no wiring.
|
||||
A real `i2c:` block is still needed so the config validates.
|
||||
- `run.sh LABEL` builds, flashes, captures 90 s and writes `out-ezo<LABEL>.events`. Compare dev and the PR: the
|
||||
replies, callback texts, read values and the wait before each read (912 ms for reads and calibration, 304 ms for
|
||||
the rest) must match; differences in queue limits are the PR's intended change.
|
||||
- Found with it: dev read past its 32-byte buffer on an unterminated reply (a custom callback got 56 bytes), and
|
||||
two queued address changes both went to the first new address.
|
||||
- Results: https://github.com/esphome/esphome/pull/21840#issuecomment-6493565259
|
||||
|
||||
## Trigger argument lifetime (StringRef or an owning copy, #22204)
|
||||
|
||||
Give each text trigger two automations (`- then:` list items): a synchronous one that logs `x`, `x.size()` and
|
||||
`strlen(x.c_str())` and hands `x` to a parallel `script` (`text: !lambda return x;`, the script delays 2.5 s), and
|
||||
one with `delay: 3s` or `wait_until` that logs `x` afterwards. Fire bursts so newer replies reuse the reply buffer
|
||||
during the wait, then diff the synchronous texts, in order, against the delayed and script texts. Check the generated
|
||||
`main.cpp` for `StringRef` or `TriggerForwarder<std::string>`. The fake's `sNN` replies give texts of any length.
|
||||
|
||||
## A real EZO circuit
|
||||
|
||||
An EZO-HUM ships in UART mode (blinking green, streams `33.91\r` every second at 9600 baud). To switch it to I2C
|
||||
without bridging PGND, flash a one-shot software UART build (TX to the EZO RX, RX from the EZO TX) that writes
|
||||
`I2C,111\r`; the LED turns blue and the circuit answers at 0x6F. Replies: `i` gives `HUM,1.01`, `Status`
|
||||
`?STATUS,S,3.31`, `O,?` `?O,HUM,T,DEW`, and `R` with all outputs `34.82,22.03,Dew,5.85`; `Name,x` returns no text.
|
||||
EZO-HUM rejects `T,?`, `Slope,?` and `Cal,?` (syntax error), so `on_t`, `on_slope` and `on_calibration` need another
|
||||
circuit or the fake bus. Log every callback with an `EV` prefix and count them per step.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Fake I2C devices without a chip
|
||||
|
||||
## Fake devices on an in-firmware I2C bus
|
||||
|
||||
When there is no chip and no wiring, subclass `i2c::I2CBus` in an include, implement `write_readv()` only, answer
|
||||
the registers the driver reads and check each write. Point the component at it from an `on_boot` lambda that runs
|
||||
before the component's `setup()`: `id(x)->set_i2c_bus(&fake::bus)` at priority 950, which is ahead of every I2C
|
||||
component (hubs set up at IO, 900, sensors at DATA, 600). The configs here that use 700 to 1000 work for their
|
||||
components; use 950 for a new one. Keep a real `i2c:` block in the config so validation passes. Bulk
|
||||
loops in a button lambda must call `App.feed_wdt()` and keep their per-iteration logs below INFO: 2000 DEBUG lines at
|
||||
115200 baud trip the task watchdog, which is a fault of the harness and not of the PR.
|
||||
|
||||
`templates/i2c_fake/` holds the fakes; each `*-common.yaml` or `*-test.yaml` is a classic ESP32 or ESP32-S3 config
|
||||
that includes its `.hpp` (add `esphome: name:` for the `-common` ones):
|
||||
- `ccsfake.hpp`, `ccs-common.yaml`: CCS811 at 0x5A (#21927). Answers setup and update reads and checks every
|
||||
ENV_DATA write against the humidity and temperature the case set; "CCS Cases" runs edge values, "CCS Sweep" 2000
|
||||
random ones and logs `SWEEP checked N bad M bad_reads R bad_writes W`. A read without a register, of a register
|
||||
the fake does not model or of the wrong length gets a NACK and counts in `bad_reads`; a write other than the
|
||||
driver's SW_RESET, APP_START, MEAS_MODE and BASELINE writes (each with its own length) gets a NACK and counts in
|
||||
`bad_writes`. The CASES and SWEEP lines end in PASS only when something was checked and `bad`, `bad_reads` and
|
||||
`bad_writes` stayed 0, and in FAIL otherwise.
|
||||
- `eefake.hpp`, `ee-common.yaml`: HTE501 (0x40) and TEE501 (0x48) with CRC words; "Next" steps through raw
|
||||
temperature words and logs the value a signed hundredths decode should give (#21930). A command other than
|
||||
identification and measure, and a read longer than the reply, gets a NACK and counts as `bad`. The DONE line
|
||||
200 ms later, after both reads, shows the running `bad` total, the measure replies each driver read and the
|
||||
published values; it ends in PASS only while `bad` is 0, both drivers read a reply this case and both published
|
||||
the expected temperature (and HTE501 the fixed humidity).
|
||||
- `scdfake.hpp`, `scd-common.yaml`: SCD4x at 0x62 and 0x63 with Sensirion CRC; logs every `0xE000` ambient pressure
|
||||
write, and the CASE and SHOW lines count writes with a bad CRC apart from the good ones. A command the scd4x driver
|
||||
does not send, one sent with the wrong length (2 bytes bare, 5 bytes with one CRC word), or any other command with a
|
||||
bad CRC, gets a NACK and counts as `bad writes`. A read after a command the fake does not answer, or one longer than the command's
|
||||
reply, gets a NACK and counts as `unknown reads`. The second instance has a fixed `ambient_pressure_compensation`
|
||||
to cover the setup path (#21935). Each CASE line ends in PASS only when bad CRC, bad writes and unknown reads did
|
||||
not move, the `0xE000` writes match `expect_e000()` (none, or one with the expected value) and, for a source case,
|
||||
CO2 was published. `RANGE_CHECKED` in `scdfake.hpp` picks the rule: false truncates as dev does, true skips values
|
||||
outside 700 to 1200 hPa and rounds as #21935 did. The SHOW line ends in PASS when nothing bad was counted, neither
|
||||
instance failed and 0x63 got exactly one write of its fixed value.
|
||||
- `sxfake.hpp`, `sx-common.yaml`: two SX1509 register files with reset values and the `7D 12/34` reset, recording
|
||||
every write. Only a byte that lands on RegReset (0x7D) drives the reset, so a burst through it keeps the bytes for
|
||||
0x7E and 0x7F. A read without a register, or a transfer past 0x7F, gets a NACK and `dump_log()` warns with the
|
||||
count. Compare per address: hub setup order can legitimately move between hubs (#21897). Outputs of an Apply
|
||||
platform are distinct template types, so a lambda list of them needs
|
||||
`std::initializer_list<output::FloatOutput *>{...}`. Each case ends with an END line, PASS only while no write was
|
||||
dropped and no transfer was bad; the writes themselves are judged by the diff.
|
||||
- `msfake.hpp`: eight MS8607 pressure/temperature fakes at 0x76 and up and humidity fakes at 0x40 and up, with faults
|
||||
per device (reset NACKs, bad PROM CRC4, zero D1 every Nth read, humidity CRC8 errors, out of range D2, full scale
|
||||
humidity). It counts every command, so skipped conversions show in `report()`, and an ADC read with no conversion
|
||||
pending answers 0 like the real chip and counts as `adc_no_conv`, and a humidity read with no measure pending gets
|
||||
a NACK like the real chip in no-hold mode and counts as `read_no_measure` (a reset aborts either); each injected
|
||||
fault counts when it is delivered (`inj_reset_nack` with `reset_nack_left`, `inj_zero_d1`, `inj_bad_crc`), so a
|
||||
run that never reached a fault path shows 0 there; a PROM read, conversion or humidity measure
|
||||
before that device's first reset counts as `no_reset`; a humidity read of any shape but a bare
|
||||
3 byte read, and a pressure/temperature read that is not a 2 byte read of a PROM word (even addresses 0xA0 to 0xAC) or
|
||||
a 3 byte ADC read, gets a NACK and counts as `bad_reads`; a bare read answers the last PROM or ADC pointer and never
|
||||
repeats a reset or conversion; a command write the fake does not know, or one longer than a
|
||||
byte, gets a NACK and counts as `bad_cmds`. Point both the component and its `humidity_i2c_id` device at the bus. That
|
||||
driver logs no values, so give each sensor an INFO `on_value` log (#16487).
|
||||
- `htfake.hpp`, `ht16k33-char-test.yaml`: HT16K33 chips at 0x70 to 0x77 (0x74 and 0x76 NACK) keeping the 16 byte
|
||||
display RAM and a command trace, rendered back as text for the Adafruit 0.56" 7-segment backpack ("12:34",
|
||||
decimal points, "!" for stray bytes); a read gets a NACK and counts as malformed. The test runs 64 checks (setup order, font, colon and dots, chained chips,
|
||||
printf/strftime/clock, dimming, blink, standby, NACK) and ends with `ALL DONE pass=N fail=M` and PASS or FAIL (#8520). It found a
|
||||
crash (null glyph table) when one display had `add_characters` and another did not.
|
||||
- `ssfake.hpp`, `seesaw-test.yaml`: Adafruit seesaw at 0x36 (ATtiny817), 0x49 (SAMD09) and 0x4A (NACK): status,
|
||||
GPIO bulk with the pull latch, ADC, touch, encoder and NeoPixel with chunk offsets, and the write to read gap per
|
||||
module. A read it does not model, a write without a register and a write to a module or register it does not model
|
||||
(or with the wrong data length) get a NACK and count in `bad`. A 100 ms interval step machine drives it and ends with `ALL DONE` and PASS or FAIL (#13538).
|
||||
- `bldcfake.hpp` (three M5Unit-BLDC units at 0x65 to 0x67 with simple motor physics, one that never spins down),
|
||||
`gtfake.hpp` (GT911 at 0x5D: switches, max values, touch points and key byte) and `sdpfake.hpp` (SDP3x/SDP8xx at
|
||||
0x21 to 0x26: product IDs, an unknown product, a failing read every third time, a failing ID read, stop refused
|
||||
while idle) have no config yet; wire them the same way. Each fake sets up its own faults in its constructor, so
|
||||
there is no init step to call. Each NACKs a read it does not model or one of the wrong
|
||||
length, and a write or command it does not know, and counts them as `bad_reads` and `bad_writes` (`bad_cmds` for
|
||||
the SDP) in `report()`; check those are 0. The SDP report also counts each injected ID read NACK at 0x24 as
|
||||
`id_fail`, so 0 there means the driver never reached that fault.
|
||||
|
||||
Check the PR head again just before posting results: a head that moves during a run means a retest.
|
||||
|
||||
## Pico 2 W as an I2C target (#22236)
|
||||
|
||||
`scripts/pico_i2c_target/`: `picofake.hpp` is a raw RP2350 I2C0 interrupt handler on GP4 (SDA) and GP5 (SCL) at 0x76 that
|
||||
answers one byte per read request from a 256 byte register file, so any read length is byte exact. `picofake.yaml`
|
||||
plays a BME280 (`chip_id` 0x60) or BMP280 (0x58) with the datasheet calibration; every 15 s it switches between the
|
||||
datasheet example readings and the 0x80000 "no measurement" values, and logs `STAT` counters every 5 s. `bad`
|
||||
counts reads and writes of registers the drivers do not use (soft reset only with 0xB6, read pointer wrapping past
|
||||
0xFF). The DUT is
|
||||
an ESP32-C3 (`dut-common.yaml`, GPIO6/GPIO7) with two sensors on the one fake (`dut-bme280.yaml`,
|
||||
`dut-bmp280.yaml`), the second with oversampling NONE. `run.sh bme280|bmp280 LABEL` flashes the DUT, starts the
|
||||
fake capture and waits for a `STAT` line as the baseline, then resets and captures the DUT and prints the distinct
|
||||
values per sensor. The fake capture runs on until a `STAT` line arrives after the DUT capture ends, so that line
|
||||
covers the whole run. It prints PASS when every sensor gave a numeric state, that `STAT` line came, and the fake's
|
||||
`abrt` (read replies the controller aborted) and `bad` counts did not move from the baseline; diff the output of dev
|
||||
and the PR.
|
||||
|
||||
The ESP32-S3 I2C slave v2 driver cannot emulate a register device: answering from a task and straight from the ISR
|
||||
both gave shifted multi-byte reads. Use the Pico.
|
||||
|
||||
## DS2482-800 simulator (ds248x)
|
||||
|
||||
`scripts/ds248x_sim/`: `dssim.yaml` with `sim.hpp` turns a Pico 2 W into a DS2482-800 at 0x18 (GP4 SDA, GP5 SCL)
|
||||
with four DS18B20s: channel 0 at 21.5 °C, channel 1 at 33.0 °C, channel 2 at -5.25 and 40.0 °C. The DUT is an
|
||||
ESP32-C3 (`dspd.yaml`, GPIO4/GPIO5 plus GND); the internal pull-ups are enough at 50 kHz. Faults are one character
|
||||
on the Pico's USB console: `c` NACKs the read pointer write after the next channel select, `z` NACKs the 9 reads
|
||||
after the next 0xBE, `b` and `k` stick busy with a failed reset or config write, `x` and `X` make it dead and alive.
|
||||
`SC=<serial_capture.py> python3 drive.py TAG SIM_PORT DUT_PORT [c,cd,z,b,k,dead]` (default `c,z,b,k,dead`; `cd`
|
||||
runs the `c` fault densely, 28 times about 3 s apart) resets the DUT per phase and prints
|
||||
readings, wrong values, log counts and the simulator's counters (`wrong_ch` counts ROM matches on the wrong
|
||||
channel). The DUT is captured with `--ms` so the log can be split at the last fault: the recovery window starts
|
||||
`SETTLE` (3) seconds after it. A phase fails when any of the four sensors got no correct reading in the recovery
|
||||
window, the DUT logged a CRC, held low, busy timeout or no response line in it, a temperature is wrong, a phase other
|
||||
than `dead` logged `recovery failed`, `DS248x hub failed` or a component marked failed, a ds248x warning is still
|
||||
set at the end of the phase, the DUT capture
|
||||
failed (stderr in `TAG-<phase>.err`), a fault moved its injection counter (counted when the DUT meets the
|
||||
fault, not when it is armed) by other than the number of faults sent (`arm_lost` counts arms that landed while one
|
||||
of the same fault was still pending and merged into it), a NACK fault never moved `nacked`, a write delivered data while a NACK was armed
|
||||
(`nack_miss`, also counted when the write is followed by a repeated start into a read; the NACK never reached the wire and stays pending), the DUT sent an address-only write (`empty_wr`), a write was cut off by a repeated start into another write and
|
||||
never processed (`dropped`),
|
||||
a fault is still pending (`pend=1`) at the end of the
|
||||
phase, the simulator counted a config write with a wrong complement (`cfg_bad`) or an unknown, short or overlong
|
||||
command (`bad_cmd`), a counter is missing from the simulator's CNT line, the simulator aborted a read reply
|
||||
(`tx_abrt`, not gated in the `dead` phase, which switches the controller off), a phase other than `b` and `k` (which
|
||||
reset the chip under the driver) counted `wrong_ch` or a 1-Wire command without the active pull-up bit (`ops_no_apu`),
|
||||
the simulator never logged `DEAD` for the `dead`
|
||||
phase, or the DUT log of the `dead` phase has no sign of the outage (`did not respond`, a ds248x warning,
|
||||
`DS248x hub failed` or a component marked failed). It ends with PASS or `FAIL:` and the failed phases. The whole
|
||||
phase CRC, held low, busy timeout and no response counts are printed for information: the faults cause them.
|
||||
|
||||
- Sensors polled at the same interval go through the channels in blocks, so a stale channel cache never shows.
|
||||
Poll them at different intervals, as `dspd.yaml` does (1, 1.3, 1.7 and 2.3 s).
|
||||
- The NACKs use the RP2350 `slv_data_nack_only` register, set at STOP.
|
||||
- A NACKed write and an address-only write both end with no data, so the simulator cannot tell them apart.
|
||||
The ds248x driver never sends one, `dspd.yaml` sets `scan: false` and the simulator only sees STOPs after its
|
||||
own address, so `empty_wr` (address-only writes outside a NACK window) stays 0; a nonzero count means `nacked`
|
||||
may include writes that never carried a NACKed byte.
|
||||
|
||||
## I2C fault injection in software (vl53l0x)
|
||||
|
||||
`scripts/i2c_fault/`: `faultbus.h` wraps the real `I2CBus` and returns NACK for chosen transfers to one address. An
|
||||
`on_boot` priority 800 lambda swaps the sensor's bus (`id(d)->set_i2c_bus(&fb)`) before its setup, and an interval
|
||||
arms the faults. `vlpf.yaml` runs a VL53L0X on an ESP32-S3 (GPIO8/GPIO9) through quiet, fault A (the next
|
||||
measurement-in-progress read fails), quiet, fault B (the next measurement start fails) and quiet phases over 150 s,
|
||||
with a model ID probe every 10 s to tell a driver stall from a dead chip. `run.sh LABEL` builds, flashes, captures
|
||||
and runs `an.py`, which counts readings per `PHASE` line. Each `PHASE` line clears a fault that was armed and has
|
||||
not fired yet and logs the fired totals so far, so `an.py` counts the fires of each phase. It fails when the phases are not quiet, faultA, quiet,
|
||||
faultB, quiet (a short capture or a restart), the first quiet phase has no readings, a later quiet phase reads less
|
||||
than half as often as the first, a later quiet phase reads NaN (`nan` or `-nan`) or logs the driver's "update called before prior
|
||||
reading" warning when the first did not (a target out of range reads NaN in every phase), a quiet phase has a failed
|
||||
model ID probe, one that did not return 0xEE or no successful probe at all,
|
||||
anything crashed or rebooted, or a fault did not fire in its own phase or fired in another one.
|
||||
|
||||
Prefer this to glitching the wire. Holding SDA low by rerouting the pin (`esp_rom_gpio_connect_out_signal` to
|
||||
`SIG_GPIO_OUT_IDX` and back to `I2CEXT0_SDA_OUT_IDX`) wedges the ESP-IDF controller, which then needs
|
||||
`i2c_master_bus_reset()` after each glitch, and glitches timed onto writes left the VL53L0X answering 0x00 until a
|
||||
power cycle.
|
||||
@@ -45,3 +45,24 @@ without the fix logs `Applying the report that arrived during the transition` an
|
||||
ends at 100% on both builds because the last call's target differs from the held report, so it is a regression
|
||||
check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade.
|
||||
The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695
|
||||
|
||||
## Wizard-linked homeassistant entities on every platform (#21908)
|
||||
|
||||
`scripts/homeassistant_wizard/`: `ha-all-common.yaml` adds to the buttons of `ha-button-common.yaml` one entity with a
|
||||
fixed `entity_id` and one linked to an `api: wizard:` input for each of sensor (plus one with `attribute:`),
|
||||
binary_sensor, text_sensor, number, switch, select, text and light (the wizard light is in `ha-wiz-light.yaml`), and
|
||||
logs the ESP8266 heap every 5 s (`ramchk`). `hadrive.py HOST` plays Home Assistant over a plaintext API connection: it
|
||||
records the state subscriptions the device asks for, sends states (including number and text min, max, step and mode,
|
||||
select options and light supported_color_modes), commands every writer, sets each wizard input with
|
||||
`WizardInputSetRequest` (key = FNV-1 hash of the input id), changes it to a second ID in another domain, sends an
|
||||
invalid one and reconnects. It prints SUB, STATE and ACTION lines that diff cleanly between two builds, and PASS or
|
||||
FAIL. `run.sh LABEL` (env `TREE`, `PORT`, `SC`, `PY`; `NO_LIGHT=1` for a build without #21908) flashes a D1 mini, takes
|
||||
three idle boots of heap readings and drives one boot. `lightcheck.py HOST` checks again that the wizard light follows
|
||||
only its current ID.
|
||||
|
||||
- Builds before #21908 cannot compile a wizard-linked homeassistant light (the wizard called `set_entity_id()` on the
|
||||
LightState); `NO_LIGHT=1` leaves `ha-wiz-light.yaml` out.
|
||||
- An unset wizard number or select rejects a command in the core (`> max 0`, `has no options`) before the
|
||||
`No entity ID set yet` check; nothing is sent either way.
|
||||
- Field 3 of LightStateResponse is brightness, not `missing_state`; SwitchStateResponse has no missing flag.
|
||||
- A light publishes only changes, so a report of the state it already has publishes nothing.
|
||||
@@ -0,0 +1,26 @@
|
||||
# HTTPS on web_server_idf (#22185)
|
||||
|
||||
#22185 adds an HTTPS developer API to `web_server_idf` with no YAML option, so `scripts/https_idf/` brings a test
|
||||
component: `ext/httpstest` (`httpstest.hpp`) serves `/hw` (a status line with mode, request count and free heap),
|
||||
`/hw/big` (16 KB) and `/hw/switch/{http,https,https8443,bad}`. A switch runs on the main loop through `defer()`, or
|
||||
3 s later with a `/later` suffix (a stalled TLS client holds the server task, so its switch is armed first), and
|
||||
calls `set_ssl_credentials(cert, key, port)`: plain HTTP on 80, HTTPS on 443 or 8443, or a key that does not match
|
||||
the certificate. Its schema uses `web_server_idf.consume_ssl_sockets`. The run-time switch is the follow-up API; on a
|
||||
tree that only has #22185, drop the switch paths and set the credentials once before `init()`.
|
||||
|
||||
Copy the directory into a scratch directory with a `secrets.yaml`, then `run.sh LABEL` (env `TREE`, `PORT`, `SC`,
|
||||
`PY`): it runs `make_certs.sh` (a self-signed P-256 certificate, its key and an unrelated key in
|
||||
`ext/httpstest/certs.h`; test material, never commit it), builds `https-dut.yaml` (ESP32-C3, logs on USB), flashes,
|
||||
takes the address from the serial log and runs:
|
||||
- `t1.sh HOST`: one GET with the TLS time, 20 sequential, 6 parallel three times and the 16 KB body. The fixture has
|
||||
no event source, so the 501 that #22185 sends for an event stream on HTTPS is not covered here.
|
||||
- `stall.py HOST`: arms a switch to HTTP and to 8443, opens two idle TCP clients (no ClientHello) on 443 before it
|
||||
runs, then switches back; every switch must be accepted and work, and the idle clients must be closed.
|
||||
- `t2.sh HOST`: HTTP and back, an armed switch to 8443 with a client open that sends a handshake record header and
|
||||
never the rest, 60 requests that must all get a 200 for a heap check (compare the `/hw` heap before and after),
|
||||
then the bad key. The server must log the error and stay off, without falling back to plain HTTP; reset the board
|
||||
afterwards.
|
||||
|
||||
The runner fails on any unexpected status, a missing key error for the bad key, a reset or a task watchdog. `https-soak.yaml` adds a switch between 443
|
||||
and 8443 every 20 s for a long run with clients attached. Each HTTPS connection takes about 26 KB of internal RAM
|
||||
(no PSRAM on the C3), so watch the `ALIVE heap=... min=...` lines.
|
||||
@@ -0,0 +1,21 @@
|
||||
# matrix_keypad, wiegand, rdm6300 and key_collector without hardware (#21787, #21788, #21790)
|
||||
|
||||
All on a D1 mini with no wiring: `scripts/hub_input_sim/hub.yaml` and `sim.hpp`.
|
||||
|
||||
- Keypad: one row (GPIO5) and columns GPIO4, 14 and 12 with pull-ups. `sim::press(pin)` switches the column to
|
||||
`OUTPUT_OPEN_DRAIN` driven low, so the single row reads that key pressed on every scan; `sim::release` goes back
|
||||
to `INPUT_PULLUP`.
|
||||
- Wiegand: D0 on GPIO13, D1 on GPIO0. Call `sim::wiegand_arm()` once, then `sim::wiegand(value, bits)` sends the
|
||||
pulses (80 us low, 1.5 ms apart, most significant bit first). `pinMode(OUTPUT_OPEN_DRAIN)` clears the pull-up and,
|
||||
with the output latch low, makes a falling edge that adds a bit: write `GPOS` high before the mode switch and set
|
||||
`GPF |= 1 << GPFPU` after. The interrupt type survives `pinMode`.
|
||||
- rdm6300 reads UART0 with its loopback bit set (`USC0(0) |= 1 << UCLBE`), so the logger runs with `baud_rate: 0`
|
||||
and the log goes over the API. Frames are 0x02, 12 ASCII hex characters (version, 4 id bytes, xor) and 0x03. The
|
||||
uid binary sensor pulses on and off per read.
|
||||
- key_collector takes both the keypad and wiegand as `source_id`.
|
||||
- `run.sh LABEL` flashes over serial, reads the API log until `DONE` and writes `out-hubsim<LABEL>.ev`; diff the
|
||||
`[ev` lines of dev and the PR.
|
||||
- Wiegand frames with valid parity: 26 bit 0x3579bdf (11259375), 34 bit 0x3bd5b7dde (3735928559), 37 bit
|
||||
0x1b4b4b4b4a (24253932965). 0x3579bdd fails parity, so only `on_raw` fires for it.
|
||||
- rc522 and sx1509 need their chips; compile them only.
|
||||
- Results: https://github.com/esphome/esphome/pull/21790#issuecomment-6491675405
|
||||
@@ -0,0 +1,28 @@
|
||||
# hx711 without a chip: fake HX711 on the same ESP32 (#8899)
|
||||
|
||||
`scripts/hx711`: classic ESP32, no wiring. SCK is GPIO32, DOUT GPIO33 (configured as an input with pull-up, so DOUT
|
||||
reads busy until the fake runs). `hxsim.hpp` starts from `on_boot` priority -100 (after the component has set up its
|
||||
pins), switches both pins to `GPIO_MODE_INPUT_OUTPUT` (it reads the clock and drives the DOUT level the component
|
||||
reads back) and runs a task pinned to core 0 (the ESPHome loop runs on core 1; core 0's idle task is removed from
|
||||
the task watchdog).
|
||||
|
||||
- Ready means DOUT low; the task waits for the clock with interrupts off in 2 ms windows. Each rising edge puts the
|
||||
next bit out and the 25th sets DOUT high; extra pulses pick the next conversion (25 A/128, 26 B/32, 27 A/64), and
|
||||
each conversion takes 100 ms. Clock high for more than 60 us is power down (back to A/128), clock low powers up.
|
||||
- Values encode the conversion: A/128 is 128000+n, A/64 64000+n, B/32 -(32000+n), so a published value shows which
|
||||
conversion it came from. `check.py CAP...` matches every `Got value` against the served values and exits non-zero
|
||||
on an unmatched value, no values or a crash.
|
||||
- `mode`: 0 normal, 1 DOUT stuck high, 2 DOUT left low after the read.
|
||||
- The waiting and shifting code must run from IRAM. `IRAM_ATTR` on inline class members either gets inlined into
|
||||
flash code or fails to link ("dangerous relocation: l32r: literal placed after use"); use static free functions
|
||||
with `IRAM_ATTR __attribute__((noinline))` and check with `xtensa-esp32-elf-nm` that they sit at 0x4008xxxx. Run
|
||||
from flash, the first transfer after boot lost one clock pulse (the value shifted right by one bit). Log from the
|
||||
main loop (an interval drains an event ring), never inside the critical section.
|
||||
- Runs: `hxbase` (65 s), `hxpr1` channel B plus set_gain and power actions (115 s), `hxpr2`
|
||||
power_down_after_reading (45 s). `hx-common.yaml` starts each run's `run_all` script and logs the `DONE` line.
|
||||
`build.sh NAME TREE LABEL` writes `NAME<LABEL>.yaml` with its own name (so dev and the PR builds of the same config
|
||||
do not share a build directory) and builds it; then `flashcap.sh DIR NAME<LABEL> TREE SECONDS` (`PORT`, `SC` in
|
||||
the environment), which fails without a `DONE` line and runs `check.py`.
|
||||
- The configs have no Wi-Fi on purpose: the fake keeps core 0 busy with interrupts off in 2 ms windows, which would
|
||||
starve the Wi-Fi task. Keep the harness without Wi-Fi, API or OTA.
|
||||
- Result on #8899: no missed pulses over 141 transfers, and every published value matched.
|
||||
@@ -0,0 +1,20 @@
|
||||
# I2C master interrupt storm and stuck bus faults (esphome#22270 / #22396, 2026-11-06 to 11-07)
|
||||
|
||||
`scripts/i2c_isr_fault/inasim.hpp` is a software INA219 at 0x40 answered on the ESP32 master's own pads (SDA GPIO32,
|
||||
SCL GPIO33, nothing wired): the slave pulls a line low by switching the pad's output mux from the I2C0 signal to plain
|
||||
GPIO output 0 (a wired AND with the master), from a GPIO ISR on core 0 that spins through one transaction. A core 0
|
||||
task re-asserts long stuck lines every tick. From 20 s after boot it injects seeded faults into `p_fault` per mille of
|
||||
transactions (garbage bits, NACK, clock stretch, SCL/SDA held low up to `hold_max_ms`, glitches, vanish).
|
||||
|
||||
`i2cbase.yaml` (package): `i2c:` at `freq` with `scan: true`, an `ina219` polled every `ina_interval`, a bus voltage
|
||||
register read every `hammer`, a boot self test, and 30 s `MASTER`/`SLAVE` counter lines (ok, timeouts by kind,
|
||||
garbage, longest failed run, loop stalls). `example.yaml` shows the substitutions: framework and version (`fw`,
|
||||
`fwver`), `seed` (seed 1 at 5 % hangs dev with an interrupt watchdog on CPU0 at about 245 s, the #22270 report),
|
||||
`p_fault`, `fault_mask`. `run.sh LABEL [SECONDS]` builds, flashes and captures (640 s default); `summary.sh` prints
|
||||
crashes, watchdogs, timeout kinds and the minimum ok reads per window.
|
||||
|
||||
Findings: ESP-IDF 5.4.2 has a different failure (the driver waits for bus idle with no timeout after a NACK, so the task
|
||||
watchdog reboots in a loop with or without the guard); 5.5.5, 5.5.6 and 6.0.1 hang on the storm without the guard and
|
||||
run clean with it. ESPHome never uses the driver's async mode, so there is no asynchronous path to test.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/22396#issuecomment-6713396173
|
||||
@@ -10,3 +10,7 @@ back with a repeated start, probes an absent address and reads both stretch regi
|
||||
(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.
|
||||
On the NiceMCU WB3S V1 board (BK7238), P26 and P24 are on the right-hand header.
|
||||
|
||||
Fake devices that need no real chip (in-firmware buses, the Pico 2 W target, the DS2482-800 simulator, NACK
|
||||
injection) are in [fake_i2c.md](fake_i2c.md).
|
||||
@@ -0,0 +1,14 @@
|
||||
# i2s_audio
|
||||
|
||||
## I2S microphone loopback on one S3 (#22130)
|
||||
|
||||
With no I2S microphone at hand, `scripts/i2s_loopback/` makes one. `dcgen.hpp` runs an ESP-IDF std TX channel on
|
||||
I2S1 as master (GPIO4 BCLK, 5 WS, 6 DOUT, 16-bit stereo) in its own task, switching patterns (DC step, opposite DC
|
||||
per channel, DC plus a 1 kHz tone, full and half scale 50 Hz squares) through `dcgen::set_mode()`. Jumper 4 to 15,
|
||||
5 to 16 and 6 to 17 to an `i2s_audio` microphone on I2S0 with `i2s_mode: secondary`. `dcgen::on_data` (registered
|
||||
with `add_data_callback`) keeps per-segment and last 500 ms stats plus a 1 ms trace of channel 0, and `report()`
|
||||
logs `SEG` and `SETTLE` lines from an interval. `run.sh LABEL [SAMPLE_RATE] [CHANNEL]` flashes and captures 60 s,
|
||||
and `sum.py` tabulates. The segment min and max include the tail of the previous pattern (capture latency), so judge
|
||||
with the tail values. The runner fails on a missing segment, a full-scale square under rms 8000 (no signal through the
|
||||
jumpers), on either channel for a stereo run, or a crash after the first segment.
|
||||
Results: https://github.com/esphome/esphome/pull/22130#issuecomment-6615554179
|
||||
@@ -0,0 +1,28 @@
|
||||
# IPv6 link-local and SLAAC on ESP32 Wi-Fi: boot timing and race injection (#22364)
|
||||
|
||||
`scripts/ipv6_boot/` (ESP32-S3 and ESP32-C3, native USB console, ESP-IDF). One YAML per build
|
||||
(`example-s3.yaml`, `example-c3.yaml` show the shape) with the substitutions `min_count` (network
|
||||
`min_ipv6_addr_count`) and `cap_ms`.
|
||||
|
||||
- `v6probe.hpp` and `v6common.yaml`: `network: enable_ipv6`, VERBOSE logs, and a 50 ms interval that logs milestones
|
||||
from boot (`MS assoc|ipv4|linklocal|slaac|connected <ms>`; addresses come from `esp_netif_get_all_ip6`, so they
|
||||
count once they are preferred, after duplicate address detection), then `RESULT boot ...` with `heap_used`, then
|
||||
disables Wi-Fi for 3 s and logs `RESULT reconnect ...` the same way, then reboots. A phase ends when SLAAC and the
|
||||
connection are both seen, or after `cap_ms` (`timeout=1`).
|
||||
- `v6hf.yaml` adds a `HighFrequencyLoopRequester` (a busy main loop, as with a display or audio).
|
||||
- `v6inject.yaml` links with `-Wl,--wrap=esp_netif_action_connected` and defines `V6_INJECT`; the wrapper in
|
||||
`v6probe.hpp` sleeps 300 ms in the event task before esp-netif brings the station interface up, after the wifi
|
||||
component already queued STA_CONNECTED. That makes the "interface not up yet" race happen on every connect.
|
||||
- `run.sh NAME SECONDS` (env `TREE`, `PORT`, `SC`, `PY`) builds `NAME.yaml` as device `NAME`, flashes, captures the
|
||||
self-rebooting boots and runs `an.py CAPTURE...`: per phase the `esp_netif_create_ip6_linklocal failed` count,
|
||||
boots without link-local, SLAAC or a connection, connection timeouts, and median and worst times.
|
||||
|
||||
- Check the network first: a computer on the same network shows an `autoconf` IPv6 address when a router advertises
|
||||
a prefix. SLAAC arrived about 2 s after link-local on the test network.
|
||||
- The natural race did not show: no failure in about 160 boots on the S3 (normal and busy loop) and the C3 without
|
||||
the fix. esp_event runs handlers in registration order and the wifi component registers before esp-netif, but the
|
||||
event task reaches esp-netif's handler within microseconds. Only the injected delay reproduced the failure.
|
||||
- An extra event handler registered from `on_boot` cannot inject the race: it runs after esp-netif's handler, when
|
||||
the interface is already up. Wrap the esp-netif call instead.
|
||||
- `wifi.disable` removes the addresses, so the reconnect phase creates link-local again on the same path.
|
||||
- Count failures by the probe's `ll=0` too, not only by the warning: the USB console drops lines.
|
||||
@@ -0,0 +1,21 @@
|
||||
# ld2420 without a module (#21914)
|
||||
|
||||
`scripts/ld2420_sim/` runs the hub on a D1 mini UART0 with the loopback bit set and no radar. A `uart: debug` TX hook
|
||||
(delimiter `04 03 02 01`, the command footer) hands each command frame the hub sends to `ldsim::on_tx()`, which writes
|
||||
a success ack right behind it: the hub reads its own echo and then the ack. The acks carry the version (the `fw`
|
||||
substitution), the min and max gate and timeout registers and the gate parameters, so `setup()` completes (`Sim stats`
|
||||
logs `cmds 21 acks 21`). Template buttons then inject energy frames (`Energy steps`) or simple mode `ON`, `OFF` and
|
||||
`Range N` lines (`Simple steps`). Firmware v1.5.3 or older keeps the hub in Simple Mode.
|
||||
|
||||
- `run.sh LABEL energy|simple HOST` builds, flashes and records two `lddrive.py` runs over the API; `compare.py` takes
|
||||
the same run of two builds and prints PASS when the state sequences after the steps button match, every setup
|
||||
command was answered, a Distance and a Presence state arrived and no ERROR line was logged.
|
||||
- The loopback bit is set at `on_boot` priority 900: after the UART is set up and before the hub's blocking `setup()`.
|
||||
- `fire_trigger_()` hands the debug lambda the debugger's own buffer, which writing the ack changes; `on_tx()` copies
|
||||
it first, and ignores its own acks (command high byte 0x01) when the nested call sees them.
|
||||
- The hub also reads its own echoed command frames. A read register command carries `00 00` where an ack has its error
|
||||
word, so the echo counts as that command's ack and every real ack lands one command late. The Firmware text sensor
|
||||
published during `setup()` therefore shows `v0.0.0` in this harness; the config dump and the Simple Mode check see
|
||||
the simulated version. A real module does not echo, so this harness cannot check that text sensor value.
|
||||
- Energy mode and simple mode publish at most once a second; a burst of 5 frames 100 ms apart publishes only the first.
|
||||
- Results: https://github.com/esphome/esphome/pull/21914#issuecomment-6517294344
|
||||
@@ -0,0 +1,29 @@
|
||||
# Bit-banged LED drivers without the chips (#21841 to #21847)
|
||||
|
||||
The GPIO LED drivers (bp5758d, bp1658cj, sm2235, sm2335, tlc5971, tlc5947, my9231, sm16716) never read the chip
|
||||
back, so a classic ESP32 devkit can capture its own pads with no wiring. `scripts/led_driver_sniff`.
|
||||
|
||||
- `sniff.hpp`: SPI2 and SPI3 run as DMA slaves fed from the driver's clock and data pads through the GPIO matrix
|
||||
(`esp_rom_gpio_connect_in_signal`, pad input enabled with `gpio_input_enable`). Mode 1 samples on the rising edge
|
||||
of its clock input (measured, not what the mode table suggests): SPI2 takes the clock straight (rising-edge bits,
|
||||
`R` lines) and SPI3 inverted (falling-edge bits, `F` lines; my9231 clocks a bit on both edges). CS is GPIO2,
|
||||
driven by the test. PCNT counts clock and data edges and latch rises per window.
|
||||
- ESP32 slave DMA drops a partial last word, so `close()` pads the clock with whole pulses to a 32-bit boundary plus
|
||||
32, and the bit count comes from PCNT. `selftest()` sends a known 57-bit pattern with the clock idling low and
|
||||
high; `selftest.py` checks it, and `run.sh` runs it on every capture.
|
||||
- bp5758d, bp1658cj and sm10bit_base (sm2235, sm2335) release the data line for the ack with `pin_mode(INPUT)`, and
|
||||
`pin_mode(OUTPUT)` disables the pad input. The `sniff` local component (`ext/`) links with
|
||||
`-Wl,--wrap=gpio_set_direction` and the header re-enables input on the sniffed pins. The ack bit reads the
|
||||
floating line, the same on every build.
|
||||
- `gen.py` writes `led.yaml`: all eight drivers on separate pins (bp5758d 25/26, bp1658cj 23/4, sm2235 18/19, sm2335
|
||||
21/22, tlc5971 27/32, tlc5947 33/13 latch 14, my9231 16/17, sm16716 5/12, selftest 15/0) and a script per driver:
|
||||
one window per step (open, set levels, 200 ms of main loop, close), a repeated step that must send nothing, two
|
||||
direct `loop()` calls timed with `esp_timer`, and an idle window.
|
||||
- `run.sh LABEL` builds, flashes, captures until `DONE` and checks the selftest. `compare.py BASE PR DRIVER` diffs
|
||||
every window of one driver between two runs and decodes it (9-bit groups for the BP and SM frames, 12-bit words
|
||||
for tlc5947, 16-bit for tlc5971, the two-edge interleave for my9231, 50 zero bits plus 25-bit groups for
|
||||
sm16716). Loop timing varies about 3 % between builds even for drivers a PR does not touch.
|
||||
- my9231 toggles DCKI with `!digital_read()`. ESP32 output pins have the pad input disabled, so without the
|
||||
sniffer's input enable the read is always 0 and the clock never toggles (on dev too).
|
||||
- Out-of-range channels: run `esphome config` with channel = last and last + 1 on dev and the PR.
|
||||
- Results: https://github.com/esphome/esphome/pull/21844#issuecomment-6491228132
|
||||
@@ -12,3 +12,52 @@
|
||||
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.
|
||||
|
||||
## RMT channel and memory budget (#22172)
|
||||
|
||||
`scripts/rmt_budget/`: each line of `cases.txt` is a mix of strips, transmitters and receivers with their
|
||||
`rmt_symbols` (`s:96`, `t:def`, `r:48`, ...) on an ESP32-S3 or ESP32-C6, and `gen_case.py CASE` turns it into a
|
||||
config whose strips have an `output_id` and which logs `STATUS so0=<is_failed> tx1=...` every 3 s. `run.sh CASE...`
|
||||
builds each case under one name per board (so builds stay incremental), flashes, captures 15 s and prints the STATUS
|
||||
line and the RMT driver errors; it exits 1 when a case printed no STATUS. `verdict.sh CASE...` runs only
|
||||
`--only-generate` and prints the config errors, the RMT memory warnings and the components registered, to compare
|
||||
with what the board then does. No LEDs are needed: allocation failures show at boot.
|
||||
|
||||
- On the S3 USB console the ESP-IDF `no free tx channels` lines are printed before the port reopens; the C6 on
|
||||
UART0 shows them.
|
||||
- S3 TX channels can use RX memory (384 symbols at most) but only start on blocks 0 to 3, so two 192 symbol strips
|
||||
do not fit.
|
||||
- 17 of 17 predictions of the PR matched the driver. Results:
|
||||
https://github.com/esphome/esphome/pull/22172#issuecomment-6615083139
|
||||
|
||||
## Capture the wire, not the buffer (#21577)
|
||||
|
||||
`templates/led_wire/wire.hpp` reads what the strips send, with no wiring:
|
||||
- RMT RX channels on the `esp32_rmt_led_strip` pads at 10 MHz, decoded with a high-time threshold per chipset
|
||||
(ws2812 0.4/0.8 µs: threshold 7 ticks; sk6812 0.3/0.6 µs: threshold 4);
|
||||
- an SPI2 DMA slave fed from the `spi_led_strip` pads, with `spi: interface: spi3` for the strip.
|
||||
|
||||
`led-wire-dut.yaml` (classic ESP32) drives a GRB ws2812 strip, a GRBW sk6812 strip, an SPI strip, an RGB light and a
|
||||
partition through steps S0 to S8 (on, `addressable_set`, lambda writes, partition, effect, power limit, off), logging
|
||||
the decoded `RMT`, `SPI` and buffer `GET` lines and `DONE` per step, then runs `stress_step()` over 300 shows and
|
||||
logs bad frames per strip. Diff the step lines of dev and the PR.
|
||||
|
||||
- A comparison of the buffers alone missed a PR bug in which the start frame was not sent.
|
||||
- Give the captured strip `rmt_symbols` that hold a whole frame (192) and capture one RX channel at a time: with 64
|
||||
symbols the TX refill interrupt stretches bits on dev and the PR alike while the capture runs. The substitutions
|
||||
at the top of the config pick the strip.
|
||||
|
||||
## rp2040_pio_led_strip: a self-sampling PIO probe (Pico 2 W)
|
||||
|
||||
`scripts/pio_probe/pioscope.hpp` samples any GPIO from PIO2 at 25 MHz while another peripheral drives it (PIO reads
|
||||
the pad without taking the pin) and decodes WS2812-style pulses into bit timings and bytes; no wires or LEDs.
|
||||
`pa.yaml` probes five strips on PIO0 and PIO1 (WS2812 and two custom timings), `pc.yaml` one strip after claiming
|
||||
DMA channels 0 to 11. `run.sh pa|pc LABEL` builds, flashes, captures 40 s and prints the `RESULT` lines; a correct
|
||||
frame of two LEDs set to `Color(0xA5, 0x3C, 0x0F)` reads `3ca50f3ca50f`.
|
||||
|
||||
- Turn the light on at full brightness before probing, or color correction writes zeros.
|
||||
- The probe FIFO overruns once in a while: count only full captures. `run.sh` fails unless every strip gave at least
|
||||
one correct frame.
|
||||
- An RP2350 `panic()` escalates to a HardFault and the rp2 crash handler reboots within about a second, so a panic
|
||||
during component setup boot-loops before USB CDC enumerates and the board looks dead (no port, no BOOTSEL drive).
|
||||
Hold BOOTSEL while plugging it in and flash the UF2 drive. To see such a fault, trigger it after boot.
|
||||
@@ -160,3 +160,21 @@ auth frames.
|
||||
Known LibreTiny behaviour seen here: after a join with a BSSID and channel, LibreTiny's partial-scan reset leaves
|
||||
the next SSID-only join with nothing to scan (`RTW_NONE_NETWORK` in about 18 ms). That is a firmware bug, not
|
||||
the board; passing channel 0 avoids it.
|
||||
|
||||
## Pulse counting and edge interrupts on LibreTiny (esphome#22391, #22400, #22403, #22404, 2026-11-06 to 11-07)
|
||||
|
||||
`scripts/libretiny_edges/`:
|
||||
- `pulse-bk.yaml` + `pulsegen.hpp` (BK7238, generator pins `GEN_PIN_A`/`GEN_PIN_B` jumpered to the counted pins) and
|
||||
`pulse-rtl.yaml` + the same header (BW15, mbed `pwmout` on `GEN_PIN_NAME`): one pulse shape per 35 s stage, from
|
||||
BL0937-like 38 µs pulses at 1 kHz up to 50 ms, counted by `pulse_counter` with `inverted` and filters as
|
||||
substitutions; `meas` lines give count per stage. Found: Beken counted half of BL0937 pulses (readings halve, so a
|
||||
calibration note was needed), RTL8720C counted none, RTL8710B none with inverted pins, LN882H counted correctly.
|
||||
- `bw12-base.yaml` with two pin layouts (`common-bw12.yaml`, `common-bw12-b.yaml`) + `bwdbg.hpp` (BW12, RTL8710B): edges
|
||||
without wiring by toggling the pad pull on floating pins (`PAD_PullCtrl`), `-Wl,--wrap=digitalRead` to count ISR
|
||||
versus task reads, and a pin/IRQ register dump; `summ.py` summarizes. A jumper IO12 to IO13 on the jig gave a real
|
||||
driven edge.
|
||||
- `common-bw12-time.yaml`: `millis()`/`micros()` every 5 s against host time, a DWT cycle bench per call (also under
|
||||
`InterruptLock`), and a mask ROM dump of `SYSTIMER_TickGet`.
|
||||
Results: #22400 https://github.com/esphome/esphome/pull/22400#issuecomment-6712520146, #22403
|
||||
https://github.com/esphome/esphome/pull/22403#issuecomment-6712901242, #22404
|
||||
https://github.com/esphome/esphome/pull/22404#issuecomment-6713095035.
|
||||
@@ -14,3 +14,23 @@ more than 20 ms off the half period, and the SLEEP share from `esp_pm_dump_locks
|
||||
- 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.
|
||||
|
||||
## Peripheral power-down needs an external signal (ESP32-P4)
|
||||
|
||||
LEDC in `LEDC_SLEEP_MODE_KEEP_ALIVE` keeps the TOP power domain on, so the self-generated wave above never tests
|
||||
`power_down_peripherals`. With that option the PR's ext1 path also hands an LP pin to the LP IO block, and an LEDC
|
||||
wave on the same pad no longer reaches the input (the pad reads 0).
|
||||
|
||||
- `wake-stim.yaml` puts a 2 Hz square wave on GPIO25 of a classic ESP32 (LEDC on REF_TICK). Wire it to GPIO4 of the
|
||||
board under test; boards on the same USB host share ground.
|
||||
- `wake_detail.hpp` with `wake-detail-esp32p4.yaml` (substitution `power_down`): GPIO4 is the any-edge interrupt pin
|
||||
and GPIO22, jumpered to it, is read as the true level. The first 40 s hold a `NO_LIGHT_SLEEP` lock, then sleep is
|
||||
allowed. Each report counts interrupt calls, real edges (the GPIO22 level changed) and calls with no change, per
|
||||
phase, and two windows (from 9.5 s and from 69.5 s) log every call with its gap and both levels. A light-sleep
|
||||
exit callback counts wakes when `CONFIG_PM_LIGHT_SLEEP_CALLBACKS` is set.
|
||||
- Waveshare ESP32-P4-WIFI6 (pre-rev3 silicon, CH343 console): `engineering_sample: true`, `flash_size: 32MB` and
|
||||
`logger: hardware_uart: UART0`, or the app logs nothing on that port. No Wi-Fi needed. GPIO4 is the left header,
|
||||
12th pin from the USB end; GPIO22 the right header, 9th.
|
||||
- Result on #21074 at 2ef417bd81 with peripherals powered down: the PR caught all 644 real edges (dev 66) but added
|
||||
4642 calls while the line was high, one per sleep cycle (about 16 ms), with the level read correctly. Peripherals
|
||||
on: both builds caught every edge. https://github.com/esphome/esphome/pull/21074#issuecomment-6503939850
|
||||
@@ -0,0 +1,17 @@
|
||||
# MCP23017 IOCON and interrupt line (#22232)
|
||||
|
||||
Bench: an ESP8266 (Feather HUZZAH) with an MCP23017 breakout at 0x27, SDA GPIO4, SCL GPIO5, INTA to GPIO14, INTB to
|
||||
GPIO13, PA0 jumpered to PB0 and PA1 to PB1, no extra pull-ups (the breakout's were enough at 100 kHz). The esptool
|
||||
reset keeps the expander powered, so a reflash shows whether register bits from the previous build survive.
|
||||
|
||||
`scripts/mcp23017/`: the `mcptest` external component (`ext/mcptest`) reads IOCON at 0x0A and 0x0B 6 s after boot,
|
||||
toggles PA0 and PA1 20 times, samples the INT levels right after and 200 ms after each write, counts INTB edges in
|
||||
an interrupt, checks the PB binary sensors (`SUMMARY` line), then checks whether IOCONB aliases IOCONA
|
||||
(`IOCONB_TEST`). `int.yaml` wires INTA to the hub's `interrupt_pin`, open drain or push-pull by the `open_drain`
|
||||
substitution (variants `int-od` and `int-pp`); `no-int.yaml` has no interrupt pin and counts INTA edges in
|
||||
`mcptest`. `run.sh VARIANT LABEL` (env `TREE`, `PORT`, `SC`, `PY`) builds, flashes and captures 14 s.
|
||||
|
||||
- In BANK=0, 0x0A and 0x0B are the same register: a write to one reads back from the other.
|
||||
- A push-pull INT line works without a pull-up. Open drain with no pull-up stays low after the first interrupt
|
||||
(ESP8266 INPUT), so the hub loop never stops and the inputs still update by polling.
|
||||
- Builds before #22232 leave ODR and MIRROR set across reflashes.
|
||||
@@ -0,0 +1,33 @@
|
||||
# MCP2518FD CAN FD over SPI (esphome#21951, 2026-11-07)
|
||||
|
||||
Bench: ESP32-PICO-D4 (`board: pico32`, ESP-IDF) wired to an MCP2518FD module with an ATA6563 transceiver: SCK GPIO25,
|
||||
SDI GPIO26, SDO GPIO27, nCS GPIO32, INT GPIO33, module 3V3/GND, CLK pin to GPIO34. No second CAN node, so everything
|
||||
runs in `LOOPBACK`, or with the controller forced into internal loopback behind the driver.
|
||||
|
||||
- **Read the crystal.** The module's can is marked `H20.00` (20 MHz) even though listings say "8 Mbps"; the PR default
|
||||
is `40MHZ`. Loopback never leaves the chip, so a wrong `clock:` still passes at half the configured rates. Measure it:
|
||||
`pulse_counter` on the CLK pin with `internal_filter: 0us` reads SYSCLK/10 (2 MHz) at reset. Older heads wrote 0 to
|
||||
`OSC` byte 0, which set `CLKODIV` to /1 (CLKO = 20 MHz, still countable by PCNT).
|
||||
- `scripts/mcp251xfd/canlb.hpp`: stream checker (one frame in flight, burst of 8 every 64th; standard/extended IDs, FD
|
||||
with and without BRS, every 4th classic, DLC 0 to 15, sequence in the payload; starts 5 s after boot so the
|
||||
`dump_config` stall cannot trip the 50 ms in-flight timeout), a queue checker for bursts, RTR and TX objects, raw SPI
|
||||
register access on the driver's own `SPIDevice` (`rd32`, `wr8`, `chip_reset` = `0x00 0x00`), forced modes
|
||||
(`force_mode` writes `CON+3`; `force_via_config` goes through Configuration so the chip accepts it), `watch_start`
|
||||
(polls `CON` every 2 ms until `OPMOD` and `REQOP` equal the target, logs the time), `txcheck` (reads each TX object
|
||||
back from chip RAM after a frame full of 0xAA, padding included), `burst` (sends N frames in one call so the loop
|
||||
cannot drain RX: FD depth 20, CLASSIC depth 32 + one overrun) and a PCNT unit on the CS pin, which counts SPI
|
||||
transactions while the pin stays an output (`cs_mark`/`cs_report`: idle cost per second).
|
||||
- `common-can.yaml` is the package; each config adds its `canbus` block and a `phases` script: `can-loopback.yaml`
|
||||
(stream, RTR, TX objects, idle cost, chip reset, two refused `REQOP` writes), `can-classic.yaml` (CLASSIC with a
|
||||
forced internal-loopback window: force once to trigger the driver's recovery, force again right after it restores,
|
||||
then burst 48 and RTR inside the next 5 s check interval), `can-normal-busoff.yaml` (no node acknowledges, so the
|
||||
controller cycles error passive/bus-off; each forced fault and reset waits for TREC `TXBO`).
|
||||
`run.sh CONFIG LABEL [SECONDS]` builds, flashes, captures and prints the summary lines.
|
||||
- Gotchas found: a refused `REQOP` (LISTENONLY or Restricted from LOOPBACK) leaves `OPMOD` unchanged and stalls TX;
|
||||
the chip refuses LOOPBACK to CLASSIC directly (go through Configuration); after a chip reset TREC reads `0x00200000`
|
||||
(`TXBO` set in Configuration mode), so a driver that skips recovery on bus-off never recovers a reset during an error
|
||||
state. Neither a reset nor a refused request sets an interrupt flag, so detection waits for the periodic check.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21951#issuecomment-6715452893 (first run),
|
||||
#issuecomment-6716105991 (TX objects, idle cost), #issuecomment-6716741859 (reset during bus-off bug),
|
||||
#issuecomment-6716957543 (fixed).
|
||||
@@ -0,0 +1,18 @@
|
||||
# mDNS responder (#22267)
|
||||
|
||||
`scripts/mdns/mdns-common.yaml` (a package) adds a `_hwtest._tcp` service with a short and a 62 character TXT
|
||||
value and logs free, minimum and largest free heap every 10 s; `mdns-wifi.yaml` adds Wi-Fi from `secrets.yaml` and
|
||||
switches it off and on every 120 s so the responder restarts. The host's multicast often does not reach the boards
|
||||
(a host with several network interfaces, or a mesh Wi-Fi network), so the scripts send legacy unicast queries (source port not 5353)
|
||||
straight to the device's port 5353 with python-zeroconf packets:
|
||||
|
||||
- `mq.py IP HOSTNAME dump` asks for the `_esphomelib` and `_hwtest` PTR records, the A record and the SRV and TXT
|
||||
records of both services, prints the answers and exits 1 when one is missing. Diff the dump between builds.
|
||||
- `mq.py IP HOSTNAME hammer N [TIMEOUT]` sends N questions back to back (0.5 s timeout each) and counts misses per
|
||||
question. It exits 1 when nothing was answered, or when more than `MAX_MISS_PCT` percent (env) were missed.
|
||||
- `mq.py IP HOSTNAME lat N` sends A queries 300 ms apart and prints the median, p90, p99 and maximum latency and how
|
||||
many answers came after 1.5 s or never.
|
||||
|
||||
Judge responder timing on an Ethernet board: on a crowded Wi-Fi network back-to-back query loss swung between 0 and
|
||||
14% between builds for no code reason. Run the hammer across a Wi-Fi off and on cycle too, and compare the heap
|
||||
lines before and after many cycles.
|
||||
@@ -0,0 +1,26 @@
|
||||
# microphone_http_stream (#21593)
|
||||
|
||||
`microphone_http_stream` serves raw microphone PCM at `/audio/microphones/<stream_id>` (and a listing at
|
||||
`/audio/microphones`) on the web server. `templates/microphone_http_stream/`:
|
||||
- `mh-base.yaml`: Waveshare ESP32-S3-ETH on PoE (logs over the API on Ethernet, the native USB port is the USB host
|
||||
port), encrypted API, `web_server` on 80 with a dummy sensor and switch, mic start and stop buttons and a line
|
||||
every 5 s with the mic state and internal and PSRAM heap. Any ESP32-S3 with PSRAM works for `mh-fake.yaml`.
|
||||
- `mh-fake.yaml`: the stream fed by `ext/fakemic`, a test microphone that publishes 32 ms chunks of a 16-bit counter
|
||||
(one count per sample) from its own task, so a reader can check every sample. Its "Mic stall" button stops the
|
||||
chunks for 6 s.
|
||||
- `mh-usb.yaml`: the stream fed by a USB microphone on `usb_audio` (48 kHz, 16-bit stereo, channel 0 streamed).
|
||||
|
||||
Copy the directory next to a `secrets.yaml` with `api_key`, flash, then run
|
||||
`mhclient.py HOST STREAM_ID [--clients N] [--seconds S] [--pattern] [--stall K] [--stagger S] [--expect-reject R]`.
|
||||
Each reader prints its HTTP status, format headers, sample count and rate, the longest gap between reads and, with
|
||||
`--pattern`, every break in the counter. `--stall K` makes reader K stop reading after 2 s while keeping its socket
|
||||
open, then checks that the device closed it. `--expect-reject R` marks the last R readers as past `max_clients`;
|
||||
they must get a non-200 status or a close or reset before any samples, and a timeout or a served reader fails.
|
||||
The script exits 1 when the listing is not a 200 naming the stream, the stalled reader was not dropped, a reader past
|
||||
the limit was not refused, or any other reader got no 200, no samples, an early EOF, a read error or a broken counter.
|
||||
|
||||
Cases worth running on each head: one reader with `--pattern` (rate must match the sample rate, 0 breaks), the
|
||||
`max_clients` limit plus one with `--expect-reject 1` (note what the extra one gets; the others must be unaffected), a stalled reader among
|
||||
three (the device should drop the stalled one only), the "Mic stall" button during a read (a gap of about 6 s, then
|
||||
the counter continues), mic start and stop while readers are attached, and the heap line after many connect and
|
||||
disconnect cycles. On the USB microphone, check the rate and `rms` instead of the pattern with `--rate 16000 --min-rms 1`, and add `--max-gap-ms` to fail on long gaps; these set the exit code.
|
||||
@@ -79,3 +79,63 @@ Retest at c9cf3c2813 (shutdown message sent at QoS 1, teardown waits for the bro
|
||||
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
|
||||
|
||||
## MQTT discovery against a throwaway Home Assistant (#21956)
|
||||
|
||||
Run a Home Assistant dev install with its own config directory (`hass -c <scratch>/ha`) holding `http:
|
||||
server_port: 8129`, `api:`, `config:`, `onboarding:` and MQTT debug logging, plus a local mosquitto on 1883. In
|
||||
`scripts/mqtt/ha_discovery/`, `onboard.py DIR` creates a generated test user (the credentials stay in DIR) and a
|
||||
token, `flow.py TOKENFILE` adds the MQTT entry (the `protocol` and `other_settings: {set_client_cert: false,
|
||||
set_ca_cert: "off", transport: tcp}` fields are required), and `hadrive.py TOKENFILE NAME` reads the device's time,
|
||||
date and datetime entities, calls `set_value` on each and reads again; it exits 1 on a missing entity, a failed
|
||||
call or a state that does not read back the value that was set. The three share the REST calls in `ha_rest.py`. `dt-common.yaml` is the device side (classic ESP32, three
|
||||
template datetime entities, `${broker}`).
|
||||
|
||||
- The HA dev branch keeps a YAML port change as a pending HTTP config and reverts it after 5 minutes. Before
|
||||
starting HA again, set `data.stable.server_port` to 8129 and `data.pending` to null in `.storage/http`.
|
||||
- ESPHome MQTT unique ids are name based: clear the previous build's retained discovery (`mosquitto_pub -r -n` on
|
||||
each config topic) and expect the entity ids of the first build.
|
||||
|
||||
## mqtt.disable and broker drops on ESP-IDF (#22230)
|
||||
|
||||
`scripts/mqtt/disable/disable-common.yaml` disables MQTT for 30 s every 60 s (every third cycle starts with fast
|
||||
toggles, a disable while connecting), publishes a tick every 2 s and logs `EV CONNECT`, `EV DISCONNECT` and
|
||||
`EV MSG`. `run.sh LABEL outage|drop SECONDS` (env `TREE`, `PORT`, `SC`, `PY`, `BROKER`, `BROKER_PORT`) starts a
|
||||
private mosquitto so it can stop and start it, flashes and captures:
|
||||
- `outage` publishes to `md<LABEL>/in` every second and stops the broker inside a disabled window; `analyze.py`
|
||||
prints per window the device connects, inbound messages, broker connects, ticks that reached the broker and the
|
||||
time from enable to connect, and fails the run on a device connect or an inbound message inside a window.
|
||||
- `drop` stops the broker 6 s after every enable for 15 s. This is the case that found a watchdog reset: calling
|
||||
`esp_mqtt_client_stop()` while the esp-mqtt task sleeps in WAIT_RECONNECT blocks the caller for up to
|
||||
reconnect_timeout/2 (5 s), 6 of 6 task watchdog resets on an S3 and a C6.
|
||||
|
||||
Both modes also fail when the device never connects or the broker was never stopped, and `outage` when no inbound
|
||||
message arrived.
|
||||
|
||||
Use the host's current address for `BROKER`: a stale one gives `esp-tls select() timeout`. Before #22230 the client
|
||||
reconnects on its own during disable (14 of 16 windows). Results:
|
||||
https://github.com/esphome/esphome/pull/22230#issuecomment-6614854671
|
||||
|
||||
## Retained message burst at connect (#22200)
|
||||
|
||||
`scripts/mqtt/retained_burst/`: `burst-topics.yaml` (a package) subscribes 25 `on_message` topics plus 15 switch
|
||||
command topics, `burst-common.yaml` logs the counts 15 s after the connect, and `seed.sh BROKER_PORT` leaves all 40 retained on a local mosquitto (`seed.sh BROKER_PORT clear` removes them).
|
||||
`run.sh LABEL BOOTS` (env `TREE`, `PORT`, `SC`, `PY`, `BROKER`, `BROKER_PORT`, default board an ESP32-C3) resets the
|
||||
board BOOTS times and prints `RESULT got=x/25 switches_on=y/15` and any `Dropped` lines per boot; it passes only with
|
||||
40 of 40 on every boot. Before #22200 the C3 dropped exactly 49 events (80 events minus 31 slots): the main loop
|
||||
drains nothing during the burst.
|
||||
|
||||
## Forced reconnect mid-burst (#22248)
|
||||
|
||||
`scripts/mqtt/reconnect/`: `kick-common.yaml` includes the same `burst-topics.yaml` and counts per connection with
|
||||
a `mode: restart` script on `on_connect`, logging `RESULT` 6 s after each connect. It and `run.sh` use files from
|
||||
`../retained_burst/`, so copy the whole `mqtt/` directory to the scratch directory. `kick.py BROKER_LOG BROKER_PORT
|
||||
PREFIX CYCLES` tails the broker log and takes over the device's client id 0 to 400 ms after every other connect
|
||||
(mosquitto drops it with "session taken over"), then leaves the next connection alone for 8 s and ends it. It
|
||||
starts by kicking the connection that is already up and skips its own connections (from 127.0.0.1 with the same id).
|
||||
`run.sh LABEL CYCLES` (env as above plus `BROKER_LOG`) seeds, flashes, waits for the first connect and runs the
|
||||
kicker. The device counts its first connection as CONN 1, so the kicked ones are the even CONN numbers and the
|
||||
left-alone ones the odd numbers from 3 up; kick.py prints each left-alone connection with its time and CONN number,
|
||||
and `judge.py` pairs those with the RESULT lines by time and passes when every one has `got=25/25`. A reset after
|
||||
the first connect fails the run. Retained switch commands fire `on_turn_on` only on the first connection, so
|
||||
`switches_on` is not judged.
|
||||
@@ -28,3 +28,76 @@ and ends with PASS (more than one server connection) or FAIL; an empty capture o
|
||||
- macOS is a poor central here: it keeps old bonds with a board (`Peer removed pairing information` on connect)
|
||||
and clearing them is a system setting. Use an ESP32 `ble_client`; the server's random static address comes from
|
||||
its own log.
|
||||
|
||||
## SMP over UDP OTA over Thread, with reset faults (#17245, #21910)
|
||||
|
||||
Setup: a Thread border router (see openthread.md) whose OMR prefix the laptop routes to, and an ItsyBitsy nRF52840
|
||||
(the Adafruit bootloader carries the MCUboot layout; the nRF52840 Dongle's Open DFU bootloader is rejected for
|
||||
`zephyr_mcumgr` OTA). `scripts/smp_udp_ota/smp-udp-ota-dut.yaml` joins the network with `force_dataset: true` and
|
||||
logs its build label every 10 s. The first image goes in over UF2, every later one with
|
||||
`ota.sh MODE YAML VER ADDR` to the device's OMR address. A full image (about 380 KB) takes about 130 s.
|
||||
|
||||
- An ESP Thread Border Router board running ESPHome `openthread: border_router: rcp:` works as the border router
|
||||
(pins in `boards.md`). Give both it and the device a fixed dataset with `force_dataset: true`; each keeps an old
|
||||
stored dataset otherwise. The host learns the route to the OMR /64 from the border router's RA; ping the
|
||||
device's OMR address before the first upload.
|
||||
- Install the smpclient version pinned in requirements. An older one fails with `unexpected keyword argument
|
||||
'timeout_s'` and a missing `get_max_cbor_and_data_size`, which looks like a PR bug. Check the version with the
|
||||
Python of the venv that runs ESPHome, not a system pip.
|
||||
- The build holds two `zephyr.uf2` files. Copy `.pioenvs/<name>/zephyr/zephyr.uf2` (the app, from 0x26000 up,
|
||||
about 827 KB). `mcuboot/zephyr/zephyr.uf2` (about 70 KB) lands on the app slot and the board stops enumerating;
|
||||
double tap RESET and copy the right one.
|
||||
- To log the device's addresses, format `otIp6GetUnicastAddresses()` by hand: `network::IPAddress::str_to()`
|
||||
does not link on nRF52 (no `inet_ntop`).
|
||||
- A new image refuses the next upload (`No free slot. Testing mode but not confirmed yet.`) until it confirms itself.
|
||||
Wait about 4 minutes between uploads.
|
||||
- An image that is already in the secondary slot is only marked and reset (a few seconds). Change `ver` for every
|
||||
upload that has to transfer, and expect a host-only change to rebuild byte-identical firmware.
|
||||
- `inject.py` faults the reset on the host: `lostreply` drops the reply of a reset the device got, `lostreq` drops the
|
||||
first request, `lostreq2` drops every reset. The device resets after a short delay, so even after a lost reply the
|
||||
client usually still finds the old image running with the new one pending and resends the reset to the old image,
|
||||
which is safe. `lostreq2` must end in an error with the old image still running. `ota.sh` ends with `check.sh`,
|
||||
which prints PASS or FAIL for the mode, and `inject.py` fails the run when no reset was faulted.
|
||||
- Without `libc_nano` the config overflows the 396,800 B slot by about 4 KB with Zephyr's TCP stack linked (about
|
||||
11 KB of it, unused by SMP over UDP). With TCP left out (#21910) it fits with the full libc. A config with `api:`
|
||||
keeps TCP.
|
||||
- **The real OTA size limit is lower than the link limit.** The nRF Connect SDK partition manager overrides the device
|
||||
tree, so read `partitions.yml` in the build directory, not `zephyr.dts`. On the ItsyBitsy layout `mcuboot_primary` is
|
||||
0x61000 (97 sectors of 4 KB, 0x2f000 to 0x90000) and the app partition 0x60e00 (396,800 B); `mcuboot_secondary`
|
||||
is 0x90000 to 0xf1000 and `settings_storage` 0xf2000 to 0xf4000. MCUboot swap using move keeps one sector for
|
||||
its trailer and one for the move, so it only swaps in an image up to (97 - 1 - 1) × 4 KB = 389,120 B
|
||||
(`app_max_sectors()` and `app_max_size()` in bootutil `swap_move.c`). An image between 389,120 and 396,800 B links,
|
||||
uploads and the CLI reports `Successfully uploaded program`, but MCUboot never swaps it in: it stays pending in the
|
||||
secondary slot, and every later upload and the SMP image erase fail with `NO_FREE_SLOT`. OTA stays stuck until
|
||||
the slot is cleared over USB. Measured: 388,787 B swapped in, 392,155 B did not. `ota.sh` checks `app_update.bin`
|
||||
against this limit with `mcuboot_slot.py limit` before it uploads, and afterwards asks the device which image runs
|
||||
(`mcuboot_slot.py active`, by the image's SHA-256), since the CLI's exit code alone is not proof. That check passes once the
|
||||
new image runs and says whether it has confirmed itself; add `--expect confirmed --wait 300` to prove it stays.
|
||||
- With #17245 at 03d45d1aaa the build itself fails above the limit (`The application image is N bytes, but MCUboot
|
||||
can only swap in L bytes on this board`), and after a confirmed reset the client waits for the device and logs
|
||||
`runs the new image` or fails with `rebooted but did not swap in the new image`. After a dropped reset reply it still
|
||||
ends with `The reboot of ... is not confirmed` without that wait, so `ota.sh`'s own image check is what proves those
|
||||
runs.
|
||||
- Clearing a stuck pending image: the SMP image erase is refused while the image is pending, and resets do not help.
|
||||
Over USB, touch the app's CDC port at 1200 baud to enter the UF2 bootloader, then copy the 16-block UF2 from
|
||||
`mcuboot_slot.py blank-uf2 BUILD OUT`, which writes 0xFF over the secondary slot's header page (`mcuboot_secondary`
|
||||
in `partitions.yml`, 0x90000 on the ItsyBitsy). The bootloader resets on its own once the last block is written
|
||||
(`cp` may report an I/O error then, which is normal), MCUboot finds no image header and clears the slot, and the
|
||||
image states show slot 0 only.
|
||||
- Over USB, the Adafruit UF2 bootloader only accepts blocks inside its app area (its `CURRENT.UF2` maps 0x1000 to
|
||||
0xE9000). A UF2 with a block outside that area is never completed, and the bootloader then ignores every
|
||||
later file, even a good app UF2, until a physical reset (double tap RESET or replug). Keep hand-made UF2 writes inside the app area.
|
||||
- Results: https://github.com/esphome/esphome/pull/17245#issuecomment-6516152182,
|
||||
https://github.com/esphome/esphome/pull/17245#issuecomment-6518292041 (swap limit),
|
||||
https://github.com/esphome/esphome/pull/17245#issuecomment-6519010038 (retest)
|
||||
|
||||
## TCP stack only when a component uses TCP (#21910)
|
||||
|
||||
`scripts/nrf52_tcp_probe/tcp-probe.yaml` joins the test Thread network with no TCP user and opens a TCP and a UDP
|
||||
socket twice from an interval. Build it per tree with `-s name NAME`, flash over UF2, capture the USB log, then
|
||||
`check.sh BUILD CAPTURE`: it reads `CONFIG_NET_TCP` and the packet and buffer counts from
|
||||
`.pioenvs/<name>/zephyr/zephyr/.config` and wants `tcp null udp ok` twice plus the `TCP stack not built` error exactly
|
||||
once on a build without TCP, or `tcp ok udp ok` twice and no error with it. Add `api:` to see TCP and the larger pools
|
||||
come back, and connect with `apicheck.py ADDR` over Thread. Measured on the ItsyBitsy: 12.9 KB less flash and 17.1 KB
|
||||
less RAM without TCP (Zephyr's default pools, 16/16 instead of 24/48), and SMP over UDP OTA still works with them.
|
||||
Results: https://github.com/esphome/esphome/pull/21910#issuecomment-6518287139
|
||||
@@ -47,7 +47,6 @@ each ring size at least the encoder window, three times, and logs the best time
|
||||
`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
|
||||
@@ -67,3 +66,41 @@ refusal prints no `Connecting to` line, and an image that slips through cannot r
|
||||
`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.
|
||||
|
||||
## OTA automations on two OTA platforms (#21903)
|
||||
|
||||
`scripts/ota_automations/ota-automations-common.yaml` (a package) puts `esphome` and `web_server` OTA in one config,
|
||||
each with all six automations (`on_begin`, `on_progress`, `on_end`, `on_abort`, `on_error`, `on_state_change`)
|
||||
logging a platform tag and the argument. `run.sh esp32|esp8266 LABEL` builds, flashes by serial, and runs four cases
|
||||
under one capture: a native upload, a native upload whose uploader is SIGKILLed after the first progress fire, a
|
||||
web_server upload of a random 64 KB file and a web_server upload of the real image. `summ.py OUT_DIR` prints each
|
||||
case's fires in order with progress collapsed to a count; diff dev against the PR.
|
||||
|
||||
- `web_server: ota: false` removes `/update` (404), so leave it out when the web platform is under test.
|
||||
- A killed native uploader ends in `state 4` and `error 255`, not `abort`. `on_abort` only comes from a web upload
|
||||
cut on the network. On ESP8266 those happen by themselves (1 in 4 uploads on dev), so count them on both builds with
|
||||
`webrep.sh IP IMAGE COUNT` before blaming the PR.
|
||||
- A random file through web_server gives `error 128` on the ESP32 and `error 132` on the ESP8266.
|
||||
- A SIGTERM to the uploader lets it finish the transfer; use SIGKILL for the native error path.
|
||||
- Results: https://github.com/esphome/esphome/pull/21903#issuecomment-6516529409
|
||||
|
||||
## Signed OTA with several trusted keys (`verification_keys`, #22398)
|
||||
|
||||
`scripts/signed_ota/` on an ESP32-C3 (`signed-ota-c3.yaml`, `signed_ota_verification: signing_scheme: rsa3072,
|
||||
verification_keys: [keyA.pem]`):
|
||||
1. `python -m espsecure generate-signing-key --version 2 --scheme rsa3072 keyA.pem` (and keyB).
|
||||
2. Build four tagged images with `esphome -s tag <name> compile`, serial-flash the first factory image (no on-boot
|
||||
signature check without secure boot), copy each `firmware.ota.bin`.
|
||||
3. `sign_variants.py <image> keyA.pem keyB.pem <prefix>` writes key A, key B, B then A, stored CRC flipped, byte 2
|
||||
changed (covered only by the CRC), the same with the CRC recomputed by zlib, and B then A with block 1's CRC flipped.
|
||||
4. `otaseq.sh <ip> FILE=reject... FILE=<tag>...` sends them with `esphome upload --file` and checks the first verdict
|
||||
in the device log and the upload result against the expectation (a rejection, or the `BUILDTAG` of that build);
|
||||
send the rejects first so every check runs in the same build, then the accepted ones.
|
||||
Expected: unsigned, B only and every CRC edit rejected; A, B+A and the zlib-fixed CRC accepted. The byte 2 pair isolates
|
||||
the CRC from the signature and key digest.
|
||||
|
||||
Gotchas: an ESP-IDF image can contain `E7 02` at a 4 KiB boundary by chance; find the signature sector by the CRC, as
|
||||
`sign_variants.py` does. An unsigned image sent after a signed one can be rejected with "trusted key failed to verify"
|
||||
instead of "no signature block": the partition still holds the old signature sector past the new image's end.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/22398#issuecomment-6716330334
|
||||
@@ -0,0 +1,15 @@
|
||||
# Per period PWM capture on the same chip (#15438)
|
||||
|
||||
`scripts/pwm_capture/` (ESP32-C6): `pwmcap.hpp` routes the `ledc` pad through the GPIO matrix into RMT receive at
|
||||
80 MHz (ping pong buffers, `en_partial_rx`, `mem_block_symbols: 96`) and a PCNT unit, checks every period in the IRAM
|
||||
safe RMT interrupt and logs a `WIN` line every 10 s (high time and period histograms, minimum and maximum, level
|
||||
changes, spikes, reversals). `common.yaml` needs `include_builtin_idf_components: [esp_driver_rmt, esp_driver_pcnt]`
|
||||
and `CONFIG_RMT_ISR_IRAM_SAFE` and `CONFIG_PCNT_ISR_IRAM_SAFE`; build it with `-s name NAME -s ps light` (or `none`).
|
||||
`run.sh NAME SECONDS [--every 5 --transition 1]` (env `TREE`, `PORT`, `SC`, `PY`) resets and captures while
|
||||
`apiclient.py` holds an API connection and optionally sends light transitions; `summ.py` totals the windows.
|
||||
|
||||
- LEDC and RMT share PLL_F80M, so a 20 kHz 11 bit output reads exactly 4000 ticks per period with no jitter.
|
||||
- Result: no glitch in about 10 million periods with power save `light` or `none`; #15718 (LEDC in IRAM) changed
|
||||
nothing. Positive control: a lambda that changes the duty for a few periods is caught every time.
|
||||
- Window boundaries follow RMT chunks (about 1000 periods), so `n` alternates between 199584 and 200592; sum over
|
||||
the run.
|
||||
@@ -0,0 +1,22 @@
|
||||
# rs485_frame DLE/STX/ETX bus without a bus (#16758)
|
||||
|
||||
`scripts/rs485_frame`: an ESP32-C3 (logger on USB_SERIAL_JTAG) with the hub's UART on tx GPIO3 rx GPIO4 and a
|
||||
simulated bus device on tx GPIO4 rx GPIO3, cross-wired through the GPIO matrix (`allow_other_uses: true` on all
|
||||
four pins), 9600 baud, no wires. The simulator is polled from an `interval` and logs with the `sim` tag; the test
|
||||
script logs with `hwt`.
|
||||
|
||||
- `simrs.hpp` builds sum16 big endian, header-inclusive frames (`10 00` stuffing, `10 10` with
|
||||
`simrs::double_mode`) and logs each frame the hub sends (`RXF`) with start and end poll times, the silence since
|
||||
the previous frame and the time since the simulator's own last byte. `rs-base.yaml` (board, cross-wired UARTs,
|
||||
poll interval, the hub's CRC and a log line per received frame) and `buttons.yaml` are shared packages.
|
||||
- Case files: `common.yaml` runs good / bad CRC / garbage / split at 25 and 150 ms / overlong / max length / two
|
||||
frames in one write / a cut frame followed by a new DLE STX / a bad escape, then button presses that the hub must
|
||||
hold until a `01 01` gate frame arrives (`replace_latest` keeps only the last press). `rsi2.yaml` (idle_gap gate),
|
||||
`rsx.yaml` (fixed_delay gate), `rsd.yaml` (sniffer_only, double escape).
|
||||
- `run.sh CASES LABEL [SECONDS]` (`CASES` is `common`, `rsi2`, `rsx` or `rsd`; `TREE`, `PORT`, `SC` in the
|
||||
environment) writes a run file with its own name, builds and flashes with `esphome run --no-logs`, captures and
|
||||
writes `out-rs<CASES><LABEL>.log` with time stamps and line numbers stripped. It fails without a `DONE` line or on
|
||||
a crash. Diff the logs of dev and the PR; for the gate timing cases read the `RXF` lines (silence before each frame
|
||||
the hub sent).
|
||||
- On ESP32 at 9600 baud the default `rx_full_threshold` is 8, so a component does not see bus bytes for about 9 ms.
|
||||
Queue a test frame at least 20 ms after the bus starts talking, or it collides on any idle-gap design.
|
||||
@@ -6,3 +6,18 @@ An external component whose template button arms an SDK `os_timer` (50 ms, one s
|
||||
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
|
||||
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
|
||||
`button_command`). Build the same yaml twice with different names, one per tree.
|
||||
|
||||
## interval startup_delay and control from lambdas (#21799)
|
||||
|
||||
`scripts/interval/interval-common.yaml` has plain 1 s and 2.5 s intervals, a 1 s interval with `startup_delay: 5s`, a
|
||||
3 s one with `startup_delay: 500ms`, and a 15 s control interval (`startup_delay: 0s`). The control interval
|
||||
changes the plain 1 s interval to 500 ms with `id(i_plain).set_update_interval(500)` and `start_poller()`, then
|
||||
stops it and the 5 s delayed one with `stop_poller()`. Every run logs `millis()`. `run.sh LABEL` flashes a D1 mini,
|
||||
captures 40 s from boot and runs `ivsum.py`, which prints the run count, first run after boot and gaps per interval.
|
||||
|
||||
- Check that each delayed interval's first run comes at or after its delay, that the gaps equal the interval, that
|
||||
the 500 ms change takes effect, and that nothing runs after a stop.
|
||||
- The first run of an interval without a delay falls at a different time on every boot (the scheduler spreads first
|
||||
runs), so do not compare those offsets between builds.
|
||||
- `main.cpp` shows the class each interval gets (`IntervalTrigger` or `DelayedIntervalTrigger` on #21799).
|
||||
- Results: https://github.com/esphome/esphome/pull/21799#issuecomment-6516798398
|
||||
@@ -0,0 +1,20 @@
|
||||
# Sensor, binary_sensor and text_sensor filter A/B (#21831, #21813, #21830)
|
||||
|
||||
`scripts/sensor_filters` needs only a board. `sensors.yaml` holds 29 template sensors with every offset, multiply,
|
||||
clamp, NTC, calibrate_linear, delta and lambda filter shape, plus a sensor with id `x` (a value lambda that reads an
|
||||
id named `x`). `sf.yaml` adds binary and text sensor lambda filters and an interval that feeds the 50 inputs of
|
||||
`sf.hpp` (NaN, ±inf, -0.0, values on and across the bounds) 200 ms apart, logging one line per input with every
|
||||
filter output as float bits (`-` means dropped), then `DONE`.
|
||||
|
||||
- `run.sh esp8266|bk72xx LABEL` writes the per-run file, builds, flashes, captures and writes `out-sf<kind><LABEL>.out`
|
||||
(100 lines). Compare a PR build with dev using `cmp`: identical files mean identical filter output, including
|
||||
which values were dropped. ESP8266 matters because its float math is in software.
|
||||
- The template sensor ids are `flt1`, `flt2` ...: `s8` and `s16` clash with ESP8266 SDK typedefs.
|
||||
- Grep the build's `main.cpp` for the new code (for example `clamp_filter_value`, `delta_passes`, generated filter
|
||||
functions) to prove the build came from the PR tree.
|
||||
- `BASELINE` sets the delta baseline: a lambda works everywhere, a plain number crashed before #21811.
|
||||
- Between boards the only difference is the sign of the NaN that `to_ntc_temperature` returns for 0 Ω (libm), on dev
|
||||
too, so compare builds on the same board.
|
||||
- The ESP8266 esptool stub can hit serial noise on a CH340 and the BK7238 link sometimes times out once; the runner
|
||||
retries.
|
||||
- Results: https://github.com/esphome/esphome/pull/21831#issuecomment-6490999545
|
||||
@@ -0,0 +1,36 @@
|
||||
# sigma_delta speaker and rtttl sample taps
|
||||
|
||||
## Pulse density of the speaker pin, measured inside the chip (ESP32-P4, #18702)
|
||||
|
||||
Nothing is wired to the pin. `scripts/sigma_delta/sdprobe.hpp` calls `gpio_input_enable()` on the SDM output pin,
|
||||
and a core 0 task reads it with `gpio_get_level()` 2000 times per window, with pseudo-random gaps of 0 to 255 cycles
|
||||
(xorshift) that decorrelate the samples from the 1 MHz modulator. One window is about 2 ms and the density noise is
|
||||
about 0.5% sd. The expected duty for an int8 density d is (d + 128) / 256, so 16-bit PCM at +50% reads 75%, 0 reads
|
||||
50%, and full scale reads 99.6% or 0%.
|
||||
|
||||
The same header has:
|
||||
- a core 0 feeder task (DC, sine, stereo L/R, a burst of N frames, any 8/16/24/32 bit mono or stereo format) that
|
||||
calls `speaker->play(..., 20 ms)`;
|
||||
- a played-frame counter on `add_audio_output_callback()`: frames per second over 4 s matches the rounding of the
|
||||
10 MHz timer exactly;
|
||||
- a CPU probe: a priority 10 task pinned to the loop core spins for 200 ms, and the iterations while playing against
|
||||
idle give the interrupt share (44.1 kHz cost 20% of core 1).
|
||||
|
||||
`gen.py tpl.yaml > NAME.yaml` builds the whole sequence (the device name comes from `esphome -s name NAME`): DC levels,
|
||||
volume, mute, formats, 2 Hz and 50 Hz sines with DUMP lines, sample rates, underrun, stop mid-play, finish, rtttl and a
|
||||
play/stop hammer. `gen2.py` (both share the step helpers in `steps.py`) builds the inverted pin run (set the speaker
|
||||
pin to `{number: GPIO21, inverted: true}` in a copy of `tpl.yaml`) plus the CPU cost per rate. `run.sh LABEL
|
||||
[gen|gen2]` generates, builds, flashes, captures with `--ms` until `ALLDONE` and prints the `RES` lines; for `gen` it
|
||||
then runs `an.py`, which fits the sines and prints the level edges of the underrun, stop and finish dumps. GPIO21 is
|
||||
free on the Waveshare ESP32-P4 Module DEV KIT. Long operation warnings in the log come from the probe's own lambdas.
|
||||
|
||||
## rtttl click measurement (#22075)
|
||||
|
||||
`scripts/rtttl_edge_tap/edgetap.hpp`: `EdgeTap`, a pass-through `speaker::Speaker` that forwards to the real speaker
|
||||
and reads the int16 samples rtttl writes. `rtttl.set_speaker()` it from a lambda, and call `sync()` from an
|
||||
`interval: 10ms`, because `is_running()` and `is_stopped()` read the base class state. Use tunes with a rest between
|
||||
every note, so a note is a run of samples between runs of 8 or more zeros. It logs each note's onset and release
|
||||
level (the largest of the first or last 4 samples as a share of the note's peak): dev about 95%, the 32-sample fade
|
||||
at most 12.5%. `rtttl-edge-tap-dut.yaml` is the AUX-2 config with a USB speaker as the mixer output (it needs the
|
||||
AUX-2 package next to it, see [usb_audio.md](usb_audio.md)); `run.sh LABEL` plays eight tunes and prints the note
|
||||
reports. Any speaker works the same way: wrap the one rtttl writes to.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Simulated chips on the bus, fake Home Assistant, static buffers
|
||||
|
||||
`scripts/sim_buses/` and `scripts/static_buffer/`. Each header is added with `esphome: includes:` and wired in an
|
||||
`on_boot` lambda, so no chip is needed. The wrappers take the real bus or SPI delegate and pass through every
|
||||
address they do not simulate.
|
||||
|
||||
- `lcdsim.hpp`: `LcdSimBus`, an `i2c::I2CBus` wrapper that plays PCF8574 backpacks (P0 RS, P2 EN, P4 to P7 D4 to D7)
|
||||
driving HD44780 controllers. It decodes the EN-strobed nibbles and rebuilds the rows written after each 0x80 and
|
||||
0xC0 address command. `add()` up to three addresses, hand it to each `lcd_pcf8574` with `set_i2c_bus()` at
|
||||
`on_boot` priority 800, and call `report()` from an interval. `lcd_an.py SERIAL` checks the rows against the
|
||||
patterns its docstring lists (0x27 20x4, 0x26 16x2, 0x25 64x4), and fails on a display whose frame count does not
|
||||
grow between its first and last report, on a segment longer than its rows or on
|
||||
data past the 128 byte segment buffer, which the report counts as `overflow`, on a report from an address it does
|
||||
not expect, or on any read (`reads`; the driver never reads, so the simulator NACKs them). A 64x4 display hangs the
|
||||
code before #22331 in
|
||||
setup.
|
||||
- HT16K33: use `templates/i2c_fake/htfake.hpp` with its test runner (see [fake_i2c.md](fake_i2c.md)).
|
||||
- `mxsim.hpp`: `MxSniff`, an `spi::SPIDelegate` in front of the real one that keeps what each MAX7219 in a chain
|
||||
latches. Swap it in at `on_boot` priority 399 through `DelegatePeek::of(id(x))`:
|
||||
`auto &d = DelegatePeek::of(id(x)); d = new MxSniff(d, chips);`. `mx_an.py SERIAL CHAIN...`
|
||||
summarizes the reports, CHAIN being each name passed to `report()`, and fails on a named chain with no report line,
|
||||
on malformed transactions (byte count not two per chip, or a partial byte from `write()` or
|
||||
`write_cmd_addr_data()`, which are recorded and forwarded too), on a named chain with fewer than two report lines,
|
||||
or a chain whose transaction count stays 0 or stops growing, and prints PASS otherwise.
|
||||
- `spysim.hpp`: the same delegate trick (both headers carry `DelegatePeek`, so one config can include both), logging length and FNV-1a hash of every transaction over 1000 bytes (whole
|
||||
frames, used for `pixoo`). `write()` and `write_cmd_addr_data()` are hashed and forwarded like `write_array()`;
|
||||
a partial byte or a bus width other than 1 is hashed as whole bytes, so the SPY line carries `odd`, the count of
|
||||
such transactions, and a dev vs PR hash match only holds when `odd` is 0 on both.
|
||||
- `pisim.hpp`: drives two `packet_interface` stacks on cross-wired UARTs through `top()`, with payloads whose bytes
|
||||
are a function of their index so every byte is checked, and the index and length each side sent (last 256) so a
|
||||
truncated or split packet, or one whose slot a later index reused, counts as `rx_bad`. Send indices 0, 1, 2 and so
|
||||
on from each side: a skipped index counts as `rx_lost` and an old or repeated one as `rx_bad`. `report(tag, true)` once both links are idle ends each
|
||||
line in PASS only when that side received every packet the peer sent intact and the peer had no send refused;
|
||||
`missing` is the rest, packets still in flight or lost at the tail. `packet_transport` drops packets carrying its own device
|
||||
name, so a one-board test must use the layers directly.
|
||||
- `hcsim.hpp`: a software SN74HC165 chain on ESP-IDF (data GPIO6, clock GPIO7, load GPIO10). A falling load edge
|
||||
latches the pattern and each rising clock edge drives the next bit onto the data pad, which is in
|
||||
`GPIO_MODE_INPUT_OUTPUT` so the component reads it from the same pin. Call `hcsim::report(label, loads0, clocks0)`
|
||||
after each window with `hcsim::loads` and `hcsim::clocks` from its start; it logs `INVALID` instead of a result
|
||||
when the GPIO or interrupt setup failed or the window saw no load or no clock edge, so a dead simulator cannot pass
|
||||
as a wrong but steady pattern.
|
||||
- `ledprobe.hpp`: samples a status LED (or any output) pin every 2 ms from an `esp_timer`, off the loop task, and
|
||||
prints blink count, on time, period and the longest gap per window. It measures `status_led` while the loop is
|
||||
blocked (#16407). Pass `ledprobe::n` from the window start as the mark; a window with more than 400 edges logs
|
||||
`INVALID` and no result line instead of passing for a stalled LED. So does every window when the pin or timer
|
||||
setup failed or the timer has not sampled for 100 ms, so a dead probe cannot read as a quiet LED.
|
||||
- `fakeha.py HOST OUT_PREFIX`: an aioesphomeapi client standing in for Home Assistant for `homeassistant` select
|
||||
mirrors. It answers state subscriptions from a table, pushes option and state changes (full, overfull, quoted,
|
||||
unparseable), checks each step through a "Dump" button, captures the action calls, churns 600 option updates
|
||||
while watching the heap, and checks the entity options after a reconnect. A step fails when either DUMP line is
|
||||
missing, and a rejected update also needs the device's rejection log line, so an update that never arrived cannot
|
||||
pass. Exits 1 on a failed step and prints PASS otherwise.
|
||||
- `wizard_drive.py HOST`: finishes the device wizard through aioesphomeapi 46.28 (`device_wizard`,
|
||||
`wizard_input_set`), answers the subscriptions the inputs create and checks the values reached the entities.
|
||||
|
||||
## Static buffers (`cg.static_buffer`)
|
||||
|
||||
`scripts/static_buffer/` holds two local external components and `static-buffer-dut.yaml`, which loads them:
|
||||
- `sbtest` allocates three buffers with `cg.static_buffer` (37 `uint8_t`, 5 `uint32_t`, and `const char *` sized by
|
||||
a C++ expression) and checks at setup that they are zeroed and aligned, then fills each completely; at
|
||||
`dump_config()` it checks no other instance's fill reached them. Two instances prove the buffers are distinct.
|
||||
Both `SBTEST` lines end in `PASS` or `FAIL`; the `dump_config()` one passes only when the setup checks passed too.
|
||||
- `sbpsram` asks for `psram=True` and uses the pointer codegen hands it: the static buffer without PSRAM, or a
|
||||
zeroed `psram_buffer<T>()` with a `psram:` block, which logs `external=1` (nullptr if the allocation failed).
|
||||
It also gets a plain 8 word `uint32_t` static buffer, which must be non-null, aligned and zeroed at start and read
|
||||
back its fill (`plain=1`). Its `dump_config()` line ends in `PASS` only when both readbacks match, both buffers
|
||||
were zeroed at start and, with PSRAM, the PSRAM one is in external RAM.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Some "classic" devkits with a CP2102N carry an ESP32-PICO-D4. GPIO16 and GPIO17 are its flash pins, and an
|
||||
ESP-IDF build that drives them hits the interrupt watchdog. Use `board: pico32` and GPIO9/GPIO10.
|
||||
- An `addressable_light` display writes nothing until its light is turned on.
|
||||
- Before calling a rebased PR's change trivial, diff the PR's own diff (head against its parent) old against new: a
|
||||
move that looks like a merge only can carry real commits from below.
|
||||
@@ -17,3 +17,46 @@ only, announcement all four, after stop none, every cycle. API commands land a s
|
||||
the probe against the `State changed to` lines rather than the driver's timestamps. Internal free heap after a stop
|
||||
moved by 2.5 kB between two boots of the same build, so a heap delta needs more than one boot per build. Results:
|
||||
https://github.com/esphome/esphome/pull/21203#issuecomment-6299581943
|
||||
|
||||
## Streamed TTS with a pause (#21090, #21091)
|
||||
|
||||
Home Assistant streams TTS while it is still being generated, so the player must start on the first part instead of
|
||||
waiting for the whole file. `scripts/speaker_tts_pause/`:
|
||||
- `mk.py INTRO_AUDIO CONT_AUDIO www/s1.flac` builds one 48 kHz mono FLAC stream (the intro padded to a 4096-sample
|
||||
frame boundary, then the continuation) and writes the byte offset of the split to `s1.flac.cut`. Make the intro
|
||||
with any TTS, e.g. `say -o intro.aiff "..."` on macOS; build a short one (`s1`) and a long one (`s2`).
|
||||
- `srv.py PORT www` serves the file chunked: the header and intro at once, then it holds the connection open for
|
||||
`PAUSE` seconds (8), then the rest.
|
||||
- `tts-common.yaml` swaps the AUX-2's `speaker_source` player for the `speaker` platform player and counts played
|
||||
frames with `add_audio_output_callback` on both resampler speakers into the counters in `frames.h` (one `[frames`
|
||||
log line per change, read over serial with `--ms`). Its `on_boot` must be a list entry: a dict `on_boot` replaces
|
||||
the package's list and the AUX-2 never starts Wi-Fi or Ethernet.
|
||||
- `run.sh LABEL=TREE...` flashes each build and drives two short-intro and two long-intro announcements and one
|
||||
media play through `scripts/speaker_media_player/drive.py` (set `DRIVE` when running from a scratch copy); `an.py`
|
||||
prints first audio, the server's send times and how much of the intro played before the rest arrived, per request.
|
||||
A request fails when it never played the whole intro or stopped short of 95 % of the stream.
|
||||
|
||||
Before the two PRs the player waited for the rest of the stream (8.2 s) for both a 22.5 KB and a 50 KB intro; with
|
||||
both it starts at about 1 s.
|
||||
|
||||
## Voice PE wake word audio (#21885)
|
||||
|
||||
`scripts/voice_wake_word_audio/va.py HOST KEY OUTDIR accept,reject,timeout` stands in for Home Assistant
|
||||
(aioesphomeapi 46.27.0 or later, `wake_word_audio=True`), answers each run by mode and saves both channels as WAV;
|
||||
`locate.py OUTDIR` finds the played phrase in them by envelope cross-correlation against `wake16k.wav`.
|
||||
`run.sh HOST TAG [ROUNDS]` runs reject and timeout rounds while playing the phrase every 9 s, starting once `va.py`
|
||||
is READY. `va.py` fails a START before READY or during a run, and `run.sh`, `stale.py` and `mww.py` fail at once
|
||||
when the player fails or hangs for 30 s.
|
||||
|
||||
- Give the test build its own API key so Home Assistant cannot hold the voice assistant subscription.
|
||||
- Make the phrase with any TTS (`say -o wake.aiff "Okay Nabu"`, then `ffmpeg -i wake.aiff -ac 1 -ar 16000
|
||||
wake16k.wav`) and play it from the host near the device (`PLAY`, default `afplay`). On macOS the terminal needs
|
||||
audio permission first; before that `afplay` hangs.
|
||||
- The PE mute switch only mutes samples. To stop microWakeWord, add `mww-buttons.yaml` (template buttons calling
|
||||
`micro_wake_word.stop` and `start`); `mww.py` checks that no run starts while it is stopped.
|
||||
- Stale clip check: `stale.py` says a spoken marker word (`marker.aiff`), stops microWakeWord for 3 s, starts it and
|
||||
says the wake word; `stalean.py` measures loud audio before the phrase in each clip and exits 1 above
|
||||
`MAX_LOUD_MS` (default 100). The loud level has no default: calibrate `LOUD_LEVEL` on each setup from the phrase
|
||||
and pre-phrase peaks it prints (a run without it prints them and fails), somewhere between room noise and the
|
||||
marker's peak. A trial whose phrase peak is under twice `LOUD_LEVEL` fails as too quiet to judge. A steady 1 kHz
|
||||
beep never reaches the PE stream (the XMOS processing removes it), so the marker has to be speech.
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPI bus capture on the same board, no chips or wiring (#22376)
|
||||
|
||||
`scripts/spi_self/`: `gen.py` writes `spi<board>.yaml` (e32 classic ESP32, c3, e66 ESP8266, rp Pico 2 W) with a
|
||||
hardware bus, a software bus, several `spi_device` instances (all four modes, MSB and LSB first, different rates and
|
||||
CS pins) and a `max7219`. `spitest.hpp` drives every device from a 20 ms interval (an 8 byte transfer, write16, a 12
|
||||
bit write, read_array, write_array, back to back transfers, teardown plus setup, a second setup, a max7219 update),
|
||||
three passes, then reboots. `run.sh BOARD LABEL SECONDS` (env `TREE`, `PORT`, `SC`, `PY`, and `PORTS` for the Pico)
|
||||
builds, flashes, captures and checks; `analyze.py spi<board>.yaml BASE.txt PR.txt` checks each record against the
|
||||
expected wire data and diffs the two builds by the majority payload per record, so interrupt jitter does not count.
|
||||
|
||||
- Classic ESP32: the ESPHome bus is on SPI2 through the matrix and SPI3 runs as a slave without DMA (64 bytes at
|
||||
most), set up again in the device's mode for each operation. Its nominal CS must be a spare pad held high
|
||||
(GPIO14); the device CS is routed into `spics_in` only after `spi_slave_queue_trans()`, because the slave driver
|
||||
reconnects the nominal pad when a transaction loads. Never give the slave the device's CS pad:
|
||||
`spicommon_cs_initialize` takes over its output and `spi_slave_free` resets it. `gpio_set_direction(..., OUTPUT)`
|
||||
turns a pad back into a plain GPIO output, so only call `gpio_input_enable` on pads the master drives.
|
||||
- With that slave attached, full duplex reads on the master lose their last bytes after the first read of a boot,
|
||||
even through a MOSI to MISO loopback; a config without the slave reads fine. Do not judge reads on that layout.
|
||||
- The slave sees bit-banged mode 1 data one bit late; the interrupt captures decode it correctly.
|
||||
- C3, ESP8266 and Pico 2 W: GPIO edge interrupts on SCK and CS take snapshots of the input register. C3 SPI2 cannot
|
||||
clock below about 78 kHz; at 80 to 100 kHz the capture works with
|
||||
`gpio_install_isr_service(ESP_INTR_FLAG_LEVEL3 | ESP_INTR_FLAG_IRAM)`, but one build lost an SCK pair at the start
|
||||
of a frame in every run and a rebuild did not: compare two builds before calling a missing edge real. Let the log
|
||||
output drain (20 ms) before a capture that follows `spi_setup()`.
|
||||
- ESP8266: the HSPI pads (12, 13, 14) do not show in `GPI` while in SPI function; the script logs `SPI1CLK`,
|
||||
`SPI1C`, `SPI1U` and `SPI1P` after each hardware bus operation instead.
|
||||
- Pico 2 W: `IO_BANK0` INOVER on the MISO pad forces the SPI receive input low or high (reads 00 and ff).
|
||||
- Software bus loopback: MOSI and MISO on one pad with `allow_other_uses`, then set the pad back to output.
|
||||
- Seen on dev before #22376: the software bus ignores `data_rate` (`cycle_clock_()` never waits), and SCK moves to
|
||||
the new idle level only after CS is low when devices with different clock polarity share the software bus.
|
||||
- LibreTiny (BK72xx, RTL87xx, LN882x) has no `spi:` (config validation allows esp32, esp8266, nrf52 and rp2 only),
|
||||
so SPI changes cannot be run there.
|
||||
@@ -0,0 +1,19 @@
|
||||
# sun on_sunrise and on_sunset without waiting for the sun (#21803)
|
||||
|
||||
`scripts/sun/sun-common.yaml` (a package) puts the observer on the equator and, once SNTP has synced, searches the
|
||||
longitudes for one where the sun stands near -5° and rises as the observer moves east. It holds that longitude for
|
||||
one 60 s elevation poll first: the poll before saw the sun at longitude 0, and the jump could read as a crossing.
|
||||
Crossings until then are logged as `crossing before the sweep ignored` and not counted. An interval then moves the
|
||||
longitude by 0.125° every 5 s, which sweeps the elevation from -5° to +6° and back at about 1.5° a minute, reversing
|
||||
above +6° and below -5°. Five triggers (sunrise at the default, 2° and 4°, sunset at the default and -2°) fire from
|
||||
the real 60 s elevation poll; each logs the elevation it fired at and counts in `fires`, logged every 30 s.
|
||||
|
||||
- The sweep stops after one full pass and logs `sweep done`. `run.sh esp32|esp8266 LABEL` builds, flashes and
|
||||
captures up to 20 minutes (a pass takes about 17), then `check.py` wants `sweep done` and the counter at 5 with no
|
||||
trigger firing twice. Run dev and the PR on the same board; two boards can run in parallel.
|
||||
- At 1.5° a minute a crossing fires up to about 1.7° past its elevation, one poll late at most.
|
||||
- The seconds past the minute of the fire lines show the poll phase: separate pollers fire at different seconds,
|
||||
one shared poll fires every trigger in the same second.
|
||||
- `id(sun_id)` is a pointer in lambdas (`->`). Judge by the `fires` counter: the classic devkit's serial log can
|
||||
garble single lines.
|
||||
- Results: https://github.com/esphome/esphome/pull/21803#issuecomment-6490173072
|
||||
@@ -0,0 +1,24 @@
|
||||
# CAN (esp32_can) without a transceiver (#21792, #21795)
|
||||
|
||||
`scripts/twai_self`: `esp32_can` with `mode: NO_ACK`, TX on GPIO25 and RX on GPIO26. After boot `canself.hpp` enables
|
||||
the input on GPIO25 and routes it to the controller's receive signal (`esp_rom_gpio_connect_in_signal(25,
|
||||
TWAI_RX_IDX, false)`), so the controller hears its own transmit pad. Frames go out with `twai_transmit_v2` and
|
||||
`TWAI_MSG_FLAG_SELF` (the driver handle is reached through explicit template instantiation), so every frame runs
|
||||
through the normal receive path and `on_frame`.
|
||||
|
||||
- `can.yaml` has nine `on_frame` entries (standard and extended ids, masks, remote-only and data-only filters, two
|
||||
entries with the same filter, an id with bits outside its mask that must never fire) and sends 20 rounds of 7
|
||||
frames. `run.sh LABEL` writes `out-can<LABEL>.counts` (fires per trigger, id, flags and data); diff dev and the PR.
|
||||
- Set `rx_queue_len: 32`: with the default 5, a burst of 7 frames loses the last two most of the time, on every
|
||||
build.
|
||||
- ESPHome's own `canbus.send` does not set the self flag, so its frames are not received in this setup.
|
||||
- Results: https://github.com/esphome/esphome/pull/21795#issuecomment-6491777381
|
||||
|
||||
## ESP32-C3 and extra frame shapes (#21950)
|
||||
|
||||
The C3 works the same way: `variant: esp32c3`, TX GPIO5 and RX GPIO6, `TX_PAD = GPIO_NUM_5` in `canself.hpp`, and
|
||||
the logger on USB_SERIAL_JTAG. `canx.hpp` adds a classic frame with DLC 12 (`TWAI_MSG_FLAG_DLC_NON_COMP`), the
|
||||
`send_data` overloads (standard, extended, remote, empty, a 10-byte payload that must be clamped, a vector) and a
|
||||
`twai_get_status_info_v2` dump before and after (`canx::tx_status(id(can0), "before")`). The `get()` that reads the
|
||||
private handle is a friend found only by argument-dependent lookup: call `get(canself::Handle{})`, never
|
||||
`canself::get(...)`.
|
||||
@@ -15,5 +15,60 @@ packages; copy them with a different name (`frabase` and so on) for the base bui
|
||||
- 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).
|
||||
LOG press:NAME sel:NAME=OPT num:NAME=V wait:S @FILE` (`@FILE` reads one op per line), 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, `ERASE=1` erases the flash first, `DRIVER` picks another
|
||||
API driver).
|
||||
|
||||
## Particulate sensors: gcja5, pm1006, sds011 (#21939)
|
||||
|
||||
`templates/uart_sim/pmsim.hpp` adds frame builders `sim::gcja5`, `sim::pm1006` and `sim::sds011`, each with a bad
|
||||
checksum or bad end flag. `fp-parts.yaml` puts gcja5 on UART1 (9600 8E1, with a template binary sensor that shows
|
||||
`status_has_warning()`) and pm1006 on UART2; `fs-parts.yaml` has sds011 alone. Write the run files the same way as
|
||||
`fra.yaml` (`fp.yaml`, `fs.yaml` with their own name).
|
||||
|
||||
- Every button injects one case with its own PM value, so each published state names its case: good, two frames,
|
||||
bad checksum, end or tail, garbage before a frame, a cut-off frame followed by a good one, a repeated first header
|
||||
byte, and splits of 300 and 700 ms (either side of the 500 ms gap reset).
|
||||
- `run.sh NAME TREE 150 @fp-ops.txt` runs the op list from the file; `fs-ops.txt`, `fd-ops.txt` and `fj-ops.txt`
|
||||
are the other lists.
|
||||
- The loopback also feeds each component its own writes (pm1006 request, sds011 setup and sleep/work commands),
|
||||
which must not disturb the next frame.
|
||||
- The API coalesces two states published in the same loop pass, so "two frames" shows only the second value.
|
||||
- Found with it: on dev gcja5 published bad and misaligned frames as readings (268435456).
|
||||
|
||||
## 4-byte distance sensors: a01nyub, a02yyuw, jsn_sr04t (#21948)
|
||||
|
||||
`sim::ds1603l(mm, bad_ck)` builds the same `FF H L sum` frame; `fd-parts.yaml` (a01nyub on UART1, a02yyuw on UART2) and `fj-parts.yaml`
|
||||
(jsn_sr04t, polled every 15 s), with the same case list as above plus a reading below the minimum distance. With
|
||||
loopback the jsn_sr04t request byte `55` can land inside a split frame and break it; check the `Request read out`
|
||||
timestamps before calling a difference between builds.
|
||||
|
||||
## LD6002B TinyFrame parser (#19836, #19837)
|
||||
|
||||
`templates/uart_sim/ld6002b/`: `ld6sim.hpp` is a fake module on a UART cross-wired to the component's UART (UART1
|
||||
tx 25 rx 26, UART2 tx 26 rx 25), set up by the shared `ld6-base.yaml` package. It ACKs every command with an empty
|
||||
frame of the same type and logs the bytes it received (`MOD RX`), so the bytes on the wire can be diffed between
|
||||
builds. Target reports carry a distinct X per
|
||||
case, and `T1 X` shows which frames got through.
|
||||
|
||||
- `ld6-parser-cases.yaml` (used by `ld6-common.yaml`, which turns the query answers off): good, byte by byte (one
|
||||
byte per poll, so the component's loop runs in between), garbage before a frame, bad and lost header or data
|
||||
checksums, an oversized payload, a burst of 20. The script runs by itself from `on_boot`; `ld6p.yaml` is the run file
|
||||
(copy it with another name for the base build). `run.sh NAME TREE` builds, flashes over serial and captures,
|
||||
and fails without a `CASE done` line.
|
||||
- Garbage that contains `01` right before a frame loses that frame on dev and on the PR (a false header, no rescan).
|
||||
- Full component A/B: `ld6sim.hpp` also answers every query with a fixed report (delay, sensitivity, trigger,
|
||||
installation, Z range, low power, low power sleep, both area reports, version); `ld6x-common.yaml` has the full
|
||||
entity set and runs the parser cases too (`ld6x.yaml` is its run file); `ld6drive.py HOST` presses every button,
|
||||
sets every number and select and toggles every switch over the API. Run it with `ERASE=1 DRIVER=ld6drive.py
|
||||
scripts/uart_sim/run.sh NAME TREE 100`: switch and select states and the area id are stored preferences, and a previous build's run changes the
|
||||
next build's boot state.
|
||||
- `m4-common.yaml` and `m5-common.yaml` run the same fake against `ld6004`: target and area reports, work status and
|
||||
radar on/off reports, malformed reports, and the point cloud and target display switches across a reboot
|
||||
(`m5` reboots once and checks the restored switch states). `m4.yaml` and `m5.yaml` are the run files, and like
|
||||
`ld6p.yaml` each can be copied with another name for the base build. Run m5 with `ERASE=1 run.sh NAME TREE 75`: the boot
|
||||
counter and switch states are stored preferences that survive a flash, and the second boot's `CASE done` comes
|
||||
about 49 s after reset. Only the second boot logs `CASE done`, so a skipped reboot fails the run.
|
||||
|
||||
See `rs485_frame.md` for the same cross-wired UART trick on an ESP32-C3.
|
||||
@@ -0,0 +1,15 @@
|
||||
# sensor and number on_value_range A/B (#21814, #21817, #21818)
|
||||
|
||||
`scripts/value_range/value-range-common.yaml` (a package) has one template sensor with seven triggers (constant
|
||||
bounds, one-sided, a lambda bound on `x`, a bound from another sensor's state that stays NaN until step 22, both
|
||||
bounds NaN, and an identical twin) and one template number with three triggers. It feeds them 30 values 200 ms apart
|
||||
(on the bounds, just past them, repeats, NaN, ±inf, a moving bound), then syncs preferences and restarts, then
|
||||
sends 50, 95 and 50 to check the restored in-range flag. Every fire logs its step and `x`.
|
||||
|
||||
- `run.sh bk72xx|esp8266|esp32 LABEL` writes the per-build file with a fresh `tag`, builds, flashes, captures 90 s
|
||||
(through the restart) and writes `out-rng<kind><LABEL>.seq`; diff dev against each PR head.
|
||||
- The sequence primes every trigger first (bound 0, value -500, bound NaN): the in-range flags are stored in flash,
|
||||
so without priming a fresh build starts from whatever the previous run left, and the first fires differ.
|
||||
- The phase global resets when `tag` changes, so each reflash runs the whole sequence.
|
||||
- Log line numbers differ between trees; the runner strips them before writing the `.seq` file.
|
||||
- Results: https://github.com/esphome/esphome/pull/21818#issuecomment-6491846503
|
||||
@@ -0,0 +1,15 @@
|
||||
# voice_assistant
|
||||
|
||||
## Voice PE late TTS start (#19408)
|
||||
|
||||
Only the subscribed client (Home Assistant) may send `VoiceAssistantAnnounceRequest`, so both builds carry
|
||||
`scripts/voice_announce/slow-announce.yaml`, a template button that calls `id(va).on_announce(msg)` with
|
||||
`start_conversation = true` and a URL on the host. `drive.py HOST KEY DELAY OUT` serves a FLAC (WAV as `audio/wav`
|
||||
is rejected by micro_decoder) only after DELAY seconds, presses the button and timestamps the device logs. It
|
||||
holds the voice assistant subscription throughout, since without a subscriber the device drops the announcement
|
||||
state at once. It imports `va_common.py` from `scripts/voice_wake_word_audio/`, so keep the two directories side
|
||||
by side.
|
||||
`run.sh DEVICE LABEL=CONFIG=TREE...` builds, OTAs and runs both builds twice. The player gives up after 6 header
|
||||
attempts of 5 s.
|
||||
|
||||
The wake word audio flow is in [`speaker.md`](speaker.md).
|
||||
@@ -12,13 +12,14 @@ on ESP32. Keep `power_save_mode: none`, and drive the device by address: mDNS na
|
||||
|
||||
`scripts/web_server/sse.py HOST SECONDS [--clients N] [--stall] [--churn CYCLES]` opens raw TCP connections to
|
||||
`/events`, decodes the chunked body and parses every `state` event as JSON; `--stall` adds a connection with a 2 kB
|
||||
receive buffer that never reads (the device must log `Closing stuck EventSource connection` after the 20 s stall
|
||||
timeout), `--churn` opens and resets a connection CYCLES times. `sse_cases.sh HOST KEY LABEL CAPTURE` runs the
|
||||
set: 1 client 60 s, 4 clients 60 s, a reader plus a stalled client 45 s, a reader plus 10 abrupt closes, then two
|
||||
clients with the "Burst" button (50 sensor and 50 text publishes in one loop pass). Pass: 0 invalid JSON on every
|
||||
client, the oversize sensor dropped with its warning on every publish, the stalled session closed after about
|
||||
20 s, no reset. The loop task watermark is a minimum since boot, so compare the value after the first client, after
|
||||
four clients and after the burst between the builds (the SSE split in #21109 left 1280 B more on the ESP32 classic).
|
||||
receive buffer that never reads (the device must log `Closing stuck stream` on ESP-IDF and `Closing stuck
|
||||
EventSource connection` elsewhere, after the 20 s stall timeout), `--churn` opens and resets a connection CYCLES
|
||||
times. `sse_cases.sh HOST KEY LABEL CAPTURE` runs the set: 1 client 60 s, 4 clients 60 s, a reader plus a stalled
|
||||
client 45 s, a reader plus 10 abrupt closes, then two clients with the "Burst" button (50 sensor and 50 text
|
||||
publishes in one loop pass). Pass: 0 invalid JSON on every client, the oversize sensor dropped with its warning on
|
||||
every publish, the stalled session closed after about 20 s, no reset. The loop task watermark is a minimum since
|
||||
boot, so compare the value after the first client, after four clients and after the burst between the builds (the
|
||||
SSE split in #21109 left 1280 B more on the ESP32 classic).
|
||||
|
||||
For the HTTPD task, `web_server: auth: type: digest` (the default `auth` is Basic) and
|
||||
`scripts/web_server/httpd_cases.sh HOST USER PASS LABEL CAPTURE`: `httpmix.sh` sends a `curl --anyauth` request
|
||||
|
||||
@@ -45,9 +45,9 @@ the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also
|
||||
`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
|
||||
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the main
|
||||
network at 0, and reboot 30 s after joining the AP and 150 s after joining the main network. So they move to the
|
||||
AP while it is up and fall back to the main 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
|
||||
@@ -74,7 +74,22 @@ the captive portal's DNS answer for its TTL.
|
||||
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
|
||||
AP start. After 19 minutes the DUTs run `wifi.configure` to the main 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.
|
||||
|
||||
## Channel and network switching with a bench AP (#22142)
|
||||
|
||||
`scripts/wifi_channel/`: `bench-ap.yaml` makes an ESP32-S3 an AP-only bench network on a fixed channel (11 by
|
||||
default; pick one your normal network does not use), and `dut-common.yaml` gives the device under test that AP at
|
||||
priority 10 plus your normal network, logs every `wifi_info` ssid and channel update (with the channel the component
|
||||
reports and whether it is connected), and cycles `wifi.disable` and `wifi.enable` every 90 s. `run.sh LABEL` (env
|
||||
`TREE`, `PORT`, `AP_PORT`, `SC`, `PY`, and `BENCH_CHANNEL` when the AP is not on 11) flashes the device and captures 420 s while it takes the AP down with
|
||||
`esptool --port <AP port> --after no-reset chip_id` (the board stays in the bootloader) and back with
|
||||
`--after hard-reset`. The channel sensor must follow every move between the two networks: the run passes when the
|
||||
connected channel starts and ends on the bench channel with another channel in between.
|
||||
|
||||
- Dropping the AP under a connected device gives a `Beacon Timeout` link loss.
|
||||
- The 90 s interval fires once right after boot.
|
||||
- A `Disconnected ... Association Leave` right after `Connecting to` is the aborted attempt, not a missed connect.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Run artifacts the runners write next to their scripts; none of these patterns match a tracked file.
|
||||
out-*/
|
||||
out-*.*
|
||||
.esphome/
|
||||
secrets.yaml
|
||||
__pycache__/
|
||||
ble_adv_triggers/rxbk72xx*.yaml
|
||||
ble_adv_triggers/rxesp32*.yaml
|
||||
ezo_fake_bus/ezo?*.yaml
|
||||
homeassistant_button/habtn*.yaml
|
||||
hub_input_sim/hubsim*.yaml
|
||||
interval/ivl*.yaml
|
||||
led_driver_sniff/led*.yaml
|
||||
ota_automations/otaauto*.yaml
|
||||
output_button/outbtn*.yaml
|
||||
remote_base/rb*.yaml
|
||||
sensor_filters/sf?*.yaml
|
||||
sun/sunesp*.yaml
|
||||
twai_self/can?*.yaml
|
||||
uart_button/uartbtn*.yaml
|
||||
value_range/rng*.yaml
|
||||
ld2420_sim/ldenergy*.yaml
|
||||
ld2420_sim/ldsimple*.yaml
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Shared helpers for the API drivers: connect with retries, and the text of an API log line.
|
||||
|
||||
Import it with this directory on sys.path (the drivers add their parent directory, runners set PYTHONPATH).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
EncryptionPlaintextAPIError,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
)
|
||||
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
# a wrong key or a plaintext/Noise mismatch does not go away with a retry
|
||||
FATAL = (
|
||||
EncryptionPlaintextAPIError,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
)
|
||||
|
||||
|
||||
async def connect(host: str, tries: int = 30, timeout: float = 10) -> APIClient:
|
||||
"""Connect and log in, retrying once a second; exits after TRIES failures with the last error.
|
||||
|
||||
API_KEY in the env selects Noise; unset, empty or - is plaintext.
|
||||
"""
|
||||
key = os.environ.get("API_KEY")
|
||||
last: BaseException | None = None
|
||||
for _ in range(tries):
|
||||
c = APIClient(
|
||||
host, 6053, None, noise_psk=None if key in (None, "", "-") else key
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(c.connect(login=True), timeout)
|
||||
except FATAL as e:
|
||||
sys.exit(f"no API connection to {host}: {type(e).__name__}: {e}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
# release the half-open socket so retries do not use up the device's API slots
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect(force=True)
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
return c
|
||||
sys.exit(
|
||||
f"no API connection to {host} after {tries} tries: {type(last).__name__}: {last}"
|
||||
)
|
||||
|
||||
|
||||
def log_text(msg) -> str:
|
||||
"""The message of a subscribe_logs callback as text, without ANSI colour codes."""
|
||||
return ANSI.sub("", msg.message.decode(errors="replace"))
|
||||
@@ -0,0 +1,348 @@
|
||||
# Automation code generation trace: every step logs a marker "M <case> <values>". A package: the per-run file from
|
||||
# run.sh supplies the board, Wi-Fi and name. drive.py records the markers over the API.
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
globals:
|
||||
- {id: g_a, type: int, initial_value: "0"}
|
||||
- {id: g_b, type: int, initial_value: "0"}
|
||||
- {id: g_tick, type: int, initial_value: "0"}
|
||||
- {id: g_ticking, type: bool, initial_value: "false"}
|
||||
- {id: g_par, type: int, initial_value: "0"}
|
||||
- {id: g_par3, type: int, initial_value: "0"}
|
||||
- {id: g_par4, type: int, initial_value: "0"}
|
||||
|
||||
interval:
|
||||
- interval: 100ms
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(g_ticking);
|
||||
then:
|
||||
- lambda: id(g_tick)++;
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
id: num
|
||||
name: Num
|
||||
optimistic: true
|
||||
min_value: 0
|
||||
max_value: 100
|
||||
step: 1
|
||||
on_value:
|
||||
- logger.log: {format: "M num_on_value %.0f", args: [x]}
|
||||
- if:
|
||||
condition:
|
||||
lambda: return x > 50;
|
||||
then:
|
||||
- logger.log: {format: "M num_high %.0f", args: [x]}
|
||||
else:
|
||||
- logger.log: {format: "M num_low %.0f", args: [x]}
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
id: sw
|
||||
name: Sw
|
||||
optimistic: true
|
||||
on_turn_on:
|
||||
- logger.log: "M sw_on"
|
||||
- globals.set: {id: g_b, value: !lambda "return id(g_b) + 1;"}
|
||||
- logger.log: {format: "M sw_on_b %d", args: [id(g_b)]}
|
||||
on_turn_off:
|
||||
- logger.log: "M sw_off"
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
id: sel
|
||||
name: Sel
|
||||
optimistic: true
|
||||
options: [a, b, c]
|
||||
initial_option: a
|
||||
on_value:
|
||||
- logger.log: {format: "M sel_on_value %s idx %d", args: [x.c_str(), (int) i]}
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: sens
|
||||
name: Sens
|
||||
update_interval: never
|
||||
on_value_range:
|
||||
- above: 50
|
||||
then:
|
||||
- logger.log: {format: "M sens_above %.0f", args: [x]}
|
||||
- below: 10
|
||||
then:
|
||||
- logger.log: {format: "M sens_below %.0f", args: [x]}
|
||||
|
||||
alarm_control_panel:
|
||||
- platform: template
|
||||
id: acp
|
||||
name: Acp
|
||||
codes: ["1234"]
|
||||
requires_code_to_arm: false
|
||||
arming_away_time: 0s
|
||||
on_armed_away:
|
||||
- logger.log: "M acp_armed_away"
|
||||
on_disarmed:
|
||||
- logger.log: "M acp_disarmed"
|
||||
on_state:
|
||||
- logger.log: "M acp_state"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Run All
|
||||
on_press:
|
||||
- logger.log: "M press_run_all"
|
||||
- script.execute: master
|
||||
- platform: template
|
||||
name: Chain Button
|
||||
on_press:
|
||||
- logger.log: "M cb_1"
|
||||
- lambda: id(g_a) = 7;
|
||||
- globals.set: {id: g_b, value: "3"}
|
||||
- logger.log: {format: "M cb_2 %d %d", args: [id(g_a), id(g_b)]}
|
||||
- switch.toggle: sw
|
||||
- logger.log: "M cb_3"
|
||||
|
||||
script:
|
||||
- id: s_single
|
||||
mode: single
|
||||
parameters: {x: int}
|
||||
then:
|
||||
- logger.log: {format: "M single_start %d", args: [x]}
|
||||
- delay: 150ms
|
||||
- logger.log: {format: "M single_end %d", args: [x]}
|
||||
- id: s_restart
|
||||
mode: restart
|
||||
parameters: {x: int, s: string}
|
||||
then:
|
||||
- logger.log: {format: "M restart_start %d %s", args: [x, s.c_str()]}
|
||||
- delay: 150ms
|
||||
- logger.log: {format: "M restart_end %d %s", args: [x, s.c_str()]}
|
||||
- id: s_queued
|
||||
mode: queued
|
||||
max_runs: 5
|
||||
parameters: {x: int}
|
||||
then:
|
||||
- logger.log: {format: "M queued_start %d", args: [x]}
|
||||
- delay: 60ms
|
||||
- logger.log: {format: "M queued_end %d", args: [x]}
|
||||
- id: s_parallel
|
||||
mode: parallel
|
||||
parameters: {x: int}
|
||||
then:
|
||||
- logger.log: {format: "M parallel_start %d", args: [x]}
|
||||
- delay: !lambda "return 50 + x * 30;"
|
||||
- logger.log: {format: "M parallel_end %d", args: [x]}
|
||||
- id: s_selfstop
|
||||
mode: single
|
||||
then:
|
||||
- logger.log: "M selfstop_1"
|
||||
- lambda: id(g_a) = 100;
|
||||
- script.stop: s_selfstop
|
||||
- logger.log: "M selfstop_SHOULD_NOT_RUN"
|
||||
- lambda: id(g_a) = 200;
|
||||
- id: s_par_self
|
||||
mode: parallel
|
||||
max_runs: 2
|
||||
then:
|
||||
- lambda: id(g_par)++;
|
||||
- logger.log: {format: "M parself_run %d running=%d", args: [id(g_par), (int) id(s_par_self).is_running()]}
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(g_par) < 4;
|
||||
then:
|
||||
- script.execute: s_par_self
|
||||
- delay: 200ms
|
||||
- id: s_par3
|
||||
mode: parallel
|
||||
max_runs: 3
|
||||
then:
|
||||
- lambda: id(g_par3)++;
|
||||
- logger.log: {format: "M par3_run %d", args: [id(g_par3)]}
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(g_par3) < 8;
|
||||
then:
|
||||
- script.execute: s_par3
|
||||
- delay: 200ms
|
||||
- id: s_par4
|
||||
mode: parallel
|
||||
max_runs: 4
|
||||
then:
|
||||
- lambda: id(g_par4)++;
|
||||
- logger.log: {format: "M par4_run %d", args: [id(g_par4)]}
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(g_par4) < 8;
|
||||
then:
|
||||
- script.execute: s_par4
|
||||
- delay: 200ms
|
||||
- id: master
|
||||
mode: single
|
||||
then:
|
||||
- logger.log: "M c1_chain_start"
|
||||
- lambda: id(g_a) = 1;
|
||||
- globals.set: {id: g_b, value: "2"}
|
||||
- logger.log: {format: "M c1_vals %d %d", args: [id(g_a), id(g_b)]}
|
||||
- switch.turn_on: sw
|
||||
- number.set: {id: num, value: 42}
|
||||
- select.set: {id: sel, option: b}
|
||||
- lambda: |-
|
||||
int local = id(g_a) + id(g_b);
|
||||
if (local > 0) { ESP_LOGI("main", "M c1_lambda_local %d", local); return; }
|
||||
ESP_LOGI("main", "M c1_lambda_NOT_REACHED");
|
||||
- logger.log: "M c1_after_return"
|
||||
- logger.log: "M c2_delay_chain_start"
|
||||
- delay: 50ms
|
||||
- logger.log: "M c2_after_delay"
|
||||
- lambda: id(g_a) = 5;
|
||||
- delay: 0ms
|
||||
- logger.log: {format: "M c2_end %d", args: [id(g_a)]}
|
||||
- logger.log: "M c3_if_start"
|
||||
- repeat:
|
||||
count: 4
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
and:
|
||||
- lambda: return iteration % 2 == 0;
|
||||
- not:
|
||||
lambda: return iteration == 2;
|
||||
then:
|
||||
- logger.log: {format: "M c3_even_not2 %d", args: [iteration]}
|
||||
else:
|
||||
- if:
|
||||
condition:
|
||||
or:
|
||||
- lambda: return iteration == 1;
|
||||
- xor:
|
||||
- lambda: return iteration == 2;
|
||||
- lambda: return false;
|
||||
then:
|
||||
- logger.log: {format: "M c3_or_xor %d", args: [iteration]}
|
||||
else:
|
||||
- logger.log: {format: "M c3_else %d", args: [iteration]}
|
||||
- logger.log: "M c4_choose_start"
|
||||
- repeat:
|
||||
count: 3
|
||||
then:
|
||||
- choose:
|
||||
- if:
|
||||
lambda: return iteration == 0;
|
||||
then:
|
||||
- logger.log: {format: "M c4_choice0 %d", args: [iteration]}
|
||||
- if:
|
||||
lambda: return iteration == 1;
|
||||
then:
|
||||
- logger.log: {format: "M c4_choice1 %d", args: [iteration]}
|
||||
- else:
|
||||
- logger.log: {format: "M c4_default %d", args: [iteration]}
|
||||
- logger.log: "M c5_while_start"
|
||||
- globals.set: {id: g_a, value: "0"}
|
||||
- while:
|
||||
condition:
|
||||
lambda: return id(g_a) < 4;
|
||||
then:
|
||||
- logger.log: {format: "M c5_while %d", args: [id(g_a)]}
|
||||
- lambda: id(g_a)++;
|
||||
- logger.log: {format: "M c5_while_done %d", args: [id(g_a)]}
|
||||
- globals.set: {id: g_a, value: "0"}
|
||||
- while:
|
||||
condition:
|
||||
lambda: return id(g_a) < 3;
|
||||
then:
|
||||
- logger.log: {format: "M c5_while_delay %d", args: [id(g_a)]}
|
||||
- delay: 20ms
|
||||
- lambda: id(g_a)++;
|
||||
- logger.log: {format: "M c5_while_delay_done %d", args: [id(g_a)]}
|
||||
- logger.log: "M c6_repeat_start"
|
||||
- repeat:
|
||||
count: 2
|
||||
then:
|
||||
- lambda: id(g_b) = iteration;
|
||||
- repeat:
|
||||
count: 3
|
||||
then:
|
||||
- logger.log: {format: "M c6_nested outer=%d inner=%d", args: [id(g_b), iteration]}
|
||||
- repeat:
|
||||
count: !lambda "return id(g_b) + 2;"
|
||||
then:
|
||||
- logger.log: {format: "M c6_dyn %d", args: [iteration]}
|
||||
- delay: 10ms
|
||||
- logger.log: "M c7_wait_until_start"
|
||||
- lambda: |-
|
||||
id(g_tick) = 0;
|
||||
id(g_ticking) = true;
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: return id(g_tick) >= 3;
|
||||
timeout: 2s
|
||||
- logger.log: {format: "M c7_waited %d", args: [(int) (id(g_tick) >= 3)]}
|
||||
- wait_until:
|
||||
condition:
|
||||
for:
|
||||
time: 300ms
|
||||
condition:
|
||||
lambda: return id(g_tick) >= 3;
|
||||
timeout: 3s
|
||||
- lambda: id(g_ticking) = false;
|
||||
- logger.log: "M c7_for_done"
|
||||
- logger.log: "M c8_scripts_start"
|
||||
- script.execute: {id: s_single, x: 1}
|
||||
- script.execute: {id: s_single, x: 2}
|
||||
- script.wait: s_single
|
||||
- script.execute: {id: s_restart, x: 1, s: one}
|
||||
- delay: 40ms
|
||||
- script.execute: {id: s_restart, x: 2, s: two}
|
||||
- script.wait: s_restart
|
||||
- script.execute: {id: s_queued, x: 1}
|
||||
- script.execute: {id: s_queued, x: 2}
|
||||
- script.execute: {id: s_queued, x: 3}
|
||||
- script.wait: s_queued
|
||||
- script.execute: {id: s_parallel, x: 2}
|
||||
- script.execute: {id: s_parallel, x: 1}
|
||||
- script.wait: s_parallel
|
||||
- logger.log: "M c9_selfstop_start"
|
||||
- script.execute: s_selfstop
|
||||
- script.wait: s_selfstop
|
||||
- logger.log: {format: "M c9_selfstop_g_a %d", args: [id(g_a)]}
|
||||
- logger.log: "M c10_sensor_start"
|
||||
- sensor.template.publish: {id: sens, state: 60}
|
||||
- sensor.template.publish: {id: sens, state: 30}
|
||||
- sensor.template.publish: {id: sens, state: 5}
|
||||
- logger.log: "M c11_acp_start"
|
||||
- alarm_control_panel.arm_away: acp
|
||||
- delay: 100ms
|
||||
- alarm_control_panel.disarm: {id: acp, code: "1234"}
|
||||
- delay: 100ms
|
||||
- logger.log: "M c12_parself_start"
|
||||
- globals.set: {id: g_par, value: "0"}
|
||||
- script.execute: s_par_self
|
||||
- delay: 800ms
|
||||
- logger.log: {format: "M c12_parself_total %d", args: [id(g_par)]}
|
||||
- logger.log: "M c13_parmax_start"
|
||||
- globals.set: {id: g_par3, value: "0"}
|
||||
- script.execute: s_par3
|
||||
- delay: 800ms
|
||||
- logger.log: {format: "M c13_par3_total %d", args: [id(g_par3)]}
|
||||
- globals.set: {id: g_par4, value: "0"}
|
||||
- script.execute: s_par4
|
||||
- delay: 800ms
|
||||
- logger.log: {format: "M c13_par4_total %d", args: [id(g_par4)]}
|
||||
- switch.turn_off: sw
|
||||
- logger.log: "M done"
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- logger.log: "M boot"
|
||||
- delay: 8s
|
||||
- script.execute: master
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Record every 'M ...' marker from the device log while driving the automation trace config.
|
||||
|
||||
usage: drive.py HOST OUT env: API_KEY (unset for a plaintext API)
|
||||
|
||||
Order: wait for the boot run's 'M done', press Run All and wait for 'M done' again, press Chain Button twice, set Num
|
||||
70 then 20, toggle Sw on and off, select c then a. Writes one marker per line to OUT (timestamps dropped). Exits 1
|
||||
when the Run All pass never reached 'M done'.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import LogLevel
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
from apiconn import connect, log_text # noqa: E402
|
||||
|
||||
HOST, OUT = sys.argv[1], sys.argv[2]
|
||||
marks: list[str] = []
|
||||
done = asyncio.Event()
|
||||
|
||||
|
||||
async def main() -> bool:
|
||||
cli = await connect(HOST)
|
||||
|
||||
def on_log(m) -> None:
|
||||
if mm := re.search(r"\]: (M .*)$", log_text(m)):
|
||||
marks.append(mm.group(1).strip())
|
||||
if mm.group(1).strip() == "M done":
|
||||
done.set()
|
||||
|
||||
cli.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
infos, _ = await cli.list_entities_services()
|
||||
by = {i.name: i for i in infos}
|
||||
try:
|
||||
await asyncio.wait_for(done.wait(), 40)
|
||||
except TimeoutError:
|
||||
marks.append("DRIVER boot run not seen")
|
||||
marks.append("DRIVER press Run All")
|
||||
done.clear()
|
||||
cli.button_command(by["Run All"].key)
|
||||
ok = True
|
||||
try:
|
||||
await asyncio.wait_for(done.wait(), 40)
|
||||
except TimeoutError:
|
||||
marks.append("DRIVER run all timed out")
|
||||
ok = False
|
||||
steps = (
|
||||
("cb1", lambda: cli.button_command(by["Chain Button"].key)),
|
||||
("cb2", lambda: cli.button_command(by["Chain Button"].key)),
|
||||
("num70", lambda: cli.number_command(by["Num"].key, 70.0)),
|
||||
("num20", lambda: cli.number_command(by["Num"].key, 20.0)),
|
||||
("swon", lambda: cli.switch_command(by["Sw"].key, True)),
|
||||
("swoff", lambda: cli.switch_command(by["Sw"].key, False)),
|
||||
("selc", lambda: cli.select_command(by["Sel"].key, "c")),
|
||||
("sela", lambda: cli.select_command(by["Sel"].key, "a")),
|
||||
)
|
||||
for label, send in steps:
|
||||
marks.append(f"DRIVER {label}")
|
||||
send()
|
||||
await asyncio.sleep(0.8)
|
||||
await asyncio.sleep(1)
|
||||
await cli.disconnect()
|
||||
return ok
|
||||
|
||||
|
||||
OK = asyncio.run(main())
|
||||
pathlib.Path(OUT).write_text("\n".join(marks) + "\n", encoding="utf-8")
|
||||
print(f"{len(marks)} lines")
|
||||
sys.exit(0 if OK else 1)
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# run.sh LABEL: build the automation trace config as at<LABEL> from TREE, flash it over PORT, read the device address
|
||||
# from the serial log, record the marker trace with drive.py and keep the part from "DRIVER press Run All" on in
|
||||
# out-at<LABEL>.trace. Diff the .trace files of dev and the PR; the boot run is partly missed while the API connects.
|
||||
# Run it from a scratch copy of this directory with a secrets.yaml (wifi_ssid, wifi_password).
|
||||
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python),
|
||||
# BOARD (esp32 board, default esp32dev), VARIANT (default esp32), API_KEY (only if you add api encryption)
|
||||
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; P=${PORT:?set PORT}
|
||||
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; NAME=at$L; OUT=$R/out-$NAME; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
cat > "$R/$NAME.yaml" <<YAML
|
||||
esphome:
|
||||
name: $NAME
|
||||
esp32:
|
||||
board: ${BOARD:-esp32dev}
|
||||
variant: ${VARIANT:-esp32}
|
||||
framework:
|
||||
type: esp-idf
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
packages:
|
||||
trace: !include automation-trace-common.yaml
|
||||
YAML
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome run --no-logs "$R/$NAME.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "build or upload failed"; exit 1; }
|
||||
grep -a -E '^(RAM|Flash):' "$OUT/upload.log"
|
||||
$PY "$SC" "$P" 120 "$OUT/serial.txt" --reset > /dev/null 2>&1 &
|
||||
CAP=$!; trap 'kill $CAP 2>/dev/null' EXIT
|
||||
H=; for _ in $(seq 60); do
|
||||
H=$(grep -a -o -E 'IP Address: [0-9.]+' "$OUT/serial.txt" 2>/dev/null | tail -1 | awk '{print $3}'); [ -n "$H" ] && break; sleep 1
|
||||
done
|
||||
[ -n "$H" ] || { echo "FAIL: no IP address in the serial log"; exit 1; }
|
||||
PYTHONPATH=$(dirname "$SC") $PY "$R/drive.py" "$H" "$OUT/markers.txt"; RC=$?
|
||||
sed -n '/^DRIVER press Run All/,$p' "$OUT/markers.txt" > "$R/out-$NAME.trace"
|
||||
CR=$(grep -a -c -E 'Guru|abort\(\)|Backtrace|task_wdt' "$OUT/serial.txt")
|
||||
echo "$NAME: $(wc -l < "$R/out-$NAME.trace") trace lines, crashes $CR"
|
||||
[ "$CR" = 0 ] || { echo "FAIL: a crash or watchdog in the serial log"; exit 1; }
|
||||
[ "$RC" = 0 ] || { echo "FAIL: Run All never reached M done"; exit 1; }
|
||||
grep -q -E "SHOULD_NOT_RUN|NOT_REACHED" "$R/out-$NAME.trace" && { echo "FAIL: a step that must be skipped ran"; exit 1; }
|
||||
echo PASS
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
// Rotating non-connectable advertisements with known service and manufacturer data (random static addresses).
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <cstring>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace af {
|
||||
struct Frame {
|
||||
uint8_t last; // address C4:C1:38:00:00:<last>
|
||||
uint8_t len;
|
||||
uint8_t adv[31];
|
||||
};
|
||||
// AD structures after the flags. 0x16 = service data 16-bit, 0x20 = 32-bit, 0x21 = 128-bit, 0xFF = manufacturer.
|
||||
static const Frame FRAMES[] = {
|
||||
// 21: service 181C AA01 + manufacturer 02E5 BB01
|
||||
{0x21, 12, {5, 0x16, 0x1C, 0x18, 0xAA, 0x01, 5, 0xFF, 0xE5, 0x02, 0xBB, 0x01}},
|
||||
// 22: service 32-bit 12345678 AA02
|
||||
{0x22, 8, {7, 0x20, 0x78, 0x56, 0x34, 0x12, 0xAA, 0x02}},
|
||||
// 23: service 128-bit 12345678-9ABC-DEF0-1122-334455667788 (wire order) AA03
|
||||
{0x23, 20, {19, 0x21, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11,
|
||||
0xF0, 0xDE, 0xBC, 0x9A, 0x78, 0x56, 0x34, 0x12, 0xAA, 0x03}},
|
||||
// 24: manufacturer 181C BB04 (same id as the service UUID of 21, other data kind)
|
||||
{0x24, 6, {5, 0xFF, 0x1C, 0x18, 0xBB, 0x04}},
|
||||
// 25: service 181C AA05 then a second 181C entry AA06 (only the first may fire)
|
||||
{0x25, 12, {5, 0x16, 0x1C, 0x18, 0xAA, 0x05, 5, 0x16, 0x1C, 0x18, 0xAA, 0x06}},
|
||||
// 26: the 128-bit UUID in printed byte order AA07 (must not match)
|
||||
{0x26, 20, {19, 0x21, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0xAA, 0x07}},
|
||||
};
|
||||
static constexpr int COUNT = sizeof(FRAMES) / sizeof(FRAMES[0]);
|
||||
static uint32_t failed = 0;
|
||||
static uint32_t sent = 0;
|
||||
|
||||
inline void send(int i) {
|
||||
const Frame &f = FRAMES[i];
|
||||
uint8_t adv[31];
|
||||
uint8_t n = 0;
|
||||
adv[n++] = 2;
|
||||
adv[n++] = 0x01;
|
||||
adv[n++] = 0x06;
|
||||
memcpy(adv + n, f.adv, f.len);
|
||||
n += f.len;
|
||||
esp_ble_gap_stop_advertising();
|
||||
uint8_t addr[6] = {0xC4, 0xC1, 0x38, 0x00, 0x00, f.last};
|
||||
esp_err_t e1 = esp_ble_gap_set_rand_addr(addr);
|
||||
esp_err_t e2 = esp_ble_gap_config_adv_data_raw(adv, n);
|
||||
esp_ble_adv_params_t p{};
|
||||
p.adv_int_min = 0x20; // 20 ms
|
||||
p.adv_int_max = 0x20;
|
||||
p.adv_type = ADV_TYPE_NONCONN_IND;
|
||||
p.own_addr_type = BLE_ADDR_TYPE_RANDOM;
|
||||
p.channel_map = ADV_CHNL_ALL;
|
||||
p.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY;
|
||||
esp_err_t e3 = esp_ble_gap_start_advertising(&p);
|
||||
sent++;
|
||||
if (e1 != ESP_OK || e2 != ESP_OK || e3 != ESP_OK) {
|
||||
failed++;
|
||||
ESP_LOGW("af", "frame %d failed: %d %d %d", i, e1, e2, e3);
|
||||
}
|
||||
}
|
||||
} // namespace af
|
||||
@@ -0,0 +1,41 @@
|
||||
# Advertiser for the service and manufacturer data trigger test: any ESP32 with BLE. Every 250 ms it switches to
|
||||
# the next of six raw non-connectable advertisements in adv_frames.hpp (addresses C4:C1:38:00:00:21 to 26).
|
||||
esphome:
|
||||
name: advtrig
|
||||
includes:
|
||||
- adv_frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble:
|
||||
|
||||
globals:
|
||||
- id: idx
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 250ms
|
||||
then:
|
||||
- lambda: |-
|
||||
af::send(id(idx));
|
||||
id(idx) = (id(idx) + 1) % af::COUNT;
|
||||
- interval: 30s
|
||||
then:
|
||||
- lambda: ESP_LOGI("af", "sent %u, failed %u", (unsigned) af::sent, (unsigned) af::failed);
|
||||
@@ -0,0 +1,72 @@
|
||||
"""check.py CAPTURE [--zero-mac any|filter]: judge the last count report of a receiver capture.
|
||||
|
||||
Expected per trigger (see triggers.yaml): T1 AA01+AA05, T2 AA01, T3 nothing, T4 AA02, T5 AA03, T6 BB01, T7 BB04,
|
||||
T8 the same count as T2, T9 (mac 00:00:00:00:00:00) AA01+AA05 with --zero-mac any or nothing with filter,
|
||||
T10 AA03, T11 nothing. "other" on a trigger without a MAC filter is another device in range and is only reported.
|
||||
Prints PASS or FAIL with the reason and exits 0 or 1.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
NO_MAC = {1, 7, 10}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("capture")
|
||||
parser.add_argument("--zero-mac", choices=["any", "filter"], default="filter")
|
||||
args = parser.parse_args()
|
||||
zero = args.zero_mac
|
||||
text = pathlib.Path(args.capture).read_text(encoding="utf-8", errors="replace")
|
||||
rows: dict[int, dict[str, int]] = {}
|
||||
for m in re.finditer(r"count T(\d+):([^\n]*)", text):
|
||||
counts = dict(re.findall(r"(AA\d\d|BB\d\d|other)=(\d+)", m.group(2)))
|
||||
rows[int(m.group(1))] = {k: int(v) for k, v in counts.items()}
|
||||
if len(rows) != 11:
|
||||
print(
|
||||
f"FAIL: found {len(rows)} trigger rows, want 11 (no report in the capture?)"
|
||||
)
|
||||
return 1
|
||||
want = {
|
||||
1: {"AA01", "AA05"},
|
||||
2: {"AA01"},
|
||||
3: set(),
|
||||
4: {"AA02"},
|
||||
5: {"AA03"},
|
||||
6: {"BB01"},
|
||||
7: {"BB04"},
|
||||
8: {"AA01"},
|
||||
9: {"AA01", "AA05"} if zero == "any" else set(),
|
||||
10: {"AA03"},
|
||||
11: set(),
|
||||
}
|
||||
# With --zero-mac any, the all-zero MAC means no filter, so T9 also hears other devices
|
||||
no_mac = NO_MAC | {9} if zero == "any" else NO_MAC
|
||||
bad = []
|
||||
for t, keys in want.items():
|
||||
got = {k for k in rows[t] if k != "other"}
|
||||
if got != keys:
|
||||
bad.append(f"T{t} fired on {sorted(got)}, want {sorted(keys)}")
|
||||
if rows[t].get("other") and t not in no_mac:
|
||||
bad.append(
|
||||
f"T{t} has other={rows[t]['other']} although it filters on a MAC"
|
||||
)
|
||||
if keys and min(rows[t].get(k, 0) for k in keys) < 10:
|
||||
bad.append(
|
||||
f"T{t} has fewer than 10 hits on one of {sorted(keys)}: {rows[t]}"
|
||||
)
|
||||
if rows[8] != rows[2]:
|
||||
bad.append(f"T8 {rows[8]} differs from T2 {rows[2]} (same filter)")
|
||||
for t in sorted(rows):
|
||||
print(f"T{t}: {rows[t]}")
|
||||
print("FAIL: " + "; ".join(bad) if bad else "PASS")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# run.sh esp32|bk72xx LABEL: build rx-<kind>.yaml as rx<kind><LABEL> from TREE, flash it, capture 150 s, run check.py.
|
||||
# The advertiser (advtrig.yaml) must already be running on a second ESP32 in range.
|
||||
# env: TREE (esphome checkout to build from), PORT (receiver serial port), SC (path to serial_capture.py),
|
||||
# PY (checkout python), BOARD (bk72xx board, default xh-wb3s), BKPY + BKFLASH (python and flash_bk72xx.py for
|
||||
# bk72xx), ZERO_MAC (any or filter, what mac_address 00:00:00:00:00:00 should do on this build; default filter)
|
||||
R=$(cd "$(dirname "$0")" && pwd); K=${1:?esp32 or bk72xx}; L=${2:?label}; T=${TREE:?set TREE}; P=${PORT:?set PORT}
|
||||
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; NAME=rx$K$L; OUT=$R/out-$NAME; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
sed -e "s/\${name}/$NAME/" -e "s/\${board}/${BOARD:-xh-wb3s}/" "$R/rx-$K.yaml" > "$R/$NAME.yaml"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
if [ "$K" = esp32 ]; then
|
||||
(cd "$T" && $PY -m esphome run --no-logs "$R/$NAME.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "build or upload failed"; exit 1; }
|
||||
$PY "$SC" "$P" 150 "$OUT/serial.txt" --reset > /dev/null 2>&1
|
||||
else
|
||||
(cd "$T" && $PY -m esphome compile "$R/$NAME.yaml" > "$OUT/compile.log" 2>&1) || { echo "build failed"; exit 1; }
|
||||
${BKPY:?set BKPY} "${BKFLASH:?set BKFLASH}" flash write -d "$P" "$R/.esphome/build/$NAME/.pioenvs/$NAME/firmware.uf2" > "$OUT/flash.log" 2>&1 || { echo "flash failed"; exit 1; }
|
||||
$PY "$SC" "$P" 150 "$OUT/serial.txt" > /dev/null 2>&1
|
||||
fi
|
||||
[ -s "$OUT/serial.txt" ] || { echo "serial capture got nothing"; exit 1; }
|
||||
grep -a "adverts" "$OUT/serial.txt" | tail -1
|
||||
$PY "$R/check.py" "$OUT/serial.txt" --zero-mac "${ZERO_MAC:-filter}"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Receiver on a BK72xx board (bk72xx_ble_tracker). substitutions: name, board
|
||||
# Wi-Fi must be configured: without it the BK7238 tracker logs "Scan started" and never receives anything.
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- rx.hpp
|
||||
|
||||
bk72xx:
|
||||
board: ${board}
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
bk72xx_ble:
|
||||
enable_on_boot: true
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 30ms
|
||||
continuous: true
|
||||
<<: !include triggers.yaml
|
||||
on_ble_advertise:
|
||||
- then:
|
||||
- lambda: |-
|
||||
rx::all++;
|
||||
if ((x.address_uint64() >> 8) == 0xC4C1380000ULL) rx::ours++;
|
||||
|
||||
interval:
|
||||
- interval: 20s
|
||||
then:
|
||||
- lambda: |-
|
||||
rx::report("count");
|
||||
ESP_LOGI("rx", "adverts %u, from the advertiser %u", (unsigned) rx::all, (unsigned) rx::ours);
|
||||
@@ -0,0 +1,32 @@
|
||||
# Receiver on a classic ESP32 (esp32_ble_tracker). substitutions: name
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- rx.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 100ms
|
||||
active: false
|
||||
<<: !include triggers.yaml
|
||||
on_ble_advertise:
|
||||
- then:
|
||||
- lambda: |-
|
||||
rx::all++;
|
||||
if ((x.address_uint64() >> 8) == 0xC4C1380000ULL) rx::ours++;
|
||||
|
||||
interval:
|
||||
- interval: 20s
|
||||
then:
|
||||
- lambda: |-
|
||||
rx::report("count");
|
||||
ESP_LOGI("rx", "adverts %u, from the advertiser %u", (unsigned) rx::all, (unsigned) rx::ours);
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
// Per trigger counts of the received payloads: AAnn (service data) and BBnn (manufacturer data) from the advertiser,
|
||||
// anything else counted as "other".
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace rx {
|
||||
static constexpr int TRIGGERS = 12;
|
||||
static uint32_t cnt[TRIGGERS][16];
|
||||
static uint32_t other[TRIGGERS];
|
||||
static uint32_t all, ours;
|
||||
|
||||
inline void hit(int t, const std::vector<uint8_t> &x) {
|
||||
if (x.size() == 2 && (x[0] == 0xAA || x[0] == 0xBB) && x[1] >= 1 && x[1] <= 7) {
|
||||
cnt[t][(x[0] == 0xBB ? 8 : 0) + x[1]]++;
|
||||
} else {
|
||||
other[t]++;
|
||||
}
|
||||
}
|
||||
|
||||
inline void report(const char *label) {
|
||||
for (int t = 1; t < TRIGGERS; t++) {
|
||||
char buf[160];
|
||||
int n = snprintf(buf, sizeof(buf), "T%d:", t);
|
||||
for (int k = 0; k < 16; k++) {
|
||||
if (cnt[t][k])
|
||||
n += snprintf(buf + n, sizeof(buf) - n, " %s%02u=%u", k >= 8 ? "BB" : "AA", (unsigned) (k & 7),
|
||||
(unsigned) cnt[t][k]);
|
||||
}
|
||||
if (other[t])
|
||||
n += snprintf(buf + n, sizeof(buf) - n, " other=%u", (unsigned) other[t]);
|
||||
ESP_LOGI("rx", "%s %s", label, buf);
|
||||
}
|
||||
}
|
||||
} // namespace rx
|
||||
@@ -0,0 +1,44 @@
|
||||
# Shared trigger block, included under the tracker key.
|
||||
on_ble_service_data_advertise:
|
||||
- service_uuid: 181C
|
||||
then:
|
||||
- lambda: rx::hit(1, x);
|
||||
- mac_address: C4:C1:38:00:00:21
|
||||
service_uuid: 181C
|
||||
then:
|
||||
- lambda: rx::hit(2, x);
|
||||
- mac_address: C4:C1:38:00:00:22
|
||||
service_uuid: 181C
|
||||
then:
|
||||
- lambda: rx::hit(3, x);
|
||||
- mac_address: C4:C1:38:00:00:22
|
||||
service_uuid: "12345678"
|
||||
then:
|
||||
- lambda: rx::hit(4, x);
|
||||
- mac_address: C4:C1:38:00:00:23
|
||||
service_uuid: 12345678-9ABC-DEF0-1122-334455667788
|
||||
then:
|
||||
- lambda: rx::hit(5, x);
|
||||
- mac_address: C4:C1:38:00:00:21
|
||||
service_uuid: 181C
|
||||
then:
|
||||
- lambda: rx::hit(8, x);
|
||||
- mac_address: 00:00:00:00:00:00
|
||||
service_uuid: 181C
|
||||
then:
|
||||
- lambda: rx::hit(9, x);
|
||||
- service_uuid: 12345678-9ABC-DEF0-1122-334455667788
|
||||
then:
|
||||
- lambda: rx::hit(10, x);
|
||||
on_ble_manufacturer_data_advertise:
|
||||
- mac_address: C4:C1:38:00:00:21
|
||||
manufacturer_id: 02E5
|
||||
then:
|
||||
- lambda: rx::hit(6, x);
|
||||
- manufacturer_id: 181C
|
||||
then:
|
||||
- lambda: rx::hit(7, x);
|
||||
- mac_address: C4:C1:38:00:00:22
|
||||
manufacturer_id: 02E5
|
||||
then:
|
||||
- lambda: rx::hit(11, x);
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
// Injects ThermoPro advertisements (the records from the thermopro_ble unit tests, #22047) as scan results.
|
||||
// Call inject_all() from the main loop; MACs are A4:C1:38:00:10:01 to :10, one per frame.
|
||||
#include <cstring>
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
namespace tpinject {
|
||||
struct Frame {
|
||||
uint8_t mac_last;
|
||||
const char *name;
|
||||
uint8_t len;
|
||||
uint8_t rec[25];
|
||||
};
|
||||
static constexpr Frame FRAMES[] = {
|
||||
{0x01, "TP357 (2142)", 6, {0xC2, 0xF1, 0x00, 0x1D, 0x02, 0x2C}},
|
||||
{0x02, "TP357S (2142)", 7, {0xC2, 0xEE, 0x00, 0x29, 0x22, 0x0B, 0x01}},
|
||||
{0x03, "TP393 (9376)", 6, {0xC2, 0xF2, 0x00, 0x35, 0x02, 0x2C}},
|
||||
{0x04, "TP960R", 7, {0x00, 0x3A, 0x00, 0x30, 0x08, 0x38, 0x00}},
|
||||
{0x05, "TP970R", 7, {0x00, 0x35, 0x00, 0x46, 0x0B, 0x35, 0x00}},
|
||||
{0x06, "TP972S", 23, {0x00, 0x72, 0x00, 0x6A, 0x0A, 0x33, 0xD3, 0xB8, 0x42, 0x00, 0x40, 0xAE,
|
||||
0x42, 0x66, 0x06, 0xAE, 0x42, 0x6C, 0x54, 0x73, 0x77, 0x44, 0xF8}},
|
||||
{0x07, "TP972S", 23, {0x00, 0x8D, 0x00, 0xA6, 0x0A, 0xCD, 0xEC, 0xFF, 0x42, 0x9A, 0x69, 0x00,
|
||||
0x43, 0x9A, 0x69, 0x00, 0x43, 0x6C, 0x54, 0x73, 0x77, 0x44, 0xF8}},
|
||||
{0x08, "TP972S", 13, {0x00, 0x33, 0x00, 0x48, 0x0A, 0x33, 0x00, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA}},
|
||||
{0x09, "TP960R", 13, {0x00, 0x3A, 0x00, 0x30, 0x08, 0x38, 0x00, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA}},
|
||||
{0x0A, "TP970R", 23, {0x00, 0x72, 0x00, 0x6A, 0x0A, 0x33, 0xD3, 0xB8, 0x42, 0x00, 0x40, 0xAE,
|
||||
0x42, 0x66, 0x06, 0xAE, 0x42, 0x6C, 0x54, 0x73, 0x77, 0x44, 0xF8}},
|
||||
{0x0B, "TP357S (3C62)", 7, {0xC2, 0xFF, 0xFF, 0xFF, 0x22, 0x0B, 0x01}},
|
||||
{0x0C, "TP357 (2142)", 6, {0xC2, 0xF1, 0x00, 0x1D, 0x00, 0x2C}},
|
||||
{0x0D, "TP357 (2142)", 6, {0xC2, 0xF1, 0x00, 0x1D, 0x03, 0x2C}},
|
||||
{0x0E, "TP357 (2142)", 6, {0xC2, 0xCE, 0xFF, 0x1D, 0x02, 0x2C}},
|
||||
{0x0F, "TP972S", 25, {0xC2, 0xC7, 0x01, 0x8D, 0x00, 0xE1, 0x0A, 0x33, 0x13, 0xFA, 0x42, 0x00, 0xC0,
|
||||
0xFA, 0x42, 0x9A, 0xF9, 0xFA, 0x42, 0x6C, 0x54, 0x73, 0x77, 0x44, 0xF8}},
|
||||
{0x10, "TP25", 6, {0xC2, 0xF1, 0x00, 0x1D, 0x02, 0x2C}},
|
||||
};
|
||||
|
||||
inline void inject_all() {
|
||||
for (const auto &f : FRAMES) {
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
const uint8_t mac[6] = {0xA4, 0xC1, 0x38, 0x00, 0x10, f.mac_last};
|
||||
memcpy(r.bda, mac, 6);
|
||||
r.ble_addr_type = 0;
|
||||
r.rssi = -60 - f.mac_last;
|
||||
uint8_t n = 0;
|
||||
size_t nl = strlen(f.name);
|
||||
r.ble_adv[n++] = 2;
|
||||
r.ble_adv[n++] = 0x01;
|
||||
r.ble_adv[n++] = 0x06;
|
||||
r.ble_adv[n++] = nl + 1;
|
||||
r.ble_adv[n++] = 0x09;
|
||||
memcpy(r.ble_adv + n, f.name, nl);
|
||||
n += nl;
|
||||
r.ble_adv[n++] = f.len + 1;
|
||||
r.ble_adv[n++] = 0xFF;
|
||||
memcpy(r.ble_adv + n, f.rec, f.len);
|
||||
n += f.len;
|
||||
r.adv_data_len = n;
|
||||
r.scan_rsp_len = 0;
|
||||
r.search_evt = ESP_GAP_SEARCH_INQ_RES_EVT;
|
||||
esphome::esp32_ble_tracker::global_esp32_ble_tracker->gap_scan_event_handler(r);
|
||||
}
|
||||
}
|
||||
} // namespace tpinject
|
||||
@@ -0,0 +1,72 @@
|
||||
# ble_client (classic ESP32): a sensor and a text sensor with notify, two on_notify each, link cycle every 60 s.
|
||||
# Substitutions: name, server (the server's BLE MAC from its esp32_ble dump).
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
ble_client:
|
||||
- mac_address: ${server}
|
||||
id: srv
|
||||
on_connect:
|
||||
- logger.log: {format: "CLI connected", level: INFO}
|
||||
on_disconnect:
|
||||
- logger.log: {format: "CLI disconnected", level: INFO}
|
||||
|
||||
sensor:
|
||||
- platform: ble_client
|
||||
type: characteristic
|
||||
ble_client_id: srv
|
||||
id: num
|
||||
name: num
|
||||
service_uuid: 5ec10000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec10001-0000-1000-8000-00805f9b34fb
|
||||
notify: true
|
||||
update_interval: never
|
||||
lambda: |-
|
||||
if (x.empty()) return -1.0f;
|
||||
if (x.size() == 1) return x[0] * 10.0f;
|
||||
return (float) ((x[1] << 8) | x[2]) * 10.0f;
|
||||
on_value:
|
||||
- logger.log: {format: "CLI num value %.1f", args: [x], level: INFO}
|
||||
on_notify:
|
||||
- then:
|
||||
- logger.log: {format: "CLI num notify A %.1f", args: [x], level: INFO}
|
||||
- then:
|
||||
- logger.log: {format: "CLI num notify B %.1f", args: [x], level: INFO}
|
||||
|
||||
text_sensor:
|
||||
- platform: ble_client
|
||||
ble_client_id: srv
|
||||
id: txt
|
||||
name: txt
|
||||
service_uuid: 5ec10000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec10002-0000-1000-8000-00805f9b34fb
|
||||
notify: true
|
||||
update_interval: never
|
||||
on_value:
|
||||
- logger.log: {format: "CLI txt value '%s'", args: [x.c_str()], level: INFO}
|
||||
on_notify:
|
||||
- then:
|
||||
- logger.log: {format: "CLI txt notify A '%s' len=%u", args: [x.c_str(), (unsigned) x.size()], level: INFO}
|
||||
- then:
|
||||
- delay: 1s
|
||||
- logger.log: {format: "CLI txt notify B '%s' len=%u", args: [x.c_str(), (unsigned) x.size()], level: INFO}
|
||||
|
||||
interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- logger.log: {format: "CLI cycle link", level: INFO}
|
||||
- ble_client.disconnect: srv
|
||||
- delay: 3s
|
||||
- ble_client.connect: srv
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# run.sh LABEL: build and flash the notify server (SERVER_PORT) and the ble_client (CLIENT_PORT) from TREE, capture
|
||||
# the client 150 s (two link cycles) and count on_notify against on_value per sensor. Fails on a crash, no notify, the
|
||||
# two triggers of a sensor firing a different number of times (the delayed txt one may miss the last notify), or no
|
||||
# `services released` line (#21821).
|
||||
# env: TREE (esphome checkout), SERVER_PORT, CLIENT_PORT, SERVER_BLE_MAC (from the server's esp32_ble dump; flash the
|
||||
# server once and read it), SC (path to serial_capture.py), PY (checkout python)
|
||||
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
|
||||
SP=${SERVER_PORT:?set SERVER_PORT}; CP=${CLIENT_PORT:?set CLIENT_PORT}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}
|
||||
OUT=$R/out-bn$L; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $SP"; pkill -f "[s]erial_capture.py $CP"
|
||||
(cd "$T" && $PY -m esphome -s name "bnsrv$L" run --no-logs "$R/server.yaml" --device "$SP" > "$OUT/server-upload.log" 2>&1) ||
|
||||
{ echo "FAIL: server build or upload"; exit 1; }
|
||||
(cd "$T" && $PY -m esphome -s name "bncli$L" -s server "$MAC" run --no-logs "$R/client.yaml" --device "$CP" \
|
||||
> "$OUT/client-upload.log" 2>&1) || { echo "FAIL: client build or upload"; exit 1; }
|
||||
$PY "$SC" "$CP" 150 "$OUT/client.txt" --reset > /dev/null 2>&1 || { echo "FAIL: client capture got nothing"; exit 1; }
|
||||
c() { grep -a -c "$1" "$OUT/client.txt"; }
|
||||
echo "bn$L: connects $(c 'CLI connected'), num value $(c 'CLI num value') notify A $(c 'num notify A') B $(c 'num notify B')," \
|
||||
"txt value $(c 'CLI txt value') notify A $(c 'txt notify A') B $(c 'txt notify B'), services released $(c 'All clients established, services released')"
|
||||
CRASH=$(sed -n '/CLI connected/,$p' "$OUT/client.txt" | grep -a -c -E 'Guru|abort\(\)|Backtrace|rst:0x|task_wdt')
|
||||
[ "$CRASH" = 0 ] || { echo "FAIL: the client crashed or reset $CRASH times after connecting"; exit 1; }
|
||||
[ "$(c 'num notify A')" -gt 0 ] && [ "$(c 'txt notify A')" -gt 0 ] || { echo "FAIL: no on_notify fired"; exit 1; }
|
||||
[ "$(c 'txt notify B')" -gt 0 ] || { echo "FAIL: the delayed txt on_notify never fired"; exit 1; }
|
||||
[ "$(c 'num notify A')" = "$(c 'num notify B')" ] || { echo "FAIL: the two num on_notify triggers fired a different number of times"; exit 1; }
|
||||
# the 1 s delay can push the last txt notify B past the end of the capture
|
||||
TD=$(($(c 'txt notify A') - $(c 'txt notify B')))
|
||||
[ "$TD" -ge 0 ] && [ "$TD" -le 1 ] || { echo "FAIL: txt notify A and B differ by $TD"; exit 1; }
|
||||
[ "$(c 'All clients established, services released')" -gt 0 ] || { echo "FAIL: services were never released (#21821)"; exit 1; }
|
||||
echo PASS
|
||||
@@ -0,0 +1,64 @@
|
||||
# GATT server (ESP32-S3): a numeric and a text characteristic notify every 2 s; every 5th round is empty
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
esp32_ble:
|
||||
name: ${name}
|
||||
|
||||
esp32_ble_server:
|
||||
on_connect:
|
||||
- logger.log: {format: "SRV connected", level: INFO}
|
||||
on_disconnect:
|
||||
- logger.log: {format: "SRV disconnected", level: INFO}
|
||||
services:
|
||||
- uuid: 5ec10000-0000-1000-8000-00805f9b34fb
|
||||
advertise: true
|
||||
characteristics:
|
||||
- id: numc
|
||||
uuid: 5ec10001-0000-1000-8000-00805f9b34fb
|
||||
read: true
|
||||
notify: true
|
||||
value: [0]
|
||||
- id: txtc
|
||||
uuid: 5ec10002-0000-1000-8000-00805f9b34fb
|
||||
read: true
|
||||
notify: true
|
||||
value: "t0"
|
||||
|
||||
globals:
|
||||
- {id: cnt, type: int, initial_value: "0"}
|
||||
|
||||
interval:
|
||||
- interval: 2s
|
||||
then:
|
||||
- lambda: |-
|
||||
int n = ++id(cnt);
|
||||
if (n % 5 == 0) {
|
||||
id(numc)->set_value(std::vector<uint8_t>{});
|
||||
id(txtc)->set_value(std::vector<uint8_t>{});
|
||||
ESP_LOGI("srv", "SRV sent %d empty", n);
|
||||
} else if (n % 5 == 3) {
|
||||
// uint16 format byte: 0x06, value n * 100 big endian
|
||||
uint16_t v = n * 100;
|
||||
id(numc)->set_value(std::vector<uint8_t>{0x06, (uint8_t) (v >> 8), (uint8_t) v});
|
||||
std::string t = "text-" + std::to_string(n) + "-long-enough-to-matter";
|
||||
id(txtc)->set_value(t);
|
||||
ESP_LOGI("srv", "SRV sent %d num=%u txt=%s", n, v, t.c_str());
|
||||
} else {
|
||||
id(numc)->set_value(std::vector<uint8_t>{(uint8_t) n});
|
||||
std::string t = "t" + std::to_string(n);
|
||||
id(txtc)->set_value(t);
|
||||
ESP_LOGI("srv", "SRV sent %d num=%d txt=%s", n, n & 0xFF, t.c_str());
|
||||
}
|
||||
id(numc)->notify();
|
||||
id(txtc)->notify();
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include <esp_err.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
// Logs the bond list Bluedroid loaded from NVS
|
||||
inline void log_bonds(const char *who) {
|
||||
int n = esp_ble_get_bond_device_num();
|
||||
esp_ble_bond_dev_t list[8];
|
||||
int len = n > 8 ? 8 : n;
|
||||
if (len > 0) {
|
||||
esp_err_t err = esp_ble_get_bond_device_list(&len, list);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW("bonds", "%s: %d bonded, bond list failed: %s", who, n, esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGI("bonds", "%s: %d bonded", who, n);
|
||||
for (int i = 0; i < len; i++) {
|
||||
char mac[esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
const uint8_t *k = list[i].bond_key.penc_key.ltk;
|
||||
ESP_LOGI("bonds", "%s: bond %d %s key_mask=%02x ltk=%02x%02x..%02x%02x", who, i,
|
||||
esphome::format_mac_addr_upper(list[i].bd_addr, mac), list[i].bond_key.key_mask, k[0], k[1], k[14], k[15]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Bonding GATT client (classic ESP32): connects to esp32central-srv.yaml, cycles the link every 40 s.
|
||||
# Substitutions: name, server (the server's BLE MAC from its esp32_ble dump).
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes: [bonds.hpp]
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 2s
|
||||
- lambda: 'log_bonds("client");'
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
esp32_ble:
|
||||
io_capability: display_yes_no
|
||||
auth_req_mode: sc_mitm_bond
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
ble_client:
|
||||
- mac_address: ${server}
|
||||
id: c
|
||||
on_connect:
|
||||
- logger.log: {format: "CLI connected", level: INFO}
|
||||
# Start encryption like a phone does after an insufficient authentication reply
|
||||
- lambda: |-
|
||||
esp_err_t err = esp_ble_set_encryption(id(c).get_remote_bda(), ESP_BLE_SEC_ENCRYPT_MITM);
|
||||
ESP_LOGI("main", "CLI set_encryption %d", (int) err);
|
||||
on_disconnect:
|
||||
- logger.log: {format: "CLI disconnected", level: INFO}
|
||||
on_numeric_comparison_request:
|
||||
- logger.log: {format: "CLI numeric comparison %06u", args: ["(unsigned) passkey"], level: INFO}
|
||||
- ble_client.numeric_comparison_reply:
|
||||
id: c
|
||||
accept: true
|
||||
on_passkey_request:
|
||||
- logger.log: {format: "CLI passkey request", level: INFO}
|
||||
|
||||
text_sensor:
|
||||
- platform: ble_client
|
||||
ble_client_id: c
|
||||
name: plain
|
||||
on_value:
|
||||
- logger.log: {format: "CLI value plain %s", args: ['x.c_str()'], level: INFO}
|
||||
service_uuid: 5ec00000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec00001-0000-1000-8000-00805f9b34fb
|
||||
update_interval: 7s
|
||||
- platform: ble_client
|
||||
ble_client_id: c
|
||||
name: encrypted
|
||||
on_value:
|
||||
- logger.log: {format: "CLI value encrypted %s", args: ['x.c_str()'], level: INFO}
|
||||
service_uuid: 5ec00000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec00002-0000-1000-8000-00805f9b34fb
|
||||
update_interval: 7s
|
||||
- platform: ble_client
|
||||
ble_client_id: c
|
||||
name: mitm
|
||||
on_value:
|
||||
- logger.log: {format: "CLI value mitm %s", args: ['x.c_str()'], level: INFO}
|
||||
service_uuid: 5ec00000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec00003-0000-1000-8000-00805f9b34fb
|
||||
update_interval: 7s
|
||||
- platform: ble_client
|
||||
ble_client_id: c
|
||||
name: notified
|
||||
on_value:
|
||||
- logger.log: {format: "CLI value notified %s", args: ['x.c_str()'], level: INFO}
|
||||
service_uuid: 5ec00000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec00004-0000-1000-8000-00805f9b34fb
|
||||
notify: true
|
||||
|
||||
interval:
|
||||
- interval: 40s
|
||||
startup_delay: 40s
|
||||
then:
|
||||
- ble_client.ble_write:
|
||||
id: c
|
||||
service_uuid: 5ec00000-0000-1000-8000-00805f9b34fb
|
||||
characteristic_uuid: 5ec00003-0000-1000-8000-00805f9b34fb
|
||||
value: [0x01, 0x02, 0x03]
|
||||
- delay: 2s
|
||||
- logger.log: {format: "CLI cycling link", level: INFO}
|
||||
- lambda: 'id(c).set_enabled(false);'
|
||||
- delay: 3s
|
||||
- lambda: 'id(c).set_enabled(true);'
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# esp32central-flow.sh TAG: erase both boards, flash esp32central-srv.yaml (SERVER_PORT, from SERVER_TREE) and
|
||||
# esp32central-cli.yaml (CLIENT_PORT, from TREE), then
|
||||
# A: capture a fresh pairing and three link cycles (170 s)
|
||||
# B: reset both together so each loads its bond from NVS, capture 130 s
|
||||
# and count pairings, bond reuse and dropped bonds per phase. Logs go to out-ec<TAG>/. PASS when phase A read the MITM
|
||||
# characteristic and phase B read it on the stored bond (no numeric comparison, no "Deleting unpaired device").
|
||||
# env: TREE, SERVER_TREE (checkout with #17562, default TREE), SERVER_PORT, CLIENT_PORT, SERVER_BLE_MAC (from the
|
||||
# server's esp32_ble dump), SC (path to serial_capture.py), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); G=${1:?tag}; T=${TREE:?set TREE}; ST=${SERVER_TREE:-$T}; PY=${PY:-$T/venv/bin/python}
|
||||
S=${SERVER_PORT:?set SERVER_PORT}; C=${CLIENT_PORT:?set CLIENT_PORT}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}; SC=${SC:?set SC}
|
||||
O=$D/out-ec$G; rm -rf "$O"; mkdir -p "$O"
|
||||
pkill -f "[s]erial_capture.py $S"; pkill -f "[s]erial_capture.py $C"
|
||||
$PY -m esptool --port "$S" erase_flash > "$O/erase-srv.log" 2>&1 & E=$!
|
||||
$PY -m esptool --port "$C" erase_flash > "$O/erase-cli.log" 2>&1 && wait $E || { echo "FAIL: erase"; exit 1; }
|
||||
(cd "$ST" && $PY -m esphome -s name "ecsrv$G" run --no-logs "$D/esp32central-srv.yaml" --device "$S" > "$O/srv-upload.log" 2>&1) ||
|
||||
{ echo "FAIL: server build or upload"; exit 1; }
|
||||
(cd "$T" && $PY -m esphome -s name "eccli$G" -s server "$MAC" run --no-logs "$D/esp32central-cli.yaml" --device "$C" \
|
||||
> "$O/cli-upload.log" 2>&1) || { echo "FAIL: client build or upload"; exit 1; }
|
||||
phase() { # phase NAME SECONDS
|
||||
$PY "$SC" "$S" "$2" "$O/$1-srv.txt" --ms --reset > /dev/null 2>&1 & local cap=$!
|
||||
sleep 2
|
||||
$PY "$SC" "$C" "$(($2 - 2))" "$O/$1-cli.txt" --ms --reset > /dev/null 2>&1
|
||||
wait $cap
|
||||
[ -s "$O/$1-srv.txt" ] && [ -s "$O/$1-cli.txt" ] || { echo "FAIL: phase $1 capture empty"; exit 1; }
|
||||
local f=$O/$1-cli.txt
|
||||
echo "$G $1: connects $(grep -a -c 'CLI connected' "$f"), numeric comparisons $(grep -a -c 'CLI numeric comparison' "$f")," \
|
||||
"mitm reads $(grep -a -c 'CLI value mitm' "$f"), server writes $(grep -a -c 'SRV mitm write' "$O/$1-srv.txt")," \
|
||||
"unpaired deletes cli $(grep -a -c 'Deleting unpaired device' "$f") srv $(grep -a -c 'Deleting unpaired device' "$O/$1-srv.txt")"
|
||||
grep -a -h '\[bonds' "$O/$1-srv.txt" "$f" | sed 's/.*\]: //'
|
||||
}
|
||||
phase A 170
|
||||
phase B 130
|
||||
grep -a -q 'CLI value mitm' "$O/A-cli.txt" || { echo "FAIL: the client never read the MITM characteristic"; exit 1; }
|
||||
grep -a -q 'CLI value mitm' "$O/B-cli.txt" || { echo "FAIL: phase B never read the MITM characteristic"; exit 1; }
|
||||
NB=$(grep -a -c 'CLI numeric comparison' "$O/B-cli.txt"); UB=$(cat "$O/B-cli.txt" "$O/B-srv.txt" | grep -a -c 'Deleting unpaired device')
|
||||
[ "$NB" = 0 ] && [ "$UB" = 0 ] || { echo "FAIL: phase B paired again ($NB numeric comparisons, $UB unpaired deletes)"; exit 1; }
|
||||
echo PASS
|
||||
@@ -0,0 +1,78 @@
|
||||
# Secure GATT server (ESP32-S3, #17562): plain, encrypted, MITM read+write and an encrypted notify characteristic.
|
||||
# Numeric comparison is accepted automatically. Substitution: name.
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes: [bonds.hpp]
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 2s
|
||||
- lambda: 'log_bonds("server");'
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 8MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: UART0
|
||||
|
||||
esp32_ble:
|
||||
name: ${name}
|
||||
io_capability: display_yes_no
|
||||
auth_req_mode: sc_mitm_bond
|
||||
|
||||
esp32_ble_server:
|
||||
id: srv
|
||||
on_connect:
|
||||
- logger.log: {format: "SRV connected", level: INFO}
|
||||
on_disconnect:
|
||||
- logger.log: {format: "SRV disconnected", level: INFO}
|
||||
on_numeric_comparison_request:
|
||||
- logger.log: {format: "SRV numeric comparison %06u", args: ["(unsigned) passkey"], level: INFO}
|
||||
- ble_server.numeric_comparison_reply:
|
||||
id: srv
|
||||
address: !lambda return address;
|
||||
accept: true
|
||||
on_passkey_request:
|
||||
- logger.log: {format: "SRV passkey request", level: INFO}
|
||||
services:
|
||||
- uuid: 5ec00000-0000-1000-8000-00805f9b34fb
|
||||
advertise: true
|
||||
characteristics:
|
||||
- uuid: 5ec00001-0000-1000-8000-00805f9b34fb
|
||||
read: true
|
||||
value: "plain"
|
||||
- uuid: 5ec00002-0000-1000-8000-00805f9b34fb
|
||||
read: true
|
||||
read_encrypt: true
|
||||
value: "encrypted"
|
||||
- uuid: 5ec00003-0000-1000-8000-00805f9b34fb
|
||||
read: true
|
||||
read_encrypt_mitm: true
|
||||
write: true
|
||||
write_encrypt_mitm: true
|
||||
value: "mitm"
|
||||
on_write:
|
||||
- logger.log: {format: "SRV mitm write %s", args: ['format_hex_pretty(x.data(), x.size()).c_str()'], level: INFO}
|
||||
- id: notify_char
|
||||
uuid: 5ec00004-0000-1000-8000-00805f9b34fb
|
||||
read: false
|
||||
notify: true
|
||||
read_encrypt: true
|
||||
value: "n0"
|
||||
|
||||
globals:
|
||||
- {id: cnt, type: int, initial_value: "0"}
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: 'id(cnt)++;'
|
||||
- ble_server.characteristic.set_value:
|
||||
id: notify_char
|
||||
value: !lambda 'auto s = "n" + std::to_string(id(cnt)); return std::vector<uint8_t>(s.begin(), s.end());'
|
||||
- ble_server.characteristic.notify:
|
||||
id: notify_char
|
||||
@@ -0,0 +1,25 @@
|
||||
# Runs hex_bench() twice after boot. Needs a tree with esp32_ble/bluedroid_hex.h (#22081).
|
||||
# Substitutions: name, board (default esp32dev).
|
||||
substitutions:
|
||||
board: esp32dev
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes: [hexbench.hpp]
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- lambda: "hex_bench();"
|
||||
- delay: 1s
|
||||
- lambda: "hex_bench();"
|
||||
|
||||
esp32:
|
||||
board: ${board}
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
esp32_ble:
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
// Times Bluedroid's sscanf parsing against esphome#22081's replacements on the same inputs.
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <esp_cpu.h>
|
||||
#include "esphome/components/esp32_ble/bluedroid_hex.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Bluedroid string_to_bdaddr (bdaddr.c)
|
||||
static bool __attribute__((noinline)) ref_bdaddr(const char *string, uint8_t *addr) {
|
||||
unsigned int p[6];
|
||||
bool ok = sscanf(string, "%02x:%02x:%02x:%02x:%02x:%02x", &p[0], &p[1], &p[2], &p[3], &p[4], &p[5]) == 6;
|
||||
if (ok)
|
||||
for (int i = 0; i < 6; i++)
|
||||
addr[i] = (uint8_t) p[i];
|
||||
return ok;
|
||||
}
|
||||
// Bluedroid btc_config_get_bin decode loop (btc_config.c), after its length and hex checks
|
||||
static bool __attribute__((noinline)) ref_bin(const char *str, size_t len, uint8_t *value, size_t *length) {
|
||||
if (len % 2 || len / 2 > *length)
|
||||
return false;
|
||||
for (size_t i = 0; i < len; i++)
|
||||
if (!isxdigit((unsigned char) str[i]))
|
||||
return false;
|
||||
for (size_t i = 0; i < len / 2; i++) {
|
||||
unsigned int val;
|
||||
sscanf(str + i * 2, "%02x", &val);
|
||||
value[i] = (uint8_t) val;
|
||||
}
|
||||
*length = len / 2;
|
||||
return true;
|
||||
}
|
||||
static bool __attribute__((noinline)) new_bdaddr(const char *s, uint8_t *a) {
|
||||
return esphome::esp32_ble::parse_bdaddr(s, a);
|
||||
}
|
||||
static bool __attribute__((noinline)) new_bin(const char *s, size_t n, uint8_t *v, size_t *l) {
|
||||
return esphome::esp32_ble::parse_config_bin(s, n, v, l);
|
||||
}
|
||||
|
||||
inline void hex_bench() {
|
||||
static constexpr const char *ADDRS[] = {"12:34:56:78:9A:BC", "aa:bb:cc:dd:ee:ff", "1:2:3:4:5:6", "00:11:22:33:44:5G"};
|
||||
// LTK (16 bytes), IRK (16), a 28 byte bonding blob
|
||||
static constexpr const char *BINS[] = {"00112233445566778899aabbccddeeff", "F0E1D2C3B4A5968778695A4B3C2D1E0F",
|
||||
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c"};
|
||||
const int n = 2000;
|
||||
int mism = 0;
|
||||
for (const char *s : ADDRS) {
|
||||
uint8_t a[6] = {}, b[6] = {};
|
||||
bool ra = ref_bdaddr(s, a), rb = new_bdaddr(s, b);
|
||||
if (ra != rb || (ra && memcmp(a, b, 6) != 0)) {
|
||||
mism++;
|
||||
ESP_LOGW("bench", "bdaddr mismatch on %s: ref %d new %d", s, ra, rb);
|
||||
}
|
||||
uint32_t t0 = esp_cpu_get_cycle_count();
|
||||
for (int i = 0; i < n; i++)
|
||||
ref_bdaddr(s, a);
|
||||
uint32_t t1 = esp_cpu_get_cycle_count();
|
||||
for (int i = 0; i < n; i++)
|
||||
new_bdaddr(s, b);
|
||||
uint32_t t2 = esp_cpu_get_cycle_count();
|
||||
ESP_LOGI("bench", "bdaddr %-17s ok=%d sscanf %5u cycles, new %4u cycles", s, (int) rb, (unsigned) ((t1 - t0) / n),
|
||||
(unsigned) ((t2 - t1) / n));
|
||||
}
|
||||
for (const char *s : BINS) {
|
||||
uint8_t a[32] = {}, b[32] = {};
|
||||
size_t la = 32, lb = 32, len = strlen(s);
|
||||
bool ra = ref_bin(s, len, a, &la), rb = new_bin(s, len, b, &lb);
|
||||
if (ra != rb || la != lb || memcmp(a, b, la) != 0) {
|
||||
mism++;
|
||||
ESP_LOGW("bench", "bin mismatch on %s", s);
|
||||
}
|
||||
uint32_t t0 = esp_cpu_get_cycle_count();
|
||||
for (int i = 0; i < n; i++) {
|
||||
la = 32;
|
||||
ref_bin(s, len, a, &la);
|
||||
}
|
||||
uint32_t t1 = esp_cpu_get_cycle_count();
|
||||
for (int i = 0; i < n; i++) {
|
||||
lb = 32;
|
||||
new_bin(s, len, b, &lb);
|
||||
}
|
||||
uint32_t t2 = esp_cpu_get_cycle_count();
|
||||
ESP_LOGI("bench", "bin %2u bytes sscanf %5u cycles, new %4u cycles", (unsigned) lb, (unsigned) ((t1 - t0) / n),
|
||||
(unsigned) ((t2 - t1) / n));
|
||||
}
|
||||
ESP_LOGI("bench", "DONE mismatches=%d", mism);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# run.sh LABEL: build bench.yaml as hb<LABEL> from TREE, flash it over PORT, capture 25 s and print the cycle counts.
|
||||
# Fails when a parser result differs from Bluedroid's sscanf parsing or the bench never finished.
|
||||
# env: TREE (checkout with #22081), PORT, SC (path to serial_capture.py), PY (checkout python), BOARD (default esp32dev)
|
||||
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; P=${PORT:?set PORT}
|
||||
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; OUT=$R/out-hb$L; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome -s name "hb$L" -s board "${BOARD:-esp32dev}" run --no-logs "$R/bench.yaml" --device "$P" \
|
||||
> "$OUT/upload.log" 2>&1) || { echo "FAIL: build or upload"; exit 1; }
|
||||
$PY "$SC" "$P" 25 "$OUT/serial.txt" --reset > /dev/null 2>&1 || { echo "FAIL: capture got nothing"; exit 1; }
|
||||
grep -a '\[bench' "$OUT/serial.txt" | sed 's/.*\]: //'
|
||||
[ "$(grep -a -c 'DONE mismatches=0' "$OUT/serial.txt")" -ge 2 ] || { echo "FAIL: missing DONE or a mismatch"; exit 1; }
|
||||
echo PASS
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# cases.sh PROXY_HOST LABEL [swap]: half-open API session cases against a proxy that already runs the build under
|
||||
# test (proxy.yaml) and the peripheral (peripheral.yaml). Captures both serial logs with millisecond stamps into
|
||||
# out-ho<LABEL>/ and runs:
|
||||
# C owner client killed (socket closed), new client after 4 s, watched 60 s
|
||||
# H owner frozen with SIGSTOP (socket stays open, no FIN), new client after 4 s, watched 300 s, then it asks the
|
||||
# proxy to connect to the peripheral and releases it
|
||||
# N owner frozen and nobody reconnects for 200 s, then watched 40 s
|
||||
# With swap it instead runs case H three times, the new client asking for the held peripheral right after it
|
||||
# subscribes (ha.py swap) and retrying on the first advert, with logs in out-ho<LABEL>-4/. PASS when every case's
|
||||
# observer saw the peripheral advertise (the held link was released) or every swap round connected.
|
||||
# env: PROXY_PORT, PERI_PORT (serial ports), SC (path to serial_capture.py), PY (python with aioesphomeapi),
|
||||
# API_KEY (the proxy's api key, - for plaintext)
|
||||
D=$(cd "$(dirname "$0")" && pwd); H=${1:?proxy host}; L=${2:?label}; MODE=${3:-cases}; PY=${PY:-python3}
|
||||
SC=${SC:?set SC}
|
||||
PP=${PROXY_PORT:?set PROXY_PORT}; QP=${PERI_PORT:?set PERI_PORT}; KEY=${API_KEY:?set API_KEY}
|
||||
case $MODE in
|
||||
cases) O=$D/out-ho$L; CAP_S=900;;
|
||||
swap) O=$D/out-ho$L-4; CAP_S=150;;
|
||||
*) echo "the third argument is swap or nothing"; exit 2;;
|
||||
esac
|
||||
rm -rf "$O"; mkdir -p "$O"
|
||||
pkill -f "[s]erial_capture.py $PP"; pkill -f "[s]erial_capture.py $QP"
|
||||
$PY "$SC" "$PP" $CAP_S "$O/proxy.txt" --ms > /dev/null 2>&1 & C1=$!
|
||||
$PY "$SC" "$QP" $CAP_S "$O/peri.txt" --ms > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null; [ -n "$OWNER" ] && { kill -9 $OWNER; kill -CONT $OWNER; } 2>/dev/null' EXIT
|
||||
sleep 3
|
||||
FAIL=0
|
||||
own() { # own CASE: start the owner and wait until it holds the link
|
||||
$PY "$D/ha.py" "$H" "$KEY" owner > "$O/owner-$1.log" 2>&1 & OWNER=$!
|
||||
for _ in $(seq 60); do grep -q "OWNER HOLDS LINK" "$O/owner-$1.log" && break; sleep 1; done
|
||||
grep -q "OWNER HOLDS LINK" "$O/owner-$1.log" || { echo "FAIL: case $1: owner never connected"; FAIL=1; kill -9 $OWNER; return 1; }
|
||||
ADDR=$(grep -oE "peripheral 0x[0-9a-f]+" "$O/owner-$1.log" | awk '{print $2}')
|
||||
}
|
||||
release() { kill -9 $OWNER; kill -CONT $OWNER 2>/dev/null; OWNER=; }
|
||||
observe() { # observe CASE ARGS...: run the observer, keeping its exit status in obs-CASE.rc
|
||||
local c=$1; shift
|
||||
$PY "$D/ha.py" "$H" "$KEY" observe "$@" > "$O/obs-$c.log" 2>&1; echo $? > "$O/obs-$c.rc"
|
||||
}
|
||||
if [ "$MODE" = swap ]; then
|
||||
for r in 1 2 3; do
|
||||
own "$r" || continue
|
||||
sleep 5; kill -STOP $OWNER; sleep 4
|
||||
$PY "$D/ha.py" "$H" "$KEY" swap "$ADDR" > "$O/swap-$r.log" 2>&1
|
||||
release
|
||||
echo "round $r: $(grep -o 'CONNECTED on attempt [0-9] at [0-9.]* s' "$O/swap-$r.log" || echo 'never connected')"
|
||||
grep -q 'CONNECTED on attempt' "$O/swap-$r.log" || FAIL=1
|
||||
sleep 15
|
||||
done
|
||||
[ "$FAIL" = 0 ] && echo PASS || { echo "FAIL: a round never connected"; exit 1; }
|
||||
exit 0
|
||||
fi
|
||||
echo "CASE C $(date +%T)"
|
||||
own C && { sleep 5; kill -9 $OWNER; OWNER=; sleep 4; observe C 60; }
|
||||
sleep 10
|
||||
echo "CASE H $(date +%T)"
|
||||
own H && { sleep 5; kill -STOP $OWNER; sleep 4; observe H 300 connect "$ADDR"; release; }
|
||||
sleep 10
|
||||
echo "CASE N $(date +%T)"
|
||||
own N && { sleep 5; kill -STOP $OWNER; sleep 200; observe N 40; release; }
|
||||
for c in C H N; do
|
||||
f=$O/obs-$c.log; [ -s "$f" ] || { echo "$c: no observer run"; FAIL=1; continue; }
|
||||
rc=$(cat "$O/obs-$c.rc"); first=$(grep -c 'FIRST ADVERT' "$f")
|
||||
echo "$c: observer exit $rc, first advert $first, last count '$(grep 'adverts so far' "$f" | tail -1 | sed 's/.*so far //')'," \
|
||||
"$(grep -o 'state connected=[A-Za-z]*.*after [0-9.]* s' "$f" | head -1)"
|
||||
[ "$rc" = 0 ] && [ "$first" -gt 0 ] || FAIL=1
|
||||
done
|
||||
R=$(grep -a -c -E 'Guru|abort\(\)|Backtrace|rst:0x' "$O/proxy.txt")
|
||||
echo "proxy: replaces warnings $(grep -a -c replaces "$O/proxy.txt"), resets $R"
|
||||
[ "$R" = 0 ] || FAIL=1
|
||||
[ "$FAIL" = 0 ] && echo PASS || { echo "FAIL: an observer failed or never saw the peripheral advertise, or the proxy reset"; exit 1; }
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Play Home Assistant against a bluetooth_proxy.
|
||||
|
||||
usage: ha.py HOST KEY owner connect to the test peripheral through the proxy, then idle forever
|
||||
ha.py HOST KEY swap ADDR connect at once after subscribing, retry on the first advert, release
|
||||
ha.py HOST KEY observe SECONDS [connect ADDR]
|
||||
watch the peripheral's adverts and the proxy's slot report; optionally
|
||||
ask the proxy to connect to ADDR at the end and then release it
|
||||
KEY of - is a plaintext API. The peripheral is found by its manufacturer data (FF FF 21 56, see peripheral.yaml).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
HOST, KEY, ROLE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
MARK = b"\xff\xff\x21\x56"
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
print(f"{time.time():.3f} {ROLE}", *a, flush=True)
|
||||
|
||||
|
||||
async def find_peripheral(c: APIClient) -> tuple[int, int]:
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_adv(resp) -> None:
|
||||
for adv in resp.advertisements:
|
||||
if MARK in adv.data and not fut.done():
|
||||
fut.set_result((adv.address, adv.address_type))
|
||||
|
||||
# Stay subscribed like Home Assistant does: with no subscriber the proxy releases every link
|
||||
c.subscribe_bluetooth_le_raw_advertisements(on_adv)
|
||||
return await asyncio.wait_for(fut, 60)
|
||||
|
||||
|
||||
async def owner(c: APIClient, flags: int) -> None:
|
||||
addr, atype = await find_peripheral(c)
|
||||
log(f"peripheral {addr:#x} seen, connecting")
|
||||
if not await connect_once(c, addr, flags, "owner", time.time(), atype):
|
||||
sys.exit("owner could not connect")
|
||||
log("OWNER HOLDS LINK")
|
||||
await asyncio.sleep(10**6)
|
||||
|
||||
|
||||
async def connect_once(
|
||||
c: APIClient, addr: int, flags: int, label: str, t0: float, atype: int = 0
|
||||
) -> bool:
|
||||
st = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_state(connected: bool, mtu: int, err: int) -> None:
|
||||
log(
|
||||
f"{label} state connected={connected} mtu={mtu} err={err} at {time.time() - t0:.2f} s"
|
||||
)
|
||||
if not st.done():
|
||||
st.set_result(connected)
|
||||
|
||||
log(f"{label} connect at {time.time() - t0:.2f} s")
|
||||
try:
|
||||
await c.bluetooth_device_connect(
|
||||
addr, on_state, timeout=30, feature_flags=flags, address_type=atype
|
||||
)
|
||||
return await asyncio.wait_for(st, 30)
|
||||
except (TimeoutError, APIConnectionError) as e:
|
||||
# TimeoutAPIError and a dropped API link are both APIConnectionError; anything else is a rig bug
|
||||
log(f"{label} error {e!r} at {time.time() - t0:.2f} s")
|
||||
return False
|
||||
|
||||
|
||||
async def swap(c: APIClient, flags: int, addr: int) -> None:
|
||||
t0 = time.time()
|
||||
adv = asyncio.Event()
|
||||
|
||||
def on_adv(resp) -> None:
|
||||
for a in resp.advertisements:
|
||||
if MARK in a.data and not adv.is_set():
|
||||
log(f"FIRST ADVERT after {time.time() - t0:.2f} s")
|
||||
adv.set()
|
||||
|
||||
c.subscribe_bluetooth_le_raw_advertisements(on_adv)
|
||||
for attempt in (1, 2):
|
||||
if attempt == 2:
|
||||
await asyncio.wait_for(adv.wait(), 60)
|
||||
if await connect_once(c, addr, flags, f"attempt {attempt}", t0):
|
||||
log(f"CONNECTED on attempt {attempt} at {time.time() - t0:.2f} s")
|
||||
break
|
||||
else:
|
||||
log("both attempts failed, nothing to release")
|
||||
return
|
||||
await c.bluetooth_device_disconnect(addr)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
async def observe(
|
||||
c: APIClient, flags: int, secs: float, connect_addr: int | None
|
||||
) -> None:
|
||||
seen = {"n": 0, "first": None}
|
||||
|
||||
def on_adv(resp) -> None:
|
||||
for adv in resp.advertisements:
|
||||
if MARK in adv.data:
|
||||
seen["n"] += 1
|
||||
if seen["first"] is None:
|
||||
seen["first"] = time.time()
|
||||
log("FIRST ADVERT from peripheral")
|
||||
|
||||
c.subscribe_bluetooth_le_raw_advertisements(on_adv)
|
||||
end = time.time() + secs
|
||||
while time.time() < end:
|
||||
await asyncio.sleep(10)
|
||||
log(f"adverts so far {seen['n']}")
|
||||
if connect_addr is None:
|
||||
return
|
||||
log(f"asking proxy to connect {connect_addr:#x}")
|
||||
ok = await connect_once(c, connect_addr, flags, "observer", time.time())
|
||||
await c.bluetooth_device_disconnect(connect_addr)
|
||||
log("released")
|
||||
n0 = seen["n"]
|
||||
await asyncio.sleep(20)
|
||||
log(f"adverts in 20 s after release {seen['n'] - n0}")
|
||||
if not ok:
|
||||
sys.exit("observer connect failed")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(
|
||||
HOST,
|
||||
6053,
|
||||
None,
|
||||
noise_psk=None if KEY == "-" else KEY,
|
||||
client_info="Home Assistant test",
|
||||
)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
flags = info.bluetooth_proxy_feature_flags_compat(c.api_version)
|
||||
log("api connected")
|
||||
c.subscribe_bluetooth_connections_free(
|
||||
lambda free, limit, alloc: log(
|
||||
f"slots free={free}/{limit} allocated={[hex(a) for a in alloc]}"
|
||||
)
|
||||
)
|
||||
if ROLE == "owner":
|
||||
await owner(c, flags)
|
||||
elif ROLE == "swap":
|
||||
await swap(c, flags, int(sys.argv[4], 16))
|
||||
else:
|
||||
addr = (
|
||||
int(sys.argv[6], 16)
|
||||
if len(sys.argv) > 6 and sys.argv[5] == "connect"
|
||||
else None
|
||||
)
|
||||
await observe(c, flags, float(sys.argv[4]), addr)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
# Single client BLE peripheral: advertises only while nobody is connected.
|
||||
esphome:
|
||||
name: btperi
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 8MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
baud_rate: 115200
|
||||
hardware_uart: UART0
|
||||
|
||||
esp32_ble:
|
||||
name: btperi
|
||||
|
||||
esp32_ble_server:
|
||||
manufacturer_data: [0xFF, 0xFF, 0x21, 0x56]
|
||||
on_connect:
|
||||
- logger.log: {format: "PERI connected", level: INFO}
|
||||
on_disconnect:
|
||||
- logger.log: {format: "PERI disconnected", level: INFO}
|
||||
services:
|
||||
- uuid: 2a7b0000-0000-1000-8000-00805f9b34fb
|
||||
advertise: true
|
||||
characteristics:
|
||||
- uuid: 2a7b0001-0000-1000-8000-00805f9b34fb
|
||||
read: true
|
||||
notify: true
|
||||
value: "hello"
|
||||
|
||||
interval:
|
||||
- interval: 30s
|
||||
then:
|
||||
- logger.log: {format: "PERI alive", level: INFO}
|
||||
@@ -0,0 +1,38 @@
|
||||
# bluetooth_proxy under test: VERBOSE with the busy tags at DEBUG, logs on UART0.
|
||||
substitutions:
|
||||
name: btprox
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble_tracker: DEBUG
|
||||
esp32_ble: DEBUG
|
||||
api.service: DEBUG
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
wifi: DEBUG
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: true
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
@@ -0,0 +1,194 @@
|
||||
#pragma once
|
||||
// BMP390 bench helpers: register dump, INT data-ready edge counter, start write fault injection (software NACK or
|
||||
// SDA held through the GPIO matrix) and a bus probe. ESP32-S3, ESP-IDF.
|
||||
#include <cmath>
|
||||
#include "esphome/components/i2c/i2c_bus.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/i2c_master.h"
|
||||
#include "esp_rom_gpio.h"
|
||||
#include "soc/gpio_sig_map.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "soc/gpio_reg.h"
|
||||
#include "soc/soc.h"
|
||||
#include "soc/io_mux_reg.h"
|
||||
#include "soc/gpio_periph.h"
|
||||
|
||||
namespace bmpt {
|
||||
static const char *const TAG = "bmpt";
|
||||
|
||||
inline volatile uint32_t g_int_edges = 0;
|
||||
inline void int_isr(void *) { g_int_edges = g_int_edges + 1; }
|
||||
inline void int_counter_begin(int pin) {
|
||||
gpio_config_t c{};
|
||||
c.pin_bit_mask = 1ULL << pin;
|
||||
c.mode = GPIO_MODE_INPUT;
|
||||
c.pull_down_en = GPIO_PULLDOWN_ENABLE;
|
||||
c.intr_type = GPIO_INTR_POSEDGE;
|
||||
gpio_config(&c);
|
||||
esp_err_t e = gpio_install_isr_service(0);
|
||||
if (e != ESP_OK && e != ESP_ERR_INVALID_STATE)
|
||||
ESP_LOGE(TAG, "isr service %d", e);
|
||||
gpio_isr_handler_add((gpio_num_t) pin, int_isr, nullptr);
|
||||
}
|
||||
|
||||
inline int g_sda = -1;
|
||||
inline int g_port = 0;
|
||||
inline bool g_held = false;
|
||||
inline int g_hold_level = 0; // 0: SDA shorted low, 1: SDA disconnected from the controller (open line, pulled high)
|
||||
inline bool g_bus_reset_on_probe = false;
|
||||
inline uint32_t g_out_sel = 0;
|
||||
inline bool g_saved = false;
|
||||
inline bool g_log_pad_once = true;
|
||||
inline uint32_t out_sel_reg() { return REG_READ(GPIO_FUNC0_OUT_SEL_CFG_REG + 4 * g_sda); }
|
||||
inline uint32_t pin_reg() { return REG_READ(GPIO_PIN0_REG + 4 * g_sda); }
|
||||
inline uint32_t mux_reg() { return REG_READ(GPIO_PIN_MUX_REG[g_sda]); }
|
||||
inline uint32_t en_bit() { return (REG_READ(GPIO_ENABLE_REG) >> g_sda) & 1; }
|
||||
inline uint32_t g_pin = 0, g_mux = 0, g_en = 0;
|
||||
inline void log_pad(const char *when) {
|
||||
ESP_LOGI(TAG, "PAD %s out_sel=0x%08X pin=0x%08X mux=0x%08X en=%u", when, (unsigned) out_sel_reg(),
|
||||
(unsigned) pin_reg(), (unsigned) mux_reg(), (unsigned) en_bit());
|
||||
}
|
||||
inline void sda_hold() {
|
||||
if (!g_saved) {
|
||||
g_out_sel = out_sel_reg(); // routing the i2c driver set up; restored verbatim on release
|
||||
g_pin = pin_reg();
|
||||
g_mux = mux_reg();
|
||||
g_en = en_bit();
|
||||
log_pad("before-hold");
|
||||
g_saved = true;
|
||||
ESP_LOGI(TAG, "SDA out_sel saved 0x%08X (I2CEXT0_SDA_OUT_IDX=%d port=%d)", (unsigned) g_out_sel,
|
||||
I2CEXT0_SDA_OUT_IDX, g_port);
|
||||
}
|
||||
gpio_set_level((gpio_num_t) g_sda, g_hold_level);
|
||||
esp_rom_gpio_connect_out_signal(g_sda, SIG_GPIO_OUT_IDX, false, false);
|
||||
g_held = true;
|
||||
if (g_log_pad_once) {
|
||||
log_pad("held");
|
||||
}
|
||||
}
|
||||
inline void sda_release() {
|
||||
gpio_set_level((gpio_num_t) g_sda, 1);
|
||||
REG_WRITE(GPIO_FUNC0_OUT_SEL_CFG_REG + 4 * g_sda, g_out_sel);
|
||||
REG_WRITE(GPIO_PIN0_REG + 4 * g_sda, g_pin);
|
||||
REG_WRITE(GPIO_PIN_MUX_REG[g_sda], g_mux);
|
||||
if (g_en)
|
||||
REG_WRITE(GPIO_ENABLE_W1TS_REG, 1u << g_sda);
|
||||
else
|
||||
REG_WRITE(GPIO_ENABLE_W1TC_REG, 1u << g_sda);
|
||||
g_held = false;
|
||||
if (g_log_pad_once) {
|
||||
log_pad("released");
|
||||
g_log_pad_once = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps the sensor's bus: injects the start write faults and logs every failed transfer
|
||||
class FaultBus : public esphome::i2c::I2CBus {
|
||||
public:
|
||||
explicit FaultBus(esphome::i2c::I2CBus *real) : real_(real) {}
|
||||
uint32_t hold_until{0}; // millis() deadline of a timed hold
|
||||
bool arm_start{false}; // hold SDA only during the next forced mode start write (PWR_CTRL mode bits 01)
|
||||
// until this deadline every transfer fails on its own: free-bus dummy read, hold, transfer, release
|
||||
uint32_t window_until{0};
|
||||
bool soft_arm{false}; // next start write fails with NACK in software, the wire is not touched
|
||||
uint32_t soft_until{0}; // every start write fails with NACK in software until this deadline
|
||||
uint32_t xfers{0}, fails{0}, start_writes{0}, start_fails{0}, normal_writes{0};
|
||||
esphome::i2c::ErrorCode write_readv(uint8_t address, const uint8_t *w, size_t wn, uint8_t *r, size_t rn) override {
|
||||
// mode bits of a PWR_CTRL write: 1 forced (a start), 3 normal
|
||||
int mode = wn == 2 && w[0] == 0x1B ? (w[1] >> 4) & 3 : -1;
|
||||
bool is_start = mode == 1;
|
||||
bool targeted = is_start && this->arm_start;
|
||||
if (is_start && (this->soft_arm || (int32_t) (this->soft_until - esphome::millis()) > 0)) {
|
||||
this->soft_arm = false;
|
||||
this->start_writes++;
|
||||
this->start_fails++;
|
||||
this->xfers++;
|
||||
this->fails++;
|
||||
ESP_LOGI(TAG, "SOFTFAIL start write reg=0x1B val=0x%02X -> NACK", w[1]);
|
||||
return esphome::i2c::ERROR_NOT_ACKNOWLEDGED;
|
||||
}
|
||||
bool timed = (int32_t) (this->hold_until - esphome::millis()) > 0;
|
||||
bool window = (int32_t) (this->window_until - esphome::millis()) > 0;
|
||||
if (window) {
|
||||
// let the driver run its post-error FSM reset with free lines, so each injected failure starts from a clean bus
|
||||
uint8_t reg = 0x00, v;
|
||||
this->real_->write_readv(address, ®, 1, &v, 1);
|
||||
targeted = true;
|
||||
}
|
||||
if (timed || targeted)
|
||||
sda_hold(); // reasserted per transfer in case the driver re-routes the pin after an error
|
||||
if (is_start)
|
||||
this->start_writes++;
|
||||
if (mode == 3)
|
||||
this->normal_writes++;
|
||||
auto err = this->real_->write_readv(address, w, wn, r, rn);
|
||||
if (targeted) {
|
||||
this->arm_start = false;
|
||||
sda_release();
|
||||
} else if (!timed && g_held) {
|
||||
sda_release();
|
||||
}
|
||||
this->xfers++;
|
||||
if (err != esphome::i2c::ERROR_OK) {
|
||||
this->fails++;
|
||||
if (is_start)
|
||||
this->start_fails++;
|
||||
ESP_LOGI(TAG, "XFERFAIL addr=0x%02X reg=0x%02X wn=%u rn=%u val=0x%02X err=%d start=%d", address,
|
||||
wn > 0 ? w[0] : 0xFF, (unsigned) wn, (unsigned) rn, wn > 1 ? w[1] : 0, (int) err, is_start);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
protected:
|
||||
esphome::i2c::I2CBus *real_;
|
||||
};
|
||||
inline FaultBus *g_fb = nullptr;
|
||||
inline esphome::i2c::I2CBus *g_real = nullptr;
|
||||
|
||||
// Release a timed hold once its deadline passes even when no transfer happens.
|
||||
inline void fault_tick() {
|
||||
if (g_fb != nullptr && g_held && !g_fb->arm_start && (int32_t) (g_fb->hold_until - esphome::millis()) <= 0)
|
||||
sda_release();
|
||||
}
|
||||
|
||||
inline bool rd(uint8_t addr, uint8_t reg, uint8_t *v, size_t n = 1) {
|
||||
return g_real->write_readv(addr, ®, 1, v, n) == esphome::i2c::ERROR_OK;
|
||||
}
|
||||
inline bool wr(uint8_t addr, uint8_t reg, uint8_t v) {
|
||||
uint8_t b[2] = {reg, v};
|
||||
return g_real->write_readv(addr, b, 2, nullptr, 0) == esphome::i2c::ERROR_OK;
|
||||
}
|
||||
|
||||
inline void dump_regs(uint8_t addr) {
|
||||
uint8_t chip = 0, err = 0, ictl = 0, pwr = 0, osr = 0, odr = 0, cfg = 0;
|
||||
bool ok = rd(addr, 0x00, &chip) && rd(addr, 0x02, &err) && rd(addr, 0x19, &ictl) && rd(addr, 0x1B, &pwr) &&
|
||||
rd(addr, 0x1C, &osr) && rd(addr, 0x1D, &odr) && rd(addr, 0x1F, &cfg);
|
||||
ESP_LOGI(TAG, "REGS ok=%d chip=0x%02X err=0x%02X int_ctrl=0x%02X pwr=0x%02X mode=%u osr=0x%02X odr=0x%02X cfg=0x%02X",
|
||||
ok, chip, err, ictl, pwr, (pwr >> 4) & 3, osr, odr, cfg);
|
||||
}
|
||||
|
||||
inline void log_heap() {
|
||||
ESP_LOGI(TAG, "HEAP internal_free=%u internal_total=%u min_free=%u",
|
||||
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
||||
(unsigned) heap_caps_get_total_size(MALLOC_CAP_INTERNAL),
|
||||
(unsigned) heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL));
|
||||
}
|
||||
|
||||
// Probe the chip; on failure log the line levels, reset the IDF bus once and probe again.
|
||||
inline void probe(uint8_t addr, int scl) {
|
||||
uint8_t v = 0;
|
||||
bool ok = rd(addr, 0x00, &v);
|
||||
ESP_LOGI(TAG, "PROBE %s chip=0x%02X held=%d sel=0x%08X sda=%d scl=%d", ok ? "ok" : "err", v, g_held,
|
||||
(unsigned) out_sel_reg(), gpio_get_level((gpio_num_t) g_sda), gpio_get_level((gpio_num_t) scl));
|
||||
if (ok || !g_bus_reset_on_probe)
|
||||
return;
|
||||
i2c_master_bus_handle_t h;
|
||||
esp_err_t e = i2c_master_get_bus_handle((i2c_port_num_t) g_port, &h);
|
||||
if (e == ESP_OK)
|
||||
e = i2c_master_bus_reset(h);
|
||||
ok = rd(addr, 0x00, &v);
|
||||
ESP_LOGI(TAG, "PROBE after bus reset (%d): %s chip=0x%02X", e, ok ? "ok" : "err", v);
|
||||
}
|
||||
} // namespace bmpt
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Write a BMP390 bench config (ESP32-S3, no Wi-Fi) next to this script.
|
||||
|
||||
usage: gen.py NAME [--rate 50Hz] [--iir 128x|off] [--interval 1s] [--fault --kinds NMTWLSHD --probe-delay MS]
|
||||
[--reset-at 40,80] [--open-line] [--bus-reset]
|
||||
Fault kinds, one per event at 30 s and then every 20 s: N next start write NACKs in software, M every start write
|
||||
NACKs for 5 s, T SDA held low for the next start write, W the same for every transfer for 5 s, L a 5 s continuous
|
||||
hold, S a 300 ms hold, H and D harness-only held transfers.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
FAULT = """
|
||||
- interval: 100ms
|
||||
then:
|
||||
- lambda: |-
|
||||
bmpt::fault_tick();
|
||||
static constexpr char KIND[] = "KINDS";
|
||||
static uint8_t i = 0;
|
||||
static uint32_t probe_at = 0;
|
||||
uint32_t now = millis();
|
||||
if (i < sizeof(KIND) - 1 && now >= 30000u + 20000u * i) { // events at 30 s, then every 20 s
|
||||
char k = KIND[i++];
|
||||
ESP_LOGI("bmpt", "FAULT %c at %u failed=%d warn=%d", k, (unsigned) now, id(bmp)->is_failed(),
|
||||
id(bmp)->status_has_warning());
|
||||
// S and L hold SDA at once; fault_tick() releases it at the deadline
|
||||
if (k == 'S') {
|
||||
bmpt::g_fb->hold_until = now + 300;
|
||||
bmpt::sda_hold();
|
||||
}
|
||||
if (k == 'W') bmpt::g_fb->window_until = now + 5000;
|
||||
if (k == 'L' || k == 'D') bmpt::g_fb->hold_until = now + 5000;
|
||||
if (k == 'L')
|
||||
bmpt::sda_hold();
|
||||
if (k == 'N') bmpt::g_fb->soft_arm = true;
|
||||
if (k == 'M') bmpt::g_fb->soft_until = now + 5000;
|
||||
if (k == 'H') {
|
||||
uint8_t reg = 0x00, v;
|
||||
bmpt::g_fb->arm_start = false;
|
||||
bmpt::sda_hold();
|
||||
bool hok = bmpt::g_real->write_readv(ADDR, ®, 1, &v, 1) == esphome::i2c::ERROR_OK;
|
||||
bmpt::sda_release();
|
||||
ESP_LOGI("bmpt", "HARNESS held transfer ok=%d", hok);
|
||||
}
|
||||
if (k == 'D') {
|
||||
uint8_t reg = 0x00, v;
|
||||
for (int n = 0; n < 2; n++)
|
||||
bmpt::g_fb->write_readv(ADDR, ®, 1, &v, 1);
|
||||
}
|
||||
if (k == 'T') bmpt::g_fb->arm_start = true;
|
||||
probe_at = now + PROBEDELAY;
|
||||
}
|
||||
if (probe_at != 0 && now >= probe_at) {
|
||||
probe_at = 0;
|
||||
bmpt::probe(ADDR, SCLPIN);
|
||||
}
|
||||
"""
|
||||
|
||||
RESET = """
|
||||
- interval: 1s
|
||||
then:
|
||||
- lambda: |-
|
||||
static constexpr uint32_t AT[] = {RESETAT};
|
||||
static uint8_t i = 0;
|
||||
if (i < sizeof(AT) / sizeof(AT[0]) && millis() >= AT[i] * 1000) {
|
||||
i++;
|
||||
bool ok = bmpt::wr(ADDR, 0x7E, 0xB6);
|
||||
delay(10);
|
||||
ESP_LOGI("bmpt", "SOFTRESET ok=%d", ok);
|
||||
bmpt::dump_regs(ADDR);
|
||||
ESP_LOGI("bmpt", "DRDY ok=%d", bmpt::wr(ADDR, 0x19, 0x42));
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("name")
|
||||
ap.add_argument("--addr", default="0x77")
|
||||
ap.add_argument("--rate")
|
||||
ap.add_argument("--iir", default="128x")
|
||||
ap.add_argument("--interval", default="1s")
|
||||
ap.add_argument("--posr", default="8x")
|
||||
ap.add_argument("--tosr", default="none")
|
||||
ap.add_argument("--fault", action="store_true")
|
||||
ap.add_argument("--kinds", default="WTSWTSWT")
|
||||
ap.add_argument("--probe-delay", default="7000")
|
||||
ap.add_argument(
|
||||
"--open-line",
|
||||
action="store_true",
|
||||
help="fault routes SDA away (floats high) instead of low",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--bus-reset",
|
||||
action="store_true",
|
||||
help="reset the IDF bus after a failed probe",
|
||||
)
|
||||
ap.add_argument("--sda", default="1")
|
||||
ap.add_argument("--scl", default="2")
|
||||
ap.add_argument("--int-pin", default="42")
|
||||
ap.add_argument(
|
||||
"--no-drdy", action="store_true", help="do not enable data ready on INT"
|
||||
)
|
||||
ap.add_argument(
|
||||
"--reset-at",
|
||||
default="",
|
||||
help="seconds, comma separated: soft reset the chip behind the driver",
|
||||
)
|
||||
a = ap.parse_args()
|
||||
|
||||
rate = f"\n data_rate: {a.rate}" if a.rate else ""
|
||||
# data ready on INT, push-pull, active high; the driver's soft reset in setup clears it
|
||||
drdy = (
|
||||
""
|
||||
if a.no_drdy
|
||||
else f' ESP_LOGI("bmpt", "DRDY ok=%d", bmpt::wr({a.addr}, 0x19, 0x42));\n'
|
||||
)
|
||||
extra = ""
|
||||
if a.fault:
|
||||
extra += (
|
||||
FAULT.replace("KINDS", a.kinds)
|
||||
.replace("PROBEDELAY", a.probe_delay)
|
||||
.replace("SCLPIN", a.scl)
|
||||
)
|
||||
if a.reset_at:
|
||||
extra += RESET.replace("RESETAT", a.reset_at)
|
||||
yaml = f"""esphome:
|
||||
name: {a.name}
|
||||
includes:
|
||||
- bmp390probe.hpp
|
||||
on_boot:
|
||||
- priority: 800
|
||||
then:
|
||||
- lambda: |-
|
||||
bmpt::g_real = id(bus);
|
||||
bmpt::g_sda = {a.sda};
|
||||
bmpt::g_port = id(bus)->get_port();
|
||||
static bmpt::FaultBus fb(id(bus));
|
||||
id(bmp)->set_i2c_bus(&fb);
|
||||
bmpt::g_fb = &fb;
|
||||
bmpt::g_bus_reset_on_probe = {"true" if a.bus_reset else "false"};
|
||||
bmpt::g_hold_level = {1 if a.open_line else 0};
|
||||
ESP_LOGI("bmpt", "PRESETUP");
|
||||
bmpt::dump_regs({a.addr});
|
||||
- priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("bmpt", "POSTSETUP failed=%d", id(bmp)->is_failed());
|
||||
bmpt::dump_regs({a.addr});
|
||||
{drdy} bmpt::int_counter_begin({a.int_pin});
|
||||
bmpt::dump_regs({a.addr});
|
||||
bmpt::log_heap();
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
i2c:
|
||||
id: bus
|
||||
sda: GPIO{a.sda}
|
||||
scl: GPIO{a.scl}
|
||||
scan: true
|
||||
|
||||
sensor:
|
||||
- platform: bmp3xx_i2c
|
||||
id: bmp
|
||||
address: {a.addr}
|
||||
pressure:
|
||||
name: pressure
|
||||
oversampling: {a.posr}
|
||||
on_value:
|
||||
- lambda: 'ESP_LOGI("bmpt", "READ %u %.4f", (unsigned) millis(), x);'
|
||||
temperature:
|
||||
name: temperature
|
||||
oversampling: {a.tosr}
|
||||
iir_filter: "{a.iir}"{rate}
|
||||
update_interval: {a.interval}
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: |-
|
||||
static uint32_t last_edges = 0, last_ms = 0;
|
||||
uint32_t e = bmpt::g_int_edges, now = millis();
|
||||
ESP_LOGI("bmpt", "INT edges=%u in %u ms failed=%d warn=%d xfers=%u fails=%u starts=%u "
|
||||
"start_fails=%u normal_writes=%u",
|
||||
(unsigned) (e - last_edges), (unsigned) (now - last_ms), id(bmp)->is_failed(),
|
||||
id(bmp)->status_has_warning(), bmpt::g_fb->xfers, bmpt::g_fb->fails, bmpt::g_fb->start_writes,
|
||||
bmpt::g_fb->start_fails, bmpt::g_fb->normal_writes);
|
||||
last_edges = e;
|
||||
last_ms = now;
|
||||
if (!bmpt::g_held) bmpt::dump_regs({a.addr});
|
||||
bmpt::log_heap();
|
||||
{extra.replace("ADDR", a.addr)}"""
|
||||
out = HERE / f"{a.name}.yaml"
|
||||
out.write_text(yaml, encoding="utf-8")
|
||||
print(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# run.sh NAME SECONDS [TAG]: build NAME.yaml (written by gen.py) from TREE, flash the ESP32-S3, capture SECONDS from
|
||||
# a fresh boot into out-NAME/TAG.txt and print stats.py's summary.
|
||||
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
|
||||
R=$(cd "$(dirname "$0")" && pwd); N=${1:?name}; S=${2:?seconds}; G=${3:-run1}
|
||||
T=${TREE:?set TREE}; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
|
||||
[ -f "$R/$N.yaml" ] || { echo "no $N.yaml, run gen.py first"; exit 1; }
|
||||
OUT=$R/out-$N; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome run --no-logs "$R/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) ||
|
||||
{ echo "build or upload failed"; tail -5 "$OUT/upload.log"; exit 1; }
|
||||
grep -a -E '^(RAM|Flash):' "$OUT/upload.log"
|
||||
$PY "$SC" "$P" "$S" "$OUT/$G.txt" --reset > /dev/null 2>&1
|
||||
[ -s "$OUT/$G.txt" ] || { echo "serial capture got nothing"; exit 1; }
|
||||
$PY "$R/stats.py" "$OUT/$G.txt"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Pressure statistics from a bmp390 capture.
|
||||
|
||||
usage: stats.py CAPTURE [SKIP_S]
|
||||
Prints the read count and cadence, the noise (standard deviation, of consecutive differences and detrended over a
|
||||
21 sample window), identical consecutive readings, the INT data ready rate per 10 s window and counts of errors,
|
||||
warnings and harness lines. SKIP_S drops the readings before that many seconds after boot (IIR settling).
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lines = pathlib.Path(sys.argv[1]).read_bytes().decode(errors="replace").splitlines()
|
||||
skip = float(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
reads = [
|
||||
(int(m[1]), float(m[2]))
|
||||
for line in lines
|
||||
if (m := re.search(r"READ (\d+) ([\d.]+)", line))
|
||||
]
|
||||
r = [p for t, p in reads if t / 1000 >= skip]
|
||||
if len(r) > 2:
|
||||
d = [b - a for a, b in zip(r, r[1:], strict=False)]
|
||||
same = sum(1 for x in d if x == 0)
|
||||
print(
|
||||
f"reads={len(reads)} used={len(r)} mean={statistics.mean(r):.4f} sd={statistics.pstdev(r) * 100:.2f}Pa "
|
||||
f"sd_diff={statistics.pstdev(d) * 100:.2f}Pa identical_consecutive={same} min={min(r):.4f} max={max(r):.4f}"
|
||||
)
|
||||
ts = [t for t, _ in reads]
|
||||
if len(ts) > 2:
|
||||
gaps = [b - a for a, b in zip(ts, ts[1:], strict=False)]
|
||||
print(f"cadence mean={statistics.mean(gaps):.0f}ms max_gap={max(gaps)}ms")
|
||||
ints = [
|
||||
(int(m[1]), int(m[2]))
|
||||
for line in lines
|
||||
if (m := re.search(r"INT edges=(\d+) in (\d+) ms", line))
|
||||
]
|
||||
# the first window starts at boot, before data ready is on
|
||||
if rates := [e * 1000 / ms for e, ms in ints[1:] if ms > 9000]:
|
||||
print(f"INT rate Hz: {' '.join(f'{x:.2f}' for x in rates)}")
|
||||
for pat in (
|
||||
r"\[E\]",
|
||||
r"\[W\]",
|
||||
"XFERFAIL",
|
||||
"SOFTFAIL",
|
||||
"FAULT",
|
||||
"PROBE",
|
||||
"marked FAILED",
|
||||
"REGS",
|
||||
):
|
||||
print(f"{pat}: {sum(1 for line in lines if re.search(pat, line))}")
|
||||
if len(r) > 30:
|
||||
k = 10
|
||||
res = [
|
||||
r[i] - statistics.mean(r[i - k : i + k + 1]) for i in range(k, len(r) - k)
|
||||
]
|
||||
print(f"detrended sd (21 sample window)={statistics.pstdev(res) * 100:.3f}Pa")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
# Crash buttons for the crash report flow (#21596, #22288). A package: the per-run file supplies the board and name.
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
ota:
|
||||
- platform: esphome
|
||||
logger:
|
||||
level: DEBUG
|
||||
button:
|
||||
- platform: template
|
||||
name: crash abort
|
||||
on_press:
|
||||
- lambda: abort();
|
||||
- platform: template
|
||||
name: crash null store
|
||||
on_press:
|
||||
- lambda: '*(volatile uint32_t *) nullptr = 1;'
|
||||
- platform: template
|
||||
name: crash div zero
|
||||
on_press:
|
||||
- lambda: |-
|
||||
volatile int z = 0;
|
||||
volatile int r = 10 / z;
|
||||
ESP_LOGI("hwt", "r=%d", r);
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Press a crash button, reconnect, fetch the crash report twice; print capabilities around it and the replayed log.
|
||||
|
||||
usage: drive.py HOST BUTTON_OBJECT_ID env: API_KEY (unset for a plaintext API)
|
||||
An earlier report is read first so it cannot pass for this crash. Exits 1 when the device still answers after the
|
||||
press, does not offer a crash report after the crash, or the report has no reason or a PC of 0.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo
|
||||
from aioesphomeapi.model import CrashReason, LogLevel
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
import apiconn # noqa: E402
|
||||
|
||||
HOST, BTN = sys.argv[1], sys.argv[2]
|
||||
T0 = time.monotonic()
|
||||
|
||||
|
||||
def mark(w: str) -> None:
|
||||
print(f"{time.monotonic() - T0:7.2f} {w}", flush=True)
|
||||
|
||||
|
||||
async def connect() -> APIClient:
|
||||
return await apiconn.connect(HOST, tries=90)
|
||||
|
||||
|
||||
async def main() -> str | None:
|
||||
"""Run the flow; returns why it failed, or None."""
|
||||
c = await connect()
|
||||
before = (await c.device_capabilities()).crash_report
|
||||
mark(f"before: crash_report={before}")
|
||||
if before:
|
||||
# reading it drops the record at the next reboot, so only a new crash leaves one
|
||||
old = await c.crash_report()
|
||||
mark(f"read the earlier report first: reason={old.reason}")
|
||||
ents, _ = await c.list_entities_services()
|
||||
key = next((e.key for e in ents if e.object_id == BTN), None)
|
||||
if key is None:
|
||||
await c.disconnect()
|
||||
buttons = [e.object_id for e in ents if isinstance(e, ButtonInfo)]
|
||||
return f"no button {BTN!r}, the device has {buttons}"
|
||||
mark(f"PRESS {BTN}")
|
||||
c.button_command(key)
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 3)
|
||||
except Exception: # noqa: BLE001
|
||||
mark("no answer after the press (expected)")
|
||||
else:
|
||||
await c.disconnect()
|
||||
return (
|
||||
"the device still answers after the press, so the button did not crash it"
|
||||
)
|
||||
try:
|
||||
await c.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
mark("disconnect after the crash failed (expected)")
|
||||
await asyncio.sleep(3)
|
||||
c = await connect()
|
||||
mark("reconnected")
|
||||
offered = (await c.device_capabilities()).crash_report
|
||||
mark(f"after crash: crash_report={offered}")
|
||||
r = await c.crash_report()
|
||||
mark(
|
||||
f"REPORT1 flags={r.flags!r} reason={r.reason} pc={r.pc:#010x} cause={r.cause} "
|
||||
f"fault={r.fault_address:#010x} build_time={r.build_time} crashed_core={r.crashed_core}"
|
||||
)
|
||||
for core in r.cores:
|
||||
mark(f" core {core.core}: " + " ".join(f"{a:#010x}" for a in core.backtrace))
|
||||
r2 = await c.crash_report()
|
||||
mark(f"REPORT2 flags={r2.flags!r} reason={r2.reason} cores={len(r2.cores)}")
|
||||
mark(f"after read: crash_report={(await c.device_capabilities()).crash_report}")
|
||||
lines: list[str] = []
|
||||
c.subscribe_logs(
|
||||
lambda m: lines.append(apiconn.log_text(m)),
|
||||
log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE,
|
||||
dump_config=True,
|
||||
)
|
||||
await asyncio.sleep(4)
|
||||
for line in lines:
|
||||
if re.search(
|
||||
r"crash|Crash|CRASH|PC|BT\d|backtrace|Backtrace|reset reason|Reset", line
|
||||
):
|
||||
mark("LOG " + line[:160])
|
||||
await c.disconnect()
|
||||
c = await connect()
|
||||
mark(f"second client: crash_report={(await c.device_capabilities()).crash_report}")
|
||||
r3 = await c.crash_report()
|
||||
mark(f"REPORT3 flags={r3.flags!r}")
|
||||
await c.disconnect()
|
||||
if not offered:
|
||||
return "no crash report offered after the crash"
|
||||
if r.reason in (None, CrashReason.UNKNOWN) or r.pc == 0:
|
||||
return f"the report has reason {r.reason} and PC {r.pc:#010x}"
|
||||
return None
|
||||
|
||||
|
||||
if err := asyncio.run(main()):
|
||||
print(f"FAIL: {err}")
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Print the crash report capability and how many replayed 'CRASH DETECTED' lines a new log client gets.
|
||||
|
||||
usage: peek.py HOST env: API_KEY (unset for a plaintext API). Press a crash button with api_button.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
from aioesphomeapi.model import LogLevel
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
from apiconn import connect, log_text # noqa: E402
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = await connect(sys.argv[1], tries=60)
|
||||
caps = await c.device_capabilities()
|
||||
lines: list[str] = []
|
||||
c.subscribe_logs(
|
||||
lambda m: lines.append(log_text(m)),
|
||||
log_level=LogLevel.LOG_LEVEL_DEBUG,
|
||||
dump_config=True,
|
||||
)
|
||||
await asyncio.sleep(4)
|
||||
print(
|
||||
"caps",
|
||||
caps.crash_report,
|
||||
"replay lines",
|
||||
sum("CRASH DETECTED" in line for line in lines),
|
||||
)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
includes:
|
||||
- crctest.hpp
|
||||
on_boot:
|
||||
- priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- lambda: |-
|
||||
// unqualified call, as a lambda user would write it; counted in the CRCRESULT totals
|
||||
crctest::run(crc32(0, reinterpret_cast<const uint8_t *>("123456789"), 9));
|
||||
|
||||
interval:
|
||||
- interval: 20s
|
||||
then:
|
||||
- lambda: crctest::run();
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
// crc32 vectors for esphome#22398, expected values from Python zlib.crc32
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
namespace crctest {
|
||||
inline int pass_n = 0, fail_n = 0;
|
||||
inline void check(const char *name, uint32_t got, uint32_t want) {
|
||||
if (got == want)
|
||||
pass_n++;
|
||||
else
|
||||
fail_n++;
|
||||
ESP_LOGI("crctest", "%s %s got=%08X want=%08X", got == want ? "PASS" : "FAIL", name, (unsigned) got, (unsigned) want);
|
||||
}
|
||||
// unqualified: a crc32() of "123456789" computed in the caller's lambda, or -1 to skip that check
|
||||
inline void run(int64_t unqualified = -1) {
|
||||
pass_n = fail_n = 0;
|
||||
const uint8_t *s = reinterpret_cast<const uint8_t *>("123456789");
|
||||
check("check-123456789", esphome::crc32(0, s, 9), 0xCBF43926u);
|
||||
check("empty-seed0", esphome::crc32(0, s, 0), 0u);
|
||||
check("empty-seed12345678", esphome::crc32(0x12345678u, s, 0), 0x12345678u);
|
||||
check("chained-4+5", esphome::crc32(esphome::crc32(0, s, 4), s + 4, 5), 0xCBF43926u);
|
||||
uint8_t all[256];
|
||||
for (int i = 0; i < 256; i++)
|
||||
all[i] = i;
|
||||
check("bytes-0-255", esphome::crc32(0, all, 256), 0x29058C73u);
|
||||
uint8_t ff[32];
|
||||
memset(ff, 0xFF, sizeof(ff));
|
||||
check("32x0xFF", esphome::crc32(0, ff, 32), 0xFF6CAB0Bu);
|
||||
uint8_t *pat = static_cast<uint8_t *>(malloc(4100 + 8192));
|
||||
if (pat == nullptr) {
|
||||
ESP_LOGE("crctest", "malloc failed");
|
||||
fail_n++;
|
||||
ESP_LOGI("crctest", "CRCRESULT pass=%d fail=%d", pass_n, fail_n);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < 4100; i++)
|
||||
pat[i] = (i * 7 + 3) & 0xFF;
|
||||
static const uint32_t EXP_OFFSET[4] = {0xDF4C2BA9u, 0x5D4B54F6u, 0x3B885695u, 0xAE989C6Eu};
|
||||
for (int o = 0; o < 4; o++) {
|
||||
char n[24];
|
||||
snprintf(n, sizeof(n), "unaligned-off%d", o);
|
||||
check(n, esphome::crc32(0, pat + o, 4093), EXP_OFFSET[o]);
|
||||
}
|
||||
uint32_t c = 0;
|
||||
for (int i = 0; i < 300; i++)
|
||||
c = esphome::crc32(c, pat + i, 1);
|
||||
check("chained-1-byte-x300", c, 0xDE0E57CEu);
|
||||
c = 0;
|
||||
for (int i = 0; i < 4093; i += 97)
|
||||
c = esphome::crc32(c, pat + 1 + i, std::min(97, 4093 - i));
|
||||
check("chained-97-byte-unaligned", c, 0x5D4B54F6u);
|
||||
uint8_t *big = pat + 4100;
|
||||
for (int i = 0; i < 8192; i++)
|
||||
big[i] = (i * 13 + 5) & 0xFF;
|
||||
uint32_t best = 0xFFFFFFFF, r = 0;
|
||||
for (int k = 0; k < 5; k++) {
|
||||
const uint32_t t0 = (esphome::micros) ();
|
||||
r = esphome::crc32(0, big, 8192);
|
||||
const uint32_t dt = (esphome::micros) () - t0;
|
||||
if (dt < best)
|
||||
best = dt;
|
||||
}
|
||||
check("8KiB", r, 0xF6950930u);
|
||||
free(pat);
|
||||
ESP_LOGI("crctest", "CRCSPEED 8192 bytes in %u us = %.1f us/KiB", (unsigned) best, best / 8.0f);
|
||||
if (unqualified >= 0)
|
||||
check("unqualified", (uint32_t) unqualified, 0xCBF43926u);
|
||||
ESP_LOGI("crctest", "CRCRESULT pass=%d fail=%d", pass_n, fail_n);
|
||||
}
|
||||
} // namespace crctest
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Run the ds248x fault phases against the DS2482-800 simulator and summarise them.
|
||||
|
||||
usage: SC=path/to/serial_capture.py drive.py TAG SIM_PORT DUT_PORT [PHASES]
|
||||
PHASES is a comma list of c, cd, z, b, k, dead (default c,z,b,k,dead). Holds the simulator port for the whole run
|
||||
(log and commands); the DUT log is captured per phase into TAG-<phase>.log, each phase starting with a DUT reset.
|
||||
Exits 1 when the simulator never reports, the simulator port fails, the DUT capture fails, a phase ends without a
|
||||
fresh simulator report, a sensor got no correct reading after the last fault of the phase had SETTLE seconds to clear
|
||||
(the recovery window), the DUT logged a CRC, held low, busy timeout or no response line in the recovery window, any
|
||||
temperature is wrong, a phase other than dead logged a failed recovery, a failed hub or a component marked failed, a
|
||||
ds248x warning was still set at the end of a phase, a fault phase moved its injection counter (counted when the
|
||||
DUT meets the fault) by other than the number of faults sent (arm_lost counts arms merged into a pending one) or,
|
||||
for a NACK fault, never moved the nacked counter, a write delivered
|
||||
data while a NACK was armed (nack_miss, including a write followed by a repeated start into a read), the DUT sent an address-only write (empty_wr, which would make
|
||||
nacked ambiguous), a write was cut off by a repeated start into another write and never processed
|
||||
(dropped), a fault is still pending at the end of a phase, the simulator counted a config write with a wrong complement (cfg_bad) or an unknown
|
||||
command or argument (bad_cmd), a counter is missing from the simulator CNT line, the simulator aborted a read reply
|
||||
(tx_abrt, not gated in the dead phase), a phase that does not reset the chip (all but b and k) counted a ROM match on
|
||||
the wrong channel (wrong_ch) or a 1-Wire command without the active pull-up bit (ops_no_apu), the simulator never
|
||||
confirmed going dead, or the DUT log shows no sign of the dead hub (no response, warning, failed hub or failed
|
||||
component) in the dead phase. DUT capture stderr goes to TAG-<phase>.err. The whole phase CRC, held low, busy
|
||||
timeout and no response counts are printed for information only: the faults cause them.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
TAG, SIM, DUT = sys.argv[1:4]
|
||||
PHASES = sys.argv[4].split(",") if len(sys.argv) > 4 else ["c", "z", "b", "k", "dead"]
|
||||
CAP = [sys.executable, os.environ["SC"]]
|
||||
# seconds after the last fault action before the recovery window opens
|
||||
SETTLE = 3.0
|
||||
STAMP = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d{3}))?\]")
|
||||
# every sensor dspd.yaml configures, with the temperature the simulator serves
|
||||
EXPECT = {"t0": 21.5, "t1": 33.0, "t2a": -5.25, "t2b": 40.0}
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
COUNTERS = (
|
||||
"wrong_ch",
|
||||
"nacked",
|
||||
"nack_miss",
|
||||
"empty_wr",
|
||||
"dropped",
|
||||
"arm_lost",
|
||||
"drst",
|
||||
"ops_no_apu",
|
||||
"inj_c",
|
||||
"inj_z",
|
||||
"inj_b",
|
||||
"inj_k",
|
||||
"readsp",
|
||||
"cfg_bad",
|
||||
"bad_cmd",
|
||||
"tx_abrt",
|
||||
)
|
||||
|
||||
sim = serial.Serial(SIM, 115200, timeout=0.2)
|
||||
sim_lines: list[tuple[float, str]] = []
|
||||
stop = threading.Event()
|
||||
failed: list[str] = []
|
||||
reader_error: list[BaseException] = []
|
||||
|
||||
|
||||
def sim_reader() -> None:
|
||||
buf = bytearray()
|
||||
while not stop.is_set():
|
||||
try:
|
||||
buf += sim.read(4096)
|
||||
except Exception as err: # noqa: BLE001 - reported by main() and per phase
|
||||
reader_error.append(err)
|
||||
print(f"FAIL: simulator port read failed: {err!r}", flush=True)
|
||||
return
|
||||
*lines, rest = buf.split(b"\n")
|
||||
buf = bytearray(rest)
|
||||
now = time.time()
|
||||
sim_lines.extend(
|
||||
(now, ANSI.sub("", line.decode(errors="replace")).strip()) for line in lines
|
||||
)
|
||||
|
||||
|
||||
def send(c: str) -> None:
|
||||
sim.write(c.encode())
|
||||
sim.flush()
|
||||
|
||||
|
||||
def last_cnt(before: float) -> dict[str, int]:
|
||||
for t, line in reversed(sim_lines):
|
||||
if t <= before and "CNT " in line:
|
||||
body = line.split("CNT ", 1)[1]
|
||||
return {k: int(v) for k, v in re.findall(r"(\w+)=(-?\d+)", body)}
|
||||
return {}
|
||||
|
||||
|
||||
def after(log: str, when: float) -> str:
|
||||
"""Lines of a serial_capture --ms log stamped after the wall clock time `when`."""
|
||||
lt = time.localtime(when)
|
||||
ref = lt.tm_hour * 3600 + lt.tm_min * 60 + lt.tm_sec + when % 1
|
||||
keep = []
|
||||
for line in log.splitlines():
|
||||
if m := STAMP.match(line):
|
||||
sod = int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4] or 0) / 1000
|
||||
if 0 < (sod - ref) % 86400 < 43200: # survives midnight
|
||||
keep.append(line)
|
||||
return "\n".join(keep)
|
||||
|
||||
|
||||
def phase(
|
||||
name: str, seconds: int, actions: list[tuple[float, str]], pre: str = ""
|
||||
) -> None:
|
||||
if pre:
|
||||
n_pre = len(sim_lines)
|
||||
send(pre)
|
||||
time.sleep(0.3)
|
||||
if want := CONFIRMED_BY.get(pre):
|
||||
deadline = time.time() + 3
|
||||
while not any(line.endswith(want) for _, line in sim_lines[n_pre:]):
|
||||
if time.time() > deadline:
|
||||
failed.append(
|
||||
f"{name} (simulator never logged {want} after {pre!r})"
|
||||
)
|
||||
break
|
||||
time.sleep(0.05)
|
||||
out = pathlib.Path(f"{TAG}-{name}.log")
|
||||
out.unlink(missing_ok=True)
|
||||
with pathlib.Path(f"{TAG}-{name}.err").open("wb") as err:
|
||||
p = subprocess.Popen(
|
||||
[*CAP, DUT, str(seconds), str(out), "--reset", "--ms"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=err,
|
||||
)
|
||||
t0 = last_fault = time.time()
|
||||
for at, c in actions:
|
||||
time.sleep(max(0.0, t0 + at - time.time()))
|
||||
send(c)
|
||||
last_fault = time.time()
|
||||
if rc := p.wait():
|
||||
failed.append(f"{name} (DUT capture exited {rc}, see {TAG}-{name}.err)")
|
||||
n0 = len(sim_lines)
|
||||
send("s") # one more simulator report
|
||||
deadline = time.time() + 6
|
||||
while time.time() < deadline and not any(
|
||||
"CNT " in line for _, line in sim_lines[n0:]
|
||||
):
|
||||
time.sleep(0.05)
|
||||
fresh = any("CNT " in line for _, line in sim_lines[n0:])
|
||||
a, b = last_cnt(t0), last_cnt(time.time())
|
||||
if missing := [k for k in (*COUNTERS, "pend") if k not in a or k not in b]:
|
||||
failed.append(f"{name} (missing counters {missing} on the simulator CNT line)")
|
||||
delta = {k: b.get(k, 0) - a.get(k, 0) for k in COUNTERS}
|
||||
log = out.read_text(encoding="utf-8", errors="replace") if out.exists() else ""
|
||||
temps = re.findall(r"'(\w+)': Got Temperature=(-?[\d.]+)", log)
|
||||
per = dict.fromkeys(EXPECT, 0)
|
||||
wrong = []
|
||||
for s, v in temps:
|
||||
per[s] = per.get(s, 0) + 1
|
||||
if abs(float(v) - EXPECT.get(s, 999)) > 0.01:
|
||||
wrong.append(f"{s}={v}")
|
||||
|
||||
def errors(text: str) -> dict[str, int]:
|
||||
return {
|
||||
"crc": len(re.findall(r"[Cc]hecksum|CRC", text)),
|
||||
"held_low": text.count("held low"),
|
||||
"busy_to": text.count("busy timeout"),
|
||||
"no_resp": text.count("did not respond"),
|
||||
}
|
||||
|
||||
counts = errors(log) | {
|
||||
"recov_fail": log.count("recovery failed"),
|
||||
"marked_failed": len(re.findall(r"was marked as failed", log)),
|
||||
"warn_set": len(re.findall(r"ds248x set Warning", log)),
|
||||
"warn_clear": len(re.findall(r"ds248x cleared Warning", log)),
|
||||
"hub_failed_bus": log.count("DS248x hub failed"),
|
||||
}
|
||||
# the recovery window: from SETTLE seconds after the last fault action to the end of the capture
|
||||
tail = after(log, last_fault + SETTLE)
|
||||
late_ok = dict.fromkeys(EXPECT, 0)
|
||||
for s, v in re.findall(r"'(\w+)': Got Temperature=(-?[\d.]+)", tail):
|
||||
if s in EXPECT and abs(float(v) - EXPECT[s]) <= 0.01:
|
||||
late_ok[s] += 1
|
||||
late_err = {k: n for k, n in errors(tail).items() if n}
|
||||
more = "..." if len(wrong) > 6 else ""
|
||||
print(
|
||||
f"{TAG} {name}: readings={per} recovered={late_ok} wrong={wrong[:6]}{more} n_wrong={len(wrong)} {counts} "
|
||||
f"late_errors={late_err} sim={delta}",
|
||||
flush=True,
|
||||
)
|
||||
if silent := [s for s, n in late_ok.items() if not n]:
|
||||
failed.append(
|
||||
f"{name} (no correct reading from {silent} in the recovery window)"
|
||||
)
|
||||
if late_err:
|
||||
failed.append(f"{name} (errors in the recovery window {late_err})")
|
||||
if name not in DUT_SAW and (
|
||||
hard := {
|
||||
k: counts[k]
|
||||
for k in ("recov_fail", "marked_failed", "hub_failed_bus")
|
||||
if counts[k]
|
||||
}
|
||||
):
|
||||
failed.append(f"{name} (failed recovery, hub or component {hard})")
|
||||
warn = re.findall(r"ds248x (set|cleared) Warning", log)
|
||||
if warn and warn[-1] == "set":
|
||||
failed.append(f"{name} (ds248x warning still set at the end of the phase)")
|
||||
if wrong:
|
||||
failed.append(f"{name} ({len(wrong)} wrong temperatures)")
|
||||
if (inj := INJECTED_BY.get(name)) and delta[inj] != (
|
||||
sent := sum(c == inj[-1] for _, c in actions)
|
||||
):
|
||||
failed.append(
|
||||
f"{name} ({inj} moved {delta[inj]} for {sent} faults sent, arm_lost={delta['arm_lost']})"
|
||||
)
|
||||
if name in NACK_PHASES and not delta["nacked"]:
|
||||
failed.append(f"{name} (nacked never moved, the DUT never got the NACK)")
|
||||
if name in DUT_SAW and not any(counts[k] for k in DUT_SAW[name]):
|
||||
failed.append(
|
||||
f"{name} (no {'/'.join(DUT_SAW[name])} in the DUT log, the DUT never saw the fault)"
|
||||
)
|
||||
if delta["nack_miss"]:
|
||||
failed.append(
|
||||
f"{name} ({delta['nack_miss']} writes delivered data while a NACK was armed)"
|
||||
)
|
||||
if delta["empty_wr"]:
|
||||
failed.append(
|
||||
f"{name} ({delta['empty_wr']} address-only writes, nacked cannot tell them from NACKs)"
|
||||
)
|
||||
if delta["dropped"]:
|
||||
failed.append(
|
||||
f"{name} ({delta['dropped']} writes cut off by a repeated start, never processed)"
|
||||
)
|
||||
if malformed := {k: delta[k] for k in ("cfg_bad", "bad_cmd") if delta[k]}:
|
||||
failed.append(f"{name} (malformed writes {malformed})")
|
||||
if name not in RESET_PHASES and (
|
||||
stale := {k: delta[k] for k in ("wrong_ch", "ops_no_apu") if delta[k]}
|
||||
):
|
||||
failed.append(f"{name} (stale channel or lost active pull-up {stale})")
|
||||
if name not in ABORT_OK_PHASES and delta["tx_abrt"]:
|
||||
failed.append(f"{name} (simulator aborted {delta['tx_abrt']} read replies)")
|
||||
if b.get("pend"):
|
||||
failed.append(f"{name} (a fault was still pending at the end of the phase)")
|
||||
if not fresh or not a or reader_error:
|
||||
print(f"{TAG} {name}: no fresh simulator report, sim counters are not valid")
|
||||
failed.append(f"{name} (no simulator report)")
|
||||
|
||||
|
||||
# fault phase -> simulator counter that must move during it
|
||||
INJECTED_BY = {"c": "inj_c", "cd": "inj_c", "z": "inj_z", "b": "inj_b", "k": "inj_k"}
|
||||
# fault phases whose fault is a NACK
|
||||
NACK_PHASES = {"c", "cd", "z", "k"}
|
||||
# fault phases that reset the chip (channel 0, config cleared) under the driver; the 1-Wire commands left in the
|
||||
# interrupted transaction may then run on the wrong channel or without APU, so wrong_ch and ops_no_apu are not gated
|
||||
RESET_PHASES = {"b", "k"}
|
||||
# phases that switch the controller off, which may abort a read reply in flight
|
||||
ABORT_OK_PHASES = {"dead"}
|
||||
# phase -> DUT log counts of which at least one must be non-zero
|
||||
DUT_SAW = {"dead": ("no_resp", "warn_set", "hub_failed_bus", "marked_failed")}
|
||||
# pre command -> simulator log line that confirms it took effect
|
||||
CONFIRMED_BY = {"x": "DEAD"}
|
||||
|
||||
# name: (seconds, actions, pre)
|
||||
RUNS: dict[str, tuple[int, list[tuple[float, str]], str]] = {
|
||||
"c": (90, [(12 + 6 * i, "c") for i in range(12)], ""),
|
||||
"cd": (100, [(10 + 3 * i, "c") for i in range(28)], ""),
|
||||
"z": (50, [(12 + 5 * i, "z") for i in range(6)], ""),
|
||||
"b": (40, [(12, "b")], ""),
|
||||
"k": (40, [(12, "k")], ""),
|
||||
"dead": (45, [(25, "X")], "x"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
threading.Thread(target=sim_reader, daemon=True).start()
|
||||
deadline = time.time() + 15
|
||||
while not any("CNT " in line for _, line in sim_lines):
|
||||
if time.time() > deadline:
|
||||
print(
|
||||
"FAIL: no CNT line from the simulator in 15 s (flashed dssim.yaml? SIM_PORT right?)"
|
||||
)
|
||||
return 1
|
||||
time.sleep(0.2)
|
||||
for name in PHASES:
|
||||
phase(name, *RUNS[name])
|
||||
send("X")
|
||||
stop.set()
|
||||
if failed:
|
||||
print(f"FAIL: {failed}")
|
||||
return 1
|
||||
print("PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
# ESP32-C3 DUT for the DS2482-800 simulator: GPIO4 to GP4 (SDA), GPIO5 to GP5 (SCL) and GND. The four sensors poll
|
||||
# at different intervals so channel switches interleave; equal intervals hide a stale channel cache.
|
||||
esphome:
|
||||
name: dspd
|
||||
build_path: build-dspd
|
||||
esp32:
|
||||
board: esp32-c3-devkitm-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
i2c:
|
||||
sda: GPIO4
|
||||
scl: GPIO5
|
||||
frequency: 50kHz
|
||||
scan: false
|
||||
ds248x:
|
||||
- id: hub
|
||||
type: ds2482-800
|
||||
active_pullup: true
|
||||
one_wire:
|
||||
- platform: ds248x
|
||||
id: ow0
|
||||
channel: 0
|
||||
- platform: ds248x
|
||||
id: ow1
|
||||
channel: 1
|
||||
- platform: ds248x
|
||||
id: ow2
|
||||
channel: 2
|
||||
sensor:
|
||||
- platform: dallas_temp
|
||||
one_wire_id: ow0
|
||||
address: 0xB200000068221028
|
||||
name: t0
|
||||
update_interval: 1s
|
||||
- platform: dallas_temp
|
||||
one_wire_id: ow1
|
||||
address: 0x8500000068221128
|
||||
name: t1
|
||||
update_interval: 1300ms
|
||||
- platform: dallas_temp
|
||||
one_wire_id: ow2
|
||||
address: 0xDC00000068221228
|
||||
name: t2a
|
||||
update_interval: 1700ms
|
||||
- platform: dallas_temp
|
||||
one_wire_id: ow2
|
||||
address: 0xEB00000068221328
|
||||
name: t2b
|
||||
update_interval: 2300ms
|
||||
@@ -0,0 +1,21 @@
|
||||
# Pico 2 W playing a DS2482-800 at 0x18 (GP4 SDA, GP5 SCL) with four DS18B20s; faults are armed over USB serial.
|
||||
esphome:
|
||||
name: dssim
|
||||
build_path: build-dssim
|
||||
includes:
|
||||
- sim.hpp
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: sim::begin();
|
||||
rp2040:
|
||||
board: rpipico2w
|
||||
logger:
|
||||
level: DEBUG
|
||||
interval:
|
||||
- interval: 50ms
|
||||
then:
|
||||
- lambda: sim::poll_serial();
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: sim::report();
|
||||
@@ -0,0 +1,546 @@
|
||||
// DS2482-800 + DS18B20 simulator on an RP2040/RP2350 I2C slave (i2c0, GP4 SDA, GP5 SCL, address 0x18).
|
||||
// Fault injections are armed from the USB serial console, one character per command:
|
||||
// c NACK the write that follows the next channel select (the read pointer restore)
|
||||
// z after the next 0xBE (read scratchpad) byte, NACK the next 9 writes (every read8 fails)
|
||||
// b stuck busy on the next 1-Wire command, and the next device reset reports no RST bit
|
||||
// k stuck busy on the next 1-Wire command, and NACK the config write after the next device reset
|
||||
// x dead (address NACK) X alive again s print counters now
|
||||
// inj_* count a fault when the DUT meets it, not when it is armed; pend=1 while one has not been met yet.
|
||||
// nack_miss counts writes that delivered data while a NACK was armed, including one followed by a repeated
|
||||
// start into a read; that NACK stays pending.
|
||||
// A NACKed write and an address-only write look the same at STOP (no data), so empty_wr counts address-only
|
||||
// writes outside a NACK window; dspd.yaml turns the bus scan off, so it stays 0 unless the DUT sends them.
|
||||
// dropped counts writes thrown away by a repeated start into another write before any read or STOP.
|
||||
// arm_lost counts arms merged into one of the same fault that the DUT had not met yet.
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include "hardware/i2c.h"
|
||||
#include "hardware/irq.h"
|
||||
#include "hardware/gpio.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace sim {
|
||||
|
||||
struct Dev {
|
||||
uint8_t ch;
|
||||
uint8_t rom[8];
|
||||
uint8_t sp[9];
|
||||
};
|
||||
|
||||
static Dev devs[4];
|
||||
static const int NDEV = 4;
|
||||
|
||||
static void make_dev(int i, uint8_t ch, uint8_t serial, float temp) {
|
||||
Dev &d = devs[i];
|
||||
d.ch = ch;
|
||||
uint8_t r[8] = {0x28, serial, 0x22, 0x68, 0x00, 0x00, 0x00, 0};
|
||||
r[7] = esphome::crc8(r, 7);
|
||||
memcpy(d.rom, r, 8);
|
||||
int16_t t = (int16_t) (temp * 16);
|
||||
uint8_t s[9] = {(uint8_t) (t & 0xFF), (uint8_t) (t >> 8), 0x4B, 0x46, 0x7F, 0xFF, 0x0C, 0x10, 0};
|
||||
s[8] = esphome::crc8(s, 8);
|
||||
memcpy(d.sp, s, 9);
|
||||
}
|
||||
|
||||
// --- chip state (touched from the IRQ only, counters read from the main loop) ---
|
||||
enum Rp : uint8_t { RP_STATUS, RP_DATA, RP_CONFIG, RP_CHANNEL };
|
||||
static volatile uint8_t rp = RP_STATUS, status = 0x18, data = 0, config = 0, channel = 0;
|
||||
static const uint8_t CH_READ[8] = {0xB8, 0xB1, 0xAA, 0xA3, 0x9C, 0x95, 0x8E, 0x87};
|
||||
static const uint8_t CH_WRITE[8] = {0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87};
|
||||
|
||||
// 1-Wire state
|
||||
enum Ow : uint8_t { OW_IDLE, OW_ROM, OW_MATCH, OW_FUNC, OW_READSP, OW_WRITESP, OW_SEARCH };
|
||||
static uint8_t ow = OW_IDLE, mcount = 0, mrom[8], spidx = 0, sbit = 0;
|
||||
static uint8_t selected = 0, part = 0; // device bitmasks
|
||||
|
||||
// faults
|
||||
static volatile bool arm_c = false, arm_z = false, arm_b = false, arm_k = false;
|
||||
static volatile bool stuck = false, bad_reset = false, nack_cfg_after_reset = false;
|
||||
static volatile uint8_t nack_left = 0; // writes still to NACK
|
||||
static volatile bool nack_on = false, z_pending = false, dead = false;
|
||||
static volatile char nack_src = 0; // fault whose NACK is pending: 'c', 'z' or 'k'
|
||||
|
||||
// counters
|
||||
struct Cnt {
|
||||
uint32_t txn, drst, chsl, owreset, wbyte, rbyte, trip, match, wrong_ch, nacked, cfg_writes, ops_no_apu, convert,
|
||||
readsp, inject_c, inject_z, inject_b, inject_k;
|
||||
uint32_t cfg_bad, bad_cmd; // config writes with a wrong complement; unknown, short or overlong commands
|
||||
uint32_t tx_abrt; // read replies the controller aborted, so the DUT did not get them
|
||||
uint32_t nack_miss; // writes that ended with data while a NACK was armed, so the NACK never reached the wire
|
||||
uint32_t empty_wr; // address-only writes while no NACK was armed
|
||||
uint32_t dropped; // writes cut off by a repeated start into another write, never processed
|
||||
uint32_t arm_lost; // arms merged into a pending one of the same fault
|
||||
};
|
||||
static volatile Cnt cnt;
|
||||
static volatile uint8_t last_wrong_rom1 = 0, last_wrong_on = 0;
|
||||
|
||||
// trace of the commands after an injection: cmd, arg, channel
|
||||
static volatile uint8_t tr[24][3];
|
||||
static volatile uint8_t trn = 24, trlen = 0;
|
||||
static uint8_t rx[4]; // no command is longer than 2 bytes, so a full rx is an overlong write
|
||||
static uint8_t rxn = 0;
|
||||
static bool had_rd = false;
|
||||
|
||||
static inline i2c_hw_t *hw() { return i2c_get_hw(i2c0); }
|
||||
|
||||
static void ow_cmd_started() {
|
||||
// every 1-Wire command; with active_pullup configured the APU bit must be set
|
||||
if (!(config & 0x01))
|
||||
cnt.ops_no_apu++;
|
||||
if (arm_b) {
|
||||
arm_b = false;
|
||||
stuck = true;
|
||||
bad_reset = true;
|
||||
} else if (arm_k) {
|
||||
arm_k = false;
|
||||
stuck = true;
|
||||
nack_cfg_after_reset = true;
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t dev_mask_on_channel() {
|
||||
uint8_t m = 0;
|
||||
for (int i = 0; i < NDEV; i++)
|
||||
if (devs[i].ch == channel)
|
||||
m |= 1 << i;
|
||||
return m;
|
||||
}
|
||||
|
||||
static void ow_write(uint8_t b) {
|
||||
cnt.wbyte++;
|
||||
switch (ow) {
|
||||
case OW_ROM:
|
||||
if (b == 0x55) {
|
||||
ow = OW_MATCH;
|
||||
mcount = 0;
|
||||
} else if (b == 0xCC) {
|
||||
selected = dev_mask_on_channel();
|
||||
ow = OW_FUNC;
|
||||
} else if (b == 0xF0) {
|
||||
part = dev_mask_on_channel();
|
||||
sbit = 0;
|
||||
ow = OW_SEARCH;
|
||||
} else {
|
||||
ow = OW_IDLE;
|
||||
}
|
||||
break;
|
||||
case OW_MATCH:
|
||||
mrom[mcount++] = b;
|
||||
if (mcount == 8) {
|
||||
cnt.match++;
|
||||
selected = 0;
|
||||
for (int i = 0; i < NDEV; i++) {
|
||||
if (memcmp(devs[i].rom, mrom, 8) == 0) {
|
||||
if (devs[i].ch == channel) {
|
||||
selected |= 1 << i;
|
||||
} else {
|
||||
cnt.wrong_ch++;
|
||||
last_wrong_rom1 = mrom[1];
|
||||
last_wrong_on = channel;
|
||||
}
|
||||
}
|
||||
}
|
||||
ow = OW_FUNC;
|
||||
}
|
||||
break;
|
||||
case OW_FUNC:
|
||||
if (b == 0xBE) {
|
||||
cnt.readsp++;
|
||||
ow = OW_READSP;
|
||||
spidx = 0;
|
||||
if (arm_z) {
|
||||
arm_z = false;
|
||||
if (z_pending || nack_left)
|
||||
cnt.arm_lost++;
|
||||
z_pending = true;
|
||||
}
|
||||
} else if (b == 0x4E) {
|
||||
ow = OW_WRITESP;
|
||||
spidx = 0;
|
||||
} else {
|
||||
if (b == 0x44)
|
||||
cnt.convert++;
|
||||
ow = OW_IDLE;
|
||||
}
|
||||
break;
|
||||
case OW_WRITESP:
|
||||
for (int i = 0; i < NDEV; i++)
|
||||
if (selected & (1 << i)) {
|
||||
devs[i].sp[2 + spidx] = b;
|
||||
devs[i].sp[8] = esphome::crc8(devs[i].sp, 8);
|
||||
}
|
||||
if (++spidx == 3)
|
||||
ow = OW_IDLE;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t ow_read() {
|
||||
cnt.rbyte++;
|
||||
if (ow == OW_READSP && spidx < 9) {
|
||||
uint8_t v = 0xFF;
|
||||
for (int i = 0; i < NDEV; i++)
|
||||
if (selected & (1 << i))
|
||||
v &= devs[i].sp[spidx];
|
||||
spidx++;
|
||||
return v;
|
||||
}
|
||||
return 0xFF;
|
||||
}
|
||||
|
||||
static uint8_t ow_triplet(bool dir) {
|
||||
cnt.trip++;
|
||||
uint8_t st = 0;
|
||||
if (ow != OW_SEARCH || sbit >= 64) {
|
||||
return 0x20 | 0x40 | 0x80;
|
||||
}
|
||||
bool b = true, c = true;
|
||||
for (int i = 0; i < NDEV; i++)
|
||||
if (part & (1 << i)) {
|
||||
bool bit = devs[i].rom[sbit / 8] & (1 << (sbit % 8));
|
||||
b = b && bit;
|
||||
c = c && !bit;
|
||||
}
|
||||
bool d;
|
||||
if (b != c)
|
||||
d = b;
|
||||
else if (!b && !c)
|
||||
d = dir;
|
||||
else
|
||||
d = true;
|
||||
for (int i = 0; i < NDEV; i++)
|
||||
if (part & (1 << i)) {
|
||||
bool bit = devs[i].rom[sbit / 8] & (1 << (sbit % 8));
|
||||
if (bit != d)
|
||||
part &= ~(1 << i);
|
||||
}
|
||||
sbit++;
|
||||
if (b)
|
||||
st |= 0x20;
|
||||
if (c)
|
||||
st |= 0x40;
|
||||
if (d)
|
||||
st |= 0x80;
|
||||
return st;
|
||||
}
|
||||
|
||||
static void set_status_idle(uint8_t extra) { status = (uint8_t) (0x08 | extra | (stuck ? 0x01 : 0)); }
|
||||
|
||||
static void process(const uint8_t *b, uint8_t n) {
|
||||
cnt.txn++;
|
||||
bool no_arg = b[0] == 0xF0 || b[0] == 0xB4 || b[0] == 0x96; // device reset, 1-Wire reset, read byte
|
||||
if (n > 2 || (no_arg && n != 1)) {
|
||||
cnt.bad_cmd++;
|
||||
return;
|
||||
}
|
||||
if (trn < 24 && b[0] != 0xE1) {
|
||||
tr[trn][0] = b[0];
|
||||
tr[trn][1] = n > 1 ? b[1] : 0;
|
||||
tr[trn][2] = channel;
|
||||
trn++;
|
||||
trlen = trn;
|
||||
}
|
||||
uint8_t keep = status & 0xE2; // SBR TSB DIR PPD survive between commands
|
||||
switch (b[0]) {
|
||||
case 0xF0: // device reset
|
||||
cnt.drst++;
|
||||
stuck = false;
|
||||
config = 0;
|
||||
channel = 0;
|
||||
ow = OW_IDLE;
|
||||
rp = RP_STATUS;
|
||||
if (bad_reset) {
|
||||
bad_reset = false;
|
||||
status = 0x08;
|
||||
cnt.inject_b++;
|
||||
} else {
|
||||
status = 0x18;
|
||||
}
|
||||
if (nack_cfg_after_reset) {
|
||||
nack_cfg_after_reset = false;
|
||||
nack_left = 1;
|
||||
nack_src = 'k';
|
||||
}
|
||||
break;
|
||||
case 0xE1: // set read pointer
|
||||
if (n >= 2) {
|
||||
switch (b[1]) {
|
||||
case 0xF0:
|
||||
rp = RP_STATUS;
|
||||
break;
|
||||
case 0xE1:
|
||||
rp = RP_DATA;
|
||||
break;
|
||||
case 0xC3:
|
||||
rp = RP_CONFIG;
|
||||
break;
|
||||
case 0xD2:
|
||||
rp = RP_CHANNEL;
|
||||
break;
|
||||
default:
|
||||
cnt.bad_cmd++;
|
||||
}
|
||||
} else {
|
||||
cnt.bad_cmd++;
|
||||
}
|
||||
break;
|
||||
case 0xD2: // write config
|
||||
if (n >= 2 && ((b[1] >> 4) ^ 0x0F) == (b[1] & 0x0F)) {
|
||||
config = b[1] & 0x0F;
|
||||
cnt.cfg_writes++;
|
||||
status = status & ~0x10;
|
||||
rp = RP_CONFIG;
|
||||
} else {
|
||||
cnt.cfg_bad++;
|
||||
}
|
||||
break;
|
||||
case 0xC3: // channel select
|
||||
if (n >= 2) {
|
||||
uint32_t before = cnt.chsl;
|
||||
for (int i = 0; i < 8; i++)
|
||||
if (CH_WRITE[i] == b[1]) {
|
||||
channel = i;
|
||||
cnt.chsl++;
|
||||
}
|
||||
if (cnt.chsl == before)
|
||||
cnt.bad_cmd++;
|
||||
rp = RP_CHANNEL;
|
||||
if (arm_c) {
|
||||
arm_c = false;
|
||||
if (nack_left)
|
||||
cnt.arm_lost++;
|
||||
nack_left = 1;
|
||||
nack_src = 'c';
|
||||
trn = 0;
|
||||
}
|
||||
} else {
|
||||
cnt.bad_cmd++;
|
||||
}
|
||||
break;
|
||||
case 0xB4: // 1-Wire reset
|
||||
cnt.owreset++;
|
||||
ow_cmd_started();
|
||||
ow = OW_ROM;
|
||||
selected = 0;
|
||||
set_status_idle(dev_mask_on_channel() ? 0x02 : 0x00);
|
||||
rp = RP_STATUS;
|
||||
break;
|
||||
case 0xA5: // write byte
|
||||
if (n >= 2) {
|
||||
ow_cmd_started();
|
||||
ow_write(b[1]);
|
||||
set_status_idle(keep & 0x02);
|
||||
rp = RP_STATUS;
|
||||
} else {
|
||||
cnt.bad_cmd++;
|
||||
}
|
||||
break;
|
||||
case 0x96: // read byte
|
||||
ow_cmd_started();
|
||||
data = ow_read();
|
||||
set_status_idle(keep & 0x02);
|
||||
rp = RP_STATUS;
|
||||
break;
|
||||
case 0x78: // triplet
|
||||
if (n >= 2) {
|
||||
ow_cmd_started();
|
||||
set_status_idle(ow_triplet(b[1] & 0x80));
|
||||
rp = RP_STATUS;
|
||||
} else {
|
||||
cnt.bad_cmd++;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
cnt.bad_cmd++;
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t read_reg() {
|
||||
switch (rp) {
|
||||
case RP_DATA:
|
||||
return data;
|
||||
case RP_CONFIG:
|
||||
return config;
|
||||
case RP_CHANNEL:
|
||||
return CH_READ[channel & 7];
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
static void drain_rx(i2c_hw_t *h) {
|
||||
while (h->rxflr) {
|
||||
uint32_t d = h->data_cmd;
|
||||
if (d & I2C_IC_DATA_CMD_FIRST_DATA_BYTE_BITS) {
|
||||
if (rxn)
|
||||
cnt.dropped++;
|
||||
rxn = 0;
|
||||
}
|
||||
if (rxn < sizeof(rx))
|
||||
rx[rxn++] = d & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
static void irq() {
|
||||
i2c_hw_t *h = hw();
|
||||
uint32_t st = h->intr_stat;
|
||||
if (st & I2C_IC_INTR_STAT_R_RX_FULL_BITS)
|
||||
drain_rx(h);
|
||||
if (st & I2C_IC_INTR_STAT_R_RD_REQ_BITS) {
|
||||
if (rxn) {
|
||||
if (nack_on) // a write before a repeated start got through the NACK; STOP sees only the read
|
||||
cnt.nack_miss++;
|
||||
process(rx, rxn);
|
||||
rxn = 0;
|
||||
}
|
||||
had_rd = true;
|
||||
h->data_cmd = read_reg();
|
||||
(void) h->clr_rd_req;
|
||||
}
|
||||
if (st & I2C_IC_INTR_STAT_R_TX_ABRT_BITS) {
|
||||
(void) h->clr_tx_abrt;
|
||||
cnt.tx_abrt++;
|
||||
}
|
||||
if (st & I2C_IC_INTR_STAT_R_STOP_DET_BITS) {
|
||||
drain_rx(h);
|
||||
(void) h->clr_stop_det;
|
||||
bool was_write = !had_rd;
|
||||
bool got_data = rxn > 0;
|
||||
if (rxn) {
|
||||
process(rx, rxn);
|
||||
rxn = 0;
|
||||
}
|
||||
// a write that got NACKed carries no data; one that delivered data missed the NACK, which stays pending
|
||||
if (nack_on && was_write && got_data) {
|
||||
cnt.nack_miss++;
|
||||
} else if (nack_on && was_write) {
|
||||
if (nack_left)
|
||||
nack_left--;
|
||||
cnt.nacked++;
|
||||
if (nack_src == 'c')
|
||||
cnt.inject_c++;
|
||||
else if (nack_src == 'z')
|
||||
cnt.inject_z++;
|
||||
else if (nack_src == 'k')
|
||||
cnt.inject_k++;
|
||||
nack_src = 0;
|
||||
} else if (was_write && !got_data) {
|
||||
cnt.empty_wr++;
|
||||
}
|
||||
if (z_pending) { // armed by 0xBE: start NACKing after that write
|
||||
z_pending = false;
|
||||
nack_left = 9;
|
||||
nack_src = 'z';
|
||||
}
|
||||
bool want = nack_left > 0;
|
||||
if (want != nack_on) {
|
||||
nack_on = want;
|
||||
h->slv_data_nack_only = want ? 1 : 0;
|
||||
}
|
||||
had_rd = false;
|
||||
}
|
||||
}
|
||||
|
||||
inline void begin() {
|
||||
make_dev(0, 0, 0x10, 21.5f);
|
||||
make_dev(1, 1, 0x11, 33.0f);
|
||||
make_dev(2, 2, 0x12, -5.25f);
|
||||
make_dev(3, 2, 0x13, 40.0f);
|
||||
i2c_init(i2c0, 100000);
|
||||
i2c_set_slave_mode(i2c0, true, 0x18);
|
||||
gpio_set_function(4, GPIO_FUNC_I2C);
|
||||
gpio_set_function(5, GPIO_FUNC_I2C);
|
||||
gpio_pull_up(4);
|
||||
gpio_pull_up(5);
|
||||
i2c_hw_t *h = hw();
|
||||
// STOP only after our address, so a bus recovery STOP from the DUT is not counted as an empty write
|
||||
h->enable = 0;
|
||||
hw_set_bits(&h->con, I2C_IC_CON_STOP_DET_IFADDRESSED_BITS);
|
||||
h->enable = 1;
|
||||
h->intr_mask = I2C_IC_INTR_MASK_M_RX_FULL_BITS | I2C_IC_INTR_MASK_M_RD_REQ_BITS | I2C_IC_INTR_MASK_M_STOP_DET_BITS |
|
||||
I2C_IC_INTR_MASK_M_TX_ABRT_BITS;
|
||||
irq_set_exclusive_handler(I2C0_IRQ, irq);
|
||||
irq_set_enabled(I2C0_IRQ, true);
|
||||
}
|
||||
|
||||
inline void set_dead(bool d) {
|
||||
dead = d;
|
||||
hw()->enable = d ? 0 : 1;
|
||||
}
|
||||
|
||||
inline void report();
|
||||
|
||||
// I2C0_IRQ masked, so its own arm_lost++ and arm clears cannot interleave with this one
|
||||
inline void arm(volatile bool &flag) {
|
||||
irq_set_enabled(I2C0_IRQ, false);
|
||||
if (flag)
|
||||
cnt.arm_lost++;
|
||||
flag = true;
|
||||
irq_set_enabled(I2C0_IRQ, true);
|
||||
}
|
||||
|
||||
inline void poll_serial() {
|
||||
while (Serial.available()) {
|
||||
int c = Serial.read();
|
||||
switch (c) {
|
||||
case 'c':
|
||||
arm(arm_c);
|
||||
ESP_LOGI("sim", "ARM c");
|
||||
break;
|
||||
case 'z':
|
||||
arm(arm_z);
|
||||
ESP_LOGI("sim", "ARM z");
|
||||
break;
|
||||
case 'b':
|
||||
arm(arm_b);
|
||||
ESP_LOGI("sim", "ARM b");
|
||||
break;
|
||||
case 'k':
|
||||
arm(arm_k);
|
||||
ESP_LOGI("sim", "ARM k");
|
||||
break;
|
||||
case 'x':
|
||||
set_dead(true);
|
||||
ESP_LOGI("sim", "DEAD");
|
||||
break;
|
||||
case 'X':
|
||||
set_dead(false);
|
||||
ESP_LOGI("sim", "ALIVE");
|
||||
break;
|
||||
case 's':
|
||||
report();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void report() {
|
||||
bool pend = arm_c || arm_z || arm_b || arm_k || stuck || bad_reset || nack_cfg_after_reset || nack_left || z_pending;
|
||||
if (trlen) {
|
||||
char buf[24 * 9 + 1];
|
||||
int o = 0;
|
||||
for (int i = 0; i < trlen; i++)
|
||||
o += snprintf(buf + o, sizeof(buf) - o, "%02X%02X/%u ", (unsigned) tr[i][0], (unsigned) tr[i][1],
|
||||
(unsigned) tr[i][2]);
|
||||
ESP_LOGI("sim", "TRACE %s", buf);
|
||||
trlen = 0;
|
||||
}
|
||||
ESP_LOGI("sim",
|
||||
"CNT txn=%u drst=%u chsl=%u owreset=%u wbyte=%u rbyte=%u trip=%u match=%u wrong_ch=%u nacked=%u "
|
||||
"nack_miss=%u empty_wr=%u dropped=%u arm_lost=%u cfgw=%u cfg_bad=%u bad_cmd=%u ops_no_apu=%u convert=%u "
|
||||
"readsp=%u "
|
||||
"inj_c=%u inj_z=%u inj_b=%u inj_k=%u tx_abrt=%u ch=%u cfg=%u dead=%d pend=%d",
|
||||
(unsigned) cnt.txn, (unsigned) cnt.drst, (unsigned) cnt.chsl, (unsigned) cnt.owreset, (unsigned) cnt.wbyte,
|
||||
(unsigned) cnt.rbyte, (unsigned) cnt.trip, (unsigned) cnt.match, (unsigned) cnt.wrong_ch,
|
||||
(unsigned) cnt.nacked, (unsigned) cnt.nack_miss, (unsigned) cnt.empty_wr, (unsigned) cnt.dropped,
|
||||
(unsigned) cnt.arm_lost, (unsigned) cnt.cfg_writes, (unsigned) cnt.cfg_bad, (unsigned) cnt.bad_cmd,
|
||||
(unsigned) cnt.ops_no_apu, (unsigned) cnt.convert, (unsigned) cnt.readsp, (unsigned) cnt.inject_c,
|
||||
(unsigned) cnt.inject_z, (unsigned) cnt.inject_b, (unsigned) cnt.inject_k, (unsigned) cnt.tx_abrt,
|
||||
(unsigned) channel, (unsigned) config, (int) dead, (int) pend);
|
||||
}
|
||||
|
||||
} // namespace sim
|
||||
@@ -0,0 +1,121 @@
|
||||
# substitutions: name
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- fake_ezo.hpp
|
||||
on_boot:
|
||||
- priority: 900
|
||||
then:
|
||||
- lambda: id(ph).set_i2c_bus(&fakeezo::bus);
|
||||
- priority: -100
|
||||
then:
|
||||
- delay: 6s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "1 burst of 8 commands");
|
||||
id(ph).get_device_information();
|
||||
id(ph).get_slope();
|
||||
id(ph).get_calibration();
|
||||
id(ph).get_t();
|
||||
id(ph).get_led_state();
|
||||
id(ph).set_led_state(true);
|
||||
id(ph).send_custom("Name,?");
|
||||
id(ph).set_t(21.5);
|
||||
- delay: 15s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "1b burst of 14 built-in commands with an explicit read");
|
||||
id(ph).get_device_information();
|
||||
id(ph).get_slope();
|
||||
id(ph).get_calibration();
|
||||
id(ph).get_t();
|
||||
id(ph).get_led_state();
|
||||
id(ph).set_led_state(false);
|
||||
id(ph).get_state();
|
||||
id(ph).set_t(22.5);
|
||||
id(ph).get_device_information();
|
||||
id(ph).get_slope();
|
||||
id(ph).get_calibration();
|
||||
id(ph).get_t();
|
||||
id(ph).get_led_state();
|
||||
id(ph).set_led_state(true);
|
||||
- delay: 20s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "2 pending");
|
||||
id(ph).send_custom("pend");
|
||||
- delay: 2s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "2 syntax error");
|
||||
id(ph).send_custom("bad");
|
||||
- delay: 2s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "2 no data");
|
||||
id(ph).send_custom("nodata");
|
||||
- delay: 2s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "2 NAK");
|
||||
id(ph).send_custom("nak");
|
||||
- delay: 2s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "2 full reply");
|
||||
id(ph).send_custom("full31");
|
||||
- delay: 3s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "2b three custom commands at once");
|
||||
id(ph).send_custom("Name,?");
|
||||
id(ph).send_custom("pend");
|
||||
id(ph).send_custom("bad");
|
||||
- delay: 6s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "3 calibration");
|
||||
id(ph).set_calibration_point_mid(7.0);
|
||||
id(ph).set_calibration_point_low(4.0);
|
||||
id(ph).set_calibration_point_high(10.0);
|
||||
id(ph).clear_calibration();
|
||||
- delay: 8s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "4a 25 character custom command");
|
||||
id(ph).send_custom("Name,abcdefghijklmnopqrst");
|
||||
- delay: 2s
|
||||
- lambda: |-
|
||||
ESP_LOGI("step", "4b 26 character custom command, sleep, then address change");
|
||||
id(ph).send_custom("Name,abcdefghijklmnopqrstu");
|
||||
id(ph).set_sleep();
|
||||
id(ph).set_address(0x63);
|
||||
id(ph).get_device_information();
|
||||
id(ph).set_address(0x64);
|
||||
id(ph).get_device_information();
|
||||
- delay: 8s
|
||||
- lambda: 'ESP_LOGI("step", "DONE writes %u reads %u", (unsigned) fakeezo::bus.writes_, (unsigned) fakeezo::bus.reads_);'
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
i2c:
|
||||
sda: GPIO21
|
||||
scl: GPIO22
|
||||
scan: false
|
||||
|
||||
sensor:
|
||||
- platform: ezo
|
||||
id: ph
|
||||
name: ph
|
||||
address: 0x61
|
||||
update_interval: 2s
|
||||
on_value:
|
||||
- lambda: 'ESP_LOGI("val", "%.3f", x);'
|
||||
on_led:
|
||||
- lambda: 'ESP_LOGI("cb", "led %d", x);'
|
||||
on_device_information:
|
||||
- lambda: 'ESP_LOGI("cb", "device_information \"%s\"", x.c_str());'
|
||||
on_slope:
|
||||
- lambda: 'ESP_LOGI("cb", "slope \"%s\"", x.c_str());'
|
||||
on_calibration:
|
||||
- lambda: 'ESP_LOGI("cb", "calibration \"%s\"", x.c_str());'
|
||||
on_t:
|
||||
- lambda: 'ESP_LOGI("cb", "t \"%s\"", x.c_str());'
|
||||
on_custom:
|
||||
- lambda: 'ESP_LOGI("cb", "custom \"%s\" len %u", x.c_str(), (unsigned) x.size());'
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
// A fake EZO circuit behind a fake I2C bus: the real ezo component runs unchanged and its bus pointer is swapped for
|
||||
// this one, which logs every write and read with timestamps and answers like an EZO pH circuit.
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include "esphome/components/i2c/i2c_bus.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace fakeezo {
|
||||
static const char *const TAG = "fakeezo";
|
||||
|
||||
class FakeEzoBus : public esphome::i2c::I2CBus {
|
||||
public:
|
||||
char last_[48]{};
|
||||
uint32_t write_ms_{0};
|
||||
uint32_t reads_{0};
|
||||
uint32_t pend_left_{0};
|
||||
uint32_t writes_{0};
|
||||
|
||||
esphome::i2c::ErrorCode write_readv(uint8_t address, const uint8_t *wb, size_t wn, uint8_t *rb, size_t rn) override {
|
||||
uint32_t now = millis();
|
||||
if (wn > 0) {
|
||||
size_t n = wn < sizeof(this->last_) - 1 ? wn : sizeof(this->last_) - 1;
|
||||
memcpy(this->last_, wb, n);
|
||||
this->last_[n] = 0;
|
||||
this->write_ms_ = now;
|
||||
this->writes_++;
|
||||
if (strcmp(this->last_, "pend") == 0)
|
||||
this->pend_left_ = 2;
|
||||
ESP_LOGI(TAG, "W 0x%02X \"%s\"", address, this->last_);
|
||||
}
|
||||
if (rn == 0)
|
||||
return esphome::i2c::ERROR_OK;
|
||||
uint32_t waited = now - this->write_ms_;
|
||||
if (strcmp(this->last_, "nak") == 0) {
|
||||
ESP_LOGI(TAG, "R 0x%02X after %u ms for \"%s\": NAK", address, (unsigned) waited, this->last_);
|
||||
return esphome::i2c::ERROR_NOT_ACKNOWLEDGED;
|
||||
}
|
||||
memset(rb, 0, rn);
|
||||
rb[0] = 1;
|
||||
char *p = reinterpret_cast<char *>(rb + 1);
|
||||
size_t cap = rn - 1;
|
||||
const char *c = this->last_;
|
||||
if (strcmp(c, "R") == 0) {
|
||||
this->reads_++;
|
||||
if (this->reads_ % 3 == 0)
|
||||
snprintf(p, cap, "7.%03u,1.25", (unsigned) (this->reads_ % 1000));
|
||||
else
|
||||
snprintf(p, cap, "7.%03u", (unsigned) (this->reads_ % 1000));
|
||||
} else if (strcmp(c, "i") == 0) {
|
||||
snprintf(p, cap, "?I,pH,2.16");
|
||||
} else if (strcmp(c, "Slope,?") == 0) {
|
||||
snprintf(p, cap, "?Slope,99.7,100.3");
|
||||
} else if (strcmp(c, "Cal,?") == 0) {
|
||||
snprintf(p, cap, "?Cal,2");
|
||||
} else if (strcmp(c, "T,?") == 0) {
|
||||
snprintf(p, cap, "?T,25.00");
|
||||
} else if (strcmp(c, "L,?") == 0) {
|
||||
snprintf(p, cap, "?L,1");
|
||||
} else if (strcmp(c, "Name,?") == 0) {
|
||||
snprintf(p, cap, "?Name,ezo1");
|
||||
} else if (strcmp(c, "pend") == 0) {
|
||||
if (this->pend_left_ > 0) {
|
||||
this->pend_left_--;
|
||||
rb[0] = 254;
|
||||
} else {
|
||||
snprintf(p, cap, "done");
|
||||
}
|
||||
} else if (strcmp(c, "bad") == 0) {
|
||||
rb[0] = 2;
|
||||
} else if (strcmp(c, "nodata") == 0) {
|
||||
rb[0] = 255;
|
||||
} else if (c[0] == 's' && c[1] >= '0' && c[1] <= '9') {
|
||||
// sNN: an NN character reply ('a'..'z' repeating), terminated if the read has room
|
||||
size_t want = (size_t) atoi(c + 1);
|
||||
for (size_t i = 0; i < want && i < cap; i++)
|
||||
p[i] = 'a' + (i % 26);
|
||||
if (want < cap)
|
||||
p[want] = 0;
|
||||
} else if (strcmp(c, "full31") == 0) {
|
||||
// 31 payload bytes with no terminator anywhere in the 32 byte reply
|
||||
for (size_t i = 0; i < cap; i++)
|
||||
p[i] = 'A' + (i % 26);
|
||||
} else {
|
||||
// L,0 / L,1 / Cal,... / T,<v>: success with no data
|
||||
}
|
||||
ESP_LOGI(TAG, "R 0x%02X after %u ms for \"%s\": code %u", address, (unsigned) waited, this->last_, rb[0]);
|
||||
return esphome::i2c::ERROR_OK;
|
||||
}
|
||||
};
|
||||
|
||||
static FakeEzoBus bus;
|
||||
} // namespace fakeezo
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# run.sh LABEL: build ezo.yaml as ezo<LABEL> from TREE, flash a classic ESP32 devkit, capture 90 s and write
|
||||
# out-ezo<LABEL>.events (bus traffic, callbacks, steps and sensor values with time stamps). Compare the events of dev
|
||||
# and the PR: the replies, callback texts, read values and the delay before each read must match.
|
||||
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
|
||||
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; P=${PORT:?set PORT}
|
||||
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; NAME=ezo$L; OUT=$R/out-$NAME; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
printf 'substitutions:\n name: %s\n\npackages:\n ezo: !include ezo.yaml\n' "$NAME" > "$R/$NAME.yaml"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome run --no-logs "$R/$NAME.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "build or upload failed"; exit 1; }
|
||||
grep -a -E '^(RAM|Flash):' "$OUT/upload.log"
|
||||
$PY "$SC" "$P" 90 "$OUT/serial.txt" --reset > /dev/null 2>&1
|
||||
[ -s "$OUT/serial.txt" ] || { echo "serial capture got nothing"; exit 1; }
|
||||
grep -a -E "fakeezo|\[cb|\[step|ezo.sensor|\[sensor" "$OUT/serial.txt" | sed -E 's/^\[([0-9:.]+)\]/\1/' > "$R/out-$NAME.events"
|
||||
CRASH=$(grep -a -c -i 'guru\|panic\|abort\|stack smashing' "$OUT/serial.txt")
|
||||
echo "$NAME: $(wc -l < "$R/out-$NAME.events") event lines, $CRASH crash lines"
|
||||
DONE=$(grep -a -o "step.*DONE writes [0-9]* reads [0-9]*" "$OUT/serial.txt" | tail -1); CB=$(grep -a -c "\[cb" "$R/out-$NAME.events")
|
||||
echo "${DONE:-no DONE line}, $CB callback lines"
|
||||
[ "$CRASH" = 0 ] || { echo "FAIL: crash lines in the capture"; exit 1; }
|
||||
echo "$DONE" | grep -q "writes [1-9][0-9]* reads [1-9]" || { echo "FAIL: no DONE line with bus writes and reads"; exit 1; }
|
||||
[ "$CB" -gt 0 ] || { echo "FAIL: no callback fired"; exit 1; }
|
||||
echo PASS
|
||||
@@ -9,6 +9,7 @@ before the LinkChecks instead.
|
||||
import sys
|
||||
|
||||
import bk7231tools.serial.main as bk_main
|
||||
from lt_platform import with_platform
|
||||
from ltchiptool.__main__ import cli
|
||||
from ltchiptool.soc.bk72xx.flash import BK72XXFlash
|
||||
from serial import Serial
|
||||
@@ -47,5 +48,5 @@ def flash_connect(self) -> None:
|
||||
BK72XXFlash.flash_hw_reset = flash_hw_reset
|
||||
BK72XXFlash.flash_connect = flash_connect
|
||||
|
||||
sys.argv = ["ltchiptool", *sys.argv[1:]]
|
||||
sys.argv = ["ltchiptool", *with_platform(sys.argv[1:])]
|
||||
cli()
|
||||
@@ -11,6 +11,7 @@ Usage: python flash_rtl87xx_kit.py flash write -d <port> firmware.uf2
|
||||
import sys
|
||||
import time
|
||||
|
||||
from lt_platform import with_platform
|
||||
import serial
|
||||
|
||||
_first = [True]
|
||||
@@ -61,7 +62,9 @@ def boot_app(port: str) -> None:
|
||||
def main() -> int:
|
||||
rc = 1
|
||||
try:
|
||||
result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False)
|
||||
result = cli_entrypoint.main(
|
||||
args=with_platform(sys.argv[1:]), standalone_mode=False
|
||||
)
|
||||
rc = int(result) if isinstance(result, int) else 0
|
||||
finally:
|
||||
port = port_argument(sys.argv)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Three homeassistant buttons (a button entity, an input_button helper, a wizard input with no entity_id) and a
|
||||
# press from on_boot before any client connects. secrets.yaml (wifi_ssid, wifi_password) goes next to this file.
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
wizard:
|
||||
pages:
|
||||
- inputs:
|
||||
- entity: hab_wiz
|
||||
|
||||
# homeassistant entities are internal by default; internal: false lets the API client list and press them
|
||||
button:
|
||||
- platform: homeassistant
|
||||
id: hab_btn
|
||||
internal: false
|
||||
name: HA button
|
||||
entity_id: button.doorbell
|
||||
- platform: homeassistant
|
||||
id: hab_input
|
||||
internal: false
|
||||
name: HA input
|
||||
entity_id: input_button.chime
|
||||
- platform: homeassistant
|
||||
id: hab_wiz
|
||||
internal: false
|
||||
name: HA wizard
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 2s
|
||||
- button.press: hab_btn
|
||||
Loaded 100 of 1061 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user