Compare commits

...
Author SHA1 Message Date
clyde 547dbf18d4 Merge branch 'runtime-image-jpeg-scale' into runtime-image-integer-draw 2026-10-22 22:03:58 +11:00
clyde ba3af07a23 Merge branch 'online-image-progress' into runtime-image-jpeg-scale 2026-10-22 22:03:58 +11:00
clyde 9c6ae9c615 Merge branch 'online-image-jpeg-stream' into online-image-progress 2026-10-22 22:03:58 +11:00
clyde de02ef8555 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 22:03:57 +11:00
clydeandClaude Opus 5.5 38b23152f4 [runtime_image] Put the is_finished() doc back above is_finished()
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:03:51 +11:00
clyde 375e8bc39b Merge branch 'runtime-image-jpeg-scale' into runtime-image-integer-draw 2026-10-22 21:44:49 +11:00
clyde 16a91ab760 Merge branch 'online-image-progress' into runtime-image-jpeg-scale 2026-10-22 21:44:44 +11:00
clydeandClaude Opus 5.5 2456e8d686 [online_image] Use emplace_back in the download started test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:44:39 +11:00
clyde 0c8e1c3f1c Merge branch 'runtime-image-jpeg-scale' into runtime-image-integer-draw 2026-10-22 21:35:06 +11:00
clyde a3c575d643 Merge branch 'online-image-progress' into runtime-image-jpeg-scale 2026-10-22 21:31:12 +11:00
clydeandClaude Opus 5.5 0573c8b568 [online_image] Add on_download_started
Fires when the server has accepted the request and the image starts to
download, before the first on_download_progress; not for a 304 or an
HTTP error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:24:05 +11:00
clyde d01205fa62 Merge branch 'runtime-image-jpeg-steps' into runtime-image-jpeg-scale 2026-10-22 21:17:21 +11:00
clyde de7ea89d0b Merge branch 'online-image-jpeg-stream' into online-image-progress 2026-10-22 21:17:19 +11:00
clyde 0077f68158 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 21:17:18 +11:00
clydeandClaude Opus 5.5 7458f8c7d8 [runtime_image] Pin JPEGDEC to 1.8.4-esphome.3, built with -O2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:17:13 +11:00
clyde 0e5e0a8ff6 Merge remote-tracking branch 'upstream/dev' into runtime-image-jpeg-steps 2026-10-22 21:15:57 +11:00
clydeandClaude Opus 5.5 752c230405 [runtime_image] Check once per draw() call whether the image is scaled
An image drawn at its own size needs no span maths at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:14:33 +11:00
clydeandClaude Opus 5.5 cfc92657a2 [runtime_image] Say the generic draw() path keeps the same pixels as the RGB565 path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:51:14 +11:00
clydeandClaude Opus 5.5 32f32f144d [runtime_image] Skip the span division for an image drawn at its own size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:44:48 +11:00
clydeandClaude Opus 5.5 9b60e7cff2 [runtime_image] Decode a JPEG at the smallest JPEGDEC scale that covers the buffer
JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work, but
every JPEG was decoded at full size and then shrunk to the buffer. A
3596 x 2840 image shown at 281 x 222 took 7.1 s on an ESP32-S3; at
1/8 size it takes 2.0 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:19:44 +11:00
clydeandClaude Opus 5.5 02b8db1f3f [runtime_image] Draw each target pixel once, with integer maths
ImageDecoder::draw() painted every source pixel into the buffer, so a
shrunk image wrote each target pixel many times, and worked out the
spans with double multiplications and ceil() for every pixel. The S3
has no double FPU, so drawing took about 6 us a pixel: 3.7 s of a 1000
x 617 PNG's 6 s. Give each source pixel the span from ceil(start *
scale) to ceil(end * scale), worked out with integers, so neighbouring
spans meet without overlapping. The PNG now loads in 3.2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:17:27 +11:00
clyde f860c8a8d9 Merge branch 'online-image-jpeg-stream' into online-image-progress 2026-10-22 20:13:39 +11:00
clyde bb7a060138 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 20:13:38 +11:00
clydeandClaude Opus 5.5 84839507bd [runtime_image] Pin JPEGDEC to 1.8.4-esphome.2, with the ESP32-S3 SIMD under ESP-IDF
Full-size JPEG decodes are 12-19% faster on an ESP32-S3 with ESP-IDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:13:33 +11:00
18348b8794 [vnc] Add VNC client component (#20623)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-21 21:14:37 -10:00
f7dc2ecb51 [wifi] [ethernet] Bridge the WISP access point to an ethernet upstream (#7147)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 07:02:25 +00:00
ddcc5874ed [api] Let a media_player ask for a constant bitrate (#19962)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 06:17:59 +00:00
clyde ef54eb14d8 Merge branch 'online-image-jpeg-stream' into online-image-progress 2026-10-22 16:29:02 +11:00
clydeandClaude Opus 5.5 60aa3e24f1 [online_image] Brace the whole-file fallback for clang-tidy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:01 +11:00
clyde 63566999bd Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 16:28:45 +11:00
clydeandClaude Opus 5.5 1728d3326b [runtime_image] Log a failed open from a source at debug level, as the caller may recover
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:28:44 +11:00
clyde 2b99c56754 Merge branch 'online-image-jpeg-stream' into online-image-progress 2026-10-22 16:21:09 +11:00
clydeandClaude Opus 5.5 d79cd9a7eb [online_image] Download the whole JPEG when its headers do not fit the buffer
Before JPEG images were decoded as they download, the buffer grew to
hold the whole file, so an image with large EXIF or ICC data decoded
with any buffer_size. Keep that working: when the headers do not fit,
grow the buffer to the file size, keeping what has arrived, and decode
it at the end as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:21:08 +11:00
esphome[bot] 28013a60e1 Bump aioesphomeapi from 46.19.0 to 46.20.0 (#21362) 2026-10-21 19:19:35 -10:00
clyde fa18994fab Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 16:19:20 +11:00
clydeandClaude Opus 5.5 1090706216 [runtime_image] Count the bytes of a stepped decode, and say when begin_source() can be called
Report any failed read of the source as DECODE_ERROR_READ from one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:19:01 +11:00
J. Nick Koston 966193cb09 [core] Emit generated lambda code after the user includes (#21307) 2026-10-21 18:28:04 -10:00
e1077748be [api][cover] Expose toggle support in native API (#16506)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 18:11:09 -10:00
cc89a011e2 Bump clang-tidy + clang-format from 22.1.8 to 23.1.3 (#21296)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 17:45:36 -10:00
clydeandClaude Opus 5.5 ced497995d [online_image] Add on_download_progress, to show how far a download has got
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:35:37 +11:00
clydeandClaude Opus 5.5 228c553f60 [online_image] Also give the buffer_size hint when unreadable headers end in a read error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:34:15 +11:00
clydeandClaude Opus 5.5 563e197c6d [online_image] Open a streamed JPEG in a pass of its own, and only blame the buffer for unreadable headers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:29:07 +11:00
clyde 9d07f1f400 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 14:27:47 +11:00
clydeandClaude Opus 5.5 846fc70bdf [runtime_image] Report a progressive JPEG as an unsupported format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:27:46 +11:00
Craig Andrews c76e9e1d62 [epaper_spi] Support Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) (#21218) 2026-10-21 17:09:44 -10:00
esphome[bot] 7a3aac407f Bump aioesphomeapi from 46.18.0 to 46.19.0 (#21345) 2026-10-21 17:03:34 -10:00
J. Nick Koston b0a1054e8d [ld2410] Publish queried number values directly (#21346) 2026-10-21 16:56:51 -10:00
clyde 74212d8d04 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 13:41:53 +11:00
clydeandClaude Opus 5.5 101c9cd840 [runtime_image] Take JPEGDEC from esphome-libs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:41:48 +11:00
clyde 8152dcf224 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 13:37:13 +11:00
clydeandClaude Opus 5.5 f2d50b4aa7 [runtime_image] Drop the trailing underscore from the static JPEGDEC callbacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:37:13 +11:00
a8a8acb918 [sensor] Add Relative Humidity Correction Filter (#18287)
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>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 16:33:11 -10:00
Jesse Hills 3ae5e1dfad [core] Add unit tests for the smaller core modules (#21333) 2026-10-22 15:23:22 +13:00
Jonathan Swoboda ef79d48b6f [core] Add a default label to the switches clang-tidy 23 flags as not covering every path (#21338) 2026-10-22 02:22:32 +00:00
clydeandClaude Opus 5.5 f75b3d6ded [online_image] Test decode and read errors part way through a streamed JPEG
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:17:00 +11:00
clyde 3cf29bf4fb Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 13:14:54 +11:00
clydeandClaude Opus 5.5 f752e3b150 [runtime_image] Report a failed seek to the start as a read error
Also limit the JPEGDEC pin comment to the fixes ESPHome uses: it never
crops, and refuses progressive images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:14:51 +11:00
Jonathan Swoboda 94804cc741 [core] Drop IRAM_ATTR from interrupt handler declarations (#21335) 2026-10-21 16:13:18 -10:00
Jonathan Swoboda 879c5e5e14 [weikai][haier][zephyr_ble_server][wifi][improv_base] Fix the bugprone and diagnostic findings of clang-tidy 23 (#21334) 2026-10-22 02:12:43 +00:00
Jonathan Swoboda edda565237 [esp32_ble][esp32_ble_tracker][bluetooth_proxy][bluetooth_connection] Re-export ble_device_base types with using-declarations (#21337) 2026-10-21 16:09:19 -10:00
Clyde StubbsandClaude Code 3616052a7c [ci] Add display-test agent skill (#21098)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-22 15:08:53 +13:00
clyde a5bc7070d1 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 13:06:41 +11:00
clydeandClaude Opus 5.5 b2eebfb010 [runtime_image] Pin JPEGDEC to the 1.8.4-esphome.1 release
Adds the upstream fix that skips building AC Huffman tables for
progressive images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:06:37 +11:00
Jonathan Swoboda fbc4bc9e10 [core] Fix the performance and modernize findings of clang-tidy 23 (#21336) 2026-10-22 02:03:00 +00:00
01b29dbf53 [cover] Add stop tilt support (#20248)
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>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 15:49:07 -10:00
esphome[bot] e15797c18b Bump aioesphomeapi from 46.17.0 to 46.18.0 (#21332) 2026-10-22 01:46:31 +00:00
J. Nick Koston 3c6b1e66e7 [ci] Add the MQTT TLS, udp.write and entity icon flows to the hardware-test skill (#21264) 2026-10-21 15:30:27 -10:00
J. Nick Koston 179f9ed395 [mt6701_i2c][opentherm][const] Move CONF_OUT_PIN to the shared component constants (#21329) 2026-10-21 15:25:39 -10:00
J. Nick Koston 2f7105fa54 [core] Fix the findings of clang-tidy 23 (#21301) 2026-10-21 21:20:54 -04:00
J. Nick Koston e3590bdac7 [ci] Label pull requests that change api.proto (#21294) 2026-10-21 15:15:43 -10:00
Jonathan Swoboda 54440a3559 [pulse_counter] Attach the software interrupt to the counted edges only (#21271) 2026-10-21 20:46:48 -04:00
J. Nick Koston 45c7fd5214 [cdc_acm_uart][serial_proxy][const] Move CONF_DTR_PIN and CONF_RTS_PIN to the shared component constants (#21325) 2026-10-22 00:41:12 +00:00
Jesse Hills bfcefbb896 [core] Assert sub-device relations in integration test (#21326) 2026-10-21 14:40:46 -10:00
J. Nick Koston 40a58e1d3c [mt6701_i2c][as5600][const] Move CONF_PWM_FREQUENCY to the shared component constants (#21324) 2026-10-21 14:37:39 -10:00
Jesse Hills 07f24708b4 [core] Add relation option to sub-devices (#21232) 2026-10-22 00:27:38 +00:00
Jesse Hills c26967f623 [core] Add unit tests for core modules and test the pure-Python YAML loader (#21314) 2026-10-22 13:13:45 +13:00
esphome[bot] 9ff76f4d79 Bump aioesphomeapi from 46.16.0 to 46.17.0 (#21322) 2026-10-21 14:09:14 -10:00
J. Nick Koston e17e386dea [core] Read entity icons and device classes straight from flash (#21111) 2026-10-22 00:01:44 +00:00
Jesse Hills 7133d02d4c [core] Remove dead generated_files logic from copy_src_tree (#21312) 2026-10-22 11:52:03 +13:00
clyde a596d1d3c8 Merge branch 'runtime-image-jpeg-steps' into online-image-jpeg-stream 2026-10-22 09:09:13 +11:00
clydeandClaude Opus 5.5 6701aedae2 [runtime_image] Pin JPEGDEC with the grayscale, crop and progressive fixes
A grayscale JPEG decoded for any image type but direct RGB565 came out garbled:
JPEGDEC wrote RGB565 words where it was asked for RGB8888. The new pin fixes
that, along with cropping and a crash on progressive images with skipped blocks,
which ESPHome does not use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:09:09 +11:00
J. Nick Koston e2d261e744 [web_server] Read flash state strings PROGMEM-aware on ESP8266 (#21316) 2026-10-21 12:06:44 -10:00
Darafei PraliaskouskiandJ. Nick Koston 6e054555ac [const][heatpumpir][mitsubishi] Move horizontal_default/vertical_default to shared constants (#21309)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-21 21:47:22 +00:00
esphome[bot] 4fd3e41ee7 Bump aioesphomeapi from 46.15.1 to 46.16.0 (#21311) 2026-10-21 21:47:16 +00:00
J. Nick Koston 4280ac9659 [core] Move the remaining components to flash-safe entity names and deprecate get_name() (#20615) 2026-10-22 10:07:14 +13:00
J. Nick Kostonandpre-commit-ci-lite[bot] 211dcfa325 [core] Keep entity names in flash on ESP8266 (#20423)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 10:07:14 +13:00
c74a9da996 [veml6040] Add VEML6040 RGBW colour sensor (#20812)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 10:54:25 -10:00
Jonathan Swoboda b67ac5d0be [esp32_hosted] Keep the OTA watchdog timeout through the coprocessor end call (#21300) 2026-10-21 16:41:28 -04:00
9c4edc767b [core] Update paho-mqtt to v2.1.0 (#18402)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 10:30:55 -10:00
clydeandClaude Opus 5.5 6fddbd1d48 [online_image] Decode a JPEG as it downloads
A JPEG had to be downloaded whole, into a buffer grown to the file size, and was
then decoded in one call. When the server gives the size, the JPEG decoder now
reads the download from the buffer itself and decodes a row of MCUs at a time,
within a time budget per loop pass. The buffer only has to hold the headers, so
buffer_size bounds the RAM used, and a slow link makes the decoder wait for the
rest rather than decode past what has arrived. Downloads without a size keep the
old path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 07:23:28 +11:00
esphome[bot] 04432d3fd7 Bump aioesphomeapi from 46.14.0 to 46.15.1 (#21298) 2026-10-21 20:23:00 +00:00
clydeandClaude Opus 5.5 03aaa00d75 [runtime_image] Refuse a source decode without a usable image size
JPEGDEC needs the file size when it opens a source, and holds it in an int.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 07:21:14 +11:00
J. Nick Koston 63b610bf17 [tests] Wait for the ld2410 query ACK log line instead of asserting it arrived (#21191) 2026-10-21 10:13:22 -10:00
clydeandClaude Opus 5.5 b612eb2929 [runtime_image] Let a source-reading decoder wait for data that has not arrived
A source that is still arriving, such as a download, may have nothing to give
when the decoder asks for more. The pinned JPEGDEC now pauses before an MCU when
less input is buffered than it needs and the source has no more yet, and
decode_step() reports that as DECODE_STEP_NEED_DATA; the next call carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 07:04:54 +11:00
J. Nick Koston 62c9e05541 [tests] Wait for the template sensor's first reading before the Home Assistant service tests (#21302) 2026-10-21 09:56:20 -10:00
esphome[bot] 671ac3e0bc Bump bundled esphome-device-builder to 1.26.0 (#21299) 2026-10-21 09:45:18 -10:00
dependabot[bot] 6671ded245 Bump platformdirs from 4.13.3 to 4.13.4 (#21297)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 19:32:36 +00:00
dependabot[bot] 9010c4bb2e Bump filelock from 4.2.1 to 4.2.2 (#21295)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 19:32:16 +00:00
J. Nick Koston e0a48ff683 [waveshare_epaper] Set removal of the deprecated component for 2027.5.0 (#20692) 2026-10-21 19:06:38 +00:00
e3d7b957a7 [mqtt] increase TLS options and compatibility (#14318)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 19:02:31 +00:00
J. Nick Koston 9ab230f98d [speaker] Build the pipeline task names on the stack (#21203) 2026-10-21 08:41:59 -10:00
Jonathan Swoboda 35883270fe [ci] Ignore BUILD_ALWAYS external project byproducts in the idf.py check (#21291) 2026-10-21 08:35:58 -10:00
J. Nick Koston da37e89b9d [tests] Let the offline probe finish before checking that the continuous modbus poll stopped (#21293) 2026-10-21 08:35:38 -10:00
J. Nick Koston 32bbf55f2a [tests] Clear testing_mode after every test (#21292) 2026-10-21 08:35:22 -10:00
Kevin Ahrendt 16759c848f [i2s_audio] Convert audio to the configured slot width on the original ESP32 (#20893) 2026-10-21 14:25:38 -04:00
J. Nick Koston 244e419f82 [tests] Drop the timeout wrappers around the integration state helpers (#21245) 2026-10-21 08:04:00 -10:00
J. Nick Koston 04f53f0ab4 [tests] Fail the test when the integration state helpers time out (#21244) 2026-10-21 08:03:59 -10:00
9eef601f99 [network][socket] Allow disabling IPV4 (#14526)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-21 13:57:49 -04:00
J. Nick Koston 454dbe59db [tests] Speed up the modbus uart_mock integration tests (#21250) 2026-10-21 07:48:38 -10:00
J. Nick Koston 7c0fa49b00 [tests] Speed up the host OTA integration tests (#21248) 2026-10-21 13:29:58 -04:00
J. Nick KostonandJonathan Swoboda c9fe7451a6 [tests] Use wait_or_fail in the scheduler and loop integration tests (#21241)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-21 17:29:44 +00:00
J. Nick Koston 92320ed14e [tests] Use wait_or_fail in the uart_mock integration tests (#21247) 2026-10-21 13:28:32 -04:00
J. Nick Koston 601dcce0e4 [tests] Use require_entity and require_only_entity for the remaining entity lookups (#21243) 2026-10-21 13:20:50 -04:00
J. Nick Koston 980f415c64 [tests] Use require_service and assert_device_name in the online_image and backpressure tests (#21240) 2026-10-21 13:19:35 -04:00
J. Nick Koston 054071171c [tests] Use assert_device_name in the remaining object_id tests (#21238) 2026-10-21 13:19:16 -04:00
J. Nick Koston a8ab7adf0d [nfc] Stop copying the binary sensor match string (#21208) 2026-10-21 13:14:28 -04:00
J. Nick Koston 3bf98fbc98 [mqtt_subscribe] Stop copying the topic (#21207) 2026-10-21 13:10:01 -04:00
J. Nick Koston 8906a3e1bf [tuya] Store the product text in an exact-size buffer (#21206) 2026-10-21 13:09:41 -04:00
J. Nick Koston 0dd02fd68a [thermopro_ble] Pick the parser from the name without storing it (#21205) 2026-10-21 13:08:24 -04:00
J. Nick Koston 1fa828bf9e [gps] Size the listener list at compile time (#21201) 2026-10-21 13:05:34 -04:00
J. Nick Koston 55a658dca6 [slow_pwm] Group the bool members to drop padding (#21200) 2026-10-21 13:02:16 -04:00
J. Nick Koston 436d756396 [ina2xx_base] Expand adc_time in the schema (#21197) 2026-10-21 13:00:55 -04:00
J. Nick Koston 8225fd95e7 [ina226] Expand adc_time in the schema (#21196) 2026-10-21 13:00:29 -04:00
J. Nick KostonandJonathan Swoboda 3cd3d776e7 [climate] Keep custom fan modes and presets in a ConstVector; remove the deprecated traits setters (#20213)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-21 12:42:49 -04:00
J. Nick Koston f3c025c6e3 [fan] Keep preset modes in a ConstVector; remove the deprecated traits setters (#20214) 2026-10-21 12:24:35 -04:00
Jonathan Swoboda 7784210ec4 [epaper_spi] List the SSD1681 driver source on its model (#21270) 2026-10-21 08:42:01 -04:00
Bonne EgglestonandJ. Nick Koston e4dd6d452d [epaper_spi] Add SSD1681 model with differential partial refresh (#20332)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-21 08:18:32 -04:00
Jonathan Swoboda 3f29f1a5f1 [esp32_ulp][esp32] Add component to run programs on the ULP coprocessor (#21127) 2026-10-21 08:16:36 -04:00
Jonathan Swobodaandpre-commit-ci-lite[bot] 80fedf020f [ci] Bump clang-format from 13.0.1 to 22.1.8 (#20551)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-21 08:15:55 -04:00
Jesse Hills 3a15ffa8df Merge branch 'release' into dev 2026-10-22 00:43:07 +13:00
Jesse Hills 704551a6c9 Merge pull request #21268 from esphome/bump-2026.10.0
2026.10.0
2026-10-22 00:42:40 +13:00
clydeandClaude Opus 5.5 63c041a95b [runtime_image] Pin JPEGDEC with the ARM NEON clipped MCU fix
Upstream's fix for the NEON path writing past the end of a row on clipped MCUs,
which overflows a framebuffer whose width is not a multiple of 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:36:44 +11:00
clydeandClaude Opus 5.5 f8d5096093 [runtime_image] Pin JPEGDEC with the ARM NEON colour order fix
The NEON 4:2:0 RGB8888 path swapped red and blue (JPEGDEC PR 121), which shows on
host builds on 64-bit ARM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:26:39 +11:00
clydeandClaude Opus 5.5 f835600fa6 [runtime_image] Let a decoder read its input itself and decode JPEG a row at a time
JPEGDEC decodes a whole image in one call, from a copy of the whole file in RAM.
A JPEGDEC that can stop between rows (1.8.4 plus decodeBegin() and decodeRows(),
pinned from a fork for now) lets the JPEG decoder read a DecodeSource itself,
through JPEGDEC's own 2 KB buffer, and decode one row of MCUs per decode_step().
A component that can read its input on demand, such as a file, no longer needs a
buffer for the whole file or a main loop pass long enough to decode the image.

Decoders that cannot read a source say so, and the fed decode() path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:08:01 +11:00
Jesse Hills f30e70acfa Bump version to 2026.10.0 2026-10-21 23:53:12 +13:00
Jeroen 594a691d9f [web_server_idf] Increase HTTP server task stack to 4512 bytes (#21038) 2026-10-21 23:53:09 +13:00
Hubert Teo ab41553e8d [beken_spi_led_strip] Fix LED strip component for bk7231t (#21259) 2026-10-20 22:31:22 -10:00
Bryce Boe 82a0c5b2f5 [sx126x][sx127x] Move CONF_BITRATE to the shared component constants (#21261) 2026-10-20 22:13:35 -10:00
Jeroen 659eab1958 [web_server_idf] Increase HTTP server task stack to 4512 bytes (#21038) 2026-10-20 19:59:52 -10:00
Jesse Hills fb2ed65d1b Merge branch 'beta' into dev 2026-10-21 16:40:13 +13:00
Jesse Hills 6c90b5c549 Merge pull request #21254 from esphome/bump-2026.10.0b6
2026.10.0b6
2026-10-21 16:39:54 +13:00
Pat Satyshur 940de19d3b [pca9554] Add a device option with the PCA953x/PCA955x/TCA955x names and the PCAL9538 and PCAL9555 expanders (#15449) 2026-10-21 03:26:25 +00:00
Jesse Hills c8df23a9cb Bump version to 2026.10.0b6 2026-10-21 16:17:51 +13:00
4fdce9d96b [web_server_idf] Reduce loopTask stack usage for state SSE (#21109)
Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-21 16:17:49 +13:00
J. Nick Koston 9d296a5748 [ota] Accept an unknown image size in the RP2040 backend so web_server OTA works (#21178) 2026-10-21 16:17:49 +13:00
esphome[bot] f2e013d71a Bump bundled esphome-device-builder to 1.25.0 (#21175) 2026-10-21 16:17:49 +13:00
mrtoy-me ded94c6698 [sx126x][sx127x] Move CONF_MODULATION to the shared component constants (#21249) 2026-10-20 16:50:49 -10:00
Jesse Hills e415c099e0 [font] Crop blank space from glyph bitmaps (#21235) 2026-10-21 15:46:15 +13:00
Jesse Hills d25a925a9b [epaper_spi] Only compile the driver sources the configured models need (#21228) 2026-10-21 15:39:52 +13:00
will-tm 41c3bbff25 [vl53l1x] Add VL53L1X time-of-flight distance sensor (#13605) 2026-10-20 16:05:46 -10:00
J. Nick Koston 3b8b6c6429 [ac_dimmer] Pack members and drop the unused zero-cross ISR pin (#21193) 2026-10-20 15:51:07 -10:00
J. Nick Koston 7c58a9698b [tests] Use require_service in the integration tests (#21214) 2026-10-21 01:47:31 +00:00
J. Nick Koston f47dca7e4e [tests] Use the exact entity lookups in the integration tests (#21213) 2026-10-20 15:42:31 -10:00
J. Nick Koston 701ba8eaa5 [key_collector] Stop copying the key strings (#21210) 2026-10-20 15:42:05 -10:00
J. Nick Koston 7af7b6f1d6 [bme680_bsec] Stop copying the device id (#21209) 2026-10-20 15:34:28 -10:00
J. Nick Koston 7fe4352786 [graph] Stop copying the trace name (#21211) 2026-10-20 15:33:38 -10:00
J. Nick Koston e70b0ace13 [display_menu_base] Stop copying the number format and switch texts (#21212) 2026-10-20 15:33:12 -10:00
J. Nick Koston 915458cfcf [tests] Use assert_device_name in the a-l integration tests (#21215) 2026-10-20 15:32:07 -10:00
J. Nick Koston 4eaad21f08 [tests] Use assert_device_name in the m-z integration tests (#21216) 2026-10-20 15:31:14 -10:00
J. Nick Koston 6e4e5ae830 [mqtt] Print the dump_config header in LOG_MQTT_COMPONENT (#21226) 2026-10-20 15:30:18 -10:00
J. Nick Koston a66faec424 [ci] Add the speaker media player, web_server_idf and MQTT WebSocket flows to the hardware-test skill (#21229) 2026-10-20 15:29:45 -10:00
J. Nick Koston d24cadbc8f [components] Use cv.only_when and cv.key_requires for key dependencies (#21236) 2026-10-20 15:27:03 -10:00
J. Nick Koston f9373bd7b3 [core] Add cv.only_when and cv.key_requires key validators (#21234) 2026-10-20 15:27:03 -10:00
Remco van Essen eb32f80d53 [speaker][speaker_source] Add a set volume for announcements that start while nothing is playing (#21002) 2026-10-20 14:48:11 -10:00
cef13c8697 [mqtt] Add MQTT over WebSocket transport for ESP32 IDF (#16056)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-20 23:33:06 +00:00
epicRE 876537186c [apc1][systa_bus] Move CONF_ERROR_CODE to the shared component constants (#21230) 2026-10-20 11:16:19 -10:00
Jacopo Labardi 6bb51c8cd9 [vbus] Discard P1 telegrams after a frame checksum error (#21045) 2026-10-20 11:15:40 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] 5d9ca0e45b [core] Share one log_entity_header() for entity dump_config headers (#21116)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-20 20:53:28 +00:00
f3f056ca41 [gree] Add sleep preset support for GREE YAC1FB9 (#18090)
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-10-20 10:42:40 -10:00
bf36e7c33a [web_server_idf] Reduce loopTask stack usage for state SSE (#21109)
Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-20 10:41:10 -10:00
dependabot[bot] bac4d2aa29 Bump yamlrocks from 0.7.0 to 0.7.1 (#21223)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-20 19:32:37 +00:00
dependabot[bot] 992860637b Bump pillow from 12.3.0 to 13.0.0 (#21224)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-20 09:32:19 -10:00
dependabot[bot] b0d3899483 Bump pytest-mock from 3.16.0 to 3.17.0 (#21225)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-20 19:32:15 +00:00
dependabot[bot] f46ebdd345 Bump filelock from 4.2.0 to 4.2.1 (#21222)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-20 09:31:45 -10:00
fbe4699ad4 [nextion] Fix: Properly deliver numeric responses (#17744)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@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@koston.org>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:08:41 -10:00
Jesse Hills 6423af2978 [core] Replace flake8 with ruff docstring rules and drop unused scripts (#21176) 2026-10-21 07:32:56 +13:00
J. Nick Koston ebc9619e3b [sx126x] Pass the sx1261 hardware version as a flag (#21202) 2026-10-20 11:36:38 -04:00
J. Nick Koston c0cbf9cdfd [tests] Add require_service, assert_device_name and exact entity lookups (#21150) 2026-10-20 09:07:31 -04:00
J. Nick Koston 89eb58edce [ld2420][ld2450] Use Apply numbers for hub forwarding numbers (#21146) 2026-10-20 09:06:58 -04:00
J. Nick Koston 442bd4975e [ci] Add the SEN0395, LD2450 zone and held light report flows to the hardware-test skill (#21160) 2026-10-20 09:05:05 -04:00
J. Nick KostonandJonathan Swoboda 808075bf93 [components] Set optional plain values with cg.add_optional (n-z) (#21183)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-20 09:03:51 -04:00
J. Nick Koston a2fbedeac3 [components] Set optional plain values with cg.add_optional (a-m) (#21181) 2026-10-20 08:55:50 -04:00
J. Nick Koston 8b951fa620 [haier_tundra][tm1638][bl0940][micronova] Use Apply entities for hub forwarding buttons and switches (#21148) 2026-10-20 08:52:26 -04:00
J. Nick Koston b18a5b09e7 [tests] Drop the external component path replacements the yaml_config fixture already does (#21149) 2026-10-20 08:50:38 -04:00
J. Nick Koston 6c4a42c069 [daikin][daikin_brc][daikin_arc][whirlpool][samsung] Send and read IR frame bytes with the remote_base helpers (#21170) 2026-10-20 08:45:43 -04:00
J. Nick Koston 18120cc4a8 [airton][ballu][delonghi][hitachi_ac424][mitsubishi] Send and read IR frame bytes with the remote_base helpers (#21169) 2026-10-20 08:45:03 -04:00
J. Nick Koston 7719179dcd [tcl112][zhlt01][heatpumpir][fujitsu_general] Send and read IR frame bytes with the remote_base helpers (#21168) 2026-10-20 08:44:13 -04:00
J. Nick Koston 74e1057268 [yashima][remote_base] Send IR frame bytes with item_bytes, including most significant bit first (#21171) 2026-10-20 08:39:49 -04:00
J. Nick Koston 6304411341 [core] Add cg.add_optional for optional plain config values (#21180) 2026-10-20 08:38:02 -04:00
J. Nick Koston fd902a646a [ota] Accept an unknown image size in the RP2040 backend so web_server OTA works (#21178) 2026-10-20 07:09:13 -04:00
John McLear 09b580e4ea [st25r] Add ST25R3916 NFC reader (SPI, ISO14443A) (#15608) 2026-10-19 22:53:50 -10:00
esphome[bot] 89536632dc Bump bundled esphome-device-builder to 1.25.0 (#21175) 2026-10-19 20:18:55 -10:00
Clyde Stubbs 3da581233a [st7305] Fix position of partial updates on landscape panels (#21172) 2026-10-20 16:08:03 +11:00
J. Nick Koston ff1c448ecc [core] Document the new codegen, bus and Apply entity helpers in AGENTS.md (#21130) 2026-10-19 17:35:09 -10:00
J. Nick Koston 1430428f70 [tests] Use wait_or_fail in the api, light, script and other integration tests (#21165) 2026-10-19 17:34:25 -10:00
J. Nick Koston 91f7b0d147 [tests] Use wait_or_fail in the sensor, text, loop and host integration tests (#21164) 2026-10-19 17:34:00 -10:00
J. Nick Koston 5bccdab01d [remote_base] Add helpers to send and read IR frame bytes bit by bit (#21167) 2026-10-19 17:29:11 -10:00
Jesse Hills 68df9248ed [sx126x][sx127x] Move CONF_RST_PIN to the shared component constants (#21174) 2026-10-20 16:11:27 +13:00
J. Nick Koston 0a501b7ef5 [core] Pass the LOG_PIN prefix to the log straight from flash (#21117) 2026-10-19 20:04:39 -04:00
J. Nick Koston b65395174c [core] Log the build time at boot straight from flash (#21118) 2026-10-19 20:04:05 -04:00
J. Nick Koston da0fed0517 [dfrobot_sen0609][airton] Use Apply selects and switches for hub forwarding entities (#21147) 2026-10-19 14:02:53 -10:00
J. Nick Koston 984ecc9f3c [tests] Add wait_or_fail for integration test timeouts (#21152) 2026-10-19 14:02:19 -10:00
J. Nick Koston 7a1ed1ce34 [atm90e32] Use Apply buttons for the calibration buttons (#21163) 2026-10-19 14:01:51 -10:00
J. Nick Koston c6eeeac7e8 [copy] Use an Apply button for the copy button (#21162) 2026-10-19 14:01:26 -10:00
Igor Stadnyk 01524a2ffd [ld2410] Wait for complete query parameter acknowledgements (#21145) 2026-10-19 11:29:39 -10:00
dependabot[bot] 65ba64b9a6 Bump ruff from 0.17.0 to 0.17.1 (#21137) 2026-10-19 09:59:49 -10:00
dependabot[bot] 1b6a43a8a1 Bump esphome/workflows/.github/workflows/lock.yml from 2026.9.0 to 2026.10.2 (#21142) 2026-10-19 09:59:35 -10:00
Jonathan Swoboda 7a88c73fac [espota2] Stop the fake OTA device once the client is done (#21134) 2026-10-19 15:36:22 -04:00
dependabot[bot] e3a7437bdd Bump CodSpeedHQ/action from 5.4.2 to 5.5.0 (#21143)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:34:30 -10:00
dependabot[bot] ee53f8ab1c Bump platformdirs from 4.13.0 to 4.13.3 (#21136)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:33:07 -10:00
dependabot[bot] bc8bebfaba Bump filelock from 4.1.1 to 4.2.0 (#21138)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 19:33:00 +00:00
dependabot[bot] 0ab57126d8 Bump github/codeql-action/analyze from 4.38.3 to 4.39.0 (#21139)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:54 -10:00
dependabot[bot] 6d8e36cc97 Bump esphome/workflows/.github/workflows/stale.yml from 4fbce13026c393d6eb6b247e394dcd9ef1af76c2 to 31d160e5f0ffa0ee65d5b27cb55c153d415f2abe (#21141)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:46 -10:00
dependabot[bot] 0b70f297c2 Bump github/codeql-action/init from 4.38.3 to 4.39.0 (#21140)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:35 -10:00
dependabot[bot] 89a222b95a Bump esptool from 5.5.0 to 5.6.0 (#21135)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:21 -10:00
Daeho RoandJonathan Swoboda b537611bfa [core] Support Python 3.15 (#21052)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-19 09:15:54 -10:00
a3d3dd4a51 [combination] Remove @Cat-Ion from codeowners (#21133)
Co-authored-by: Lynn Ochs <lynn@gremlynn.de>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 18:48:53 +00:00
Igor Stadnyk a5fbd34a1e [ld2460] Preserve buffered frames across overflow (#21123) 2026-10-19 08:23:31 -10:00
Igor Stadnyk fd3c1fad45 [ld2410s] Fix parameter writes after long uptime (#21121) 2026-10-19 08:23:07 -10:00
Igor Stadnyk f250fa73d0 [ld2420] Resume UART reads after a silent command timeout (#21131) 2026-10-19 08:22:32 -10:00
J. Nick Koston 85dd0f71b3 [core] Add Apply entities so hub forwarding buttons, switches, numbers and selects need no class (#20935) 2026-10-19 07:50:56 -10:00
J. Nick Koston 7cf72c287d [web_server] Pass the listing flag as a bool and share the state event send (#21112) 2026-10-19 07:50:39 -10:00
J. Nick Koston dc56b27f33 [api] Send the device info strings from flash and keep the version out of RAM (#21106) 2026-10-19 07:50:21 -10:00
J. Nick Koston 384c2ec26c [ci] Extend the hardware-test skill with the audio, globals, outgoing connection and Home Assistant light flows (#21104) 2026-10-19 07:49:34 -10:00
Jonathan Swoboda 6868011336 [combination][speaker_source][const] Share CONF_SOURCES from components/const (#21129) 2026-10-19 07:49:05 -10:00
J. Nick Koston 8ffdbfd4ce [homeassistant] Judge a light report against the transition it arrived during (#21100) 2026-10-19 07:48:32 -10:00
J. Nick Koston 1cd5641900 [web_server] Keep the remaining ESP8266 web server strings in flash (#21113) 2026-10-19 12:25:30 -04:00
Clyde Stubbs 15b988be9c [cst9220] Fix long press reported as several short presses (#21034) 2026-10-19 20:44:22 +11:00
Clyde Stubbs 2d0e3faa3d [st7305] Add reflective LCD display driver (#21099) 2026-10-19 19:57:05 +11:00
esphome[bot] 2087d28684 Bump aioesphomeapi from 46.12.0 to 46.14.0 (#21114) 2026-10-19 08:07:50 +00:00
b919b692f7 [scd4x] Fix ambient_pressure_compensation unit handling (#19888)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-18 21:54:33 -10:00
Clyde Stubbs 4498ad2c77 [core] Warn when the .esphome build tree is tracked by git (#18973) 2026-10-19 18:38:47 +11:00
esphome[bot] 2addbaaa7f [ci] Refresh integration test durations (#21105) 2026-10-18 21:31:16 -10:00
1dc4006bfc [usb_audio] Add USB Audio Class (UAC) host support (#11728)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 17:44:59 -10:00
Clyde StubbsandClaude Opus 5.5 4d4ba5a1bc [core] Move CONF_MAX_PACKET_SIZE to components/const (#21102)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:37:35 -10:00
Jesse Hills 0e8e45c528 [mipi_spi] Fix GC9107 native size and add M5Stack AtomS3 model (#21087) 2026-10-19 16:35:15 +13:00
J. Nick Kostonandpre-commit-ci-lite[bot] 28e43235fa [gpio_expander][pcf8574][mpr121][ch422g][ch423][pcm5122][weikai] Move the GPIO expanders and pin providers onto the shared pin helpers (#20868)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-18 16:27:01 -10:00
J. Nick Koston ded5ad6cb1 [wifi] Build the wifi actions and conditions with the apply helpers (#20842) 2026-10-18 15:23:31 -10:00
J. Nick Koston ab656acbf1 [api] Build api.connected with register_apply_condition (#20836) 2026-10-18 15:23:19 -10:00
J. Nick Koston 52390ebbe4 [ethernet] Build the ethernet automations with the apply helpers (#20845) 2026-10-18 15:22:23 -10:00
J. Nick Koston 8d6b40f735 [components] Use Component::status_comm_result for communication failures (#20864) 2026-10-18 15:19:53 -10:00
J. Nick Koston 257a636099 [rx8025t][rx8130][rx8111][ufm01] Use the shared BCD helpers (#20861) 2026-10-18 15:17:24 -10:00
J. Nick Koston b53b3968b4 [light] Build light.toggle with register_apply_action (#20833) 2026-10-18 15:10:02 -10:00
J. Nick Koston 73ba726838 [ci] Extend the hardware-test skill with the flows learned since it was added (#21011) 2026-10-18 15:09:01 -10:00
J. Nick Koston 490d4723ca [ota] Log why the upload switched to UDP (#20996) 2026-10-18 15:08:46 -10:00
Jesse Hills a6dee800d1 Merge branch 'beta' into dev 2026-10-19 14:03:21 +13:00
Jesse Hills 2001dd2be8 Merge pull request #21097 from esphome/bump-2026.10.0b5
2026.10.0b5
2026-10-19 14:03:02 +13:00
J. Nick Koston c793ab30a7 [core][gpio_expander] Add a shared expander pin class and pin codegen helpers (#20866) 2026-10-18 14:48:35 -10:00
Clyde StubbsandClaude Opus 5.5 1753c45e53 [core] Move CONF_PROVIDER and CONF_REMOTE_ID to components/const (#21096)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:47:37 -10:00
J. Nick Koston 1767e4b0be [sensor] Add SensorWithDedup for child sensors that skip repeated values (#20925) 2026-10-18 14:47:19 -10:00
J. Nick Koston 5575dd8928 [core] Add final_validate.get_declaring_config (#20912) 2026-10-18 14:46:57 -10:00
J. Nick Koston 704dd4b445 [components] Report communication failures with status_comm_result in the remaining components (#21079) 2026-10-19 00:17:57 +00:00
e6ad995c8e [i2s_audio] Add ESP32-S31 support (#20115)
Co-authored-by: cosmicDustOfLightLength <cosmicDustOfLightLength@example.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 00:07:52 +00:00
J. Nick Koston 84ce39e493 [components] Use the codegen helpers in the remaining components (#21078) 2026-10-18 14:05:37 -10:00
J. Nick Koston d9242520e3 [components] Decode bytes with the shared helpers (#21080) 2026-10-18 20:00:48 -04:00
J. Nick Koston 03bf2de7df [components] Decode BLE fields with the shared helpers (#21082) 2026-10-18 19:59:19 -04:00
J. Nick Koston a70d75e469 [components] Read UART bytes with read_available and discard_available (#21084) 2026-10-18 13:59:09 -10:00
J. Nick Koston 2dcb97774d [tests] Add an esp32-s31-idf base file and shared i2c and i2s_audio packages (#21086) 2026-10-18 19:54:48 -04:00
Jacopo Labardi be9741c6de [const] Share CONF_VALUE_TYPE across components (#21062) 2026-10-18 13:02:22 -10:00
Jesse Hills a6a4b49e75 Bump version to 2026.10.0b5 2026-10-19 11:29:05 +13:00
Bonne EgglestonandJ. Nick Koston 649f2c91f5 [api] Poll the outgoing dial every loop (#21085)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-19 11:29:02 +13:00
Bonne Eggleston ffa20abe96 [api] Poll the outgoing dial every loop on deep sleep builds (#21077) 2026-10-19 11:29:02 +13:00
J. Nick Koston f979ddcf0d [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) 2026-10-19 11:29:02 +13:00
J. Nick Koston 26064f1415 [uart_tcp] Expose the connection entities as platforms (#21008) 2026-10-19 11:29:02 +13:00
ab-tools b1c138c612 [audio] Compute the remaining AudioStreamInfo conversions in 64 bits (#21092) 2026-10-18 12:12:50 -10:00
ab-tools 9526233821 [audio] Compute frames_to_microseconds() in 64 bits (#21089) 2026-10-18 11:39:37 -10:00
J. Nick Koston c50ee215f2 [tcp_uart] Expose the connection entities as platforms (#21009) 2026-10-19 10:36:23 +13:00
Jonathan Swoboda 0fbba03522 [debug] Log uptime in dump_config (#20989) 2026-10-19 10:33:13 +13:00
Jonathan Swoboda 7ccaedfb9e [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) 2026-10-19 10:33:13 +13:00
esphome[bot] 0173127239 Bump bundled esphome-device-builder to 1.24.0 (#20851)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-19 10:33:13 +13:00
Clyde Stubbs 235c614e68 [epaper_spi] Ensure test card gets drawn (#20804) 2026-10-19 10:33:13 +13:00
Jonathan Swoboda 79823c89ab [mipi_rgb] Rotate pure RGB panels in software (#20757) 2026-10-19 10:33:13 +13:00
J. Nick Koston e09cb64756 [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) 2026-10-19 10:33:13 +13:00
J. Nick Koston bd5b3fa641 [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) 2026-10-19 10:33:13 +13:00
Kevin AhrendtandJ. Nick Koston f464994f85 [api][sendspin] Add the Sendspin client id to the capabilities message (#20775)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-19 10:33:13 +13:00
esphome[bot] 4552dbad7a Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-19 10:33:13 +13:00
J. Nick Koston ad8cd0dd3e [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) 2026-10-19 10:33:13 +13:00
puddlyandJ. Nick Koston dd2582bdfd [usb_uart] Expose interface_class and port_number (#20659)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 10:33:13 +13:00
J. Nick Koston ba575600dc [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) 2026-10-19 10:33:13 +13:00
esphome[bot] cd6c0dd402 Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-19 10:33:13 +13:00
4645d34e1a [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 10:33:13 +13:00
dff1422e36 [mdns] Advertise the connected interface in the network TXT record (#20673)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-19 10:33:13 +13:00
Bonne EgglestonandJ. Nick Koston 512e43793b [api] Poll the outgoing dial every loop (#21085)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 20:47:02 +00:00
603f16623f [usb_host] Size the FIFO split on ESP32-S2/S3 from periodic packet requests (#21081)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 20:44:05 +00:00
218e696654 [globals] Add RTC memory support (#13954)
Co-authored-by: Copilot <175728472+Copilot@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>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 10:40:55 -10:00
Clyde Stubbs 3e68e0f5e1 [epaper_spi] Add the UC8151D controller and the Waveshare 2.13" B V3 (#21022) 2026-10-19 07:40:16 +11:00
dd456ea0b2 [homeassistant] Add the light platform (#11354)
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>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 10:02:31 -10:00
Bonne Eggleston e54d66e620 [api] Poll the outgoing dial every loop on deep sleep builds (#21077) 2026-10-18 09:36:35 -10:00
J. Nick Koston a3bb17d2c6 [components] Create BLE and modbus devices with the new helpers (#21026) 2026-10-18 15:09:58 -04:00
71c0f46e35 [bme68x_bsec2] disable loop (#20994)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 08:33:17 -10:00
J. Nick Koston a153fcc8ee [bl0906][kamstrup_kmp][ufm01] Drop stale receive bytes with discard_available (#21071) 2026-10-18 14:30:10 -04:00
85724892cf [light] Add power estimation and current limiting for addressable LED strips (#15348)
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@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-18 08:08:31 -10:00
J. Nick Koston deea006b39 [components] Set referenced components with cg.add_variable and register_parented (a-l) (#21021) 2026-10-18 17:43:05 +00:00
J. Nick Kostonandpre-commit-ci-lite[bot] 619728b686 [components] Drop stale UART receive bytes with discard_available (#21030)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-18 07:23:06 -10:00
J. Nick Koston c35d98b622 [core] Add progmem_string_table_find() (#20790) 2026-10-18 07:19:14 -10:00
J. Nick Koston b2d6c78499 [light] Add LightState::set_external_state() for outputs that mirror a light elsewhere (#20787) 2026-10-18 07:18:47 -10:00
J. Nick KostonandJonathan Swoboda a801e414a0 [components] Set referenced components with cg.add_variable and register_parented (m-z) (#21023)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-18 13:32:06 +00:00
J. Nick Koston 22a36de4ae [components] Decode little-endian I2C values with the new helpers (#20939) 2026-10-18 09:20:58 -04:00
J. Nick Koston eca95348ac [components] Assemble 24-bit readings with encode_uint24 (#21016) 2026-10-18 09:16:50 -04:00
J. Nick KostonandJonathan Swoboda ed9616d512 [components] Set pins with cg.add_pin (#21019)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-18 09:10:22 -04:00
J. Nick Koston 0cd1ae01f1 [components] Use cv.uint8_t and cv.uint16_t for full byte ranges (#21015) 2026-10-18 08:52:14 -04:00
J. Nick Koston 2a416fbce6 [core] Document that hub entities go in platforms in AGENTS.md (#21003) 2026-10-18 08:45:21 -04:00
J. Nick Koston 270d989481 [core] Add cg.add_pin and cg.add_variable for optional pins and ids (#21018) 2026-10-18 08:41:33 -04:00
J. Nick Koston 3665bcc4e3 [ble_device_base][ble_client][modbus] Add new_ble_device_component, new_ble_node_component and new_modbus_client_component (#21025) 2026-10-18 08:40:35 -04:00
J. Nick Koston 0922df9bb3 [uart] Add discard_available to drop stale receive bytes (#21029) 2026-10-18 08:39:02 -04:00
J. Nick Koston 54a28b3e3b [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) 2026-10-18 08:37:48 -04:00
Michele PorelliandJ. Nick Koston 032a2b7200 [core] Let entities choose flash or RTC storage for their preferences (#21017)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-18 06:58:40 +00:00
J. Nick Koston 0bb78186f2 [components] Set display lambdas with display.add_writer (#21028) 2026-10-17 18:34:38 -10:00
J. Nick Koston 2fe1333cba [display] Add add_writer for display lambdas (#21027) 2026-10-17 18:34:38 -10:00
J. Nick Koston 265754c467 [components] Drop get_setup_priority overrides that return the default (#21014) 2026-10-17 17:43:16 -10:00
6580f4f314 [micro_wake_word] Allow configurable capture buffer duration (#20942)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 16:43:07 -10:00
J. Nick Koston 5ad63e3bb6 [http_request] Only treat a URL as secure when its scheme is https (#20867) 2026-10-18 02:38:07 +00:00
J. Nick Koston a8ab035b59 [lock] Filter on_lock and on_unlock with when (#20858) 2026-10-17 16:33:05 -10:00
J. Nick Koston 968d57c56b [core][time] Add BCD helpers and a shared RTC BCD calendar codec (#20860) 2026-10-17 16:32:13 -10:00
J. Nick Koston 1307faef06 [core] Add Component::status_comm_result (#20863) 2026-10-17 16:31:17 -10:00
ee117886b9 [vevor_dtx] Add Vevor weather station component (#17224)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 15:42:55 -10:00
1dbad7b951 [airton] Add support to climate for Airton IR A/C units (#7753)
Co-authored-by: Arjan <44190435+vingerha@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-18 01:37:58 +00:00
J. Nick Koston 5df6c65cc2 [api] Test api.connected without options and with a lambda (#20835) 2026-10-17 15:23:35 -10:00
J. Nick Koston af5efabdc6 [light] Test light.toggle with a transition length (#20832) 2026-10-17 15:23:22 -10:00
J. Nick Koston a2754f655c [dfrobot_sen0609][http_request] Use ESPHOME_strstr_P (#20825) 2026-10-17 15:23:06 -10:00
J. Nick Koston 34e6d3ec9c [pylontech] Restore the CONF_ID import (#21013) 2026-10-17 15:21:36 -10:00
J. Nick Koston 21362f8c92 [ethernet] Test the ethernet.connected condition (#20844) 2026-10-17 15:21:18 -10:00
J. Nick Koston 626d84bc64 [sensor][number] Test in_range with every bound combination (#20847) 2026-10-17 15:20:52 -10:00
J. Nick Koston 68fa7b9847 [wifi] Test the wifi.connected and wifi.enabled conditions (#20841) 2026-10-17 15:20:00 -10:00
J. Nick Koston 32dc3b8535 [http_request] Test ota.http_request.flash with every field (#20829) 2026-10-17 15:19:24 -10:00
J. Nick Koston 74bcec34ad [i2c] Add I2CDevice::update_register for register read-modify-write (#20854) 2026-10-17 15:18:36 -10:00
J. Nick Koston c1687aba4c [components] Let core report communication failures in dump_config (5/5) (#20874) 2026-10-17 15:16:21 -10:00
J. Nick Koston ec642f928f [i2c] Add little-endian and signed byte decoding helpers (#20938) 2026-10-17 15:15:36 -10:00
J. Nick Koston ec9a207f42 [components] Create UART devices with new_uart_component (#20919) 2026-10-17 15:15:21 -10:00
J. Nick Koston 3b7847d951 [components] Create SPI devices with new_spi_component (#20920) 2026-10-17 21:13:49 -04:00
J. Nick Koston 1197f22f00 [fusb302b] Expose the sensors as platforms (#21004) 2026-10-17 15:09:49 -10:00
J. Nick Koston e034662dc6 [uart_tcp] Expose the connection entities as platforms (#21008) 2026-10-17 15:09:32 -10:00
J. Nick Koston 42e849967a [bq76972] Expose the sensors as a sensor platform (#21006) 2026-10-17 15:09:14 -10:00
J. Nick Koston e6f1ed8d51 [tcp_uart] Expose the connection entities as platforms (#21009) 2026-10-17 15:07:42 -10:00
Igor StadnykandJ. Nick Koston bb9067acd7 [tfluna] Recover from transient runtime reconfiguration failures (#20992)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 15:07:16 -10:00
Michele Porelli 928cf7d696 [libretiny] Emulate rtc preference storage on the Beken BK7231T, BK7231N and BK7238 with flash and the reset reason (#21007) 2026-10-17 14:59:40 -10:00
89afb111ad [mt6701] Add MT6701 (MagnTek magnetic encoder) component (#17643)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 14:06:46 -10:00
Brandon HarveyandJ. Nick Koston d1ec52e95a [sen6x] Add device status binary sensors (#18784)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 23:34:15 +00:00
Bonne Egglestonandclydebarrow 818d43f742 [epaper_spi] Run the loop continuously between the steps of an update (#20554)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-18 10:25:36 +11:00
1a28a9220b [uart] Add a binary sensor that fires on a byte sequence (#14032)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 22:35:46 +00:00
Brandon HarveyandJ. Nick Koston 022733c46b [sen6x] Add PM number concentration sensors (#18782)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 12:16:45 -10:00
0f6fc503bd [sen6x] Add temperature compensation, acceleration and startup delay (#18781)
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-10-17 11:01:14 -10:00
Bonne Egglestonandclydebarrow ae62960193 [epaper_spi] Add the UC8279 controller and the Xteink X4 Pro (#20545)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-18 07:51:07 +11:00
325c747dcd [sen6x] Add CO2 configuration options (#18780)
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-10-17 10:41:13 -10:00
Brandon HarveyandJ. Nick Koston 5759c6b8e6 [deep_sleep] Honor nested wakeup_pin_mode on ESP32 single-pin configs (#16618)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 10:25:40 -10:00
c9ba6c355c [hoermann_hcp] Add an actuator error binary sensor (#20299)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 10:00:31 -10:00
Brandon HarveyandJ. Nick Koston d02da3c8de [wifi] Support hidden option in single-network shorthand (#18157)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 09:54:55 -10:00
Bascht74andJ. Nick Koston a576092f25 [remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 09:52:01 -10:00
Simonas Kazlauskas 792d650fb2 [remote_base] Add a protocol for 433MHz A-OK remotes (#20991) 2026-10-17 09:44:25 -10:00
f72857151a [pylontech] Add individual cell voltage and temperature sensors (#16852)
Co-authored-by: Michael <michael_e@gmx-topmail.com>
Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 19:28:52 +00:00
5f8f075e76 [zephyr_coredump] Store a coredump in flash on nRF52 and print it to the next log reader (#13122)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 19:22:28 +00:00
Matias FernandezandJ. Nick Koston c2c8cb5eb3 [climate_ir_lg] Fix swing command to not include climate state (#17417)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 09:12:35 -10:00
J. Nick Koston 5c2b05d0eb [ci] Run the C++ unit tests on macOS without a sanitizer whose runtime aborts (#21000) 2026-10-17 09:00:51 -10:00
Igor StadnykandJ. Nick Koston e110613ef1 [core] Construct App once in the C++ unit test main (#21001)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 19:00:37 +00:00
luar123 4324b7d084 [zigbee] bump esp-zigbee-sdk to 2.0.5 (#20998) 2026-10-17 08:58:49 -10:00
37d6140193 [epaper_spi] Fix partial refresh and port waveshare 2.90inv2 (#18959)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: luar123 <49960470+luar123@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 08:55:37 -10:00
4f192130f8 [water_heater] Hand the component to the core team (#20999)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-17 18:51:06 +00:00
Hasan Khalilandpre-commit-ci-lite[bot] 4ef17cebdb [api] Pre-reserve Home Assistant state subscriptions vector (#20986)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-17 08:01:01 -10:00
35276a38bc [rtsp_server] Add an RTSP server that streams the esp32_camera picture (#17990)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 07:18:52 -10:00
J. Nick Koston 635df9c6ad [socket] Share the allow-list reject log and config lines through Ipv4Allow (#20983) 2026-10-17 06:51:29 -10:00
Jonathan Swoboda d16d8c111e [debug] Log uptime in dump_config (#20989) 2026-10-17 06:50:54 -10:00
Jonathan Swoboda 1ead61f11f [esp32] Remove trailing comment from ECDSA test signing key (#20988) 2026-10-17 06:50:11 -10:00
J. Nick Koston bd06d37d1a [components] Check I2C chip IDs with check_chip_id (#20943) 2026-10-17 06:49:46 -10:00
Jonathan Swoboda 7c6fac91ff [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) 2026-10-17 06:49:15 -10:00
J. Nick Koston 052a6096e6 [components] Log hex dumps with the ESP_LOG*_HEX macros (#20932) 2026-10-17 16:46:16 +00:00
J. Nick Koston 25b684f762 [pylontech] Parse each line as it arrives with one fixed buffer (#20959) 2026-10-17 06:44:41 -10:00
J. Nick Koston 62f8cc3436 [uart] Add read_available helpers to drain available bytes in chunks (#20947) 2026-10-17 06:41:51 -10:00
J. Nick Koston 34f9152d61 [i2c] Add write_registers for constant register tables (#20945) 2026-10-17 06:41:20 -10:00
luar123 73af6b0500 [core][scheduler] run process_to_add after component phase (#20993) 2026-10-17 06:40:39 -10:00
J. Nick Koston a384aae5b1 [button] Add factory reset, restart and query button schema presets (#20915) 2026-10-17 06:38:45 -10:00
J. Nick Koston 905267a9c3 [esp32_ble] Build ble.enabled, ble.enable and ble.disable with the apply helpers (#20839) 2026-10-17 06:38:25 -10:00
J. Nick Koston d08c845c65 [components] Create I2C devices with new_i2c_component (#20909) 2026-10-17 06:36:49 -10:00
J. Nick Koston 91dfb92c60 [esp32_ble] Test ble.enabled, ble.enable and ble.disable (#20838) 2026-10-17 09:22:29 -04:00
J. Nick Koston cef9418a0f [i2c][uart][spi] Add new_i2c_component, new_uart_component and new_spi_component (#20908) 2026-10-17 09:20:28 -04:00
J. Nick Koston c0a693dfce [components] Use xor_bytes and sum16_bytes for checksums (#20922) 2026-10-17 09:18:24 -04:00
Clyde Stubbs 1b4721d445 [core] Script improvements including choose (#20547) 2026-10-17 21:23:12 +11:00
f8d31c3e54 [dfrobot_sen0465] Add DFRobot SEN0465-SEN0476 electrochemical gas sensors (#20880)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-16 22:47:50 -10:00
eafcca8274 [mics_5524] Add datasheet model, diagnostics and advanced wiring (2/2) (#20521)
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-10-16 22:47:28 -10:00
Remco van Essen 06b585d373 [i2s_audio] Fix speaker restart loop with full_duplex (#20984) 2026-10-16 22:05:21 -10:00
HugoPeters1024andJ. Nick Koston 885f2fe5d8 [kamstrup_kmp] Fix Kamstrup KMP reads with echoing optical heads (#20897)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-16 22:04:01 -10:00
J. Nick Koston 4e27b2fc12 [components] Collect UART lines with LineReader (#20956) 2026-10-16 19:54:11 -10:00
J. Nick Koston 375900bfcd [core] Add LineReader to collect lines in a fixed buffer (#20952) 2026-10-16 19:54:11 -10:00
Zhaoqi Xu 6d664d3fef [web_server] Report numbers without a state as NA (#20973) 2026-10-16 19:15:04 -10:00
J. Nick Koston 4dc2b5911b [core] Move CONF_DRIVE_STRENGTH to the shared component constants (#20975) 2026-10-16 18:21:04 -10:00
tomaszduda23 687466b6cd [logger][usb_cdc_acm][core] Add CDC uart to NRF52 (#11634) 2026-10-16 18:14:51 -10:00
J. Nick Koston dcdc042a4b [host] Define a no-op InterruptLock (#20974) 2026-10-16 17:53:33 -10:00
d2a1f7ee31 [zephyr] Fix preference log levels and report settings read/save errors (#20810)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-17 03:02:54 +00:00
2337 changed files with 58664 additions and 15840 deletions
+156
View File
@@ -0,0 +1,156 @@
---
name: display-test
description: Test an ESPHome display driver on a real panel with a camera. Use when a task asks to verify a display (LCD, e-paper, OLED, RLCD) on hardware, check orientation/rotation, partial updates, colours, contrast, or that text and shapes render correctly. Covers finding the USB port, flashing a test pattern, capturing the panel with any camera, and reading the photo.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# Display testing with a camera
Point a camera at the panel to get visual evidence that a driver renders correctly. Any camera
and capture tool works; this skill covers the common ones. `$SKILL` below is this directory
(`.agents/skills/display-test`).
## Ask before using a camera
A camera can show people and rooms, not only the panel. Before the first capture, ask the user
for permission and for which camera to use. List the cameras the host has (the commands below
show how), name the one you intend to use, and wait for the user to confirm it. Do not pick a
camera yourself, and do not fall back to another camera if the chosen one fails; ask again.
The permission covers this session and the camera the user named. Ask again for a different
camera or in a new session. If the user declines, stop and report that the display was not
checked visually.
## Capturing a frame
Grab a still image, skipping the first few frames so auto-exposure and white balance settle.
Read the JPEG with the Read tool; the image is shown visually.
### macOS
The bundled Swift tool uses AVFoundation and avoids the frame-grabbing problems some USB
cameras have with other tools:
```bash
mkdir -p /tmp/camsnap /tmp/display-test
swiftc -O "$SKILL/scripts/camsnap.swift" -o /tmp/camsnap/camsnap
/tmp/camsnap/camsnap <camera-name-substring> /tmp/display-test/after.jpg 30
```
The camera argument is a case-insensitive substring of the name AVFoundation reports. If it does
not match, the tool lists the cameras it found, so a run with a name that matches nothing lists
the cameras without capturing. Use a substring that matches only the camera the user approved.
It creates the output directory if needed and reports encoding or write errors on stderr. Other
options include `ffmpeg` with `avfoundation` input, `imagesnap`, or Photo Booth's still capture.
### Linux
Use `ffmpeg` with the v4l2 input, `fswebcam`, or `v4l2-ctl`. With `ffmpeg`, `-ss 2` after the
input discards the first two seconds of frames while exposure settles:
```bash
mkdir -p /tmp/display-test
ffmpeg -f v4l2 -i /dev/video0 -ss 2 -frames:v 1 -y /tmp/display-test/after.jpg
# or
fswebcam -d /dev/video0 --skip 10 /tmp/display-test/after.jpg
```
List devices with `v4l2-ctl --list-devices`.
### Windows
Use `ffmpeg` with the DirectShow input (`-ss 2` skips the first frames, as on Linux), or any
camera application that can save a still image:
```powershell
mkdir C:\temp -Force | Out-Null
ffmpeg -f dshow -list_devices true -i dummy # find the device name
ffmpeg -f dshow -i video="<device name>" -ss 2 -frames:v 1 -y C:\temp\after.jpg
```
### Camera caveats
- The board may sit at an angle. Do not trust absolute pixel positions or aspect ratios from the
photo; judge orientation, ordering and approximate spacing, using the corner markers as
references.
- Reflections or a glossy cover can brighten one side. Capture again if a region is unclear.
- A moving pattern is caught at an arbitrary phase. Take several captures, or use a static marker
per position.
## Board and port
Find the port; names change on every replug:
```bash
python3 "$SKILL/../hardware-test/scripts/ports.py"
```
Native-USB ESP32-S3/C3/C6/H2 boards show up as `USB JTAG/serial debug unit` and need
`logger: hardware_uart: USB_SERIAL_JTAG`.
## Test pattern
Write a scratch config outside the checkout. Use one `name:` per board and include:
- a white background, a black border and a filled square at the centre, drawn in the first
update (full-window check and scale);
- a 60x20 bar near each corner, one bar per later update with `auto_clear_enabled: false`, so
each bar is written by its own partial window. The bars are wider than tall, which shows a
90 or 270 degree rotation; the corners exercise the window alignment;
- a 20x20 square under the left end of the top-left bar, drawn in the same update, which marks
the top-left corner and shows a 180 degree rotation or a mirrored image.
Use `Color::BLACK` and `Color::WHITE` in lambdas. Do not use the old `COLOR_OFF`/`COLOR_ON`
constants; black must mean black.
A ready template is in `$SKILL/templates/display-pattern.yaml`. Substitute the display platform,
pins and (for a driver with fonts) a font. The marker pattern needs no font.
## Build, flash, capture
Run from the worktree under test so `python -m esphome` imports the checkout, not an installed
copy:
```bash
cd <worktree>
python3 -c "import esphome; print(esphome.__file__)" # must print the worktree path
PYTHONUNBUFFERED=1 python3 -m esphome compile /tmp/display-test/display-pattern.yaml
PYTHONUNBUFFERED=1 python3 -m esphome upload /tmp/display-test/display-pattern.yaml \
--device /dev/cu.usbmodemXXXX
sleep 6 # boot plus a few update intervals
<capture command> /tmp/display-test/after.jpg
```
The device path differs by OS and host; use the path printed by `ports.py`. If the USB console
wedges during flashing (esptool `No serial data received`), replug the board and retry. A board
powered only by a battery may need the USB cable for flashing; the panel keeps its image when
power is removed, which is useful for checking that an image is bistable.
## What to check
1. **Orientation**: the extra square sits under the bar in the expected top-left corner. If it is
in another corner, the image is rotated or mirrored. Test `rotation:` values with the same
pattern. The logical width and height swap at 90 and
270 degrees, so the 60x20 corner bars should then appear taller than wide on the physical
panel.
2. **Coverage**: the border touches all four edges, with no dead rows or columns at the edges.
3. **Partial updates**: with `auto_clear_enabled: false`, draw one marker per update. Each should
appear exactly at its requested position with no smearing, offset or mirrored placement.
Include all four corners.
4. **Text and contrast**: sharp glyphs, correct polarity (black text on white, not inverted), no
ghosting from the previous frame.
5. **Speed**: if the driver logs transfer sizes, check that a full refresh and a partial window
take the expected times.
Record the build commit, board, config, and the captured image path when reporting results.
## When a partial update looks wrong
Take a marker-only capture before assuming the mapping is wrong: the camera's perspective makes
absolute positions deceptive. If markers land in mirrored positions, the controller's address
order (`MADCTL` MX/MY/MV) or the RAM block mapping is inverted for the window; verify the
full-frame path separately, then fix the window computation.
## Add findings
Add new capture tools, panel quirks, or recovery steps here so the next session starts ahead.
@@ -0,0 +1,71 @@
// camsnap <camera name substring> <output.jpg> [frames to skip]
// macOS only: grabs one frame from an AVFoundation camera after letting exposure settle.
// See SKILL.md for capture tools on other systems.
import AVFoundation
import AppKit
import CoreImage
let args = CommandLine.arguments
guard args.count >= 3 else {
FileHandle.standardError.write("usage: camsnap <camera name> <out.jpg> [skip]\n".data(using: .utf8)!)
exit(2)
}
let wanted = args[1].lowercased()
let outPath = args[2]
let skip = args.count > 3 ? Int(args[3]) ?? 30 : 30
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.external, .builtInWideAngleCamera], mediaType: .video, position: .unspecified)
guard let device = discovery.devices.first(where: { $0.localizedName.lowercased().contains(wanted) }) else {
let names = discovery.devices.map { $0.localizedName }.joined(separator: ", ")
FileHandle.standardError.write("no camera matching '\(wanted)'; found: \(names)\n".data(using: .utf8)!)
exit(1)
}
final class Grabber: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
var seen = 0
let skip: Int
let out: String
init(skip: Int, out: String) { self.skip = skip; self.out = out }
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
seen += 1
guard seen > skip, let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let image = CIImage(cvPixelBuffer: pixels)
let rep = NSBitmapImageRep(ciImage: image)
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else {
FileHandle.standardError.write("failed to encode \(out) as JPEG\n".data(using: .utf8)!)
exit(1)
}
let url = URL(fileURLWithPath: out)
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
do {
try jpeg.write(to: url)
} catch {
FileHandle.standardError.write("failed to write \(out): \(error)\n".data(using: .utf8)!)
exit(1)
}
print("saved \(out) after \(seen) frames")
exit(0)
}
}
let session = AVCaptureSession()
session.sessionPreset = .high
guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else {
FileHandle.standardError.write("cannot open \(device.localizedName)\n".data(using: .utf8)!)
exit(1)
}
session.addInput(input)
let output = AVCaptureVideoDataOutput()
output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
let grabber = Grabber(skip: skip, out: outPath)
output.setSampleBufferDelegate(grabber, queue: DispatchQueue(label: "camsnap"))
session.addOutput(output)
session.startRunning()
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
FileHandle.standardError.write("timed out waiting for frames\n".data(using: .utf8)!)
exit(1)
}
RunLoop.main.run()
@@ -0,0 +1,43 @@
# Display test pattern: corner markers, centre reference and partial-update markers.
# Replace the display platform and pins with the driver under test.
esphome:
name: display-pattern
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
level: DEBUG
spi:
clk_pin: GPIO11
mosi_pin: GPIO12
display:
- platform: st7305
id: test_display
cs_pin: GPIO40
dc_pin: GPIO5
reset_pin: GPIO41
update_interval: 1s
auto_clear_enabled: false
lambda: |-
static int frame = 0;
if (frame == 0) {
it.fill(Color::WHITE);
it.rectangle(0, 0, it.get_width(), it.get_height(), Color::BLACK);
it.filled_rectangle(it.get_width() / 2 - 10, it.get_height() / 2 - 10, 20, 20, Color::BLACK);
} else if (frame == 1) {
it.filled_rectangle(20, 20, 60, 20, Color::BLACK);
it.filled_rectangle(20, 50, 20, 20, Color::BLACK);
} else if (frame == 2) {
it.filled_rectangle(20, it.get_height() - 40, 60, 20, Color::BLACK);
} else if (frame == 3) {
it.filled_rectangle(it.get_width() - 80, 20, 60, 20, Color::BLACK);
} else if (frame == 4) {
it.filled_rectangle(it.get_width() - 80, it.get_height() - 40, 60, 20, Color::BLACK);
}
frame++;
+49 -12
View File
@@ -15,19 +15,26 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
the shell that runs it. Never run `esphome logs` in a
`while true` loop: the loop respawns it and holds the port.
the shell that runs it. When more than one agent session shares the bench, run a copy of the script
under a session-specific name (`cap<topic>.py`) so another session's `pkill` cannot take your capture
down. Never run `esphome logs` in a `while true` loop: the loop respawns it and holds the port.
2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
`!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs.
`!secret`. A copy of a device's own config keeps using the secrets file next to that config: two
checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. Never paste a config with credentials into a PR, never commit scratch configs or logs.
3. **Build from the tree under test.** `python -m esphome` puts the current directory first on
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
the build's `src/` for the change before trusting any number.
the build's `src/` for the change before trusting any number. Tool calls sent together share one
shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=<worktree>` on
every compile and upload command and print `esphome.__file__` into the compile log.
4. **PR build and baseline build get different `name:` values.** Builds share
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh
name every run.
Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must
start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one
fixed key shared by every such config on the board, so a new name does not help there: wipe once at
boot with an `on_boot` lambda that resets the preferences and saves a marker key
(`templates/dut-ap-only-wipe-once-bk72xx.yaml`).
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
@@ -73,7 +80,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test |
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner |
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
@@ -86,12 +93,15 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
`--serial-of PORT` (the serial number behind a path).
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append] [--ms]`: bounded timestamped
capture (`--ms` adds milliseconds for timing work), ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN.
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now
and then; retry up to three times.
- `api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the
recipes drive their test configs with it.
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
@@ -100,6 +110,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/`
(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
## Templates in `$SKILL/templates`
- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
@@ -107,17 +124,37 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
plain provisioning check.
- Per-topic device configs, each described in `reference/recipes/<component>.md`: `online-image-dut.yaml`,
`usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`,
`waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`,
`bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`,
`homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`,
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`.
## When something looks wrong
- 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
pipe open and `$!` is that shell, not the server. Write `(cd dir && exec server > out 2>&1 < /dev/null) &`.
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
booted with saved credentials because the name was reused. One AP device at a time on the bench.
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3).
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse
too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below
the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first.
- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash
preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does.
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
- The merge base comes from a freshly fetched `origin/dev`; a stale local `dev` puts dependency bumps into the
PR diff and builds the control from the wrong commit.
- A `.local` name can stop resolving in the middle of a run; keep the address from the boot log and drive by it.
- `wc -l` pads its count on macOS, so `tail -n +$n0` needs `$((n0+0))` or an arithmetic expansion.
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
soak tests.
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` has the proven
flows. Add new findings there.
@@ -88,8 +88,28 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset
reaches the downloader; no software reboot does.
- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter.
- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one
capture open for the whole test and never reopen mid-phase.
- Opening the port may reset the chip (bootloader banner on open) or may not, depending on the adapter and
what pyserial does with DTR and RTS; `serial_capture.py --reset` pulses RTS on CEN and always does, and
`lt_get_reboot_reason()` reports that as Power-On. Check for the banner at the start of a capture before
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.
- 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
for 10 s and the hardware watchdog resets the chip. With Wi-Fi connected a dev build hangs forever after a
crash, and never resets from a plain `portDISABLE_INTERRUPTS(); while (1) {}` either: the boot-time
watchdog is dead once Wi-Fi is up, for any lockup. The crash handler fix (#21012) re-arms the watchdog
from its wrapper, after which the reboot comes within a second (BK7238 and BK7231N).
- The SDK prints nothing on UART1 under `sdk_silent: all`; the bootloader then prints the banked
`cpsr/spsr/r13/r14` of SVC, IRQ, FIQ and SYS plus `ST:` (the retention register) before `go os_addr`.
A CEN or power reset wipes ITCM (reads `0xaaaaaaaa`), so only a watchdog reset keeps a record there. What
survives that reboot on the BK7238: ITCM (`0x3F0000..0x3F4000`) and the top 96 KB of SRAM
(`0x428000..0x440000`, the heap); the bootloader zeroes SRAM up to about `0x420000` and rewrites
`0x400024..0x4000FF` with its own register dump every boot, so neither a `.noinit` after `.bss` nor the
SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception
handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit`
placed there after `.itcm.code` works for a crash record.
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
@@ -123,6 +143,38 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
button P0.18.
- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an
`ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC
port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the
1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader
serial port can fail, copying `.pioenvs/<name>/zephyr/zephyr.uf2` to the volume works (the copy reports
an I/O error as the board reboots, which is normal). Reboot a running `dfu: true` app with a 2001 bps touch.
- The PCA10059 board DTS already defines a `cdc_acm_uart` node, so every ESPHome CDC port is one USB port
later than its number (log on `cdc_acm_uart0` is the second port). A third CDC instance needs
`CONFIG_USB_MAX_NUM_TRANSFERS` above 4 or it never moves data (see `recipes/usb_cdc_acm.md`).
- USB CDC boot output: the Zephyr legacy stack's `cdc_acm` TX ring is 1 KB and `uart_poll_out` drops when it
is full, so anything printed in one burst beyond about 1 KB (a coredump, a long table) is lost even with
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
a never-saved value.
## Waveshare ESP32-S3-ETH with the OV5640 camera module
- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11,
MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21.
- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45,
46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or
power-down GPIO exists.
- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so
the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is
not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot:
priority: 700` with `delay(1200)` and `esp32_camera: setup_priority: 200`
(`templates/rtsp-waveshare-s3-eth-dut.yaml`). `esphome config` rejects a config that uses GPIO8 for both the switch
and `power_down_pin` (pin reuse), so it is one or the other.
- The sensor at SCCB 0x3C answers only with XCLK running, so the ESPHome i2c scan at boot (before XCLK)
reporting no devices on 48/47 is expected. If the SCCB ID register (0x3100 = 0x78) reads fine but every
0x30xx register reads 0x00 (`Detected camera not supported`, 0x106), reseat the ribbon before blaming
software; `templates/waveshare-s3-eth-camdiag.yaml` runs that probe with LEDC XCLK and raw SCCB reads.
## ESP Thread Border Router board v1.2 (S3 host + H2 RCP)
@@ -1,117 +0,0 @@
# Proven test flows
Each flow names the pull requests it was used on, so their result tables show what to post.
## Captive portal `/wifisave` hammer (#20802, #20784)
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
refused proves the portal's server is gone.
## `reboot_timeout` with a fallback AP (#9958)
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
## MQTT (#20433, #17935, #20762, #20767, #20768)
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
paho script publishes and polls the serial capture file.
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
rejects `%.*s`; use a lambda.
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
`pkill -f "a\|b"` does not alternate; use two pkills.
## DHCP lease cache (#17767)
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
`upload` then `logs`.
## HDMI-CEC two-board wire (#9189)
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
filter on the reply handler, template buttons for each target plus lambdas calling
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
lags the press).
## LD2450 polygon zones (#20154)
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
Writing those numbers blocks the ld2450 loop about 50 ms.
## Scheduler and loop wake A/B (#20356)
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
`button_command`). Build the same yaml twice with different names, one per tree.
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
## ESP-NOW on the ESP8266 (#14407)
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
mode the device transmits from the soft-AP MAC.
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
supplicant state machine.
## Thread border router (#18104)
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
## nRF52840 dongle boards (#19009, #20814)
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
@@ -0,0 +1,32 @@
# Proven test flows
Each flow names the pull requests it was used on, so their result tables show what to post.
One file per component, named after it:
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
- [`scheduler.md`](scheduler.md): loop wake A/B
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP)
- [`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
- [`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
- [`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
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
@@ -0,0 +1,30 @@
# api
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
## Outgoing connection wake timing on a deep sleep build (#21077)
`templates/deep-sleep-outgoing-dut.yaml`: classic ESP32 ESP-IDF, `wifi: fast_connect`, `api: encryption` with a bare
`outgoing_connection:`, `deep_sleep` 15 s run and 15 s sleep, a `homeassistant` text sensor, and `millis()` logged
from `wifi: on_connect`, `api: on_client_connected` and the sensor's first `on_value`. Home Assistant's dial-in
listener is on 6054; reuse a device name it already has. Flash by USB, `serial_capture.py PORT 360 OUT --reset` for
12 cycles, then `scripts/api_outgoing/cycles.py OUT LABEL` prints per cycle the wifi, client and state millis with
the client-wifi and state-client gaps. Wi-Fi takes 4 to 8 s after a wake, so compare the client-wifi gap, and run
the PR twice around one base run because the spread is a few hundred ms.
Results: https://github.com/esphome/esphome/pull/21077#issuecomment-6254098014
## Outgoing connection on a mains build after a Wi-Fi drop (#21085)
`templates/outgoing-wifi-drop-dut.yaml`: classic ESP32, `outgoing_connection: delay: 2s`, a "Wifi cycle" button doing
`wifi.disable`, 4 s, `wifi.enable`, a `debug` loop-time sensor, millis from the wifi and api triggers. Flash by USB,
`serial_capture.py PORT SECONDS OUT --ms`, press the button N times 40 s apart with `api_button.py DEV_IP KEY press
"Wifi cycle"`, then `wificycles.py CAPTURE LABEL` prints per drop the Wi-Fi-to-dial gap, dials and dials closed, who
connected first, and the loop-time maxima. For the loop cost of polling, repeat with an unreachable
`host: 192.0.2.1`. Home Assistant refuses a dial-in while its own session is READY (`Refusing dial-in adoption`),
so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the
first dial landing in the loop the network flag flips.
Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791
@@ -0,0 +1,21 @@
# captive_portal
## Captive portal `/wifisave` hammer (#20802, #20784)
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
refused proves the portal's server is gone.
@@ -0,0 +1,11 @@
# cse7766
## cse7766 on a Sonoff S31 (#20948)
`templates/s31-cse7766-dut.yaml`: `logger: baud_rate: 0` because the CSE7766 owns the UART, VERBOSE logger
with everything but cse7766 quieted, 2 s throttles, and a `globals` frame counter fed by the voltage sensor's
`on_raw_value` and logged every 10 s (about 96 frames per 10 s). OTA only, every reflash reboots the relay.
`scripts/cse7766/run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]` subscribes to states and logs over the
API with a concurrent ping and prints the frame counter, API gaps and warnings. API state gaps of 10 to 20 s
can appear on a weak Wi-Fi path while the device counter stays flat, so judge a UART parser by the
device-side counter, not by the API cadence. With nothing plugged in, current and power read 0.
@@ -0,0 +1,21 @@
# dfrobot_sen0395
## DFRobot SEN0395 mmWave on an ESP32 DevKit V1 (#20906, #20979)
Wiring: V to VIN (5 V, the module wants 3.6 to 5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17
(TX2), IO2 to GPIO18 (presence output, a gpio binary sensor), I1 unconnected. `templates/dfrobot-sen0395-dut.yaml`
has the hub, both detection binary sensors, the four switches, settings buttons and junk-line buttons.
`scripts/dfrobot_sen0395/ctl.py HOST KEY press:NAME sw:NAME=on|off wait:S` drives it and prints binary sensor and
switch updates; `cases.sh DEV_IP KEY CAPTURE LABEL` runs the whole flow against a running serial capture. Flash by
USB, `serial_capture.py PORT 540 CAPTURE --reset`, then the cases; compare the `Sending command` and `Message:` lines
and the counts of `Done`, `Error`, prompts and frames between builds. The module echoes every command, so the echoed
lines are the bytes the component sent and can be compared between builds as a set.
Module facts: it answers `Done` or `Error` and then the `leapMMW:/>` prompt without a line end; a junk line gets
`<line> is not recognized as a CLI command` (36 chars longer than the line, so 220 and 240 byte junk gives 256 and
276 char replies); input lines of 256 bytes or more make the module assert (`..\User_code\main.c line:232`) and
restart, after which the sensor is stopped; a factory reset restarts the module about 7 s later; `sensorStop` on a
stopped sensor answers `sensor stopped already` then `Error`; a settings press takes 5 to 6 s (stop, change, save,
start). Free heap on the ESP32 is Wi-Fi noise (192 to 198 kB) and says nothing about the component.
Results: https://github.com/esphome/esphome/pull/20979#issuecomment-6279129146 and
https://github.com/esphome/esphome/pull/20906#issuecomment-6279129604
@@ -0,0 +1,7 @@
# espnow
## ESP-NOW on the ESP8266 (#14407)
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
mode the device transmits from the soft-AP MAC.
@@ -0,0 +1,15 @@
# globals
## Restoring globals with storage: rtc and flash (#13954)
`templates/globals-storage-dut.yaml` is the shared package: five restoring globals (rtc int, flash int, default int,
rtc `std::string`, rtc `uint8_t[2]`), bumped and logged with the reset reason on every boot, saved and synced after
2 s, odd boots `App.safe_reboot()`. Platform files add the board (`packages: dut: !include globals-storage-dut.yaml`), the `debug` reset-reason text sensor and, on the
ESP32, `deep_sleep: run_duration: 12s, sleep_duration: 8s` (even boots sleep). No network needed: flash by USB,
`serial_capture.py PORT 110 OUT --reset` gives the reset pulse plus a run of soft restarts and sleeps; the ESP32's
EN pulse is a `POWERON_RESET` that empties RTC memory, the D1 mini's RST pulse (`rst cause:2`) keeps it, so the
ESP8266 needs a real power cut by hand; arm long `--released` captures on both ports first (the capture
reopens the port when it comes back, the very first boot lines are still lost, read the next boot's restored
values). Unchanged-build check: the config with every `storage:` removed, built on the PR and on the dev merge
commit under the PR's own commits (not the merge base, which is older dev), `cmp -l` plus an `nm -S` symbol diff;
only the template names differ. Results: https://github.com/esphome/esphome/pull/13954#issuecomment-6254794712
@@ -0,0 +1,9 @@
# hdmi_cec
## HDMI-CEC two-board wire (#9189)
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
filter on the reply handler, template buttons for each target plus lambdas calling
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
lags the press).
@@ -0,0 +1,47 @@
# homeassistant
## homeassistant light platform against a local Home Assistant (#11354)
A local Home Assistant dev install (`hass -c config`, https://127.0.0.1:8123 with a self-signed cert); its ESPHome
entries and entity ids are in `config/.storage/`. Two ESPHome RGBCW bulbs with a plaintext API are the lights, so an
"HA side" change is made directly on a bulb with `scripts/homeassistant_light/bulb.py BULB_IP - on|off|state|restart`.
Add the test devices to HA by hand with the shared `api_key` and "Allow the device to perform Home Assistant actions".
`templates/homeassistant-light-dut.yaml` is a package: two mirrors, buttons for each change, a "Log state" button, a
30 s alive line, an `on_boot` `light.turn_on` for the change-before-first-write path, and VERBOSE logging with the
noisy tags at DEBUG (a tag cannot exceed the global level). A platform file sets the board and includes it:
```yaml
esphome:
name: ha-light-dut
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
dut: !include homeassistant-light-dut.yaml
```
Flash by USB, start a bounded `esphome logs` capture per device, then press buttons with
`scripts/api_button.py DEV_IP KEY press "Button"` and read the bulb with `bulb.py`. Cases: each
button against the bulb and the device log; a direct bulb change that both mirrors follow; restart the bulb, send a
change, expect the 5 s `did not report the change back` and the fallback (with an off and with an on last report);
OTA reboot with a serial capture running to see the boot-time change (the API log client reconnects too late).
Lessons: the light core clamps mireds to the range HA reported; a flash sends nothing; a change sent while HA is
connected but not yet subscribed is dropped by the api server (`client has not subscribed to actions (yet)`); after a
fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has
no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and
https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409
## Held report versus a later call (#21144)
A report that arrives during a transition is held and applied when the transition ends, unless a call came after
it. `scripts/homeassistant_light/heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY]` (a key of `-` is plaintext) keeps one API connection to the mirror
device and one to the bulb so the steps land inside a 3 s fade: press "A 50 fade 3s", 0.7 s later set the bulb to
90% directly (the report is held), 1.4 s in press the same fade again (or "A many calls", 16 calls in one loop
pass). Pass: the mirror and the bulb end at the second call's target. Only `same` reproduces the bug: a build
without the fix logs `Applying the report that arrived during the transition` and the bulb stays at 90%. `many`
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
@@ -0,0 +1,15 @@
# ld2450
## LD2450 polygon zones (#20154)
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
Writing those numbers blocks the ld2450 loop about 50 ms.
## Zone numbers round trip (#21146)
`scripts/ld2450/zonedrive.py HOST KEY(- for plaintext) set:NAME=VALUE sel:NAME=OPTION wait:S list` sets numbers and
selects over the API and prints every LD2450 state update. After each set the hub sends the set zone command, queries
the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct
value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content.
Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922
@@ -0,0 +1,58 @@
# libretiny
## LibreTiny rtc preference emulation (#21007)
The PR keeps `storage: rtc` preferences in FlashDB and deletes them on the first load or save after a boot
whose `lt_get_reboot_reason()` is not software, watchdog, crash or sleep. Only the families listed in
`_libretiny_rtc_supported()` accept the config keys; on others force the backend in with
`platformio_options: build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]` after confirming the rejection.
- Harness: `scripts/libretiny_prefs/ltprefs.hpp` plus `templates/bk72xx-rtc-prefs-dut.yaml`. `ltprefs_boot()`
loads one rtc and one flash preference, a second pair that shares one key as rtc and as flash, and a third
rtc entry (so a hard reset drops three entries in one pass), logs the reason and both results, saves `100 + step`, syncs; after
a templated delay `ltprefs_exit()` bumps the flash-backed step and runs the step's action (`lt_reboot()`,
`__builtin_trap()`, `lt_reboot_wdt()` or idle). The last 8 boot reports are kept in a flash preference and
repeated in the 10 s alive line, so a boot missed by the capture (a power cut, an OTA-only board) is still
readable. Bump `run:` for fresh keys.
- A store to an unmapped address does not fault on the ARM968, the board carries on: use `__builtin_trap()`
for the crash row. The SDK crash handler reboots about 10 s later. Works on BK7238 and BK7231N.
- Hard resets: `serial_capture.py ... --reset` pulses RTS on CEN (reported as Power-On). A real power cut
needs a hand or a smart plug; the history in the alive line covers the boot the host cannot see. On the
Beken SDK a chip-enable reset and a power cut both report Power-On, there is no separate reset-pin source.
- Step 0 and step 8 wait 30 s before acting so the host can open the port (and pulse a reset) after a flash
without losing the first boot; the flash tool's own reset starts the sequence immediately otherwise.
- OTA-only board: keep the device's wifi, api and ota blocks verbatim, keep safe_mode, read logs with
`esphome logs <yaml> --device <name>.local` (the API attaches late, hence the history), and restore the
device's own config afterwards.
- Plain-build comparison: `raw_firmware.bin` differs by the timestamp and by `__LINE__` in log calls of
functions below the added code; map offsets through `objdump -h` load addresses and `addr2line` before
calling a byte difference a code change.
## BK72xx crash report on the next boot (#21012)
Harness: `scripts/libretiny_crash/bkprobe.hpp` plus `templates/bk72xx-crash-probe-dut.yaml`. A flash
preference holds the step; `on_boot` (priority -100) logs the reset reason, the SDK start type, two canaries
and hex dumps of the low SRAM, a set of marker words and the record area after `.bss`, waits 30 s on step 0
(so a capture can be opened after the flash) or 5 s, saves `step + 1`, syncs, then acts: 0 `__builtin_trap()`,
1 call into unmapped memory, 2 unmapped and unaligned reads and a store (no fault on the ARM968), 3
`lt_reboot()`. A `globals: restore_value` step counter does not work here: it is only saved from the global's
loop, which a crash 50 ms later never reaches; save an `ESPPreferenceObject` and `global_preferences->sync()`
first. Bump `run:` for fresh keys. Keep the `wifi:` block: the bug the fix addresses only shows with Wi-Fi up.
Flow: `flash_bk72xx.py` (retry up to three times), one `serial_capture.py ... --reset` capture of 150 to 230 s,
then `grep -a "libretiny.crash\|probe"`. Decode with
`arm-none-eabi-addr2line -pfiaC -e .pioenvs/<name>/raw_firmware.elf <addr...>` (the ELF is `raw_firmware.elf`
on LibreTiny), or run `scripts/libretiny_crash/decode_capture.py <yaml> <capture>` from the config directory
to feed the capture through the CLI's `LogLineProcessor`.
Expected: `*** CRASH DETECTED ON PREVIOUS BOOT ***`, `Reason: Undefined instruction`, PC on the `udf`,
LR = PC + 1, BT0..BT7 walking the lambda, `StatelessLambdaAction`, `Action::play_complex`, `DelayAction`,
`Scheduler::call`, `Application::loop`. The wild call reports where the core ended up (it executes zeros as
Thumb NOPs until it wraps into real code), not the called address. Without the fix a dev build with Wi-Fi
connected never comes back from the crash; see the BK72xx section in `boards.md`.
OTA-only board: copy the device's own config, add the probe include and the step machine (step 0 crashes
45 s after boot, later steps idle), keep its wifi, api, ota and safe_mode blocks and use the secrets file
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
client gets it on subscribe) or on the board's serial log port if it has one.
@@ -0,0 +1,53 @@
# mqtt
## MQTT (#20433, #17935, #20762, #20767, #20768)
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
paho script publishes and polls the serial capture file.
## WebSocket transports on ESP-IDF (#16056)
One build per transport from `templates/mqtt-transport-dut.yaml`, with `scripts/mqtt/mosquitto-ws.conf` as the
local broker (mosquitto 2.x from brew has `websockets` support; `log_type all` prints `New connection from <ip>:<port>
on port <listener>`, which proves which listener each client used) and `scripts/mqtt/make_test_ca.sh BROKER_IP` for
the TLS listener's certificate, both run in the scratch directory so keys, logs and outputs stay out of the checkout. `scripts/mqtt/cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart]` subscribes
to `NAME/#`, sends a switch command and an `on_message` topic, presses the 3000 byte publish button over the API,
restarts the local broker when the last argument is `restart` (the PID in `mosquitto.pid`, written when the broker
was started with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` in the working directory next to the
certificates), checks every listener in the broker config answers again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak
WebSocket, so it subscribes on the plain listener of the same broker. Cases: `tcp` on 1883, `ws` on 9001, `wss` on
9002 with `certificate_authority` (the certificate names an address, so `skip_cert_cn_check: true`), `wss` to
test.mosquitto.org on 8081 with no CA for the certificate bundle, and two negatives that must never connect: a
different CA (`mbedtls_ssl_handshake returned -0x2700`) and the self-signed server through the bundle
(`esp-x509-crt-bundle: Failed to verify certificate`, `-0x3000`). `CONFIG_MQTT_TRANSPORT_WEBSOCKET` is on by
default in the generated sdkconfig. The TLS session costs about 29 kB of internal heap while connected; the public
broker carries other devices' `esphome/discover` traffic, which is noise. Results:
https://github.com/esphome/esphome/pull/16056#issuecomment-6301034019
## TLS trust sources on ESP-IDF (#14318)
One build per trust source from `templates/mqtt-tls-dut.yaml`: broker only (port must resolve to 1883),
`certificate_authority` with no port (must resolve to 8883), `verify_ssl: false` with no CA, the CA plus
`client_certificate` and `client_certificate_key` on the 8884 listener that requires one, `transport: wss` with the CA,
`tls: true` to test.mosquitto.org on 8886 for the certificate bundle, and `tls: true` to the local self-signed broker as
the negative. `scripts/mqtt/mosquitto-ws.conf` now has the 8883 and 8884 listeners and `scripts/mqtt/make_test_ca.sh`
also writes `client.crt`/`client.key`. Before flashing, check each build's `src/esphome/core/defines.h` for
`USE_MQTT_TLS`, `USE_MQTT_CERT_BUNDLE` and `USE_MQTT_TLS_INSECURE` and its `sdkconfig.<name>` for
`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE` and `CONFIG_ESP_TLS_INSECURE`: only the bundle builds may carry the bundle and only
the unverified build the insecure flags. `scripts/mqtt/flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST
local|public|neg` runs one build end to end (flash, bounded capture, `cases.sh` with a restart for the local broker,
counts and the broker's per-listener connection lines) and prints the config dump's `TLS:` line (`CA certificate`,
`certificate bundle` or `no verification`). The CLI side (`esphome/mqtt.py`) is covered without a board by
`scripts/mqtt/cli_tls_check.sh TREE [neg] YAML...`, which runs `esphome clean-mqtt` under a timeout: status 124
after `Connected to MQTT broker!` is a pass, and with `neg` each config must instead fail with
`CERTIFICATE_VERIFY_FAILED` and never connect. Python's default SSL
context rejects a CA that lacks a `keyUsage` extension (`CA cert does not include key usage extension`) while mbedTLS
on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one
`Last esp-tls error: 0x8008` on every build, TLS or not. Results:
https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827
@@ -0,0 +1,6 @@
# nrf52
## nRF52840 dongle boards (#19009, #20814)
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
@@ -0,0 +1,20 @@
# online_image
## online_image stalled downloads (#20594)
One classic ESP32, both frameworks (the ESP32 Arduino build also uses `http_request_idf.cpp`). The host
serves the images: `scripts/online_image/make_images.py <www>` writes 16x16 BMP/PNG/JPEG/QOI plus an 8 bit
palette BMP with a 1078 byte header; `scripts/online_image/oi_server.py <www> 8765 <log>` answers one
misbehaviour per path name (`ok`, `truncated`, `large_header`, `empty`, `stall` holds the socket 40 s,
`closed_early`, `slow` pauses 2.5 s, `chunked`, `chunked_cut`, `noclen`, `huge` claims 20 MB) with an ETag
and a 304 on If-None-Match. `templates/online-image-dut.yaml` has two AUTO-format images (default and
256 byte buffer), API actions `fetch`/`fetch_small` (set_url with `update: false`, then component.update)
and `refetch`/`refetch_small` (component.update only, keeps the cache headers), and a restart button.
`scripts/online_image/drive.py <device ip> <api key> <capture> http://<host ip>:8765 pr|base` runs the
case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server
ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with
`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete
(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
A case expected as `any` passes on `finished` or `error`, never on no outcome.
@@ -0,0 +1,5 @@
# openthread
## Thread border router (#18104)
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
@@ -0,0 +1,8 @@
# ota
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
`pkill -f "a\|b"` does not alternate; use two pkills.
@@ -0,0 +1,18 @@
# rtsp_server
## RTSP camera streaming on the Waveshare ESP32-S3-ETH (#17990)
`templates/rtsp-waveshare-s3-eth-dut.yaml` (OV5640 pins, GPIO8 camera power switch with the 1.2 s boot delay,
`rtsp_server`, logger VERBOSE for `rtsp_server`); swap the `wifi:` block for the W5500 `ethernet:` block when
the cable is in, and add `network: enable_high_performance: true` for the big TCP buffers. Flash over native
USB, capture the log with `serial_capture.py`, take the IP from the last `IP Address` line that is not
0.0.0.0 (or `dns-sd -Q <name>.local`). `scripts/rtsp_server/rtsp_client.py IP [cases]` covers play, udp,
pipeline, badreq, replay, getparam, teardown, third, stall, timeout; `rawrate2.py IP quiet|chatty|rtcp|bigbuf`
measures the raw receive rate; `measure.sh IP LABEL LOGFILE [OUTDIR]` runs the fixed set (ping, ffmpeg 12 s
twice, two ffmpeg clients, raw reader) and appends to `results.txt`; `cam_api.py IP` pulls one image over the
API; `ffmpeg -rtsp_transport tcp -i rtsp://IP:554/ -frames:v 30 -f image2 f%03d.jpg` plus Pillow checks
decoding. The 5760 byte default TCP send buffer makes the stream stall over Wi-Fi (about 1 fps, dozens of
stall drops per run); `enable_high_performance` gives several fps on Wi-Fi and removes the stalls on Ethernet.
Frame counts vary a lot between runs (auto exposure changes the JPEG size), so always measure twice. Home
Assistant's stream pipeline does not accept MJPEG over RTSP; its ffmpeg camera opens one RTSP session per
snapshot, so `max_connections` needs headroom, and go2rtc transcoding to H.264 is the way to a live view.
@@ -0,0 +1,8 @@
# scheduler
## Scheduler and loop wake A/B (#20356)
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
`button_command`). Build the same yaml twice with different names, one per tree.
@@ -0,0 +1,19 @@
# speaker
## Media player pipeline tasks (#21203)
The `speaker` media player platform (`speaker/media_player/`) creates `ann_read`, `ann_decode`, `med_read` and
`med_decode` FreeRTOS tasks on every play and deletes them after a stop. The AUX-2 and sendspin packages use the
`speaker_source` player instead, which has its own pipeline and never runs that code, so a test of
`speaker/media_player/*` has to swap the player: `templates/aux-2-preprod-speaker-player-dut.yaml` removes the
package's `external_media_player` with `id: !remove` and adds a `speaker` platform player on the package's resampler
speakers, plus a "Task names" button whose lambda calls `xTaskGetHandle()` on the four names and logs internal free
heap. Serve FLAC files from the host (`ffmpeg -f lavfi -i "sine=frequency=440:duration=90" -ac 2 -ar 48000 -c:a flac
media90.flac`, a 3 s mono one for announcements, `python3 -m http.server 8765 --directory www`); `audio_http`
refuses WAV and MP3. `scripts/speaker_media_player/cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL` runs three
cycles through `drive.py` (`play:URL | ann:URL | stop | press:NAME | wait:S` on one API connection) with the probe
pressed idle, during media, during the announcement, after it and after the stop. Pass: idle none, media `med_*`
only, announcement all four, after stop none, every cycle. API commands land a second or two late over Wi-Fi, so read
the probe against the `State changed to` lines rather than the driver's timestamps. Internal free heap after a stop
moved by 2.5 kB between two boots of the same build, so a heap delta needs more than one boot per build. Results:
https://github.com/esphome/esphome/pull/21203#issuecomment-6299581943
@@ -0,0 +1,26 @@
# udp
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
rejects `%.*s`; use a lambda.
## udp.write payload shapes (#21263, ApplyBytes)
`templates/udp-write-dut.yaml` puts every payload shape behind a template button (constant 4 bytes twice, 3, 9, 0,
128, 129 and 600 bytes, a 300 byte lambda, a simple value, a string) plus an `on_receive` that replies with the
received bytes reversed. `scripts/udp/write_cases.sh DEVICE_IP KEY` records the device's datagrams with
`udp_listen.py` on the test host, presses the buttons two seconds apart, sends one unicast datagram for the echo and
compares everything byte for byte with `check.py`; a missing datagram shifts every later one, so read the first
mismatch. Before flashing, check the generated `main.cpp`: on ESP8266 a constant payload is copied out of its
`PROGMEM` table into `uint8_t esphome__bytes[N]` up to the stack limit and into `std::make_unique<uint8_t[]>` beyond,
identical payloads share one table, a lambda is sent from its vector. An empty constant payload is generated as
`send_packet(nullptr, 0)`, and the raw lwIP UDP `sendto` used on ESP8266 rejected a null buffer with `EINVAL`
(`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
@@ -0,0 +1,52 @@
# usb_audio
## USB audio on the Waveshare ESP32-S3-ETH (#21081, #11728)
`templates/usb-audio-waveshare-s3-eth-dut.yaml`: ESP-IDF, PoE, logs over the API on Ethernet (the native USB port is
the host port, so there is no console), `usb_audio` hub, a 48 kHz 16-bit microphone, a 48 kHz 24-bit speaker (many
speakerphones offer only 24-bit; a wrong format logs the device's alternates), buttons for capture start and stop,
a non-blocking beep fed from an 8 ms interval with `play(buf, len, 0)` (a blocking `play()` on the main loop trips
the task watchdog), and a status interval with mic bytes, speaker state and heap. Drive the buttons with
`scripts/api_button.py IP KEY press "Beep"`. Expect 192 kB/s microphone bytes and the FIFO split
RX 80 / NPTX 16 / PTX 104 in the build's `defines.h`; with the balanced split the 294-byte OUT pipe is refused
(`EP MPS (294) exceeds supported limit (128)`). A device whose OUT endpoint is larger than 600 bytes cannot be
claimed on an S3 at all.
## Music Assistant through sendspin
`templates/usb-audio-sendspin-s3-eth-dut.yaml` overlays the template above with the Apollo CAST-1 wiring: resampler
speakers into a mixer whose inputs never time out, `speaker_source` media player with the sendspin source, and
`sendspin: unpaired_access: true`. A `media_player: platform: speaker` is not a media-source consumer and gives
"Player over its buffer", no audio. Music Assistant finds the device by mDNS within 15 s of boot.
Read the result from the device, not only by ear: sendspin's sync task logs `Lost sync (N us off)` (DEBUG, tag
`SsSync`) for every correction over 5 ms, a positive N inserts that much silence, and each one is an audible click.
For the chunk lead histogram, edit the managed library in the build directory,
`.esphome/build/<name>/managed_components/sendspin__sendspin-cpp/src/sync_task.cpp` (an `SS_LOGD` before the
late-chunk skip in `decode_chunk()` printing the chunk's lead against `new_audio_client_playtime`), update only that
file's `size` and sha256 `hash` in the component's `CHECKSUMS.json`, and compile again; deleting `.component_hash` or
recomputing it makes the manager refuse or refetch. Check the edit is in the image with `strings firmware.ota.bin`.
A lead below -5 ms makes the library skip the whole chunk; an I2S speaker keeps it within 0.1 ms of zero, which is
the figure a USB speaker has to match.
Gotchas: wait two minutes after a reboot before the measured stream (safe mode's boot counter write stalls the cores
for about 140 ms once, 60 to 75 s after boot); with the serial console on, a DEBUG log line per report saturates
UART0 and trips the task watchdog, so use `logger: baud_rate: 0` and the API log; two overlapping `esphome logs`
captures double every count; and every audio test must include a pause and resume cycle, since a stream end that
leaves less than one transfer parked in the driver was what hung the speaker after #11728 merged.
## Controls
Apollo CAST PRO-1 (`templates/cast-pro-1-sendspin-control.yaml`): `Core.yaml` next to it is
`Integrations/ESPHome/Core.yaml` from ApolloAutomation/CAST_PRO-1, which also defines the `version` substitution.
Keep its framework log level at ERROR (DEBUG floods driver output and trips the watchdog); the package reboots itself
into Ethernet-only once it sees a link and appends the MAC suffix to the name. `wifi: enable_on_boot: false` keeps
sendspin off Wi-Fi.
Nabu Casa Connect AUX-2 (a pre-production unit): build from `packages/aux-2-pre-prod.yaml` at NabuCasa/aux-2 commit
283f2e64 (dropped in bde41e01), not the public `aux-2.yaml`, whose W5500, codec and I2S pins differ (the public one
reads W5500 chip ID 0x00 and fails the codec's I2C writes). Point its local `external_components` source at
`github://NabuCasa/aux-2@main`. Templates `aux-2-preprod-stock-dut.yaml` (I2S codec) and
`aux-2-preprod-usb-audio-dut.yaml` (the USB speaker as the mixer output via `!extend mixing_speaker`). The console is
the CH343 (1a86:55d3); first flash over it, OTA after. Forcing the Wi-Fi flag off with `restore_value: no` boot-loops
when Ethernet fails, so let the package switch itself.
@@ -0,0 +1,20 @@
# usb_cdc_acm
## usb_cdc_acm on the nRF52840 dongle (#11634)
`templates/usb-cdc-acm-dongle-dut.yaml`: logger on USB_CDC port 0, `usb_cdc_acm` interface 0 disabled,
interface 1 echoing from a 20 ms lambda, 0x01-prefixed commands for burst, stream, flush, peek, line coding
and counters. Build and flash with `dfu: true` through the app port; the dongle then shows three CDC ports:
the board DTS node `cdc_acm_uart` (unused), `cdc_acm_uart0` (log) and `cdc_acm_uart1` (data), in that order.
Capture the log port with `serial_capture.py`, drive the data port with `scripts/usb_cdc_acm/drive.py
DATA_PORT` (11 cases, prints PASS/FAIL and a summary) and grep the log for `Opened`, `Closed`, `buffer full`,
`flush result`, `line coding`. Boot lines are held in the driver's 1 KB ring until the host opens the port, so
a capture started later still shows the first 1 KB of the boot and then a truncated line.
With three CDC instances the Zephyr legacy stack's `CONFIG_USB_MAX_NUM_TRANSFERS=4` starves the last instance
(each instance holds an OUT read and an IN ZLP), so interface 1 shows DTR changes but moves no data.
`scripts/usb_cdc_acm/cdc_probe.hpp` (include it, call `cdc_probe()` from an interval) reads the Zephyr driver
directly to tell a dead endpoint from a component bug. Raising the transfer pool to 8, or disabling the
board's `cdc_acm_uart` node, fixes it. Design limit of the driver: `fifo_fill` holds `tx_ready` until the
write callback and `write_array` drops when the ring is full, so a 64 KiB full-duplex echo loses one 64 byte
tail on every buffer size.
@@ -0,0 +1,60 @@
# web_server
## State SSE on web_server_idf (#21109) and the HTTPD task stack (#21038)
`templates/web-server-sse-dut.yaml` (ESP32 classic, ESP-IDF, `web_server` version 3) publishes eight sensors every
200 ms (small state events on the 1024 byte stack buffer) and three text sensors whose JSON carries the string twice
(`value` and `state`): 600 chars for about 1.3 kB (the 2048 byte tail), 1800 chars for about 3.7 kB (tail grown to
4096) and 2800 chars for about 5.7 kB, over the 5120 byte JSON room, which must log `State event does not fit`
and never put a truncated frame on the wire. An `interval` lambda logs `uxTaskGetStackHighWaterMark()` of the loop
task (`nullptr`) and of the HTTPD task (`xTaskGetHandle("httpd")`) every 5 s; the ESPHome loop task is `loopTask`
on ESP32. Keep `power_save_mode: none`, and drive the device by address: mDNS names dropped out mid-run once.
`scripts/web_server/sse.py HOST SECONDS [--clients N] [--stall] [--churn CYCLES]` opens raw TCP connections to
`/events`, decodes the chunked body and parses every `state` event as JSON; `--stall` adds a connection with a 2 kB
receive buffer that never reads (the device must log `Closing stuck EventSource connection` after the 20 s stall
timeout), `--churn` opens and resets a connection CYCLES times. `sse_cases.sh HOST KEY LABEL CAPTURE` runs the
set: 1 client 60 s, 4 clients 60 s, a reader plus a stalled client 45 s, a reader plus 10 abrupt closes, then two
clients with the "Burst" button (50 sensor and 50 text publishes in one loop pass). Pass: 0 invalid JSON on every
client, the oversize sensor dropped with its warning on every publish, the stalled session closed after about
20 s, no reset. The loop task watermark is a minimum since boot, so compare the value after the first client, after
four clients and after the burst between the builds (the SSE split in #21109 left 1280 B more on the ESP32 classic).
For the HTTPD task, `web_server: auth: type: digest` (the default `auth` is Basic) and
`scripts/web_server/httpd_cases.sh HOST USER PASS LABEL CAPTURE`: `httpmix.sh` sends a `curl --anyauth` request
mix per round (page, `/sensor/<Name>`, `/text_sensor/<Name>`, `/switch/<Name>`, a `POST` with `Content-Length: 0`
to `/switch/<Name>/toggle`, an unknown path, which the IDF server closes without a response, an unauthenticated
request answered 401, and a 2 s `/events` stream), 20 rounds alone and 12 rounds next to two `ssecurl.sh` SSE
clients. Entity URLs use the entity name, URL encoded, not an object id. The HTTPD watermark follows the deepest
request of the boot; compare after the mix and after the SSE round, with two boots when the numbers are close.
Results: https://github.com/esphome/esphome/pull/21109#issuecomment-6300582328 and
https://github.com/esphome/esphome/pull/21038#issuecomment-6301310182
## Entity icon and device class readback (#21111)
Covers every path that reads an entity's icon or device class: the native API listing, MQTT discovery, the web_server
`/events` listing and REST endpoints, and `dump_config`. `templates/entity-icon-dut.yaml` (ESP8266 board with wifi,
encrypted api, ota, web_server and mqtt against a local broker) carries a sensor with a 63 character icon, one with no
icon or device class, an internal sensor, and binary_sensor, switch, button, text_sensor, number, select, event, cover
and text entities with icons and device classes. The per-run file supplies `name` and `broker_ip` through
substitutions. Flash the PR and base builds under different names, then from the scratch directory:
```sh
scripts/entity_icons/readback.sh DEVICE_IP KEY NAME BROKER_HOST cap-NAME.txt # once per build
scripts/entity_icons/compare.sh ic-base ic-pr # api, web, mqttn, dump all identical
```
`readback.sh` takes about 25 s once the device is on the network: 12 s of `mosquitto_sub` on `homeassistant/#` and
`NAME/#`, the API listing (`api_list.py`, one JSON line per entity with icon, device class, unit, options and event
types) and `web_dump.py` (6 s on `/events`, keeping only the listing rows that carry `domain`, then a GET of each
listed id with `?detail=all` and one retry, because the ESP8266 async server occasionally leaves a request unanswered
within 5 s). The web ids are `domain/Name`, not object ids, and `/event/<name>` has no GET handler (404 on every
build). `mqtt_norm.py` drops the `dev` block (compile time) and the `NAME/debug` topic (heap probe), so a clean run
differs in nothing. `dump_config` prints icons and device classes only for some domains; button, event and
binary_sensor icons never appear there.
Found with this flow on 2026-10-21: since #19577 every ESP8266 `web_server` build that lists a cover, climate, lock,
valve, update, alarm_control_panel or water_heater entity reboots with `Exception (3) LoadStoreErr` in
`json::linked(const char *)` (the ROM `strlen` on a flash address), fixed by #21316. The cover in the template keeps
that path covered; before #21316 is merged, apply its one line change to both trees or the web_server comparison
cannot run.
@@ -0,0 +1,25 @@
# wifi
## `reboot_timeout` with a fallback AP (#9958)
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
## DHCP lease cache (#17767)
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
`upload` then `logs`.
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
supplicant state machine.
@@ -0,0 +1,10 @@
# zephyr
## Zephyr preferences on the nRF52840 dongle (#20810)
`templates/nrf52-preferences-dut.yaml`: logger on USB_CDC at VERBOSE, `flash_write_interval: 5s`, one
`globals` with `restore_value` incremented every 15 s. Give each build its own global id so each first boot
sees a never-saved key (settings survive a reflash). Flow per build: flash through the log port, 40 s capture
of the first boot, then start a second capture and reboot with a 2001 baud touch on the dongle's other CDC
port (the dfu rate callback is global, and the capture holds the log port); `serial_capture.py` reopens the
log port after the USB re-enumeration and gets the buffered boot lines (`load setting`, `Loaded N settings`).
@@ -0,0 +1,17 @@
# zephyr_coredump
## zephyr_coredump crash and decode on nRF52 boards (#13122)
`templates/nrf52-coredump-crash-dut.yaml` (dongle; change `board:` to `adafruit_itsybitsy_nrf52840` for the
ItsyBitsy): `zephyr_coredump:`, `dfu: true`, a `restore_value` global keyed by the `run` substitution
(settings survive a reflash, so bump `run` for every new crash), and an `on_boot` sequence that sets the
flag, yields 3 s so the globals loop saves it (a blocking lambda never gets there and the board crash-loops),
syncs preferences, then writes to 0xDEADBEEF. Flash, keep every port closed for 80 s, then
`scripts/zephyr_coredump/flow.sh LABEL PORT ELF [OUTDIR]` does the three opens (3 s cut, 100 s full print,
20 s reopen) and calls `decode.sh`, which keeps the first complete `#CD:BEGIN#..END#` block, runs Zephyr's
`coredump_serial_log_parser.py` and `coredump_gdbserver.py` from the sdk-nrf ESPHome installed and prints
`bt` and `info threads` with its `arm-zephyr-eabi-gdb`. The ELF is `.pioenvs/<name>/zephyr/zephyr/zephyr.elf`.
Expect about 9 KB dumps, 140 to 150 `#CD:` lines, a 673 ms loop warning for the erase, and `Has stored
dump: 0` on the reopen. Check `compiled on` in every capture: another session flashing the same board
mid-test looks exactly like a failed test otherwise. Boards without a network cannot exercise the API and
MQTT delivery paths of the component; say so in the results.
@@ -0,0 +1,23 @@
# zwave_proxy
## zwave_proxy frame checksums on a live proxy (#20922)
A Z-Wave proxy (ESP32-S3 with the module on `usb_uart`) is a live device: flash it over OTA and read logs
over the API, there is no serial console because the native USB is the host port for the module, so the
boot-time `GET_NETWORK_IDS` exchange is never captured. Home Assistant's Z-Wave JS holds the single API
subscription (it polls `0x3B` every 30 s and resubscribes after every reboot), so do not subscribe from a
script, and keep the device `name:` so it stays on Z-Wave JS; give each build its own scratch directory
instead.
1. `logger: level: VERY_VERBOSE` with `logs: zwave_proxy: VERY_VERBOSE` and DEBUG for the rest: the
`Sending:`, `Received frame:` and `CHECKSUM Received ... Calculated` lines are `ESP_LOGVV`, which needs
the compile-time level.
2. `md5` the two `firmware.ota.bin` files to be sure the A and B builds differ, then `esphome upload
--device <name>.local` and a `timeout 330 esphome logs --device <name>.local` capture started right after
the OTA to catch the resubscribe traffic.
3. `scripts/zwave_proxy/verify_checksums.py <captures>` recomputes the XOR (init 0xFF, LENGTH..payload) over
every logged frame and counts bad checksums and ACK/NAK sent and received; `scripts/zwave_proxy/devinfo.py
<name>.local` prints the firmware version, compile time and the Home ID from the API (a Home ID proves the
device-built `GET_NETWORK_IDS` frame was accepted at boot).
4. Pre-existing: one `zwave_proxy took a long time for an operation` warning on the first frame after the
log client connects, caused by VERY_VERBOSE logging; it appears on dev too.
@@ -0,0 +1,33 @@
"""Press a template button on the test device over the API, or list its buttons.
usage: api_button.py HOST KEY list | press "Button name" [...] (KEY of - means a plaintext API)
"""
import asyncio
import sys
from aioesphomeapi import APIClient, ButtonInfo
HOST, KEY = sys.argv[1], sys.argv[2]
CMD, ARGS = sys.argv[3], sys.argv[4:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
if CMD == "list":
print("\n".join(sorted(buttons)))
else:
for name in ARGS:
if name not in buttons:
sys.exit(f"no button named {name!r}; have {sorted(buttons)}")
c.button_command(buttons[name].key)
print("pressed", name)
await asyncio.sleep(0.3)
await asyncio.sleep(0.5)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,73 @@
"""Per wake cycle timings from a serial capture of the deep sleep timing config.
usage: cycles.py CAPTURE [LABEL]
A cycle starts at the boot banner; the numbers are the device's millis() from the timing lines, and the
capture clock gap from the first line of the cycle to the "Dialing" line.
"""
import pathlib
import re
import statistics
import sys
cap = (
pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
)
label = sys.argv[2] if len(sys.argv) > 2 else ""
TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]")
def secs(line):
m = TS.match(line)
if not m:
return None
h, mi, s, f = m.groups()
return int(h) * 3600 + int(mi) * 60 + int(s) + (int(f) / 10 ** len(f) if f else 0)
cycles = []
cur = None
for line in cap:
if "rst:0x" in line or (cur is None and "Log initialized" in line):
cur = {"start": secs(line), "reset": line.split("]", 1)[-1].strip()[:60]}
cycles.append(cur)
continue
if cur is None:
continue
if "Dialing" in line:
cur.setdefault("dial_cap", secs(line))
m = re.search(r"wifi connected at (\d+) ms", line)
if m:
cur.setdefault("wifi", int(m.group(1)))
m = re.search(r"client connected at (\d+) ms \((.*?)\)", line)
if m and "client" not in cur:
cur["client"] = int(m.group(1))
cur["who"] = m.group(2)
m = re.search(r"first HA state '(.*?)' at (\d+) ms", line)
if m and "state" not in cur:
cur["state"] = int(m.group(2))
print(f"{label} cycles: {len(cycles)}")
print(
f"{'n':>2} {'wifi ms':>8} {'dial +ms':>9} {'client ms':>10} {'state ms':>9} {'client-wifi':>11} {'state-client':>12} client reset"
)
for i, c in enumerate(cycles, 1):
dial = ""
if c.get("dial_cap") is not None and c.get("start") is not None:
dial = f"{(c['dial_cap'] - c['start']) * 1000:.0f}"
cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else ""
sc = c["state"] - c["client"] if "state" in c and "client" in c else ""
print(
f"{i:>2} {c.get('wifi', ''):>8} {dial:>9} {c.get('client', ''):>10} {c.get('state', ''):>9} {cw:>11} {sc:>12} {c.get('who', '')} {c.get('reset', '')[:28]}"
)
for c in cycles:
if "client" in c and "wifi" in c:
c["client-wifi"] = c["client"] - c["wifi"]
if "state" in c and "client" in c:
c["state-client"] = c["state"] - c["client"]
for key in ("wifi", "client", "state", "client-wifi", "state-client"):
vals = [c[key] for c in cycles if key in c]
if vals:
print(
f"{key}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}"
)
@@ -0,0 +1,107 @@
"""Per Wi-Fi drop timings from a serial capture of the outgoing connection timing config.
usage: wificycles.py CAPTURE [LABEL]
A cycle starts at "wifi cycle disabling"; the numbers are the device's millis() from the timing lines.
"dial" says whether the client that connected first came from the device's own dial ("Dialing" seen after the
reconnect and before the hello) or from an incoming connection ("Accept" seen first).
"""
import pathlib
import re
import statistics
import sys
cap = (
pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
)
label = sys.argv[2] if len(sys.argv) > 2 else ""
TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]")
def secs(line):
m = TS.match(line)
if not m:
return None
h, mi, se, f = m.groups()
return int(h) * 3600 + int(mi) * 60 + int(se) + (int(f) / 10 ** len(f) if f else 0)
cycles = []
cur = None
loop_max = []
for line in cap:
m = re.search(r"loop time max (\d+) ms", line)
if m:
loop_max.append(int(m.group(1)))
if "wifi cycle disabling" in line:
cur = {"order": []}
cycles.append(cur)
continue
if cur is None:
continue
m = re.search(r"wifi connected at (\d+) ms", line)
if m and "wifi" not in cur:
cur["wifi"] = int(m.group(1))
cur["wifi_cap"] = secs(line)
if "Dialing" in line:
cur["order"].append("dial")
cur["dials"] = cur.get("dials", 0) + 1
if "dial_cap" not in cur and "wifi_cap" in cur:
cur["dial_cap"] = secs(line)
elif "dial_cap" not in cur and "dial_before_wifi_cap" not in cur:
cur["dial_before_wifi_cap"] = secs(line)
if (
re.search(r"client disconnected at \d+ ms \(\d+\.\d+\.\d+\.\d+\)", line)
and "wifi" in cur
and "client" not in cur
):
cur["dial_closed"] = cur.get("dial_closed", 0) + 1
if "Accept " in line and "wifi" in cur:
cur["order"].append("accept")
m = re.search(r"client connected at (\d+) ms \((.*?)\)", line)
if m and "wifi" in cur and "client" not in cur:
cur["client"] = int(m.group(1))
cur["who"] = m.group(2).split(" ")[0]
cur["via"] = cur["order"][0] if cur["order"] else "?"
m = re.search(r"Connect (failed|timeout)", line)
if m:
cur.setdefault("connect_errors", 0)
cur["connect_errors"] += 1
print(f"{label} cycles: {len(cycles)}")
print(
f"{'n':>2} {'wifi ms':>8} {'dial-wifi cap ms':>16} {'client ms':>10} {'client-wifi':>11} via dials closed client"
)
for i, c in enumerate(cycles, 1):
cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else ""
c["client-wifi"] = cw
dw = ""
if "wifi_cap" in c and c.get("wifi_cap") is not None:
# the first dial of the cycle, whether it was logged before or after the Wi-Fi line
if c.get("dial_before_wifi_cap") is not None:
dw = f"{(c['dial_before_wifi_cap'] - c['wifi_cap']) * 1000:.0f}"
elif "dial_cap" in c and c["dial_cap"] is not None:
dw = f"{(c['dial_cap'] - c['wifi_cap']) * 1000:.0f}"
c["dial-wifi"] = dw
print(
f"{i:>2} {c.get('wifi', ''):>8} {dw:>16} {c.get('client', ''):>10} {cw:>11} {c.get('via', ''):7s} {c.get('dials', 0):>5} {c.get('dial_closed', 0):>6} {c.get('who', '')}"
)
for via in ("dial", "accept"):
vals = [
c["client-wifi"]
for c in cycles
if c.get("via") == via and c["client-wifi"] != ""
]
if vals:
print(
f"client-wifi via {via}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}"
)
dws = [float(c["dial-wifi"]) for c in cycles if c.get("dial-wifi") not in ("", None)]
if dws:
print(
f"dial after wifi connected (capture clock): n={len(dws)} min={min(dws):.0f} median={statistics.median(dws):.0f} max={max(dws):.0f}"
)
if loop_max:
print(
f"loop time max per 5 s: n={len(loop_max)} min={min(loop_max)} median={statistics.median(loop_max):.0f} max={max(loop_max)}"
)
@@ -0,0 +1,71 @@
"""Subscribe to states and logs of a device over the API for N seconds and summarize its float sensors.
usage: observe.py IP SECONDS OUTFILE [NAME_FILTER]
Every float sensor state whose entity name contains NAME_FILTER (default: all) is appended to OUTFILE
with a relative timestamp, log lines are appended with a LOG prefix, and a one-line summary per sensor
(count, min, max, last) plus the warning and error count is printed at the end.
"""
import asyncio
import pathlib
import re
import sys
import time
from aioesphomeapi import APIClient, LogLevel
IP, SECS, OUT = sys.argv[1], float(sys.argv[2]), sys.argv[3]
FILTER = sys.argv[4] if len(sys.argv) > 4 else ""
# ESPHome LOG_SECRET span; one cut off before its end marker is secret to the end of the line
SECRET = re.compile(r"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL)
ANSI = re.compile(r"\x1b\[[0-9;]*m")
async def main() -> None:
c = APIClient(IP, 6053, None)
await c.connect(login=True)
di = await c.device_info()
ents, _ = await c.list_entities_services()
names = {e.key: e.name for e in ents}
readings: dict[str, list[float]] = {}
warnings: list[str] = []
t0 = time.monotonic()
with pathlib.Path(OUT).open("w", encoding="utf-8") as out:
out.write(
f"device {di.name} version {di.esphome_version} compiled {di.compilation_time}\n"
)
def on_state(st) -> None:
n = names.get(st.key, str(st.key))
if FILTER in n and hasattr(st, "state") and isinstance(st.state, float):
readings.setdefault(n, []).append(st.state)
out.write(f"[{time.monotonic() - t0:6.1f}] {n}: {st.state}\n")
out.flush()
def on_log(msg) -> None:
line = (
msg.message.decode(errors="replace")
if isinstance(msg.message, bytes)
else msg.message
)
line = ANSI.sub("", SECRET.sub("<secret>", line))
out.write(f"[{time.monotonic() - t0:6.1f}] LOG {line}\n")
out.flush()
if "[W]" in line or "[E]" in line or "Invalid" in line:
warnings.append(line)
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
await asyncio.sleep(SECS)
await c.disconnect()
summary = "; ".join(
f"{k.split()[-1]} n={len(v)} min={min(v):.3f} max={max(v):.3f} last={v[-1]:.3f}"
for k, v in sorted(readings.items())
)
print(f"{di.compilation_time}: {summary}; warnings/errors {len(warnings)}")
for w in warnings[:5]:
print(" ", w)
asyncio.run(main())
+27
View File
@@ -0,0 +1,27 @@
#!/bin/zsh
# usage: run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]
# Observes states and logs over the API with a concurrent ping and prints a summary: the device-side
# "frames parsed" counter per 10 s, gaps over 5 s between voltage updates seen by the API, and warnings.
# Needs a python with aioesphomeapi (PYTHON, default python3).
D=${0:A:h}
IP=$1; SECS=$2; L=$3; S=${4:-.}; F=${5:-}
PY=${PYTHON:-python3}
setopt pipefail
ping -i 1 -c $SECS $IP > $S/ping-$L.txt 2>&1 &
P=$!
if ! timeout $((SECS + 40)) $PY $D/observe.py $IP $SECS $S/obs-$L.txt "$F" 2>&1 | sed "s/$IP/<ip>/g"; then
echo "observe.py failed or timed out, no summary"; wait $P; exit 1
fi
wait $P
echo "ping: $(tail -2 $S/ping-$L.txt | head -1 | sed "s/$IP/<ip>/g") $(tail -1 $S/ping-$L.txt)"
python3 - "$S/obs-$L.txt" <<'PY'
import re, sys
txt = open(sys.argv[1]).read()
fr = [int(m.group(1)) for m in re.finditer(r"frames parsed: (\d+) in 10 s", txt)]
print(f"device frame counter per 10 s: n={len(fr)} min={min(fr) if fr else None} max={max(fr) if fr else None} mean={sum(fr)/len(fr) if fr else 0:.1f}")
ts = [float(m.group(1)) for m in re.finditer(r"^\[\s*([0-9.]+)\] .*Voltage: ", txt, re.M)]
gaps = [round(b - a, 1) for a, b in zip(ts, ts[1:]) if b - a > 5]
print(f"API voltage updates {len(ts)}, gaps over 5 s: {gaps}")
warn = [l for l in txt.splitlines() if "LOG" in l and ("[W]" in l or "[E]" in l or "Invalid" in l)]
print(f"warnings/errors in log: {len(warn)}", *warn[:3], sep="\n ")
PY
@@ -0,0 +1,22 @@
#!/bin/sh
# cases.sh DEV_IP KEY CAPTURE LABEL : the SEN0395 flow (settings, switches, junk lines, factory reset, reset) against
# templates/dfrobot-sen0395-dut.yaml, with a serial capture of the device running into CAPTURE
D=$1; K=$2; LOG=$3; LABEL=$4; PY=${PYTHON:-python3}; CTL="$(dirname "$0")/ctl.py"
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'dfrobot\|Detected\|\[W\]\|\[E\]\|sen\]' | grep -a -v 'JYBSS\|took a long time' | cut -c1-200; }
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; $PY "$CTL" "$D" "$K" "$@" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; }
frames() { echo "frames: $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,1') present, $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,0') absent"; }
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames ($(date +%T))"; sleep 20; frames "$n0"
step "range 0 to 3 m" "press:Range 0 to 3m" wait:12
step "range 0 to 9 m" "press:Range 0 to 9m" wait:12
step "latency 1 s / 2 s" "press:Latency 1s 2s" wait:12
step "latency 0 / 0" "press:Latency 0 0" wait:12
step "sensitivity 7" "press:Sensitivity 7" wait:12
step "LED off then on" "sw:LED=off" wait:10 "sw:LED=on" wait:10
step "sensor active off then on" "sw:Sensor active=off" wait:10 "sw:Sensor active=on" wait:10
step "presence via UART off then on" "sw:Presence via UART=off" wait:10 "sw:Presence via UART=on" wait:10
step "start after boot off then on" "sw:Start after boot=off" wait:10 "sw:Start after boot=on" wait:10
for n in 100 160 200 220 240 256; do step "junk line of $n bytes" "press:Junk $n" wait:7; done
step "junk 220 then a command" "press:Junk 220" wait:6 "press:Range 0 to 9m" wait:12
step "factory reset" "press:Factory reset" wait:15
step "reset" "press:Reset" wait:15
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames after ($(date +%T))"; sleep 20; frames "$n0"
@@ -0,0 +1,63 @@
"""Drive the SEN0395 test device over the API and print its binary sensor and switch updates.
usage: ctl.py HOST KEY(- for plaintext) OP [OP ...] OP: press:NAME | sw:NAME=on|off | wait:SECONDS
"""
import asyncio
import sys
import time
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
SwitchInfo,
SwitchState,
)
HOST, KEY = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
switches = {e.name: e for e in ents if isinstance(e, SwitchInfo)}
def on_state(s) -> None:
e = by_key.get(s.key)
if isinstance(s, BinarySensorState):
print(
f"{time.time() - T0:7.3f} BIN {e.name} = {'ON' if s.state else 'off'}"
)
elif isinstance(s, SwitchState):
print(
f"{time.time() - T0:7.3f} SW {e.name} = {'on' if s.state else 'off'}"
)
c.subscribe_states(on_state)
await asyncio.sleep(1.0)
for op in OPS:
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
elif kind == "press":
if rest not in buttons:
sys.exit(f"no button {rest!r}; have {sorted(buttons)}")
print(f"{time.time() - T0:7.3f} >>> press {rest}")
c.button_command(buttons[rest].key)
elif kind == "sw":
name, _, val = rest.partition("=")
if name not in switches:
sys.exit(f"no switch {name!r}; have {sorted(switches)}")
print(f"{time.time() - T0:7.3f} >>> switch {name} {val}")
c.switch_command(switches[name].key, val == "on")
else:
sys.exit(f"unknown op {op!r}")
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,56 @@
"""List every entity over the native API with its icon, device class and unit: one sorted JSON line each.
usage: api_list.py HOST KEY OUT
"""
import asyncio
import json
import pathlib
import sys
from aioesphomeapi import APIClient
HOST, KEY, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
rows = []
for e in ents:
row = {
"type": type(e).__name__,
"name": e.name,
"object_id": e.object_id,
"icon": e.icon,
}
for k in (
"device_class",
"unit_of_measurement",
"event_types",
"options",
"mode",
):
if hasattr(e, k):
v = getattr(e, k)
row[k] = (
list(v)
if isinstance(v, (list, tuple))
else (v.name if hasattr(v, "name") else v)
)
row["entity_category"] = (
e.entity_category.name
if hasattr(e.entity_category, "name")
else e.entity_category
)
row["disabled_by_default"] = e.disabled_by_default
rows.append(row)
rows.sort(key=lambda r: (r["type"], r["name"]))
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rows)
print(f"{len(rows)} entities listed")
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,13 @@
#!/bin/sh
# compare.sh NAME_A NAME_B : diff the readback.sh outputs of two device names (api, web, mqttn and, when present, dump)
# after replacing each name with DEV. Exit 1 when any pair differs; the diffs stay in cmp-KIND.diff.
A=$1; B=$2; rc=0
[ -n "$A" ] && [ -n "$B" ] || { echo "usage: compare.sh NAME_A NAME_B"; exit 2; }
for k in api web mqttn dump; do
ext=jsonl; [ $k = dump ] && ext=txt
[ -f "$PWD/$k-$A.$ext" ] && [ -f "$PWD/$k-$B.$ext" ] || { echo "$k: missing"; [ $k = dump ] || rc=1; continue; }
sed "s/$A/DEV/g; s/$(echo "$A" | tr - _)/DEV/g" "$PWD/$k-$A.$ext" > "$PWD/cmp-$k-a.txt"
sed "s/$B/DEV/g; s/$(echo "$B" | tr - _)/DEV/g" "$PWD/$k-$B.$ext" > "$PWD/cmp-$k-b.txt"
if diff -u "$PWD/cmp-$k-a.txt" "$PWD/cmp-$k-b.txt" > "$PWD/cmp-$k.diff"; then echo "$k: identical ($(wc -l < "$PWD/cmp-$k-b.txt" | tr -d ' ') lines)"; else echo "$k: DIFFERS"; head -20 "$PWD/cmp-$k.diff"; rc=1; fi
done
exit $rc
@@ -0,0 +1,37 @@
"""Normalise mosquitto_sub output (topic TAB payload) for one device: drop other devices, replace the name, drop dev block.
usage: mqtt_norm.py IN NAME OUT
"""
import json
import pathlib
import sys
IN, NAME, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
rows = {}
with pathlib.Path(IN).open(encoding="utf-8") as f:
for line in f:
topic, _, payload = line.rstrip("\n").partition("\t")
if (
f"/{NAME}/" not in topic and not topic.startswith(f"{NAME}/")
) or topic == f"{NAME}/debug":
continue
topic = topic.replace(NAME, "DEV")
try:
obj = json.loads(payload)
except ValueError:
rows[topic] = payload.replace(NAME, "DEV")
continue
if isinstance(obj, dict):
obj.pop("dev", None)
obj = {
k: (v.replace(NAME, "DEV") if isinstance(v, str) else v)
for k, v in obj.items()
}
rows[topic] = obj
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
f.writelines(
json.dumps({"topic": t, "payload": rows[t]}, sort_keys=True) + "\n"
for t in sorted(rows)
)
print(f"{len(rows)} topics for {NAME}")
@@ -0,0 +1,18 @@
#!/bin/sh
# readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE] : record how a device built from templates/entity-icon-dut.yaml
# reports its entity icons and device classes: the native API listing (api-NAME.jsonl), the retained MQTT discovery
# payloads and states (mqtt-NAME.txt, normalised to mqttn-NAME.jsonl with the device name replaced and the debug topic
# dropped), the web_server /events listing plus every REST endpoint with ?detail=all (web-NAME.jsonl) and, when a
# serial CAPTURE is given, the dump_config Icon and Device Class lines (dump-NAME.txt, timestamps and line numbers
# removed). KEY as for api_button.py. Outputs go to the working directory; compare two names with compare.sh.
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; N=$3; B=$4; CAP=$5; rc=0; TAB=$(printf '\t')
[ -n "$DEV" ] && [ -n "$KEY" ] && [ -n "$N" ] && [ -n "$B" ] || { echo "usage: readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE]"; exit 2; }
timeout 12 mosquitto_sub -h "$B" -v -t 'homeassistant/#' -t "$N/#" -F "%t$TAB%p" > "$PWD/mqtt-$N.txt" 2> "$PWD/mqtt-$N.err"
$PY "$HERE/mqtt_norm.py" "$PWD/mqtt-$N.txt" "$N" "$PWD/mqttn-$N.jsonl" || rc=1
$PY "$HERE/api_list.py" "$DEV" "$KEY" "$PWD/api-$N.jsonl" || { echo "API listing failed"; rc=1; }
$PY "$HERE/web_dump.py" "$DEV" "$PWD/web-$N.jsonl" || { echo "web_server dump failed"; rc=1; }
if [ -n "$CAP" ]; then
LC_ALL=C grep -a '\]: Icon: \|\]: Device Class: ' "$CAP" | LC_ALL=C sed 's/\x1b\[[0-9;]*m//g; s/^\[[^]]*\]//; s/:[0-9]*\]:/]:/' | sort > "$PWD/dump-$N.txt"
echo "dump_config icon lines $(grep -c 'Icon:' "$PWD/dump-$N.txt"), device class lines $(grep -c 'Device Class:' "$PWD/dump-$N.txt")"
fi
exit $rc
@@ -0,0 +1,99 @@
"""Dump the web_server view of every entity: the /events listing (detail all) and each REST endpoint with ?detail=all.
usage: web_dump.py HOST OUT
"""
import json
import pathlib
import socket
import sys
import time
import urllib.parse
import urllib.request
HOST, OUT = sys.argv[1], sys.argv[2]
def sse_listing(seconds: float) -> list[dict]:
s = socket.create_connection((HOST, 80), timeout=5)
s.sendall(
b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n"
% HOST.encode()
)
buf = b""
deadline = time.monotonic() + seconds
s.settimeout(1.0)
while time.monotonic() < deadline:
try:
data = s.recv(65536)
except TimeoutError:
continue
if not data:
break
buf += data
s.close()
head, _, body = buf.partition(b"\r\n\r\n")
if b"chunked" in head.lower():
out = b""
while True:
i = body.find(b"\r\n")
if i < 0:
break
size = int(body[:i].split(b";")[0], 16)
out += body[i + 2 : i + 2 + size]
body = body[i + 2 + size + 2 :]
if size == 0:
break
body = out
rows = []
for block in body.decode(errors="replace").split("\n\n"):
ev, data = None, None
for line in block.split("\n"):
line = line.rstrip("\r")
if line.startswith("event:"):
ev = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
if ev in ("state", "state_detail_all") and data:
try:
obj = json.loads(data)
except ValueError:
rows.append({"event": ev, "invalid": data})
continue
# bare state updates that land inside the window carry no domain; only the listing rows are kept
if "domain" in obj:
rows.append({"event": ev, **obj})
return rows
def get_json(url: str) -> dict:
"""One retry: the ESP8266 async server occasionally leaves a request unanswered."""
for attempt in (1, 2):
try:
with urllib.request.urlopen(url, timeout=5) as resp:
return json.loads(resp.read())
except TimeoutError:
if attempt == 2:
raise
print(f"retrying {url}", flush=True)
return {}
rows = sse_listing(6)
ids = sorted({r["id"] for r in rows if "id" in r})
rest = []
for ident in ids:
domain, _, obj = ident.partition("/")
url = f"http://{HOST}/{domain}/{urllib.parse.quote(obj)}?detail=all"
try:
rest.append({"url": url.replace(HOST, "HOST"), **get_json(url)})
except Exception as e: # noqa: BLE001
rest.append({"url": url.replace(HOST, "HOST"), "error": str(e)})
time.sleep(0.3)
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
f.writelines(
json.dumps(r, sort_keys=True) + "\n"
for r in sorted(rows, key=lambda r: (r.get("id", ""), r["event"]))
)
f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rest)
print(f"{len(rows)} sse rows, {len(ids)} ids, {len(rest)} rest rows")
@@ -0,0 +1,78 @@
"""Read or drive a bulb directly over its own ESPHome API (an HA-side change, independent of the mirror).
usage: bulb.py HOST KEY state | on [brightness 0-1] [ct_mireds|rgb r,g,b] | off | restart
"""
import asyncio
import sys
from aioesphomeapi import APIClient, ButtonInfo, LightColorCapability, LightInfo
HOST, KEY, CMD = sys.argv[1], sys.argv[2], sys.argv[3]
ARGS = sys.argv[4:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
light = next((e for e in ents if isinstance(e, LightInfo)), None)
if light is None:
sys.exit("no light entity on this device; is HOST a bulb?")
if CMD == "state":
fut = asyncio.get_running_loop().create_future()
def on_state(st):
if getattr(st, "key", None) == light.key and not fut.done():
fut.set_result(st)
c.subscribe_states(on_state)
st = await asyncio.wait_for(fut, 5)
print(
f"{light.name}: on={st.state} mode={st.color_mode} bri={st.brightness:.2f} "
f"ct={st.color_temperature:.1f} rgb={st.red:.2f}/{st.green:.2f}/{st.blue:.2f} "
f"white={st.white:.2f} cold={st.cold_white:.2f} warm={st.warm_white:.2f}"
)
elif CMD == "on":
kw = {"state": True}
if ARGS:
kw["brightness"] = float(ARGS[0])
if len(ARGS) > 1:
if "," in ARGS[1]:
r, g, b = (float(x) for x in ARGS[1].split(","))
kw["rgb"] = (r, g, b)
kw["color_mode"] = (
LightColorCapability.RGB
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
)
else:
kw["color_temperature"] = float(ARGS[1])
kw["color_mode"] = (
LightColorCapability.COLOR_TEMPERATURE
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
)
c.light_command(light.key, **kw)
print("bulb on", kw)
elif CMD == "off":
c.light_command(light.key, state=False)
print("bulb off")
elif CMD == "restart":
btn = next(
(
e
for e in ents
if isinstance(e, ButtonInfo) and "restart" in e.name.lower()
),
None,
)
if btn is None:
sys.exit("no restart button on this device")
c.button_command(btn.key)
print("bulb restart pressed")
await asyncio.sleep(0.5)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,71 @@
"""Held-report cases for esphome#21144 with persistent API connections, so the steps land inside the 3 s fade.
usage: heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY] (KEY or BULB_KEY of - means plaintext)
t0 press "A 50 fade 3s" on the mirror device (3 s fade to 50%)
t0+0.7 set the bulb directly to 90% (Home Assistant reports it to the mirror during the fade: held)
t0+1.4 same: press "A 50 fade 3s" again (same target as the running fade)
many: press "A many calls" (16 calls in one loop pass, the last one 100% with a 1 s fade)
t0+6.5 read the bulb, press Log state so the mirror prints its state
"""
import asyncio
import sys
import time
from aioesphomeapi import APIClient, ButtonInfo, LightInfo, LightState
DEV, KEY, BULB, CASE = sys.argv[1:5]
BULB_KEY = sys.argv[5] if len(sys.argv) > 5 else "-"
if CASE not in ("same", "many"):
sys.exit(f"case must be same or many, got {CASE!r}")
T0 = time.time()
def stamp() -> str:
return f"{time.time() - T0:6.3f}"
async def main() -> None:
dev = APIClient(DEV, 6053, None, noise_psk=None if KEY == "-" else KEY)
bulb = APIClient(BULB, 6053, None, noise_psk=None if BULB_KEY == "-" else BULB_KEY)
await dev.connect(login=True)
await bulb.connect(login=True)
dents, _ = await dev.list_entities_services()
bents, _ = await bulb.list_entities_services()
buttons = {e.name: e for e in dents if isinstance(e, ButtonInfo)}
blight = next(e for e in bents if isinstance(e, LightInfo))
states = {}
def on_bulb(s) -> None:
if isinstance(s, LightState) and s.key == blight.key:
states["bulb"] = s
print(f"{stamp()} bulb state: on={s.state} bri={s.brightness:.2f}")
bulb.subscribe_states(on_bulb)
await asyncio.sleep(1.0)
print(f"{stamp()} >>> bulb to 100% (precondition)")
bulb.light_command(blight.key, state=True, brightness=1.0)
await asyncio.sleep(4.0)
t = time.time()
print(f"{stamp()} >>> t0 press A 50 fade 3s")
dev.button_command(buttons["A 50 fade 3s"].key)
await asyncio.sleep(0.7 - (time.time() - t))
print(f"{stamp()} >>> bulb direct to 90%")
bulb.light_command(blight.key, state=True, brightness=0.9)
await asyncio.sleep(1.4 - (time.time() - t))
name = "A 50 fade 3s" if CASE == "same" else "A many calls"
print(f"{stamp()} >>> press {name}")
dev.button_command(buttons[name].key)
await asyncio.sleep(6.5 - (time.time() - t))
print(f"{stamp()} >>> press Log state (the mirror's state is in the device log)")
dev.button_command(buttons["Log state"].key)
await asyncio.sleep(1.0)
b = states.get("bulb")
print(
f"{stamp()} RESULT bulb "
+ (f"bri={b.brightness:.2f}" if b is not None else "no state")
)
await dev.disconnect()
await bulb.disconnect()
asyncio.run(main())
@@ -0,0 +1,85 @@
"""Drive LD2450 numbers and selects over the API while printing every LD2450 state update.
usage: zonedrive.py HOST KEY(- for plaintext) OP [OP ...]
OP: set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS | list
Every state update of an entity whose name contains "LD2450" is printed with a timestamp.
"""
import asyncio
import sys
import time
from aioesphomeapi import (
APIClient,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
SensorInfo,
SensorState,
)
HOST, KEY = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
sensors = {e.name: e for e in ents if isinstance(e, SensorInfo)}
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None or "LD2450" not in e.name:
return
if isinstance(s, NumberState):
print(
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}"
)
elif isinstance(s, SelectState):
print(f"{stamp()} SEL {e.name} = {s.state}")
elif isinstance(s, SensorState) and "Count" in e.name:
print(f"{stamp()} CNT {e.name} = {s.state:.0f}")
c.subscribe_states(on_state)
await asyncio.sleep(1.5)
for op in OPS:
if op == "list":
for n in sorted(numbers):
print("number:", n)
for n in sorted(selects):
print("select:", n, selects[n].options)
for n in sorted(sensors):
if "Count" in n:
print("sensor:", n)
continue
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
continue
name, _, value = rest.partition("=")
if kind == "set":
if name not in numbers:
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
print(f"{stamp()} >>> set {name} = {value}")
c.number_command(numbers[name].key, float(value))
elif kind == "sel":
if name not in selects:
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
print(f"{stamp()} >>> select {name} = {value}")
c.select_command(selects[name].key, value)
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(0.3)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,116 @@
// BK72xx crash probe: forces one crash per step and shows what the SDK and the bootloader leave behind.
// Steps: 0 undefined instruction, 1 call into unmapped memory, 2 unmapped and unaligned accesses (no fault),
// 3 lt_reboot(). The step lives in a flash preference saved and synced before the crash.
#pragma once
#include <Arduino.h>
#include <FreeRTOS.h>
#include <task.h>
#include <cstdint>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
extern "C" unsigned bk_misc_get_start_type(void);
extern "C" char _bss_end[];
static constexpr uint32_t PROBE_CANARY_LOW = 0x004000F8; // free word in the SDK-reserved low SRAM
static constexpr uint32_t PROBE_CANARY_ITCM = 0x003F3FF0; // top of ITCM
static char bkprobe_report[112];
static constexpr uint32_t PROBE_MARKS[] = {0x40A1B0, 0x40B000, 0x40C000, 0x40E000, 0x410000, 0x414000, 0x418000,
0x41C000, 0x420000, 0x428000, 0x430000, 0x438000, 0x43C000, 0x43E000,
0x43F000, 0x43F800, 0x43FC00, 0x43FE00, 0x43FFF0};
static constexpr size_t PROBE_MARK_COUNT = sizeof(PROBE_MARKS) / sizeof(PROBE_MARKS[0]);
static inline uint32_t rd(uint32_t a) { return *(volatile uint32_t *) a; }
inline esphome::ESPPreferenceObject bkprobe_pref(uint32_t run) {
return esphome::global_preferences->make_preference<int>(0xBC000000u + run);
}
inline int bkprobe_boot(uint32_t run) {
using namespace esphome;
int step = 0;
bkprobe_pref(run).load(&step);
unsigned st = bk_misc_get_start_type();
snprintf(bkprobe_report, sizeof(bkprobe_report), "step=%d reason=%s(%d) start_type=%u low=%08lx itcm=%08lx top=%08lx",
step, lt_get_reboot_reason_name(lt_get_reboot_reason()), (int) lt_get_reboot_reason(), st,
(unsigned long) rd(PROBE_CANARY_LOW), (unsigned long) rd(PROBE_CANARY_ITCM),
(unsigned long) rd(0x0043FFF0u));
ESP_LOGI("probe", "%s", bkprobe_report);
for (uint32_t a = 0x400000; a < 0x400100; a += 32) {
ESP_LOGI("probe", "%06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a,
(unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12),
(unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24),
(unsigned long) rd(a + 28));
}
for (size_t i = 0; i < PROBE_MARK_COUNT; i += 7) {
char line[120];
int n = 0;
for (size_t j = i; j < i + 7 && j < PROBE_MARK_COUNT; j++)
n += snprintf(line + n, sizeof(line) - n, " %06lx=%08lx", (unsigned long) PROBE_MARKS[j],
(unsigned long) rd(PROBE_MARKS[j]));
ESP_LOGI("probe", "marks%s", line);
}
uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u;
for (uint32_t a = rec; a < rec + 64; a += 32) {
ESP_LOGI("probe", "rec %06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a,
(unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12),
(unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24),
(unsigned long) rd(a + 28));
}
*(volatile uint32_t *) PROBE_CANARY_LOW = 0xC0DE0000u | (uint32_t) step;
*(volatile uint32_t *) PROBE_CANARY_ITCM = 0x17C40000u | (uint32_t) step;
return step;
}
__attribute__((noinline)) void bkprobe_data_abort() {
using namespace esphome;
volatile uint32_t v = rd(0xFFFFFFF0);
ESP_LOGI("probe", "read 0xFFFFFFF0 -> %08lx (no fault)", (unsigned long) v);
uint32_t r;
__asm__ volatile("ldr %0, [%1]" : "=r"(r) : "r"(0x00400101) : "memory");
ESP_LOGI("probe", "unaligned ldr 0x400101 -> %08lx (no fault)", (unsigned long) r);
*(volatile uint32_t *) 0xFFFFFFF0 = 1;
ESP_LOGI("probe", "store 0xFFFFFFF0 (no fault)");
}
// act is the step number before it was bumped
inline void bkprobe_act(uint32_t run, int act) {
using namespace esphome;
int next = act + 1;
bkprobe_pref(run).save(&next);
global_preferences->sync();
ESP_LOGI("probe", "acting on step %d (saved %d)", act, next);
// mark candidate storage areas so the next boot shows what survives
uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u;
for (uint32_t i = 0; i < 8; i++)
*(volatile uint32_t *) (rec + 32 + i * 4) = 0xA5A50000u | i; // second half of the record area
for (size_t i = 0; i < PROBE_MARK_COUNT; i++)
*(volatile uint32_t *) PROBE_MARKS[i] = 0x70700000u | (uint32_t) i;
ESP_LOGI("probe", "ramtop before: %08lx", (unsigned long) rd(0x0043FFF0u));
vTaskDelay(pdMS_TO_TICKS(50));
switch (act) {
case 0:
__builtin_trap(); // undefined instruction
break;
case 1: {
void (*fn)() = (void (*)()) 0xFFFFFFF1; // prefetch abort (unmapped, Thumb)
fn();
ESP_LOGI("probe", "call 0xFFFFFFF1 returned (no fault)");
break;
}
case 2:
bkprobe_data_abort();
break;
case 3:
lt_reboot();
break;
default:
break;
}
}
inline void bkprobe_alive(int step) {
using namespace esphome;
ESP_LOGI("probe", "alive step=%d uptime=%lus boot: %s", step, (unsigned long) (millis() / 1000), bkprobe_report);
}
@@ -0,0 +1,25 @@
"""Feed a serial capture through the CLI's stacktrace decoder for a build.
usage: decode_capture.py CONFIG_YAML CAPTURE
Run from the directory that holds the config (its .esphome build is used), with the esphome checkout that
built it on PYTHONPATH. Lines of the crash report are echoed, decoded frames follow them.
"""
import logging
from pathlib import Path
import sys
from esphome.config import read_config
from esphome.core import CORE
from esphome.stacktrace import LogLineProcessor
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
yaml, capture = Path(sys.argv[1]), Path(sys.argv[2])
CORE.config_path = yaml
config = read_config({})
proc = LogLineProcessor(config, CORE.target_platform)
for line in capture.read_text(encoding="utf-8", errors="replace").splitlines():
if "libretiny.crash" in line:
print(line[:120])
proc.process_line(line)
@@ -0,0 +1,155 @@
#pragma once
// BK7238 bench harness for esphome#21007: one rtc and one flash preference, a step counter in flash,
// and an exit action per step so the board drives its own soft reboot, crash and watchdog reboot.
#include <libretiny.h>
#include <cstring>
#include <FreeRTOS.h>
#include <task.h>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
using namespace esphome;
// Boot report kept for the alive line, so a boot the host could not capture is still visible
static char ltprefs_report[128];
// Last boots' reports, kept in a flash preference so boots the host never saw stay readable
struct LtprefsHistory {
char line[8][128];
};
static LtprefsHistory ltprefs_hist;
inline ESPPreferenceObject ltprefs_step_pref(uint32_t run) {
return global_preferences->make_preference<int>(0x3B210070u ^ run, true);
}
// Loads the step and both preferences, logs them, saves this step's value.
// Returns the exit action: 0 idle, 1 lt_reboot, 2 crash, 3 watchdog.
inline int ltprefs_boot(int &step, uint32_t run) {
auto reason = lt_get_reboot_reason();
step = 0;
ltprefs_step_pref(run).load(&step);
auto rtcp = global_preferences->make_preference<uint32_t>(0x1B210070u ^ run, false);
auto flp = global_preferences->make_preference<uint32_t>(0x2B210070u ^ run, true);
uint32_t rv = 0, fv = 0;
// a second pair sharing one key as rtc and as flash, and a third rtc entry, so a hard reset drops three
auto rtcp2 = global_preferences->make_preference<uint32_t>(0x5B210070u ^ run, false);
auto flp2 = global_preferences->make_preference<uint32_t>(0x5B210070u ^ run, true);
auto rtcp3 = global_preferences->make_preference<uint32_t>(0x6B210070u ^ run, false);
uint32_t rv2 = 0, fv2 = 0, rv3 = 0;
bool rok = rtcp.load(&rv);
bool fok = flp.load(&fv);
bool rok2 = rtcp2.load(&rv2);
bool fok2 = flp2.load(&fv2);
bool rok3 = rtcp3.load(&rv3);
snprintf(ltprefs_report, sizeof(ltprefs_report),
"step=%d reason=%s(%d) rtc=%s%u flash=%s%u sharedkey rtc=%s%u flash=%s%u rtc3=%s%u", step,
lt_get_reboot_reason_name(reason), (int) reason, rok ? "" : "none ", rv, fok ? "" : "none ", fv,
rok2 ? "" : "none ", rv2, fok2 ? "" : "none ", fv2, rok3 ? "" : "none ", rv3);
ESP_LOGI("lttest", "%s", ltprefs_report);
auto histp = global_preferences->make_preference<LtprefsHistory>(0x4B210070u ^ run, true);
if (!histp.load(&ltprefs_hist))
memset(&ltprefs_hist, 0, sizeof(ltprefs_hist));
memmove(&ltprefs_hist.line[1], &ltprefs_hist.line[0], sizeof(ltprefs_hist.line) - sizeof(ltprefs_hist.line[0]));
strncpy(ltprefs_hist.line[0], ltprefs_report, sizeof(ltprefs_hist.line[0]) - 1);
ltprefs_hist.line[0][sizeof(ltprefs_hist.line[0]) - 1] = 0;
if (!histp.save(&ltprefs_hist)) {
ESP_LOGW("lttest", "history save failed");
}
int act = 0;
bool save = true;
switch (step) {
case 0:
act = 1;
break; // soft reboot
case 1:
act = 2;
break; // crash
case 2:
act = 3;
break; // watchdog
case 3:
act = 0;
break; // idle: chip-enable reset from the host
case 4:
act = 1;
break; // soft reboot after the hard reset
case 5:
act = 0;
break; // idle: chip-enable reset from the host
case 6:
act = 1;
save = false;
break; // no save, soft reboot: deleted, not skipped?
case 7:
act = 0;
break; // idle
case 8:
act = 2;
break; // crash
case 9:
act = 0;
break; // idle
case 10:
act = 0;
break; // idle: power cut by the user
default:
act = 0;
save = false;
break;
}
if (save) {
uint32_t v = 100 + step, v2r = 200 + step, v2f = 300 + step, v3 = 400 + step;
bool srtc = rtcp.save(&v);
bool sfl = flp.save(&v);
bool srtc2 = rtcp2.save(&v2r);
bool sfl2 = flp2.save(&v2f);
bool srtc3 = rtcp3.save(&v3);
bool synced = global_preferences->sync();
ESP_LOGI("lttest", "save %u/%u/%u/%u: rtc=%d flash=%d sharedkey rtc=%d flash=%d rtc3=%d sync=%d", v, v2r, v2f, v3,
srtc, sfl, srtc2, sfl2, srtc3, synced);
}
ESP_LOGI("lttest", "exit action=%d", act);
return act;
}
inline void ltprefs_exit(int &step, uint32_t run, int act) {
step++;
bool saved = ltprefs_step_pref(run).save(&step);
bool synced = global_preferences->sync();
ESP_LOGI("lttest", "step %d save=%d sync=%d", step, saved, synced);
if (!saved || !synced) {
ESP_LOGE("lttest", "step not stored, staying idle instead of repeating the step");
return;
}
vTaskDelay(pdMS_TO_TICKS(200));
switch (act) {
case 1:
ESP_LOGW("lttest", "soft reboot now");
vTaskDelay(pdMS_TO_TICKS(50));
lt_reboot();
break;
case 2:
ESP_LOGW("lttest", "crashing now");
vTaskDelay(pdMS_TO_TICKS(50));
__builtin_trap(); // undefined instruction: a plain bad store does not fault on the ARM968
break;
case 3:
ESP_LOGW("lttest", "watchdog reboot now");
vTaskDelay(pdMS_TO_TICKS(50));
lt_reboot_wdt();
break;
default:
ESP_LOGW("lttest", "idle, waiting for an external reset");
break;
}
}
inline void ltprefs_alive(int step, int act) {
ESP_LOGI("lttest", "alive step=%d act=%d boot: %s", step, act, ltprefs_report);
for (int i = 7; i >= 0; i--) {
if (ltprefs_hist.line[i][0] != 0) {
ESP_LOGI("lttest", " history[-%d]: %s", i, ltprefs_hist.line[i]);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] : subscribe to NAME/# on the broker, send a
# switch command and an on_message topic, press the 3000 byte publish button over the API (KEY, - for plaintext),
# optionally restart the local broker (its PID from mosquitto.pid in the working directory, written when it was started
# with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid`), and show the device log. Outputs (sub-NAME.txt, mosquitto.out) go to the working directory, which should be the scratch directory.
N=$1; KEY=$2; B=$3; SP=$4; LOG=$5; DEV=${6:-$1.local}; RESTART=$7; HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; WORK=$PWD
BTN=$HERE/../api_button.py
[ -n "$KEY" ] && [ -r "$LOG" ] || { echo "usage: cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] (CAPTURE must be a readable file)"; exit 2; }
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'mqtt\|\[probe:\|cmd payload\|Relay\|\[W\]\|\[E\]\|Guru\|abort\|Backtrace\|ESPHome version' | grep -a -v 'took a long time\|Publish.*Counter\|sensor/counter' | cut -c1-170; }
pub() { mosquitto_pub -h "$B" -p "$SP" -t "$1" -m "$2" || { echo "mosquitto_pub to $1 failed"; exit 1; }; }
n0=$(($(wc -l < "$LOG")+0))
timeout 75 mosquitto_sub -h "$B" -p "$SP" -v -t "$N/#" > "$WORK/sub-$N.txt" 2> "$WORK/sub-$N.err" &
sub_pid=$!
sleep 20
echo "=== $N: command to the switch and the 3000 byte publish ($(date +%T))"
pub "$N/switch/relay/command" ON; sleep 2
pub "$N/cmd" "hello-over-$N"; sleep 2
$PY "$BTN" "$DEV" "$KEY" press "Publish big" || { echo "button press failed"; exit 1; }; sleep 3
pub "$N/switch/relay/command" OFF; sleep 2
if [ "$RESTART" = restart ]; then
[ -f "$WORK/mosquitto-ws.conf" ] || { echo "no mosquitto-ws.conf in $WORK, cannot restart the broker"; exit 1; }
echo "=== $N: broker restart ($(date +%T))"; n1=$(($(wc -l < "$LOG")+0))
# Only this run's broker is signalled: its PID comes from mosquitto.pid, written when it was started
pid=$(cat "$WORK/mosquitto.pid" 2>/dev/null); [ -n "$pid" ] || { echo "no mosquitto.pid in $WORK, cannot restart the broker"; exit 1; }
kill "$pid" || { echo "broker PID $pid is not running, nothing to restart"; exit 1; }; sleep 8
nc -z -w 3 "$B" 1883 && { echo "the old broker still answers on 1883 after the kill"; exit 1; }
# exec so the PID is the broker's own and no shell stays behind holding the caller's output pipe open
(cd "$WORK" && exec mosquitto -c mosquitto-ws.conf > mosquitto.out 2>&1 < /dev/null) &
echo $! > "$WORK/mosquitto.pid"; sleep 25
for port in $(awk '/^listener/ {print $2}' "$WORK/mosquitto-ws.conf"); do nc -z -w 3 "$B" "$port" || { echo "broker port $port not listening after the restart"; exit 1; }; done
tail -n +$((n1+1)) "$LOG" | grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected" || { echo "the device did not reconnect after the broker restart"; exit 1; }
else
echo "=== $N: no broker restart (pass 'restart' as the 7th argument for a local broker)"
fi
wait $sub_pid; sub_rc=$?
[ "$sub_rc" = 124 ] || { echo "subscriber ended with status $sub_rc: $(cat "$WORK/sub-$N.err")"; exit 1; }
echo "--- broker saw ($(grep -c . "$WORK/sub-$N.txt") messages): $(cut -c1-60 "$WORK/sub-$N.txt" | awk '{print $1}' | sort | uniq -c | tr '\n' ';')"
big=$(grep "^$N/big " "$WORK/sub-$N.txt" | awk '{print length($2)}' | tr '\n' ' ')
echo "--- big payload bytes seen: $big"
echo "--- device log:"; show "$n0"
case " $big " in *" 3000 "*) ;; *) echo "the 3000 byte payload did not reach the broker"; exit 1;; esac
for want in "Relay ON" "cmd payload: hello-over-$N" "Relay OFF"; do
tail -n +$((n0+1)) "$LOG" | grep -a -q "$want" || { echo "the device never logged '$want': the broker to device path failed"; exit 1; }
done
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# cli_tls_check.sh TREE [neg] YAML... : run `esphome clean-mqtt` from the checkout TREE for each config, which goes
# through the same TLS setup in esphome/mqtt.py as `esphome logs` over MQTT, without needing a board. clean-mqtt never
# exits on its own, so it runs under a 25 s timeout: status 124 after "Connected to MQTT broker!" means the CLI
# connected. With neg every config must instead fail with CERTIFICATE_VERIFY_FAILED and never connect, so a negative
# that fails for another reason (bad YAML, broker down) is reported as a failure too. A relative YAML path is taken
# from the working directory, where the cleanmqtt-NAME.log outputs also go. Exit 1 when any config missed its result.
TREE=$1; shift; PY=${PYTHON:-python3}; NEG=0
[ "$1" = neg ] && { NEG=1; shift; }
[ -d "$TREE/esphome" ] && [ -n "$1" ] || { echo "usage: cli_tls_check.sh TREE [neg] YAML..."; exit 2; }
bad=0
for y in "$@"; do
case $y in /*) ;; *) y=$PWD/$y;; esac
n=$(basename "$y" .yaml); log=$PWD/cleanmqtt-$n.log
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 25 $PY -m esphome clean-mqtt "$y" > "$log" 2>&1); rc=$?
if [ "$NEG" = 0 ] && [ "$rc" = 124 ] && grep -q "Connected to MQTT broker" "$log"; then
echo "$n: connected"
elif [ "$NEG" = 1 ] && grep -q "CERTIFICATE_VERIFY_FAILED" "$log" && ! grep -q "Connected to MQTT broker" "$log"; then
echo "$n: rejected (CERTIFICATE_VERIFY_FAILED)"
else
echo "$n: FAILED (status $rc): $(grep -a -m1 -i 'cannot connect\|error' "$log" | cut -c1-160)"; bad=$((bad+1))
fi
done
[ "$bad" = 0 ]
@@ -0,0 +1,53 @@
#!/bin/sh
# flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST MODE [SUB_PORT] : flash YAML from the checkout TREE over the serial
# PORT, capture the serial log (cap-NAME.txt in the working directory), wait for the first MQTT connect or TLS error,
# then drive the device with cases.sh (KEY as for cases.sh, - for a plaintext API). MODE local runs cases.sh with a
# broker restart (the working directory must hold mosquitto-ws.conf and mosquitto.pid), public runs it without a
# restart, neg only watches for 100 s (a build that must never connect). SUB_PORT is the plain listener cases.sh
# subscribes on (default 1883). Ends with connect, disconnect, handshake error and crash counts, the config dump's
# TLS line, the probe heap lines and the broker listener each connection used. Exit 1 when the capture did not start,
# the device reached no MQTT event within 90 s (local and public), it crashed, or the mode's own check failed.
TREE=$1; YAML=$2; PORT=$3; KEY=$4; B=$5; MODE=$6; SP=${7:-1883}
case $YAML in /*) ;; *) YAML=$PWD/$YAML;; esac
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; N=$(basename "$YAML" .yaml); CAP=$PWD/cap-$N.txt; ULOG=$PWD/upload-$N.log
[ -d "$TREE/esphome" ] && [ -e "$PORT" ] && [ -n "$KEY" ] && [ -n "$B" ] || { echo "usage: flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST local|public|neg [SUB_PORT]"; exit 2; }
case $MODE in local|public|neg) ;; *) echo "MODE must be local, public or neg"; exit 2;; esac
pkill -f "[s]erial_capture.py $PORT "; sleep 1
echo "=== $N: flash ($(date +%T))"
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 300 $PY -m esphome upload "$YAML" --device "$PORT" > "$ULOG" 2>&1) || { echo "upload failed"; tail -5 "$ULOG"; exit 1; }
SECS=200; [ "$MODE" = neg ] && SECS=100
# a stale capture from an earlier run must not satisfy the wait below
rm -f "$CAP"
$PY "$HERE/../serial_capture.py" "$PORT" $SECS "$CAP" > "$PWD/cap-$N.stderr" 2>&1 &
cap_pid=$!
i=0; until grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected\|mbedtls_ssl_handshake returned\|Failed to verify" "$CAP" 2>/dev/null || [ $i -ge 45 ]; do sleep 2; i=$((i+1)); done
kill -0 $cap_pid 2>/dev/null && [ -f "$CAP" ] || { echo "serial capture is not running on $PORT"; tail -3 "$PWD/cap-$N.stderr"; exit 1; }
DEV=$(grep -a -o 'IP Address: [0-9.]*' "$CAP" | head -1 | awk '{print $3}')
if [ $i -ge 45 ] || [ -z "$DEV" ]; then
echo "=== $N: no MQTT event or no IP within 90 s ($(date +%T))"
[ "$MODE" = neg ] || { pkill -f "[s]erial_capture.py $PORT "; exit 1; }
else
echo "=== $N: first MQTT event after $((i*2)) s, device $DEV ($(date +%T))"
fi
grep -a 'ESPHome version\|\]: Transport\|\]: TLS:' "$CAP" | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120
rc=0
case $MODE in
local) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV" restart; rc=$?;;
public) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV"; rc=$?;;
neg) sleep 70;;
esac
wait $cap_pid
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
# count the backend's own events so the result does not depend on on_connect/on_disconnect log lines in the config
connects=$(grep -a -c 'MQTT_EVENT_CONNECTED' "$CAP"); errors=$(grep -a -c 'mbedtls_ssl_handshake returned\|Failed to verify certificate' "$CAP")
crashes=$(grep -a -c 'Guru\|abort()\|Backtrace' "$CAP")
echo "=== $N: connects $connects, disconnects $(grep -a -c 'MQTT_EVENT_DISCONNECTED' "$CAP"), handshake errors $errors, crashes $crashes"
grep -a 'mbedtls_ssl_handshake returned\|Failed to verify\|esp-tls' "$CAP" | strip | sed 's/^\[[^]]*\]//' | sort | uniq -c | sort -rn | head -4 | cut -c1-150
grep -a '\[probe:' "$CAP" | strip | sed 's/.*\[probe:[0-9]*\]: //' | sed -n '1p;$p'
[ -f "$PWD/mosquitto.log" ] && [ -n "$DEV" ] && echo "broker listeners used by $DEV: $(grep "New connection from $DEV" "$PWD/mosquitto.log" | sed 's/.*on port //' | sort | uniq -c | tr '\n' ';')"
[ "$crashes" = 0 ] || { echo "device crashed $crashes time(s)"; exit 1; }
if [ "$MODE" = neg ]; then
[ "$connects" = 0 ] && [ "$errors" -gt 0 ] || { echo "negative case: expected no connect and a handshake error"; exit 1; }
else
[ "$rc" = 0 ] || { echo "cases.sh failed with status $rc"; exit 1; }
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# make_test_ca.sh BROKER_IP : a throwaway CA, a server certificate for the broker address and a client certificate
# (30 days), written into the working directory; run it in the scratch directory where a copy of mosquitto-ws.conf
# lives. The device config takes ca.crt as certificate_authority (skip_cert_cn_check: true, the name check does not
# match an address) and client.crt/client.key as client_certificate/client_certificate_key for the listener that
# requires one; other-ca.crt is a second CA for the negative case. The CA carries basicConstraints and keyUsage
# because Python's default SSL context (esphome logs, esphome clean-mqtt) rejects a CA without them.
set -e
IP=$1
[ -n "$IP" ] || { echo "usage: make_test_ca.sh BROKER_IP"; exit 2; }
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=hwtest-broker"
printf "subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n" "$IP" > san.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 30 -extfile san.ext
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=hwtest-client"
printf "extendedKeyUsage=clientAuth\n" > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 30 -extfile client.ext
openssl req -x509 -newkey rsa:2048 -nodes -keyout other-ca.key -out other-ca.crt -days 30 -subj "/CN=other-ca" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl verify -CAfile ca.crt server.crt client.crt
@@ -0,0 +1,26 @@
# Copy next to the certificates in the scratch directory and start it there with
# `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` (cases.sh restarts that PID): plain MQTT, MQTT over TLS,
# MQTT over TLS that requires a client certificate, MQTT over WebSocket, and MQTT over WebSocket with TLS
# (server.crt, server.key, ca.crt from make_test_ca.sh in the same directory). log_type all gives the listener port
# per connection.
listener 1883
protocol mqtt
listener 8883
protocol mqtt
certfile server.crt
keyfile server.key
listener 8884
protocol mqtt
certfile server.crt
keyfile server.key
cafile ca.crt
require_certificate true
listener 9001
protocol websockets
listener 9002
protocol websockets
certfile server.crt
keyfile server.key
allow_anonymous true
log_type all
log_dest file mosquitto.log
@@ -0,0 +1,179 @@
"""Drive the online_image cases over the native API and judge each from the serial capture."""
import asyncio
import pathlib
import re
import sys
import time
from aioesphomeapi import (
APIClient,
APIConnectionError,
InvalidEncryptionKeyAPIError,
RequiresEncryptionAPIError,
)
HOST, KEY, CAPTURE, BASE, MODE = sys.argv[1:6]
CAP_S = 45
CASES = [
# (label, action, path, expected outcome)
("ok bmp", "fetch", "ok.bmp", "finished"),
("ok png", "fetch", "ok.png", "finished"),
("ok jpg", "fetch", "ok.jpg", "finished"),
("ok qoi", "fetch", "ok.qoi", "finished"),
("truncated bmp", "fetch", "truncated.bmp", "error"),
("ok bmp after error", "fetch", "ok.bmp", "finished"),
("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"),
("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"),
("empty body", "fetch", "empty.bmp", "error"),
("stalled connection", "fetch", "stall.bmp", "error"),
("closed early", "fetch", "closed_early.bmp", "error"),
("slow but alive", "fetch", "slow.bmp", "finished"),
("chunked bmp", "fetch", "chunked.bmp", "finished"),
("chunked png", "fetch", "chunked.png", "finished"),
("chunked qoi", "fetch", "chunked.qoi", "finished"),
("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"),
("chunked png cut", "fetch", "chunked_cut.png", "any"),
("no content-length", "fetch", "noclen.bmp", "any"),
("huge jpeg claim", "fetch", "huge.jpg", "error"),
("ok jpg after huge", "fetch", "ok.jpg", "finished"),
("ok bmp again", "fetch", "ok.bmp", "finished"),
("ok bmp refetch: 304", "refetch", "", "finished"),
("ok png", "fetch", "ok.png", "finished"),
("ok png refetch: 304", "refetch", "", "finished"),
("chunked bmp", "fetch", "chunked.bmp", "finished"),
("chunked bmp refetch: 304", "refetch", "", "finished"),
("chunked png", "fetch", "chunked.png", "finished"),
("chunked png refetch: not cached", "refetch", "", "finished"),
("chunked png cut", "fetch", "chunked_cut.png", "any"),
("chunked png cut refetch: not cached", "refetch", "", "any"),
]
if MODE == "base":
# Each stuck case is followed by a fetch that shows the symptom, then a reboot.
CASES = [
("ok bmp", "fetch", "ok.bmp", "finished"),
("truncated bmp", "fetch", "truncated.bmp", "error"),
("ok bmp after truncated", "fetch", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"),
("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("empty body", "fetch", "empty.bmp", "error"),
("ok bmp after empty", "fetch", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("stalled connection", "fetch", "stall.bmp", "error"),
("ok bmp after stall", "fetch", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("closed early", "fetch", "closed_early.bmp", "error"),
("ok bmp after closed early", "fetch", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("chunked png", "fetch", "chunked.png", "finished"),
("ok bmp after chunked png", "fetch", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"),
("ok bmp after chunked cut", "fetch", "ok.bmp", "finished"),
("REBOOT", "", "", ""),
("huge jpeg claim", "fetch", "huge.jpg", "error"),
("ok jpg after huge", "fetch", "ok.jpg", "finished"),
]
OUTCOME = re.compile(r"download (finished|error): (img_small|img)")
INTEREST = re.compile(
r"\[(E|W)\]\[online_image|Image already being updated|download (finished|error)|Downloading image|Updating image|Image fully downloaded|HTTP 304|allocat|Panic|abort\(\)|Guru|Backtrace|rst:0x",
re.IGNORECASE,
)
def tail(path: str, offset: int) -> tuple[list[str], int]:
with pathlib.Path(path).open("rb") as f:
f.seek(offset)
data = f.read()
return data.decode("utf-8", "replace").splitlines(), offset + len(data)
async def connect() -> tuple[APIClient, dict, int]:
for _ in range(60):
try:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, services = await c.list_entities_services()
except (InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError) as exc:
raise SystemExit(f"API key problem, not retrying: {exc}") from exc
except (APIConnectionError, OSError) as exc:
# only connection trouble is retried; a wrong key or a missing button must fail at once
print(f"connect: {exc}", flush=True)
await asyncio.sleep(3)
continue
btn = next(
(e.key for e in ents if getattr(e, "object_id", "") == "reboot"), None
)
if btn is None:
raise SystemExit("the config has no reboot button")
return c, {s.name: s for s in services}, btn
raise SystemExit("API never connected")
async def main() -> None:
client, actions, btn = await connect()
with pathlib.Path(CAPTURE).open("rb") as f:
f.seek(0, 2)
offset = f.tell()
rows = []
for label, action, path, expect in CASES:
if label == "REBOOT":
client.button_command(btn)
await asyncio.sleep(1)
await client.disconnect()
await asyncio.sleep(8)
client, actions, btn = await connect()
_, offset = tail(CAPTURE, offset)
print("--- rebooted", flush=True)
continue
img = "img_small" if action.endswith("_small") else "img"
t0 = time.monotonic()
args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"}
await client.execute_service(actions[action], args)
outcome, notes = "no outcome", []
while time.monotonic() - t0 < CAP_S:
await asyncio.sleep(0.5)
lines, offset = tail(CAPTURE, offset)
done = False
for line in lines:
if INTEREST.search(line):
notes.append(line.strip())
m = OUTCOME.search(line)
if m and m.group(2) == img:
outcome = m.group(1)
done = True
if done:
break
took = time.monotonic() - t0
if outcome == "no outcome":
took = CAP_S
# drain a little more so the "already being updated" line lands
await asyncio.sleep(1)
lines, offset = tail(CAPTURE, offset)
notes += [line.strip() for line in lines if INTEREST.search(line)]
ok = outcome == expect or (expect == "any" and outcome != "no outcome")
rows.append((label, path, expect, outcome, took, ok, notes))
print(
f"{'PASS' if ok else 'FAIL'} {label:32s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s",
flush=True,
)
for n in notes:
print(f" {n}", flush=True)
await asyncio.sleep(1.5)
await client.disconnect()
print(
"\nSUMMARY",
"all expectations met"
if all(r[5] for r in rows)
else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[5]),
flush=True,
)
if not all(r[5] for r in rows):
sys.exit(1)
asyncio.run(main())
@@ -0,0 +1,38 @@
"""Build the small test images the HTTP server serves."""
from pathlib import Path
import struct
import sys
from PIL import Image
out = Path(sys.argv[1])
out.mkdir(exist_ok=True)
w = h = 16
im = Image.new("RGB", (w, h))
px = im.load()
for y in range(h):
for x in range(w):
px[x, y] = (x * 16, y * 16, (x + y) * 8)
im.save(out / "ok.bmp", "BMP")
im.save(out / "ok.png", "PNG")
im.save(out / "ok.jpg", "JPEG", quality=90)
# QOI with only QOI_OP_RGB codes: valid and trivially encoded.
qoi = b"qoif" + struct.pack(">IIBB", w, h, 3, 0)
for y in range(h):
for x in range(w):
qoi += bytes([0xFE, *px[x, y]])
qoi += bytes(7) + b"\x01"
(out / "ok.qoi").write_bytes(qoi)
# 8x8 8-bit palette BMP: 1078 byte header, pixels start beyond a 256 byte buffer.
pw = ph = 8
data_offset = 14 + 40 + 256 * 4
file_size = data_offset + pw * ph
hdr = b"BM" + struct.pack("<IHHI", file_size, 0, 0, data_offset)
dib = struct.pack("<IiiHHIIiiII", 40, pw, ph, 1, 8, 0, pw * ph, 0, 0, 256, 0)
(out / "large_header.bmp").write_bytes(hdr + dib + bytes(256 * 4) + bytes(pw * ph))
for p in sorted(out.iterdir()):
print(p.name, p.stat().st_size)
@@ -0,0 +1,142 @@
"""HTTP server with one misbehaviour per path, for the online_image stalled-download test."""
import asyncio
from pathlib import Path
import sys
import time
WWW = Path(sys.argv[1])
PORT = int(sys.argv[2])
LOG = Path(sys.argv[3]).open("a", encoding="utf-8", buffering=1) # noqa: SIM115
MIME = {"bmp": "image/bmp", "png": "image/png", "jpg": "image/jpeg", "qoi": "image/qoi"}
IMG = {n: (WWW / f"ok.{n}").read_bytes() for n in MIME}
BMP = IMG["bmp"]
BMP_HDR = 54 # 16x16 24-bit: pixel rows follow directly
LARGE_HEADER = (WWW / "large_header.bmp").read_bytes()
def log(msg: str) -> None:
LOG.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
def headers(ctype: str, extra: list[bytes]) -> bytes:
lines = [
b"HTTP/1.1 200 OK",
b"Content-Type: " + ctype.encode(),
b"Connection: close",
*extra,
b"",
b"",
]
return b"\r\n".join(lines)
def etag(path: str) -> bytes:
return b'"' + path.strip("/").encode() + b'-v1"'
def chunked(body: bytes, size: int) -> bytes:
out = b""
for i in range(0, len(body), size):
part = body[i : i + size]
out += b"%x\r\n" % len(part) + part + b"\r\n"
return out + b"0\r\n\r\n"
async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str:
name, _, ext = path.lstrip("/").rpartition(".")
ctype = MIME.get(ext, "application/octet-stream")
body = IMG.get(ext, BMP)
tag = [b"ETag: " + etag(path)]
if b"if-none-match: " + etag(path).lower() in req.lower():
w.write(
b"HTTP/1.1 304 Not Modified\r\nETag: "
+ etag(path)
+ b"\r\nConnection: close\r\n\r\n"
)
return "304 not modified"
if name == "ok":
w.write(headers(ctype, [b"Content-Length: %d" % len(body), *tag]) + body)
return f"full {len(body)} bytes"
if name == "truncated": # cut one byte into the sixth pixel, Content-Length matches
cut = BMP[: BMP_HDR + 5 * 3 + 1]
w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut)
return f"truncated {len(cut)} bytes"
if name == "large_header":
w.write(
headers(ctype, [b"Content-Length: %d" % len(LARGE_HEADER)]) + LARGE_HEADER
)
return f"large header {len(LARGE_HEADER)} bytes"
if name == "empty":
w.write(headers(ctype, [b"Content-Length: 0"]))
return "empty body"
if (
name == "stall"
): # claims the full length, sends part, then holds the socket open
part = body[: BMP_HDR + 15]
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part)
await w.drain()
await asyncio.sleep(40)
return f"stalled after {len(part)} bytes, closed after 40 s"
if name == "closed_early": # claims the full length, sends part, closes at once
part = body[: BMP_HDR + 15]
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part)
return f"closed after {len(part)} of {len(body)} bytes"
if name == "slow": # pauses under the timeout, then completes
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + body[:BMP_HDR])
await w.drain()
await asyncio.sleep(2.5)
w.write(body[BMP_HDR:])
return f"full {len(body)} bytes with a 2.5 s pause"
if name == "chunked":
w.write(
headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(body, 64)
)
return f"chunked full {len(body)} bytes"
if (
name == "chunked_cut"
): # chunked, ends cleanly at a pixel boundary (or half a png)
cut = body[: BMP_HDR + 5 * 3] if ext == "bmp" else body[: len(body) // 2]
w.write(
headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(cut, 64)
)
return f"chunked cut {len(cut)} of {len(body)} bytes"
if name == "noclen": # body delimited by connection close
w.write(headers(ctype, []) + body)
return f"no content-length, full {len(body)} bytes"
if name == "huge": # claims 20 MB so the JPEG buffer resize fails
w.write(headers(ctype, [b"Content-Length: 20000000"]) + body)
return f"claims 20000000, sent {len(body)} bytes"
w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
return "404"
async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
peer = w.get_extra_info("peername")
try:
async with asyncio.timeout(5):
line = await r.readuntil(b"\r\n")
req = await r.readuntil(b"\r\n\r\n")
path = line.split(b" ")[1].decode()
log(
f"GET {path} from {peer}"
+ (" (If-None-Match)" if b"if-none-match" in req.lower() else "")
)
what = await respond(path, req, w)
await w.drain()
log(f" -> {path}: {what}")
except Exception as exc: # noqa: BLE001
log(f" !! {peer}: {exc!r}")
finally:
w.close()
async def main() -> None:
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
log(f"listening on {PORT}")
async with server:
await server.serve_forever()
asyncio.run(main())
@@ -0,0 +1,33 @@
"""Request one camera image over the native API and report its size (direct esp32_camera check)."""
import asyncio
import sys
from aioesphomeapi import APIClient
async def main():
c = APIClient(sys.argv[1], 6053, None)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
cams = [e for e in ents if type(e).__name__ == "CameraInfo"]
print("camera entities:", [e.name for e in cams])
got = asyncio.get_event_loop().create_future()
def on_state(state):
if type(state).__name__ == "CameraState" and not got.done():
got.set_result(len(state.data))
c.subscribe_states(on_state)
c.request_single_image()
try:
n = await asyncio.wait_for(got, 10)
print("single image bytes:", n)
except TimeoutError:
print("no image within 10 s")
await c.disconnect()
sys.exit(1)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,32 @@
#!/bin/zsh
# usage: measure.sh IP LABEL LOGFILE [OUTDIR]
# Runs the fixed RTSP measurement set against one build and appends a result line to OUTDIR/results.txt:
# ping RTT, two 12 s ffmpeg runs, two concurrent ffmpeg clients, and the raw receive rate with the default
# and a 1 MiB socket buffer. LOGFILE is the device capture, used to count "stalled" drops during the run.
# A measurement that produced nothing is written as ERR and the script exits 1, so a broken run never looks
# like a measured one. Needs ffmpeg and a python with the rtsp scripts' dependencies (PYTHON, default python3).
D=${0:A:h}
IP=$1; LABEL=$2; LOG=$3; S=${4:-.}
PY=${PYTHON:-python3}
[ -f "$LOG" ] || { echo "device capture not found: $LOG"; exit 1; }
rtt=$(ping -c 10 -i 0.2 $IP 2>&1 | tail -1 | awk -F/ '{print $5}')
stall0=$(grep -a -c "stalled" $LOG)
frames() { # OUTFILE RC: the frame count of a run that finished on its own, nothing for a failed or killed one
[ "$2" = 0 ] && grep -o 'frame= *[0-9]*' $1 | tail -1 | tr -dc 0-9
}
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f1.txt 2>&1; f1=$(frames $S/m-f1.txt $?); sleep 2
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f2.txt 2>&1; f2=$(frames $S/m-f2.txt $?); sleep 2
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoA.txt 2>&1 & A=$!; sleep 1
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoB.txt 2>&1 & B=$!
wait $A; ra=$?; wait $B; rb=$?
fa=$(frames $S/m-twoA.txt $ra); fb=$(frames $S/m-twoB.txt $rb); sleep 2
timeout 40 $PY $D/rawrate2.py $IP quiet > $S/m-raw.txt 2>&1 && raw=$(grep -o "[0-9.]* KiB/s" $S/m-raw.txt | head -1); sleep 2
timeout 40 $PY $D/rawrate2.py $IP bigbuf > $S/m-raw2.txt 2>&1 && raw2=$(grep -o "[0-9.]* KiB/s" $S/m-raw2.txt | head -1); sleep 1
stall1=$(grep -a -c "stalled" $LOG)
bad=0
for v in rtt f1 f2 fa fb raw raw2; do
if [ -z "${(P)v}" ]; then eval "$v=ERR"; bad=1; fi
done
line="$LABEL | rtt avg ${rtt} ms | ffmpeg single 12 s: ${f1}, ${f2} frames | two clients: ${fa} + ${fb} | python raw: ${raw} (1 MiB rcvbuf ${raw2}) | stalls logged: $((stall1 - stall0))"
echo "$line" | tee -a $S/results.txt
if [ $bad = 1 ]; then echo "one or more measurements failed (ERR above)"; exit 1; fi
@@ -0,0 +1,83 @@
import contextlib
import socket
import sys
import time
IP = sys.argv[1]
mode = sys.argv[2]
secs = 8
s = socket.create_connection((IP, 554), timeout=5)
if mode == "bigbuf":
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
cseq = 0
buf = b""
def read_response() -> bytes:
"""One complete RTSP response: headers through the blank line plus a Content-Length body."""
global buf # noqa: PLW0603
while (end := buf.find(b"\r\n\r\n")) < 0:
chunk = s.recv(4096)
if not chunk:
raise ConnectionError("closed")
buf += chunk
head = buf[:end].decode(errors="replace")
body_len = 0
for line in head.split("\r\n"):
if line.lower().startswith("content-length:"):
body_len = int(line.split(":", 1)[1])
while len(buf) < end + 4 + body_len:
chunk = s.recv(4096)
if not chunk:
raise ConnectionError("closed")
buf += chunk
resp, buf = buf[: end + 4 + body_len], buf[end + 4 + body_len :]
return resp
def req(m, extra="", wait=True):
global cseq # noqa: PLW0603
cseq += 1
url = f"rtsp://{IP}:554/trackID=0" if m == "SETUP" else f"rtsp://{IP}:554/"
s.sendall(f"{m} {url} RTSP/1.0\r\nCSeq: {cseq}\r\n{extra}\r\n".encode())
return read_response() if wait else b""
req("OPTIONS")
req("DESCRIBE", "Accept: application/sdp\r\n")
r = req("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")
sess = (
[x for x in r.decode(errors="replace").split("\r\n") if x.startswith("Session")][0]
.split()[1]
.split(";")[0]
)
req("PLAY", f"Session: {sess}\r\n")
t0 = time.monotonic()
total = 0
chunks = 0
last_chat = t0
s.settimeout(2)
while time.monotonic() - t0 < secs:
now = time.monotonic()
if mode == "chatty" and now - last_chat > 0.2:
req("GET_PARAMETER", f"Session: {sess}\r\n", wait=False)
last_chat = now
if mode == "rtcp" and now - last_chat > 0.2:
s.sendall(b"$\x01\x00\x08" + b"\x80\xc9\x00\x01\x00\x00\x00\x01")
last_chat = now
try:
d = s.recv(65536)
except TimeoutError:
continue
if not d:
break
total += len(d)
chunks += 1
print(
f"{mode:7s}: {total / 1024 / secs:5.1f} KiB/s, {chunks} recvs, avg {total // max(chunks, 1)} B/recv"
)
with contextlib.suppress(OSError):
req("TEARDOWN", f"Session: {sess}\r\n")
s.close()
@@ -0,0 +1,345 @@
"""Raw RTSP/TCP client for the esphome rtsp_server hardware test.
usage: rtsp_client.py HOST [case ...]
cases: play, udp, pipeline, third, stall, teardown, getparam, badreq, replay, timeout
"""
import socket
import struct
import sys
import time
HOST = sys.argv[1]
PORT = 554
cases = sys.argv[2:] or [
"play",
"udp",
"pipeline",
"badreq",
"replay",
"getparam",
"teardown",
"third",
"stall",
]
results = []
def say(case, ok, detail):
results.append((case, ok, detail))
print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True)
class Client:
def __init__(self, timeout=5):
self.s = socket.create_connection((HOST, PORT), timeout=timeout)
self.cseq = 0
self.session = None
self.buf = b""
def send(self, method, extra="", url=None):
self.cseq += 1
url = (
url or f"rtsp://{HOST}:{PORT}/trackID=0"
if method == "SETUP"
else (url or f"rtsp://{HOST}:{PORT}/")
)
req = f"{method} {url} RTSP/1.0\r\nCSeq: {self.cseq}\r\n"
if self.session:
req += f"Session: {self.session}\r\n"
req += extra + "\r\n"
self.s.sendall(req.encode())
return self.cseq
def _recv(self):
chunk = self.s.recv(4096)
if not chunk:
raise ConnectionError("closed")
self.buf += chunk
def read_response(self):
# Read until a full RTSP response (headers + optional body); skip any interleaved data in between
while True:
if self.buf.startswith(b"$"):
if len(self.buf) < 4:
self._recv()
continue
ln = struct.unpack(">H", self.buf[2:4])[0]
if len(self.buf) < 4 + ln:
self._recv()
continue
self.buf = self.buf[4 + ln :]
continue
end = self.buf.find(b"\r\n\r\n")
if end < 0:
self._recv()
continue
head = self.buf[:end].decode(errors="replace")
body_len = 0
for line in head.split("\r\n"):
if line.lower().startswith("content-length:"):
body_len = int(line.split(":", 1)[1])
while len(self.buf) < end + 4 + body_len:
self._recv()
body = self.buf[end + 4 : end + 4 + body_len]
self.buf = self.buf[end + 4 + body_len :]
status = int(head.split(" ", 2)[1])
for line in head.split("\r\n"):
if line.lower().startswith("session:"):
self.session = line.split(":", 1)[1].strip().split(";")[0]
return status, head, body
def read_frames(self, seconds):
"""Collect interleaved RTP packets on channel 0 for `seconds`; return (frames, packets, seq_gaps, sizes)."""
deadline = time.monotonic() + seconds
frames = 0
packets = 0
gaps = 0
last_seq = None
cur = 0
sizes = []
self.s.settimeout(1.0)
while time.monotonic() < deadline:
try:
chunk = self.s.recv(65536)
except TimeoutError:
continue
if not chunk:
break
self.buf += chunk
while True:
if len(self.buf) < 4 or self.buf[0:1] != b"$":
if self.buf and self.buf[0:1] != b"$":
end = self.buf.find(b"\r\n\r\n")
if end < 0:
break
self.buf = self.buf[end + 4 :]
continue
break
ch = self.buf[1]
ln = struct.unpack(">H", self.buf[2:4])[0]
if len(self.buf) < 4 + ln:
break
pkt = self.buf[4 : 4 + ln]
self.buf = self.buf[4 + ln :]
if ch != 0 or len(pkt) < 12:
continue
packets += 1
seq = struct.unpack(">H", pkt[2:4])[0]
marker = pkt[1] & 0x80
if last_seq is not None and seq != ((last_seq + 1) & 0xFFFF):
gaps += 1
last_seq = seq
cur += len(pkt) - 12
if marker:
frames += 1
sizes.append(cur)
cur = 0
return frames, packets, gaps, sizes
def close(self):
self.s.close()
def setup_play(c):
c.send("OPTIONS")
st, head, _ = c.read_response()
assert st == 200, head
c.send("DESCRIBE", "Accept: application/sdp\r\n")
st, head, body = c.read_response()
assert st == 200, head
c.send("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")
st, head, _ = c.read_response()
assert st == 200, head
c.send("PLAY")
st, head, _ = c.read_response()
assert st == 200, head
return body
for case in cases:
try:
if case == "play":
c = Client()
sdp = setup_play(c)
frames, packets, gaps, sizes = c.read_frames(10)
c.send("TEARDOWN")
c.read_response()
c.close()
ok = frames >= 50 and gaps == 0
say(
case,
ok,
f"{frames} frames in 10 s ({frames / 10:.1f} fps), {packets} RTP packets, {gaps} seq gaps, frame size min {min(sizes) if sizes else 0} max {max(sizes) if sizes else 0}; sdp has m=video {'m=video' in sdp.decode()}",
)
elif case == "udp":
c = Client()
c.send("OPTIONS")
c.read_response()
c.send("SETUP", "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n")
st, head, _ = c.read_response()
c.close()
say(case, st == 461, f"UDP SETUP answered {st}")
elif case == "pipeline":
c = Client()
c.s.sendall(
f"OPTIONS rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 1\r\n\r\nDESCRIBE rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n".encode()
)
c.cseq = 2
st1, h1, _ = c.read_response()
st2, h2, b2 = c.read_response()
c.close()
cs = [x for x in (h1 + h2).split("\r\n") if x.startswith("CSeq")]
say(
case,
st1 == 200 and st2 == 200 and cs == ["CSeq: 1", "CSeq: 2"],
f"two pipelined requests answered {st1}, {st2} with {cs}",
)
elif case == "badreq":
c = Client()
c.s.sendall(b"BOGUS rtsp://x/ RTSP/1.0\r\nCSeq: 7\r\n\r\n")
st, head, _ = c.read_response()
c.close()
say(
case,
st in (400, 405, 501) and "CSeq: 7" in head,
f"unknown method answered {st}, CSeq echoed {'CSeq: 7' in head}",
)
elif case == "replay":
c = Client()
setup_play(c)
f1, _, _, _ = c.read_frames(3)
c.send("PLAY")
st, head, _ = c.read_response()
f2, _, gaps, _ = c.read_frames(3)
c.send("TEARDOWN")
c.read_response()
c.close()
say(
case,
st == 200 and f2 >= 10 and gaps == 0,
f"second PLAY answered {st}, {f1} then {f2} frames, {gaps} seq gaps after repeat PLAY",
)
elif case == "getparam":
c = Client()
setup_play(c)
c.read_frames(1)
c.send("GET_PARAMETER")
st, head, _ = c.read_response()
f, _, _, _ = c.read_frames(2)
c.send("TEARDOWN")
c.read_response()
c.close()
say(
case,
st == 200 and f > 5,
f"GET_PARAMETER while playing answered {st}, stream continued with {f} frames in 2 s",
)
elif case == "teardown":
c = Client()
setup_play(c)
c.read_frames(2)
c.send("TEARDOWN")
st, head, _ = c.read_response()
c.s.settimeout(3)
time.sleep(1.5)
try:
rest = c.s.recv(65536)
closed = rest == b""
except TimeoutError:
rest = b"x"
closed = False
except ConnectionError:
closed = True
c.close()
say(
case,
st == 200,
f"TEARDOWN answered {st}; connection closed by server afterwards: {closed}",
)
elif case == "third":
a = Client()
setup_play(a)
b = Client()
setup_play(b)
fa, _, ga, _ = a.read_frames(4)
fb, _, gb, _ = b.read_frames(4)
try:
d = Client(timeout=3)
d.send("OPTIONS")
st, _, _ = d.read_response()
third = f"third got {st}"
d.close()
except ConnectionError as e:
# refused, reset or closed by the server; a timeout or a bug propagates and fails the case
third = f"third rejected ({type(e).__name__})"
a.send("TEARDOWN")
a.read_response()
a.close()
b.send("TEARDOWN")
b.read_response()
b.close()
say(
case,
fa > 10 and fb > 10 and ga == 0 and gb == 0 and "rejected" in third,
f"two clients: {fa} and {fb} frames in 4 s each, gaps {ga}/{gb}; {third}",
)
elif case == "stall":
a = Client()
setup_play(a)
b = Client()
setup_play(b)
# stop reading on a for 4 s while b keeps playing
a.s.settimeout(None)
fb1, _, gb1, _ = b.read_frames(4)
a.s.settimeout(1.0)
fa, _, ga, _ = a.read_frames(4)
fb2, _, gb2, _ = b.read_frames(3)
a.send("TEARDOWN")
a.read_response()
a.close()
b.send("TEARDOWN")
b.read_response()
b.close()
say(
case,
fb1 > 10 and fb2 > 10 and fa > 5,
f"b kept {fb1} then {fb2} frames while a stalled 4 s; a recovered {fa} frames after resuming (gaps {ga}); b gaps {gb1}/{gb2}; device log must show stalled/dropping frame",
)
elif case == "timeout":
c = Client(timeout=70)
setup_play(c)
c.s.settimeout(
2
) # a server that stops sending but never closes must not hang the run
t0 = time.monotonic()
dead = False
try:
while time.monotonic() - t0 < 68:
try:
d = c.s.recv(65536)
except TimeoutError:
continue
if not d:
dead = True
break
except ConnectionError:
dead = True
elapsed = time.monotonic() - t0
say(
case,
dead and 50 <= elapsed <= 68,
f"connection closed by server after idle: {dead} at {elapsed:.0f} s (expect the 60 s session timeout when the client sends nothing)",
)
except Exception as e: # noqa: BLE001
say(case, False, f"exception {type(e).__name__}: {e}")
time.sleep(1)
print(
"SUMMARY",
sum(1 for r in results if r[1]),
"pass",
sum(1 for r in results if not r[1]),
"fail",
)
sys.exit(1 if any(not r[1] for r in results) else 0)
@@ -1,6 +1,6 @@
"""Bounded, timestamped serial capture that always exits and closes the port.
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append]
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] [--ms]
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
@@ -76,14 +76,21 @@ def open_port(args: argparse.Namespace, path: str) -> serial.Serial:
return s
def write_line(out, tag: bytes, data: bytes) -> None:
stamp = time.strftime("[%H:%M:%S]").encode()
def write_line(out, tag: bytes, data: bytes, ms: bool = False) -> None:
if ms:
now = time.time()
stamp = (
time.strftime("[%H:%M:%S", time.localtime(now))
+ f".{int(now * 1000) % 1000:03d}]"
).encode()
else:
stamp = time.strftime("[%H:%M:%S]").encode()
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
out.flush()
def note(out, text: str) -> None:
write_line(out, b"[capture] ", text.encode())
def note(out, text: str, ms: bool = False) -> None:
write_line(out, b"[capture] ", text.encode(), ms)
def capture(args: argparse.Namespace, out) -> int:
@@ -102,18 +109,18 @@ def capture(args: argparse.Namespace, out) -> int:
if serial_number is None:
serial_number, index = identify(path)
if path != args.port:
note(out, f"reopened as {path}")
note(out, f"reopened as {path}", args.ms)
except PORT_ERRORS as err:
note(out, f"open failed: {err}")
note(out, f"open failed: {err}", args.ms)
time.sleep(1)
continue
try:
data = s.read(4096)
except PORT_ERRORS as err:
if buf: # keep the cut-off line apart from the next boot's first line
write_line(out, b"[partial]", buf)
write_line(out, b"[partial]", buf, args.ms)
buf = b""
note(out, f"port lost: {err}")
note(out, f"port lost: {err}", args.ms)
with contextlib.suppress(*PORT_ERRORS):
s.close()
s = None
@@ -125,9 +132,9 @@ def capture(args: argparse.Namespace, out) -> int:
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
write_line(out, b"", line.rstrip(b"\r"))
write_line(out, b"", line.rstrip(b"\r"), args.ms)
if buf:
write_line(out, b"[partial]", buf)
write_line(out, b"[partial]", buf, args.ms)
if s is not None:
s.close()
return received
@@ -144,6 +151,7 @@ def main() -> int:
ap.add_argument("--released", action="store_true")
ap.add_argument("--reset", action="store_true")
ap.add_argument("--append", action="store_true")
ap.add_argument("--ms", action="store_true", help="millisecond timestamps")
args = ap.parse_args()
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
received = capture(args, out)
@@ -0,0 +1,12 @@
#!/bin/sh
# cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL : three play, announce, stop cycles on a speaker media player with the
# "Task names" probe button pressed idle, during media, during the announcement, after it and after the stop.
# KEY of - is plaintext. Serve FLAC files with `python3 -m http.server 8765 --directory www` (audio_http refuses WAV and MP3).
H=$1; K=$2; MEDIA=$3; ANN=$4; LOG=$5; LABEL=$6; PY=${PYTHON:-python3}; HERE=$(dirname "$0")
[ -r "$LOG" ] || { echo "usage: cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL (CAPTURE must be a readable file)"; exit 2; }
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a '\[tasks:\|speaker_media_player\|speaker_mixer\|\[W\]\|\[E\]\|Guru\|abort\|Backtrace' | grep -a -v 'took a long time' | cut -c1-180; }
for cycle in 1 2 3; do
n0=$(wc -l < "$LOG"); echo "=== $LABEL cycle $cycle ($(date +%T))"
$PY "$HERE/drive.py" "$H" "$K" "press:Task names" wait:1 "play:$MEDIA" wait:5 "press:Task names" wait:1 "ann:$ANN" wait:1.5 "press:Task names" wait:5 "press:Task names" wait:1 stop wait:3 "press:Task names" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }
sleep 1; echo "--- device log:"; show "$n0"
done
@@ -0,0 +1,50 @@
"""Drive the speaker platform media player (the first media player found) over one API connection.
usage: drive.py HOST KEY op [op ...]
ops: play:URL | ann:URL | stop | press:BUTTON | wait:S
"""
import asyncio
import sys
from aioesphomeapi import APIClient, ButtonInfo, MediaPlayerCommand, MediaPlayerInfo
HOST, KEY, OPS = sys.argv[1], sys.argv[2], sys.argv[3:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
try:
await run_ops(c)
finally:
await c.disconnect()
async def run_ops(c: APIClient) -> None:
ents, _ = await c.list_entities_services()
player = next((e for e in ents if isinstance(e, MediaPlayerInfo)), None)
if player is None:
sys.exit("no media player on this device")
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
for op in OPS:
kind, _, rest = op.partition(":")
print(f"{asyncio.get_running_loop().time():.1f} {op}", flush=True)
if kind == "play":
c.media_player_command(player.key, media_url=rest)
elif kind == "ann":
c.media_player_command(player.key, media_url=rest, announcement=True)
elif kind == "stop":
c.media_player_command(player.key, command=MediaPlayerCommand.STOP)
elif kind == "press":
if rest not in buttons:
sys.exit(f"no button {rest!r}; have {sorted(buttons)}")
c.button_command(buttons[rest].key)
elif kind == "wait":
await asyncio.sleep(float(rest))
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(0.5)
asyncio.run(main())
@@ -0,0 +1,40 @@
"""Compare the listener log (JSON lines from udp_listen.py) against the udp-write-dut.yaml payload sequence.
usage: check.py LISTEN_JSONL ECHO_HEX (ECHO_HEX is the datagram sent to the device for the echo case)
"""
import json
import pathlib
import sys
with pathlib.Path(sys.argv[1]).open(encoding="utf-8") as f:
log = [json.loads(line) for line in f if line.strip()]
echo_in = bytes.fromhex(sys.argv[2])
small = bytes([0xAA, 0x55, 0x01, 0x02])
expected = [
("small 1/2", small),
("small 2/2", small),
("simple", bytes([1, 2, 3])),
("text", b"hello udp"),
("empty", b""),
("stack128", bytes((0x10 + i) & 0xFF for i in range(128))),
("heap129", bytes((0x20 + i) & 0xFF for i in range(129))),
("heap600", bytes((0x30 + i) & 0xFF for i in range(600))),
("lambda300", bytes((i * 7) & 0xFF for i in range(300))),
("echo", echo_in[::-1]),
]
got = [bytes.fromhex(e["hex"]) for e in log]
ok = 0
for i, (name, want) in enumerate(expected):
have = got[i] if i < len(got) else None
res = (
"OK"
if have == want
else f"MISMATCH got {None if have is None else have[:16].hex() + '... len ' + str(len(have))}"
)
ok += res == "OK"
print(f"{name:10s} {len(want):4d} B {res}")
print(
f"{ok}/{len(expected)} payloads byte-exact, {len(got)} datagrams received, sources {sorted({e['src'] for e in log})}"
)
sys.exit(0 if ok == len(expected) and len(got) == len(expected) else 1)
@@ -0,0 +1,32 @@
"""Log every datagram on PORT for SECONDS as one JSON line: time, source, length, hex payload."""
import json
import pathlib
import socket
import sys
import time
port, secs, out = int(sys.argv[1]), float(sys.argv[2]), sys.argv[3]
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", port))
s.settimeout(1.0)
end = time.monotonic() + secs
with pathlib.Path(out).open("w", encoding="utf-8") as f:
while time.monotonic() < end:
try:
data, (ip, sport) = s.recvfrom(65535)
except TimeoutError:
continue
f.write(
json.dumps(
{
"t": time.strftime("%H:%M:%S"),
"src": ip,
"len": len(data),
"hex": data.hex(),
}
)
+ "\n"
)
f.flush()
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# write_cases.sh DEVICE_IP KEY [PORT] : drive a device built from templates/udp-write-dut.yaml. Records every datagram
# the device sends to PORT (the udp component's port, default 18511) with udp_listen.py in the working directory,
# presses the eight payload buttons over the API two seconds apart (KEY as for api_button.py, - for a plaintext API),
# sends one 16 byte unicast datagram to the device for the echo case, then compares the recorded datagrams with
# check.py. The recorder runs until the driver stops it. Exit 1 when the recorder did not start or a payload is
# missing or differs; listen.jsonl stays in the working directory.
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; PORT=${3:-18511}
[ -n "$DEV" ] && [ -n "$KEY" ] || { echo "usage: write_cases.sh DEVICE_IP KEY [PORT]"; exit 2; }
ECHO=0102030405060708090a0b0c0d0e0f10
rm -f "$PWD/listen.jsonl"
$PY "$HERE/udp_listen.py" "$PORT" 600 "$PWD/listen.jsonl" &
lst=$!
sleep 2
kill -0 $lst 2>/dev/null || { echo "udp_listen.py is not running on port $PORT"; exit 1; }
for b in Small Simple Text Empty Stack128 Heap129 Heap600 Lambda300; do
$PY "$HERE/../api_button.py" "$DEV" "$KEY" press "$b" > /dev/null || echo "press $b failed"; sleep 2
done
$PY -c "import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(bytes.fromhex('$ECHO'), ('$DEV', $PORT))"
sleep 5
kill $lst 2>/dev/null; wait $lst 2>/dev/null
$PY "$HERE/check.py" "$PWD/listen.jsonl" "$ECHO"
@@ -0,0 +1,30 @@
"""Play a URL on the speaker_source media player over the API (no sync task involved).
usage: playurl.py HOST KEY URL [PLAYER_NAME]
"""
import asyncio
import sys
from aioesphomeapi import APIClient, MediaPlayerInfo
HOST, KEY, URL = sys.argv[1], sys.argv[2], sys.argv[3]
PLAYER = sys.argv[4] if len(sys.argv) > 4 else "Player"
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
players = {e.name: e for e in ents if isinstance(e, MediaPlayerInfo)}
print("media players:", sorted(players))
if PLAYER not in players:
sys.exit(f"no media player named {PLAYER!r}; have {sorted(players)}")
p = players[PLAYER]
c.media_player_command(p.key, media_url=URL)
print("sent play_media to", PLAYER)
await asyncio.sleep(1)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,13 @@
#pragma once
#include <zephyr/device.h>
#include <zephyr/drivers/uart.h>
#include "esphome/core/log.h"
// Direct look at the Zephyr cdc_acm_uart1 driver state, bypassing the usb_cdc_acm component
inline void cdc_probe() {
const device *d = DEVICE_DT_GET(DT_NODELABEL(cdc_acm_uart1));
uint8_t tmp[32];
int rr = uart_irq_rx_ready(d);
int n = uart_fifo_read(d, tmp, sizeof(tmp));
ESP_LOGI("probe", "driver rx_ready=%d fifo_read=%d first=0x%02X", rr, n, n > 0 ? tmp[0] : 0);
}
@@ -0,0 +1,203 @@
"""Drive the usb_cdc_acm echo firmware on the nRF52840 dongle from the Mac.
usage: drive.py DATA_PORT [case ...]
Cases: short, bulk1k, bulk16k, bulk64k, burst, flush, peek, linecoding, dtr, stream, counters
"""
import random
import sys
import time
import serial
port = sys.argv[1]
cases = sys.argv[2:] or [
"short",
"bulk1k",
"bulk16k",
"bulk64k",
"burst",
"flush",
"peek",
"linecoding",
"dtr",
"stream",
"counters",
]
results = []
def say(case, ok, detail):
results.append((case, ok, detail))
print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True)
def info(case, detail):
"""A case whose result lives in the device log; printed, not counted."""
print(f"INFO {case}: {detail}", flush=True)
def open_port(baud=115200):
s = serial.Serial(port, baudrate=baud, timeout=1)
time.sleep(0.3) # let the 100 ms line-state poll see DTR and the echo loop settle
return s
def read_exact(s, n, timeout):
buf = bytearray()
deadline = time.monotonic() + timeout
while len(buf) < n and time.monotonic() < deadline:
chunk = s.read(min(4096, n - len(buf)))
if chunk:
buf += chunk
return bytes(buf)
def payload(n):
# No 0x01 anywhere: the device treats a chunk starting with 0x01 as a command
rnd = random.Random(n)
return bytes(rnd.choice(range(2, 256)) for _ in range(n))
def cmd(s, c, extra=b""):
s.write(b"\x01" + c + extra)
s.flush()
def echo_case(name, n, chunk):
s = open_port()
s.reset_input_buffer()
data = payload(n)
t0 = time.monotonic()
for i in range(0, n, chunk):
s.write(data[i : i + chunk])
got = read_exact(s, n, timeout=max(5, n / 2000))
dt = time.monotonic() - t0
s.close()
ok = got == data
first_bad = next((i for i in range(min(len(got), n)) if got[i] != data[i]), None)
say(
name,
ok,
f"{len(got)}/{n} bytes echoed in {dt:.2f}s ({n / dt / 1024:.1f} KiB/s), first mismatch {first_bad}",
)
for case in cases:
if case == "short":
s = open_port()
s.reset_input_buffer()
ok = True
lat = []
for i in range(20):
msg = f"hello {i}\n".encode()
t0 = time.monotonic()
s.write(msg)
got = read_exact(s, len(msg), 2)
lat.append(time.monotonic() - t0)
ok = ok and got == msg
s.close()
say(
case,
ok,
f"20 lines, latency min {min(lat) * 1000:.0f} ms max {max(lat) * 1000:.0f} ms",
)
elif case == "bulk1k":
echo_case(case, 1024, 1024)
elif case == "bulk16k":
echo_case(case, 16 * 1024, 4096)
elif case == "bulk64k":
echo_case(case, 64 * 1024, 16384)
elif case == "burst":
s = open_port()
s.reset_input_buffer()
cmd(s, b"B")
got = read_exact(s, 1000, 3)
s.close()
expect = bytes((ord("A") + (i % 26)) for i in range(1000))
prefix_ok = got == expect[: len(got)]
say(
case,
prefix_ok and 0 < len(got) < 1000,
f"{len(got)} of 1000 bytes arrived (256 byte TX ring, rest must be dropped and warned), prefix intact {prefix_ok}",
)
elif case == "flush":
s = open_port()
s.reset_input_buffer()
cmd(s, b"F")
got = read_exact(s, 200, 3)
s.close()
expect = bytes((ord("a") + (i % 26)) for i in range(200))
say(case, got == expect, f"{len(got)}/200 bytes, flush result in device log")
time.sleep(0.5)
elif case == "peek":
s = open_port()
s.reset_input_buffer()
cmd(s, b"K", b"Z")
time.sleep(0.5)
s.close()
info(
case,
"peek/read result is in the device log: expect `peek ok=1 0x5A read ok=1 0x5A`",
)
elif case == "linecoding":
for baud in (9600, 115200, 921600):
s = open_port(baud)
s.reset_input_buffer()
cmd(s, b"L")
time.sleep(0.4)
s.close()
time.sleep(0.3)
info(case, "opened at 9600, 115200, 921600; the device log must show each baud")
elif case == "dtr":
for _i in range(5):
s = open_port()
time.sleep(0.3)
s.close()
time.sleep(0.3)
info(
case, "5 open/close cycles; the device log must show 5 Opened and 5 Closed"
)
elif case == "stream":
# device streams 100 x 100 byte lines over 10 s; close the port for 3 s in the middle
s = open_port()
s.reset_input_buffer()
cmd(s, b"S")
part1 = bytearray()
t0 = time.monotonic()
while time.monotonic() - t0 < 2.5:
part1 += s.read(4096)
s.close()
time.sleep(3.0)
s = open_port()
part2 = bytearray()
t0 = time.monotonic()
while time.monotonic() - t0 < 6.0:
part2 += s.read(4096)
s.close()
lines1 = [x for x in bytes(part1).split(b"\n") if x.startswith(b"STREAM")]
lines2 = [x for x in bytes(part2).split(b"\n") if x.startswith(b"STREAM")]
n1 = [int(x[7:10]) for x in lines1]
n2 = [int(x[7:10]) for x in lines2]
resumed = bool(n2) and max(n2) >= 90
say(
case,
resumed,
f"before close: lines {min(n1) if n1 else None}-{max(n1) if n1 else None} ({len(n1)}), after reopen: {min(n2) if n2 else None}-{max(n2) if n2 else None} ({len(n2)}); streaming resumed after reopen {resumed}",
)
elif case == "counters":
s = open_port()
cmd(s, b"C")
time.sleep(0.3)
s.close()
info(case, "counters are in the device log")
time.sleep(0.5)
print(
"SUMMARY",
sum(1 for r in results if r[1]),
"pass",
sum(1 for r in results if not r[1]),
"fail",
)
sys.exit(1 if any(not r[1] for r in results) else 0)
@@ -0,0 +1,17 @@
#!/bin/sh
# httpd_cases.sh HOST USER PASS LABEL CAPTURE : HTTPD task stack load for the web_server_idf task size; probe lines from
# CAPTURE, SSE client output into the working directory. A failed step ends the run with its status.
H=$1; U=$2; P=$3; LABEL=$4; LOG=$5; HERE=$(dirname "$0")
[ -r "$LOG" ] || { echo "usage: httpd_cases.sh HOST USER PASS LABEL CAPTURE (CAPTURE must be a readable file)"; exit 2; }
probes() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a -o "\[[0-9:]*\]\[I\]\[probe:[0-9]*\]: .*httpd min free stack [0-9]*\|\[[0-9:]*\]\[[WE]\]\[[a-z_.]*:[0-9]*\].*\|Guru.*" | grep -a -v "took a long time\|does not fit\|\]\[httpd\]: /" | sed 's/\[I\]\[probe:[0-9]*\]: loopTask min free stack \([0-9]*\), httpd min free stack \([0-9]*\)/ loop \1 httpd \2/' | uniq -f1 | cut -c1-140; }
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; "$@" || { rc=$?; echo "step failed ($rc)"; exit $rc; }; echo "--- device log:"; probes "$n0"; }
step "idle 15 s" sleep 15
step "request mix, 20 rounds" sh "$HERE/httpmix.sh" "$H" "$U" "$P" 20
# Two SSE clients next to the request mix; arguments stay quoted, nothing is reparsed as shell code
concurrent() {
sh "$HERE/ssecurl.sh" "$H" "$U" "$P" 40 "$PWD/sse1-$LABEL.txt" & p1=$!
sh "$HERE/ssecurl.sh" "$H" "$U" "$P" 40 "$PWD/sse2-$LABEL.txt" & p2=$!
sh "$HERE/httpmix.sh" "$H" "$U" "$P" 12 && wait $p1 && wait $p2
}
step "2 SSE clients 40 s with the request mix alongside" concurrent
step "idle 15 s after" sleep 15
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# httpmix.sh HOST USER PASS ROUNDS : authenticated request mix (curl --anyauth follows the server's Basic or Digest
# challenge): page, two json endpoints, a switch POST, a 404, an unauthenticated 401 and a 2 s SSE stream per round
H=$1; U=$2; P=$3; N=$4; ok=0; bad=0
# chk EXPECTED LABEL curl-args... : only LABEL is printed on a miss, never the argument list with the credentials
chk() { exp=$1; label=$2; shift 2; c=$(curl -s -m 6 -o /dev/null -w "%{http_code}" "$@"); if [ "$c" = "$exp" ]; then ok=$((ok+1)); else bad=$((bad+1)); echo "expected $exp got $c: $label"; fi; }
sse_tmp=$(mktemp)
for i in $(seq 1 $N); do
page=$(curl -s -m 6 -o /dev/null -w "%{http_code}" -u "$U:$P" --anyauth "http://$H/")
if [ "$page" = 200 ]; then ok=$((ok+1)); else bad=$((bad+1)); echo "expected 200 got $page: /"; fi
chk 200 "GET /sensor/Fast%201" -u "$U:$P" --anyauth "http://$H/sensor/Fast%201"
chk 200 "GET /text_sensor/Big" -u "$U:$P" --anyauth "http://$H/text_sensor/Big"
chk 200 "GET /switch/Toggle" -u "$U:$P" --anyauth "http://$H/switch/Toggle"
chk 200 "POST /switch/Toggle/toggle" -u "$U:$P" --anyauth -X POST -H "Content-Length: 0" "http://$H/switch/Toggle/toggle"
# The IDF server closes an unknown path without a response: curl ends with 52 (empty reply) or 56 (reset), never a
# timeout (28) or a refused connection (7); only trust the close when the page just answered
c=$(curl -s -m 6 -o /dev/null -w "%{http_code}" -u "$U:$P" --anyauth "http://$H/nonexistent"); rc=$?
if [ "$c" = 000 ] && [ "$page" = 200 ] && { [ "$rc" = 52 ] || [ "$rc" = 56 ]; }; then ok=$((ok+1)); else bad=$((bad+1)); echo "unknown path: got $c, curl status $rc, with the page at $page"; fi
chk 401 "GET / without credentials" "http://$H/"
timeout 2 curl -s -N -u "$U:$P" --anyauth -o "$sse_tmp" "http://$H/events" 2>/dev/null; rc=$?
if [ "$rc" = 124 ] && grep -q "^event: state" "$sse_tmp"; then ok=$((ok+1)); else bad=$((bad+1)); echo "SSE stream: curl status $rc, $(grep -c '^event: state' "$sse_tmp") state events"; fi
sleep 1
done
rm -f "$sse_tmp"
echo "httpmix: $ok ok, $bad unexpected"
[ "$bad" = 0 ]
@@ -0,0 +1,187 @@
"""SSE client(s) for a web_server /events stream: counts state events and checks each is valid JSON.
usage: sse.py HOST SECONDS [--clients N] [--stall] [--churn CYCLES]
--stall opens one extra connection that never reads (the device's socket buffer fills); --churn opens
and abruptly closes a connection CYCLES times, 2 s apart, next to the reading clients.
"""
import argparse
from collections import Counter
import contextlib
import json
import socket
import sys
import threading
import time
ap = argparse.ArgumentParser()
ap.add_argument("host")
ap.add_argument("seconds", type=float)
ap.add_argument("--clients", type=int, default=1)
ap.add_argument("--stall", action="store_true")
ap.add_argument("--churn", type=int, default=0)
args = ap.parse_args()
REQ = b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\nConnection: keep-alive\r\n\r\n"
def read_chunked(sock, deadline, on_line):
"""Decode HTTP chunked transfer encoding and feed complete event-stream lines to on_line."""
buf = b""
body = b""
headers_done = False
sock.settimeout(1.0)
while time.monotonic() < deadline:
try:
data = sock.recv(65536)
except TimeoutError:
continue
if not data:
return "closed"
buf += data
if not headers_done:
i = buf.find(b"\r\n\r\n")
if i < 0:
continue
headers_done = True
buf = buf[i + 4 :]
while True:
i = buf.find(b"\r\n")
if i < 0:
break
size = int(buf[:i].split(b";")[0], 16)
if len(buf) < i + 2 + size + 2:
break
body += buf[i + 2 : i + 2 + size]
buf = buf[i + 2 + size + 2 :]
if size == 0:
return "closed"
while b"\n" in body:
line, body = body.split(b"\n", 1)
on_line(line.rstrip(b"\r"))
return "deadline"
class Client(threading.Thread):
def __init__(self, idx, seconds):
super().__init__(daemon=True)
self.idx = idx
self.seconds = seconds
self.events = Counter()
self.ids = Counter()
self.invalid = 0
self.max_data = 0
self.bytes = 0
self.end = "?"
self.cur_event = None
def on_line(self, line):
self.bytes += len(line) + 1
if line.startswith(b"event:"):
self.cur_event = line[6:].strip().decode()
elif line.startswith(b"data:"):
ev = self.cur_event or "message"
self.events[ev] += 1
data = line[5:].strip()
self.max_data = max(self.max_data, len(data))
if ev in ("state", "state_detail_all"):
try:
obj = json.loads(data)
self.ids[obj.get("id", "?")] += 1
except ValueError:
self.invalid += 1
elif line == b"":
self.cur_event = None
def run(self):
try:
s = socket.create_connection((args.host, 80), timeout=5)
s.sendall(REQ % args.host.encode())
self.end = read_chunked(s, time.monotonic() + self.seconds, self.on_line)
s.close()
except (OSError, ValueError) as e:
self.end = f"error: {e}"
clients = [Client(i, args.seconds) for i in range(args.clients)]
for c in clients:
c.start()
time.sleep(0.3)
stall_sock = None
if args.stall:
# The window scale is negotiated in the SYN, so the small buffer must be set before connect()
stall_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
stall_sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2048)
stall_sock.settimeout(5)
stall_sock.connect((args.host, 80))
stall_sock.sendall(REQ % args.host.encode())
print("stalled client opened (never reads)", flush=True)
churn_done = 0
for _ in range(args.churn):
try:
cs = socket.create_connection((args.host, 80), timeout=5)
cs.sendall(REQ % args.host.encode())
cs.settimeout(1.0)
with contextlib.suppress(TimeoutError):
cs.recv(4096)
cs.setsockopt(
socket.SOL_SOCKET, socket.SO_LINGER, b"\x01\x00\x00\x00\x00\x00\x00\x00"
) # RST on close
cs.close()
churn_done += 1
except OSError as e:
print("churn error", e, flush=True)
time.sleep(2)
if args.churn:
print(f"churn: {churn_done}/{args.churn} abrupt closes", flush=True)
for c in clients:
c.join()
stall_closed = None
if stall_sock is not None:
# Drain what the small buffer holds: a close shows as b"" or a reset after it. A session the device left
# open keeps feeding events, so the drain has its own deadline and data past it means "still open".
stall_sock.settimeout(2.0)
drain_deadline = time.monotonic() + 5.0
try:
while stall_sock.recv(65536):
if time.monotonic() > drain_deadline:
break
else:
stall_closed = True
except TimeoutError:
pass
except OSError:
stall_closed = True
if stall_closed is None:
stall_closed = False
print(
"stalled client: closed by device"
if stall_closed
else "stalled client: still open",
flush=True,
)
stall_sock.close()
for c in clients:
states = c.events["state"]
print(
f"client {c.idx}: {c.end}, {states} state events, {c.invalid} invalid JSON, max data {c.max_data} B, "
f"{c.bytes} B, other events { {k: v for k, v in c.events.items() if k != 'state'} }",
flush=True,
)
print(" per id:", dict(sorted(c.ids.items())), flush=True)
failed = [
c for c in clients if c.end != "deadline" or c.invalid or not c.events["state"]
]
if failed:
sys.exit(
f"{len(failed)} client(s) did not run clean to the deadline with valid state events"
)
if stall_closed is False:
sys.exit("stalled session was not closed by the device")
if churn_done < args.churn:
sys.exit(f"only {churn_done} of {args.churn} churn connections were made")
@@ -0,0 +1,19 @@
#!/bin/sh
# sse_cases.sh HOST KEY LABEL CAPTURE : the SSE cases against templates/web-server-sse-dut.yaml with a console capture
# running into CAPTURE; KEY is the API key (- for plaintext). The burst client output goes to the working directory.
H=$1; K=$2; LABEL=$3; LOG=$4; PY=${PYTHON:-python3}; HERE=$(dirname "$0"); BTN=$HERE/../api_button.py
[ -r "$LOG" ] || { echo "usage: sse_cases.sh HOST KEY LABEL CAPTURE (CAPTURE must be a readable file)"; exit 2; }
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a '\[probe:\|\[W\]\|\[E\]\|web_server\|Guru\|abort\|Backtrace\|ESPHome version' | grep -a -v 'took a long time' | cut -c1-200; }
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; "$@" || { rc=$?; echo "client failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; }
step "idle 15 s, no client" sleep 15
step "1 client 60 s" $PY "$HERE/sse.py" "$H" 60 --clients 1
step "4 clients 60 s" $PY "$HERE/sse.py" "$H" 60 --clients 4
step "1 reader plus a stalled client 45 s" $PY "$HERE/sse.py" "$H" 45 --clients 1 --stall
step "1 reader plus 10 abrupt closes 30 s" $PY "$HERE/sse.py" "$H" 30 --clients 1 --churn 10
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 2 clients 25 s with the Burst button at 5 s ($(date +%T))"
$PY "$HERE/sse.py" "$H" 25 --clients 2 > "$PWD/burst-$LABEL.txt" 2>&1 &
pid=$!
sleep 5; $PY "$BTN" "$H" "$K" press "Burst" || { echo "button press failed"; exit 1; }
wait $pid || { cat "$PWD/burst-$LABEL.txt"; echo "SSE clients failed during the burst"; exit 1; }
cat "$PWD/burst-$LABEL.txt"; echo "--- device log:"; show "$n0"
step "idle 15 s after" sleep 15
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# ssecurl.sh HOST USER PASS SECONDS OUT : one authenticated SSE client through curl; counts state events and checks JSON
H=$1; U=$2; P=$3; T=$4; OUT=$5
timeout "$T" curl -sS -f -N -u "$U:$P" --anyauth "http://$H/events" > "$OUT"; rc=$?
[ "$rc" = 124 ] || { echo "sse: curl ended with status $rc before the $T s deadline"; exit 1; }
python3 - "$OUT" <<'PY'
import json, sys
states = invalid = 0; biggest = 0; ev = None
data = open(sys.argv[1], "rb").read()
lines = data.split(b"\n")
if not data.endswith(b"\n"):
lines = lines[:-1] # the deadline can cut the last line in half; only complete lines are checked
for line in lines:
line = line.rstrip(b"\r")
if line.startswith(b"event:"):
ev = line[6:].strip()
elif line.startswith(b"data:") and ev == b"state":
states += 1; d = line[5:].strip(); biggest = max(biggest, len(d))
try: json.loads(d)
except ValueError: invalid += 1
elif line == b"":
ev = None
print(f"sse: {states} state events, {invalid} invalid JSON, largest {biggest} B")
sys.exit(1 if invalid or not states else 0)
PY
@@ -0,0 +1,39 @@
#!/bin/zsh
# usage: decode.sh CAPTURE ELF OUTPREFIX
# Parses the #CD: lines of a serial capture with Zephyr's coredump tools from the sdk-nrf that ESPHome
# installed (SDK_NRF, default: the sdk-nrf directory under the ESPHome cache), serves the dump to GDB on a
# random port and prints the backtrace and the thread list. The ELF is .pioenvs/<name>/zephyr/zephyr/zephyr.elf.
# Every step is checked: a missing tool, a capture without a complete dump, an empty .bin or a gdbserver
# that died all stop the script with exit 1, and the exit status is GDB's otherwise.
CAP=$1; ELF=$2; OUT=$3
SDK_NRF=${SDK_NRF:-$(ls -d ~/Library/Caches/esphome/sdk-nrf ~/.cache/esphome/sdk-nrf 2>/dev/null | head -1)}
[ -n "$SDK_NRF" ] || { echo "sdk-nrf not found, set SDK_NRF"; exit 1; }
F=$(ls -d $SDK_NRF/frameworks/v*/zephyr/scripts/coredump 2>/dev/null | tail -1)
PY=${PYTHON:-$(ls $SDK_NRF/penvs/*/bin/python 2>/dev/null | head -1)}
# SDK 0.x keeps the tools under arm-zephyr-eabi/, SDK 1.0+ under gnu/arm-zephyr-eabi/
GDB=$(ls $SDK_NRF/toolchains/*/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb $SDK_NRF/toolchains/*/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb 2>/dev/null | tail -1)
for v in F PY GDB; do
[ -n "${(P)v}" ] || { echo "$v not found under $SDK_NRF"; exit 1; }
done
[ -f "$ELF" ] || { echo "ELF not found: $ELF"; exit 1; }
# strip capture timestamps and ANSI so the parser sees the raw log lines
sed -E 's/^\[[0-9:.]+\]//; s/\x1b\[[0-9;]*m//g' "$CAP" > $OUT-all.log
# keep only the first complete #CD:BEGIN# .. #CD:END# block
awk '/#CD:BEGIN#/{if(!done){p=1;buf=""}} p{buf=buf $0 "\n"} /#CD:END#/{if(p){printf "%s", buf; done=1; p=0}}' $OUT-all.log > $OUT-raw.log
echo "CD lines in capture: $(grep -c '#CD:' $OUT-all.log) (BEGIN $(grep -c '#CD:BEGIN#' $OUT-all.log), END $(grep -c '#CD:END#' $OUT-all.log)); first complete block: $(grep -c '#CD:' $OUT-raw.log) lines"
[ -s $OUT-raw.log ] || { echo "no complete #CD:BEGIN# .. #CD:END# block in the capture"; exit 1; }
if ! $PY $F/coredump_serial_log_parser.py $OUT-raw.log $OUT.bin > $OUT-parser.log 2>&1; then
echo "parser failed:"; tail -5 $OUT-parser.log; exit 1
fi
tail -3 $OUT-parser.log
[ -s $OUT.bin ] || { echo "parser wrote an empty $OUT.bin"; exit 1; }
ls -la $OUT.bin | awk '{print "bin bytes", $5}'
PORT=$((20000 + RANDOM % 10000))
$PY $F/coredump_gdbserver.py --port $PORT "$ELF" $OUT.bin > $OUT-gdbserver.log 2>&1 &
G=$!
sleep 3
if ! kill -0 $G 2>/dev/null; then echo "gdbserver exited:"; tail -5 $OUT-gdbserver.log; exit 1; fi
$GDB -q -batch -ex "target remote :$PORT" -ex "bt" -ex "info threads" "$ELF" 2>&1 | grep -v "^warning\|Reading symbols\|^$" | head -40
rc=$pipestatus[1]
kill $G 2>/dev/null
exit $rc
@@ -0,0 +1,29 @@
#!/bin/zsh
# usage: flow.sh LABEL PORT ELF [OUTDIR]
# Run at least 60 s after the board rebooted from its crash, with the port closed. Proves the stored
# coredump waits for a reader and survives a reader that goes away:
# 1) open the port for 3 s and close it (a reader that leaves mid-print)
# 2) open it for 100 s: the whole dump must print again, then be erased
# 3) open it again for 20 s: dump_config must report no stored dump
# then decode.sh parses the first complete dump and prints the GDB backtrace.
# Needs a python with pyserial (PYTHON, default python3).
D=${0:A:h}
L=$1; P=$2; ELF=$3; S=${4:-.}
PY=${PYTHON:-python3}
CAP=$D/../serial_capture.py
capture() { # SECONDS OUTFILE: stop the flow when the capture fails (busy or missing port)
if ! $PY $CAP $P $1 $2 > $2.err 2>&1; then
echo "capture of $2 failed:"; cat $2.err; exit 1
fi
}
capture 3 $S/$L-brief.txt
echo "$L brief 3 s open: $(wc -l < $S/$L-brief.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-brief.txt), END $(grep -a -c '#CD:END#' $S/$L-brief.txt)"
sleep 5
capture 100 $S/$L-full.txt
echo "$L full 100 s open: $(wc -l < $S/$L-full.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-full.txt), BEGIN $(grep -a -c '#CD:BEGIN#' $S/$L-full.txt), END $(grep -a -c '#CD:END#' $S/$L-full.txt)"
grep -a "crashtest\|zephyr_coredump\|Reset reason\|Has stored\| Size:\| Error:\|took a long\|compiled on" $S/$L-full.txt | grep -a -v "#CD:[0-9A-F]" | head -16
sleep 3
capture 20 $S/$L-after.txt
echo "$L reopen 20 s: CD lines $(grep -a -c '#CD:' $S/$L-after.txt); $(grep -a 'Has stored\| Size:' $S/$L-after.txt | sed -E 's/^\[[^]]*\]//' | tr '\n' ' ')"
echo "== decode"
$D/decode.sh $S/$L-full.txt $ELF $S/$L
@@ -0,0 +1,24 @@
import asyncio
import sys
from aioesphomeapi import APIClient
async def main():
c = APIClient(sys.argv[1], 6053, None)
await c.connect(login=True)
di = await c.device_info()
print(
"version",
di.esphome_version,
"compiled",
di.compilation_time,
"home_id",
hex(di.zwave_home_id),
"flags",
di.zwave_proxy_feature_flags,
)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,45 @@
"""Recompute the Z-Wave checksum (XOR of LENGTH..payload, initial 0xFF) over every frame the proxy logged."""
from functools import reduce
import pathlib
import re
import sys
def zw_checksum(frame: bytes) -> int:
return reduce(lambda a, b: a ^ b, frame[1:-1], 0xFF)
def complete(frame: bytes) -> bool:
"""A data frame is SOF, LENGTH, then LENGTH bytes; the proxy logs at most 168 bytes, so longer ones are cut."""
return len(frame) >= 3 and frame[0] == 0x01 and len(frame) == frame[1] + 2
for path in sys.argv[1:]:
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
sent = re.findall(r"Sending: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text)
recv = re.findall(r"Received frame: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text)
calc = re.findall(
r"CHECKSUM Received: 0x([0-9A-F]{2}) - Calculated: 0x([0-9A-F]{2})", text
)
bad = len(re.findall(r"Bad checksum", text))
acks_sent = len(re.findall(r"Sending ACK", text))
naks_sent = len(re.findall(r"Sending NAK/CAN", text))
acks_recv = len(re.findall(r"Received ACK", text))
naks_recv = len(re.findall(r"Received NAK", text))
reboots = len(re.findall(r"Boot count|Reset reason|rst:0x", text))
long_ops = len(re.findall(r"zwave_proxy took a long time", text))
rx_frames = [bytes.fromhex(f.replace(":", "")) for f in recv]
tx_frames = [bytes.fromhex(f.replace(":", "")) for f in sent]
rx_cut = sum(1 for b in rx_frames if not complete(b))
tx_cut = sum(1 for b in tx_frames if len(b) > 1 and not complete(b))
rx_ok = sum(1 for b in rx_frames if complete(b) and zw_checksum(b) == b[-1])
tx_ok = sum(1 for b in tx_frames if complete(b) and zw_checksum(b) == b[-1])
calc_ok = sum(1 for r, c in calc if r == c)
cmds = sorted({f.split(":")[3] for f in recv})
print(
f"{path.rsplit('/', 1)[-1]}: tx frames {len(sent)} (checksum ok {tx_ok}, truncated in log {tx_cut}), "
f"rx frames {len(recv)} (recomputed ok {rx_ok}, truncated in log {rx_cut}), "
f"device calc==received {calc_ok}/{len(calc)}, bad checksum warnings {bad}, ACK sent {acks_sent}, NAK sent {naks_sent}, "
f"ACK received {acks_recv}, NAK received {naks_recv}, long ops {long_ops}, rx cmd ids {cmds}"
)
@@ -0,0 +1,66 @@
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file (see
# reference/recipes/usb_audio.md). The AUX-2 package with the package's speaker_source player swapped for the speaker
# platform player, which is the one built on speaker/media_player/audio_pipeline.cpp, plus the task name probe.
packages:
core: !include aux-2-pre-prod.yaml
esphome:
name: aux-2-dut
name_add_mac_suffix: false
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
logger:
level: DEBUG
sendspin:
unpaired_access: true
media_player:
- id: !remove external_media_player
- platform: speaker
id: speaker_media_player
name: Media Player
volume_increment: 0.05
volume_min: 0.4
volume_max: 0.85
announcement_pipeline:
speaker: announcement_resampling_speaker
format: FLAC
num_channels: 1
sample_rate: 48000
media_pipeline:
speaker: media_resampling_speaker
format: FLAC
num_channels: 2
sample_rate: 48000
on_announcement:
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 20
duration: 0.0s
button:
- platform: template
name: Task names
on_press:
- lambda: |-
static const char *const NAMES[] = {"ann_read", "ann_decode", "med_read", "med_decode"};
for (const char *n : NAMES) {
TaskHandle_t h = xTaskGetHandle(n);
if (h == nullptr) {
ESP_LOGI("tasks", "%s: missing", n);
} else {
ESP_LOGI("tasks", "%s: found as '%s', stack high water %u", n, pcTaskGetName(h),
(unsigned) uxTaskGetStackHighWaterMark(h));
}
}
ESP_LOGI("tasks", "internal free %u, largest block %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
@@ -0,0 +1,23 @@
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local
# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md).
# Nabu Casa Connect AUX-2 on the usb_audio PR tree: stock package (I2S PCM5122 output) as a control.
packages:
core: !include aux-2-pre-prod.yaml
esphome:
name: aux-2-dut
name_add_mac_suffix: false
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
logger:
level: DEBUG
sendspin:
unpaired_access: true
@@ -0,0 +1,39 @@
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local
# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md).
# Nabu Casa Connect AUX-2 on the usb_audio PR tree: the Anker S330 on the USB host port as the mixer output.
packages:
core: !include aux-2-pre-prod.yaml
esphome:
name: aux-2-usb-dut
name_add_mac_suffix: false
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
logger:
level: DEBUG
baud_rate: 0
sendspin:
unpaired_access: true
usb_audio:
id: usb_audio_id
speaker:
- platform: usb_audio
id: usb_speaker
usb_audio_id: usb_audio_id
sample_rate: 48000
bits_per_sample: 24bit
num_channels: 2
buffer_size: 28800
write_timeout: 50ms
- id: !extend mixing_speaker
output_speaker: usb_speaker
@@ -0,0 +1,44 @@
substitutions:
run: "1"
esphome:
name: bk72xx-crash-probe
includes: [../scripts/libretiny_crash/bkprobe.hpp]
# step 0 waits 30 s so a capture can be opened (and a reset pulsed) after the flash; later steps 5 s
on_boot:
priority: -100
then:
- lambda: id(step) = bkprobe_boot(${run});
- delay: !lambda 'return id(step) == 0 ? 30000 : 5000;'
- lambda: bkprobe_act(${run}, id(step));
bk72xx:
board: generic-bk7238-tuya
logger:
hardware_uart: UART1
level: DEBUG
preferences:
flash_write_interval: 1s
globals:
- id: step
type: int
initial_value: "0"
interval:
- interval: 10s
then:
- lambda: bkprobe_alive(id(step));
# Wi-Fi matters: a crashed BK72xx with Wi-Fi up never reboots on its own without the crash handler fix
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
@@ -0,0 +1,38 @@
substitutions:
run: "1"
esphome:
name: lt21007e
includes: [../scripts/libretiny_prefs/ltprefs.hpp]
platformio_options:
build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]
on_boot:
priority: -100
then:
- lambda: id(act) = ltprefs_boot(id(step), ${run});
# the first boot waits so the host can open the port and pulse a reset before the sequence starts
- delay: !lambda 'return (id(step) == 0 || id(step) == 8) ? 30000 : 3000;'
- lambda: ltprefs_exit(id(step), ${run}, id(act));
bk72xx:
board: generic-bk7238-tuya
logger:
hardware_uart: UART1
level: DEBUG
preferences:
flash_write_interval: 1s
globals:
- id: step
type: int
initial_value: "0"
- id: act
type: int
initial_value: "0"
interval:
- interval: 10s
then:
- lambda: ltprefs_alive(id(step), id(act));
@@ -0,0 +1,27 @@
# CAST PRO-1 control for the sendspin sync comparison: Apollo's current package, DEBUG logging as on the S3.
esphome:
name: "cast-pro-1-dut"
friendly_name: CAST PRO-1 test
project:
name: "ApolloAutomation.CAST_PRO-1"
version: ${version}
packages:
core: !include Core.yaml
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
enable_on_boot: false
logger:
level: DEBUG
api:
encryption:
key: !secret api_key
sendspin:
manufacturer: Apollo
model: CAST PRO-1 control
unpaired_access: true
@@ -0,0 +1,46 @@
# Deep sleep wake timing for the outgoing API connection: boot -> dial -> client hello -> first HA state.
esphome:
name: deep-sleep-dut
on_boot:
priority: -100
then:
- lambda: ESP_LOGI("timing", "setup done at %u ms", (unsigned) millis());
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
on_connect:
- lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis());
api:
encryption:
key: !secret api_key
outgoing_connection:
on_client_connected:
- lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str());
on_client_disconnected:
- lambda: ESP_LOGI("timing", "client disconnected at %u ms", (unsigned) millis());
ota:
- platform: esphome
text_sensor:
- platform: homeassistant
id: ha_state
entity_id: light.ENTITY
on_value:
- lambda: ESP_LOGI("timing", "first HA state '%s' at %u ms", x.c_str(), (unsigned) millis());
deep_sleep:
id: ds
run_duration: 15s
sleep_duration: 15s
@@ -0,0 +1,186 @@
# DFRobot SEN0395 on an ESP32 DevKit V1: V to VIN (5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17 (TX2),
# IO2 to GPIO18, I1 unconnected. Settings buttons, the four switches and junk-line buttons whose echo and
# "is not recognized" reply come back as lines of known length (220 and 240 bytes give replies over 252 chars).
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
api: DEBUG
api.connection: DEBUG
api.service: DEBUG
wifi: DEBUG
mdns: DEBUG
scheduler: DEBUG
component: DEBUG
esp32: DEBUG
uart: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
uart:
id: mmwave_uart
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 115200
dfrobot_sen0395:
id: mmwave
uart_id: mmwave_uart
binary_sensor:
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
name: Detected via UART
id: uart_detected
- platform: gpio
name: Detected via GPIO
device_class: motion
pin:
number: GPIO18
mode: INPUT_PULLDOWN
switch:
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: sensor_active
name: Sensor active
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: presence_via_uart
name: Presence via UART
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: turn_on_led
name: LED
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: start_after_boot
name: Start after boot
button:
- platform: template
name: Range 0 to 3m
on_press:
- dfrobot_sen0395.settings:
id: mmwave
detection_segments:
- [0cm, 3m]
- platform: template
name: Range 0 to 9m
on_press:
- dfrobot_sen0395.settings:
id: mmwave
detection_segments:
- [0cm, 9m]
- platform: template
name: Latency 1s 2s
on_press:
- dfrobot_sen0395.settings:
id: mmwave
output_latency:
delay_after_detect: 1s
delay_after_disappear: 2s
- platform: template
name: Latency 0 0
on_press:
- dfrobot_sen0395.settings:
id: mmwave
output_latency:
delay_after_detect: 0s
delay_after_disappear: 0s
- platform: template
name: Sensitivity 7
on_press:
- dfrobot_sen0395.settings:
id: mmwave
sensitivity: 7
- platform: template
name: Factory reset
on_press:
- dfrobot_sen0395.settings:
id: mmwave
factory_reset: true
- platform: template
name: Reset
on_press:
- dfrobot_sen0395.reset:
id: mmwave
- platform: template
name: Junk 100
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(100, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 160
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(160, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 200
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(200, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 220
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(220, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 240
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(240, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 256
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(256, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
interval:
- interval: 30s
then:
- lambda: 'ESP_LOGI("sen", "alive uart_detected=%d heap=%u", id(uart_detected).state, (unsigned) esp_get_free_heap_size());'
@@ -0,0 +1,34 @@
esphome:
name: i19430-dut3
on_boot:
priority: 800
then:
- lambda: |-
auto marker = global_preferences->make_preference<uint32_t>(0x19430001, true);
uint32_t v = 0;
if (!marker.load(&v)) {
ESP_LOGW("test", "first boot: wiping preferences");
global_preferences->reset();
static_cast<esphome::libretiny::LibreTinyPreferences *>(global_preferences)->open();
v = 1;
marker.save(&v);
global_preferences->sync();
}
bk72xx:
board: generic-bk7238
logger:
hardware_uart: UART1
level: VERBOSE
wifi:
ap:
ssid: i19430-ap
captive_portal:
interval:
- interval: 5s
then:
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
@@ -0,0 +1,148 @@
# Entity icon and device class readback (esphome#21111): the per-run file supplies name and broker_ip through substitutions and includes this file as a package. Every readback path is covered: API listing, MQTT discovery, web_server JSON and dump_config. See reference/recipes/web_server.md.
esp8266:
board: d1_mini
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
mqtt:
broker: ${broker_ip}
reboot_timeout: 0s
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("probe", "free heap %u, max block %u", (unsigned) ESP.getFreeHeap(), (unsigned) ESP.getMaxFreeBlockSize());
esphome:
name: ${name}
sensor:
- platform: template
name: Temp
id: temp
device_class: temperature
unit_of_measurement: "°C"
icon: mdi:thermometer
accuracy_decimals: 1
lambda: return 21.5;
update_interval: 30s
- platform: template
name: Long Icon
id: long_icon
icon: mdi:this-icon-name-is-exactly-sixty-three-characters-long-abcde
device_class: humidity
unit_of_measurement: "%"
lambda: return 40.0;
update_interval: 30s
- platform: template
name: Plain
id: plain
lambda: return 1.0;
update_interval: 30s
- platform: template
name: Hidden
id: hidden
internal: true
icon: mdi:eye-off
device_class: power
lambda: return 2.0;
update_interval: 30s
binary_sensor:
- platform: template
name: Motion
id: motion
device_class: motion
icon: mdi:motion-sensor
lambda: return false;
switch:
- platform: template
name: Relay
id: relay
device_class: outlet
icon: mdi:power-plug
optimistic: true
button:
- platform: template
name: Reboot
id: reboot_btn
device_class: restart
icon: mdi:restart
on_press:
- logger.log: "Reboot pressed"
text_sensor:
- platform: template
name: Version
id: version
device_class: timestamp
icon: mdi:tag
lambda: return {"2026-10-21T00:00:00+00:00"};
update_interval: 30s
number:
- platform: template
name: Level
id: level
device_class: battery
icon: mdi:knob
unit_of_measurement: "%"
min_value: 0
max_value: 100
step: 1
optimistic: true
initial_value: 50
select:
- platform: template
name: Mode
id: mode
icon: mdi:format-list-bulleted
options: [a, b, c]
optimistic: true
initial_option: a
event:
- platform: template
name: Doorbell
id: doorbell
device_class: doorbell
icon: mdi:doorbell
event_types: [ring, knock]
cover:
- platform: template
name: Garage
id: garage
device_class: garage
icon: mdi:garage
optimistic: true
text:
- platform: template
name: Secret Note
id: note
mode: password
icon: mdi:form-textbox-password
optimistic: true
initial_value: "x"
@@ -0,0 +1,66 @@
# Restoring globals with every storage choice; each boot bumps the counters, logs them with the reset reason, saves
# and syncs; odd boots soft restart, even boots deep sleep (ESP32) or idle (D1 mini). The host's reset pulse and a
# power cut give the other reset kinds.
logger:
level: DEBUG
preferences:
flash_write_interval: 1s
globals:
- id: cnt_rtc
type: int
restore_value: true
storage: rtc
initial_value: "0"
- id: cnt_flash
type: int
restore_value: true
storage: flash
initial_value: "0"
- id: cnt_default
type: int
restore_value: true
initial_value: "0"
- id: str_rtc
type: std::string
restore_value: true
storage: rtc
max_restore_data_length: 24
initial_value: '"fresh"'
- id: arr_rtc
type: uint8_t[2]
restore_value: true
storage: rtc
initial_value: "{0, 0}"
esphome:
on_boot:
priority: -100
then:
- lambda: |-
id(cnt_rtc) += 1;
id(cnt_flash) += 1;
id(cnt_default) += 1;
id(arr_rtc)[0] += 1;
id(arr_rtc)[1] += 2;
char buf[24];
snprintf(buf, sizeof(buf), "boot%d", id(cnt_rtc));
ESP_LOGI("gl", "restored rtc=%d flash=%d default=%d str='%s' arr=%u/%u (values before this boot's bump: one less)",
id(cnt_rtc) - 1, id(cnt_flash) - 1, id(cnt_default) - 1, id(str_rtc).c_str(), id(arr_rtc)[0] - 1, id(arr_rtc)[1] - 2);
id(str_rtc) = buf;
- delay: 2s
- lambda: |-
global_preferences->sync();
ESP_LOGI("gl", "saved rtc=%d flash=%d default=%d str='%s' arr=%u/%u", id(cnt_rtc), id(cnt_flash), id(cnt_default),
id(str_rtc).c_str(), id(arr_rtc)[0], id(arr_rtc)[1]);
- if:
condition:
lambda: 'return id(cnt_rtc) % 2 == 1;'
then:
- delay: 3s
- lambda: 'ESP_LOGI("gl", "odd boot, soft restart now"); App.safe_reboot();'
button:
- platform: restart
name: Restart
@@ -0,0 +1,159 @@
# shared part: two Home Assistant lights mirrored on the device plus buttons that drive them
esphome:
# A local change made before the light core's first write (b19a776 sends it instead of merging it).
on_boot:
priority: -100
then:
- lambda: 'ESP_LOGI("halight", "on_boot turning B on at 35 percent before the first write");'
- light.turn_on:
id: bulb_b
brightness: 35%
logger:
level: VERBOSE
logs:
api: DEBUG
api.connection: DEBUG
api.service: DEBUG
wifi: DEBUG
scheduler: DEBUG
component: DEBUG
sensor: DEBUG
mdns: DEBUG
ota: DEBUG
socket: DEBUG
esp-idf: DEBUG
preferences: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
light:
- platform: homeassistant
id: bulb_a
name: Mirror A
entity_id: light.ENTITY_A
report_timeout: 5s
on_turn_on:
- logger.log: "bulb_a on_turn_on"
on_turn_off:
- logger.log: "bulb_a on_turn_off"
- platform: homeassistant
id: bulb_b
name: Mirror B
entity_id: light.ENTITY_B
report_timeout: 5s
button:
- platform: template
name: A warm 40
id: a_warm_40
on_press:
- light.turn_on:
id: bulb_a
brightness: 40%
color_temperature: 370 mireds
- platform: template
name: A cool 100
on_press:
- light.turn_on:
id: bulb_a
brightness: 100%
color_temperature: 153 mireds
- platform: template
name: A red
on_press:
- light.turn_on:
id: bulb_a
brightness: 80%
red: 100%
green: 0%
blue: 0%
- platform: template
name: A green dim
on_press:
- light.turn_on:
id: bulb_a
brightness: 20%
red: 0%
green: 100%
blue: 0%
- platform: template
name: A brightness only 60
on_press:
- light.turn_on:
id: bulb_a
brightness: 60%
- platform: template
name: A transition 100
on_press:
- light.turn_on:
id: bulb_a
brightness: 100%
transition_length: 3s
- platform: template
name: A flash
on_press:
- light.turn_on:
id: bulb_a
flash_length: 1s
- platform: template
name: A off
on_press:
- light.turn_off: bulb_a
- platform: template
name: A toggle
on_press:
- light.toggle: bulb_a
- platform: template
name: B warm 50
on_press:
- light.turn_on:
id: bulb_b
brightness: 50%
color_temperature: 300 mireds
- platform: template
name: B off
on_press:
- light.turn_off: bulb_b
- platform: template
name: A 50 fade 3s
on_press:
- light.turn_on:
id: bulb_a
brightness: 50%
transition_length: 3s
- platform: template
name: A many calls
on_press:
- lambda: |-
for (int i = 0; i < 16; i++) {
id(bulb_a).make_call().set_brightness(i == 15 ? 1.0f : 0.6f).set_transition_length(1000).perform();
}
- platform: template
name: Log state
on_press:
- lambda: |-
auto v = id(bulb_a).current_values;
ESP_LOGI("halight", "A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f",
v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(),
v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(),
id(bulb_b).current_values.get_brightness());
interval:
- interval: 30s
then:
- lambda: |-
auto v = id(bulb_a).current_values;
ESP_LOGI("halight", "alive A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f",
v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(),
v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(),
id(bulb_b).current_values.get_brightness());
@@ -0,0 +1,99 @@
# MQTT TLS trust-source test (#14318, needs that PR's tree for tls and verify_ssl): one build per trust source, with
# the name and the mqtt block edited per file (see reference/recipes/mqtt.md). Secrets: broker_ip (the local broker
# from make_test_ca.sh), mqtt_ca, mqtt_client_cert and mqtt_client_key as block scalars from the same run.
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
wifi: DEBUG
api: DEBUG
api.connection: DEBUG
scheduler: DEBUG
component: DEBUG
sensor: DEBUG
esp32: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("probe", "mqtt connected %d, internal free %u, largest block %u", id(mq).is_connected(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
sensor:
- platform: template
name: Counter
update_interval: 2s
accuracy_decimals: 0
lambda: |-
static uint32_t n = 0;
return n++;
switch:
- platform: template
name: Relay
id: relay
optimistic: true
on_turn_on:
- logger.log: "Relay ON"
on_turn_off:
- logger.log: "Relay OFF"
button:
- platform: template
name: Publish big
on_press:
- mqtt.publish:
topic: ${name}/big
payload: !lambda |-
return std::string(3000, 'q');
substitutions:
name: mqtt-dut
esphome:
name: ${name}
mqtt:
id: mq
broker: !secret broker_ip # test.mosquitto.org for the bundle run
# No port: it must resolve to 1883 without TLS options and to 8883 with them. Spell it out only for the
# client-certificate listener (8884), wss (9002) and the public bundle broker (8886).
# The next two lines are the own-CA build. Remove both for the broker-only build (skip_cert_cn_check alone turns
# TLS on), replace both with verify_ssl: false for the unverified build (the flag is rejected next to it), or with
# tls: true for the bundle builds (public broker, and the local one as the negative).
skip_cert_cn_check: true # with the local CA: the certificate names an address
certificate_authority: !secret mqtt_ca
# client_certificate: !secret mqtt_client_cert # with the CA, port 8884
# client_certificate_key: !secret mqtt_client_key
# transport: wss # with the CA, port 9002
reboot_timeout: 0s
discovery: false
on_message:
- topic: ${name}/cmd
then:
- logger.log:
format: "cmd payload: %s"
args: ["x.c_str()"]
on_connect:
- logger.log: "MQTT on_connect"
on_disconnect:
- logger.log: "MQTT on_disconnect"
@@ -0,0 +1,88 @@
# MQTT transport test (#16056, needs that PR's tree for the transport option): one build per transport, name and mqtt block per file (see reference/recipes/mqtt.md).
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
wifi: DEBUG
api: DEBUG
api.connection: DEBUG
scheduler: DEBUG
component: DEBUG
sensor: DEBUG
esp32: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("probe", "mqtt connected %d, internal free %u, largest block %u", id(mq).is_connected(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
sensor:
- platform: template
name: Counter
update_interval: 2s
accuracy_decimals: 0
lambda: |-
static uint32_t n = 0;
return n++;
switch:
- platform: template
name: Relay
id: relay
optimistic: true
on_turn_on:
- logger.log: "Relay ON"
on_turn_off:
- logger.log: "Relay OFF"
button:
- platform: template
name: Publish big
on_press:
- mqtt.publish:
topic: ${name}/big
payload: !lambda |-
return std::string(3000, 'q');
substitutions:
name: mqtt-dut
esphome:
name: ${name}
mqtt:
id: mq
broker: !secret broker_ip # test.mosquitto.org for the public bundle run
port: 9002 # 1883 tcp, 9001 ws, 9002 wss, 8081 public wss
transport: wss # drop for tcp
ws_path: /mqtt # ws and wss only
certificate_authority: !secret mqtt_ca # drop for the bundle runs
skip_cert_cn_check: true # with the local CA only: the certificate names an address
reboot_timeout: 0s
discovery: false
on_message:
- topic: ${name}/cmd
then:
- logger.log:
format: "cmd payload: %s"
args: ["x.c_str()"]
@@ -0,0 +1,50 @@
substitutions:
run: "3"
esphome:
name: cd13122d
on_boot:
priority: -100
then:
- lambda: |-
ESP_LOGI("crashtest", "boot, crashed_before=%d run=%s", id(crashed_${run}), "${run}");
- delay: 20s
- if:
condition:
lambda: 'return id(crashed_${run}) == 0;'
then:
- lambda: |-
id(crashed_${run}) = 1;
ESP_LOGW("crashtest", "flag set, letting the loop save it");
- delay: 3s
- lambda: |-
global_preferences->sync();
delay(500);
ESP_LOGW("crashtest", "writing to 0xDEADBEEF now");
delay(100);
*reinterpret_cast<volatile uint32_t *>(0xDEADBEEF) = 1;
else:
- lambda: |-
ESP_LOGI("crashtest", "already crashed once, running normally");
nrf52:
board: nrf52840dongle
dfu: true
logger:
hardware_uart: USB_CDC
level: DEBUG
zephyr_coredump:
globals:
- id: crashed_${run}
type: int
restore_value: true
initial_value: "0"
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("crashtest", "alive");
@@ -0,0 +1,31 @@
esphome:
name: pref20810
on_boot:
priority: -100
then:
- lambda: |-
ESP_LOGI("test", "boot: restored counter_pr=%d", id(counter_pr));
nrf52:
board: nrf52840dongle
dfu: true
logger:
hardware_uart: USB_CDC
level: VERBOSE
preferences:
flash_write_interval: 5s
globals:
- id: counter_pr
type: int
restore_value: true
initial_value: "0"
interval:
- interval: 15s
then:
- lambda: |-
id(counter_pr) += 1;
ESP_LOGI("test", "counter_pr now %d", id(counter_pr));
@@ -0,0 +1,84 @@
esphome:
name: online-image-dut
friendly_name: online-image-dut
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
actions:
- action: fetch
variables:
url: string
then:
- online_image.set_url:
id: img
url: !lambda 'return url;'
update: false
- component.update: img
- action: refetch
then:
- component.update: img
- action: fetch_small
variables:
url: string
then:
- online_image.set_url:
id: img_small
url: !lambda 'return url;'
update: false
- component.update: img_small
- action: refetch_small
then:
- component.update: img_small
http_request:
timeout: 4.5s
verify_ssl: false
display:
image:
- platform: online_image
id: img
url: http://127.0.0.1/none.bmp
format: AUTO
type: RGB565
update_interval: never
on_download_finished:
logger.log:
format: "download finished: img cached=%d"
args: [cached]
on_error:
logger.log: "download error: img"
- platform: online_image
id: img_small
url: http://127.0.0.1/none.bmp
format: AUTO
type: RGB565
buffer_size: 256
update_interval: never
on_download_finished:
logger.log:
format: "download finished: img_small cached=%d"
args: [cached]
on_error:
logger.log: "download error: img_small"
button:
- platform: restart
id: reboot_btn
name: Reboot
@@ -0,0 +1,55 @@
# Mains build (no deep sleep): outgoing API connection timing after a Wi-Fi drop, and the loop cost while a
# connect is pending or the network is down.
esphome:
name: wifi-drop-dut
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
on_connect:
- lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis());
on_disconnect:
- lambda: ESP_LOGI("timing", "wifi disconnected at %u ms", (unsigned) millis());
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
outgoing_connection:
delay: 2s
on_client_connected:
- lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str());
on_client_disconnected:
- lambda: ESP_LOGI("timing", "client disconnected at %u ms (%s)", (unsigned) millis(), client_info.c_str());
ota:
- platform: esphome
debug:
update_interval: 5s
sensor:
- platform: debug
loop_time:
name: Loop Time
on_value:
- lambda: ESP_LOGI("timing", "loop time max %.0f ms over the last 5 s at %u ms", x, (unsigned) millis());
button:
- platform: template
name: Wifi cycle
on_press:
- lambda: ESP_LOGI("timing", "wifi cycle disabling at %u ms", (unsigned) millis());
- wifi.disable:
- delay: 4s
- lambda: ESP_LOGI("timing", "wifi cycle enabling at %u ms", (unsigned) millis());
- wifi.enable:
@@ -0,0 +1,105 @@
esphome:
name: rtsp17990
on_boot:
# GPIO8 low enables the camera's power supply on the Waveshare ESP32-S3-ETH; give the sensor
# time to come up before esp32_camera (setup_priority 200) probes it
priority: 700
then:
- switch.turn_off: cam_power
- lambda: |-
delay(1200);
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
logger:
level: VERBOSE
hardware_uart: USB_SERIAL_JTAG
logs:
rtsp_server: VERBOSE
esp32_camera: DEBUG
camera: DEBUG
wifi: DEBUG
api: DEBUG
api.connection: DEBUG
api.socket: DEBUG
socket: DEBUG
scheduler: DEBUG
component: DEBUG
app: DEBUG
mdns: DEBUG
ota: DEBUG
logger: DEBUG
psram: DEBUG
esp32: DEBUG
network: DEBUG
i2c: DEBUG
i2c.idf: DEBUG
preferences: DEBUG
debug: DEBUG
sensor: DEBUG
wifi_esp32: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
ota:
- platform: esphome
debug:
update_interval: 10s
sensor:
- platform: debug
free:
name: "Heap Free"
block:
name: "Heap Largest Block"
psram:
name: "PSRAM Free"
# Waveshare ESP32-S3-ETH camera header (Waveshare wiki, WIFI_Web_CAM table)
i2c:
- id: cam_i2c
sda: GPIO48
scl: GPIO47
frequency: 400kHz
switch:
- platform: gpio
id: cam_power
name: Camera Power
pin: GPIO8
restore_mode: ALWAYS_OFF
setup_priority: 800
esp32_camera:
name: cam
setup_priority: 200
data_pins: [GPIO41, GPIO45, GPIO46, GPIO42, GPIO40, GPIO38, GPIO15, GPIO18]
vsync_pin: GPIO1
href_pin: GPIO2
pixel_clock_pin: GPIO39
external_clock:
pin: GPIO3
frequency: 20MHz
i2c_id: cam_i2c
resolution: 640X480
max_framerate: 10fps
rtsp_server:
port: 554
max_connections: 2

Some files were not shown because too many files have changed in this diff Show More