Compare commits

...
Author SHA1 Message Date
J. Nick Koston 1adf51552d [light] Tidy the loop benchmarks and keep them off clock-dependent branches 2026-11-15 03:24:19 -06:00
J. Nick Koston 83eb6902af [light] Note the random color in the color wipe benchmark 2026-11-15 02:50:39 -06:00
J. Nick Koston 63692c50c4 [light] Share benchmark helpers and make effect benchmarks deterministic 2026-11-15 02:45:24 -06:00
J. Nick Koston ee87fa6dd3 [light] Add transition and effect benchmarks 2026-11-15 02:39:26 -06:00
Hajo Noerenberg 1dff22f578 [cc1101] Add long packet support, plus a lambda packet_length for unknown-length packets (#17578) 2026-11-14 21:43:21 -06:00
Vinicius FortunaandClyde Stubbs c2eb540b37 [sx126x] Refactor/sx126x radio lifecycle (#22197)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-11-15 13:17:23 +11:00
J. Nick Kostonandclydebarrow fad21e3f14 [cstxxx] Share the Hynitron 16-bit register touchscreen code (#22718)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-11-15 02:11:09 +00:00
Rodrigoah 904f0df920 [whirlpool] Add the Hitachi HCRA31NEWH model (#22704) 2026-11-14 14:23:51 -06:00
Chris 7128d22f07 [ethernet] Notify IP state listeners from the main loop (#22657) 2026-11-14 13:22:11 -06:00
Miguel Angel Nubla 825e2c3dd0 [wifi] Leave the AP before restarting on ESP32 (#22715) 2026-11-14 18:05:42 +00:00
J. Nick Koston eebe66da62 [i2c] Add byte and read-modify-write helpers for 16-bit register addresses (#22698) 2026-11-14 08:26:35 -06:00
J. Nick Koston c032f58c6c [libretiny] Free closed TCP connections after about 1 s instead of 120 s (#22696) 2026-11-14 08:10:14 -06:00
J. Nick Koston ff3adb407e [rp2] Bump arduino-pico framework to 6.3.2 (#22671) 2026-11-14 08:09:29 -06:00
J. Nick Koston dcf3a6eb6e [bluetooth_connection] Keep the top half of 32-bit GATT UUIDs on RP2 (#22679) 2026-11-14 08:09:09 -06:00
J. Nick Koston afbc7151d3 [async_tcp] Use the esphome-libs RPAsyncTCP fork so SSE streams do not stall on RP2040 (#22686) 2026-11-14 08:08:35 -06:00
J. Nick Koston 37b95ff544 [web_server_base] Check Basic auth without linking the library's Digest code (#22684) 2026-11-14 08:08:26 -06:00
J. Nick Koston 6c556c178a [core] Lock the scheduler against lwIP callbacks on RP2 (#22688) 2026-11-14 08:08:10 -06:00
J. Nick Koston 9f197ca8d7 [core] Provide buf_append_str_p on every platform (#22673) 2026-11-14 08:07:05 -05:00
Keith Burzinski c2e8c872b1 [serial_proxy] Keep received bytes when the client's connection is blocked (#22687) 2026-11-13 19:52:12 -06:00
Bascht74 9620899479 [tcp_uart][uart_tcp] Move CONF_DISCONNECTS to the shared component constants (#22438) 2026-11-13 15:47:32 -06:00
dependabot[bot] 2a6a1a050e Bump peter-evans/create-pull-request from 8.1.2 to 8.1.3 (#22672)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-13 19:27:44 +00:00
6a743eca57 [daly_bms] Finish the request cycle before starting a new one (#22284)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-13 12:56:40 -06:00
tomaszduda23andClaude 46844d3631 [i2c] Support I2C on the nRF54LM20 and nRF54L15 (#22642)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-13 10:11:40 -06:00
Mark MayoandJ. Nick Koston 8cad9c4035 [light] Compute a color mode's bit instead of searching for it (#22647)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-13 16:09:00 +00:00
Mark MayoandJ. Nick Koston 76ff2d6c0f [api] Write repeated sint32 elements through the short varint path (#22648)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-13 15:59:31 +00:00
Mark Mayo 8b7d68b80f [core] Make FixedRingBuffer iteration cheaper (#22646) 2026-11-13 09:42:29 -06:00
Tom d40b7b12d5 [const] Move CONF_OPERATING_MODE to the shared component constants (#22650) 2026-11-13 09:35:00 -06:00
Mark Mayo 32ac9f17f1 [select] Compare the first byte before calling strncmp in index_of() (#22649) 2026-11-13 09:23:07 -06:00
Mark Mayo eee52fe419 [core] Let the scheduler's heap algorithms inline the item comparison (#22632) 2026-11-13 03:34:58 +00:00
J. Nick Koston 915b75dcb8 [core] Brace the fast loop check in the unit test main (#22640) 2026-11-12 21:14:16 -06:00
J. Nick Koston df83d20fdc [espota2] Pin the clock in the probe deadline test (#22639) 2026-11-12 22:12:02 -05:00
J. Nick Koston 63decb1d68 [epaper_spi] Stop the fast loop after the fill tests (#22638) 2026-11-12 20:54:39 -06:00
Craig Andrews 5a04f4949a [epaper_spi] Point the UC8279 gate-count error at the height (#22628) 2026-11-13 12:10:15 +11:00
esphome[bot] cc6db898e0 Bump bundled esphome-device-builder to 1.28.1 (#22630)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-11-12 18:25:11 -06:00
Chris f6fa1d8e9e [usb_host] Respect explicit control transfer size (#22629) 2026-11-12 18:20:40 -06:00
Craig Andrews 1442554df2 [epaper_spi] Mark the frame drawn after fill() (#21505) 2026-11-13 10:37:37 +11:00
Nicolas LiaudatandJ. Nick Koston dceba4fa3b [core] Add an align parameter to progmem_array() (#22620)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-12 22:13:53 +00:00
54f2f25f2d [brizlabs_led_string] Add two-wire power-line LED string light (#22531)
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-12 16:06:25 -06:00
J. Nick Koston 9245f20b31 [wifi] Start STA on Realtek when no network or AP is configured (#22611) 2026-11-12 15:51:54 -06:00
J. Nick Koston 483a4f6a73 [uart] Stop ESP8266 software serial TX from tripping the Wi-Fi watchdog above 19200 baud (#22603) 2026-11-12 15:51:11 -06:00
Fabio Pugliese Ornellas 2ef4e52637 [ld2450] Add a button to bypass the xy text sensor filters for 10 seconds (#22285) 2026-11-12 20:47:46 +00:00
122df49ef5 [INA226] Add INA226 Alert functionality with configurable threshold and clear action (#13637)
Co-authored-by: Mashimin <msmarks@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-12 14:14:24 -06:00
e96345f5dc [text_sensor] Add detach_filters/attach_filters to take a filter chain out of use (#22621)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 19:54:48 +00:00
J. Nick Koston 4d758269a1 [mqtt] Check the fail-fast discovery tests without a wall clock (#22614) 2026-11-12 19:40:23 +00:00
43d61df1fc [xl9535] Cache the output registers so a pin write needs no read (#22483)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-12 19:39:36 +00:00
dependabot[bot] 9843763917 Bump filelock from 4.2.9 to 4.2.10 (#22622)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-12 19:30:47 +00:00
J. Nick Koston 0b0b9033e5 [zephyr] Add ISR pin_mode so dht links on nRF52 (#22609) 2026-11-12 13:30:36 -06:00
J. Nick Koston deeb88a835 [zephyr] Report interrupt context in in_isr_context() and is_main_loop_thread() (#22613) 2026-11-12 13:30:14 -06:00
J. Nick Koston 4d04cd6938 [logger] Reject a LibreTiny hardware_uart that has no default pins (#22608) 2026-11-12 13:29:59 -06:00
J. Nick Koston eef2ddca0d [dht] Negate sign-and-magnitude negative temperatures instead of inverting them (#22615) 2026-11-12 13:29:44 -06:00
J. Nick Koston 2bfa9d1524 [host] Add the empty ISRInternalGPIOPin::pin_mode (#22616) 2026-11-12 13:29:43 -06:00
rwrozelleandClaude Opus 5.5 610c9faca6 [nrf52][zephyr] Share the Python environment builder with nrf52 (#22617)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:13:49 -05:00
d09867c27e [nrf52] Add the Seeed XIAO nRF54LM20A and fix uploading to it (#22599)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-12 12:26:33 -06:00
nahumoded 20d34660b8 [homeassistant] Do not warn when a binary_sensor's state is unavailable or unknown (#22607) 2026-11-12 11:07:39 -06:00
rwrozelleandClaude Opus 5.5 c894b4514d [core][stm32] Add the stm32 platform identifier (#22605)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:08:21 -05:00
rwrozelleandClaude Opus 5.5 e347dd957b [nrf52][zephyr] Share the Zephyr script patches with nrf52 (#22606)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:08:04 -05:00
J. Nick Koston 2e60fa0f42 [gpio] Save 4 bytes per binary sensor and simplify the platforms (#22591) 2026-11-12 09:38:09 -05:00
rwrozelleandClaude Opus 5.5 7684ccee06 [nrf52][zephyr] Share the Python environment helpers with nrf52 (#22589)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:22:50 -05:00
J. Nick Koston dccb81b319 [logger] Fix LibreTiny task log messages landing unaligned and out of step (#22594) 2026-11-12 09:06:57 -05:00
J. Nick Koston cdafeb5734 [core] Make call_user_lambda public for builders that emit their own statements (#22588) 2026-11-12 08:46:26 -05:00
J. Nick Koston b09d62e6a3 [speaker] Generate speaker.play as an inline action (#22584) 2026-11-12 08:44:52 -05:00
J. Nick Koston c4a7b75596 [fan] Generate fan.cycle_speed as an inline action (#22585) 2026-11-12 08:44:07 -05:00
J. Nick Koston 2d0c1b07ec [sprinkler] Generate start_single_valve, shutdown and resume_or_start_full_cycle as inline actions (#22586) 2026-11-12 08:43:43 -05:00
J. Nick Koston f216936fdd [ble_device_base] Generate the BK72xx, LN882H and RP2 tracker start_scan actions as inline calls (#22587) 2026-11-12 08:42:48 -05:00
0240f57069 [packet_interface] Add addressing and fragmentation layers (#21101)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-11-12 20:14:26 +11:00
Jesse HillsandJ. Nick Koston 3248eeaf1f [api] Send the platform of each wizard entity (#22580)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-12 19:05:06 +13:00
J. Nick Koston a2ccd64257 [ci] Add ESP8266 lock load, CC1101 sleep, soft AP BLE scan and HA script switch flows to the hardware-test skill (#22575) 2026-11-11 22:11:42 -06:00
Jesse Hills c22a7a4188 Merge branch 'beta' into dev 2026-11-12 16:27:17 +13:00
Jesse Hills b03870b551 Merge pull request #22583 from esphome/bump-2026.11.0b4
2026.11.0b4
2026-11-12 16:26:56 +13:00
esphome[bot] 6e9b37ca0d Bump aioesphomeapi from 46.29.1 to 46.30.0 (#22582) 2026-11-11 21:07:27 -06:00
J. Nick KostonandJonathan Swoboda f695f41a3d [sensor][binary_sensor][text_sensor] Generate lambda filters as apply filters (#21830)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-12 02:54:48 +00:00
J. Nick Koston 909ffd7313 [ai] Freeze the YAML language until configs move to yaml.rocks (#22556) 2026-11-11 20:44:37 -06:00
Jesse Hills 4a965b8b32 Bump version to 2026.11.0b4 2026-11-12 15:42:05 +13:00
J. Nick Koston d826a19eb1 [esp32_ble] Sort GATT discovery responses by handle for ESP-IDF 5.5.6 and 6.0.3 (#22576) 2026-11-12 15:42:02 +13:00
J. Nick Koston fb54ec51c1 [gpio] Lock interrupts per slot on the one_wire bus (#22452) 2026-11-12 15:42:02 +13:00
J. Nick Koston 04b6c0d4ca [esp32_ble] Keep bond keys when a bonded device re-encrypts on ESP-IDF 5.5.6 (#22559) 2026-11-12 15:42:02 +13:00
J. Nick Koston abf282dd08 [esp32_ble_tracker] Scan at the short window while the wifi AP is running (#22566) 2026-11-12 15:41:58 +13:00
J. Nick Koston e097958ad9 [remote_transmitter] Lock interrupts only near each edge on ESP8266 (#22453) 2026-11-12 15:41:24 +13:00
jabla 8a8ad4d8da [zigbee] Rejoin only after the parent missed several polls (#22405) 2026-11-12 15:41:24 +13:00
J. Nick Koston 09b82388c9 [uart] Lock interrupts per bit in ESP8266 software serial TX at 19200 baud and below (#22450) 2026-11-12 15:41:23 +13:00
J. Nick Koston ea5407925a [wifi] Apply power save when the AP starts without networks (#22564) 2026-11-12 15:41:20 +13:00
J. Nick Koston 97732df3a5 [esp32_ble][ble_device_base][improv_ble] Send advertising as raw packets and keep Improv in the advertising packet (#22499) 2026-11-12 15:40:20 +13:00
Francesco Pace 04482ae0d4 [espectre] Update to SDK 3.2.0 (#22570) 2026-11-12 15:40:20 +13:00
Clyde Stubbs ca0dd8e292 [mipi_dsi] Keep the panel running while flash is written (#22535) 2026-11-12 15:40:20 +13:00
J. Nick Koston e75ac64cc9 [esp8266] Skip other devices' known mDNS answers without allocating (Arduino core 3.1.2-esphome.4) (#22533) 2026-11-12 15:40:20 +13:00
PolarGooseandJ. Nick Koston 212c0c1ea4 [dsmr] Update dsmr_parser to 1.11.0 (#22530)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-12 15:40:20 +13:00
Jonathan Swoboda 3e2f9bf296 [ota] Skip the auth request when the password is empty at run time (#22485) 2026-11-12 15:40:20 +13:00
J. Nick Koston b997866e86 [esp32_ble_server] Size the BLE2902 value buffer to its 2 byte value (#22454) 2026-11-12 15:40:20 +13:00
J. Nick Koston ae89db98e4 [core] Inline InterruptLock on ESP8266 (#22455) 2026-11-12 15:40:19 +13:00
Kevin Ahrendt e9bb102c1f [sendspin] Add bits_per_sample to the media source (#22313) 2026-11-12 15:40:19 +13:00
J. Nick Koston e95c1143af [esp32_ble] Sort GATT discovery responses by handle for ESP-IDF 5.5.6 and 6.0.3 (#22576) 2026-11-12 14:59:56 +13:00
Fabio Pugliese Ornellas 0d7a4bd87e [text_sensor] Add Filter::reset_chain to put filters back in their boot state (#22409) 2026-11-11 19:55:23 -06:00
J. Nick Koston bf6b0fd352 [gpio] Lock interrupts per slot on the one_wire bus (#22452) 2026-11-12 01:23:34 +00:00
Jesse Hills b273dfb829 [ci] Reject a new constant defined in two or more files (#22498) 2026-11-12 01:19:01 +00:00
J. Nick Koston 0b6a78d206 [binary_sensor] Generate the delayed_on_off filter as an apply filter (#21832) 2026-11-11 19:04:29 -06:00
J. Nick Koston 0cf1bad9a7 [esp32_ble] Keep bond keys when a bonded device re-encrypts on ESP-IDF 5.5.6 (#22559) 2026-11-12 00:59:50 +00:00
J. Nick Koston 9f74fcb820 [esp32_ble_tracker] Scan at the short window while the wifi AP is running (#22566) 2026-11-12 00:57:08 +00:00
J. Nick Koston 666c60b158 [core] Collect config ids once in unique_global_id and convert raw filter lambda types (#22573) 2026-11-11 18:53:58 -06:00
408a24b6a5 [nrf52][zephyr] Share the Zephyr SDK installer with nrf52 (#22562)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-11 19:53:22 -05:00
J. Nick Koston 047ff1aa15 [remote_transmitter] Lock interrupts only near each edge on ESP8266 (#22453) 2026-11-12 00:53:04 +00:00
Jonathan Swoboda e602b7aa53 [components] Fix misspelled identifiers (#22568) 2026-11-11 19:46:29 -05:00
Nicolas LiaudatandJ. Nick Koston 4f1e2da537 [micro_wake_word] Bump esp-tflite-micro to 1.4.1 and esp-nn to 1.5.0 (#21480)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-11 18:39:39 -06:00
jabla e3c0ddb5df [zigbee] Rejoin only after the parent missed several polls (#22405) 2026-11-12 13:35:19 +13:00
J. Nick Koston c8d038eada [uart] Lock interrupts per bit in ESP8266 software serial TX at 19200 baud and below (#22450) 2026-11-11 18:33:29 -06:00
J. Nick Koston 01d9547700 [wifi] Apply power save when the AP starts without networks (#22564) 2026-11-12 13:33:14 +13:00
J. Nick Koston 9c6fb6830d [esp32_ble][ble_device_base][improv_ble] Send advertising as raw packets and keep Improv in the advertising packet (#22499) 2026-11-11 18:32:55 -06:00
Jonathan Swoboda 3919f4fcbc [tests] Fix typos in test names and fixtures (#22567) 2026-11-11 18:23:30 -06:00
Francesco Pace f544ac247b [espectre] Update to SDK 3.2.0 (#22570) 2026-11-11 19:19:31 -05:00
Jonathan Swoboda 12f88f72bb [nrf52] Skip the testing mode flash overlay on nRF54L (#22571) 2026-11-11 19:11:58 -05:00
J. Nick Koston f88f254ba0 [core] Add a CodSpeed benchmark for naming generated globals (#22572) 2026-11-11 19:07:05 -05:00
Bascht74 10b503cb07 [store_yaml] Add manufacturer_key to the sensitive keys (#22569) 2026-11-11 17:58:14 -06:00
J. Nick Koston 9aee2ea1ef [core] Add ApplyFilter for generated sensor, binary_sensor and text_sensor filters (#21829) 2026-11-11 18:26:46 -05:00
Jonathan Swoboda 434ba13986 [components] Fix typos in comments and log messages (#22565) 2026-11-11 21:43:23 +00:00
f4c5d5f818 [zephyr] Use the new USB device stack on nRF Connect SDK 3.4.1 and newer (#22233)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-11 21:39:06 +00:00
cfb76b1bc4 [entity_transport] Add entity transport component (#21095)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-11-11 21:26:20 +00:00
rwrozelleandClaude Opus 5.5 74dee7dd17 [core] Share the install lock between framework installers (#22561)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:16:52 -06:00
tomaszduda23andClaude e0790a9063 [zephyr_ble_server] Fix ble on nrf sdk 3.4.1 (#22558)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 22:04:35 +01:00
J. Nick Koston 9b26a15960 [network] Build the Zephyr TCP stack on nRF52 only when a component uses TCP (#21910) 2026-11-11 13:54:47 -06:00
75b586fb57 [wifi] Add on_ap_start / on_ap_stop triggers (#22154)
Co-authored-by: git-xmu999 <173232202+git-xmu999@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 13:50:49 -06:00
dependabot[bot] 6d95112b77 Bump bleak from 3.0.2 to 3.1.0 (#22557)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-11 13:32:09 -06:00
tomaszduda23andClaude ff3075cb26 [zephyr_ble_server] Fix build on sdk-nrf 3.4.1 (#22555)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 20:16:49 +01:00
Clyde Stubbs 40a226f3fd [mipi_dsi] Keep the panel running while flash is written (#22535) 2026-11-12 06:06:46 +11:00
J. Nick Koston 182db18dd9 [core] Add cg.shared_static_function to emit one function per distinct body (#21837) 2026-11-11 12:57:00 -06:00
2a14d1375b [sen6x] Add forced CO2 recalibration action (#22256)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 12:38:50 -06:00
8e0cb460ea [cc1101] Add power-down and wake-up actions (#22519)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-11 12:25:55 -06:00
J. Nick Koston ea5c8a2fbc [store_yaml] Add esphome recover-yaml to download the stored YAML (#22536) 2026-11-11 12:04:03 -06:00
J. Nick Koston 4a6e43f9b3 [select] Store the select.is lambda as a function pointer (#22550) 2026-11-11 12:56:51 -05:00
8c2841d6f4 [socket] Let TcpListener accept into several links (#21039)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 11:23:51 -06:00
ac0dc6fe2e [epaper_spi] Name each model's driver class once (#20502)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 10:47:16 -06:00
Daniel PernoldandJ. Nick Koston dc0cb537cc [scd4x] Add automatic_self_calibration_target (#22546)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 10:38:17 -06:00
Ivan KruglovandJ. Nick Koston c3c4d70e0e [bl0939] Add configurable anti-creep threshold (#22501)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 10:36:51 -06:00
f00fa15b54 [core] Speed up component imports and config validation (#22543)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-11 10:26:00 -06:00
34c4898771 [homeassistant] Allow script to be controlled with homeassistant/switch component (#22548)
Co-authored-by: Olivier Smeesters <osmeest@gmail.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 10:15:23 -06:00
Mark MayoandJ. Nick Koston 19a332507d [api] Match Home Assistant state subscriptions by their stored length (#22544)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-11 15:51:02 +00:00
Jonathan Swoboda f71fcc1739 [remote_base] Don't require code for the KeeLoq binary sensor (#22541) 2026-11-11 09:45:53 -06:00
esphome[bot] 36fd800c83 Bump aioesphomeapi from 46.28.0 to 46.29.1 (#22540)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-11-11 09:35:58 -06:00
Mark Mayo 3a3c390621 [homeassistant] Do not warn when a sensor's state is unavailable or unknown (#22545) 2026-11-11 09:32:14 -06:00
Flaviu TamasandJonathan Swoboda 29875a9e03 [bmi160] Remove code owner (#22537)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-11 03:05:45 +00:00
Jonathan Swoboda 9abc1812d9 [script] Only import platform modules in build_codeowners.py (#22539) 2026-11-10 21:54:21 -05:00
J. Nick KostonandJonathan Swoboda a9c3c6d8d0 [ble_device_base] Match service and manufacturer data triggers from a constant (#21827)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-10 19:16:16 -06:00
J. Nick Koston b03e03e465 [combination] Keep sources in static storage and compute median and range without the heap (#22510) 2026-11-10 18:11:38 -06:00
J. Nick Koston 6b2a9870d0 [wled] Use the socket component instead of Arduino WiFiUDP (#22509) 2026-11-10 18:11:22 -06:00
J. Nick Koston da06b29845 [packet_interface] Keep the reliability and encryption buffers in static buffers (#22337) 2026-11-10 18:11:05 -06:00
J. Nick Koston 629dfe1c9a [esp8266] Skip other devices' known mDNS answers without allocating (Arduino core 3.1.2-esphome.4) (#22533) 2026-11-10 18:10:29 -06:00
J. Nick Koston 354f844843 [ci] Add heap allocation tracing to the hardware-test skill (#22529) 2026-11-10 19:06:29 -05:00
Bascht74 c54b3b41d3 [ci] Skip benchmark.yaml when looking for direct bus config (#22437) 2026-11-10 14:17:21 -08:00
PolarGooseandJ. Nick Koston 8412d6255a [dsmr] Update dsmr_parser to 1.11.0 (#22530)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-10 21:50:36 +00:00
dependabot[bot] 6b962b15d6 Bump zeroconf from 0.153.0 to 0.154.0 (#22527)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-10 19:31:24 +00:00
6d5400a80f [dsmr] Fix compiling on the host platform (#22524)
Co-authored-by: Bascht74 <66269310+Bascht74@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-11-10 11:18:44 -08:00
Zedas f6a2241eb1 [store_yaml] Find integer secrets written as quoted hex (#22516) 2026-11-10 11:49:48 -06:00
Bascht74 9d1071e2f8 [dlms_meter] Fix compiling on the host platform (#22503) 2026-11-10 17:27:01 +00:00
Bascht74 74db3c0484 [canbus][rs485_frame] Move CONF_ON_FRAME to the shared component constants (#22505) 2026-11-10 11:25:06 -06:00
J. Nick KostonandJonathan Swoboda 452b7017e6 [ezo] Pass reply text to the triggers as StringRef (#22204)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-10 16:57:56 +00:00
J. Nick Koston b65cdb788c [ezo] Pin the argument types of the reply triggers in tests (#22201) 2026-11-10 09:50:02 -05:00
J. Nick KostonandJonathan Swoboda 1558751b46 [mcp23008][mcp23017] Configure IOCON with update_register (#22232)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-10 09:49:55 -05:00
J. Nick Koston 5b4d0069d2 [ezo] Link the command text builder only when a command is queued (#21866) 2026-11-10 09:23:45 -05:00
J. Nick Koston 44e39e6bca [mcp23008][mcp23017] Add setup tests for the IOCON configuration (#22231) 2026-11-10 09:22:07 -05:00
J. Nick Koston 19e16144b6 [sprinkler] Keep the valve list in static storage (#22345) 2026-11-10 09:09:54 -05:00
Bascht74 029199b0ca [uart] Enable wake-on-RX without a network component (#22502) 2026-11-09 19:57:43 -08:00
J. Nick Koston e2b60adc2b [sensor][number] Generate the on_value_range bounds as one function (#21818) 2026-11-09 19:56:49 -08:00
J. Nick Koston 71d9a995b6 [core] Add ValueRange and new_value_range_trigger for on_value_range triggers (#21817) 2026-11-09 19:56:49 -08:00
Jonathan Swoboda 9e69326b14 [ota] Skip the auth request when the password is empty at run time (#22485) 2026-11-09 16:30:52 -05:00
942faa87ef Bump ruff from 0.17.3 to 0.17.4 (#22492)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 12:35:11 -08:00
dependabot[bot] 42cae195f8 Bump esphome-glyphsets from 0.2.0 to 0.2.1 (#22493)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 12:06:49 -08:00
dependabot[bot] f85ebd2e21 Bump prek from 0.5.9 to 0.5.10 (#22486)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 12:05:23 -08:00
dependabot[bot] 31af32a488 Bump docker/login-action from 4.7.0 to 4.8.0 in the docker-actions group (#22489)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 12:05:14 -08:00
dependabot[bot] 279e1dc48f Bump paho-mqtt from 2.1.0 to 2.2.0 (#22495)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 12:05:01 -08:00
dependabot[bot] dd641ff9d8 Bump filelock from 4.2.7 to 4.2.9 (#22488)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 12:00:44 -08:00
dependabot[bot] 4d740dc1b4 Bump click from 8.5.0 to 8.5.1 (#22487)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 11:45:40 -08:00
dependabot[bot] 292a646b03 Bump platformdirs from 4.14.3 to 4.14.5 (#22490)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 11:45:32 -08:00
dependabot[bot] 429c3586bf Bump github/codeql-action from 4.39.2 to 4.39.3 (#22491)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 11:45:24 -08:00
dependabot[bot] 0929b4d6be Bump esphome/workflows/.github/workflows/lock.yml from 2026.10.3 to 2026.11.0 (#22496)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 11:38:34 -08:00
dependabot[bot] e24fe74baa Bump peter-evans/create-pull-request from 8.1.1 to 8.1.2 (#22494)
Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 11:37:46 -08:00
1daba1b998 [core] Add the Zephyr toolchain id and tools cache (#21720)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-09 17:50:24 +00:00
J. Nick Koston e7c066b923 [lcd_base] Keep the character buffer in a static buffer (#22331) 2026-11-09 10:57:51 -06:00
J. Nick Koston 1add061d94 [core] Keep the looping components list in static storage (#22342) 2026-11-09 11:35:32 -05:00
J. Nick Koston b55460af99 [esp32_ble_tracker] Use the shared ble_device_base advertise triggers (#21823) 2026-11-09 11:32:48 -05:00
J. Nick Koston f25d66d7df [bp1658cj] Clean up the driver (#21845) 2026-11-09 11:28:43 -05:00
J. Nick Koston ad6b83dce4 [bp5758d] Use Apply outputs for the channels and clean up the driver (#21844) 2026-11-09 11:28:24 -05:00
J. Nick Koston 62d9f9ef26 [sm16716] Clean up the driver (#21842) 2026-11-09 11:25:49 -05:00
J. Nick Koston b3e75be525 [my9231] Clean up the driver (#21841) 2026-11-09 11:25:21 -05:00
J. Nick Koston d9797c2efb [modbus_client] Keep constant write values in shared flash tables (#20328) 2026-11-09 11:24:13 -05:00
J. Nick Koston 805392e9e2 [homeassistant] Keep the select's option storage in static buffers (#22329) 2026-11-09 11:20:11 -05:00
J. Nick Koston 9c350d0bc1 [light] Keep the LED strip buffers in static buffers (#22335) 2026-11-09 11:18:17 -05:00
J. Nick Koston 5f0e2db058 [core] Add StaticFixedVector for codegen-sized lists (#22341) 2026-11-09 11:13:53 -05:00
J. Nick Koston bd75639765 [ci] Validate changed component tests with a shared-id check (#22429) 2026-11-09 11:07:32 -05:00
J. Nick Kostonandpre-commit-ci-lite[bot] a4f844335d [ci] Catch omitted bus ids in grouped component tests (#22427)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-11-09 11:06:04 -05:00
J. Nick Koston 12117419f6 [spi] Mark the bus failed when ESP-IDF bus init fails (#22373) 2026-11-09 11:03:46 -05:00
J. Nick Koston 6cdf238568 [micro_wake_word] Act on the last start or stop request (#22393) 2026-11-09 11:02:18 -05:00
J. Nick Koston 1424c9b74b [components] Clear the comm warning after a successful read (#22424) 2026-11-09 11:00:34 -05:00
J. Nick KostonandJonathan Swoboda 62828cb550 [ci] Add BMP390, IPv6, MCP23017, PWM capture, SPI and Home Assistant wizard flows to the hardware-test skill (#22413)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-11-09 10:59:19 -05:00
J. Nick Koston 6cc96fab0d [components] Use status_comm_check for failed bus calls (#22422) 2026-11-09 10:59:06 -05:00
J. Nick Koston ee60953e67 [core] Add Component::status_comm_check (#22421) 2026-11-09 10:56:04 -05:00
J. Nick Koston a330714cf6 [ci] Add MCP2518FD, crc32 signed OTA, I2C fault and LibreTiny edge flows to the hardware-test skill (#22412) 2026-11-09 10:38:43 -05:00
J. Nick Koston 428883ed80 [nextion] Move the writer in set_writer instead of copying it (#22469) 2026-11-09 09:55:37 -05:00
J. Nick Koston 62cef67191 [display] Move the writer in set_writer instead of copying it (#22466) 2026-11-09 09:36:48 -05:00
J. Nick Koston ba423ff170 [components] Use cg.byte_span for std::span<const uint8_t> automation args (#22464) 2026-11-09 09:30:56 -05:00
esphome[bot] 2610a6a03a [ci] Refresh integration test durations (#22449)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-11-09 09:25:16 -05:00
J. Nick Koston 380660dcdb [esp32_ble_server] Size the BLE2902 value buffer to its 2 byte value (#22454) 2026-11-09 09:24:27 -05:00
J. Nick Koston be0550b1e8 [core] Inline InterruptLock on ESP8266 (#22455) 2026-11-09 09:23:06 -05:00
J. Nick Koston 935befcbe5 [esp32_ble_server] Build the write and connect triggers as callback automations (#22456) 2026-11-09 09:22:39 -05:00
J. Nick Koston 3325b16c56 [core] Add cg.byte_span for std::span<const uint8_t> (#22462) 2026-11-09 09:20:45 -05:00
J. Nick Koston d9cac9a88f [components] Define simple setters in the header (#22459) 2026-11-09 09:18:46 -05:00
J. Nick Koston 86442c8816 [components] Define simple setters in the header (sensor drivers) (#22468) 2026-11-09 09:18:10 -05:00
Jesse Hills 46f532e2bd [espidf] Remove unreachable fallback when choosing a library source directory (#22305) 2026-11-09 16:54:33 +13:00
Jesse Hills f1a1502fb6 [ci] Check stale PRs for merge conflicts every 3 hours (#22446) 2026-11-09 16:26:12 +13:00
Jesse Hills d3738e31f6 [core] Require CONF_ and UNIT_ constants to be plain string literals (#22445) 2026-11-09 02:58:49 +00:00
Jesse Hills 76d0b989e7 [core] Recommend VS Code extensions and set up the dev environment on first open (#22301) 2026-11-09 15:51:41 +13:00
Jesse Hills f7c91fad0f [ld2412] Remove unused threshold key lists (#22443) 2026-11-08 20:59:27 -05:00
Jesse Hills a7b3c34598 [ld2410] Remove obsolete threshold key errors (#22444) 2026-11-08 20:58:11 -05:00
Jesse Hills 918fe15eb8 Merge branch 'beta' into dev 2026-11-09 14:05:11 +13:00
Jesse Hills 26c4f8d40e Merge pull request #22442 from esphome/bump-2026.11.0b3
2026.11.0b3
2026-11-09 14:04:52 +13:00
Jesse Hills c097fe388e Bump version to 2026.11.0b3 2026-11-09 13:36:04 +13:00
J. Nick Koston 21267d7421 [libretiny] Interrupt on both edges for any edge pins on RTL8710B (#22403) 2026-11-09 13:36:03 +13:00
J. Nick Koston 582adc950a [libretiny] Run millis(), micros() and delays on real time on RTL8710B (#22404) 2026-11-09 13:36:03 +13:00
J. Nick Koston 613319f984 [i2c] Mask I2C interrupts after an error on classic ESP32 (#22396) 2026-11-09 13:36:02 +13:00
J. Nick Koston 4ad3cc7c0e [light] Keep power estimation state in codegen static storage (#22370) 2026-11-09 13:36:02 +13:00
J. Nick Koston a033260639 [libretiny] Read pins in interrupts without detaching the interrupt on Realtek (#22400) 2026-11-09 13:36:02 +13:00
24f23ba3db [i2s_audio] Fix APLL clock source on ESP32-P4 (#22402)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-09 13:36:02 +13:00
Roman PriesolandJ. Nick Koston 674d593f75 [wifi] Create the IPv6 link-local through lwIP on connect (#22364)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-11-09 13:36:02 +13:00
Jonathan Swoboda c1771e002b [ld2410][ld2412][ld2420] Drop device classes from unitless gate and light entities (#22374) 2026-11-09 13:36:02 +13:00
Jonathan Swoboda 5529e87a40 [tsl2591][seeed_mr24hpc1] Drop device classes from unitless sensors (#22375) 2026-11-09 13:36:02 +13:00
J. Nick Koston 54cae78a42 [ht16k33_char] Name the I2C bus in the component test (#22372) 2026-11-09 13:36:02 +13:00
J. Nick Koston e77a3bc54d [core] Add cg.static_buffer for config-sized static buffers (#22326) 2026-11-09 13:36:02 +13:00
guillempages af7f95a01b [ht16k33] Rename display_off method (#22352) 2026-11-09 13:36:02 +13:00
daf48ce4f2 [vl53l0x] Recover from a failed I2C read instead of stalling (#19235)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Tekla Bania <[EMAIL]>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Signed-off-by: Tekla Bania <8668038+teklabania@users.noreply.github.com>
Signed-off-by: Tekla Bania <[EMAIL]>
2026-11-09 13:36:02 +13:00
J. Nick Koston 3de3f72e7c [libretiny] Interrupt on both edges for any edge pins on RTL8710B (#22403) 2026-11-09 13:33:53 +13:00
J. Nick Koston 1878a69187 [libretiny] Run millis(), micros() and delays on real time on RTL8710B (#22404) 2026-11-09 13:31:33 +13:00
J. Nick Koston 032c8d9618 [i2c] Mask I2C interrupts after an error on classic ESP32 (#22396) 2026-11-09 13:18:05 +13:00
Jesse Hills 36c079de78 [epaper_spi] Add Good Display GDEW075T7 7.5" model (075BN-T7-D2) (#22322) 2026-11-09 11:35:45 +13:00
J. Nick Koston bdd0a2b3b6 [core][components] Use standard helpers and ternaries for if/else blocks that pick a value (#22425) 2026-11-08 12:18:46 -06:00
1051 changed files with 33517 additions and 6414 deletions

No files matched your search

+10
View File
@@ -40,6 +40,14 @@ Not shared code: the component's own platform files (`esphome/components/<c>/sen
`text_sensor/__init__.py`, ...), its new `USE_*` lines in `esphome/core/defines.h`,
and its `CODEOWNERS` entry.
## YAML language freeze
Until configurations move to yaml.rocks (esphome/backlog#201), flag any PR that adds to the YAML language: a new tag
in `esphome/yaml_util.py`, new behaviour in the `packages` or `substitutions` components (a new entry in the Jinja
`SAFE_GLOBALS` in `substitutions/jinja.py`, or a custom Jinja filter, counts), or new merge, override or remove
semantics (`esphome/config_helpers.py`, `!extend`, `!remove`). Bug fixes to the existing features are fine. This
covers how YAML files are read, included and combined, not a component's own configuration options.
## Memory and embedded constraints (highest value)
Heap allocation after `setup()` is treated as a reliability bug, not a
@@ -104,6 +112,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_")` |
@@ -236,6 +236,8 @@ Never a template per bit or a virtual listener base (a second vptr per entity).
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.
+3
View File
@@ -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.
+2
View File
@@ -107,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`.
+9 -2
View File
@@ -56,6 +56,9 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at
74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version).
`ports.py --wait 2e8a` blocks until a Pico re-enumerates.
A change in validation or platform independent code needs no board: a `host:` build serves the API on
port 6053 on every interface (reach it at 127.0.0.1, and add `api: encryption:` on a shared network); see
recipes/homeassistant.md for one against a throwaway Home Assistant.
2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need.
One yaml per board per variant, file name equal to `name:`.
3. **Compile** in the right tree:
@@ -125,12 +128,13 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
or exits 1 when a build, flash or port step failed.
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `heap_alloc/` (operator new caller
recorder, dump saver and decoder, cover cycle driver), `online_image/`
(server with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(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/` (`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
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, AT commands for the UART passthrough), `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 Line truncated
## Templates in `$SKILL/templates`
@@ -169,6 +173,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,11 @@ 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.
- `Hardware Watchdog` with `wDev_ProcessFiq` in the stack means interrupts were held off too long for the Wi-Fi
interrupt handler; `Software Watchdog` with `pm_send_nullfunc` means the loop was busy too long. Code that takes
interrupt locks needs a load run to show either (`scripts/esp8266_lock_load/`, see recipes/one_wire.md).
- `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 +45,8 @@ 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.
@@ -52,6 +58,7 @@ 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 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.
@@ -147,6 +154,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
@@ -226,6 +235,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- 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)
@@ -14,10 +14,10 @@ One file per component, named after it:
- [`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
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`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, SIM7080G behaviour with no SIM and with no data plan through a raw UART passthrough
- [`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, SMP over UDP OTA over Thread with reset faults, MCUboot swap limit and stuck slot recovery, TCP stack probe
- [`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, sdk-nrf 3.4.1 build and USB notes
- [`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 automations on two platforms
@@ -25,12 +25,14 @@ One file per component, named after it:
- [`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
- [`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, soft AP clients with the BLE tracker scanning, ESP8266 power save after captive portal provisioning
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
- [`one_wire.md`](one_wire.md): ESP8266 interrupt locks under Wi-Fi load with a DS18B20 search and a busy-wait control
- [`cc1101.md`](cc1101.md): sleep and wake checks read directly from the chip (CHIP_RDYn, PATABLE loss, registers per setter)
- [`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, homeassistant switch on a script with a host build
- [`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
@@ -61,7 +63,7 @@ One file per component, named after it:
- [`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
- [`bluetooth_proxy.md`](bluetooth_proxy.md): held GATT link after a half-open API session, frozen Home Assistant stand-in, out-of-order GATT discovery responses injected on the proxy
- [`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
@@ -70,3 +72,14 @@ One file per component, named after it:
- [`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
- [`heap_alloc.md`](heap_alloc.md): operator new counting, and a ring of allocation callers decoded with addr2line to tell the first run after boot from repeats
- [`wled.md`](wled.md): WLED realtime sender on a board's own access point
- [`combination.md`](combination.md): combination sensor NaN behaviour from the last source
@@ -56,3 +56,9 @@ include of random base64 lines with `include_secrets: true` gives four chunks (1
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
`secrets.yaml` beside it; a copy next to the package works around it.
Results: https://github.com/esphome/esphome/pull/16445#issuecomment-6335435397
## Allocations of a connecting client (ratgdo/esphome-ratgdo#705)
An API client that connects allocates about 15 times on ESP32: the connection object, then the list entities and
initial state batches growing the `DeferredBatch` vector. A log subscription adds more per line. Connect before a
heap measurement and subscribe to logs only afterwards (`heap_alloc.md`).
@@ -42,6 +42,7 @@ comparison accepted automatically) and a classic ESP32 devkit runs `esp32central
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.
#22559 fixes it: the bond survived 15 reconnects and a reset of both boards with no new pairing.
## Bluedroid hex parsing bench (#22081)
@@ -26,3 +26,32 @@ swap fails when a round never connects. An owner that never got the link also fa
- 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.
## Out-of-order GATT discovery responses (#22518)
Some peripherals answer a characteristic discovery (Read By Type) with records out of handle order. ESP-IDF 5.5.6
rejects that (`gatt_process_read_by_type_rsp invalid handle ...`) and ends the whole discovery; 5.5.5, Android and
BlueZ accept it. `scripts/att_reorder/` reproduces it with no such device:
- `server.yaml` (ESP32-S3) serves `FFF0` with six characteristics, then `1826`.
- `proxy.yaml` (classic ESP32, `idf` substitution; keep per-build files such as `example-proxy.yaml` in the same
directory) links `components/attswap`, which wraps Bluedroid's
`l2c_rcv_acl_data` (`-Wl,--wrap`); `attswap.hpp` swaps the first two records of the response whose first record
declares `FFF1`, and logs a swap count.
- `gattget.py PROXY PEER_MAC ROUNDS` reads the services through the proxy like Home Assistant (no cache);
`run.sh PROXY_YAML [ROUNDS]` flashes both boards and prints the services per round and the discovery errors.
On 5.5.6 Home Assistant gets `fff0` with no characteristics and no `1826`, while the proxy still logs
`Service discovery complete` (the proxy ignores the error status); on 5.5.5 every service arrives. To find the order a
real device uses, decode an Android HCI snoop: a Read By Type response lists its records in the order sent.
- Handles, reads and notifies after a reordered response: flash `server-notify.yaml` (`SERVER_YAML` on `run.sh`) and run
`gattcheck.py PROXY PEER_MAC`. It prints every service, characteristic and descriptor handle, reads FFF1, FFF2 and
FFF3 by the received handles (each holds its own value), and enables notify on FFF1, FFF2 and 2ACD. Compare the table
with a run of a proxy built without `attswap`. On ESP-IDF 5.5.5, which accepts the unsorted response, FFF1's
descriptor range runs over FFF2's declaration, value and CCCD, so FFF2 has no CCCD (#22576).
- `bluetooth_gatt_start_notify` only registers the notify on the proxy; the client writes the CCCD itself, as Home
Assistant does. Without that write no notification arrives.
- Give the proxy an `api` encryption key (`API_KEY` for the scripts). A Home Assistant on the same network connected to
a plaintext test proxy and took over its Bluetooth subscription (`Subscription from Home Assistant ... replaces
aioesphomeapi`), dropping the test client's connection mid-run.
@@ -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,17 @@
# cc1101
## Sleep and wake checks read from the chip (#22519)
`scripts/cc1101_sleep/`: `cc1101-sleep.yaml` runs an ESP32-S3 (ESP-IDF) with an E07-M1101D module on SPI and runs
tests T0 to T6 4 s after boot; `cctest.hpp` reads every config register and the PATABLE directly over SPI. The log
ends with `DONE fails=N`, and T6 prints whether each setter woke a sleeping chip.
- Proof that the chip sleeps: with CSn low, SO (MISO) stays high until the crystal runs, so it is high after
`power_down()` and low at once when awake. PATABLE entries 1 to 7 are also lost in SLEEP, so write marks to them
before `power_down()` and check that they are gone.
- Selecting the chip to check it wakes it, so after the check the chip is awake while the component still thinks it
sleeps. Call `wake_up()` and `begin_rx()` before the next case.
- Leave FSCAL3 to FSCAL1 (0x23 to 0x25), FSTEST (0x29) and AGCTEST (0x2B) out of register comparisons: calibration
and the chip itself change them.
- Results: on 7e6b90d08b, `set_frequency`, `set_channel` and `set_modulation_type` woke a sleeping chip and left it
in RX. 9e65d0c327 fixed that; every other check passed on both, and 200 sleep and wake cycles took about 1 s.
@@ -0,0 +1,7 @@
# combination
## NaN from the last source (#22510)
A combination sensor defers each update and uses the value of the source that published last. A NaN from that
source drops the whole update, so when testing NaN handling keep the last-publishing source finite, and compare
rows one update late. Heap churn per update: see `heap_alloc.md`.
@@ -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.
@@ -13,8 +13,6 @@ replies.
- `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.
- Call plain `millis()` in a fake: Arduino builds define a `millis()` macro (`esphome::millis()`), so
`esphome::millis()` breaks there, and on ESP-IDF the plain name resolves through `using namespace esphome`.
- 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
@@ -0,0 +1,41 @@
# Heap allocation counts and callers (#22509, #22510, ratgdo/esphome-ratgdo#705)
## Counting
Replace the global `operator new` in an `esphome: includes:` header on ESP32 (Arduino or ESP-IDF) and log the
delta from an `interval`. Compare a PR build with its base on the same flow; the rate per second shows churn.
- The ESP8266 core defines `operator new` non-weak. `-Wl,--wrap=_Znwj` worked in a sensor-only config but
boot-looped a neopixelbus config (alignment fault in the wrapper), so report free heap only there.
- `vec = {}` keeps a std::vector's capacity (it is an initializer_list assignment); only
`std::vector<T>().swap(vec)` or `clear()` plus `shrink_to_fit()` frees it. Seen as heap that did not come back
after a WLED effect stopped.
## Callers
`scripts/heap_alloc/`:
- `allocring.hpp` counts every `operator new` and, once armed, keeps the first 1024 with their size, time and
five caller PCs, about 28 KB of RAM. It disarms itself when full. The frame walker
(`esp_backtrace_get_next_frame`) exists only on Xtensa (ESP32, S2, S3); on RISC-V chips entries keep size and
time without callers. Aligned `operator new` (`std::align_val_t`) is not replaced, so it is not counted;
nothrow `new` goes through the replaced one.
- `allocring.yaml` (package, substitution `alloc_arm_s`) arms it that many seconds after boot and adds a
"Dump allocations" button that logs what was kept. The press stops recording until the next reboot, so
reboot (or reflash) before each run; a second press returns the same entries.
- `alloc_marker(a, b)` from a lambda writes a marker entry, for example on each cover operation change, so the
dump splits into phases.
- `dump.py HOST OUT` presses the button and writes the dump to a fresh OUT, pressing again until the header
and every entry arrived (a late log subscription or dropped log lines). `decode.py LOG ELF [ADDR2LINE]` prints each entry with its callers as function and file:line, and exits 2
when the recorder never armed, an entry was still being written, entries are missing, repeated or out of
range, no Xtensa entry has a caller, the ELF is from another build (the buffer address in the header differs), or the buffer
filled. A PR build and its base can share that address, so copy each build's `firmware.elf` next to its dump. On RISC-V it prints sizes and times only. It reads only the newest dump in the file. The ESP-IDF ELF is `.esphome/build/<name>/build/firmware.elf`.
- `cycles_cover.py HOST N OUT` waits for the "Allocation recorder armed" binary sensor, then moves the first
cover to the far end and back N times (`cover_command(position=...)`, waiting for idle each way) with a states-only API client, then saves the dump the same way. It exits 2 when a move
never reached its position.
Findings:
- Arm well after boot: the scheduler regrows its storage once after the freelist trim (see `scheduler.md`).
- Repeat the action several times in one session and count only what comes back. On a ratgdo cover (an external
component, ESP32 ESP-IDF) the first door open after boot allocated 18 times: four 36 B SchedulerItems,
`to_add_` and `items_` growth, and the API `DeferredBatch` vector. Cycles 2 and 3 allocated nothing.
- Keep the API client out of the count (see `api.md`): connect before the run, subscribe to states only, and
subscribe to logs only for the dump.
@@ -45,3 +45,44 @@ 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.
## homeassistant switch on a script, host build against a throwaway Home Assistant (#22548)
No board is needed when the change is in validation or platform independent code: an ESPHome `host:` build serves the
API on 127.0.0.1:6053. `scripts/homeassistant_script_switch/`:
- `ha-configuration.yaml` is the Home Assistant config (port 8129, a 20 s `long_job` script that sets and clears
`input_boolean.script_ran`, a `quick_job` toggle script). Onboard it with `scripts/mqtt/ha_discovery/onboard.py DIR`.
- `hsw.yaml` mirrors both scripts and the flag with homeassistant switches, with template buttons that call
`switch.turn_on` and `switch.turn_off`.
- `haflow.py TOKENFILE` adds the device with the esphome config flow and turns on `allow_service_calls` in the options
flow; without it the device's action calls are refused.
- `drive.py TOKENFILE NAME` presses the buttons through the REST API and prints the script and flag states after each
step.
Notes:
- homeassistant import entities default to `internal: true`, so the mirrors are not Home Assistant entities; read
them from the device's `Got state` log lines.
- Turning a script off stops it mid run (the flag stays on). A script that finishes at once reports on, then off, in
the same second.
@@ -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
@@ -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.
@@ -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.
@@ -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).
@@ -16,3 +16,18 @@ states, saves the device log). Before 8cc8df4a1f a modem only config with `api:`
config dependent `AUTO_LOAD` added `network` after the dependency check; an explicit `network:` block was the
workaround. Results: https://github.com/esphome/esphome/pull/6721#issuecomment-6347395159 and
https://github.com/esphome/esphome/pull/6721#issuecomment-6348520416
## SIM7080G on an M5Stack Atom Lite (#6721)
The M5 Unit CatM (SIM7080G) on the Atom Lite's Grove port: RX GPIO32, TX GPIO26, `model: SIM7080`.
`templates/modem/modem-passthrough.yaml` is a raw UART passthrough with both directions logged, and
`scripts/modem/mdrive.py HOST LOGFILE text:AT+CPIN? wait:5 ...` sends AT commands through it and saves the
replies, to check what the module does with no component in the way.
- With no SIM, the component sets the `SIM not ready` warning and polls `AT+CPIN?` once a second.
- With a SIM that has no data plan, `AT+CGATT=1` blocks the AT channel for about 141 s and then answers `ERROR`.
The module answers nothing meanwhile, so the component logs `Network attachment request refused` and then `Modem not
responding`, and loops every 2 minutes 22 seconds.
- `AT+CFUN=1,1` leaves the module silent for about 2.5 minutes. It comes back without a power cycle.
- A module left in CMUX (`AT+CMUX=0`, then an ESP restart) recovered 10 of 10 times (`Autodetect mode: 5`).
- PPP and a warm reboot during PPP need a SIM with a working data plan.
@@ -101,3 +101,14 @@ once on a build without TCP, or `tcp ok udp ok` twice and no error with it. Add
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
## sdk-nrf 3.4.1 on the ItsyBitsy (#22233, #22558)
- Set `framework: version: 3.4.1` and `board: adafruit_itsybitsy`.
- Trees before #22233 include `<nrf.h>` in `logger_zephyr.cpp`, which 3.4.1 no longer has on the include path, so
Adafruit bootloader boards fail to build (`nrf.h: No such file or directory`). To test such a tree on 3.4.1, change
that include to `<nrfx.h>` locally.
- On the new USB stack, `usb_cdc_acm` bulk echo ran at about half the legacy rate (67 against 121 KiB/s for 64 KiB),
and `flush()` returned `3`. The logger, DTR, line coding and the 1200 baud touch worked.
- The zephyr_ble_server flow above, on 3.4.1: before #22558 advertising failed to restart after the first disconnect
(`Advertising failed to start (rc -12)`); with it the server took 39 connections in 120 s.
@@ -0,0 +1,32 @@
# one_wire
## ESP8266 interrupt locks under Wi-Fi load (#22452, #22453)
Code that holds interrupts off for long on the ESP8266 starves the Wi-Fi interrupt handler (reset signatures in
`reference/boards.md`). `scripts/esp8266_lock_load/` is the shared harness for any component that takes such locks:
- `base.yaml` is the shared DUT base: reset reason and info logged at boot, `power_save_mode: none`, `web_server` for
the HTTP load, `api: reboot_timeout: 0s`.
- `one-wire.yaml` reads a DS18B20 on GPIO4 every 2 s and runs a bus search every `search_interval`; `busy_control:
"true"` replaces the search with the same busy time (13 ms) and no 1-Wire traffic.
- `ir-one-pin.yaml` is the remote_transmitter case (see [remote_base.md](remote_base.md)).
- `wifi_load.py HOST SECONDS PPS` sends 512 byte UDP datagrams to port 9 at a fixed rate plus back to back GETs of
the web_server page.
- `run.sh` builds and flashes a DUT, captures the serial log, runs the load and prints every reset reason (usage in its
header). A clean run has one BOOT line.
Make one file per build with `substitutions: {name: ...}` and `packages: {t: !include one-wire.yaml}`, with
`secrets.yaml` next to it, and give dev and the PR different names.
Notes:
- Run each build for 45 minutes at 500 packets per second. Shorter or lighter runs often show no reset on either
build: one search every 5 s survived on both.
- The busy-wait control separates the lock from the CPU time. On #22452 it had no Hardware Watchdog in 45 minutes,
while the PR still had 2 (dev had 6), so the remaining resets came from the slot locks. All three builds had one
Software Watchdog, which is the busy time plus the load.
- A search with no sensor answering is much shorter than one with a sensor (about 190 slot locks per search with
one DS18B20), so test with a real sensor on the bus.
- A stale `secrets.yaml` gives `No networks found` and the run has no IP to load.
- The 1-Wire bus code is shared by every platform, so check a lock change on an ESP32 too: the same DS18B20 search
config on ESP-IDF with `bluetooth_proxy` scanning and the same load gave 0 resets and no missed searches on dev and
on #22452.
@@ -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
@@ -84,3 +83,24 @@ case's fires in order with progress collapsed to a count; diff dev against the P
- 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.
@@ -12,7 +12,8 @@ markers and prints what each sent frame decoded as.
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
where no primary matched.
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
- ESP8266 before #22453: the transmitter sends the whole frame with interrupts off, so a receiver on the same pin
sees nothing (from #22453 on it decodes, see below). For older trees, `softtx.hpp`
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
@@ -28,3 +29,18 @@ Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
only the last line sees "Failed to upload" first, so rerun before blaming the build.
## ESP8266 transmitter to receiver on the same board (#22453)
- Wire the `remote_transmitter` pin to a `remote_receiver` pin (GPIO12 to GPIO14 on a D1 mini), `inverted: false`.
- Keep the receiver's default `idle`. `idle: 4ms` is shorter than the NEC leader space (4.5 ms), so every frame is
split and nothing decodes.
- Dev before #22453 sends the whole frame inside one `InterruptLock`, so a receiver on the same chip decodes 0
frames. That is expected on old builds, not a wiring fault. With locks per edge, 3,692 of 3,695 frames decoded
under Wi-Fi load.
- Count with `on_nec` matching a counter in the command, and log the counts on an `interval`.
- Without a jumper, put both on GPIO12 with `allow_other_uses: true` and call `pinMode(12, OUTPUT)` before each frame
(the receiver's setup leaves the shared pin as an input); the receiver's edge interrupt sees the transmitter's own
output. `scripts/esp8266_lock_load/ir-one-pin.yaml` does this, and its `run.sh` adds Wi-Fi load (see
[one_wire.md](one_wire.md)). Under load, the build that sends the whole frame in one lock hit a Hardware Watchdog in
`wDev_ProcessFiq` after 11 minutes.
@@ -21,3 +21,9 @@ captures 40 s from boot and runs `ivsum.py`, which prints the run count, first r
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
## Freelist trim after boot (ratgdo/esphome-ratgdo#705)
10 s after setup the scheduler frees its item pool and shrinks its `to_add_` and `items_` vectors
(`SCHEDULER_FREELIST_TRIM_DELAY_MS` in `application.cpp`). The first burst of timers after that allocates new
items and regrows the vectors once, so heap or timing measurements should skip the first run after boot.
@@ -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.
@@ -24,3 +24,8 @@ identical payloads share one table, a lambda is sent from its vector. An empty c
(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old
`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own
head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263
## Two sockets on one port (#22509)
lwIP lets two sockets bind one UDP port when `SO_REUSEADDR` is set, and unicast then goes to the socket opened
last; without it the second bind fails with errno 112.
@@ -93,3 +93,27 @@ connected channel starts and ends on the bench channel with another channel in b
- 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.
## Soft AP with the BLE tracker scanning (#22534, #22566)
`scripts/ble_scan_soft_ap/`: `dut.yaml` runs only `wifi: ap:` with `captive_portal:` and `bluetooth_proxy:`, and
`client.yaml` (on a second ESP32) joins that AP and fetches the portal page every 5 s, logging `JOIN` and `STAT`
lines. `run.sh` erases and flashes the DUT, resets the client and prints the generated scan window, the first join, the
page counts and the client's disconnect reasons (usage in its header).
- A scan window equal to the scan interval (320 ms of 320 ms) leaves the radio no time for the soft AP: on dev no
client joined on the classic ESP32, S3, C3 or C6. With #22566 the tracker uses its 30 ms connection window while
the AP runs, and every chip took a client in about 4 s.
- Only one AP with the test SSID may be up at a time; list the other AP boards in `PARK` so they wait in their ROM
bootloader.
- To check that the full window comes back after provisioning, log `esp32_ble_tracker` at VERBOSE and provision
with `templates/client-wifisave.yaml` (set `ap_ssid` to the DUT's and `ap_password: ""`, the DUT's AP is open): `Connection or AP active, using 48 unit scan window` must stop once the AP stops.
- Some clients do not join an ESP8266 AP at all (the C6 failed on dev too with `Too Many Associations`); use a C3.
## ESP8266 power save after captive portal provisioning (#22542, #22564)
The DUT runs `ap:` plus `captive_portal:` with no station network and logs `wifi_get_sleep_type()` every 5 s (0 is
`NONE_SLEEP_T`, 2 is `MODEM_SLEEP_T`); an ESP32-C3 with `templates/client-wifisave.yaml` posts the real credentials.
Erase the flash before each run so no saved credentials skip the portal. On dev the sleep type stayed 2 after
provisioning although the config asked for none; with #22564 it read 0 from the moment the AP started, and the
`networks:` path still read 0.
@@ -0,0 +1,9 @@
# wled
## Realtime sender on the bench (#22509)
When the access point stops passing traffic between clients (lwIP `sendto` on the sender fails with errno 12
because ARP never resolves), run the sender on a board instead of the host: an ESP32-S3 runs `wifi: ap:` and a
25 ms interval lambda sends WARLS, DRGB or DNRGB frames with lwIP `sendto` to the devices under test, which join
that AP with `manual_ip`. Two lights on the same port: see `udp.md`. Heap after an effect stops: see
`heap_alloc.md`.
@@ -0,0 +1,31 @@
#pragma once
// Test only: swaps the first two records of an ATT Read By Type Response whose first record declares characteristic
// 0xFFF1, the way some peripherals answer out of handle order (esphome#22518). Count of swaps in attswap_count.
#include <cstdint>
#include <cstring>
#include "esphome/core/log.h"
struct AttSwapHdr {
uint16_t event, len, offset, layer_specific;
};
extern "C" void __real_l2c_rcv_acl_data(void *p_msg);
static volatile uint32_t attswap_count = 0;
extern "C" void __wrap_l2c_rcv_acl_data(void *p_msg) {
auto *h = static_cast<AttSwapHdr *>(p_msg);
uint8_t *p = reinterpret_cast<uint8_t *>(h + 1) + h->offset;
// HCI ACL header (4), L2CAP header (4), then ATT: opcode, record length, records
if (h->len >= 4 + 4 + 2 + 14) {
uint16_t cid = p[6] | (p[7] << 8);
uint8_t *att = p + 8;
// record: handle(2) properties(1) value handle(2) uuid16(2)
if (cid == 0x0004 && att[0] == 0x09 && att[1] == 7 && att[2 + 5] == 0xF1 && att[2 + 6] == 0xFF) {
uint8_t tmp[7];
memcpy(tmp, att + 2, 7);
memcpy(att + 2, att + 9, 7);
memcpy(att + 9, tmp, 7);
attswap_count = attswap_count + 1;
}
}
__real_l2c_rcv_acl_data(p_msg);
}
@@ -0,0 +1,11 @@
"""Test only: wrap Bluedroid's l2c_rcv_acl_data so attswap.h can reorder a Read By Type response."""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.types import ConfigType
CONFIG_SCHEMA = cv.Schema({})
async def to_code(config: ConfigType) -> None:
cg.add_build_flag("-Wl,--wrap=l2c_rcv_acl_data")
@@ -0,0 +1,5 @@
substitutions:
name: attprx6
idf: 5.5.6
packages:
c: !include proxy.yaml
@@ -0,0 +1,125 @@
"""Through the proxy (no cache): print the full GATT table, read FFF1/FFF2/FFF3 by handle, and enable notify on
FFF1, FFF2 and 2ACD (writing the CCCD the way Home Assistant does), counting what arrives per handle for 6 s.
usage: gattcheck.py PROXY PEER_MAC env: API_KEY (the proxy's api encryption key)
Exits 1 when a characteristic or CCCD is missing, a read or notification carries another characteristic's value,
a notify fails, or a notifying characteristic sends nothing."""
import asyncio
import os
import sys
from aioesphomeapi import APIClient
SFX = "-0000-1000-8000-00805f9b34fb"
def short(u: str) -> str:
return u[4:8] if u.endswith(SFX) else u
async def main(host: str, peer: str) -> None:
addr = int(peer.replace(":", ""), 16)
cli = APIClient(host, 6053, None, noise_psk=os.environ["API_KEY"])
await cli.connect(login=True)
seen = asyncio.Event()
cli.subscribe_bluetooth_le_raw_advertisements(
lambda m: (
seen.set() if any(a.address == addr for a in m.advertisements) else None
)
)
await asyncio.wait_for(seen.wait(), 30)
state: asyncio.Queue = asyncio.Queue()
cancel = await cli.bluetooth_device_connect(
addr,
lambda c, mtu, err: state.put_nowait((c, mtu, err)),
timeout=20,
address_type=0,
feature_flags=0xFF,
has_cache=False,
)
c, mtu, err = await asyncio.wait_for(state.get(), 25)
if not c:
sys.exit(f"connect failed err={err}")
svcs = await cli.bluetooth_gatt_get_services(addr)
chars = {}
for s in svcs.services:
print(f"SVC {short(s.uuid)} 0x{s.handle:04X}", flush=True)
for ch in s.characteristics:
chars[short(ch.uuid)] = ch
print(
f" CHR {short(ch.uuid)} 0x{ch.handle:04X} props=0x{ch.properties:02X}",
flush=True,
)
for d in ch.descriptors:
print(f" DSC {short(d.uuid)} 0x{d.handle:04X}", flush=True)
wanted = ("fff1", "fff2", "fff3", "2acc", "2acd")
problems = [f"{u} missing" for u in wanted if u not in chars]
for u in ("fff1", "fff2", "fff3", "2acc"):
if u in chars:
v = bytes(await cli.bluetooth_gatt_read(addr, chars[u].handle))
text = v.decode(errors="replace")
print(f"READ {u} 0x{chars[u].handle:04X} -> {text}", flush=True)
# every server value starts with its own UUID, so a mixed-up handle shows here
if not text.startswith(f"{u}-"):
problems.append(f"{u} read returned {text!r}")
got: dict[int, list[str]] = {}
def on_notify(handle: int, data: bytearray) -> None:
got.setdefault(handle, []).append(bytes(data).decode(errors="replace"))
for u in ("fff1", "fff2", "2acd"):
if u in chars:
try:
await cli.bluetooth_gatt_start_notify(addr, chars[u].handle, on_notify)
# like Home Assistant's client: write the first CCCD listed under the characteristic
cccd = next(
(d for d in chars[u].descriptors if short(d.uuid) == "2902"), None
)
if cccd is None:
print(
f"NOTIFY {u} 0x{chars[u].handle:04X} NO CCCD in its descriptor list",
flush=True,
)
problems.append(f"{u} has no CCCD")
continue
await cli.bluetooth_gatt_write_descriptor(
addr, cccd.handle, b"\x01\x00"
)
print(
f"NOTIFY {u} 0x{chars[u].handle:04X} enabled, CCCD 0x{cccd.handle:04X} written",
flush=True,
)
except Exception as exc: # noqa: BLE001
print(
f"NOTIFY {u} 0x{chars[u].handle:04X} FAILED {type(exc).__name__}: {exc}",
flush=True,
)
problems.append(f"{u} notify failed")
await asyncio.sleep(6)
if not got:
print("GOT nothing", flush=True)
for h, vals in sorted(got.items()):
name = next((u for u, ch in chars.items() if ch.handle == h), "?")
print(
f"GOT 0x{h:04X} ({name}) x{len(vals)}: {vals[0]} .. {vals[-1]}", flush=True
)
problems.extend(
f"0x{h:04X} ({name}) notified {v!r}"
for v in vals
if name == "?" or not v.startswith(f"{name}-")
)
# the server notifies FFF1 and 2ACD every second
problems.extend(
f"{u} sent nothing"
for u in ("fff1", "2acd")
if u in chars and chars[u].handle not in got
)
await cli.bluetooth_device_disconnect(addr)
cancel()
await cli.disconnect()
if problems:
sys.exit("FAIL: " + ", ".join(problems))
print("PASS", flush=True)
asyncio.run(main(sys.argv[1], sys.argv[2]))
@@ -0,0 +1,56 @@
"""Connect to PEER_MAC through the proxy like Home Assistant (v3 without cache) and print the services it returns.
usage: gattget.py PROXY PEER_MAC ROUNDS env: API_KEY (the proxy's api encryption key)"""
import asyncio
import os
import sys
from aioesphomeapi import APIClient
async def main(host: str, peer: str, rounds: int) -> None:
addr = int(peer.replace(":", ""), 16)
cli = APIClient(host, 6053, None, noise_psk=os.environ["API_KEY"])
await cli.connect(login=True)
seen = asyncio.Event()
def adv(msg) -> None:
if any(a.address == addr for a in msg.advertisements):
seen.set()
cli.subscribe_bluetooth_le_raw_advertisements(adv)
await asyncio.wait_for(seen.wait(), 30)
failed = 0
for r in range(1, rounds + 1):
state: asyncio.Queue = asyncio.Queue()
cancel = await cli.bluetooth_device_connect(
addr,
lambda c, mtu, err, q=state: q.put_nowait((c, mtu, err)),
timeout=20,
address_type=0,
feature_flags=0xFF,
has_cache=False,
)
c, mtu, err = await asyncio.wait_for(state.get(), 25)
if not c:
print(f"R{r} connect failed err={err}", flush=True)
failed += 1
cancel()
await asyncio.sleep(5)
continue
svcs = await cli.bluetooth_gatt_get_services(addr)
sfx = "-0000-1000-8000-00805f9b34fb"
out = [
f"{s.uuid[4:8] if s.uuid.endswith(sfx) else s.uuid}({len(s.characteristics)})"
for s in svcs.services
]
print(f"R{r} mtu={mtu} services: {' '.join(out)}", flush=True)
await cli.bluetooth_device_disconnect(addr)
cancel()
await asyncio.sleep(5)
await cli.disconnect()
if failed:
sys.exit(f"{failed} of {rounds} rounds did not connect")
asyncio.run(main(sys.argv[1], sys.argv[2], int(sys.argv[3])))
@@ -0,0 +1,37 @@
# Proxy (classic ESP32): bluetooth_proxy without service cache, encrypted API, the reorder wrapper.
# Substitutions: name, idf.
# Keep the per-build file in this directory: attswap.hpp and components/ are found relative to it.
esphome:
name: ${name}
includes: [attswap.hpp]
esp32:
board: esp32dev
framework:
type: esp-idf
version: ${idf}
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
# a plaintext proxy can be taken over by any Home Assistant on the network mid-test
encryption:
key: !secret api_key
ota:
- platform: esphome
external_components:
- source: {type: local, path: components}
components: [attswap]
attswap:
esp32_ble_tracker:
scan_parameters:
active: true
bluetooth_proxy:
active: true
cache_services: false
interval:
- interval: 5s
then:
- lambda: 'ESP_LOGI("swap", "attswap swaps=%u", (unsigned) attswap_count);'
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# run.sh PROXY_YAML [ROUNDS]: build server.yaml and PROXY_YAML from TREE, flash both, capture the proxy and read the
# server's services through it ROUNDS times (default 3). Prints the services per round and the Bluedroid discovery
# errors. Exit 0: every round got all services; 1: a round got a partial table (the bug); 2: setup failed, a round
# did not connect, or the swap never fired (the result would be meaningless). PROXY_YAML must sit in this
# directory, next to attswap.hpp and components/.
# env: TREE, SERVER_YAML (default server.yaml), API_KEY (proxy api key), SERVER_PORT, PROXY_PORT, SERVER_BLE_MAC
# (the server's BLE address), SC (path to serial_capture.py), PY
D=$(cd "$(dirname "$0")" && pwd); Y=$(cd "$(dirname "${1:?proxy yaml}")" && pwd)/$(basename "$1"); R=${2:-3}
W=${TREE:?set TREE}; SP=${SERVER_PORT:?set SERVER_PORT}; PP=${PROXY_PORT:?set PROXY_PORT}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}
API_KEY=${API_KEY:?set API_KEY}; SC=${SC:?set SC}; PY=${PY:-$W/venv/bin/python}; N=$(basename "$Y" .yaml); O=$D/out-$N; mkdir -p "$O"
(cd "$W" && PYTHONPATH=$W $PY -m esphome -s name attsrv run --no-logs "$D/${SERVER_YAML:-server.yaml}" --device "$SP") > "$O/server.log" 2>&1 ||
{ echo "server build or upload failed"; exit 2; }
(cd "$W" && PYTHONPATH=$W $PY -m esphome run --no-logs "$Y" --device "$PP") > "$O/proxy-upload.log" 2>&1 ||
{ echo "$N build or upload failed"; exit 2; }
rm -f "$O/proxy.txt"
$PY "$SC" "$PP" $((R * 30 + 160)) "$O/proxy.txt" --reset > "$O/capture.log" 2>&1 &
CAP=$!
IP=""
for _ in $(seq 40); do
IP=$(LC_ALL=C grep -a -o -E "IP Address: [0-9.]+" "$O/proxy.txt" | tail -1 | cut -d' ' -f3)
[ -n "$IP" ] && break; sleep 2
done
[ -n "$IP" ] || { kill "$CAP"; echo "$N no IP"; exit 2; }
sleep 5
timeout $((R * 30 + 60)) $PY "$D/gattget.py" "$IP" "$MAC" "$R" | tee "$O/client.txt"
[ "${PIPESTATUS[0]}" = 0 ] || { kill "$CAP"; echo "$N client failed"; exit 2; }
wait "$CAP" || { echo "$N capture failed (see $O/capture.log)"; exit 2; }
echo "== $N"
LC_ALL=C grep -a -E "invalid handle|Service discovery complete|attswap swaps=[1-9]" "$O/proxy.txt" | sed 's/^\[[0-9:]*\]//' |
sort | uniq -c
grep -a -q -E "attswap swaps=[1-9]" "$O/proxy.txt" || { echo "$N INVALID: the reorder never fired"; exit 2; }
# every round must list the reordered service (fff0) and the one after it (1826) with all their characteristics
grep -a -q "^R[0-9]* mtu=" "$O/client.txt" || { echo "$N no client output"; exit 2; }
grep -a "^R[0-9]* mtu=" "$O/client.txt" | grep -v -q "fff0(6) 1826(2)" && exit 1
exit 0
@@ -0,0 +1,45 @@
# Peripheral (ESP32-S3) for gattcheck.py: FFF1 to FFF6 each hold their own value, FFF1, FFF2 and 2ACD can notify,
# and FFF1 and 2ACD send a changing value every second. Substitution: name.
esphome:
name: ${name}
esp32:
variant: esp32s3
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
esp32_ble:
name: ${name}
esp32_ble_server:
manufacturer: ESPHome
model: attcheck
services:
- uuid: FFF0
advertise: true
characteristics:
- {id: c1, uuid: FFF1, read: true, notify: true, value: "fff1-v"}
- {uuid: FFF2, read: true, notify: true, value: "fff2-v"}
- {uuid: FFF3, read: true, value: "fff3-v"}
- {uuid: FFF4, read: true, value: "fff4-v"}
- {uuid: FFF5, read: true, value: "fff5-v"}
- {uuid: FFF6, read: true, value: "fff6-v"}
- uuid: "1826"
characteristics:
- {uuid: 2ACC, read: true, value: "2acc-v"}
- {id: cd, uuid: 2ACD, read: true, notify: true, value: "2acd-0"}
on_connect:
- logger.log: {format: "SRV connected", level: INFO}
globals:
- {id: n, type: int, initial_value: "0"}
interval:
- interval: 1s
then:
- lambda: id(n)++;
- ble_server.characteristic.set_value:
id: cd
value: !lambda 'std::string v = "2acd-" + to_string(id(n)); return std::vector<uint8_t>(v.begin(), v.end());'
- ble_server.characteristic.notify: {id: cd}
- ble_server.characteristic.set_value:
id: c1
value: !lambda 'std::string v = "fff1-n" + to_string(id(n)); return std::vector<uint8_t>(v.begin(), v.end());'
- ble_server.characteristic.notify: {id: c1}
@@ -0,0 +1,32 @@
# Peripheral (ESP32-S3): a 6-characteristic service 0xFFF0, then an FTMS-like 0x1826 service. Substitution: name.
esphome:
name: ${name}
esp32:
variant: esp32s3
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
esp32_ble:
name: ${name}
esp32_ble_server:
manufacturer: ESPHome
model: g22518
services:
- uuid: FFF0
advertise: true
characteristics:
- {uuid: FFF1, read: true, value: [1]}
- {uuid: FFF2, read: true, value: [2]}
- {uuid: FFF3, read: true, value: [3]}
- {uuid: FFF4, read: true, value: [4]}
- {uuid: FFF5, read: true, value: [5]}
- {uuid: FFF6, read: true, value: [6]}
- uuid: "1826"
characteristics:
- {uuid: 2ACC, read: true, value: [0, 0, 0, 0]}
- {uuid: 2ACD, read: true, notify: true, value: [0, 0]}
on_connect:
- logger.log: {format: "SRV connected", level: INFO}
on_disconnect:
- logger.log: {format: "SRV disconnected", level: INFO}
@@ -0,0 +1,48 @@
# Joins the DUT's open AP, fetches the portal page every 5 s and logs JOIN and STAT lines. Substitutions: name, board,
# logger_uart, ap_ssid (same as dut.yaml).
substitutions:
board: esp32-c3-devkitm-1
logger_uart: USB_SERIAL_JTAG
ap_ssid: bleaptest
esphome:
name: ${name}
esp32:
board: ${board}
framework:
type: esp-idf
logger:
hardware_uart: ${logger_uart}
wifi:
ssid: ${ap_ssid}
reboot_timeout: 0s
on_connect:
- lambda: |-
id(joins)++;
ESP_LOGI("cli", "JOIN %d at %u ms", id(joins), (unsigned) millis());
on_disconnect:
- lambda: ESP_LOGI("cli", "LEFT at %u ms", (unsigned) millis());
http_request:
timeout: 5s
globals:
- {id: joins, type: int, initial_value: "0"}
- {id: pages_ok, type: int, initial_value: "0"}
- {id: pages_fail, type: int, initial_value: "0"}
interval:
- interval: 5s
then:
- if:
condition: wifi.connected
then:
- http_request.get:
url: http://192.168.4.1/
on_response:
then:
- lambda: if (response->status_code == 200) id(pages_ok)++; else id(pages_fail)++;
on_error:
then:
- lambda: id(pages_fail)++;
- interval: 15s
then:
- lambda: |-
ESP_LOGI("cli", "STAT up=%u joins=%d connected=%d pages_ok=%d pages_fail=%d", (unsigned) (millis() / 1000),
id(joins), (int) wifi::global_wifi_component->is_connected(), id(pages_ok), id(pages_fail));
@@ -0,0 +1,23 @@
# Soft AP only (no station network) with captive_portal and bluetooth_proxy, as in #22534. Substitutions: name,
# board, logger_uart (USB_SERIAL_JTAG on native USB chips, UART0 on a classic devkit), ap_ssid.
substitutions:
board: esp32dev
logger_uart: UART0
ap_ssid: bleaptest
esphome:
name: ${name}
esp32:
board: ${board}
framework:
type: esp-idf
logger:
hardware_uart: ${logger_uart}
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ap:
ssid: ${ap_ssid}
captive_portal:
bluetooth_proxy:
@@ -0,0 +1,29 @@
#!/bin/bash
# run.sh DUT_YAML SECONDS: erase and flash DUT_YAML from TREE, let it boot, then reset the client (already flashed with
# client.yaml) and capture both for SECONDS. Prints the generated scan window, the client's first JOIN and last STAT
# lines, its disconnect reasons and the DUT's resets. One AP with this SSID at a time: ports in PARK are held in their
# ROM bootloader first. The yaml file name must equal its `name:` (the build directory is found by it).
# Exit 0: pass; 1: the run finished and failed the checks; 2: setup or capture failed.
# env: TREE, DUT_PORT, CLIENT_PORT, PARK (space separated ports, optional), SC (path to serial_capture.py), PY
Y=$(cd "$(dirname "${1:?dut yaml}")" && pwd)/$(basename "$1"); T=${2:?seconds}; N=$(basename "$Y" .yaml)
W=${TREE:?set TREE}; DUT=${DUT_PORT:?set DUT_PORT}; CLI=${CLIENT_PORT:?set CLIENT_PORT}; SC=${SC:?set SC}
PY=${PY:-$W/venv/bin/python}; O=$(dirname "$Y")/out-$N; mkdir -p "$O"
for p in $PARK; do $PY -m esptool --port "$p" --after no-reset chip-id > /dev/null 2>&1 || { echo "park $p failed"; exit 2; }; done
(cd "$W" && PYTHONPATH=$W $PY -m esphome compile "$Y") > "$O/compile.log" 2>&1 || { echo "$N compile failed"; exit 2; }
$PY -m esptool --port "$DUT" erase_flash > "$O/erase.log" 2>&1 || { echo "$N erase failed"; exit 2; }
(cd "$W" && PYTHONPATH=$W $PY -m esphome upload "$Y" --device "$DUT") > "$O/upload.log" 2>&1 || { echo "$N upload failed"; exit 2; }
$PY "$SC" "$DUT" $((T + 40)) "$O/dut.txt" --reset > "$O/dut-capture.log" 2>&1 &
CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
sleep 30
$PY "$SC" "$CLI" "$T" "$O/cli.txt" --reset > "$O/cli-capture.log" 2>&1 || { echo "$N client capture failed"; exit 2; }
wait "$CAP" || { echo "$N DUT capture failed"; exit 2; }
B=$(dirname "$Y")/.esphome/build/$N/src/main.cpp
[ -f "$B" ] || { echo "$N: $B not found (file name differs from name:?)"; exit 2; }
echo "== $N window=$(grep -o 'set_scan_window([0-9]*)' "$B" || echo none)"
J=$(grep -a -m1 "JOIN 1" "$O/cli.txt" | cut -c1-100); echo "${J:-$N NO JOIN}"
STAT=$(grep -a "STAT" "$O/cli.txt" | tail -1 | cut -c1-140); echo "$STAT"
echo "client disconnect reasons: $(grep -a -o "reason='[^']*'" "$O/cli.txt" | sort | uniq -c | tr '\n' ' ')"
echo "dut resets: $(grep -a -c 'rst:' "$O/dut.txt")"
# pass: the client joined and fetched pages, and the DUT reset exactly once (the capture's own reset)
[ -n "$J" ] && [ -n "$STAT" ] && [[ "$STAT" != *"pages_ok=0 "* ]] && [ "$(grep -a -c 'rst:' "$O/dut.txt")" = 1 ]
@@ -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, &reg, 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, &reg, 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, &reg, 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, &reg, 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
View File
@@ -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,170 @@
# CC1101 sleep and wake checks T0 to T6 on an ESP32-S3 (SPI on GPIO12/11/13, CS GPIO10, GDO0 GPIO9), run 4 s after
# boot; the result is the "DONE fails=N" line. Substitution: name.
esphome:
name: ${name}
includes: [cctest.hpp]
on_boot:
- priority: -100
then:
- delay: 4s
- script.execute: run_tests
esp32:
variant: esp32s3
framework:
type: esp-idf
logger:
level: DEBUG
hardware_uart: USB_SERIAL_JTAG
spi:
clk_pin: GPIO12
mosi_pin: GPIO11
miso_pin: GPIO13
cc1101:
id: transceiver
cs_pin: GPIO10
gdo0_pin: GPIO9
frequency: 433.92MHz
modulation_type: GFSK
symbol_rate: 4800
packet_mode: true
packet_length: 8
crc_enable: true
sync_mode: "16/16"
output_power: 0
on_packet:
then:
- lambda: ESP_LOGI("cct", "RX packet %s rssi %.1f", format_hex(x).c_str(), rssi);
script:
- id: run_tests
then:
- lambda: |-
using namespace cct;
auto *c = id(transceiver);
Snap base, s;
int fails = 0;
snap(c, base);
ESP_LOGI("cct", "T0 baseline MARCSTATE=%02X FREQ=%02X%02X%02X TEST2=%02X TEST1=%02X TEST0=%02X so_high_awake=%d",
base.marc, base.reg[0x0D], base.reg[0x0E], base.reg[0x0F], base.reg[0x2C], base.reg[0x2D],
base.reg[0x2E], cs_low_so_high(c));
hexpa("T0 baseline", base.pa);
// the sleep checks below need a probe that sees an awake chip as awake
if (cs_low_so_high(c)) {
ESP_LOGW("cct", "T0 FAIL: SO reads high on an awake chip, the sleep checks cannot be trusted");
fails++;
}
// T1: mark PATABLE 1-7, sleep, wake by hand: lost entries prove the chip slept
const uint8_t marks[8] = {base.pa[0], 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77};
write_pa(c, marks);
snap(c, s); hexpa("T1 marked", s.pa);
c->power_down();
bool so = cs_low_so_high(c);
snap(c, s);
hexpa("T1 after raw wake", s.pa);
bool lost = s.pa[1] != 0x11;
ESP_LOGI("cct", "T1 sleep proof: so_high_on_cs=%d pa_lost=%d MARCSTATE=%02X TEST0=%02X -> %s", so, lost, s.marc,
s.reg[0x2E], (so && lost) ? "PASS" : "FAIL");
if (!(so && lost)) fails++;
// the component still thinks it sleeps
c->wake_up();
snap(c, s);
int d = diff(base, s, "T1 restore");
ESP_LOGI("cct", "T1 restore after wake_up: %d differences, MARCSTATE=%02X -> %s", d, s.marc, d == 0 ? "PASS" : "FAIL");
if (d) fails++;
c->begin_rx();
// T2: change settings while asleep, compare with the same change made awake
Snap awake;
c->set_frequency(433.42e6); c->set_output_power(5); c->set_symbol_rate(9600);
snap(c, awake);
c->set_frequency(433.92e6); c->set_output_power(0); c->set_symbol_rate(4800);
c->power_down();
c->set_frequency(433.42e6); c->set_output_power(5); c->set_symbol_rate(9600);
bool so2 = cs_low_so_high(c); // wakes the chip by hand; component still asleep
c->wake_up();
c->begin_rx();
snap(c, s);
awake.marc = s.marc;
d = diff(awake, s, "T2 asleep vs awake");
ESP_LOGI("cct", "T2 settings changed asleep: so_high=%d %d differences FREQ=%02X%02X%02X MARCSTATE=%02X -> %s", so2, d,
s.reg[0x0D], s.reg[0x0E], s.reg[0x0F], s.marc, (so2 && d == 0) ? "PASS" : "FAIL");
if (!(so2 && d == 0)) fails++;
c->set_frequency(433.92e6); c->set_output_power(0); c->set_symbol_rate(4800);
c->begin_rx();
snap(c, base);
// T3: send while asleep wakes the radio by itself
c->power_down();
uint8_t pkt[8] = {1, 2, 3, 4, 5, 6, 7, 8};
int err = (int) c->transmit_packet(pkt, sizeof(pkt));
snap(c, s);
d = diff(base, s, "T3 after send");
ESP_LOGI("cct", "T3 send while asleep: err=%d MARCSTATE=%02X TXBYTES=%02X %d differences -> %s", err, s.marc,
status(c, 0x3A), d, (err == 0 && d == 0) ? "PASS" : "FAIL");
if (!(err == 0 && d == 0)) fails++;
// T4: begin_rx while asleep
c->power_down();
c->begin_rx();
snap(c, s);
d = diff(base, s, "T4 begin_rx");
ESP_LOGI("cct", "T4 begin_rx while asleep: MARCSTATE=%02X %d differences -> %s", s.marc, d,
(s.marc == 0x0D && d == 0) ? "PASS" : "FAIL");
if (!(s.marc == 0x0D && d == 0)) fails++;
// T5: 200 cycles, each one proven asleep and restored
int bad = 0, notslept = 0;
uint32_t t0 = micros();
for (int i = 0; i < 200; i++) {
write_pa(c, marks);
c->power_down();
if (!cs_low_so_high(c)) notslept++;
c->wake_up();
c->begin_rx();
snap(c, s);
if (diff(base, s, "T5") != 0) bad++;
if ((i & 31) == 0) App.feed_wdt();
}
ESP_LOGI("cct", "T5 200 cycles: not_slept=%d restore_fail=%d in %u ms heap=%u -> %s", notslept, bad,
(unsigned) ((micros() - t0) / 1000), (unsigned) esp_get_free_heap_size(),
(notslept == 0 && bad == 0) ? "PASS" : "FAIL");
if (notslept || bad) fails++;
// T6: each setter on its own while asleep: does it wake the chip, and in which state does it leave it
struct Case { const char *name; int kind; };
const Case cases[] = {{"set_frequency", 0}, {"set_channel", 1}, {"set_modulation_type", 2}, {"set_symbol_rate", 3},
{"set_output_power", 4}, {"set_filter_bandwidth", 5}, {"set_fsk_deviation", 6}};
for (auto &k : cases) {
c->begin_rx();
c->power_down();
switch (k.kind) {
case 0: c->set_frequency(433.92e6); break;
case 1: c->set_channel(0); break;
case 2: c->set_modulation_type(cc1101::Modulation::MODULATION_GFSK); break;
case 3: c->set_symbol_rate(4800); break;
case 4: c->set_output_power(0); break;
case 5: c->set_filter_bandwidth(203e3); break;
case 6: c->set_fsk_deviation(1.5e3); break;
}
bool asleep = cs_low_so_high(c);
uint8_t marc = status(c, 0x35) & 0x1F;
ESP_LOGI("cct", "T6 %-20s while asleep: chip %s, MARCSTATE=%02X", k.name, asleep ? "still asleep" : "AWAKE", marc);
if (!asleep) fails++;
c->wake_up();
}
c->begin_rx();
uint8_t agc_rx = rd(c, 0x2B);
c->set_idle();
uint8_t agc_idle = rd(c, 0x2B);
ESP_LOGI("cct", "AGCTEST in RX=%02X in IDLE=%02X", agc_rx, agc_idle);
c->begin_rx();
ESP_LOGI("cct", "DONE fails=%d", fails);
button:
- platform: template
id: yaml_actions
name: Yaml actions
on_press:
- cc1101.power_down: transceiver
- cc1101.wake_up: transceiver
- cc1101.power_down: transceiver
- cc1101.send_packet: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
- lambda: ESP_LOGI("cct", "YAML actions done MARCSTATE=%02X", cct::status(id(transceiver), 0x35) & 0x1F);
@@ -0,0 +1,70 @@
// Register and PATABLE snapshots plus the CHIP_RDYn sleep check for the cc1101 sleep tests in cc1101-sleep.yaml.
#pragma once
#include "esphome/components/cc1101/cc1101.h"
#include "driver/gpio.h"
#include <cstring>
namespace cct {
using esphome::cc1101::CC1101Component;
static constexpr gpio_num_t MISO = GPIO_NUM_13; // the SPI MISO pin in cc1101-sleep.yaml
static constexpr uint8_t NREG = 0x2F; // IOCFG2..TEST0
struct Snap {
uint8_t reg[NREG];
uint8_t pa[8];
uint8_t marc;
};
// Chip ready flag: with CSn low, SO stays high until the crystal runs (SLEEP), low at once otherwise
inline bool cs_low_so_high(CC1101Component *c) {
c->enable();
bool high = gpio_get_level(MISO);
delayMicroseconds(300);
c->disable();
return high;
}
inline uint8_t rd(CC1101Component *c, uint8_t a) {
c->enable();
c->write_byte(a | 0x80);
uint8_t v = c->read_byte();
c->disable();
return v;
}
inline uint8_t status(CC1101Component *c, uint8_t a) { return rd(c, a | 0x40); }
inline void snap(CC1101Component *c, Snap &s) {
for (uint8_t i = 0; i < NREG; i++)
s.reg[i] = rd(c, i);
c->enable();
c->write_byte(0x3E | 0xC0);
for (auto &b : s.pa)
b = c->read_byte();
c->disable();
s.marc = status(c, 0x35) & 0x1F;
}
inline void write_pa(CC1101Component *c, const uint8_t *pa) {
c->enable();
c->write_byte(0x3E | 0x40);
for (int i = 0; i < 8; i++)
c->write_byte(pa[i]);
c->disable();
}
inline int diff(const Snap &a, const Snap &b, const char *what) {
int n = 0;
for (uint8_t i = 0; i < NREG; i++)
// FSCAL3..FSCAL1 hold calibration results, FSTEST and AGCTEST are chip owned test registers
if (i != 0x23 && i != 0x24 && i != 0x25 && i != 0x29 && i != 0x2B && a.reg[i] != b.reg[i]) {
ESP_LOGW("cct", "%s reg 0x%02X: %02X vs %02X", what, i, a.reg[i], b.reg[i]);
n++;
}
for (int i = 0; i < 8; i++)
if (a.pa[i] != b.pa[i]) {
ESP_LOGW("cct", "%s pa[%d]: %02X vs %02X", what, i, a.pa[i], b.pa[i]);
n++;
}
return n;
}
inline void hexpa(const char *tag, const uint8_t *pa) {
ESP_LOGI("cct", "%s PA %02X %02X %02X %02X %02X %02X %02X %02X", tag, pa[0], pa[1], pa[2], pa[3], pa[4], pa[5], pa[6],
pa[7]);
}
} // namespace cct
@@ -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,20 @@
# Shared ESP8266 base for the lock load DUTs: reset reason at boot, no Wi-Fi sleep, web_server for the HTTP load.
esphome:
name: ${name}
on_boot:
- priority: 800
then:
- lambda: ESP_LOGI("lock", "BOOT reason=%s info=%s", ESP.getResetReason().c_str(), ESP.getResetInfo().c_str());
esp8266:
board: ${board}
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
@@ -0,0 +1,39 @@
# remote_transmitter and remote_receiver on the same pin (GPIO12), no jumper: one NEC frame every 250 ms, each one
# checked by the receiver's edge interrupt. Substitutions: name, board.
substitutions:
board: d1_mini
packages:
base: !include base.yaml
globals:
- {id: sent, type: uint32_t, initial_value: "0"}
- {id: rx_ok, type: uint32_t, initial_value: "0"}
- {id: rx_bad, type: uint32_t, initial_value: "0"}
remote_transmitter:
id: irtx
pin:
number: GPIO12
allow_other_uses: true
carrier_duty_percent: 100%
remote_receiver:
pin:
number: GPIO12
allow_other_uses: true
on_nec:
then:
- lambda: |-
if (x.address == 0x1234 && x.command == (uint16_t) (0x7800 | (id(sent) & 0xFF))) id(rx_ok)++; else id(rx_bad)++;
interval:
- interval: 250ms
then:
# The receiver's setup leaves the shared pin as an input; drive it again before each frame
- lambda: |-
pinMode(12, OUTPUT);
id(sent)++;
- remote_transmitter.transmit_nec:
address: 0x1234
command: !lambda return (uint16_t) (0x7800 | (id(sent) & 0xFF));
- interval: 10s
then:
- lambda: |-
ESP_LOGI("lock", "ALIVE up=%u sent=%u rx_ok=%u rx_bad=%u heap=%u", (unsigned) (millis() / 1000),
(unsigned) id(sent), (unsigned) id(rx_ok), (unsigned) id(rx_bad), (unsigned) ESP.getFreeHeap());
@@ -0,0 +1,43 @@
# DS18B20 on GPIO4 with a bus search every ${search_interval}. Set busy_control to "true" to replace the search with
# the same busy time and no 1-Wire traffic (the control run). Substitutions: name, board, search_interval, busy_control.
substitutions:
board: d1_mini
search_interval: 100ms
busy_control: "false"
packages:
base: !include base.yaml
one_wire:
- platform: gpio
pin: GPIO4
id: owbus
globals:
- {id: searches, type: uint32_t, initial_value: "0"}
- {id: search_bad, type: uint32_t, initial_value: "0"}
- {id: reads_ok, type: uint32_t, initial_value: "0"}
- {id: reads_nan, type: uint32_t, initial_value: "0"}
sensor:
- platform: dallas_temp
one_wire_id: owbus
id: t1
name: Temp
update_interval: 2s
on_raw_value:
- lambda: if (std::isnan(x)) id(reads_nan)++; else id(reads_ok)++;
interval:
- interval: ${search_interval}
then:
- lambda: |-
if (${busy_control}) {
delayMicroseconds(13000); // about one search with one sensor answering
return;
}
id(owbus)->search();
id(searches)++;
if (id(owbus)->get_devices().size() != 1)
id(search_bad)++;
- interval: 10s
then:
- lambda: |-
ESP_LOGI("lock", "ALIVE up=%u ok=%u nan=%u searches=%u search_bad=%u heap=%u t=%.2f",
(unsigned) (millis() / 1000), (unsigned) id(reads_ok), (unsigned) id(reads_nan),
(unsigned) id(searches), (unsigned) id(search_bad), (unsigned) ESP.getFreeHeap(), id(t1).state);
@@ -0,0 +1,47 @@
#!/bin/bash
# run.sh NAME SECONDS PPS: build NAME.yaml (next to this script, or a path) from TREE, flash it over serial, capture
# the log for SECONDS plus 160 (boot, up to 120 s for the IP, the load) and run wifi_load.py at PPS packets per second
# (0: no load). Prints the boot count, every reset reason and the last ALIVE line. Exit 0: a clean run (exactly one
# BOOT line); 1: the run finished and saw resets; 2: setup failed or the run is invalid (no load, capture cut short,
# or the sensor or IR loopback never worked).
# env: TREE (checkout under test), PORT, SC (path to serial_capture.py), PY (checkout python)
D=$(cd "$(dirname "$0")" && pwd); N=${1:?name}; T=${2:?seconds}; P=${3:?pps}
W=${TREE:?set TREE}; PORT=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$W/venv/bin/python}
Y=$N; [ -f "$Y" ] || Y=$D/$N.yaml; Y=$(cd "$(dirname "$Y")" && pwd)/$(basename "$Y"); N=$(basename "$Y" .yaml)
O=$D/out-$N; mkdir -p "$O"
pkill -f "[s]erial_capture.py $PORT "
rm -f "$O/serial.txt" "$O/load.txt"
(cd "$W" && PYTHONPATH=$W $PY -m esphome compile "$Y") > "$O/compile.log" 2>&1 || { echo "$N compile failed"; exit 2; }
(cd "$W" && PYTHONPATH=$W $PY -m esphome upload "$Y" --device "$PORT") > "$O/upload.log" 2>&1 || { echo "$N upload failed"; exit 2; }
$PY "$SC" "$PORT" $((T + 160)) "$O/serial.txt" --reset > "$O/capture.log" 2>&1 &
CAP=$!
for _ in $(seq 60); do
IP=$(LC_ALL=C grep -a -o -E "IP Address: [0-9.]+" "$O/serial.txt" | tail -1 | cut -d' ' -f3)
[ -n "$IP" ] && break; sleep 2
done
if [ "$P" -gt 0 ]; then
[ -n "$IP" ] || { kill "$CAP"; echo "$N no IP (check the secrets file)"; exit 2; }
python3 "$D/wifi_load.py" "$IP" "$T" "$P" > "$O/load.txt" 2>&1 || { echo "$N INVALID: load failed (see $O/load.txt)"; BAD=1; }
else
sleep "$T"
fi
wait "$CAP" || { echo "$N INVALID: capture failed (see $O/capture.log)"; BAD=1; }
BOOTS=$(LC_ALL=C grep -a -c 'BOOT reason' "$O/serial.txt")
echo "== $N pps=$P boots=$BOOTS"
LC_ALL=C grep -a "BOOT reason" "$O/serial.txt" | cut -c1-150
LC_ALL=C grep -a "ALIVE" "$O/serial.txt" | tail -1 | cut -c1-200
tail -1 "$O/load.txt" 2>/dev/null
ALIVE=$(LC_ALL=C grep -a "ALIVE" "$O/serial.txt" | tail -1)
# the workload must have run: a sensor answering searches, or decoded IR frames
case "$ALIVE" in
*" ok=0 "*|*" rx_ok=0 "*|"") echo "$N INVALID: no ALIVE line or the workload did not run"; BAD=1 ;;
esac
# one-wire: the searches must find the sensor (busy_control runs no searches)
S=$(echo "$ALIVE" | sed -n 's/.* searches=\([0-9]*\) search_bad=\([0-9]*\).*/\1 \2/p')
if [ -n "$S" ]; then
set -- $S
[ "$1" -gt 0 ] && [ "$2" -ge "$1" ] && { echo "$N INVALID: no search found the sensor"; BAD=1; }
fi
# resets are a failure even when they also broke the load or the workload
[ "$BOOTS" -gt 1 ] && exit 1
[ -z "$BAD" ] && [ "$BOOTS" = 1 ] || exit 2
@@ -0,0 +1,60 @@
"""Wi-Fi load on an ESP8266 under test: a fixed rate UDP flood to port 9 plus back to back web_server GETs.
usage: wifi_load.py HOST SECONDS PPS
Prints the counts every minute and a final "done" line.
"""
import socket
import sys
import threading
import time
import urllib.request
host, secs, pps = sys.argv[1], float(sys.argv[2]), int(sys.argv[3])
end = time.monotonic() + secs
stats = {"udp": 0, "udp_err": 0, "http_ok": 0, "http_err": 0, "http_gap_s": 0}
last_ok = [time.monotonic()]
def http_load() -> None:
while time.monotonic() < end:
try:
with urllib.request.urlopen(f"http://{host}/", timeout=5) as r:
r.read()
stats["http_ok"] += 1
now = time.monotonic()
stats["http_gap_s"] = max(stats["http_gap_s"], round(now - last_ok[0]))
last_ok[0] = now
except Exception: # noqa: BLE001 any failure counts; the thread must not die
stats["http_err"] += 1
time.sleep(1)
threading.Thread(target=http_load, daemon=True).start()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
payload = b"x" * 512
nxt = last = time.monotonic()
while (now := time.monotonic()) < end:
try:
s.sendto(payload, (host, 9))
stats["udp"] += 1
except OSError: # mostly ENOBUFS when the host outruns the network
stats["udp_err"] += 1
time.sleep(0.01)
nxt += 1 / pps
if (d := nxt - time.monotonic()) > 0:
time.sleep(d)
if now - last > 60:
last = now
print(time.strftime("%H:%M:%S"), stats, flush=True)
stats["http_gap_s"] = max(stats["http_gap_s"], round(time.monotonic() - last_ok[0]))
print("done", stats, flush=True)
if (
stats["http_ok"] == 0
or stats["http_gap_s"] > 60 # the device stopped answering for over a minute
or stats["http_err"] > stats["http_ok"]
or stats["udp"] < secs * pps / 2
):
sys.exit("the load did not reach the device; the run is invalid")
@@ -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,79 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <cstdlib>
#include <new>
#ifdef __XTENSA__
#include "esp_cpu_utils.h"
#include "esp_debug_helpers.h"
#endif
#include "esp_timer.h"
// Counts every global operator new and, once armed, keeps the first ALLOC_CAP of them with five caller PCs
// (callers on Xtensa only; RISC-V entries keep size and time). Aligned operator new is not counted.
// See reference/recipes/heap_alloc.md.
struct AllocEntry {
uint32_t ms;
uint32_t size; // ALLOC_MARKER for a marker: pc[0] and pc[1] then hold its two values
uint32_t pc[5];
};
static constexpr uint32_t ALLOC_CAP = 1024;
static constexpr uint32_t ALLOC_MARKER = UINT32_MAX;
AllocEntry g_allocs[ALLOC_CAP];
std::atomic<uint32_t> g_alloc_n{0};
std::atomic<uint32_t> g_new_count{0};
// seq_cst with g_alloc_busy: the dump stores false then loads busy, a recorder adds busy then loads this
std::atomic<bool> g_alloc_armed{false};
uint32_t g_alloc_armed_ms = 0; // when recording started, 0 if it never did
// Recorders between claiming a slot and filling it; the dump waits for 0 after disarming.
std::atomic<uint32_t> g_alloc_busy{0};
// Claims the next slot, or returns nullptr and disarms once the buffer is full.
static inline AllocEntry *alloc_claim(uint32_t size) {
uint32_t i = g_alloc_n.fetch_add(1);
if (i >= ALLOC_CAP) {
g_alloc_armed = false;
return nullptr;
}
AllocEntry &e = g_allocs[i];
e.ms = (uint32_t) (esp_timer_get_time() / 1000);
e.size = size;
return &e;
}
void *operator new(size_t size) {
g_new_count.fetch_add(1, std::memory_order_relaxed);
g_alloc_busy.fetch_add(1); // before the armed check, so a dump that disarmed first sees it
AllocEntry *e = g_alloc_armed ? alloc_claim(size) : nullptr;
if (e != nullptr) {
e->pc[0] = e->pc[1] = e->pc[2] = e->pc[3] = e->pc[4] = 0;
#ifdef __XTENSA__
esp_backtrace_frame_t f;
esp_backtrace_get_start(&f.pc, &f.sp, &f.next_pc);
// The start frame is this function; record the five callers above it.
// Stop at the first frame that does not unwind; the slots after it stay 0.
for (int k = 0; k < 5 && f.next_pc != 0 && esp_backtrace_get_next_frame(&f); k++)
e->pc[k] = esp_cpu_process_stack_pc(f.pc);
#endif
}
g_alloc_busy.fetch_sub(1);
void *p = malloc(size ? size : 1);
if (p == nullptr)
abort();
return p;
}
void *operator new[](size_t size) { return operator new(size); }
void operator delete(void *p) noexcept { free(p); }
void operator delete[](void *p) noexcept { free(p); }
void operator delete(void *p, size_t) noexcept { free(p); }
void operator delete[](void *p, size_t) noexcept { free(p); }
// Splits the dump into phases, for example on each change of an entity's state.
static inline void alloc_marker(uint32_t a, uint32_t b) {
g_alloc_busy.fetch_add(1);
if (AllocEntry *e = g_alloc_armed ? alloc_claim(ALLOC_MARKER) : nullptr) {
e->pc[0] = a;
e->pc[1] = b;
e->pc[2] = e->pc[3] = e->pc[4] = 0;
}
g_alloc_busy.fetch_sub(1);
}
@@ -0,0 +1,53 @@
# Package for allocring.hpp: arms it ${alloc_arm_s} s after boot, reports that on a binary sensor and adds the
# dump button. See heap_alloc.md.
esphome:
includes:
- allocring.hpp
on_boot:
then:
- delay: ${alloc_arm_s}s
- lambda: |-
g_alloc_armed_ms = millis();
g_alloc_armed = true;
ESP_LOGI("allocs", "ARMED total=%u", g_new_count.load());
binary_sensor:
# Drivers wait for this before the first action, so no step runs before recording starts
- platform: template
name: Allocation recorder armed
lambda: return g_alloc_armed.load();
interval:
- interval: 30s
then:
- lambda: |-
ESP_LOGI("allocs", "total=%u kept=%u heap=%u", g_new_count.load(), (unsigned) g_alloc_n.load(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
button:
- platform: template
name: Dump allocations
on_press:
# Recording stops for good; reboot before the next run, or a second press returns the same entries.
- lambda: |-
g_alloc_armed = false;
// let recorders on other tasks finish the entry they claimed
for (int w = 0; w < 100 && g_alloc_busy.load() != 0; w++)
delay(1);
uint32_t claimed = g_alloc_n.load();
uint32_t n = std::min<uint32_t>(claimed, ALLOC_CAP);
// claimed > n means the buffer filled and recording stopped early
// armed_ms=0: the recorder never ran; busy>0: an entry may still be half written;
// buf is the buffer's address, which decode.py matches against the ELF
ESP_LOGI("allocs", "DUMP n=%u claimed=%u total=%u armed_ms=%u busy=%u buf=%08x", n, claimed,
g_new_count.load(), g_alloc_armed_ms, g_alloc_busy.load(), (unsigned) (uintptr_t) g_allocs);
for (uint32_t i = 0; i < n; i++) {
const AllocEntry &e = g_allocs[i];
ESP_LOGI("allocs", "E %u %u %u %08x %08x %08x %08x %08x", i, e.ms, e.size, e.pc[0], e.pc[1], e.pc[2],
e.pc[3], e.pc[4]);
if ((i & 15) == 15) {
delay(20); // let the log drain
App.feed_wdt(); // a full dump over a 115200 baud UART log outlasts the task watchdog
}
}
ESP_LOGI("allocs", "DUMP END");
@@ -0,0 +1,108 @@
"""Move the device's first cover to the far end and back N times with a states-only client, then save the dump.
usage: cycles_cover.py HOST CYCLES OUT env: API_KEY (unset for a plaintext API)
Waits for the recorder's armed binary sensor first. Exits 2 when a move did not reach its position (the
cover never moved, so the dump proves nothing).
"""
import asyncio
import pathlib
import sys
import time
from aioesphomeapi import (
BinarySensorInfo,
BinarySensorState,
CoverInfo,
CoverOperation,
CoverState,
)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from apiconn import connect # noqa: E402
from heap_alloc.dump import dump # noqa: E402
# After each move, so allocations that come once the cover has stopped land in the same cycle
SETTLE_S = 8
ARM_WAIT_S = 600
async def main() -> None:
host, cycles, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with pathlib.Path(out).open("w", encoding="utf-8") as f:
def w(s: str) -> None:
f.write(time.strftime("%H:%M:%S ") + s + "\n")
f.flush()
c = await connect(host)
ents, _ = await c.list_entities_services()
cover = next(e for e in ents if isinstance(e, CoverInfo))
armed_info = next(
e
for e in ents
if isinstance(e, BinarySensorInfo)
and e.object_id == "allocation_recorder_armed"
)
states: asyncio.Queue = asyncio.Queue()
armed = asyncio.Event()
def on_state(s) -> None:
if isinstance(s, CoverState) and s.key == cover.key:
states.put_nowait(s)
elif (
isinstance(s, BinarySensorState) and s.key == armed_info.key and s.state
):
armed.set()
c.subscribe_states(on_state)
latest = await asyncio.wait_for(states.get(), 10)
# moves made before recording starts would be missing from the dump
await asyncio.wait_for(armed.wait(), ARM_WAIT_S)
w("ARMED")
def drain() -> CoverState:
"""The newest queued state, so no stale one is read as this command's result."""
nonlocal latest
while not states.empty():
latest = states.get_nowait()
return latest
async def move(pos: float) -> bool:
drain()
w(f"CMD {pos}")
c.cover_command(cover.key, position=pos)
moved = False
deadline = time.monotonic() + 60
while (left := deadline - time.monotonic()) > 0:
try:
s = await asyncio.wait_for(states.get(), left)
except TimeoutError:
break
idle = s.current_operation == CoverOperation.IDLE
moved |= not idle
if moved and idle and abs(s.position - pos) < 0.01:
w(f"IDLE pos={s.position:.3f}")
return True
w("TIMEOUT")
return False
timeouts = 0
# start toward the far end, so the first command always moves the cover
order = (0.0, 1.0) if drain().position > 0.5 else (1.0, 0.0)
for i in range(cycles):
w(f"CYCLE {i + 1}")
for pos in order:
timeouts += not await move(pos)
await asyncio.sleep(SETTLE_S)
if timeouts:
# before the dump, which exits on its own failure
print(f"{timeouts} moves timed out", file=sys.stderr)
await dump(c, w)
await c.disconnect()
if timeouts:
sys.exit(2)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,159 @@
"""Decode an allocring dump into callers: decode.py LOG ELF [ADDR2LINE]
Prints each entry with its caller frames as function and file:line, and markers (size 0xFFFFFFFF, ALLOC_MARKER) as phase
separators.
ADDR2LINE defaults to an Xtensa one found in ESPHome's ESP-IDF tools cache, ~/.platformio or ~/.espressif.
Exits 2 when the dump is incomplete (missing header or entries, or the buffer filled during the run).
"""
from pathlib import Path
import re
import subprocess
import sys
ENTRY = re.compile(r"\bE (\d+) (\d+) (\d+) ((?:[0-9a-f]{8} ?){5})")
HEADER = re.compile(
r"\bDUMP n=(\d+) claimed=(\d+) total=\d+ armed_ms=(\d+) busy=(\d+) buf=([0-9a-f]{8})"
)
MARKER = 0xFFFFFFFF
XTENSA = 94 # ELF e_machine; the recorder walks frames only on Xtensa
def tool_roots() -> list[Path]:
roots = [Path.home() / ".platformio", Path.home() / ".espressif"]
try:
from esphome.espidf.framework import get_idf_tools_path
except ImportError:
return roots
return [get_idf_tools_path(), *roots]
def is_xtensa(elf: str) -> bool:
return int.from_bytes(Path(elf).read_bytes()[18:20], "little") == XTENSA
def find_addr2line() -> str:
for root in tool_roots():
for path in root.glob("**/bin/xtensa-esp*-elf-addr2line"):
if path.is_file():
return str(path)
sys.exit("no addr2line found; pass it as the third argument")
def short(path: str) -> str:
for marker in ("esphome/components/", "esphome/core/", "esp-idf/", "include/c++/"):
if marker in path:
return marker + path.split(marker, 1)[1]
return path
def decode(
addr2line: str, elf: str, pcs: list[str]
) -> dict[str, list[tuple[str, str]]]:
"""Every PC's frames (inlined ones too); -a prints each address before its frames."""
out = subprocess.run(
[addr2line, "-afiC", "-e", elf, *(f"0x{pc}" for pc in pcs)],
capture_output=True,
text=True,
check=True,
).stdout.splitlines()
names: dict[str, list[tuple[str, str]]] = {}
frames: list[tuple[str, str]] = []
i = 0
while i < len(out):
if out[i].startswith("0x"):
frames = names.setdefault(f"{int(out[i], 16):08x}", [])
i += 1
else:
frames.append((out[i], short(out[i + 1])))
i += 2
return names
def last_dump(text: str) -> str:
"""The newest dump in the file (a retried press or an appended file holds several); check() rejects it if cut off."""
start = max((m.start() for m in HEADER.finditer(text)), default=len(text))
return text[start:]
def check(dump: str, entries: list, xtensa: bool) -> list[str]:
"""Reasons the dump is incomplete, empty when it is whole."""
if not (header := HEADER.search(dump)):
return ["no DUMP header (log subscription started late?)"]
n, claimed, armed_ms, busy = (int(header[i]) for i in range(1, 5))
problems = []
if not armed_ms:
problems.append("the recorder was never armed (pressed before alloc_arm_s?)")
if busy:
problems.append(f"{busy} recorders were still writing an entry")
indexes = [int(e[0]) for e in entries]
if missing := sorted(set(range(n)) - set(indexes)):
problems.append(f"{len(missing)} of {n} entries missing, first {missing[:10]}")
if len(indexes) != len(set(indexes)):
problems.append("duplicate entries")
if stray := sorted(i for i in set(indexes) if i >= n):
problems.append(f"entries past n={n}: {stray[:10]}")
allocs = [pcs for _, _, size, pcs in entries if size != MARKER]
if xtensa and allocs and all(pcs[0] == "00000000" for pcs in allocs):
problems.append("no entry has a caller (frame walk failed?)")
if claimed > n:
problems.append(f"buffer filled: recording stopped after {n} entries")
if "DUMP END" not in dump:
problems.append("no DUMP END")
return problems
def main() -> None:
log, elf = sys.argv[1], sys.argv[2]
text = last_dump(Path(log).read_text(encoding="utf-8", errors="replace"))
entries = [
(idx, int(ms), int(size), pcs.split())
for idx, ms, size, pcs in ENTRY.findall(text)
]
wanted = {
pc
for _, _, size, pcs in entries
if size != MARKER
for pc in pcs
if pc != "00000000"
}
# Only Xtensa dumps carry callers; RISC-V ones still print sizes and times
names = {}
if wanted:
addr2line = sys.argv[3] if len(sys.argv) > 3 else find_addr2line()
names = decode(addr2line, elf, sorted(wanted))
for idx, ms, size, pcs in entries:
if size == MARKER:
print(
f"--- marker at {ms / 1000:.3f} s: {int(pcs[0], 16)} {int(pcs[1], 16)}"
)
continue
print(f"#{idx} {ms / 1000:.3f} s {size} B")
for pc in pcs:
if pc == "00000000":
break
for func, loc in names[pc]:
print(f" {func[:110]} {loc}")
problems = check(text, entries, is_xtensa(elf))
# addr2line names any address, so check the ELF is the build that made the dump
if wanted and (header := HEADER.search(text)):
nm = addr2line[: -len("addr2line")] + "nm"
if not Path(nm).is_file():
problems.append(f"cannot check the ELF: no {nm} next to addr2line")
elif not re.search(
rf"^0*{int(header[5], 16):x} [bBdD] g_allocs$",
subprocess.run(
[nm, elf], capture_output=True, text=True, check=True
).stdout,
re.MULTILINE,
):
problems.append(
"the ELF is not the build that made this dump (g_allocs address differs)"
)
if problems:
print("INCOMPLETE DUMP: " + "; ".join(problems), file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
@@ -0,0 +1,84 @@
"""Press the "Dump allocations" button from allocring.yaml and save the dump.
usage: dump.py HOST OUT env: API_KEY (unset for a plaintext API)
"""
import asyncio
from collections.abc import Callable
import contextlib
import pathlib
import re
import sys
from aioesphomeapi import APIClient, ButtonInfo
from aioesphomeapi.model import LogLevel
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from apiconn import connect, log_text # noqa: E402
HEADER = re.compile(r"\bDUMP n=(\d+) claimed=\d+ total=\d+ armed_ms=(\d+) busy=(\d+)")
ENTRY = re.compile(r"\bE (\d+) \d+ \d+ ")
async def dump(c: APIClient, write: Callable[[str], None], tries: int = 3) -> None:
"""Subscribe to logs, press the dump button and pass every line to WRITE until DUMP END.
subscribe_logs does not confirm the subscription and the device drops log lines when busy, so the press is
repeated (the dump is repeatable) until the header and all n entries arrived; exits 2 after TRIES presses.
"""
ents, _ = await c.list_entities_services()
button = next(
e
for e in ents
if isinstance(e, ButtonInfo) and e.object_id == "dump_allocations"
)
seen: dict = {"n": None, "idx": set()}
done = asyncio.Event()
def on_log(msg) -> None:
text = log_text(msg)
write(text)
if m := HEADER.search(text):
seen["n"], seen["idx"] = int(m[1]), set()
seen["armed"], seen["busy"] = int(m[2]) != 0, int(m[3])
elif m := ENTRY.search(text):
seen["idx"].add(int(m[1]))
elif "DUMP END" in text and seen["n"] is not None:
# without this press's header it is a late end from an earlier press
done.set()
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_INFO)
for _ in range(tries):
await asyncio.sleep(2)
seen["n"] = None
done.clear()
c.button_command(button.key)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(done.wait(), 120)
# the device drops log lines when its send buffer is full, so require every entry
if done.is_set() and seen["n"] is not None and not seen["armed"]:
print(
"the recorder was never armed (pressed before alloc_arm_s?)",
file=sys.stderr,
)
sys.exit(2)
if (
done.is_set()
and seen["n"] is not None
and not seen["busy"]
and seen["idx"] == set(range(seen["n"]))
):
return
print(f"no whole dump after {tries} presses", file=sys.stderr)
sys.exit(2)
async def main() -> None:
c = await connect(sys.argv[1])
with pathlib.Path(sys.argv[2]).open("w", encoding="utf-8") as f:
await dump(c, lambda s: f.write(s + "\n"))
await c.disconnect()
if __name__ == "__main__":
asyncio.run(main())
@@ -70,7 +70,9 @@ def frame(msg_type: int, payload: bytes) -> bytes:
return b"\x00" + varint(len(payload)) + varint(msg_type) + payload
def f_bytes(num: int, data: bytes) -> bytes:
def f_bytes(num: int, data: bytes | str) -> bytes:
if isinstance(data, str):
data = data.encode()
return varint((num << 3) | 2) + varint(len(data)) + data
@@ -0,0 +1,51 @@
"""Press the device's buttons through Home Assistant and print the script and flag states after each step.
usage: drive.py TOKENFILE NAME env: HA_URL (default http://127.0.0.1:8129)
NAME is the device name from hsw.yaml (button entity ids are button.<NAME>_long_on and so on).
"""
import pathlib
import sys
import time
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent / "mqtt" / "ha_discovery")
)
from ha_rest import req_or_exit # noqa: E402
tok = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
name = sys.argv[2].replace("-", "_") # entity ids use underscores
t0 = time.monotonic()
def state(entity: str) -> str:
return req_or_exit(f"/api/states/{entity}", token=tok)["state"]
def show(tag: str) -> None:
print(
f"{time.monotonic() - t0:6.1f}s {tag:30s} long_job={state('script.long_job'):4s}"
f" quick_job={state('script.quick_job'):4s} flag={state('input_boolean.script_ran')}",
flush=True,
)
def call(domain: str, action: str, entity: str, wait: float = 3) -> None:
req_or_exit(f"/api/services/{domain}/{action}", {"entity_id": entity}, token=tok)
time.sleep(wait)
show("start")
call("button", "press", f"button.{name}_long_on")
show("device Long on")
call("button", "press", f"button.{name}_long_off")
show("device Long off")
call("input_boolean", "turn_off", "input_boolean.script_ran", 1)
call("button", "press", f"button.{name}_long_on")
show("device Long on again")
time.sleep(20)
show("script finished on its own")
call("button", "press", f"button.{name}_quick_on")
show("device Quick on")
call("script", "turn_on", "script.long_job")
show("HA starts the script")
@@ -0,0 +1,33 @@
# Throwaway Home Assistant on port 8129: a 20 s script that sets and clears a flag, and a toggle script.
http:
server_port: 8129
api:
config:
onboarding:
frontend:
input_boolean:
script_ran:
script:
long_job:
alias: Long job
mode: single
sequence:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.script_ran
- delay: 20
- action: input_boolean.turn_off
target:
entity_id: input_boolean.script_ran
quick_job:
alias: Quick job
sequence:
- action: input_boolean.toggle
target:
entity_id: input_boolean.script_ran
logger:
default: warning
logs:
homeassistant.components.esphome: debug
homeassistant.components.script: debug
homeassistant.helpers.script: info
@@ -0,0 +1,36 @@
"""Add a host build's API to a throwaway Home Assistant and allow it to call actions.
usage: haflow.py TOKENFILE env: HA_URL (default http://127.0.0.1:8129)
Runs the esphome config flow against 127.0.0.1:6053, then turns on allow_service_calls in the options flow.
"""
import pathlib
import sys
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent / "mqtt" / "ha_discovery")
)
from ha_rest import req_or_exit # noqa: E402
tok = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
flow = "/api/config/config_entries/flow"
f = req_or_exit(flow, {"handler": "esphome"}, token=tok)
r = req_or_exit(
f"{flow}/{f['flow_id']}", {"host": "127.0.0.1", "port": 6053}, token=tok
)
if r.get("type") == "form" and r.get("errors"):
sys.exit(f"config flow step {r.get('step_id')} failed: {r['errors']}")
if r.get("type") == "form": # confirmation step
r = req_or_exit(f"{flow}/{r['flow_id']}", {}, token=tok)
if r.get("type") != "create_entry":
sys.exit(
f"config flow ended with {r.get('type')} {r.get('errors') or r.get('reason')}"
)
opts = "/api/config/config_entries/options/flow"
o = req_or_exit(opts, {"handler": r["result"]["entry_id"]}, token=tok)
o = req_or_exit(f"{opts}/{o['flow_id']}", {"allow_service_calls": True}, token=tok)
if o.get("type") != "create_entry":
sys.exit(
f"options flow ended with {o.get('type')} {o.get('errors') or o.get('reason')}"
)
print("added", r.get("title"), "with allow_service_calls")
@@ -0,0 +1,39 @@
# Host build mirroring two Home Assistant scripts and the flag they set. Substitution: name.
esphome:
name: ${name}
host:
logger:
level: DEBUG
api:
reboot_timeout: 0s
switch:
- platform: homeassistant
id: sw_long
name: "Long job mirror"
entity_id: script.long_job
on_turn_on:
- logger.log: "EV long ON"
on_turn_off:
- logger.log: "EV long OFF"
- platform: homeassistant
id: sw_quick
name: "Quick job mirror"
entity_id: script.quick_job
- platform: homeassistant
id: sw_bool
name: "Ran flag mirror"
entity_id: input_boolean.script_ran
on_turn_on:
- logger.log: "EV flag ON"
on_turn_off:
- logger.log: "EV flag OFF"
button:
- platform: template
name: "Long on"
on_press: [switch.turn_on: sw_long]
- platform: template
name: "Long off"
on_press: [switch.turn_off: sw_long]
- platform: template
name: "Quick on"
on_press: [switch.turn_on: sw_quick]
@@ -0,0 +1,59 @@
# One fixed entity_id entity and one wizard-linked entity (no entity_id) per homeassistant platform that a wizard
# input can stand for, on top of the three buttons of ha-button-common.yaml, on an ESP8266 (the ramchk interval reads
# the ESP8266 heap). run.sh adds esphome name and board, and ha-wiz-light.yaml unless NO_LIGHT is set.
packages:
buttons: !include ../homeassistant_button/ha-button-common.yaml
api:
wizard:
pages:
- inputs:
- entity: s_wiz
- entity: sa_wiz
- entity: b_wiz
- entity: t_wiz
- entity: n_wiz
- entity: sw_wiz
- entity: sel_wiz
- entity: tx_wiz
# Free heap every 5 s, and the heap size, for static + heap used
interval:
- interval: 5s
then:
- lambda: |-
uint32_t heap_size = 0x3FFFC000u - reinterpret_cast<uint32_t>(&_heap_start);
ESP_LOGI("ramchk", "heap_size=%u free=%u max_block=%u", heap_size, ESP.getFreeHeap(), ESP.getMaxFreeBlockSize());
# homeassistant entities are internal by default; internal: false lets the API client list and drive them
sensor:
- {platform: homeassistant, id: s_fix, internal: false, name: S fix, entity_id: sensor.outside}
- {platform: homeassistant, id: s_wiz, internal: false, name: S wiz}
- {platform: homeassistant, id: sa_wiz, internal: false, name: SA wiz, attribute: temperature}
binary_sensor:
- {platform: homeassistant, id: b_fix, internal: false, name: B fix, entity_id: binary_sensor.door}
- {platform: homeassistant, id: b_wiz, internal: false, name: B wiz}
text_sensor:
- {platform: homeassistant, id: t_fix, internal: false, name: T fix, entity_id: sensor.weather}
- {platform: homeassistant, id: t_wiz, internal: false, name: T wiz}
number:
- {platform: homeassistant, id: n_fix, internal: false, name: N fix, entity_id: input_number.target}
- {platform: homeassistant, id: n_wiz, internal: false, name: N wiz}
switch:
- {platform: homeassistant, id: sw_fix, internal: false, name: SW fix, entity_id: switch.porch}
- {platform: homeassistant, id: sw_wiz, internal: false, name: SW wiz}
select:
- {platform: homeassistant, id: sel_fix, internal: false, name: SEL fix, entity_id: input_select.mode}
- {platform: homeassistant, id: sel_wiz, internal: false, name: SEL wiz}
text:
- {platform: homeassistant, id: tx_fix, internal: false, name: TX fix, entity_id: input_text.note}
- {platform: homeassistant, id: tx_wiz, internal: false, name: TX wiz}
light:
- {platform: homeassistant, id: l_fix, internal: false, name: L fix, entity_id: light.kitchen}
@@ -0,0 +1,9 @@
# The wizard-linked light, which builds before #21908 cannot compile; run.sh leaves it out when NO_LIGHT is set.
api:
wizard:
pages:
- inputs:
- entity: l_wiz
light:
- {platform: homeassistant, id: l_wiz, internal: false, name: L wiz}
@@ -0,0 +1,495 @@
"""Drive every homeassistant platform over a plaintext native API connection, playing Home Assistant.
usage: hadrive.py HOST [--no-light] (exits 1 on any failed check)
Matches ha-all-common.yaml: one fixed entity_id entity and one wizard-linked entity per platform. The client
subscribes to entity states, Home Assistant actions and Home Assistant state subscriptions, then:
A sends Home Assistant states for the fixed entities (and for a wizard ID that is not set yet), commands every
writer, and checks that the wizard-linked ones send nothing;
B sets every wizard input (WizardInputSetRequest), records the subscriptions the device sends for it, sends
states for the new IDs and commands the writers;
C changes every wizard input to a second ID: old IDs must be ignored, new ones followed;
D sends an invalid ID, which the device must reject.
Prints canonical lines (SUB, STATE, ACTION, CHECK) so two builds can be diffed, and PASS or FAIL at the end.
"""
import argparse
from collections.abc import Callable
import pathlib
import struct
import sys
import time
from typing import NamedTuple
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parents[1] / "homeassistant_button")
)
from habdrive import ( # noqa: E402
Client as BaseClient,
f_bytes,
f_fixed32,
f_varint,
fields,
fnv1_hash,
varint,
)
def f_float(num: int, value: float) -> bytes:
return varint((num << 3) | 5) + struct.pack("<f", value)
# ListEntities response type -> platform
LIST_TYPES = {
12: "binary_sensor",
15: "light",
16: "sensor",
17: "switch",
18: "text_sensor",
49: "number",
52: "select",
61: "button",
97: "text",
}
STATE_TYPES = {
21: "bool",
24: "bool",
25: "float",
26: "bool",
27: "str",
50: "float",
53: "str",
98: "str",
}
class Client(BaseClient):
def __init__(self, host: str) -> None:
super().__init__(host)
self.keys: dict[str, int] = {}
self.names: dict[int, str] = {}
self.failures: list[str] = []
self.last: dict[
str, str
] = {} # the last state the device published, by entity name
def login(
self, name: str, connect_wait: float = 1, list_entities: bool = True
) -> None:
"""Hello (API 1.14) and Connect, then ListEntities into keys and names."""
self.send(1, f_bytes(1, name) + f_varint(2, 1) + f_varint(3, 14))
self.pump(10, until=2)
self.send(3)
self.pump(connect_wait)
if not list_entities:
return
self.send(11)
for msg_type, payload in self.pump(10, until=19):
if msg_type in LIST_TYPES:
d = dict(fields(payload))
key, name = struct.unpack("<I", d[2])[0], d[3].decode()
self.keys[name] = key
self.names[key] = name
def report(
self, frames: list[tuple[int, bytes]], label: str
) -> dict[str, list[str]]:
"""Print SUB, STATE and ACTION lines for frames and return them by kind."""
out = self.parse(frames)
for line in out["STATE"]:
name, _, value = line.partition(" = ")
self.last[name] = value
for kind in ("SUB", "STATE", "ACTION"):
for line in sorted(out[kind]) if kind == "SUB" else out[kind]:
print(f" {label} {kind} {line}")
return out
def collect(
self,
label: str,
done: Callable[[dict[str, list[str]]], bool] | None = None,
timeout: float = 6.0,
settle: float = 0.5,
) -> dict[str, list[str]]:
"""Read until done(parsed) holds (or for timeout without one), then settle more seconds for strays."""
frames: list[tuple[int, bytes]] = []
end = time.time() + timeout
while time.time() < end:
frames += self.pump(0.2)
if done is not None and done(self.parse(frames)):
break
frames += self.pump(settle)
return self.report(frames, label)
def parse(self, frames: list[tuple[int, bytes]]) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"SUB": [], "STATE": [], "ACTION": []}
for msg_type, payload in frames:
f = fields(payload)
d = dict(f)
if msg_type == 39: # SubscribeHomeAssistantStateResponse
attr = d.get(2, b"").decode() or "-"
line = f"{d.get(1, b'').decode()} {attr}{' once' if d.get(3) else ''}"
out["SUB"].append(line)
elif msg_type in STATE_TYPES:
key = struct.unpack("<I", d[1])[0]
kind = STATE_TYPES[msg_type]
if kind == "float":
value = d.get(2)
value = (
f"{struct.unpack('<f', value)[0]:g}"
if value is not None
else "0"
)
elif kind == "bool":
value = "ON" if d.get(2) else "OFF"
else:
value = repr(d.get(2, b"").decode())
if msg_type not in (24, 26) and d.get(
3
): # field 3 of a light state is brightness
value += " missing"
out["STATE"].append(f"{self.names.get(key, key)} = {value}")
elif msg_type == 35: # HomeassistantActionRequest
service, data = "", []
for num, value in f:
if num == 1:
service = value.decode()
elif num in (2, 3):
kv = dict(fields(value))
data.append(
f"{kv.get(1, b'').decode()}={kv.get(2, b'').decode()}"
)
out["ACTION"].append(f"{service} {' '.join(data)}")
return out
def check(self, label: str, ok: bool, detail: str) -> None:
print(f" CHECK {label}: {'ok' if ok else 'FAIL'} {detail}")
if not ok:
self.failures.append(f"{label}: {detail}")
def ha_state(self, entity_id: str, state: str, attribute: str = "") -> None:
payload = f_bytes(1, entity_id) + f_bytes(2, state)
if attribute:
payload += f_bytes(3, attribute)
self.send(40, payload)
def wizard(self, input_id: str, entity_id: str) -> None:
self.send(158, f_fixed32(1, fnv1_hash(input_id)) + f_bytes(2, entity_id))
def feed(c: Client, platform: str, eid: str, n: int) -> None:
"""Send what Home Assistant sends for a platform; n varies the values between rounds."""
if platform == "sensor":
c.ha_state(eid, f"{20 + n}.5")
elif platform == "sensor_attr":
c.ha_state(eid, "heat") # the state itself must not reach an attribute sensor
c.ha_state(eid, f"{30 + n}", "temperature")
elif platform == "binary_sensor":
c.ha_state(eid, "on" if n % 2 else "off")
elif platform == "text_sensor":
c.ha_state(eid, f"text{n}")
elif platform == "number":
c.ha_state(eid, "0", "min")
c.ha_state(eid, "100", "max")
c.ha_state(eid, "1", "step")
c.ha_state(eid, f"{40 + n}")
elif platform == "switch":
c.ha_state(eid, "on" if n % 2 else "off")
elif platform == "select":
c.ha_state(eid, "['eco', 'comfort', 'away']", "options")
c.ha_state(eid, ["eco", "comfort", "away"][n % 3])
elif platform == "text":
c.ha_state(eid, "0", "min")
c.ha_state(eid, "100", "max")
c.ha_state(eid, "text", "mode")
c.ha_state(eid, f"note{n}")
elif platform == "light":
c.ha_state(eid, "['onoff']", "supported_color_modes")
c.ha_state(eid, "onoff", "color_mode")
c.ha_state(eid, "on" if n % 2 else "off")
def expected_state(platform: str, n: int) -> str | None:
return {
"sensor": f"{20 + n}.5",
"sensor_attr": f"{30 + n}",
"binary_sensor": "ON" if n % 2 else "OFF",
"text_sensor": repr(f"text{n}"),
"number": f"{40 + n}",
"switch": "ON" if n % 2 else "OFF",
"select": repr(["eco", "comfort", "away"][n % 3]),
"text": repr(f"note{n}"),
"light": "ON" if n % 2 else "OFF",
}.get(platform)
class Row(NamedTuple):
fix: str | None # name of the fixed entity_id entity
fix_id: str | None
wiz: str | None # name of the wizard-linked entity
input_id: str | None # ESPHome id of its wizard input
platform: str
one: str | None # the first and second entity IDs the wizard input is set to
two: str | None
# fmt: off
PLATFORMS = [
Row("S fix", "sensor.outside", "S wiz", "s_wiz", "sensor", "sensor.wiz_one", "sensor.wiz_two"),
Row(None, None, "SA wiz", "sa_wiz", "sensor_attr", "climate.wiz_one", "climate.wiz_two"),
Row("B fix", "binary_sensor.door", "B wiz", "b_wiz", "binary_sensor", "binary_sensor.wiz_one",
"binary_sensor.wiz_two"),
Row("T fix", "sensor.weather", "T wiz", "t_wiz", "text_sensor", "sensor.wiz_text_one", "sensor.wiz_text_two"),
Row("N fix", "input_number.target", "N wiz", "n_wiz", "number", "input_number.wiz_one", "number.wiz_two"),
Row("SW fix", "switch.porch", "SW wiz", "sw_wiz", "switch", "input_boolean.wiz_one", "switch.wiz_two"),
Row("SEL fix", "input_select.mode", "SEL wiz", "sel_wiz", "select", "input_select.wiz_one", "select.wiz_two"),
Row("TX fix", "input_text.note", "TX wiz", "tx_wiz", "text", "input_text.wiz_one", "text.wiz_two"),
Row("L fix", "light.kitchen", "L wiz", "l_wiz", "light", "light.wiz_one", "light.wiz_two"),
]
# fmt: on
# the fixed buttons come from ha-button-common.yaml
WIZARD_BUTTON = Row(
None,
None,
"HA wizard",
"hab_wiz",
"button",
"input_button.wiz_one",
"button.wiz_two",
)
# The subscriptions each platform sends for one entity ID
SUBS = {
"button": [],
"sensor": ["{} -"],
"sensor_attr": ["{} temperature"],
"binary_sensor": ["{} -"],
"text_sensor": ["{} -"],
"number": ["{} -", "{} max once", "{} min once", "{} step once"],
"switch": ["{} -"],
"select": ["{} -", "{} options"],
"text": ["{} -", "{} max once", "{} min once", "{} mode once"],
"light": [
"{} -",
"{} brightness",
"{} color_mode",
"{} color_temp_kelvin",
"{} max_color_temp_kelvin",
"{} min_color_temp_kelvin",
"{} rgb_color",
"{} supported_color_modes",
],
}
def command(c: Client, name: str, platform: str, n: int) -> str | None:
"""Command a writer entity; returns the action it must send for entity ID {}, or None for a reader."""
key = c.keys[name]
if platform == "number":
c.send(51, f_fixed32(1, key) + f_float(2, 10 + n))
return "{d}.set_value entity_id={e} value=" + f"{10 + n}"
if platform == "switch":
state = n % 2 == 0 # the opposite of what feed() reported
c.send(33, f_fixed32(1, key) + f_varint(2, int(state)))
return f"homeassistant.turn_{'on' if state else 'off'} entity_id={{e}}"
if platform == "select":
option = ["eco", "comfort", "away"][(n + 1) % 3]
c.send(54, f_fixed32(1, key) + f_bytes(2, option))
return "{d}.select_option entity_id={e} option=" + option
if platform == "text":
c.send(99, f_fixed32(1, key) + f_bytes(2, f"cmd{n}"))
return "{d}.set_value entity_id={e} value=" + f"cmd{n}"
if platform == "light":
state = n % 2 == 0
c.send(32, f_fixed32(1, key) + f_varint(2, 1) + f_varint(3, int(state)))
return f"light.turn_{'on' if state else 'off'} entity_id={{e}}"
if platform == "button":
c.send(62, f_fixed32(1, key))
return "{d}.press entity_id={e}"
return None
def action_for(template: str, eid: str) -> str:
# number, select, text and button call the action of the entity's own domain (input_number.set_value, ...)
if template.startswith("{d}"):
return template.format(d=eid.split(".", maxsplit=1)[0], e=eid)
return template.format(e=eid)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("host")
parser.add_argument(
"--no-light", action="store_true", help="the config has no wizard light"
)
args = parser.parse_args()
c = Client(args.host)
c.login("hadrive", connect_wait=2)
print("entities", len(c.keys), sorted(c.keys))
# only the wizard light may be left out (builds before #21908 cannot compile it)
rows: list[Row] = []
for row in [*PLATFORMS, WIZARD_BUTTON]:
if row.wiz and row.wiz not in c.keys:
c.check(
f"{row.wiz} listed", args.no_light and row.wiz == "L wiz", "missing"
)
row = row._replace(wiz=None, input_id=None)
if row.fix or row.wiz:
rows.append(row)
wizard = [row for row in rows if row.wiz]
c.send(20) # SubscribeStatesRequest
c.report(c.pump(3), "INIT")
c.send(34) # SubscribeHomeassistantServicesRequest
c.pump(1)
c.send(38) # SubscribeHomeAssistantStatesRequest
expected = sorted(
s.format(r.fix_id) for r in rows if r.fix for s in SUBS[r.platform]
)
subs = c.collect("A", lambda o: len(o["SUB"]) >= len(expected), 10, 2)["SUB"]
c.check(
"A subscriptions are the fixed entities only",
sorted(subs) == expected,
f"{len(subs)} subs",
)
def drive(
label: str, entries: list[tuple[str, str | None, str]], n: int, stale: list[str]
) -> None:
"""entries: (name, entity ID or None if unset, platform). stale: IDs the device must ignore."""
for eid in stale:
c.ha_state(eid, "999")
c.ha_state(eid, "999", "temperature")
prev = dict(c.last)
for _, eid, platform in entries:
if eid:
feed(c, platform, eid, n)
wants = {
f"{name} = {expected_state(p, n)}"
for name, eid, p in entries
if eid and expected_state(p, n)
}
got = c.collect(f"{label}-feed", lambda o: wants <= set(o["STATE"]), 8, 1.5)
states = got["STATE"]
for name, eid, platform in entries:
want = expected_state(platform, n)
if want is None:
continue
mine = [s for s in states if s.startswith(f"{name} = ")]
if eid:
# A light publishes only a change, so a report of the state it already has publishes nothing
same = platform == "light" and not mine and prev.get(name) == want
c.check(
f"{label} {name} follows {eid}",
f"{name} = {want}" in mine or same,
str(mine),
)
else:
c.check(f"{label} {name} unset publishes nothing", not mine, str(mine))
c.check(
f"{label} stale IDs ignored",
not any("999" in s or "= 'heat'" in s for s in states),
"",
)
for name, eid, platform in entries:
if platform in ("sensor", "sensor_attr", "binary_sensor", "text_sensor"):
continue
template = command(c, name, platform, n)
if eid is None:
got = c.collect(f"{label}-cmd {name}", None, 2)
else:
got = c.collect(f"{label}-cmd {name}", lambda o: bool(o["ACTION"]), 6)
actions = got["ACTION"]
if eid is None:
c.check(
f"{label} {name} unset sends nothing", not actions, str(actions)
)
continue
want = action_for(template, eid)
c.check(
f"{label} {name} action", actions == [want], f"{actions} want {want}"
)
if platform == "light":
c.ha_state(
eid, "on" if n % 2 == 0 else "off"
) # Home Assistant echoes the change
c.collect(f"{label}-echo {name}", None, 1.5)
fixed = [(r.fix, r.fix_id, r.platform) for r in rows if r.fix]
fixed += [
("HA button", "button.doorbell", "button"),
("HA input", "input_button.chime", "button"),
]
wiz = [(r.wiz, None, r.platform) for r in wizard]
print("== A: fixed entities, wizard inputs not set")
drive("A", fixed + wiz, 1, ["sensor.wiz_one", "climate.wiz_one"])
def set_all(label: str, idx: int) -> dict[str, str]:
ids = {}
for row in wizard:
name = row.wiz
eid = row.one if idx == 1 else row.two
ids[name] = eid
c.wizard(row.input_id, eid)
want = sorted(s.format(eid) for s in SUBS[row.platform])
n_want = len(want)
if want:
subs = c.collect(
f"{label}-set {name}",
lambda o, n=n_want: len(o["SUB"]) >= n,
6,
0.5,
)["SUB"]
else:
subs = c.collect(f"{label}-set {name}", None, 2)["SUB"]
c.check(
f"{label} {name} subscribes to {eid}",
sorted(subs) == want,
f"{sorted(subs)}",
)
return ids
print("== B: wizard inputs set to the first IDs")
ids = set_all("B", 1)
drive("B", [(name, ids[name], p) for name, _, p in wiz], 2, [])
print("== C: wizard inputs changed to the second IDs")
old = [row.one for row in wizard]
ids = set_all("C", 2)
drive("C", [(name, ids[name], p) for name, _, p in wiz], 3, old)
print("== D: invalid ID rejected")
for input_id in ("n_wiz", "hab_wiz", "s_wiz"):
c.wizard(input_id, "not_an_entity")
subs = c.collect(f"D-set {input_id}", None, 2)["SUB"]
c.check(f"D {input_id} invalid set sends no subscription", not subs, str(subs))
drive(
"D",
[
(name, ids[name], p)
for name, _, p in wiz
if name in ("N wiz", "HA wizard", "S wiz")
],
4,
[],
)
print("== E: a new connection gets every subscription with the current IDs")
c.sock.close()
c2 = Client(parser.parse_args().host)
c2.login("hadrive2", list_entities=False)
c2.send(38)
expected = sorted(
[s.format(r.fix_id) for r in rows if r.fix for s in SUBS[r.platform]]
+ [s.format(ids[r.wiz]) for r in wizard for s in SUBS[r.platform]]
)
subs = c2.collect("E", lambda o: len(o["SUB"]) >= len(expected), 10, 2)["SUB"]
c.check(
"E subscriptions on reconnect",
sorted(subs) == expected,
f"{len(subs)} subs, want {len(expected)}",
)
if c.failures:
print("FAIL: " + "; ".join(c.failures))
sys.exit(1)
print("PASS")
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
"""After hadrive.py: the wizard light must follow its current entity ID (light.wiz_two) and ignore the old one.
usage: lightcheck.py HOST
Sends on and off states for both IDs and prints what the device publishes after each; only light.wiz_two may move
"L wiz". A light publishes only changes, so a repeated state shows nothing.
"""
import sys
from hadrive import Client
def main() -> None:
c = Client(sys.argv[1])
c.login("lightcheck")
c.send(20) # SubscribeStatesRequest
c.collect("init", None, 3)
c.send(38) # SubscribeHomeAssistantStatesRequest
c.collect("subs", None, 4)
for eid, st in (
("light.wiz_one", "on"),
("light.wiz_two", "on"),
("light.wiz_one", "off"),
("light.wiz_two", "off"),
):
print(f"send {eid} = {st}")
c.ha_state(eid, st)
c.collect(" ->", None, 2.5)
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
#!/bin/bash
# run.sh LABEL: build ha-all-common.yaml from TREE as hawiz<LABEL> for a D1 mini, flash, take three idle boots of heap
# readings (ramchk lines), then drive one boot with hadrive.py and print its result and the heap after the client.
# NO_LIGHT=1 drops the wizard-linked light, which builds before #21908 cannot compile.
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=hawiz$L
T=${TREE:?set TREE}; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
OUT=$R/out-$N; rm -rf "$OUT"; mkdir -p "$OUT"
LIGHT=' light: !include ha-wiz-light.yaml\n'; NL=(); [ -n "$NO_LIGHT" ] && { LIGHT=; NL=(--no-light); }
printf "esphome:\n name: %s\n\nesp8266:\n board: d1_mini\n\npackages:\n common: !include ha-all-common.yaml\n$LIGHT" "$N" \
> "$R/$N.yaml"
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"
for b in 1 2 3; do
$PY "$SC" "$P" 45 "$OUT/boot$b.txt" --reset > /dev/null 2>&1
echo "boot$b $(grep -a 'ramchk' "$OUT/boot$b.txt" | tail -1 | sed -E 's/.*(heap_size)/\1/')"
done
$PY "$SC" "$P" 150 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
for _ in $(seq 1 30); do
IP=$(grep -a -o "IP Address: [0-9.]*" "$OUT/serial.txt" 2>/dev/null | tail -1 | cut -d' ' -f3)
[ -n "$IP" ] && break; sleep 2
done
[ -n "$IP" ] || { echo "no IP in the capture"; kill $CAP; exit 1; }
sleep 8
$PY "$R/hadrive.py" "$IP" "${NL[@]}" > "$OUT/drive.txt" 2>&1; RC=$?
sleep 6; kill $CAP
echo "drive rc=$RC $(tail -1 "$OUT/drive.txt" | cut -c1-300)"
echo "after-client $(grep -a 'ramchk' "$OUT/serial.txt" | tail -1 | sed -E 's/.*(heap_size)/\1/')"
echo "crash lines: $(grep -a -c -E 'rst cause|Exception|Soft WDT' "$OUT/serial.txt")"
exit $RC
@@ -0,0 +1,16 @@
# One run: p_fault is per mille of transactions, seed fixes the fault sequence (seed 1 hangs dev at about 245 s on #22270)
substitutions:
name: i2cfault-pr
freq: 10kHz
hammer: 100ms
ina_interval: 1s
p_fault: "50"
fault_mask: "0xFF"
hold_max_ms: "3000"
diag_recover: "false"
seed: "1"
fw: esp-idf
fwver: 5.5.5
packages:
base: !include i2cbase.yaml
@@ -0,0 +1,132 @@
# Package: reporter-shaped I2C bus + INA219 on GPIO32/33, answered by the in-chip software INA219 (inasim.hpp)
esphome:
name: ${name}
includes:
- inasim.hpp
on_boot:
- priority: 700
then:
- lambda: |-
if (${seed} != 0) inasim::rng = ${seed};
inasim::install();
uint8_t w[3] = {0x05, 0x12, 0x34};
uint8_t r = 0x05;
for (int pass = 1; pass <= 2; pass++) {
uint8_t b[2] = {0, 0};
auto ew = id(bus_a)->write_readv(0x40, w, 3, nullptr, 0);
auto er = id(bus_a)->write_readv(0x40, &r, 1, b, 2);
ESP_LOGI("inasim", "selftest%d write=%d read=%d val=%02X%02X starts=%u", pass, ew, er, b[0], b[1],
(unsigned) inasim::st.starts);
}
esp32:
board: pico32
framework:
type: ${fw}
version: ${fwver}
logger:
level: INFO
baud_rate: 115200
i2c:
id: bus_a
sda: GPIO32
scl: GPIO33
frequency: ${freq}
scan: true
globals:
- id: n_ok
type: uint32_t
initial_value: '0'
- id: n_to
type: uint32_t
initial_value: '0'
- id: n_nack
type: uint32_t
initial_value: '0'
- id: n_other
type: uint32_t
initial_value: '0'
- id: n_bad
type: uint32_t
initial_value: '0'
- id: n_ina_bad
type: uint32_t
initial_value: '0'
- id: max_us
type: uint32_t
initial_value: '0'
- id: streak
type: uint32_t
initial_value: '0'
- id: max_streak
type: uint32_t
initial_value: '0'
interval:
- interval: ${hammer}
then:
- lambda: |-
if (millis() < 20000) return;
if (!inasim::armed) {
inasim::p_fault = ${p_fault};
inasim::fault_mask = ${fault_mask};
inasim::hold_max_ms = ${hold_max_ms};
// The ISR picks faults until one is in the mask, so an empty mask would spin there forever
if (inasim::p_fault && !(inasim::fault_mask & ((1u << inasim::F_COUNT) - 1))) {
ESP_LOGE("inasim", "fault_mask 0x%x selects no fault, running without faults", (unsigned) inasim::fault_mask);
inasim::p_fault = 0;
}
inasim::armed = true;
ESP_LOGI("inasim", "faults armed p=%u/1000 mask=0x%x", (unsigned) inasim::p_fault, (unsigned) inasim::fault_mask);
}
inasim::hb = millis();
inasim::diag_recover = ${diag_recover};
inasim::drain();
uint8_t r = 0x02, buf[2] = {0, 0};
uint32_t t0 = micros();
auto e = id(bus_a)->write_readv(0x40, &r, 1, buf, 2);
uint32_t dt = micros() - t0;
if (dt > id(max_us)) id(max_us) = dt;
if (e == i2c::ERROR_OK) {
id(streak) = 0;
id(n_ok)++;
if (((buf[0] << 8) | buf[1]) != ((3410 << 3) | 2)) id(n_bad)++;
} else {
id(streak)++;
if (id(streak) > id(max_streak)) id(max_streak) = id(streak);
if (e == i2c::ERROR_TIMEOUT) id(n_to)++;
else if (e == i2c::ERROR_NOT_ACKNOWLEDGED) id(n_nack)++;
else id(n_other)++;
}
- interval: 30s
then:
- lambda: |-
ESP_LOGI("inasim", "MASTER up=%us ok=%u timeout=%u nack=%u other=%u badval=%u ina_bad=%u max_us=%u heap=%u fail_streak_now=%u max_fail_streak=%u",
(unsigned) (millis() / 1000), (unsigned) id(n_ok), (unsigned) id(n_to), (unsigned) id(n_nack),
(unsigned) id(n_other), (unsigned) id(n_bad), (unsigned) id(n_ina_bad), (unsigned) id(max_us),
(unsigned) esp_get_free_heap_size(), (unsigned) id(streak), (unsigned) id(max_streak));
id(max_us) = 0;
inasim::log_stats();
sensor:
- platform: ina219
address: 0x40
shunt_resistance: 0.1 ohm
max_voltage: 32.0V
max_current: 3.2A
update_interval: ${ina_interval}
bus_voltage:
name: Starter battery
accuracy_decimals: 2
on_value:
- lambda: |-
if (fabsf(x - 13.64f) > 0.005f) {
id(n_ina_bad)++;
ESP_LOGW("inasim", "INA bus voltage garbage %.2f V", x);
}
current:
name: Charge current
accuracy_decimals: 2
@@ -0,0 +1,435 @@
#pragma once
// Software INA219 at 0x40 answered on the master's own pads (GPIO32 SDA, GPIO33 SCL) plus fault injection.
// The slave drives a line low by switching the pad's output mux from the I2C0 signal to the plain GPIO output
// (level 0), which is a wired AND with the master. It runs inside a GPIO ISR on core 0 that spins through one
// transaction. Long stuck lines are held by a task on core 0 that re-asserts every tick.
#include <cstdint>
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "driver/gpio.h"
#include "soc/gpio_reg.h"
#include "soc/gpio_sig_map.h"
#include "esp_cpu.h"
#include "esp_rom_sys.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
namespace inasim {
static const char *const TAG = "inasim";
constexpr int SDA = 32, SCL = 33;
constexpr uint32_t SDA_BIT = 1u << (SDA - 32), SCL_BIT = 1u << (SCL - 32);
constexpr uint32_t LOW_VAL = SIG_GPIO_OUT_IDX | GPIO_FUNC0_OEN_SEL;
constexpr uint32_t CPU_MHZ = 240;
inline volatile uint32_t &reg(uint32_t a) { return *reinterpret_cast<volatile uint32_t *>(a); }
inline uint32_t out_sel_addr(int pin) { return GPIO_FUNC0_OUT_SEL_CFG_REG + 4 * pin; }
inline uint32_t in() { return reg(GPIO_IN1_REG); }
enum Fault : uint8_t {
F_GARBAGE = 0, // flip a bit in returned data
F_NACK, // do not ack the address
F_STRETCH, // hold SCL low 0.2..20 ms inside the transaction, then continue
F_HOLD_SCL, // SCL stuck low 50..3000 ms (task)
F_HOLD_SDA, // SDA stuck low 50..3000 ms (task)
F_GLITCH_SCL, // 1..4 us low pulse on SCL while it is high
F_GLITCH_SDA, // 1..4 us low pulse on SDA while SCL is high (false START/STOP)
F_VANISH, // slave stops responding mid transaction
F_COUNT
};
struct Stats {
volatile uint32_t starts, matched, stops, idle_abort, cap_abort, rd_bytes, wr_bytes;
volatile uint32_t fault[F_COUNT];
};
inline Stats st{};
inline uint32_t rel_sda = 0, rel_scl = 0;
inline volatile bool armed = false;
inline volatile uint32_t p_fault = 0; // per mille of matched transactions
inline volatile uint32_t fault_mask = 0xFF; // which faults may be chosen
inline volatile uint32_t hold_max_ms = 3000;
inline uint16_t regs[6] = {0x399F, 0x044C, (3410 << 3) | 2, 0, 0, 0};
inline uint8_t ptr = 0;
inline uint32_t rng = 0x2270A5C3;
inline uint32_t rnd() {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
return rng;
}
struct FaultEv {
uint32_t ms;
uint8_t type;
uint32_t arg;
};
inline FaultEv ring[64];
inline volatile uint32_t ring_w = 0;
inline uint32_t ring_r = 0;
inline void note(uint8_t type, uint32_t arg) {
FaultEv &e = ring[ring_w & 63];
e.ms = (uint32_t) (esp_timer_get_time() / 1000);
e.type = type;
e.arg = arg;
ring_w = ring_w + 1;
}
inline TaskHandle_t holder = nullptr;
inline volatile uint32_t hold_mask = 0, hold_ms = 0;
inline volatile bool holding = false;
inline void drive_low(uint32_t bits) {
reg(GPIO_OUT1_W1TC_REG) = bits;
reg(GPIO_ENABLE1_W1TS_REG) = bits;
if (bits & SDA_BIT)
reg(out_sel_addr(SDA)) = LOW_VAL;
if (bits & SCL_BIT)
reg(out_sel_addr(SCL)) = LOW_VAL;
}
inline void release(uint32_t bits) {
if (bits & SDA_BIT)
reg(out_sel_addr(SDA)) = rel_sda;
if (bits & SCL_BIT)
reg(out_sel_addr(SCL)) = rel_scl;
}
inline void spin_us(uint32_t us) {
uint32_t s = esp_cpu_get_cycle_count();
while (esp_cpu_get_cycle_count() - s < us * CPU_MHZ) {
}
}
inline void update_derived() {
// current = shunt * cal / 4096, power = current * bus / 5000 (datasheet scaling)
int32_t shunt = (int16_t) regs[1];
uint32_t cal = regs[5];
int32_t cur = (int32_t) ((int64_t) shunt * cal / 4096);
regs[4] = (uint16_t) cur;
regs[3] = (uint16_t) ((int64_t) (cur < 0 ? -cur : cur) * (regs[2] >> 3) / 5000);
}
inline void start_hold(uint32_t bits, uint32_t ms, BaseType_t *woken) {
hold_mask = bits;
hold_ms = ms;
holding = true;
drive_low(bits);
vTaskNotifyGiveFromISR(holder, woken);
}
// Spins through one transaction from a START. Returns when STOP, idle or cap hits.
inline void run_transaction(BaseType_t *woken) {
enum { ADDR, WDATA, RDATA, IGNORE } phase = ADDR;
uint32_t prev = in();
uint32_t start = esp_cpu_get_cycle_count(), last = start;
uint8_t byte = 0, cnt = 0, out = 0, rbyte_idx = 0, wbyte_idx = 0, wmsb = 0;
bool acked = false, mack = false, sda_driven = false;
int fault = -1;
uint32_t fault_at = 0, rises = 0;
bool garbage_done = false;
auto set_sda = [&](bool low) {
if (low && !sda_driven) {
drive_low(SDA_BIT);
sda_driven = true;
} else if (!low && sda_driven) {
release(SDA_BIT);
sda_driven = false;
}
};
auto load_byte = [&]() {
uint16_t v = ptr < 6 ? regs[ptr] : 0xFFFF;
out = rbyte_idx == 0 ? (v >> 8) : (rbyte_idx == 1 ? (v & 0xFF) : 0xFF);
if (fault == F_GARBAGE && !garbage_done) {
out ^= 1u << (rnd() & 7);
note(F_GARBAGE, out);
garbage_done = true;
}
rbyte_idx++;
};
for (;;) {
uint32_t v = in();
uint32_t now = esp_cpu_get_cycle_count();
if (v == prev) {
if (now - last > 15000u * CPU_MHZ) {
st.idle_abort++;
break;
}
if (now - start > 60000u * CPU_MHZ) {
st.cap_abort++;
break;
}
continue;
}
last = now;
uint32_t ch = v ^ prev;
prev = v;
if (ch & SCL_BIT) {
if (v & SCL_BIT) {
// rising edge: sample
cnt++;
rises++;
if (cnt <= 8 && (phase == ADDR || phase == WDATA))
byte = (byte << 1) | ((v & SDA_BIT) ? 1 : 0);
else if (cnt == 9 && phase == RDATA)
mack = !(v & SDA_BIT);
if (fault >= 0 && rises == fault_at) {
if (fault == F_GLITCH_SCL || fault == F_GLITCH_SDA) {
spin_us(2);
uint32_t b = fault == F_GLITCH_SCL ? SCL_BIT : SDA_BIT;
bool was = b == SDA_BIT && sda_driven;
if (!was) {
note(fault, rises);
drive_low(b);
spin_us(1 + (rnd() & 3));
release(b);
}
fault = -1;
prev = in();
}
}
} else {
// falling edge: drive
if (fault >= 0 && rises == fault_at) {
if (fault == F_STRETCH) {
drive_low(SCL_BIT);
uint32_t us = 200 + rnd() % 19800;
note(F_STRETCH, us);
spin_us(us);
release(SCL_BIT);
fault = -1;
prev = in();
last = esp_cpu_get_cycle_count();
} else if (fault == F_HOLD_SCL) {
set_sda(false);
uint32_t ms = 50 + rnd() % hold_max_ms;
note(F_HOLD_SCL, ms);
start_hold(SCL_BIT, ms, woken);
return;
} else if (fault == F_HOLD_SDA) {
drive_low(SDA_BIT);
sda_driven = false;
uint32_t ms = 50 + rnd() % hold_max_ms;
note(F_HOLD_SDA, ms);
start_hold(SDA_BIT, ms, woken);
return;
} else if (fault == F_VANISH) {
note(F_VANISH, rises);
set_sda(false);
return;
}
}
if (cnt == 8) {
if (phase == ADDR) {
bool match = (byte >> 1) == 0x40;
if (match) {
st.matched++;
if (armed && p_fault && (rnd() % 1000) < p_fault) {
uint32_t f;
do {
f = rnd() % F_COUNT;
} while (!(fault_mask & (1u << f)));
fault = f;
fault_at = rises + 1 + rnd() % 30;
st.fault[f]++;
}
}
if (match && fault == F_NACK)
note(F_NACK, 0);
if (match && fault != F_NACK) {
set_sda(true);
acked = true;
phase = (byte & 1) ? RDATA : WDATA;
rbyte_idx = 0;
wbyte_idx = 0;
} else {
phase = IGNORE;
acked = false;
}
} else if (phase == WDATA) {
st.wr_bytes++;
if (wbyte_idx == 0)
ptr = byte;
else if (wbyte_idx == 1)
wmsb = byte;
else if (wbyte_idx == 2 && ptr < 6) {
uint16_t w = (wmsb << 8) | byte;
if (ptr == 0 && (w & 0x8000))
w = 0x399F;
if (ptr == 0 || ptr == 5)
regs[ptr] = w;
update_derived();
}
wbyte_idx++;
set_sda(true);
acked = true;
} else if (phase == RDATA) {
set_sda(false); // master acks
acked = false;
}
} else if (cnt == 9) {
if (acked) {
set_sda(false);
acked = false;
}
if (phase == RDATA) {
if (rbyte_idx == 0 || mack) {
load_byte();
st.rd_bytes++;
set_sda(!(out & 0x80));
} else {
phase = IGNORE;
}
}
cnt = 0;
byte = 0;
} else if (cnt >= 1 && cnt <= 7 && phase == RDATA) {
set_sda(!(out & (0x80 >> cnt)));
}
}
} else if ((ch & SDA_BIT) && (v & SCL_BIT)) {
if (v & SDA_BIT) {
st.stops++;
break;
}
// repeated START
st.starts++;
set_sda(false);
phase = ADDR;
cnt = 0;
byte = 0;
acked = false;
}
}
set_sda(false);
}
inline void slave_isr(void *) {
reg(GPIO_STATUS1_W1TC_REG) = SDA_BIT;
if (holding)
return;
uint32_t v = in();
if (!(v & SCL_BIT) || (v & SDA_BIT))
return;
st.starts++;
BaseType_t woken = pdFALSE;
run_transaction(&woken);
reg(GPIO_STATUS1_W1TC_REG) = SDA_BIT;
if (woken)
portYIELD_FROM_ISR();
}
inline void holder_task(void *) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
uint32_t end = (esphome::millis) () + hold_ms;
while ((int32_t) (end - (esphome::millis) ()) > 0) {
drive_low(hold_mask);
vTaskDelay(1);
}
release(hold_mask);
holding = false;
}
}
inline volatile uint32_t hb = 0;
inline volatile bool diag_recover = false;
constexpr uint32_t I2C0 = 0x3FF53000;
inline void dump_i2c(const char *why, uint32_t stale) {
esp_rom_printf("DIAG %s stale=%ums ctr=%08x sr=%08x to=%08x raw=%08x ena=%08x st=%08x fifo=%08x hold=%d in=%x\n", why,
(unsigned) stale, (unsigned) reg(I2C0 + 0x04), (unsigned) reg(I2C0 + 0x08),
(unsigned) reg(I2C0 + 0x0c), (unsigned) reg(I2C0 + 0x20), (unsigned) reg(I2C0 + 0x28),
(unsigned) reg(I2C0 + 0x2c), (unsigned) reg(I2C0 + 0x18), (int) holding, (unsigned) (in() & 3));
esp_rom_printf("DIAG cmd:");
for (int i = 0; i < 16; i++)
esp_rom_printf(" %08x", (unsigned) reg(I2C0 + 0x58 + 4 * i));
esp_rom_printf("\nDIAG app_intr_status %08x %08x %08x pro %08x %08x %08x\n", (unsigned) reg(0x3FF000F8),
(unsigned) reg(0x3FF000FC), (unsigned) reg(0x3FF00100), (unsigned) reg(0x3FF000EC),
(unsigned) reg(0x3FF000F0), (unsigned) reg(0x3FF000F4));
}
inline void monitor_task(void *) {
uint32_t stage = 0;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(50));
uint32_t now = (esphome::millis) ();
uint32_t h = hb;
if (h == 0)
continue;
uint32_t stale = now - h;
if (stale < 1000) {
if (stage >= 3)
esp_rom_printf("DIAG loop resumed after %u ms, stage %u\n", (unsigned) stale, (unsigned) stage);
stage = 0;
continue;
}
if (stage == 0 && stale >= 1000) {
dump_i2c("stuck1", stale);
stage = 1;
} else if (stage == 1 && stale >= 1500) {
dump_i2c("stuck2", stale);
stage = 2;
} else if (stage == 2 && stale >= 2500) {
dump_i2c("stuck3", stale);
if (diag_recover) {
reg(I2C0 + 0x28) = 0;
reg(I2C0 + 0x24) = 0xFFFF;
esp_rom_printf("DIAG cleared I2C0 int_ena\n");
}
stage = 3;
} else if (stage == 3 && stale >= 3500) {
dump_i2c("stuck4", stale);
stage = 4;
}
}
}
inline volatile bool installed = false;
inline void install_task(void *) {
rel_sda = reg(out_sel_addr(SDA));
rel_scl = reg(out_sel_addr(SCL));
// ESP_ERR_INVALID_STATE: the service is already installed, which is fine
esp_err_t e1 = gpio_install_isr_service(0);
esp_err_t et = gpio_set_intr_type((gpio_num_t) SDA, GPIO_INTR_NEGEDGE);
esp_err_t e2 = gpio_isr_handler_add((gpio_num_t) SDA, slave_isr, nullptr);
ESP_LOGI(TAG, "slave on core %d: isr service %d, intr type %d, handler %d, rel_sda 0x%x rel_scl 0x%x",
xPortGetCoreID(), e1, et, e2, (unsigned) rel_sda, (unsigned) rel_scl);
if ((e1 == ESP_OK || e1 == ESP_ERR_INVALID_STATE) && et == ESP_OK && e2 == ESP_OK) {
installed = true;
} else {
ESP_LOGE(TAG, "slave ISR install failed: service %s, intr type %s, handler %s", esp_err_to_name(e1),
esp_err_to_name(et), esp_err_to_name(e2));
}
vTaskDelete(nullptr);
}
inline void install() {
if (xTaskCreatePinnedToCore(holder_task, "inahold", 3072, nullptr, configMAX_PRIORITIES - 2, &holder, 0) != pdPASS ||
xTaskCreatePinnedToCore(monitor_task, "inamon", 3072, nullptr, configMAX_PRIORITIES - 1, nullptr, 0) != pdPASS ||
xTaskCreatePinnedToCore(install_task, "inainst", 3072, nullptr, 5, nullptr, 0) != pdPASS) {
ESP_LOGE(TAG, "task creation failed, slave not installed");
return;
}
for (int i = 0; i < 1000 && !installed; i++)
vTaskDelay(1);
if (!installed)
ESP_LOGE(TAG, "slave install did not finish");
}
inline void drain() {
static const char *const NAMES[F_COUNT] = {"garbage", "nack", "stretch_us", "hold_scl_ms",
"hold_sda_ms", "glitch_scl", "glitch_sda", "vanish"};
const uint32_t w = ring_w;
if (w - ring_r > 64) {
ESP_LOGW(TAG, "F dropped %u events (ring overrun)", (unsigned) (w - ring_r - 64));
ring_r = w - 64;
}
while (ring_r != w) {
const FaultEv &e = ring[ring_r & 63];
ESP_LOGI(TAG, "F (%u) %s %u", (unsigned) e.ms, NAMES[e.type], (unsigned) e.arg);
ring_r++;
}
}
inline void log_stats() {
ESP_LOGI(TAG,
"SLAVE starts=%u matched=%u stops=%u idle_abort=%u cap_abort=%u rd=%u wr=%u | faults garbage=%u nack=%u "
"stretch=%u hold_scl=%u hold_sda=%u glitch_scl=%u glitch_sda=%u vanish=%u",
(unsigned) st.starts, (unsigned) st.matched, (unsigned) st.stops, (unsigned) st.idle_abort,
(unsigned) st.cap_abort, (unsigned) st.rd_bytes, (unsigned) st.wr_bytes, (unsigned) st.fault[0],
(unsigned) st.fault[1], (unsigned) st.fault[2], (unsigned) st.fault[3], (unsigned) st.fault[4],
(unsigned) st.fault[5], (unsigned) st.fault[6], (unsigned) st.fault[7]);
}
} // namespace inasim
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# run.sh LABEL [SECONDS]: build example.yaml from TREE as i2cfault-<LABEL> (edit its substitutions for the framework,
# seed and fault rate), flash the ESP32, capture SECONDS (default 640) from a reset and summarize.
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; S=${2:-640}; T=${TREE:?set TREE}; P=${PORT:?set PORT}
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; NAME=i2cfault-$L; OUT=$R/out-$NAME; rm -rf "$OUT"; mkdir -p "$OUT"
sed -E "s/^ name: .*/ name: $NAME/" "$R/example.yaml" > "$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" "$S" "$OUT/serial.txt" --reset > /dev/null 2>&1 || { echo "serial capture got nothing"; exit 1; }
"$R/summary.sh" "$OUT/serial.txt"
@@ -0,0 +1,11 @@
#!/bin/bash
# summary.sh CAPTURE...: crashes, watchdogs, timeout kinds and the read counters per capture
for f in "$@"; do
first=$(grep -a -m1 'rst:0x' "$f" | cut -c2-9); last=$(tail -1 "$f" | cut -c2-9)
echo "== $(basename "$f") $first..$last boots=$(grep -a -c 'rst:0x' "$f") guru=$(grep -a -c Guru "$f") twdt=$(grep -a -c 'task_wdt: Task watchdog' "$f") diag=$(grep -a -c 'DIAG stuck' "$f")"
echo " sw=$(grep -a -c 'software timeout' "$f") hw=$(grep -a -c 'hardware timeout detected' "$f") trans=$(grep -a -c 'transaction timeout detected' "$f") busy=$(grep -a -c 'still busy' "$f") ina_garbage_lines=$(grep -a -c 'INA bus voltage garbage' "$f")"
grep -a 'MASTER' "$f" | tail -1 | sed 's/^.*MASTER/ MASTER/'
grep -a 'SLAVE' "$f" | tail -1 | sed 's/^.*SLAVE/ SLAVE/'
echo " ok per 30s window (min over windows after arm): $(grep -a MASTER "$f" | sed 's/.* ok=\([0-9]*\).*/\1/' | awk 'NR>2{d=$1-p; if(min==""||d<min)min=d} {p=$1} END{print (min == "" ? "n/a (fewer than 3 MASTER windows)" : min)}')"
echo " harness: install_errors=$(grep -a -c -E 'task creation failed|slave ISR install failed|slave install did not finish|selects no fault' "$f") $(grep -a -o 'selftest1 write=[-0-9]* read=[-0-9]* val=[0-9A-F]*' "$f" | head -1)"
done
@@ -0,0 +1,90 @@
"""Per phase IPv6 link-local and SLAAC statistics from v6probe captures.
usage: an.py CAPTURE...
For the boot and reconnect phases: boots, boots with an esp_netif_create_ip6_linklocal failure, boots without
link-local, SLAAC or a connection, connection timeouts, and median/worst times in seconds. Counts missing
addresses from the RESULT line too, since the USB console can drop a warning line. Exits 1 when a boot other
than the last of a capture has no RESULT for a phase.
"""
import pathlib
import re
import statistics
import sys
def parse(paths: list[str]) -> list[dict]:
boots: list[dict] = []
cur: dict | None = None
for p in paths:
for line in pathlib.Path(p).read_bytes().decode(errors="replace").splitlines():
if "BOOTMARK" in line:
cur = {
"boot": {},
"reconnect": {},
"fail": [0, 0],
"created": [0, 0],
"timeouts": [0, 0],
"ph": 0,
}
boots.append(cur)
continue
if cur is None:
continue
ph = cur["ph"]
if "esp_netif_create_ip6_linklocal failed" in line:
cur["fail"][ph] += 1
if "IPv6 link-local address created" in line or "No lwIP netif" in line:
cur["created"][ph] += 1
if re.search(
r"Connection timeout|Connection incomplete|Connection lost", line
):
cur["timeouts"][ph] += 1
if "REENABLE" in line:
cur["ph"] = 1
if m := re.search(r"RESULT (\w+) (.*)", line):
cur[m.group(1)] = dict(kv.split("=") for kv in m.group(2).split())
if cur is not None:
cur["last"] = True
cur = None
return boots
def med_max(xs: list[int]) -> str:
return f"{statistics.median(xs) / 1000:.1f}/{max(xs) / 1000:.1f}" if xs else "-"
def summ(boots: list[dict], phase: str, idx: int) -> int:
rs = [b for b in boots if b[phase]]
# a capture usually ends inside its last boot
no_result = sum(1 for b in boots if not b[phase] and "last" not in b)
def seen(key: str) -> list[int]:
return [int(b[phase][key]) for b in rs if b[phase][key] != "0"]
def missing(key: str) -> int:
return sum(1 for b in rs if b[phase][key] == "0")
print(
f"{phase:9} n={len(rs)} no_result={no_result} esp_fail={sum(1 for b in rs if b['fail'][idx])} no_ll={missing('ll')} "
f"no_slaac={missing('gl')} not_connected={missing('full')} timeouts={sum(b['timeouts'][idx] for b in rs)} "
f"ll(med/max s)={med_max(seen('ll'))} slaac={med_max(seen('gl'))} connected={med_max(seen('full'))} "
f"created_logs={sum(b['created'][idx] for b in rs)}"
)
return no_result
def main() -> None:
boots = parse(sys.argv[1:])
print("boots", len(boots))
no_result = summ(boots, "boot", 0) + summ(boots, "reconnect", 1)
print(
"heap_used first 3 boots",
[int(b["boot"]["heap_used"]) for b in boots if b["boot"]][:3],
)
if no_result or not boots:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,12 @@
substitutions:
min_count: "0"
cap_ms: "30000"
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
packages:
common: !include v6common.yaml
inject: !include v6inject.yaml
@@ -0,0 +1,12 @@
# One file per build; run.sh passes the name. Add v6hf.yaml or v6inject.yaml to packages.
substitutions:
min_count: "0"
cap_ms: "30000"
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
packages:
common: !include v6common.yaml
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# run.sh NAME SECONDS: build NAME.yaml from TREE as device NAME, flash, then capture SECONDS of self-rebooting boots
# (each boot is a boot phase and a reconnect phase) into out-NAME/cap.txt and summarize them with an.py.
# 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}
T=${TREE:?set TREE}; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
OUT=$R/out-$N; rm -rf "$OUT"; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome -s name "$N" 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; }
$PY "$SC" "$P" "$S" "$OUT/cap.txt" --ms > /dev/null 2>&1
[ -s "$OUT/cap.txt" ] || { echo "serial capture got nothing"; exit 1; }
$PY "$R/an.py" "$OUT/cap.txt"
@@ -0,0 +1,28 @@
# IPv6 boot and reconnect probe (#22364): logs milestones per phase, then disables Wi-Fi for 3 s, measures the
# reconnect and reboots. Substitutions: name (run.sh passes it), min_count (network min_ipv6_addr_count), cap_ms (phase time limit).
esphome:
name: ${name}
includes:
- v6probe.hpp
on_boot:
- priority: 800
then:
- lambda: 'ESP_LOGI("v6probe", "BOOTMARK reason=%d", (int) esp_reset_reason());'
logger:
level: VERBOSE
hardware_uart: USB_SERIAL_JTAG
network:
enable_ipv6: true
min_ipv6_addr_count: ${min_count}
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
reboot_timeout: 0s
interval:
- interval: 50ms
then:
- lambda: "v6probe::tick(${cap_ms});"
@@ -0,0 +1,9 @@
# Add to v6common.yaml: keeps the main loop busy, like a display or audio config
esphome:
on_boot:
- priority: 800
then:
- lambda: |-
static HighFrequencyLoopRequester hf;
hf.start();
ESP_LOGI("v6probe", "HF loop on");
@@ -0,0 +1,5 @@
# Add to v6common.yaml: delays esp-netif's STA_CONNECTED handler by 300 ms (wrapper in v6probe.hpp)
esphome:
build_flags:
- -Wl,--wrap=esp_netif_action_connected
- -DV6_INJECT
@@ -0,0 +1,100 @@
#pragma once
// Boot and reconnect timing probe for IPv6 link-local and SLAAC (#22364). Call tick(cap_ms) every 50 ms.
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/components/wifi/wifi_component.h"
#include <esp_netif.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
namespace v6probe {
static const char *const PTAG = "v6probe";
struct Times {
uint32_t assoc, v4, ll, gl, full;
};
static int phase = 0; // 0 boot, 1 wifi disabled, 2 reconnecting, 3 done
static uint32_t start = 0;
static Times t{};
inline void tick(uint32_t cap_ms) {
using esphome::millis;
uint32_t now = millis();
if (phase == 3)
return;
if (phase == 1) {
if (now - start >= 3000) {
ESP_LOGI(PTAG, "REENABLE");
t = {};
start = now;
phase = 2;
esphome::wifi::global_wifi_component->enable();
}
return;
}
uint32_t rel = (now - start) | 1; // never 0, 0 means not seen
wifi_ap_record_t ap;
if (!t.assoc && esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
t.assoc = rel;
ESP_LOGI(PTAG, "MS assoc %u", rel);
}
// a missing netif still has to reach the cap_ms timeout below
esp_netif_t *nif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
esp_netif_ip_info_t ip;
if (!t.v4 && nif != nullptr && esp_netif_get_ip_info(nif, &ip) == ESP_OK && ip.ip.addr != 0) {
t.v4 = rel;
ESP_LOGI(PTAG, "MS ipv4 %u", rel);
}
esp_ip6_addr_t a[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
int n = 0;
if (nif != nullptr) {
esphome::LwIPLock lock;
n = esp_netif_get_all_ip6(nif, a);
}
bool ll = false, gl = false;
for (int i = 0; i < n; i++) {
if (esp_netif_ip6_get_addr_type(&a[i]) == ESP_IP6_ADDR_IS_LINK_LOCAL)
ll = true;
else
gl = true;
}
if (!t.ll && ll) {
t.ll = rel;
ESP_LOGI(PTAG, "MS linklocal %u", rel);
}
if (!t.gl && gl) {
t.gl = rel;
ESP_LOGI(PTAG, "MS slaac %u", rel);
}
if (!t.full && esphome::wifi::global_wifi_component->is_connected()) {
t.full = rel;
ESP_LOGI(PTAG, "MS connected %u", rel);
}
bool done = t.gl && t.full;
if (done || now - start >= cap_ms) {
size_t used = heap_caps_get_total_size(MALLOC_CAP_INTERNAL) - heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
ESP_LOGI(PTAG, "RESULT %s assoc=%u v4=%u ll=%u gl=%u full=%u n6=%d timeout=%d heap_used=%u",
phase == 0 ? "boot" : "reconnect", t.assoc, t.v4, t.ll, t.gl, t.full, n, done ? 0 : 1, (unsigned) used);
if (phase == 0) {
phase = 1;
start = now;
esphome::wifi::global_wifi_component->disable();
} else {
phase = 3;
esphome::App.safe_reboot();
}
}
}
} // namespace v6probe
#ifdef V6_INJECT
// Fault injection: esp-netif's STA_CONNECTED handler (which brings the netif up) is delayed by 300 ms in the
// event task, after the wifi component's own handler has already queued the event for the main loop.
extern "C" void __real_esp_netif_action_connected(void *esp_netif, esp_event_base_t base, int32_t id, void *data);
extern "C" void __wrap_esp_netif_action_connected(void *esp_netif, esp_event_base_t base, int32_t id, void *data) {
ESP_LOGI("v6probe", "INJECT hold start");
vTaskDelay(pdMS_TO_TICKS(300));
ESP_LOGI("v6probe", "INJECT hold end");
__real_esp_netif_action_connected(esp_netif, base, id, data);
}
#endif
@@ -0,0 +1,82 @@
# Package: BW12 heartbeat, pull-toggle edge generator, BS binary sensor on ${bs_pin}, meas lines every 10 s
esphome:
name: ${name}
friendly_name: BW12 ISR Test
platformio_options:
upload_speed: 115200
build_flags:
- -Wl,--wrap=digitalRead
includes:
- bwdbg.hpp
rtl87xx:
board: bw12
framework:
version: recommended
logger:
level: DEBUG
baud_rate: 115200
logs:
sensor: WARN
binary_sensor: WARN
pulse_counter: WARN
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
captive_portal:
output:
- platform: gpio
pin: PA05
id: heartbeat_output
globals:
- id: bs_changes
type: uint32_t
initial_value: "0"
interval:
- interval: 1s
then:
- output.turn_on: heartbeat_output
- delay: 100ms
- output.turn_off: heartbeat_output
- interval: 50ms
then:
- lambda: bwdbg::tick();
- interval: 10s
then:
- lambda: |-
static uint32_t lg = 0, lc = 0;
uint32_t g = bwdbg::gen_rise + bwdbg::gen_fall, c = id(bs_changes);
ESP_LOGI("meas", "BS changes %u gen_edges %u state %d", c - lc, g - lg, id(bs).state);
lg = g; lc = c;
bwdbg::report();
binary_sensor:
- platform: gpio
id: bs
name: BS
pin:
number: ${bs_pin}
mode: INPUT
use_interrupt: true
interrupt_type: ANY
trigger_on_initial_state: true
on_state:
- lambda: id(bs_changes) += 1;
sensor:
- platform: uptime
name: Uptime
update_interval: 60s
@@ -0,0 +1,64 @@
#pragma once
// bw12isr: pull-toggle edge generator on floating pins, ISR read counters and pin/IRQ register dump (RTL8710B)
#include <Arduino.h>
#include "esphome/core/log.h"
extern "C" {
PinStatus __real_digitalRead(pin_size_t pin);
__attribute__((long_call)) void PAD_PullCtrl(uint8_t pin, uint8_t pull);
}
namespace bwdbg {
static constexpr uint8_t PINS[] = {12, 0, 14, 15, 19, 22};
static constexpr int NP = sizeof(PINS);
inline volatile uint32_t isr_reads[32], task_reads[32];
inline uint32_t gen_rise = 0, gen_fall = 0, checks = 0, mismatch[NP];
inline bool up = false, started = false;
inline uint32_t reg(uint32_t off) { return *reinterpret_cast<volatile uint32_t *>(0x40001000 + off); }
inline bool in_isr() {
uint32_t r;
__asm volatile("mrs %0, ipsr" : "=r"(r));
return r != 0;
}
// Every call: check each pin follows the pull set on the previous call, then flip all pulls
inline void tick() {
uint32_t lvl = reg(0x50);
if (started) {
checks++;
for (int i = 0; i < NP; i++)
if (((lvl >> PINS[i]) & 1) != (up ? 1u : 0u))
mismatch[i]++;
}
up = !up;
started = true;
for (int i = 0; i < NP; i++)
PAD_PullCtrl(PINS[i], up ? 2 : 1); // GPIO_PuPd_UP : GPIO_PuPd_DOWN
if (up)
gen_rise++;
else
gen_fall++;
}
inline void report() {
uint32_t en = reg(0x30), mask = reg(0x34), type = reg(0x38), pol = reg(0x3C), deb = reg(0x48), lvl = reg(0x50);
ESP_LOGI("dbg", "gen rise %u fall %u checks %u", gen_rise, gen_fall, checks);
for (int i = 0; i < NP; i++) {
uint8_t p = PINS[i];
PinInfo *info = pinInfo(p);
// -1 marks a pin with no PinInfo entry
const uint32_t e = info ? info->enabled : 0;
const int gpio = info ? !!(e & PIN_GPIO) : -1, irq = info ? !!(e & PIN_IRQ) : -1, pwm = info ? !!(e & PIN_PWM) : -1;
ESP_LOGI("dbg",
"PA%02u LT 0x%x (GPIO %d IRQ %d PWM %d) HW int_en %u mask %u edge %u pol_high %u deb %u lvl %u | "
"mismatch %u isr digitalRead %u task digitalRead %u",
p, e, gpio, irq, pwm, (en >> p) & 1, (mask >> p) & 1, (type >> p) & 1, (pol >> p) & 1, (deb >> p) & 1,
(lvl >> p) & 1, mismatch[i], isr_reads[p], task_reads[p]);
}
}
} // namespace bwdbg
extern "C" __attribute__((used)) PinStatus __wrap_digitalRead(pin_size_t pin) {
if (pin < 32) {
if (bwdbg::in_isr())
bwdbg::isr_reads[pin] = bwdbg::isr_reads[pin] + 1;
else
bwdbg::task_reads[pin] = bwdbg::task_reads[pin] + 1;
}
return __real_digitalRead(pin);
}
@@ -0,0 +1,27 @@
# Layout B: BS on PA19; F0/I0 moved to PA12/PA00
substitutions:
name: bw12-edges-b
bs_pin: PA19
packages:
base: !include bw12-base.yaml
sensor:
- platform: pulse_counter
name: F0
pin:
number: PA12
inverted: false
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "F0 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: I0
pin:
number: PA00
inverted: true
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "I0 raw %.0f gen_rise %u", x, g - l); l = g;'
@@ -0,0 +1,104 @@
substitutions:
name: bw12-time
esphome:
name: ${name}
friendly_name: BW12 Time Test
platformio_options:
upload_speed: 115200
rtl87xx:
board: bw12
framework:
version: recommended
logger:
level: DEBUG
baud_rate: 115200
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
captive_portal:
button:
- platform: restart
name: Restart
interval:
- interval: 20s
then:
- lambda: |-
static bool done = false;
if (done) return;
done = true;
// mask ROM: SYSTIMER_TickGet at 0x33a0 (32 bytes), RTIM_GetCount at 0xb42c (72 bytes)
const uint32_t ranges[2][2] = {{0x33a0, 32}, {0xb42c, 72}};
for (auto &r : ranges) {
char hex[3 * 72 + 1];
const volatile uint8_t *p = (const volatile uint8_t *) r[0];
for (uint32_t i = 0; i < r[1]; i++) sprintf(hex + 3 * i, "%02x ", p[i]);
ESP_LOGI("tm", "ROM %05x: %s", (unsigned) r[0], hex);
}
- interval: 5s
then:
- lambda: 'ESP_LOGI("tm", "CLOCK ms %u us %u", millis(), micros());'
- interval: 30s
then:
- lambda: |-
volatile uint32_t *demcr = (volatile uint32_t *) 0xE000EDFC, *dwt = (volatile uint32_t *) 0xE0001000;
*demcr |= 1u << 24;
dwt[0] |= 1;
const float mhz = SystemCoreClock / 1e6f;
// cycles per call, 1000 calls, loop overhead subtracted
volatile uint32_t sink = 0;
uint32_t c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + i;
uint32_t base = dwt[1] - c0;
c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + millis();
uint32_t ms_c = dwt[1] - c0 - base;
c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + micros();
uint32_t us_c = dwt[1] - c0 - base;
uint32_t isr_c;
{
InterruptLock lock;
c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + micros();
isr_c = dwt[1] - c0 - base;
}
ESP_LOGI("tm", "BENCH cycles per call millis %.1f micros %.1f micros_locked %.1f (core %.1f MHz)",
ms_c / 1000.0f, us_c / 1000.0f, isr_c / 1000.0f, mhz);
uint32_t got[6];
const uint32_t req_us[3] = {50, 500, 5000};
for (int i = 0; i < 3; i++) {
uint32_t a = dwt[1];
delayMicroseconds(req_us[i]);
got[i] = uint32_t((dwt[1] - a) / mhz);
}
const uint32_t req_ms[3] = {1, 10, 100};
for (int i = 0; i < 3; i++) {
uint32_t a = dwt[1];
delay(req_ms[i]);
got[3 + i] = uint32_t((dwt[1] - a) / mhz);
}
ESP_LOGI("tm", "DELAY real us: delayMicroseconds 50->%u 500->%u 5000->%u, delay 1->%u 10->%u 100->%u",
got[0], got[1], got[2], got[3], got[4], got[5]);
uint32_t first = micros(), prev = first, maxstep = 0, back = 0, distinct = 0, mism = 0;
for (int i = 0; i < 20000; i++) {
uint32_t ms = millis(), m = micros(), ms2 = millis();
if (ms == ms2 && m / 1000 != ms) mism++;
if (m != prev) { distinct++; if (m < prev) back++; else if (m - prev > maxstep) maxstep = m - prev; }
prev = m;
}
ESP_LOGI("tm", "MICROS span %u distinct %u maxstep %u backwards %u, micros/1000 != millis %u of 20000",
prev - first, distinct, maxstep, back, mism);
@@ -0,0 +1,48 @@
# Layout A: BS on PA22; F13/I13 on PA12/PA00, F0/I0 on PA14/PA15, B13 on PA19 counting both edges
substitutions:
name: bw12-edges-a
bs_pin: PA22
packages:
base: !include bw12-base.yaml
sensor:
- platform: pulse_counter
name: F13
pin: PA12
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "F13 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: I13
pin:
number: PA00
inverted: true
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "I13 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: F0
pin: PA14
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "F0 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: I0
pin:
number: PA15
inverted: true
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "I0 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: B13
pin: PA19
count_mode:
rising_edge: INCREMENT
falling_edge: INCREMENT
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "B13 raw %.0f gen_rise %u", x, g - l); l = g;'
@@ -0,0 +1,47 @@
substitutions:
name: pulse-bk
inv: "false"
esphome:
name: ${name}
platformio_options:
build_flags:
- -DGEN_PIN_A=26
- -DGEN_PIN_B=8
includes:
- pulsegen.hpp
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
interval:
- interval: 50ms
then:
- lambda: pulsegen::tick();
sensor:
- platform: pulse_counter
name: PC default filter
pin:
number: P6
inverted: ${inv}
update_interval: 5s
on_raw_value:
- lambda: 'ESP_LOGI("meas", "F13 %.1f", x);'
- platform: pulse_counter
name: PC filter 0
pin:
number: P24
inverted: ${inv}
internal_filter: 0us
update_interval: 5s
on_raw_value:
- lambda: 'ESP_LOGI("meas", "F0 %.1f", x);'
@@ -0,0 +1,39 @@
substitutions:
name: pulse-rtl
inv: "false"
filt: 13us
key: PC
esphome:
name: ${name}
platformio_options:
build_flags:
- -DGEN_PIN_NAME=17
includes:
- pulsegen.hpp
rtl87xx:
board: bw15
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
interval:
- interval: 50ms
then:
- lambda: pulsegen::tick();
sensor:
- platform: pulse_counter
name: PC
pin:
number: PA18
inverted: ${inv}
internal_filter: ${filt}
update_interval: 5s
on_raw_value:
- lambda: 'ESP_LOGI("meas", "${key} %.1f", x);'
@@ -0,0 +1,59 @@
#pragma once
// One pulse shape per 35 s stage on GEN_PIN_A and GEN_PIN_B (BK72xx analogWrite) or GEN_PIN_NAME (AmebaZ2 pwmout)
#include <Arduino.h>
#ifdef GEN_PIN_NAME
// mbed pwmout API (its header clashes with Arduino's PinMode); pwmout_t is held in an oversized buffer
extern "C" {
void pwmout_init(void *obj, int pin);
void pwmout_period_us(void *obj, int us);
void pwmout_pulsewidth_us(void *obj, int us);
}
#endif
#include "esphome/core/log.h"
namespace pulsegen {
struct Stage {
uint32_t period_us;
uint32_t high_us;
};
static constexpr Stage STAGES[] = {
{1000, 38}, {10000, 38}, {10000, 50}, {10000, 75}, {10000, 100}, {10000, 150},
{10000, 200}, {1000, 200}, {10000, 300}, {10000, 500}, {10000, 1000}, {100000, 1000},
{100000, 5000}, {100000, 50000}, {2000, 1000}, {1000, 500}, {250, 125}, {100000, 38},
};
static constexpr int N = sizeof(STAGES) / sizeof(STAGES[0]);
static constexpr uint32_t STAGE_MS = 35000;
#ifdef GEN_PIN_NAME
// Arduino analogWrite re-init faults on the 2nd period change on AmebaZ2: keep one pwmout and retune it
inline void apply(const Stage &st) {
alignas(8) static uint8_t pwm[512];
static bool init = false;
if (!init) {
pwmout_init(pwm, GEN_PIN_NAME);
init = true;
}
pwmout_period_us(pwm, st.period_us);
pwmout_pulsewidth_us(pwm, st.high_us);
}
#else
inline void apply_pin(uint8_t pin, const Stage &s) {
pinMode(pin, INPUT); // drop PWM mode so the new period is taken
analogWriteResolution(16);
analogWritePeriod(s.period_us);
analogWrite(pin, (int) (65535.0f * s.high_us / s.period_us + 0.5f));
}
inline void apply(const Stage &st) {
apply_pin(GEN_PIN_A, st);
apply_pin(GEN_PIN_B, st);
}
#endif
inline void tick() {
static int last = -1;
int s = (millis() / STAGE_MS) % N;
if (s == last)
return;
last = s;
apply(STAGES[s]);
ESP_LOGI("gen", "STAGE %d period %u high %u expect_per_min %.1f", s, STAGES[s].period_us, STAGES[s].high_us,
60e6f / STAGES[s].period_us);
}
} // namespace pulsegen
@@ -0,0 +1,47 @@
"""Sum the bw12 edge test `meas` lines per counter: counted pulses against generated rising edges."""
from collections import defaultdict
import pathlib
import re
import sys
MEAS = re.compile(r"meas:\d+\]: (\w+) raw ([\d.]+) gen_rise (\d+)")
BS = re.compile(r"BS changes (\d+) gen_edges (\d+) state (\d)")
def summarize(path: str) -> None:
counters: dict[str, list[float]] = defaultdict(lambda: [0.0, 0, 0])
bs = [0, 0, 0]
state = "?"
seen: set[str] = set()
with pathlib.Path(path).open(encoding="utf-8", errors="replace") as f:
for line in f:
if m := MEAS.search(line):
key = m[1]
if key not in seen: # the first window is partial
seen.add(key)
continue
c = counters[key]
c[0] += float(m[2]) / 6 # pulses per minute over a 10 s window
c[1] += int(m[3])
c[2] += 1
elif m := BS.search(line):
if "BS" not in seen:
seen.add("BS")
continue
bs[0] += int(m[1])
bs[1] += int(m[2])
bs[2] += 1
state = m[3]
print(path.rsplit("/", 1)[-1])
for key, (counted, rises, windows) in sorted(counters.items()):
ratio = f"{counted / rises:.3f}" if rises else "n/a (no generated edges)"
print(
f" {key}: windows {windows} counted {counted:.0f} gen_rise {rises} ratio_to_rise {ratio}"
)
print(f" BS: windows {bs[2]} changes {bs[0]} gen_edges {bs[1]} last_state {state}")
if __name__ == "__main__":
for arg in sys.argv[1:]:
summarize(arg)
@@ -0,0 +1,45 @@
"""Default ltchiptool's -L to the newest installed LibreTiny platform.
ltchiptool reads its chip list from ~/.platformio/platforms/libretiny, which can be an older copy than the one a
build used (BK7238 UF2s then fail with "UF2 of unsupported family").
"""
import json
from pathlib import Path
import re
import sys
def _version(platform: Path) -> tuple[int, ...]:
try:
text = json.loads((platform / "platform.json").read_text(encoding="utf-8"))[
"version"
]
except (OSError, KeyError, ValueError):
return ()
# Numeric prefix of each part, so 1.9.0-dev still sorts
return tuple(
int(m[1]) for part in text.split(".")[:3] if (m := re.match(r"(\d+)", part))
)
def with_platform(args: list[str]) -> list[str]:
if any(a.startswith(("-L", "--libretiny-path")) for a in args):
return args
found = list((Path.home() / ".platformio" / "platforms").glob("libretiny*"))
platforms = [p for p in found if _version(p)]
for p in found:
if p not in platforms:
print(
f"lt_platform: skipped {p} (no numeric version in platform.json)",
file=sys.stderr,
)
if not platforms:
print(
f"lt_platform: no versioned LibreTiny platform in {[str(p) for p in found]}, using ltchiptool's default",
file=sys.stderr,
)
return args
chosen = max(platforms, key=_version)
print(f"lt_platform: using {chosen}", file=sys.stderr)
return ["-L", str(chosen), *args]
@@ -0,0 +1,64 @@
# MCP23017 at 0x27 on an ESP8266 (Feather HUZZAH): SDA GPIO4, SCL GPIO5, INTA to GPIO14, INTB to GPIO13,
# PA0 jumpered to PB0 and PA1 to PB1. The variant file adds the mcp23017 hub and mcptest. Substitution: name.
esphome:
name: ${name}
esp8266:
board: huzzah
logger:
baud_rate: 115200
external_components:
- source:
type: local
path: ext
components: [mcptest]
i2c:
sda: GPIO4
scl: GPIO5
scan: false
frequency: 100kHz
binary_sensor:
- platform: gpio
id: pb0
name: PB0
pin:
mcp23xxx: mcp
number: 8
mode:
input: true
interrupt: CHANGE
on_state:
- lambda: id(tst).count_change();
- platform: gpio
id: pb1
name: PB1
pin:
mcp23xxx: mcp
number: 9
mode:
input: true
interrupt: CHANGE
on_state:
- lambda: id(tst).count_change();
mcptest:
id: tst
address: 0x27
label: ${name}
isr_inta: ${isr_inta}
pa0:
mcp23xxx: mcp
number: 0
mode:
output: true
pa1:
mcp23xxx: mcp
number: 1
mode:
output: true
pb0: pb0
pb1: pb1
@@ -0,0 +1,47 @@
"""Test only: drives PA0/PA1, reads IOCON and the INT lines, and checks the PB binary sensors."""
from esphome import pins
import esphome.codegen as cg
from esphome.components import binary_sensor, i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
DOMAIN = "mcptest"
mcptest_ns = cg.esphome_ns.namespace("mcptest")
McpTest = mcptest_ns.class_("McpTest", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(McpTest),
cv.Required("pa0"): pins.gpio_output_pin_schema,
cv.Required("pa1"): pins.gpio_output_pin_schema,
cv.Required("pb0"): cv.use_id(binary_sensor.BinarySensor),
cv.Required("pb1"): cv.use_id(binary_sensor.BinarySensor),
cv.Optional("isr_inta", default=False): cv.boolean,
cv.Optional("label", default=""): cv.string,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x27))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(
var.set_pins(
await cg.gpio_pin_expression(config["pa0"]),
await cg.gpio_pin_expression(config["pa1"]),
)
)
cg.add(
var.set_bs(
await cg.get_variable(config["pb0"]), await cg.get_variable(config["pb1"])
)
)
cg.add(var.set_opts(config["isr_inta"], config["label"]))
Loaded 100 of 1051 files, more files were not shown because too many files have changed in this diff. Show more