Compare commits

..
Author SHA1 Message Date
J. Nick Koston 3278ea5a04 Merge branch 'dev' into ln882x-crash-handler 2026-10-27 10:07:54 -10:00
J. Nick Koston 361a9580da [libretiny] Gate the vector-patch crash handler on one derived define
The RTL8720C and LN882X check was copied into hal.cpp, the header and the
source; USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH now names it once.
2026-10-27 09:13:25 -10:00
J. Nick Koston 43cc2a8e3d [libretiny] Arm the crash reboot watchdog before recording the fault
A fault while the record was being written spun on the first-fault guard until
the 10 s watchdog from arch_init(), not the 1 s one.
2026-10-27 09:08:25 -10:00
J. Nick Koston 281c5fcabc [libretiny] Keep the first fault when the SDK fault handler faults too
CmBacktrace on the LN882H walks past the end of RAM and faults inside the
fault handler, which overwrote the record with its own hard fault. Record the
first fault of a boot only and wait for the watchdog on any later one.
2026-10-27 09:00:07 -10:00
J. Nick Koston 79c945c76c Merge remote-tracking branch 'origin/dev' into ln882x-crash-handler 2026-10-27 08:32:17 -10:00
J. Nick Koston 92c54aea77 [libretiny] Report the previous boot's crash on LN882H
Put the fault recorder in front of the SDK's CmBacktrace fault handlers through
the RAM vector table, as on the RTL8720C, and keep the record in the RETENTION
RAM that startup does not clear. The Cortex-M capture is now shared and sizes
the exception frame from EXC_RETURN, since the LN882H has an FPU.
2026-10-27 08:18:45 -10:00
89 changed files with 182 additions and 3008 deletions

No files matched your search

+2 -3
View File
@@ -88,7 +88,6 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
| BW12 and other AmebaZ boards (RTL8710BX, rtl87xx) on a plain USB-UART | `~/.platformio/penv/.libretiny/bin/python -m ltchiptool flash write -B 115200 -b 115200 -t 120 -d <port> <uf2>`, started first, then strap by hand: CEN to GND, TX2 to GND, release CEN, release TX2. Without `-B 115200` the system data read at 1.5 Mbaud fails on CH340s (`Data read error at 0x9000`) and nothing is written. A 1 MB image takes about 80 s | `serial_capture.py`; a reset pulse does not reach CEN on a plain adapter, so a capture starts mid-run |
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner |
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
@@ -123,8 +122,8 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(server with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header,
capture decoder, RTL8720C ROM dump versus report matcher), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer, frame stream harness with presence hold and link loss drivers, BT MAC driver), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner), `gpio_hold/` (ISR pin hold self test with ESP32 and S3 configs), `ble_event_inject/` (GAP and GATTC event injector header and proxy package), `light_sleep_wake/` (LEDC wave and edge timer for light sleep wakeup on a C3), `manual_ip/` (station and AP manual IP configs, DNS probe and runners), `web_handlers/` (late handler, web_server, captive_portal and prometheus variants, web OTA runner), `bk72xx_flash/` (flash readback with retries, per partition diff of two dumps), `ble_server_security/` (secure GATT server and pairing client configs, driver, summary and runner), `prometheus/` (datetime metrics configs and driver).
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner).
## Templates in `$SKILL/templates`
@@ -133,18 +133,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
## BW12 and other AmebaZ boards (RTL8710BX, LibreTiny rtl87xx)
- Download mode is a hand strap: CEN to GND, TX2 to GND, release CEN, release TX2. A plain USB-UART
does not drive CEN, so neither ltchiptool nor `serial_capture.py --reset` can reset the chip.
- ltchiptool links at 1.5 Mbaud and reads the system data at 0x9000 at that rate before it writes at
115200. A CH340 drops bytes there (`Data read error at 0x9000`, `Timed out waiting for Quiet-Handshake`)
and the run gives up without writing. `-B 115200 -b 115200` moves the link to 115200 first and works.
Start the command with `-t 120` so it is already polling while the board is strapped.
- After a failed run the ROM may sit at another baud rate and no longer link; strap again.
- The device builder frontend's AmebaZ flasher follows ltchiptool (link and system read at 1.5 Mbaud),
so it can hit the same read failure on the same adapters.
## BW16 kit (RTL8720DN, AmebaD)
- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while
@@ -11,15 +11,15 @@ One file per component, named after it:
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar, presence hold and link loss on a frame stream, BT MAC readback
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot with a ROM dump matcher, BK7238 BLE bond store safety with a full flash backup and per partition diff
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): upload without a configuration and the refusal checks, overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
- [`scheduler.md`](scheduler.md): loop wake A/B
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
@@ -41,9 +41,3 @@ One file per component, named after it:
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
- [`gpio_hold.md`](gpio_hold.md): GPIO hold through the ISR pin API, pad read back with no wiring, per chip pin notes
- [`ble_event_inject.md`](ble_event_inject.md): made up GAP and GATTC events fed into the ESP32 BLE handlers from a button
- [`light_sleep_gpio_wake.md`](light_sleep_gpio_wake.md): GPIO wakeup from auto light sleep with an LEDC wave that keeps running in sleep
- [`manual_ip_and_web_handlers.md`](manual_ip_and_web_handlers.md): station, AP and Ethernet manual IP, web handlers added late and web OTA with web_server, captive_portal or prometheus
- [`ble_server_security.md`](ble_server_security.md): esp32_ble_server pairing, encrypted and MITM characteristics, subscription before and after pairing, bond reuse per ESP-IDF version
- [`prometheus.md`](prometheus.md): date, time and datetime metrics in UTC and another time zone
@@ -1,20 +0,0 @@
# Injecting BLE GAP and GATTC events (#21698)
Some BLE paths (repeated connection parameter update failures, a disconnect in the middle of them) are hard to get
from a real peer on demand. `scripts/ble_event_inject` feeds made up events straight into the ESP32 BLE handlers.
- `ESP32BLE::gap_event_handler` and `gattc_event_handler` are protected statics and the class is `final`.
`cp_inject.hpp` reaches them through explicit template instantiation, where access checks do not apply. Its
`cpinject::run()` sends six labelled steps (`STEP1` to `STEP6`): four peers failing five times each, a success
followed by more failures, and disconnects between failures.
- `cp-inject.yaml` is a package for a copy of a proxy config: it adds the include, a VERBOSE logger with the busy
tags back at DEBUG, and an `Inject conn param events` button. Press it with `scripts/api_button.py` while a
log capture (`esphome logs ... --device <ip>` under `timeout`) runs, then count the warning and verbose lines per
step on the PR and on dev.
- The handlers push into a queue and event pool that take a single writer, normally the Bluetooth task. Injecting
from the main task while real events arrive can corrupt them, so the button stops the scan, waits for it to end,
injects and starts it again. Run it with no active proxy connections (no Home Assistant BLE client holding one).
- Fake disconnects use `gattc_if` 0xFE: every client and proxy connection filters on its own `gattc_if_`, so the
queued event reaches only the shared handler. Do not use 0xFF (`ESP_GATT_IF_NONE`), which every client accepts.
- The fake peers (`C0:FF:EE:00:00:0n`) are never connected, so a live proxy keeps serving its real connections;
put it back on its own config afterwards.
@@ -1,26 +0,0 @@
# esp32_ble_server pairing and encrypted characteristics (#17562)
`scripts/ble_server_security`: a classic ESP32 devkit runs the secure GATT server (`server.yaml`) and a second ESP32
(an S3 here) runs `ble_client` as the central (`client.yaml`). The server has a `read_encrypt` battery level
(`99.9`), a plain characteristic, a `write_encrypt` characteristic that logs what it receives, a `read_encrypt_mitm`
characteristic and a `read_encrypt` characteristic that notifies every 2 s. `run.sh TAG IO AUTH` builds both with the
pairing mode as substitutions, pairs once, reconnects twice, and `summary.py` prints PASS or FAIL. The server needs
`esp32_ble_server` security from #17562 or later: point `SERVER_TREE` at that checkout (the client builds from
`TREE`, dev is fine). `summary.py` needs each refused read to show an `Error reading char` reply, so a run where the
client never connected fails instead of passing.
- `ble_client` never starts pairing on its own (a phone does, after an `insufficient authentication` reply); the
client's `Pair` button calls `BLEClientBase::pair()`. Read errors show as `Error reading char at handle N, status=5`
(not authenticated: no bond) or `status=15` (not encrypted: bond known, link not encrypted yet).
- The `ble_client` text sensor subscribes once, at connect, before any pairing, so a protected characteristic refuses
it. The `Subscribe` button writes the CCCD again after pairing; `ble_client` drops its service list after discovery,
so it writes the characteristic handle + 1 (where ESP-IDF's GATT server puts the CCCD).
- Numeric comparison (`display_yes_no` with `sc_mitm_bond`) tells a fresh pairing from a bond reuse: the
`numeric comparison` line only appears on a fresh pairing. Just Works cannot show it.
- ESP-IDF 5.5.6 drops the bond on the second disconnect on both roles (`Deleting unpaired device`), so that reconnect
pairs from scratch; 5.5.5 keeps it (`-s idf 5.5.5`).
- The server keeps its bonds in NVS. One holding an authenticated bond for the client refuses a Just Works re-pairing
silently; erase the devkit's flash before switching from nc to jw.
- `summary.py` failed both modes at 58636914ab: `read_encrypt_mitm` was readable with no pairing, because
`BLEService::create_characteristic()` took an 8 bit `esp_gatt_char_prop_t` and dropped the MITM bits (1 << 8 and
1 << 9). Check the generated `create_characteristic` call and the property type when a flag above bit 7 is added.
@@ -15,13 +15,3 @@ API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
latency and counts as inconclusive).
Controller build check: ESP-IDF 5.5.6 ships `libbtdm_app.a` byte for byte the same as 5.5.5 for esp32c3 and esp32s3
(build 51d9dfd). Without hardware:
```bash
strings -a <idf>/components/bt/controller/lib_esp32c3_family/<chip>/libbtdm_app.a | grep -E '^[0-9a-f]{7}$'
```
On a board: a button lambda logging `btdm_controller_get_compile_version()` (declared `extern "C"`) over the API,
since the ESP-IDF init line only reaches the UART.
@@ -1,17 +0,0 @@
# ESP32 GPIO hold through the ISR pin API (#21576)
`scripts/gpio_hold/hold_test.hpp` builds `ESP32InternalGPIOPin`s by hand, calls `to_isr()` and reads each pad back
with `gpio_get_level`: 20 ISR writes, a raw `gpio_set_level` control (a held pad must not change, an unheld one must)
and ISR `pin_mode` pulls (input must drop to `NONE` between pull changes, because the ISR path only applies pulls when
`FLAG_INPUT` toggles). It needs no wiring: unconnected pins read the pulls. Each pin runs held and unheld, then the
round logs `ROUND n PASS failures=0` or `FAIL`.
- `USE_GPIO_HOLD` comes from any pin with `hold_state: true`, so the configs add a `gpio` output on a spare pin.
- `gpio-hold-esp32.yaml` (classic devkit) runs three rounds 5 s after boot and has a `rerun` button;
`gpio-hold-esp32s3.yaml` (Waveshare ESP32-S3-ETH, where most pins go to the W5500, TF card, camera, WS2812 or
USB) runs a round every 15 s.
- C3: ESP-IDF 5.5 has `SOC_RTCIO_PIN_COUNT` 0, so `rtc_gpio_is_valid_gpio()` is false on every pin and only the
digital hold path runs.
- C6 (Waveshare ESP32-C6-GEEK, native USB): LP pads GPIO0 to 7 are RTC IO with hold. GPIO1, 2, 7, 20, 23 read high
and GPIO6 low whatever the pull (LCD, TF card or resistors); clean pins are 0, 3, 4, 5, 18, 19, 21, 22. Dev
already holds LP pads through `gpio_hal_hold_en`, so the PR and dev look the same there. H2 is untested.
@@ -40,29 +40,3 @@ least 24 selects per build and read the raw bytes with `uart: debug: direction:
id) before blaming the parser; the API log stream drops lines under that dump volume, so some misses stay
unclassified.
Results: https://github.com/esphome/esphome/pull/21357#issuecomment-6333244886
## Frame stream harness: presence hold and link loss (#21733, #21734)
`scripts/ld2450/stream-harness.yaml` is a loopback harness that feeds the hub one periodic frame every 100 ms from a
mode global set by API buttons: no target, target 1 approaching, silent, or 12 garbage bytes per tick (what a radar
at another baud rate looks like). `has_target` has `filters: []` so the hub's own hold is what gets timed, and every
change logs `presence raw ON|OFF at <millis>`. Add `stream-connected.yaml` as a second package on builds whose ld2450
has the link watchdog (`connected` binary sensor).
- `holddrive.py HOST OUT`: for `presence_timeout` 5 and 0, three seconds of target frames, then empty frames; prints
the ms from the press to the off line, then sets 0 and 20 and reads the number back after a restart. On dev 0
still held 5 s and snapped back to 5; with #21733 it clears on the next empty frame (under 200 ms) and stays 0.
- `linkdrive.py HOST OUT`: steps through target frames, 10 s of silence, empty frames, 10 s of garbage, target
frames, then the hub's radar restart followed by 3.5 s and by 15 s of silence; prints every state change and link
log line with its time. Dev freezes every entity through silence and garbage. Run against a real radar too: a
radar restart and a Bluetooth toggle must not report a loss (the Apollo R PRO-1 ETH did not).
- A freshly booted ESP8266 delays API commands sent in its first seconds and then runs them back to back; the
drivers wait 8 s after connecting before timed presses.
## BT MAC sensors (#21731)
`scripts/ld2450/macdrive.py HOST OUT` reads every LD24xx BT MAC, Bluetooth and firmware entity, turns each Bluetooth
switch off, then on, and prints the values after each step. The MAC sensors are often `disabled_by_default` in
vendor configs but still publish over the API. On the Apollo R PRO-1 ETH both radars (LD2450 fw 2.04, LD2412 fw
1.26) have the multicast bit set in the first MAC byte, which dev showed as `unknown`. A radar MAC identifies a
device: quote only its first byte in PR text.
@@ -71,31 +71,3 @@ The `restart` after the first crash checks the report shows only once. Time each
(the ROM fault dump to the next `ROM Version` line), not from the API port: the board is back before an API client
notices it went away. Compare the reported PC with the `PC =` line of the ROM dump, and check the decoded lines in the
`caseN-*.txt` files. Pass the UF2 by name (`firmware.uf2`): the build directory holds a second identically sized copy.
Regression rerun of the same flow (#21728): `scripts/libretiny_crash/rtl_rom_vs_report.py CAPTURE` lists, for each ROM
fault dump in a `--ms` capture, the ROM's CFSR and PC, the ms to the next `ROM Version` banner and the Reason, CFSR, PC
and SP the next boot reported, so two builds compare line by line. The ROM lines start with a bare CR; split the
capture on newlines only. The imprecise bus fault's PC moves between builds (it is wherever the core was when the
fault was raised); compare it with that run's ROM dump, not with another build. SP stays the same per fault (8 word
frame, no FPU). On the BK7238 time the reboot from the button press to the next `BK7238_1.0.14` banner (0.6 to 0.7 s).
## BK7238 BLE and flash sector safety (#21735)
The Beken BLE bond store reads its partition at boot and erases that sector when the CRC does not match; before
LibreTiny 1.14 the partition was unmapped and pointed at sector 0, erasing the bootloader (#18646). To prove a change
like this safe on a board that cannot be recovered without a dump:
1. Take a full backup first: `scripts/bk72xx_flash/readback.sh full-before` (ltchiptool family `beken-7238`, about
190 s for 2 MB). Keep it until the board is back on a known build.
2. Serial flash a build without the feature, then deliver the feature build by OTA (the path users take). Use
`bk72xx: framework: sdk_silent: none` with `logger: hardware_uart: UART1`, so the SDK's own lines (bootloader
banner, `CRC fail, remove bonded info`) show up in the capture.
3. Soak with Wi-Fi up, OTA again from the new build, pulse CEN a few times (`serial_capture.py --reset`), and check
each boot comes back through the bootloader.
4. Read the whole chip again and run `scripts/bk72xx_flash/partdiff.py full-before.bin full-after.bin
<libretiny>/boards/_base/beken-72xx.json`: bootloader, calibration and userdata must be unchanged; app, download
and one KVS sector change normally.
Check what already sits in the target partition before the first boot: on an XH-WB3S that shipped with Tuya firmware,
0x1E1000 (the `ble_bonding` partition of the generic Beken layout) held the Tuya `diff2ya` record (magic `ba dc cd
ab`), which the bond store erased on its first boot. Nothing depended on it there, but say so in the results.
@@ -1,16 +0,0 @@
# GPIO wakeup from auto light sleep (#21074)
`scripts/light_sleep_wake` needs no wiring. LEDC on `LEDC_USE_RC_FAST_CLK` with
`sleep_mode = LEDC_SLEEP_MODE_KEEP_ALIVE` keeps toggling GPIO10 at 2 Hz through light sleep, and an
`ESP32InternalGPIOPin` interrupt (`ANY_EDGE`) on the same pad timestamps every edge in an IRAM handler.
`wakeprobe::report()` logs every 30 s: edges seen and expected, the shortest and longest gap, how many edges came
more than 20 ms off the half period, and the SLEEP share from `esp_pm_dump_locks()` (needs `profiling: true`).
- Native ESP-IDF builds need `include_builtin_idf_components: [esp_driver_ledc]`.
- After `ledc_channel_config` call `gpio_input_enable(pin)`, never `gpio_set_direction(INPUT_OUTPUT)`: that routes the
pad back to a plain GPIO output, the wave stops and the pad reads a constant 1.
- The C3 shares one LEDC clock across timers, so a board whose own config drives LEDs through `ledc` needs a test
config without them. Logging over the API works with `logger: baud_rate: 0`.
- Read the reports over the API with `esphome logs ... --device <ip>` under `timeout`. Run PR, dev, then the PR
again. On a C3 bulb dev caught every edge but handled 8 of 845 more than 20 ms late
(up to 45 ms), the PR none; both slept 57 to 58% of the time.
@@ -1,31 +0,0 @@
# Manual IP and web handler flows (#21708, #21709, #21711, #21712)
## Station manual IP (`scripts/manual_ip/run-sta.sh`)
Set `static_ip` to the board's own DHCP lease so nothing collides, and DNS2 to a server DHCP does not hand out
(9.9.9.9), so the dump and `dns_getserver()` prove the manual values are in use. A missing SSID with its own
`manual_ip` comes first, so the real network is a later table row and inherits the top level `manual_ip`. The probe
logs both DNS servers and resolves a name every 20 s; `ipaddr_ntoa` uses one static buffer, so it uses
`ipaddr_ntoa_r` to log two addresses in one line. PASS needs the address, both DNS servers, a lookup and a ping.
## AP manual IP (`scripts/manual_ip/run-ap.sh`)
The DUT has only a missing SSID, `ap_timeout: 10s` and `captive_portal:`, so its AP comes up on 10.42.7.1; the other
board joins as a client and fetches `http://10.42.7.1/` every 10 s. A D1 mini AP drops some client requests on dev
too, so compare failure counts with a dev run and do not expect zero.
## Ethernet manual IP
Same idea on an Ethernet board: its lease as `manual_ip` plus an outside DNS2 and the DNS probe from
`scripts/manual_ip/dnsprobe.hpp`; restart it over the API and check again.
## Web handlers (`scripts/web_handlers/run.sh`)
`webbase.yaml` adds a GET /probe handler from `on_boot` at priority -100 (the shape of an external component adding
a handler late) and takes a variant: `ws` (web_server), `cp` (captive_portal only, started on the station network by
a button calling `global_captive_portal->start()`) or `prom` (captive_portal and prometheus). The runner fetches
/probe and /, then uploads the same image through web OTA (`curl -F update=@<bin> http://ip/update`; ESP-IDF
`build/<name>.bin`, not `firmware.ota.bin`; ESP8266 `.pioenvs/<name>/firmware.bin`) and checks over the API that the
board is back on that build. On dev too, prometheus GET / gets no answer on ESP32 and 404 on ESP8266.
A background `(cd dir && cmd) &` does not change the parent shell's directory: give capture scripts absolute paths.
@@ -54,16 +54,3 @@ over syslog and fails when a case did not reach the end of its stream.
to `dest`); after that lands, remove the `dest_limit` line.
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409786224
## Upload without a configuration (#21738)
`esphome upload --file IMAGE --device IP` with no YAML picks the OTA port from the image header and takes the password
from `--ota-password` or `ESPHOME_OTA_PASSWORD` and the key from `ESPHOME_OTA_KEY`. Test each chip family with its own
current image (`build/firmware.ota.bin` for ESP-IDF, `.pioenvs/<name>/firmware.bin` for ESP8266, `firmware.uf2` for
LibreTiny) and check the build over the API; switch one board between two builds to show a new image lands.
Point every case that should be refused before connecting at an unroutable address (192.0.2.1), never at a board: a
refusal prints no `Connecting to` line, and an image that slips through cannot reach a device. An ESP32-S3
`firmware.factory.bin` slipped through on 2026-10-27 (its bootloader header at offset 0 looks like an app), the device
reported `OTA successful` and its bootloader rejected the slot at boot; send the board its proper app image afterwards.
A factory image has the partition table magic `AA 50` at 0x8000 and the app at 0x10000.
@@ -1,9 +0,0 @@
# prometheus
## Date, time and datetime metrics (#9941)
`scripts/prometheus/prom-datetime.yaml` is a D1 mini with `web_server`, `prometheus` and three optimistic template
datetime entities; `prom-datetime-tz.yaml` adds an sntp time component with `HST10` (the zone applies without a sync).
`datetime_metrics.py HOST` prints the rows of `/metrics`, sets the entities over the API to 2040 values (past 2038 on
purpose) and prints them again with their UTC reading. Dates are local midnight in ms, times ms since midnight,
datetimes ms since the epoch, so a datetime set as an instant reads the same in both zones.
@@ -1,49 +0,0 @@
"""Compare two full flash dumps of a BK72xx board per partition of its LibreTiny board layout.
usage: partdiff.py BEFORE.bin AFTER.bin LAYOUT.json
LAYOUT.json is a board base file from the LibreTiny platform (for example boards/_base/beken-72xx.json); its
"flash" map ("name": "0xSTART+0xLENGTH") names the partitions. Prints the 4 KB sectors that changed in each one,
plus any changed sector outside the map.
"""
import json
import pathlib
import sys
before, after = (pathlib.Path(p).read_bytes() for p in sys.argv[1:3])
if len(before) != len(after):
sys.exit(
f"dump sizes differ: {len(before)} and {len(after)} bytes; compare two whole chip reads"
)
parts = []
for name, span in json.loads(pathlib.Path(sys.argv[3]).read_text(encoding="utf-8"))[
"flash"
].items():
start, length = (int(x, 16) for x in span.split("+"))
parts.append((start, start + length, name))
parts.sort()
if (end := max(e for _, e, _ in parts)) > len(before):
sys.exit(
f"the layout reaches {end:#x} but the dumps are {len(before):#x} bytes; read the whole chip"
)
SECTOR = 0x1000
covered = set()
for start, end, name in parts:
changed = [
a
for a in range(start, end, SECTOR)
if before[a : a + SECTOR] != after[a : a + SECTOR]
]
covered.update(range(start, end, SECTOR))
listed = " ".join(hex(a) for a in changed[:4]) if 0 < len(changed) <= 4 else ""
print(
f"{name:12} {len(changed):4} of {(end - start) // SECTOR:4} sectors changed {listed}"
)
other = [
a
for a in range(0, len(before), SECTOR)
if a not in covered and before[a : a + SECTOR] != after[a : a + SECTOR]
]
print(
f"{'unmapped':12} {len(other):4} sectors changed {' '.join(hex(a) for a in other[:8])}"
)
@@ -1,19 +0,0 @@
#!/bin/bash
# usage: readback.sh TAG [START LENGTH] read flash from a BK72xx over its adapter into TAG.bin (whole chip by default)
# env: PORT (adapter), FAMILY (ltchiptool family, default beken-7238), FLASH_BK (path to flash_bk72xx.py),
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), SIZE (whole chip bytes, default
# 2097152). A whole 2 MB chip takes about 190 s. The read must come back at the expected size.
T=${1:?tag}; P=${PORT:?set PORT}; FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
ARGS=(); [ -n "$2" ] && ARGS=(-s "$2" -l "${3:?length}")
WANT=$(( ${3:-${SIZE:-2097152}} ))
: > "$T.log"
ok=
for i in 1 2 3; do
echo "### try $i" >> "$T.log"
rm -f "$T.bin"
timeout 900 "$LT" "$FB" flash read -d "$P" "${ARGS[@]}" "${FAMILY:-beken-7238}" "$T.bin" >> "$T.log" 2>&1 &&
[ "$(wc -c < "$T.bin" | tr -d ' ')" = "$WANT" ] && ok=1 && break
sleep 3
done
[ -n "$ok" ] || { echo "FAIL: read $T (expected $WANT bytes, see $T.log)"; exit 1; }
shasum -a 256 "$T.bin"
@@ -1,39 +0,0 @@
# Add to a copy of a bluetooth_proxy config: packages: {cp_inject: !include cp-inject.yaml}
esphome:
includes:
- cp_inject.hpp
logger:
level: VERBOSE
logs:
esp32_ble_tracker: DEBUG
bluetooth_proxy: DEBUG
bluetooth_connection: DEBUG
api: DEBUG
api.service: DEBUG
api.connection: DEBUG
api.socket: DEBUG
scheduler: DEBUG
sensor: DEBUG
component: DEBUG
button:
- platform: template
name: Inject conn param events
# The handlers push into a queue that takes one writer only, the Bluetooth task. Stop the scan (and keep the
# proxy free of real connections) so nothing else writes while the button does.
on_press:
- esp32_ble_tracker.stop_scan:
- wait_until:
condition:
lambda: return !esp32_ble_tracker::global_esp32_ble_tracker->scan_running();
timeout: 5s
- delay: 1s
- lambda: |-
if (esp32_ble_tracker::global_esp32_ble_tracker->scan_running()) {
ESP_LOGE("cpinject", "scan still running after 5 s, nothing injected");
return;
}
cpinject::run();
- esp32_ble_tracker.start_scan:
continuous: true
@@ -1,68 +0,0 @@
#pragma once
// Feeds fabricated conn param update and disconnect events into ESP32BLE's static handlers.
// The event queue takes a single writer: call run() only while scanning is stopped and no connection is active.
#include <cstring>
#include "esphome/components/esp32_ble/ble.h"
#include "esphome/core/log.h"
namespace cpinject {
using GapFn = void (*)(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *);
using GattcFn = void (*)(esp_gattc_cb_event_t, esp_gatt_if_t, esp_ble_gattc_cb_param_t *);
GapFn steal_gap();
GattcFn steal_gattc();
// Explicit instantiation ignores access checks, which exposes the protected static handlers.
template<GapFn F> struct GapThief {
friend GapFn steal_gap() { return F; }
};
template<GattcFn F> struct GattcThief {
friend GattcFn steal_gattc() { return F; }
};
template struct GapThief<&esphome::esp32_ble::ESP32BLE::gap_event_handler>;
template struct GattcThief<&esphome::esp32_ble::ESP32BLE::gattc_event_handler>;
static const char *const TAG = "cpinject";
inline void peer(uint8_t n, uint8_t *bda) {
const uint8_t base[6] = {0xC0, 0xFF, 0xEE, 0x00, 0x00, 0x00};
memcpy(bda, base, 6);
bda[5] = n;
}
inline void update(uint8_t n, int status, int times) {
for (int i = 0; i < times; i++) {
esp_ble_gap_cb_param_t p{};
peer(n, p.update_conn_params.bda);
p.update_conn_params.status = (esp_bt_status_t) status;
p.update_conn_params.conn_int = 24;
p.update_conn_params.timeout = 400;
steal_gap()(ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT, &p);
}
}
inline void disconnect(uint8_t n) {
esp_ble_gattc_cb_param_t p{};
peer(n, p.disconnect.remote_bda);
p.disconnect.reason = ESP_GATT_CONN_TERMINATE_PEER_USER;
p.disconnect.conn_id = 0xEE;
// 0xFE matches no registered client, so the queued event is ignored by every connection.
steal_gattc()(ESP_GATTC_DISCONNECT_EVT, 0xFE, &p);
}
inline void run() {
ESP_LOGI(TAG, "STEP1 peers 1-4 fail 5x each (3 slots)");
for (uint8_t n = 1; n <= 4; n++)
update(n, 6, 5);
ESP_LOGI(TAG, "STEP2 peer 1 succeeds, then fails 3x");
update(1, 0, 1);
update(1, 6, 3);
ESP_LOGI(TAG, "STEP3 peer 2 disconnects, then fails 3x");
disconnect(2);
update(2, 6, 3);
ESP_LOGI(TAG, "STEP4 peer 4 fails 3x, slots still full");
update(4, 6, 3);
ESP_LOGI(TAG, "STEP5 peer 3 disconnects, peer 4 fails 3x");
disconnect(3);
update(4, 6, 3);
ESP_LOGI(TAG, "STEP6 done");
}
} // namespace cpinject
@@ -1,155 +0,0 @@
# Waveshare ESP32-S3 (any ESP32 with BLE) as the central. run.sh passes name, io, auth, idf and server (the
# server's BLE MAC from its esp32_ble dump) with -s. ble_client never pairs by itself: the Pair button calls pair().
esphome:
name: ${name}
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
version: ${idf}
logger:
hardware_uart: USB_SERIAL_JTAG
level: VERBOSE
logs:
esp32_ble_tracker: DEBUG
api: DEBUG
api.connection: DEBUG
api.service: DEBUG
api.socket: DEBUG
scheduler: DEBUG
component: DEBUG
wifi: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
esp32_ble:
io_capability: ${io}
auth_req_mode: ${auth}
esp32_ble_tracker:
ble_client:
- mac_address: ${server}
id: cli
on_connect:
- logger.log: "CLI connected"
on_disconnect:
- logger.log: "CLI disconnected"
on_numeric_comparison_request:
- logger.log:
format: "CLI numeric comparison %06u"
args: ["(unsigned) passkey"]
- ble_client.numeric_comparison_reply:
id: cli
accept: true
text_sensor:
- platform: ble_client
ble_client_id: cli
id: secure_value
name: Secure value
service_uuid: "180F"
characteristic_uuid: "2A19"
update_interval: never
on_value:
- logger.log:
format: "CLI secure read: %s"
args: ["x.c_str()"]
- platform: ble_client
ble_client_id: cli
id: open_value
name: Open value
service_uuid: 5ec50000-0000-4000-8000-00000000f000
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f001
update_interval: never
on_value:
- logger.log:
format: "CLI open read: %s"
args: ["x.c_str()"]
- platform: ble_client
ble_client_id: cli
id: mitm_value
name: Mitm value
service_uuid: 5ec50000-0000-4000-8000-00000000f000
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f004
update_interval: never
on_value:
- logger.log:
format: "CLI mitm read: %s"
args: ["x.c_str()"]
- platform: ble_client
ble_client_id: cli
id: notify_value
name: Notify value
service_uuid: 5ec50000-0000-4000-8000-00000000f000
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f003
notify: true
on_value:
- logger.log:
format: "CLI notification: %s"
args: ["x.c_str()"]
button:
- platform: template
name: Subscribe
on_press:
- lambda: |-
// ble_client drops its service list after discovery; ESP-IDF's GATT server puts the CCCD right after
// the characteristic value, so write handle + 1
uint16_t chr = id(notify_value)->handle;
if (chr == 0) { ESP_LOGW("CLI", "notify characteristic handle unknown"); return; }
esp_ble_gattc_register_for_notify(id(cli)->get_gattc_if(), id(cli)->get_remote_bda(), chr);
uint8_t on[2] = {1, 0};
auto err = esp_ble_gattc_write_char_descr(id(cli)->get_gattc_if(), id(cli)->get_conn_id(), chr + 1, 2, on,
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
ESP_LOGI("CLI", "CCCD write to handle %u (char %u) sent, err %d", chr + 1, chr, err);
- platform: template
name: Pair
on_press:
- lambda: 'ESP_LOGI("CLI", "pair() returned %d", (int) id(cli)->pair());'
- platform: template
name: Read secure
on_press:
- component.update: secure_value
- platform: template
name: Read mitm
on_press:
- component.update: mitm_value
- platform: template
name: Read open
on_press:
- component.update: open_value
- platform: template
name: Write secure
on_press:
- ble_client.ble_write:
id: cli
service_uuid: 5ec50000-0000-4000-8000-00000000f000
characteristic_uuid: 5ec50000-0000-4000-8000-00000000f002
value: [0x68, 0x65, 0x6C, 0x6C, 0x6F]
- platform: template
name: Disconnect
on_press:
- ble_client.disconnect: cli
- platform: template
name: Connect
on_press:
- ble_client.connect: cli
- platform: template
name: Remove bond
on_press:
- ble_client.remove_bond:
id: cli
@@ -1,60 +0,0 @@
"""Press the client's buttons in order and log every client line about BLE security.
usage: drive.py CLIENT_HOST OUT STEP... STEP: a button name, or wait:SECONDS env: API_KEY
"""
import asyncio
import os
import pathlib
import re
import sys
import time
from aioesphomeapi import APIClient, ButtonInfo, LogLevel
HOST, OUT, STEPS = sys.argv[1], sys.argv[2], sys.argv[3:]
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
PATTERN = re.compile(
r"CLI|ble_client|ble_text_sensor|esp32_ble_client|[Aa]uth|[Ee]ncrypt|[Bb]ond|Error|status=|pair"
)
def note(s: str) -> None:
line = f"{time.strftime('%H:%M:%S')} {s}"
print(line)
LOG.write(line + "\n")
LOG.flush()
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
for attempt in range(40):
try:
await c.connect(login=True)
break
except Exception as err: # noqa: BLE001
last_err = err
note(f"connect attempt {attempt + 1} failed: {err!r}")
await asyncio.sleep(2)
else:
raise last_err
ents, _ = await c.list_entities_services()
btn = {e.name: e.key for e in ents if isinstance(e, ButtonInfo)}
def on_log(m) -> None:
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if PATTERN.search(t):
note("LOG " + t.strip().replace("\n", " | "))
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
for step in STEPS:
if step.startswith("wait:"):
await asyncio.sleep(float(step[5:]))
continue
note(f"PRESS {step}")
c.button_command(btn[step])
await asyncio.sleep(4)
await c.disconnect()
asyncio.run(main())
@@ -1,32 +0,0 @@
#!/bin/bash
# usage: run.sh TAG IO AUTH build and flash the server (serial) and the client (OTA), then pair once and reconnect twice
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password) and run it there.
# IO/AUTH: none bond (Just Works) or display_yes_no sc_mitm_bond (numeric comparison, MITM).
# env: TREE (esphome checkout for the client), SERVER_TREE (checkout with esp32_ble_server security, #17562 or
# later; default TREE), SERVER_PORT (devkit serial port), CLIENT_HOST (client address), SERVER_BLE_MAC (from the
# server's esp32_ble dump), SC (path to serial_capture.py), IDF (default recommended), PY (default $TREE/venv/bin/python)
# A server keeps its bonds in NVS and refuses a weaker re-pairing: erase its flash before switching from nc to jw.
D=$(cd "$(dirname "$0")" && pwd); G=${1:?tag}; IO=${2:?io}; AUTH=${3:?auth}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
P=${SERVER_PORT:?set SERVER_PORT}; C=${CLIENT_HOST:?set CLIENT_HOST}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}; SC=${SC:?set SC}
E=(-s io "$IO" -s auth "$AUTH" -s idf "${IDF:-recommended}")
ST=${SERVER_TREE:-$T}
build() { local t=$1; shift; (cd "$t" && PYTHONUNBUFFERED=1 timeout 1800 $PY -m esphome "$@"); }
ok=
for i in 1 2 3; do
build "$ST" -s name "bss-$G" "${E[@]}" run --no-logs "$D/server.yaml" --device "$P" > "$D/server-$G-upload.log" 2>&1 && ok=1 && break
sleep 5
done
[ -n "$ok" ] || { echo "FAIL: server build or flash (does SERVER_TREE have #17562?)"; exit 1; }
build "$T" -s name "bsc-$G" "${E[@]}" -s server "$MAC" run --no-logs "$D/client.yaml" --device "$C" > "$D/client-$G-upload.log" 2>&1 ||
{ echo "FAIL: client build or upload"; exit 1; }
rm -f "$D/server-$G.txt"
$PY "$SC" "$P" 330 "$D/server-$G.txt" --reset > /dev/null 2>&1 & CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
sleep 25
R=("Disconnect" "wait:3" "Connect" "wait:10" "Pair" "wait:5" "Read secure" "Read mitm")
$PY "$D/drive.py" "$C" "$D/client-$G.txt" "wait:6" "Remove bond" "Disconnect" "wait:3" "Connect" "wait:10" \
"Read secure" "Read mitm" "Write secure" "Subscribe" "wait:8" "Pair" "wait:6" "Read secure" "Read mitm" "Write secure" \
"Subscribe" "wait:10" "${R[@]}" "${R[@]}" > /dev/null || { echo "FAIL: driver"; exit 1; }
wait $CAP; trap - EXIT
MODE=jw; [ "$IO" = none ] || MODE=nc
$PY "$D/summary.py" "$MODE" "$D/client-$G.txt" "$D/server-$G.txt"
@@ -1,85 +0,0 @@
# Classic ESP32 devkit as the secure GATT server. run.sh passes name, io (none or display_yes_no),
# auth (bond or sc_mitm_bond) and idf (recommended, or a version such as 5.5.5) with -s.
# Needs esp32_ble_server security from #17562 (read_encrypt, numeric comparison); build it with SERVER_TREE.
esphome:
name: ${name}
esp32:
board: esp32dev
framework:
type: esp-idf
version: ${idf}
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
esp32_ble:
io_capability: ${io}
auth_req_mode: ${auth}
esp32_ble_server:
id: srv
on_connect:
- logger.log: "SRV client connected"
on_disconnect:
- logger.log: "SRV client disconnected"
on_numeric_comparison_request:
- logger.log:
format: "SRV numeric comparison %06u"
args: ["(unsigned) passkey"]
- ble_server.numeric_comparison_reply:
id: srv
address: !lambda return address;
accept: true
services:
- uuid: "180F"
characteristics:
- uuid: "2A19"
read: true
read_encrypt: true
value:
data: "99.9"
- uuid: 5ec50000-0000-4000-8000-00000000f000
characteristics:
- uuid: 5ec50000-0000-4000-8000-00000000f001
read: true
value:
data: "open"
- uuid: 5ec50000-0000-4000-8000-00000000f002
write: true
write_encrypt: true
on_write:
- lambda: 'ESP_LOGI("SRV", "secure write received: %s", std::string(x.begin(), x.end()).c_str());'
- uuid: 5ec50000-0000-4000-8000-00000000f004
read: true
read_encrypt_mitm: true
value:
data: "mitm"
- uuid: 5ec50000-0000-4000-8000-00000000f003
id: secure_notify
read: true
read_encrypt: true
notify: true
value:
data: "n0"
interval:
- interval: 2s
then:
- lambda: |-
static int n = 0;
char buf[12];
snprintf(buf, sizeof(buf), "n%d", ++n);
id(secure_notify)->set_value((const uint8_t *) buf, strlen(buf));
- ble_server.characteristic.notify:
id: secure_notify
@@ -1,78 +0,0 @@
"""Judge one run.sh run from the client log (drive.py output) and the server's serial capture.
usage: summary.py MODE CLIENT_LOG SERVER_CAPTURE MODE: jw (Just Works) or nc (numeric comparison, MITM)
Before the first Pair: the encrypted and MITM reads must each be tried and refused (an `Error reading char` reply),
no notification may arrive and no encrypted write may reach the server. After it: the encrypted read returns 99.9,
the write reaches the server, the second Subscribe brings notifications, and the MITM-only characteristic is readable
only after an nc pairing. Prints the reconnect pattern and PASS or FAIL.
"""
import pathlib
import re
import sys
MODE = sys.argv[1]
cli = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").splitlines()
srv = re.sub(
r"\x1b\[[0-9;]*m",
"",
pathlib.Path(sys.argv[3]).read_bytes().decode(errors="replace"),
)
pair_at = next((i for i, line in enumerate(cli) if "PRESS Pair" in line), None)
if pair_at is None:
sys.exit("FAIL: no Pair step in the client log")
before, after = cli[:pair_at], cli[pair_at:]
subs = [i for i, line in enumerate(after) if "PRESS Subscribe" in line]
after_sub = after[subs[0] :] if subs else []
def count(lines: list[str], pat: str) -> int:
return sum(1 for line in lines if re.search(pat, line))
def outcome(lines: list[str], button: str, ok_pat: str) -> str:
"""'ok', 'refused' or 'none' for the first press of BUTTON.
The window runs to the next read, pair or connection step, since a reply can come after a write press. A value
wins over an error line: the error can belong to another characteristic read in the same window.
"""
start = next((i for i, line in enumerate(lines) if f"PRESS {button}" in line), None)
if start is None:
return "none"
window = []
for line in lines[start + 1 :]:
if re.search(r"PRESS (Read|Pair|Disconnect|Connect|Subscribe|Remove)", line):
break
window.append(line)
if any(re.search(ok_pat, line) for line in window):
return "ok"
return "refused" if any("Error reading char" in line for line in window) else "none"
secure_before = outcome(before, "Read secure", r"secure read: 9")
mitm_before = outcome(before, "Read mitm", r"mitm read: m")
mitm_after = outcome(after, "Read mitm", r"mitm read: m")
checks = {
f"encrypted read refused before pairing ({secure_before})": secure_before
== "refused",
f"MITM read refused before pairing ({mitm_before})": mitm_before == "refused",
"no notifications before pairing": count(before, r"notification: n") == 0,
"encrypted read after pairing": outcome(after, "Read secure", r"secure read: 99\.9")
== "ok",
"encrypted write reached the server": "secure write received" in srv,
"notifications after subscribing paired": count(after_sub, r"notification: n") > 0,
}
if MODE == "nc":
checks[f"MITM read after nc pairing ({mitm_after})"] = mitm_after == "ok"
else:
checks[f"MITM read still refused after Just Works ({mitm_after})"] = (
mitm_after == "refused"
)
for name, ok in checks.items():
print(f"{'ok ' if ok else 'FAIL'} {name}")
print(
f"server: {srv.count('Authentication complete')} authentications, {srv.count('SRV numeric comparison')} numeric "
f"comparisons, {srv.count('Deleting unpaired device')} bonds dropped by the stack"
)
print("PASS" if all(checks.values()) else "FAIL")
sys.exit(0 if all(checks.values()) else 1)
@@ -1,45 +0,0 @@
# Classic ESP32 devkit: RTC pins 25 26 27 32 33 and digital pins 19 21 22 23, three rounds 5 s after boot.
esphome:
name: gpio-hold-esp32
includes:
- hold_test.hpp
on_boot:
priority: -100
then:
- delay: 5s
- lambda: |-
static const int pins[] = {25, 26, 27, 32, 33, 19, 21, 22, 23};
for (int r = 1; r <= 3; r++) holdtest::run(pins, sizeof(pins) / sizeof(pins[0]), r);
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
# Turns on USE_GPIO_HOLD.
output:
- platform: gpio
id: held_out
pin:
number: GPIO4
hold_state: true
button:
- platform: template
name: rerun
on_press:
- lambda: |-
static const int pins[] = {25, 26, 27, 32, 33, 19, 21, 22, 23};
holdtest::run(pins, sizeof(pins) / sizeof(pins[0]), 9);
@@ -1,41 +0,0 @@
# Waveshare ESP32-S3-ETH: RTC pins 16 17 and digital pin 43 are free; a round every 15 s.
esphome:
name: gpio-hold-esp32s3
includes:
- hold_test.hpp
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
# Turns on USE_GPIO_HOLD.
output:
- platform: gpio
id: held_out
pin:
number: GPIO44
hold_state: true
interval:
- interval: 15s
then:
- lambda: |-
static int round = 0;
static const int pins[] = {16, 17, 43};
holdtest::run(pins, sizeof(pins) / sizeof(pins[0]), ++round);
@@ -1,93 +0,0 @@
#pragma once
#include "esphome/components/esp32/gpio.h"
#include "esphome/core/log.h"
#include <driver/gpio.h>
#include <driver/rtc_io.h>
// Drives held pads through the ISR pin API and reads the pad back.
namespace holdtest {
using esphome::esp32::ESP32InternalGPIOPin;
namespace gpio = esphome::gpio;
static const char *const TAG = "holdtest";
// One pin and one ISR argument block per GPIO and hold setting, made on first use and reused by later rounds,
// so a test that repeats forever does not use up the heap.
struct PinPair {
ESP32InternalGPIOPin *pin{nullptr};
esphome::ISRInternalGPIOPin isr;
};
static PinPair pairs[2][GPIO_NUM_MAX];
static PinPair &get(int num, bool hold) {
PinPair &pp = pairs[hold][num];
if (pp.pin == nullptr) {
pp.pin = new ESP32InternalGPIOPin(); // NOLINT
pp.pin->set_pin(static_cast<gpio_num_t>(num));
pp.pin->set_flags(gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | (hold ? gpio::FLAG_HOLD : gpio::FLAG_NONE));
pp.isr = pp.pin->to_isr();
}
pp.pin->setup(); // the previous round left the pad unheld with no mode
return pp;
}
static int lvl(int num) {
esphome::delayMicroseconds(50);
return gpio_get_level(static_cast<gpio_num_t>(num));
}
// Returns failures for one pin.
static int run_pin(int num, bool hold) {
int fail = 0;
auto &isr = get(num, hold).isr;
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_OUTPUT);
int wr_ok = 0;
for (int i = 0; i < 20; i++) {
bool v = i & 1;
isr.digital_write(v);
if (lvl(num) == (int) v) {
wr_ok++;
} else {
fail++;
}
}
// Control: a raw register write without a hold refresh must not reach a held pad.
int before = lvl(num);
gpio_set_level(static_cast<gpio_num_t>(num), !before);
int ctrl = lvl(num);
bool ctrl_ok = hold ? (ctrl == before) : (ctrl != before);
if (!ctrl_ok)
fail++;
isr.digital_write(before); // back in sync
// Pulls through the ISR pin_mode: input must toggle in the flags diff to apply them.
int pd1, pu, pd2;
isr.pin_mode(gpio::FLAG_NONE);
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN);
pd1 = lvl(num);
isr.pin_mode(gpio::FLAG_NONE);
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP);
pu = lvl(num);
isr.pin_mode(gpio::FLAG_NONE);
isr.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN);
pd2 = lvl(num);
if (pd1 != 0 || pu != 1 || pd2 != 0)
fail++;
ESP_LOGI(TAG, "GPIO%d hold=%d rtc=%d write %d/20 control %s pulls pd=%d pu=%d pd=%d -> %s", num, hold,
(int) rtc_gpio_is_valid_gpio(static_cast<gpio_num_t>(num)), wr_ok, ctrl_ok ? "ok" : "BAD", pd1, pu, pd2,
fail ? "FAIL" : "PASS");
// Leave the pad free for the next run.
if (hold)
gpio_hold_dis(static_cast<gpio_num_t>(num));
isr.pin_mode(gpio::FLAG_NONE);
return fail;
}
static void run(const int *pins, int n, int round) {
int fail = 0;
for (int i = 0; i < n; i++) {
fail += run_pin(pins[i], true);
fail += run_pin(pins[i], false);
}
ESP_LOGI(TAG, "ROUND %d %s failures=%d", round, fail ? "FAIL" : "PASS", fail);
}
} // namespace holdtest
@@ -1,127 +0,0 @@
"""Time the presence hold on stream-harness.yaml and check presence_timeout survives a restart.
usage: holddrive.py HOST OUT env: API_KEY (when the harness has one)
For timeouts 5 and 0: target frames for 3 s, then empty frames; prints the ms from the "mode none" press to the
has_target off line (up to one 100 ms frame of that is the harness). Then sets 0 and 20, restarts, reads the number.
A missing press line, off line or number state prints NO-DATA and the run exits 1.
"""
import asyncio
import os
import pathlib
import re
import sys
from aioesphomeapi import APIClient, ButtonInfo, LogLevel, NumberInfo, NumberState
HOST, OUT = sys.argv[1], sys.argv[2]
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
MISSING = []
def note(s: str) -> None:
print(s)
LOG.write(s + "\n")
LOG.flush()
async def session():
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
for attempt in range(60):
try:
await c.connect(login=True)
break
except Exception as err: # noqa: BLE001
last_err = err
note(f"connect attempt {attempt + 1} failed: {err!r}")
await asyncio.sleep(2)
else:
raise last_err
ents, _ = await c.list_entities_services()
btn = {e.name: e.key for e in ents if isinstance(e, ButtonInfo)}
num = next(
e.key
for e in ents
if isinstance(e, NumberInfo) and e.name == "Presence Timeout"
)
st, lines = {}, []
def on_state(s) -> None:
if isinstance(s, NumberState) and s.key == num:
st["t"] = s.state
def on_log(m) -> None:
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if "harness" in t:
lines.append(t)
LOG.write("LOG " + t + "\n")
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
for _ in range(20):
await asyncio.sleep(1)
if "t" in st:
break
# a freshly booted ESP8266 delays API commands that arrive too early
await asyncio.sleep(8)
return c, btn, num, st, lines
def first_ms(lines: list[str], pat: str) -> int | None:
for line in lines:
if m := re.search(pat + r" at (\d+)", line):
return int(m.group(1))
return None
def number(st: dict, what: str) -> str:
if "t" not in st:
MISSING.append(what)
return "NO-DATA"
return f"{st['t']:g}"
async def hold(c, btn, num, st, lines, timeout: int) -> None:
c.number_command(num, timeout)
await asyncio.sleep(2)
if st.get("t") != timeout:
# dev snaps 0 back to the saved value; the hold is then timed with what the number reads
note(f"HOLD timeout={timeout}: the number reads {st.get('t')}, timing that")
c.button_command(btn["Mode target"])
await asyncio.sleep(3)
lines.clear() # only this run's press and off lines count
c.button_command(btn["Mode none"])
await asyncio.sleep(timeout + 8)
start, off = first_ms(lines, "mode none"), first_ms(lines, "presence raw OFF")
if start is None or off is None or off < start:
MISSING.append(f"hold {timeout}")
note(f"HOLD timeout={timeout}: NO-DATA (press line {start}, off line {off})")
else:
note(
f"HOLD timeout={timeout}: off {off - start} ms after the last target frame"
)
async def main() -> None:
c, btn, num, st, lines = await session()
note(f"START number {number(st, 'start')}")
for t in (5, 0):
await hold(c, btn, num, st, lines, t)
for t in (0, 20):
st.pop("t", None)
c.number_command(num, t)
await asyncio.sleep(2)
note(f"SET {t}, number reads {number(st, f'set {t}')}")
c.button_command(btn["Restart"])
await asyncio.sleep(1)
await c.disconnect()
await asyncio.sleep(15)
c, btn, num, st, lines = await session()
note(f"AFTER RESTART number reads {number(st, f'restart {t}')}")
await c.disconnect()
if MISSING:
note(f"NO-DATA for: {', '.join(MISSING)}")
sys.exit(1)
asyncio.run(main())
@@ -1,78 +0,0 @@
"""Step stream-harness.yaml through frames, silence, garbage and radar restarts; print every state change.
usage: linkdrive.py HOST OUT env: API_KEY (when the harness has one)
Lines are seconds since start: PRESS steps, STATE changes of the ld2450 entities (unknown when missing), and LOG lines
about the link or ld2450 warnings.
"""
import asyncio
import math
import os
import pathlib
import re
import sys
import time
from aioesphomeapi import APIClient, ButtonInfo, LogLevel
HOST, OUT = sys.argv[1], sys.argv[2]
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
T0 = time.time()
STEPS = [
("Mode none", 8), ("Mode target", 6), ("Mode silent", 10), ("Mode none", 4), ("Mode target", 4),
("Mode garbage", 10), ("Mode target", 5),
("Radar Restart", 0.3), ("Mode silent", 3.5), ("Mode target", 6),
("Radar Restart", 0.3), ("Mode silent", 15), ("Mode none", 4),
] # fmt: skip
def note(s: str) -> None:
line = f"{time.time() - T0:7.2f} {s}"
print(line)
LOG.write(line + "\n")
LOG.flush()
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
await c.connect(login=True)
ents, _ = await c.list_entities_services()
names = {e.key: e.name for e in ents}
btn = {e.name: e.key for e in ents if isinstance(e, ButtonInfo)}
last = {}
def on_state(s) -> None:
n = names.get(s.key)
if n is None or n in btn:
return
v = (
"unknown"
if getattr(s, "missing_state", False)
else getattr(s, "state", None)
)
if isinstance(v, float):
v = "unknown (nan)" if math.isnan(v) else round(v, 1)
if last.get(n) != v:
last[n] = v
note(f"STATE {n} = {v}")
def on_log(m) -> None:
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if re.search(r"[Ll]ink|\[W\]\[ld2450", t):
note("LOG " + t.strip())
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(3)
for name, wait in STEPS:
note(f"PRESS {name}")
c.button_command(btn[name])
await asyncio.sleep(wait)
await c.disconnect()
# dev freezes entities during silence but still updates them on target frames; nothing at all is a dead stream
if not last:
note("NO-DATA: no entity state arrived")
sys.exit(1)
asyncio.run(main())
@@ -1,82 +0,0 @@
"""Read the LD24xx BT MAC text sensors, turn every LD24xx Bluetooth switch off, then on again.
usage: macdrive.py HOST OUT env: API_KEY (when the device has one)
Works on any device whose MAC, Bluetooth and firmware entities have those words in their names (the BT MAC sensors
may be disabled by default; they still publish over the API). Quote at most the first MAC byte in a PR.
"""
import asyncio
import os
import pathlib
import sys
import time
from aioesphomeapi import APIClient, SwitchInfo, TextSensorInfo
HOST, OUT = sys.argv[1], sys.argv[2]
LOG = pathlib.Path(OUT).open("w", encoding="utf-8") # noqa: SIM115 open for the whole run
def note(s: str) -> None:
line = f"{time.strftime('%H:%M:%S')} {s}"
print(line)
LOG.write(line + "\n")
LOG.flush()
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
await c.connect(login=True)
ents, _ = await c.list_entities_services()
keys = {
e.key: e.name
for e in ents
if isinstance(e, (SwitchInfo, TextSensorInfo))
and any(w in e.name for w in ("MAC", "Bluetooth", "Firmware"))
}
states = {}
def on_state(s) -> None:
if s.key in keys and states.get(keys[s.key]) != getattr(s, "state", None):
states[keys[s.key]] = s.state
note(f"STATE {keys[s.key]} = {s.state}")
c.subscribe_states(on_state)
await asyncio.sleep(8)
switches = {
e.name: e.key
for e in ents
if isinstance(e, SwitchInfo) and "Bluetooth" in e.name
}
macs = [n for n in keys.values() if "MAC" in n]
if not macs or not switches:
note(
f"FAIL: need a MAC text sensor and a Bluetooth switch, found {sorted(keys.values())}"
)
sys.exit(1)
missing = []
if silent := [n for n in sorted(keys.values()) if n not in states]:
note(f"NO-DATA after 8 s: {', '.join(silent)}")
missing += silent
note(
"SNAP start: "
+ "; ".join(f"{n}={states.get(n)}" for n in sorted(keys.values()))
)
for phase, on in (("off", False), ("on", True)):
for name, key in switches.items():
note(f"SET {name} {phase}")
c.switch_command(key, on)
await asyncio.sleep(1)
await asyncio.sleep(20)
note(
f"SNAP bluetooth {phase}: "
+ "; ".join(f"{n}={states.get(n)}" for n in sorted(keys.values()))
)
missing += [n for n in switches if states.get(n) is not True]
await c.disconnect()
if missing:
note(f"NO-DATA for: {', '.join(sorted(set(missing)))}")
sys.exit(1)
asyncio.run(main())
@@ -1,5 +0,0 @@
binary_sensor:
- platform: ld2450
ld2450_id: radar
connected:
name: Connected
@@ -1,128 +0,0 @@
# D1 mini, ld2450 on UART0 looped back onto itself, fed one periodic frame every 100 ms from a mode global:
# 0 no target, 1 target 1 approaching, 2 silent, 3 garbage bytes (what a radar at another baud rate looks like).
# Add `connected: !include stream-connected.yaml` under packages on builds whose ld2450 has the link watchdog.
substitutions:
name: ld2450-stream
esphome:
name: ${name}
on_boot:
priority: -100
then:
- lambda: |-
// UART0 TX looped back into its own RX so injected frames reach the hub
USC0(0) |= (1 << UCLBE);
ESP_LOGI("harness", "UART0 loopback on");
esp8266:
board: d1_mini
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
logger:
baud_rate: 0
level: DEBUG
uart:
id: radar_uart
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: 256000
ld2450:
id: radar
uart_id: radar_uart
globals:
- id: mode
type: int
initial_value: "0"
interval:
- interval: 100ms
then:
- if:
condition:
lambda: return id(mode) == 1;
then:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00, 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x55, 0xCC]
- if:
condition:
lambda: return id(mode) == 0;
then:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x55, 0xCC]
- if:
condition:
lambda: return id(mode) == 3;
then:
- uart.write:
id: radar_uart
data: [0x3F, 0x80, 0xF8, 0x00, 0x7E, 0x07, 0xC0, 0x1F, 0xE0, 0x03, 0xF0, 0x0F]
binary_sensor:
- platform: ld2450
has_target:
name: Has Target
# no settle filter, so the hub's own presence hold is what gets timed
filters: []
on_state:
- lambda: 'ESP_LOGI("harness", "presence raw %s at %u", x ? "ON" : "OFF", (unsigned) millis());'
sensor:
- platform: ld2450
target_count:
name: Target Count
target_1:
x:
name: T1 X
text_sensor:
- platform: ld2450
target_1:
direction:
name: T1 Direction
number:
- platform: ld2450
presence_timeout:
name: Presence Timeout
button:
- platform: template
name: Mode none
on_press:
- lambda: 'id(mode) = 0; ESP_LOGI("harness", "mode none at %u", (unsigned) millis());'
- platform: template
name: Mode target
on_press:
- lambda: 'id(mode) = 1; ESP_LOGI("harness", "mode target at %u", (unsigned) millis());'
- platform: template
name: Mode silent
on_press:
- lambda: 'id(mode) = 2; ESP_LOGI("harness", "mode silent at %u", (unsigned) millis());'
- platform: template
name: Mode garbage
on_press:
- lambda: 'id(mode) = 3; ESP_LOGI("harness", "mode garbage at %u", (unsigned) millis());'
- platform: ld2450
ld2450_id: radar
restart:
name: Radar Restart
- platform: restart
name: Restart
@@ -1,81 +0,0 @@
"""Match each RTL8720C ROM fault dump in a serial capture with the report on the next boot.
usage: rtl_rom_vs_report.py SERIAL_CAPTURE (taken with serial_capture.py --ms)
Per ROM dump: the ROM's CFSR and PC, ms from the dump to the next `ROM Version` banner, and the Reason, CFSR, PC and SP
the next boot reported. The ROM lines start with a bare CR, so the capture is split on newlines only.
"""
import pathlib
import re
import sys
RAW = pathlib.Path(sys.argv[1]).read_bytes().decode(errors="replace")
LINES = [
re.sub(r"\x1b\[[0-9;]*m", "", line) for line in RAW.replace("\r", "").split("\n")
]
def stamp(line: str) -> float | None:
"""Seconds since midnight from a serial_capture.py --ms timestamp."""
m = re.search(r"\[(\d\d):(\d\d):(\d\d\.\d{3})\]", line)
return (
int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3)) if m else None
)
if not any(stamp(line) is not None for line in LINES):
sys.exit(
"NO-DATA: no [HH:MM:SS.mmm] timestamps; take the capture with serial_capture.py --ms"
)
dumps = bad = 0
i = 0
while i < len(LINES):
if "Fault Handler:" not in LINES[i]:
i += 1
continue
t0 = stamp(LINES[i])
kind = LINES[i + 2].split("]", 1)[-1].strip() if i + 2 < len(LINES) else "?"
cfsr = pc = None
j = i
while j < len(LINES) and "ROM Version" not in LINES[j]:
if cfsr is None and "Configurable Fault Status Reg" in LINES[j]:
cfsr = LINES[j].split("=")[1].strip()
if pc is None and "PC =" in LINES[j]:
pc = LINES[j].split("=")[1].strip()
j += 1
tj = next(
(
stamp(LINES[x])
for x in range(j, min(j + 5, len(LINES)))
if stamp(LINES[x]) is not None
),
None,
)
reset = (
f"{(tj - t0) * 1000:.0f} ms"
if tj is not None and t0 is not None
else "no reset seen"
)
report = {}
k = j + 1
while (
k < len(LINES)
and "Fault Handler:" not in LINES[k]
and "boot reason" not in LINES[k]
):
m = re.search(r"(Reason|CFSR|PC|SP):\s+(.*)", LINES[k])
if m and "libretiny.crash" in LINES[k]:
report.setdefault(m.group(1), m.group(2).split(" (")[0].strip())
k += 1
dumps += 1
missing = [
n for n, v in (("ROM CFSR", cfsr), ("ROM PC", pc), ("reset", tj)) if v is None
]
missing += [k for k in ("Reason", "CFSR", "PC", "SP") if k not in report]
bad += bool(missing)
flag = f" | MISSING {', '.join(missing)}" if missing else ""
print(f"{kind:18} ROM CFSR {cfsr} PC {pc} | reset {reset} | report {report}{flag}")
i = j
if not dumps:
sys.exit("NO-DATA: no ROM fault dump in the capture")
sys.exit(1 if bad else 0)
@@ -1,40 +0,0 @@
# Any ESP32-C3 with GPIO10 free; drop ESPHome ledc outputs, the C3 shares one LEDC clock across timers.
esphome:
name: wake-probe-esp32c3
includes:
- wake_probe.hpp
on_boot:
priority: -100
then:
- lambda: wakeprobe::start();
esp32:
board: esp32-c3-devkitm-1
variant: esp32c3
framework:
type: esp-idf
advanced:
include_builtin_idf_components:
- esp_driver_ledc
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
power_management:
- platform: esp32_power_management
enable_light_sleep: true
profiling: true
interval:
- interval: 30s
then:
- lambda: wakeprobe::report();
@@ -1,110 +0,0 @@
#pragma once
// LEDC on an RC_FAST clock keeps toggling GPIO10 in light sleep; an ESP32InternalGPIOPin interrupt on the same pad
// timestamps every edge.
#include <cstdio>
#include "driver/ledc.h"
#include "driver/gpio.h"
#include "esp_pm.h"
#include "esp_timer.h"
#include "esphome/components/esp32/gpio.h"
#include "esphome/core/log.h"
namespace wakeprobe {
static const char *const TAG = "wakeprobe";
static constexpr gpio_num_t PIN = GPIO_NUM_10;
static volatile uint32_t edges = 0;
static volatile int64_t last_us = 0;
static volatile int64_t gap_min = INT64_MAX, gap_max = 0;
static volatile uint32_t late_edges = 0; // gap more than 20 ms off the half period
static int64_t start_us = 0;
static uint32_t half_period_us = 0;
static uint32_t freq = 0;
static esp_err_t err_timer = ESP_OK, err_channel = ESP_OK;
static void IRAM_ATTR on_edge(void *) {
int64_t now = esp_timer_get_time();
if (last_us != 0) {
int64_t gap = now - last_us;
if (gap < gap_min)
gap_min = gap;
if (gap > gap_max)
gap_max = gap;
int64_t off = gap - (int64_t) half_period_us;
if (off > 20000 || off < -20000)
late_edges = late_edges + 1;
}
last_us = now;
edges = edges + 1;
}
inline void start() {
auto *pin = new esphome::esp32::ESP32InternalGPIOPin(); // NOLINT
pin->set_pin(PIN);
pin->set_flags(esphome::gpio::FLAG_INPUT);
pin->setup();
pin->attach_interrupt(on_edge, (void *) nullptr, esphome::gpio::INTERRUPT_ANY_EDGE);
ledc_timer_config_t t{};
t.speed_mode = LEDC_LOW_SPEED_MODE;
t.duty_resolution = LEDC_TIMER_14_BIT;
t.timer_num = LEDC_TIMER_0;
t.freq_hz = 2;
t.clk_cfg = LEDC_USE_RC_FAST_CLK;
esp_err_t e1 = err_timer = ledc_timer_config(&t);
ledc_channel_config_t c{};
c.gpio_num = PIN;
c.speed_mode = LEDC_LOW_SPEED_MODE;
c.channel = LEDC_CHANNEL_0;
c.timer_sel = LEDC_TIMER_0;
c.duty = 1 << 13;
c.sleep_mode = LEDC_SLEEP_MODE_KEEP_ALIVE;
esp_err_t e2 = err_channel = ledc_channel_config(&c);
// ledc_channel_config makes the pad output only; turn its input back on so the interrupt sees the wave.
gpio_input_enable(PIN);
freq = ledc_get_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_0);
half_period_us = freq ? 500000 / freq : 0;
start_us = esp_timer_get_time();
ESP_LOGI(TAG, "LEDC timer=%s channel=%s freq=%u Hz half period %u us", esp_err_to_name(e1), esp_err_to_name(e2),
(unsigned) freq, (unsigned) half_period_us);
}
inline void report() {
if (start_us == 0)
return;
char lv[21];
for (int i = 0; i < 20; i++) {
lv[i] = gpio_get_level(PIN) ? '1' : '0';
vTaskDelay(pdMS_TO_TICKS(50));
}
lv[20] = 0;
// Elapsed time and edge count are read together, after the 1 s level loop, so they describe the same interval.
float secs = (esp_timer_get_time() - start_us) / 1e6f;
uint32_t seen = edges;
ESP_LOGI(TAG, "LEDC timer=%s channel=%s freq=%u, level over 1 s: %s", esp_err_to_name(err_timer),
esp_err_to_name(err_channel), (unsigned) freq, lv);
ESP_LOGI(TAG, "%.0f s: edges=%u expected=%.0f gap min=%lld max=%lld us, off by >20 ms: %u", secs, (unsigned) seen,
secs * 2 * freq, (long long) gap_min, (long long) gap_max, (unsigned) late_edges);
#ifdef CONFIG_PM_PROFILING
static char buf[1024];
FILE *f = fmemopen(buf, sizeof(buf) - 1, "w");
if (f == nullptr) {
ESP_LOGW(TAG, "pm dump failed: fmemopen");
return;
}
esp_pm_dump_locks(f);
long n = ftell(f);
fclose(f);
buf[n < 0 ? 0 : n] = 0;
bool sleep_seen = false;
for (char *line = strtok(buf, "\n"); line != nullptr; line = strtok(nullptr, "\n")) {
if (strstr(line, "SLEEP") || strstr(line, "CPU_MAX") || strstr(line, "APB_MAX") || strstr(line, "APB_MIN")) {
sleep_seen = sleep_seen || strstr(line, "SLEEP") != nullptr;
ESP_LOGI(TAG, "pm %s", line);
}
}
if (!sleep_seen)
ESP_LOGW(TAG, "pm dump has no SLEEP line (%ld bytes, buffer %u)", n, (unsigned) sizeof(buf));
#else
ESP_LOGW(TAG, "pm profiling unavailable: set power_management profiling: true");
#endif
}
} // namespace wakeprobe
@@ -1,36 +0,0 @@
# AP manual IP: the only network is missing, so the fallback AP starts after 10 s on 10.42.7.1.
substitutions:
name: mip-ap
plat: esp32
esphome:
name: ${name}
packages:
plat: !include plat-${plat}.yaml
logger:
level: VERBOSE
logs:
scheduler: DEBUG
component: DEBUG
wifi:
networks:
- ssid: mip-nowhere
password: nowhere123
manual_ip:
static_ip: 10.9.9.9
gateway: 10.9.9.1
subnet: 255.255.255.0
dns1: 10.9.9.1
ap:
ssid: mip-ap-test
password: mipaptest1
ap_timeout: 10s
manual_ip:
static_ip: 10.42.7.1
gateway: 10.42.7.1
subnet: 255.255.255.0
captive_portal:
@@ -1,36 +0,0 @@
# Joins the DUT's AP and fetches its page every 10 s.
substitutions:
name: mip-client
plat: esp32
esphome:
name: ${name}
packages:
plat: !include plat-${plat}.yaml
logger:
wifi:
ssid: mip-ap-test
password: mipaptest1
reboot_timeout: 0s
http_request:
verify_ssl: false
interval:
- interval: 10s
then:
- if:
condition: wifi.connected
then:
- http_request.get:
url: http://10.42.7.1/
capture_response: false
on_response:
then:
- lambda: ESP_LOGI("client", "GET http://10.42.7.1/ status=%d", response->status_code);
on_error:
then:
- logger.log: "GET http://10.42.7.1/ failed"
@@ -1,32 +0,0 @@
#pragma once
#include "esphome/core/log.h"
#include "lwip/dns.h"
#include "lwip/ip_addr.h"
#ifdef USE_ESP8266
#include <ESP8266WiFi.h>
#else
#include "lwip/netdb.h"
#include "lwip/inet.h"
#endif
inline void dns_probe() {
char b0[IPADDR_STRLEN_MAX], b1[IPADDR_STRLEN_MAX], res[16] = "-";
ipaddr_ntoa_r(dns_getserver(0), b0, sizeof(b0));
ipaddr_ntoa_r(dns_getserver(1), b1, sizeof(b1));
int ok;
#ifdef USE_ESP8266
IPAddress ip;
ok = WiFi.hostByName("pool.ntp.org", ip);
if (ok)
strncpy(res, ip.toString().c_str(), sizeof(res) - 1);
#else
struct addrinfo hints{};
hints.ai_family = AF_INET;
struct addrinfo *r = nullptr;
ok = lwip_getaddrinfo("pool.ntp.org", nullptr, &hints, &r) == 0 && r != nullptr;
if (ok) {
inet_ntoa_r(((struct sockaddr_in *) r->ai_addr)->sin_addr, res, sizeof(res));
lwip_freeaddrinfo(r);
}
#endif
ESP_LOGI("dnsprobe", "dns0=%s dns1=%s lookup=%s %s", b0, b1, ok ? "ok" : "FAIL", res);
}
@@ -1,4 +0,0 @@
esp32:
board: esp32dev
framework:
type: esp-idf
@@ -1,2 +0,0 @@
esp8266:
board: d1_mini
@@ -1,30 +0,0 @@
#!/bin/bash
# usage: run-ap.sh LABEL the DUT runs the fallback AP on 10.42.7.1, the other board joins it and fetches its page
# Copy this directory into a scratch directory and run it there; no secrets are needed.
# env: TREE (esphome checkout for the DUT), CLIENT_TREE (checkout for the client, default TREE), DUT_PORT, DUT_PLAT,
# CLIENT_PORT, CLIENT_PLAT (esp32 or esp8266), SC (path to serial_capture.py), PY (default $TREE/venv/bin/python)
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; CT=${CLIENT_TREE:-$T}
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; DP=${DUT_PORT:?set DUT_PORT}; CP=${CLIENT_PORT:?set CLIENT_PORT}
N=mip-ap-$L; CN=mip-client
up() { (cd "$1" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome -s name "$2" -s plat "$3" run --no-logs "$D/$4" \
--device "$5" > "$D/$2-upload.log" 2>&1) || { echo "FAIL: $2 build or upload"; exit 1; }; }
up "$T" "$N" "${DUT_PLAT:-esp32}" ap.yaml "$DP"
up "$CT" "$CN" "${CLIENT_PLAT:-esp8266}" client.yaml "$CP"
rm -f "$D/$N-serial.txt" "$D/$CN-$N-serial.txt"
$PY "$SC" "$DP" 120 "$D/$N-serial.txt" --reset > /dev/null 2>&1 & C1=$!
$PY "$SC" "$CP" 120 "$D/$CN-$N-serial.txt" --reset > /dev/null 2>&1 & C2=$!
wait $C1; R1=$?; wait $C2; R2=$?
strip() { LC_ALL=C sed 's/\x1b\[[0-9;]*m//g' | sort -u -k2; }
echo "--- DUT"; LC_ALL=C grep -a -E "Manual IP|Starting fallback|IP Address|Setting up AP|rst cause|Guru|abort|panic" \
"$D/$N-serial.txt" | strip | head -8
echo "--- client"; LC_ALL=C grep -a -E "IP Address:|Gateway:|Subnet:" "$D/$CN-$N-serial.txt" | strip | head -4
ok=$(grep -a -c 'status=200' "$D/$CN-$N-serial.txt"); bad=$(grep -a -c 'GET http://10.42.7.1/ failed' "$D/$CN-$N-serial.txt")
echo "client GET ok=$ok failed=$bad"
fail=0
[ "$R1" = 0 ] && [ -s "$D/$N-serial.txt" ] || { echo "FAIL: DUT capture failed or empty (rc $R1)"; fail=1; }
[ "$R2" = 0 ] && [ -s "$D/$CN-$N-serial.txt" ] || { echo "FAIL: client capture failed or empty (rc $R2)"; fail=1; }
grep -a -q -E "IP Address: +10\.42\.7\.1\b" "$D/$N-serial.txt" || { echo "FAIL: AP address is not 10.42.7.1"; fail=1; }
grep -a -q -E "IP Address: +10\.42\.7\." "$D/$CN-$N-serial.txt" || { echo "FAIL: client never joined"; fail=1; }
[ "$ok" -gt 0 ] || { echo "FAIL: no page fetched"; fail=1; }
grep -a -q -E "Guru|abort\(\)|panic|[eE]xception \(|Soft WDT reset|wdt reset" "$D/$N-serial.txt" && { echo "FAIL: DUT crash"; fail=1; }
[ $fail = 0 ] && echo PASS; exit $fail
@@ -1,24 +0,0 @@
#!/bin/bash
# usage: run-sta.sh LABEL flash sta.yaml, capture 75 s, ping the manual address, check the dump and the DNS probe
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password) and run it there.
# env: TREE (esphome checkout), PORT (board serial port), PLAT (esp32 or esp8266), IP (the board's own DHCP lease),
# GATEWAY, SUBNET, DNS1 (the network's values), SC (path to serial_capture.py), PY (default $TREE/venv/bin/python)
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
P=${PORT:?set PORT}; IP=${IP:?set IP}; SC=${SC:?set SC}; N=mip-sta-$L; C=$D/$N-serial.txt
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome -s name "$N" -s plat "${PLAT:-esp32}" -s ip "$IP" \
-s gateway "${GATEWAY:?set GATEWAY}" -s subnet "${SUBNET:?set SUBNET}" -s dns1 "${DNS1:?set DNS1}" \
run --no-logs "$D/sta.yaml" --device "$P" > "$D/$N-upload.log" 2>&1) || { echo "FAIL: build or upload"; exit 1; }
rm -f "$C"
$PY "$SC" "$P" 75 "$C" --reset > /dev/null 2>&1 & CAP=$!
# ping exits 0 when at least one reply came back, on macOS and Linux alike.
sleep 50; PING=$(ping -c 10 -i 0.3 "$IP" 2>&1); PING_RC=$?; wait $CAP; CAPRC=$?
LC_ALL=C grep -a -E "Manual IP|IP Address:|Gateway:|Subnet:|DNS1:|DNS2:|dnsprobe|rst cause|Guru|abort|panic" "$C" \
| LC_ALL=C sed 's/\x1b\[[0-9;]*m//g' | sort -u -k2 | head -14
echo "ping: $(echo "$PING" | grep -E 'transmitted')"
fail=0
[ "$CAPRC" = 0 ] && [ -s "$C" ] || { echo "FAIL: serial capture failed or empty (rc $CAPRC)"; fail=1; }
grep -a -q -E "dns0=$DNS1 dns1=9\.9\.9\.9 lookup=ok" "$C" || { echo "FAIL: DNS servers or lookup"; fail=1; }
grep -a -q -E "IP Address: +$IP\b" "$C" || { echo "FAIL: address is not $IP"; fail=1; }
[ $PING_RC = 0 ] || { echo "FAIL: no ping reply"; fail=1; }
grep -a -q -E "Guru|abort\(\)|panic|[eE]xception \(|Soft WDT reset|wdt reset" "$C" && { echo "FAIL: crash"; fail=1; }
[ $fail = 0 ] && echo PASS; exit $fail
@@ -1,62 +0,0 @@
# Station manual IP. The real network is the second table row and inherits the top level manual_ip; set ip to the
# board's own DHCP lease and dns2 to a server DHCP does not hand out, so the logs prove the manual values are used.
# run-sta.sh passes name, plat, ip, gateway, subnet and dns1 with -s; dns2 stays 9.9.9.9, which it checks for.
substitutions:
name: mip-sta
plat: esp32
ip: 192.168.1.50
gateway: 192.168.1.1
subnet: 255.255.255.0
dns1: 192.168.1.1
dns2: 9.9.9.9
esphome:
name: ${name}
includes:
- dnsprobe.hpp
packages:
plat: !include plat-${plat}.yaml
logger:
level: VERBOSE
logs:
scheduler: DEBUG
component: DEBUG
api.service: DEBUG
api.connection: DEBUG
api.socket: DEBUG
socket: DEBUG
mdns: DEBUG
wifi:
manual_ip:
static_ip: ${ip}
gateway: ${gateway}
subnet: ${subnet}
dns1: ${dns1}
dns2: ${dns2}
networks:
- ssid: mip-nowhere
password: nowhere123
manual_ip:
static_ip: 10.9.9.9
gateway: 10.9.9.1
subnet: 255.255.255.0
dns1: 10.9.9.1
- ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 20s
then:
- if:
condition: wifi.connected
then:
- lambda: dns_probe();
@@ -1,71 +0,0 @@
"""Print the date, time and datetime rows of /metrics, set new values over the API, print them again.
usage: datetime_metrics.py HOST env: API_KEY
Sets every date to 2040-03-04, every time to 23:59:59 and every datetime to 2040-03-04 05:06:07 UTC (past 2038 on
purpose). Values are printed with their UTC reading: dates are local midnight, times are ms since midnight and
datetimes are ms since the epoch.
"""
import asyncio
from datetime import UTC, datetime
import os
import re
import sys
import urllib.request
from aioesphomeapi import APIClient, DateInfo, DateTimeInfo, TimeInfo
HOST = sys.argv[1]
def metrics(tag: str) -> int:
with urllib.request.urlopen(f"http://{HOST}/metrics", timeout=10) as resp: # noqa: S310
body = resp.read().decode()
rows = [
line
for line in body.splitlines()
if re.match(r"esphome_(date|time|datetime)_", line)
]
print(f"--- {tag}: {len(rows)} rows")
parsed = 0
for line in rows:
m = re.match(r'(esphome_\w+)\{.*name="([^"]+)"\} (-?\d+)$', line)
if not m:
print(f" UNPARSED {line}")
continue
parsed += 1
metric, name, value = m.group(1), m.group(2), int(m.group(3))
utc = ""
if metric in ("esphome_date_value", "esphome_datetime_value"):
utc = " = " + datetime.fromtimestamp(value / 1000, UTC).isoformat()
print(f" {metric} {name} {value}{utc}")
return parsed
async def main() -> None:
if not metrics("initial"):
sys.exit(
"NO-DATA: no date, time or datetime rows (a build without them, or no such entities)"
)
c = APIClient(HOST, 6053, None, noise_psk=os.environ.get("API_KEY"))
await c.connect(login=True)
ents, _ = await c.list_entities_services()
sent = 0
for e in ents:
sent += isinstance(e, (DateInfo, TimeInfo, DateTimeInfo))
if isinstance(e, DateInfo):
c.date_command(e.key, 2040, 3, 4)
elif isinstance(e, TimeInfo):
c.time_command(e.key, 23, 59, 59)
elif isinstance(e, DateTimeInfo):
c.datetime_command(
e.key, int(datetime(2040, 3, 4, 5, 6, 7, tzinfo=UTC).timestamp())
)
await asyncio.sleep(2)
await c.disconnect()
if not sent:
sys.exit("NO-DATA: the device lists no date, time or datetime entities")
metrics("after setting 2040-03-04, 23:59:59 and 2040-03-04 05:06:07 UTC")
asyncio.run(main())
@@ -1,10 +0,0 @@
# Adds a device time zone (UTC-10, no DST) to prom-datetime.yaml; the zone applies without a time sync.
substitutions:
name: prom-datetime-tz
packages:
base: !include prom-datetime.yaml
time:
- platform: sntp
timezone: HST10
@@ -1,46 +0,0 @@
# D1 mini with three optimistic template datetime entities. -s name NAME; build prom-datetime-tz.yaml for a time zone.
substitutions:
name: prom-datetime
esphome:
name: ${name}
esp8266:
board: d1_mini
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
datetime:
- platform: template
name: Date
id: test_date
type: date
optimistic: true
initial_value: "2000-1-2"
- platform: template
name: Time
id: test_time
type: time
optimistic: true
initial_value: "12:34:56am"
- platform: template
name: DateTime
id: test_datetime
type: datetime
optimistic: true
initial_value: "2000-1-2 12:34:56"
prometheus:
@@ -1,6 +0,0 @@
captive_portal:
button:
- platform: template
name: Start portal
on_press:
- lambda: captive_portal::global_captive_portal->start();
@@ -1,4 +0,0 @@
esp32:
board: esp32dev
framework:
type: esp-idf
@@ -1,2 +0,0 @@
esp8266:
board: d1_mini
@@ -1,18 +0,0 @@
#pragma once
// A handler registered after setup(), the way an external component would; answers GET /probe.
#include "esphome/components/web_server_base/web_server_base.h"
class ProbeHandler : public AsyncWebHandler {
public:
bool canHandle(AsyncWebServerRequest *request) const override {
if (request->method() != HTTP_GET)
return false;
#ifdef USE_ESP32
char url_buf[AsyncWebServerRequest::URL_BUF_SIZE];
return request->url_to(url_buf) == "/probe";
#else
return request->url() == ESPHOME_F("/probe");
#endif
}
void handleRequest(AsyncWebServerRequest *request) override { request->send(200, "text/plain", "probe-ok"); }
};
inline ProbeHandler probe_handler;
@@ -1,2 +0,0 @@
captive_portal:
prometheus:
@@ -1,65 +0,0 @@
#!/bin/bash
# usage: run.sh LABEL VARIANT flash webbase.yaml, fetch /probe and /, then a web OTA of the same image
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password) and run it there.
# VARIANT is ws, cp or prom (see webbase.yaml). The cp variant presses "Start portal" over the API first.
# env: TREE (esphome checkout), PORT (board serial port), PLAT (esp32 or esp8266), IP (the board's address),
# SC (path to serial_capture.py), PY (default $TREE/venv/bin/python), SKILL_SCRIPTS (the skill's scripts directory)
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; V=${2:?variant}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
P=${PORT:?set PORT}; IP=${IP:?set IP}; SC=${SC:?set SC}; PL=${PLAT:-esp32}; N=web-$V-$PL-$L; C=$D/$N-serial.txt
# api_button.py and ota_timing/apiinfo.py live in the skill's scripts directory; set SKILL_SCRIPTS when this
# directory was copied elsewhere.
S=${SKILL_SCRIPTS:-$(cd "$D/.." && pwd)}
[ -f "$S/api_button.py" ] && [ -f "$S/ota_timing/apiinfo.py" ] || { echo "FAIL: set SKILL_SCRIPTS to the skill's scripts directory"; exit 1; }
if [ "$PL" = esp32 ]; then BIN=$D/.esphome/build/$N/build/$N.bin; else BIN=$D/.esphome/build/$N/.pioenvs/$N/firmware.bin; fi
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome -s name "$N" -s plat "$PL" -s variant "$V" \
run --no-logs "$D/webbase.yaml" --device "$P" > "$D/$N-upload.log" 2>&1) || { echo "FAIL: build or upload"; exit 1; }
rm -f "$C"
# Longer than the worst case below: 80 s to come up, 120 s of OTA, 80 s to come back.
$PY "$SC" "$P" 300 "$C" --reset > /dev/null 2>&1 & CAP=$!
# Wait on the API port: in the cp variant the HTTP server only starts when the portal does, also after the OTA reboot.
up() {
for _ in $(seq 1 40); do
$PY -c 'import socket, sys; socket.create_connection((sys.argv[1], 6053), 1).close()' "$IP" 2> /dev/null && return 0
sleep 2
done
return 1
}
up || { echo "FAIL: board not reachable"; kill $CAP; exit 1; }; sleep 8
fail=0
if [ "$V" = cp ]; then
$PY "$S/api_button.py" "$IP" - press "Start portal" || { echo "FAIL: Start portal press"; fail=1; }
sleep 2
# The captive portal only answers /config.json while it is active.
CFG=$(curl -s -m 10 -o /dev/null -w '%{http_code}' "http://$IP/config.json")
echo "GET /config.json: $CFG"; [ "$CFG" = 200 ] || { echo "FAIL: captive portal not active"; fail=1; }
fi
PROBE=$(curl -s -m 10 -w ' %{http_code}' "http://$IP/probe")
echo "GET /probe: $PROBE"
ROOT=$(curl -s -m 10 -o /dev/null -w '%{http_code}' "http://$IP/")
echo "GET /: $ROOT"
# prometheus has no page at / (no answer on ESP32, 404 on ESP8266, on dev too)
[ "$V" = prom ] || [ "$ROOT" = 200 ] || { echo "FAIL: GET /"; fail=1; }
if [ "$V" = prom ]; then
MET=$(curl -s -m 10 -o /dev/null -w '%{http_code}' "http://$IP/metrics")
echo "GET /metrics: $MET"; [ "$MET" = 200 ] || { echo "FAIL: /metrics"; fail=1; }
fi
# A failed update also answers 200, with "Update Failed!" in the body.
OTA=$(curl -s -m 120 -F "update=@$BIN" -w ' %{http_code}' "http://$IP/update" | tr -d '\r\n')
echo "POST /update ($(wc -c < "$BIN" | tr -d ' ') B): $OTA"
sleep 5
if up; then
sleep 6
$PY "$S/ota_timing/apiinfo.py" "$IP=$D/.esphome/build/$N/build_info.json" 2>&1 | tail -1; API=${PIPESTATUS[0]}
else
echo "FAIL: board not back after the web OTA"; fail=1; API=1
fi
wait $CAP; CAPRC=$?
[ "$CAPRC" = 0 ] && [ -s "$C" ] || { echo "FAIL: serial capture failed or empty (rc $CAPRC)"; fail=1; }
[ "$PROBE" = "probe-ok 200" ] || { echo "FAIL: /probe"; fail=1; }
case "$OTA" in *"Update Successful!"*" 200") ;; *) echo "FAIL: web OTA"; fail=1;; esac
[ "$API" = 0 ] || { echo "FAIL: board not on the uploaded build after the web OTA"; fail=1; }
# The image is the same build, so prove the reboot from the log: setup finishes again after the OTA reboot line.
grep -a -q "Performing OTA reboot now" "$C" || { echo "FAIL: no OTA reboot in the serial log"; fail=1; }
sed -n '/Performing OTA reboot now/,$p' "$C" | grep -a -q 'setup() finished' || { echo "FAIL: no boot after the OTA"; fail=1; }
grep -a -q -E "Guru|abort\(\)|panic|[eE]xception \(|Soft WDT reset|wdt reset" "$C" && { echo "FAIL: crash"; fail=1; }
[ $fail = 0 ] && echo PASS; exit $fail
@@ -1,36 +0,0 @@
# variant: ws (web_server), cp (captive_portal only, started from a button) or prom (captive_portal and prometheus).
# A GET /probe handler is added after setup, the way an external component would.
substitutions:
name: web-handlers
plat: esp32
variant: ws
esphome:
name: ${name}
includes:
- probehandler.hpp
on_boot:
priority: -100
then:
- lambda: |-
web_server_base::global_web_server_base->add_handler(&probe_handler);
ESP_LOGI("probe", "probe handler added after setup");
packages:
plat: !include plat-${plat}.yaml
variant: !include ${variant}.yaml
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
password: webtest123
api:
reboot_timeout: 0s
ota:
- platform: esphome
- platform: web_server
@@ -1,2 +0,0 @@
web_server:
port: 80
+18 -11
View File
@@ -4,11 +4,12 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. LibreTiny 1.14 or newer is required: earlier releases
have no BLE bonding partition, so the Beken SDK erases the bootloader flash sector
at boot on BK7238 (esphome#18646, libretiny-eu/libretiny#408). Known non-5.x
families are rejected in to_code. Unknown families are capability-checked at compile time via
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
compiled in, the Beken SDK erases the bootloader flash sector at boot because
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
to_code. Unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
@@ -28,6 +29,7 @@ from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
@@ -52,9 +54,7 @@ BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
# bootloader with BLE compiled in; the libretiny platform builds with this one
MIN_BDK_VERSION = (3, 0, 78)
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414), and
# have no BLE bond partition, so the BK7238 bond store erases flash sector 0 and
# bricks the chip (libretiny-eu/libretiny#408)
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414)
MIN_LIBRETINY_VERSION = (1, 14, 0)
_LOGGER = logging.getLogger(__name__)
@@ -86,6 +86,14 @@ def _unsupported_family_message(family: str) -> str | None:
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
if family == FAMILY_BK7238:
return (
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
"erases the bootloader flash sector at boot and the device can no longer "
"start (see https://github.com/esphome/esphome/issues/18646); support "
"returns once the LibreTiny partition table fix "
"(libretiny-eu/libretiny#408) is released"
)
return None
@@ -109,15 +117,14 @@ def check_bdk_version(value: str | None) -> None:
def check_libretiny_version(version: cv.Version) -> None:
"""Reject a LibreTiny release older than the one whose WiFi sleep and bond storage are safe with BLE."""
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
return
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
raise cv.Invalid(
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
"arm the Beken MCU sleep with wifi power save and the device halts once the "
"station stops while BLE runs, and on BK7238 the bond store erases flash "
"sector 0 and bricks the chip"
"station stops while BLE runs"
)
+6 -1
View File
@@ -102,6 +102,8 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = OUT_PIN_SENSOR + 3 + HEADER_FOO
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
void LD2410Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -423,7 +425,10 @@ void LD2410Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+6 -1
View File
@@ -120,6 +120,8 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = LIGHT_SENSOR + 3 + HEADER_FOOTE
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
void LD2412Component::dump_config() {
@@ -475,7 +477,10 @@ void LD2412Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+20 -42
View File
@@ -79,6 +79,8 @@ static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC};
// 3 zones * 8 + footer (4).
static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40;
static constexpr uint8_t PERIODIC_FRAME_SIZE = 30; // header, three targets of 8 bytes, footer
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; };
@@ -190,11 +192,6 @@ void LD2450Component::dump_config() {
for (text_sensor::TextSensor *s : this->direction_text_sensors_) {
LOG_TEXT_SENSOR(" ", "Direction", s);
}
#ifdef USE_LD2450_TARGET_XY
for (text_sensor::TextSensor *s : this->target_xy_text_sensors_) {
LOG_TEXT_SENSOR(" ", "TargetXY", s);
}
#endif
#endif
#ifdef USE_NUMBER
ESP_LOGCONFIG(TAG, "Numbers:");
@@ -298,6 +295,9 @@ bool LD2450Component::get_timeout_status_(uint32_t check_millis) {
if (check_millis == 0) {
return true;
}
if (this->timeout_ == 0) {
this->timeout_ = ld2450::convert_seconds_to_ms(DEFAULT_PRESENCE_TIMEOUT);
}
return App.get_loop_component_start_time() - check_millis >= this->timeout_;
}
@@ -468,16 +468,9 @@ void LD2450Component::handle_periodic_data_() {
// Store target info for zone target count. Zero out untracked targets (td==0)
// so stale coordinates don't produce ghost counts in count_targets_in_zone_().
#ifdef USE_LD2450_TARGET_XY
const Target previous = this->target_info_[index];
#endif
this->target_info_[index].x = (td > 0) ? tx : 0;
this->target_info_[index].y = (td > 0) ? ty : 0;
this->target_info_[index].is_moving = (td > 0) && is_moving;
#ifdef USE_LD2450_TARGET_XY
this->update_target_xy_(index,
previous.x != this->target_info_[index].x || previous.y != this->target_info_[index].y);
#endif
} // End loop thru targets
@@ -628,7 +621,10 @@ void LD2450Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
@@ -866,27 +862,6 @@ void LD2450Component::set_direction_text_sensor(uint8_t target, text_sensor::Tex
this->direction_text_sensors_[target] = s;
}
#endif
#ifdef USE_LD2450_TARGET_XY
void LD2450Component::set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s) {
this->target_xy_text_sensors_[target] = s;
}
// Publish the position of a target in one radar frame; the sensor's filters throttle it
void LD2450Component::update_target_xy_(uint8_t target, bool changed) {
text_sensor::TextSensor *s = this->target_xy_text_sensors_[target];
if (s == nullptr || (!changed && s->has_state()))
return;
const Target &pos = this->target_info_[target];
if (pos.x == 0 && pos.y == 0) {
s->publish_state(DirectionStrings::get_progmem_str(DIRECTION_NA, DirectionStrings::LAST_INDEX));
return;
}
// Longest is "-32767,-32767"
char text[16];
const size_t len = buf_append_printf(text, sizeof(text), 0, "%d,%d", pos.x, pos.y);
s->publish_state(text, len);
}
#endif
// Send Zone coordinates data to LD2450
#ifdef USE_NUMBER
@@ -925,14 +900,17 @@ void LD2450Component::set_zone_numbers(uint8_t zone, number::Number *x1, number:
// Set Presence Timeout load and save from flash
#ifdef USE_NUMBER
void LD2450Component::set_presence_timeout() {
if (this->presence_timeout_number_ == nullptr)
return;
// Restore only while the number has no state yet: 0 is a valid presence_timeout (no hold)
if (!this->presence_timeout_number_->has_state())
this->presence_timeout_number_->publish_state(this->restore_from_flash_());
const float timeout = this->presence_timeout_number_->state;
this->save_to_flash_(timeout);
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
if (this->presence_timeout_number_ != nullptr) {
if (this->presence_timeout_number_->state == 0) {
float timeout = this->restore_from_flash_();
this->presence_timeout_number_->publish_state(timeout);
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
}
if (this->presence_timeout_number_->has_state()) {
this->save_to_flash_(this->presence_timeout_number_->state);
this->timeout_ = ld2450::convert_seconds_to_ms(this->presence_timeout_number_->state);
}
}
}
// Save Presence Timeout to flash
-10
View File
@@ -146,9 +146,6 @@ class LD2450Component : public Component, public uart::UARTDevice {
#ifdef USE_TEXT_SENSOR
void set_direction_text_sensor(uint8_t target, text_sensor::TextSensor *s);
#endif
#ifdef USE_LD2450_TARGET_XY
void set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s);
#endif
#ifdef USE_NUMBER
void set_zone_coordinate(number::Number *n);
void set_zone_numbers(uint8_t zone, number::Number *x1, number::Number *y1, number::Number *x2, number::Number *y2);
@@ -192,9 +189,6 @@ class LD2450Component : public Component, public uart::UARTDevice {
void save_to_flash_(float value);
float restore_from_flash_();
bool get_timeout_status_(uint32_t check_millis);
#ifdef USE_LD2450_TARGET_XY
void update_target_xy_(uint8_t target, bool changed);
#endif
void count_targets_in_zone_(const Zone &zone, uint8_t &still, uint8_t &moving);
uint32_t presence_millis_ = 0;
@@ -232,10 +226,6 @@ class LD2450Component : public Component, public uart::UARTDevice {
#ifdef USE_TEXT_SENSOR
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
#endif
#ifdef USE_LD2450_TARGET_XY
// Publishes a target's position as "x,y" text, so both values always come from the same radar frame
std::array<text_sensor::TextSensor *, MAX_TARGETS> target_xy_text_sensors_{};
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
StaticVector<PolygonZone *, LD2450_POLYGON_ZONE_COUNT> polygon_zones_;
@@ -40,8 +40,6 @@ void PolygonZone::control(const std::string &value) {
ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", LOG_STR_ARG(this->get_log_name()));
} else {
this->polygon_ = polygon;
// A user-made edit is written to flash now, as wall-powered sensors lose power without warning
global_preferences->sync();
// A target seen inside the previous polygon must not keep the new one on
this->last_inside_ms_ = 0;
if (this->polygon_.empty())
+3 -16
View File
@@ -18,10 +18,6 @@ from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401
DEPENDENCIES = ["ld2450"]
CONF_XY = "xy"
ICON_CROSSHAIRS_GPS = "mdi:crosshairs-gps"
MAX_TARGETS = 3
CONFIG_SCHEMA = cv.Schema(
@@ -47,11 +43,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
entity_category=ENTITY_CATEGORY_NONE,
icon=ICON_SIGN_DIRECTION,
),
cv.Optional(CONF_XY): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_NONE,
icon=ICON_CROSSHAIRS_GPS,
filters=text_sensor.timeout_throttle_filters("NA"),
),
}
)
for n in range(MAX_TARGETS)
@@ -65,12 +56,8 @@ async def to_code(config: ConfigType) -> None:
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor)
for n in range(MAX_TARGETS):
if not (target_conf := config.get(f"target_{n + 1}")):
continue
if direction_config := target_conf.get(CONF_DIRECTION):
if (direction_conf := config.get(f"target_{n + 1}")) and (
direction_config := direction_conf.get(CONF_DIRECTION)
):
sens = await text_sensor.new_text_sensor(direction_config)
cg.add(hub.set_direction_text_sensor(n, sens))
if xy_config := target_conf.get(CONF_XY):
cg.add_define("USE_LD2450_TARGET_XY")
sens = await text_sensor.new_text_sensor(xy_config)
cg.add(hub.set_target_xy_text_sensor(n, sens))
+1 -24
View File
@@ -41,15 +41,7 @@ template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T
// Formats the MAC address into the buffer, or "unknown" when it is not valid
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
// mac_address_is_valid() rejects the multicast bit, which radar Bluetooth MACs may have set; only all-00 and all-FF
// mean no address
uint8_t any_set = 0x00;
uint8_t all_set = 0xFF;
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
any_set |= mac_address[i];
all_set &= mac_address[i];
}
if (any_set != 0x00 && all_set != 0xFF) {
if (mac_address_is_valid(mac_address)) {
format_mac_addr_upper(mac_address, buffer.data());
} else {
ESPHOME_strncpy_P(buffer.data(), ESPHOME_PSTR("unknown"), buffer.size());
@@ -57,21 +49,6 @@ inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MA
return buffer.data();
}
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[MAC_ADDRESS_SIZE] PROGMEM = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
// Stores the radar's reported MAC, or clears it when the radar reports Bluetooth off; returns whether Bluetooth is on
inline bool store_mac(const uint8_t *reported, uint8_t *mac_address) {
uint8_t no_mac[MAC_ADDRESS_SIZE];
progmem_memcpy(no_mac, NO_MAC, MAC_ADDRESS_SIZE);
if (std::memcmp(reported, no_mac, MAC_ADDRESS_SIZE) == 0) {
std::memset(mac_address, 0, MAC_ADDRESS_SIZE);
return false;
}
std::memcpy(mac_address, reported, MAC_ADDRESS_SIZE);
return true;
}
// Helper function to format firmware version with stack allocation
// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety)
inline void format_version_str(const uint8_t *version, std::span<char, 20> buffer) {
+3 -2
View File
@@ -44,6 +44,7 @@ from .const import (
FAMILY_BK7238,
FAMILY_COMPONENT,
FAMILY_FRIENDLY,
FAMILY_LN882H,
FAMILY_RTL8710B,
FAMILY_RTL8720C,
KEY_BOARD,
@@ -60,8 +61,8 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
# Fault handlers the crash handler wraps (BK7238, BK7231N)
BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt")
# Families with a crash handler; the RTL8720C installs it at run time (no wraps).
CRASH_HANDLER_FAMILIES = (FAMILY_BK7231N, FAMILY_BK7238, FAMILY_RTL8720C)
# Families with a crash handler; the RTL8720C and LN882H install it at run time (no wraps).
CRASH_HANDLER_FAMILIES = (FAMILY_BK7231N, FAMILY_BK7238, FAMILY_LN882H, FAMILY_RTL8720C)
CODEOWNERS = ["@kuba2k2"]
AUTO_LOAD = ["preferences"]
+68 -31
View File
@@ -3,11 +3,12 @@
#include "esphome/core/defines.h"
#ifdef USE_LIBRETINY_CRASH_HANDLER
#if !defined(USE_BK72XX) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)
#include "crash_handler.h"
#if !defined(USE_BK72XX) && !defined(USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH)
#error "The LibreTiny crash handler has no fault capture for this chip family"
#endif
#include "crash_handler.h"
#include "core.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -58,6 +59,18 @@ extern char __ram_code_text_end__[];
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_LIBRETINY_VARIANT_RTL8720C
#ifdef USE_LN882X
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
// Bounds of the code in flash and of the IRAM_ATTR code (linker symbols; the latter from patch_linker.py).
extern char _stext[];
extern char _etext[];
extern char __esphome_sram_text_start[];
extern char __esphome_sram_text_end[];
}
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_LN882X
namespace esphome::libretiny {
ESPHOME_LOG_TAG(TAG, "libretiny.crash");
@@ -69,7 +82,7 @@ static constexpr uint32_t CRASH_MAGIC_V2 = 0xDEAD0002;
static constexpr uint32_t CRASH_REASON_UNDEFINED = 1;
static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2;
static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3;
// RTL8720C: the Cortex-M exception number (3 to 7) plus this offset.
// RTL8720C and LN882H: the Cortex-M exception number (3 to 7) plus this offset.
static constexpr uint32_t CRASH_REASON_CORTEX_M_OFFSET = 1;
PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort",
"Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault");
@@ -92,6 +105,9 @@ struct CrashRecord {
#ifdef USE_BK72XX
// Kept in ITCM, see patch_noinit.py.script.
#define CRASH_RECORD_SECTION ".noinit"
#elif defined(USE_LN882X)
// ln882hki_bsp.template.ld puts .noinit in RETENTION RAM, which startup does not clear.
#define CRASH_RECORD_SECTION ".noinit"
#else
// rtl8710c_ram.ld keeps .ram.bss.noinit* out of the startup zeroing.
#define CRASH_RECORD_SECTION ".ram.bss.noinit.esphome_crash"
@@ -146,6 +162,9 @@ static inline bool is_code_addr(uint32_t val) {
#ifdef USE_BK72XX
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
#elif defined(USE_LN882X)
return (addr >= (uint32_t) _stext && addr < (uint32_t) _etext) ||
(addr >= (uint32_t) __esphome_sram_text_start && addr < (uint32_t) __esphome_sram_text_end);
#else
return (addr >= (uint32_t) __xip_code_text_start__ && addr < (uint32_t) __xip_code_text_end__) ||
(addr >= (uint32_t) __ram_entry_text_start__ && addr < (uint32_t) __ram_code_text_end__);
@@ -257,19 +276,29 @@ extern "C" void __wrap_bk_trap_dabt(arm_registers *regs) {
#endif // USE_BK72XX
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH
// The SDK points the fault vectors (3 HardFault to 7 SecureFault) of its RAM vector table (VTOR) at a
// ROM handler that prints a register dump and then spins. crash_handler_install() puts
// esphome_fault_entry() in front of it, as the SDK's own CONFIG_MIIO patch (app_start.c) does.
// The SDK's RAM vector table (VTOR) points the fault vectors at a handler that prints a register dump and then
// spins: a ROM handler on the RTL8720C, CmBacktrace (hal_interrupt.c) on the LN882H. crash_handler_install()
// puts esphome_fault_entry() in front of it, as the RTL8720C SDK's own CONFIG_MIIO patch (app_start.c) does.
static_assert(LT_HW_WATCHDOG, "the crash reboot relies on the hardware watchdog");
// The ROM dump takes about 0.4 s at 115200 baud; without the re-arm the reset waits for the 10 s
// The RTL8720C ROM dump takes about 0.4 s at 115200 baud; without the re-arm the reset waits for the 10 s
// watchdog from arch_init().
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 1000;
static constexpr uint32_t FIRST_FAULT_VECTOR = 3;
static constexpr uint32_t FAULT_VECTOR_COUNT = 5;
static constexpr uint32_t FIRST_FAULT_VECTOR = 3; // HardFault, then MemManage, BusFault, UsageFault
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
static constexpr uint32_t FAULT_VECTOR_COUNT = 5; // and SecureFault (ARMv8-M)
// DTCM, where the app's stacks and heap live (rtl8710c_ram.ld).
static constexpr uint32_t SRAM_START = 0x10000000;
static constexpr uint32_t SRAM_END = 0x1003FA00;
#else
static constexpr uint32_t FAULT_VECTOR_COUNT = 4;
// RAM0, where the stacks and heap live (ln882hki_bsp.template.ld).
static constexpr uint32_t SRAM_START = 0x20000000;
static constexpr uint32_t SRAM_END = 0x20049C00;
#endif
// Architectural System Control Block registers (ARMv8-M).
// Architectural System Control Block registers (ARMv7-M and ARMv8-M).
static constexpr uintptr_t SCB_VTOR = 0xE000ED08;
static constexpr uintptr_t SCB_CFSR = 0xE000ED28;
static constexpr uintptr_t SCB_MMFAR = 0xE000ED34;
@@ -277,26 +306,34 @@ static constexpr uintptr_t SCB_BFAR = 0xE000ED38;
static constexpr uint32_t CFSR_MMARVALID = 1u << 7;
static constexpr uint32_t CFSR_BFARVALID = 1u << 15;
// DTCM, where the app's stacks and heap live (rtl8710c_ram.ld).
static constexpr uint32_t SRAM_START = 0x10000000;
static constexpr uint32_t SRAM_END = 0x1003FA00;
// Exception frame: r0-r3, r12, lr, pc, xPSR (the RTL8720C has no FPU, so never the extended frame).
// Exception frame: r0-r3, r12, lr, pc, xPSR, then the FPU registers when EXC_RETURN says so (LN882H only).
static constexpr uint32_t FRAME_LR = 5;
static constexpr uint32_t FRAME_PC = 6;
static constexpr uint32_t FRAME_XPSR = 7;
static constexpr uint32_t FRAME_WORDS = 8;
static constexpr uint32_t EXTENDED_FRAME_WORDS = 26;
static constexpr uint32_t EXC_RETURN_STANDARD_FRAME = 1u << 4;
static constexpr uint32_t XPSR_STACK_ALIGNED = 1u << 9;
// The ROM handlers fault_entry() continues into, by vector.
static uint32_t s_rom_fault_vectors[FAULT_VECTOR_COUNT]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// The SDK handlers fault_entry() continues into, by vector.
static uint32_t s_sdk_fault_vectors[FAULT_VECTOR_COUNT]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_fault_recorded = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static inline uint32_t read_reg(uintptr_t addr) {
return *(const volatile uint32_t *) addr; // NOLINT(performance-no-int-to-ptr)
}
// Returns the ROM handler to continue into. pc, lr and sp stay 0 when the frame is outside DTCM.
extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *frame, uint32_t vector) {
// Returns the SDK handler to continue into. pc, lr and sp stay 0 when the frame is outside SRAM.
extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *frame, uint32_t vector,
uint32_t exc_return) {
// A later fault, in the recording below or in the SDK handler (CmBacktrace on the LN882H walks past the end
// of RAM), keeps the first record and waits for the watchdog instead of starting the dump again.
if (s_fault_recorded) {
while (true) {
}
}
s_fault_recorded = true;
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
uint32_t cfsr = read_reg(SCB_CFSR);
uint32_t address = 0;
if (cfsr & CFSR_BFARVALID) {
@@ -309,25 +346,24 @@ extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *f
s_record.fault_address = address;
auto frame_addr = (uint32_t) frame;
uint32_t frame_words = (exc_return & EXC_RETURN_STANDARD_FRAME) ? FRAME_WORDS : EXTENDED_FRAME_WORDS;
uint32_t sp = 0;
if (frame_addr >= SRAM_START && frame_addr <= SRAM_END - FRAME_WORDS * 4 && (frame_addr & 3) == 0) {
if (frame_addr >= SRAM_START && frame_addr <= SRAM_END - frame_words * 4 && (frame_addr & 3) == 0) {
s_record.pc = frame[FRAME_PC];
s_record.lr = frame[FRAME_LR];
sp = frame_addr + FRAME_WORDS * 4 + ((frame[FRAME_XPSR] & XPSR_STACK_ALIGNED) ? 4 : 0);
sp = frame_addr + frame_words * 4 + ((frame[FRAME_XPSR] & XPSR_STACK_ALIGNED) ? 4 : 0);
} else {
s_record.pc = 0;
s_record.lr = 0;
}
s_record.sp = sp;
finish_record(sp, SRAM_START, SRAM_END);
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
return s_rom_fault_vectors[vector - FIRST_FAULT_VECTOR];
return s_sdk_fault_vectors[vector - FIRST_FAULT_VECTOR];
}
// Fault vector: calls esphome_record_fault(frame, exception number), then jumps to the ROM handler it returns
// with r0-r3 and lr restored. Asm because C would push onto MSP before the frame is found, and the ROM handler
// reads EXC_RETURN from lr.
// Fault vector: calls esphome_record_fault(frame, exception number, EXC_RETURN), then jumps to the SDK handler it
// returns with r0-r3 and lr restored. Asm because C would push onto MSP before the frame is found, and the SDK
// handler reads EXC_RETURN from lr.
extern "C" __attribute__((naked, used)) void esphome_fault_entry() {
__asm volatile("push {r0-r3, r4, lr} \n" // r4 only keeps the stack 8-byte aligned
"tst lr, #4 \n"
@@ -338,8 +374,9 @@ extern "C" __attribute__((naked, used)) void esphome_fault_entry() {
"addeq r0, r0, #24 \n" // MSP: skip the six words pushed above
"mrs r1, ipsr \n"
"ubfx r1, r1, #0, #9 \n" // exception number, 3 to 7
"mov r2, lr \n"
"bl esphome_record_fault \n"
"mov r12, r0 \n" // ROM handler address
"mov r12, r0 \n" // SDK handler address
"pop {r0-r3, r4, lr} \n"
"bx r12 \n");
}
@@ -347,12 +384,12 @@ extern "C" __attribute__((naked, used)) void esphome_fault_entry() {
void crash_handler_install() {
auto *vectors = (uint32_t *) read_reg(SCB_VTOR); // NOLINT(performance-no-int-to-ptr)
for (uint32_t i = 0; i < FAULT_VECTOR_COUNT; i++) {
s_rom_fault_vectors[i] = vectors[FIRST_FAULT_VECTOR + i];
s_sdk_fault_vectors[i] = vectors[FIRST_FAULT_VECTOR + i];
vectors[FIRST_FAULT_VECTOR + i] = (uint32_t) &esphome_fault_entry;
}
}
#endif // USE_LIBRETINY_VARIANT_RTL8720C
#endif // USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH
} // namespace esphome::libretiny
+6 -1
View File
@@ -6,6 +6,11 @@
#ifdef USE_LIBRETINY_CRASH_HANDLER
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(USE_LN882X)
// Cortex-M families: the recorder goes in front of the SDK's fault handlers through the RAM vector table.
#define USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH
#endif
namespace esphome::libretiny {
/// Log crash data if a crash was detected on previous boot.
@@ -14,7 +19,7 @@ void crash_handler_log();
/// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data();
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH
/// Put the crash recorder in front of the SDK's fault handlers.
void crash_handler_install();
#endif
@@ -156,6 +156,7 @@ COMPONENT_CODEOWNERS = {
# Extra imports per component; the CLI looks up process_stacktrace on the platform package.
COMPONENT_EXTRA_IMPORTS = {
"bk72xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
"ln882x": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
"rtl87xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
}
+1 -1
View File
@@ -28,7 +28,7 @@ namespace esphome {
void arch_init() {
libretiny::setup_preferences();
lt_wdt_enable(10000L);
#if defined(USE_LIBRETINY_CRASH_HANDLER) && defined(USE_LIBRETINY_VARIANT_RTL8720C)
#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH
libretiny::crash_handler_install();
#endif
#ifdef USE_BK72XX
+1
View File
@@ -29,6 +29,7 @@ from esphome.components.libretiny.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
from esphome.types import ConfigType
from .boards import LN882X_BOARD_PINS, LN882X_BOARDS
@@ -1,5 +1,4 @@
#include "prometheus_handler.h"
#include <cinttypes>
#if defined(USE_NETWORK) && !defined(USE_HOST)
#include "esphome/core/application.h"
@@ -101,24 +100,6 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) {
this->valve_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_DATE
this->date_type_(stream);
for (auto *obj : App.get_dates())
this->date_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_TIME
this->time_type_(stream);
for (auto *obj : App.get_times())
this->time_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_DATETIME
this->datetime_type_(stream);
for (auto *obj : App.get_datetimes())
this->datetime_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_CLIMATE
this->climate_type_(stream);
for (auto *obj : App.get_climates())
@@ -1115,78 +1096,6 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
}
#endif
#if defined(USE_DATETIME_DATE) || defined(USE_DATETIME_DATETIME) || defined(USE_DATETIME_TIME)
void PrometheusHandler::date_time_rows_(AsyncResponseStream *stream, ProgmemStr failed_metric, ProgmemStr value_metric,
EntityBase *obj, int64_t value_ms, std::string &area, std::string &node,
std::string &friendly_name) {
if (obj->is_internal() && !this->include_internal_)
return;
this->print_metric_labels_(stream, failed_metric, obj, area, node, friendly_name);
if (!obj->has_state()) {
stream->print(ESPHOME_F("\"} 1\n"));
return;
}
stream->print(ESPHOME_F("\"} 0\n"));
this->print_metric_labels_(stream, value_metric, obj, area, node, friendly_name);
// Date and datetime are ms since the epoch (what Grafana expects), time is ms since midnight
char buf[32];
snprintf(buf, sizeof(buf), "\"} %" PRId64 "\n", value_ms);
stream->print(buf);
}
#endif
#ifdef USE_DATETIME_DATE
void PrometheusHandler::date_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_date_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_date_failed gauge\n"));
}
void PrometheusHandler::date_row_(AsyncResponseStream *stream, datetime::DateEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state()) {
// DateEntity::state_as_esptime() leaves the time fields unset, so build midnight here
ESPTime t{};
t.year = obj->year;
t.month = obj->month;
t.day_of_month = obj->day;
t.recalc_timestamp_local();
value_ms = static_cast<int64_t>(t.timestamp) * 1000;
}
this->date_time_rows_(stream, ESPHOME_F("esphome_date_failed"), ESPHOME_F("esphome_date_value"), obj, value_ms, area,
node, friendly_name);
}
#endif
#ifdef USE_DATETIME_TIME
void PrometheusHandler::time_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_time_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_time_failed gauge\n"));
}
void PrometheusHandler::time_row_(AsyncResponseStream *stream, datetime::TimeEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state())
value_ms = (static_cast<int64_t>(obj->hour) * 3600 + obj->minute * 60 + obj->second) * 1000;
this->date_time_rows_(stream, ESPHOME_F("esphome_time_failed"), ESPHOME_F("esphome_time_value"), obj, value_ms, area,
node, friendly_name);
}
#endif
#ifdef USE_DATETIME_DATETIME
void PrometheusHandler::datetime_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_datetime_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_datetime_failed gauge\n"));
}
void PrometheusHandler::datetime_row_(AsyncResponseStream *stream, datetime::DateTimeEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state())
value_ms = static_cast<int64_t>(obj->state_as_esptime().timestamp) * 1000;
this->date_time_rows_(stream, ESPHOME_F("esphome_datetime_failed"), ESPHOME_F("esphome_datetime_value"), obj,
value_ms, area, node, friendly_name);
}
#endif
} // namespace esphome::prometheus
#endif
@@ -193,36 +193,6 @@ class PrometheusHandler final : public AsyncWebHandler, public Component {
std::string &friendly_name);
#endif
#if defined(USE_DATETIME_DATE) || defined(USE_DATETIME_DATETIME) || defined(USE_DATETIME_TIME)
/// Print the failed row and, when the entity has a state, the value row in milliseconds
void date_time_rows_(AsyncResponseStream *stream, ProgmemStr failed_metric, ProgmemStr value_metric, EntityBase *obj,
int64_t value_ms, std::string &area, std::string &node, std::string &friendly_name);
#endif
#ifdef USE_DATETIME_DATE
/// Return the type for prometheus
void date_type_(AsyncResponseStream *stream);
/// Return the date state as prometheus data point
void date_row_(AsyncResponseStream *stream, datetime::DateEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_DATETIME_TIME
/// Return the type for prometheus
void time_type_(AsyncResponseStream *stream);
/// Return the time state as prometheus data point
void time_row_(AsyncResponseStream *stream, datetime::TimeEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_DATETIME_DATETIME
/// Return the type for prometheus
void datetime_type_(AsyncResponseStream *stream);
/// Return the datetime state as prometheus data point
void datetime_row_(AsyncResponseStream *stream, datetime::DateTimeEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_CLIMATE
/// Return the type for prometheus
void climate_type_(AsyncResponseStream *stream);
@@ -8,6 +8,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -94,10 +96,21 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image)
: ImageDecoder(image, JPEG), jpeg_(RAMAllocator<JPEGDEC>().make_unique()) {
if (this->jpeg_ == nullptr) {
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
@@ -19,6 +19,7 @@ class JpegDecoder : public ImageDecoder {
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
@@ -46,8 +47,8 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
/// About 18 KB of decoder state: PSRAM first, nullptr if out of memory
RAMUniquePtr<JPEGDEC> jpeg_;
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
@@ -74,16 +74,9 @@ class TinyUSB final : public Component {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
#if CFG_TUD_CDC || CFG_TUD_NCM || CFG_TUD_ECM_RNDIS
// Interface association (IAD) only for classes that group interfaces, as esp_tinyusb's default descriptor does
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
#else
.bDeviceClass = TUSB_CLASS_UNSPECIFIED,
.bDeviceSubClass = 0,
.bDeviceProtocol = 0,
#endif
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0x303A,
.idProduct = 0x4001,
+1 -2
View File
@@ -185,7 +185,6 @@
#define MDNS_DYNAMIC_TXT_COUNT 2
#define LD2420_LISTENER_COUNT 1
#define LD2450_POLYGON_ZONE_COUNT 2
#define USE_LD2450_TARGET_XY
#define SEN6X_STATUS_SENSOR_COUNT 1
#define UART_BINARY_SENSOR_COUNT 2
#define MICRONOVA_LISTENER_COUNT 1
@@ -782,7 +781,7 @@
#endif
#ifdef USE_LIBRETINY
#if defined(USE_BK72XX) || defined(USE_LIBRETINY_VARIANT_RTL8720C)
#if defined(USE_BK72XX) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(USE_LN882X)
#define USE_LIBRETINY_CRASH_HANDLER
#endif
#define USE_BK72XX_BLE
+1
View File
@@ -67,6 +67,7 @@ STACKTRACE_GATES: Final[dict[str, str]] = {
),
PLATFORM_RP2: _CRASH_HANDLER_GATE,
PLATFORM_BK72XX: _CRASH_HANDLER_GATE,
PLATFORM_LN882X: _CRASH_HANDLER_GATE,
PLATFORM_RTL87XX: _CRASH_HANDLER_GATE,
PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:",
}
+1 -1
View File
@@ -152,7 +152,7 @@ def _addr2line(tool: str, elf: Path, addr: str) -> str:
def process_crash_handler_line(
config: ConfigType, line: str, backtrace_state: bool
) -> bool:
"""Decode a crash handler report with addr2line; the process_stacktrace hook of rp2, bk72xx and rtl87xx."""
"""Decode a crash handler report with addr2line; the process_stacktrace hook of rp2, bk72xx, rtl87xx and ln882x."""
if CRASH_HANDLER_RE.search(line):
_LOGGER.error("Crash detected - decoding addresses")
return True
@@ -16,6 +16,7 @@ from esphome.core import EsphomeError
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
("test_bk7238.yaml", "BK7238.*bootloader"),
],
)
def test_unsupported_family_rejected(
@@ -32,11 +33,9 @@ def test_unsupported_family_rejected(
assert "cannot compile" in caplog.text
@pytest.mark.parametrize("config_file", ["test_bk7231n.yaml", "test_bk7238.yaml"])
def test_ble5_family_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
main_cpp = generate_main(component_config_path(config_file))
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
@@ -1,4 +1,4 @@
"""The crash handler is built for BK7238, BK7231N (both with an ITCM linker region) and RTL8720C."""
"""The crash handler is built for BK7238, BK7231N (both with an ITCM linker region), RTL8720C and LN882H."""
from __future__ import annotations
@@ -19,7 +19,7 @@ from esphome.core import CORE
("bk7231t.yaml", False, False),
("rtl8720c.yaml", True, False), # installed at run time
("rtl8710b.yaml", False, False),
("ln882x.yaml", False, False),
("ln882x.yaml", True, False), # installed at run time
],
)
def test_crash_handler_family_gate(
@@ -1,2 +0,0 @@
packages:
bk72xx_ble: !include common.yaml
-7
View File
@@ -30,18 +30,11 @@ class MockUARTComponent : public uart::UARTComponent {
class TestableLD2450 : public LD2450Component {
public:
using LD2450Component::buffer_data_;
using LD2450Component::bluetooth_on_;
using LD2450Component::buffer_pos_;
using LD2450Component::get_timeout_status_;
using LD2450Component::mac_address_;
using LD2450Component::readline_;
using LD2450Component::timeout_;
using LD2450Component::version_;
using LD2450Component::zone_config_;
using LD2450Component::zone_type_;
#ifdef USE_NUMBER
using LD2450Component::pref_;
#endif
void feed(const std::vector<uint8_t> &data) {
for (uint8_t byte : data) {
-11
View File
@@ -131,23 +131,12 @@ text_sensor:
target_1:
direction:
name: Target-1 Direction
xy:
name: Target-1 XY
target_2:
direction:
name: Target-2 Direction
xy:
name: Target-2 XY
filters:
- throttle_with_priority:
timeout: 200ms
value: NA
target_3:
direction:
name: Target-3 Direction
xy:
name: Target-3 XY
filters: []
number:
- platform: ld2450
-43
View File
@@ -1,43 +0,0 @@
#include "common.h"
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld2450::testing {
namespace {
// MAC query ACK (command 0xA5): header, length 10, command, result ok, 6 MAC bytes, footer.
std::vector<uint8_t> make_mac_ack(const uint8_t (&mac)[MAC_ADDRESS_SIZE]) {
std::vector<uint8_t> frame(std::begin(ld24xx::CMD_FRAME_HEADER), std::end(ld24xx::CMD_FRAME_HEADER));
frame.insert(frame.end(), {0x0A, 0x00, 0xA5, 0x01, 0x00, 0x00});
frame.insert(frame.end(), mac, mac + MAC_ADDRESS_SIZE);
frame.insert(frame.end(), std::begin(ld24xx::CMD_FRAME_FOOTER), std::end(ld24xx::CMD_FRAME_FOOTER));
return frame;
}
} // namespace
class LD2450MacAckTest : public ::testing::Test {
protected:
void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); }
std::string mac_str_() const {
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return ld24xx::format_mac_str(this->ld2450_.mac_address_, buffer);
}
MockUARTComponent mock_uart_;
TestableLD2450 ld2450_;
};
TEST_F(LD2450MacAckTest, BluetoothOffAckClearsStoredMac) {
// Captured from radar firmware 2.04.23101915; the first byte has the multicast bit set
static constexpr uint8_t BEDROOM_MAC[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
this->ld2450_.feed(make_mac_ack(BEDROOM_MAC));
EXPECT_TRUE(this->ld2450_.bluetooth_on_);
EXPECT_THAT(this->ld2450_.mac_address_, ::testing::ElementsAreArray(BEDROOM_MAC));
ASSERT_EQ(this->mac_str_(), "81:2C:62:BE:C9:96");
this->ld2450_.feed(make_mac_ack(ld24xx::NO_MAC));
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
EXPECT_FALSE(this->ld2450_.bluetooth_on_);
EXPECT_THAT(this->ld2450_.mac_address_, ::testing::Each(0));
}
} // namespace esphome::ld2450::testing
@@ -1,157 +0,0 @@
#ifdef USE_HOST
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include "common.h"
#include "esphome/components/host/preferences.h"
#include "esphome/components/number/number.h"
#include "esphome/core/application.h"
namespace esphome::ld2450::testing {
namespace fs = std::filesystem;
namespace {
TestableLD2450 *hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// What the generated apply="set_presence_timeout()", publish="before" number does
void apply_presence_timeout(number::Number *entity, float value) {
entity->publish_state(value);
hub->set_presence_timeout();
}
using PresenceTimeoutNumber = number::ApplyNumber<apply_presence_timeout>;
// The presence_timeout part of LD2450Component::setup(), without its radar restart and scheduled info read
void boot(TestableLD2450 &radar, number::Number *entity) {
radar.set_presence_timeout_number(entity);
if (entity != nullptr) {
radar.pref_ = entity->make_entity_preference<float>();
radar.set_presence_timeout();
}
}
} // namespace
class LD2450PresenceTimeoutTest : public ::testing::Test {
protected:
void SetUp() override {
this->temp_dir_ = fs::temp_directory_path() / "esphome_ld2450_presence_timeout_test";
fs::remove_all(this->temp_dir_);
fs::create_directories(this->temp_dir_);
setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1);
App.pre_setup("test_ld2450_timeout", strlen("test_ld2450_timeout"), "", 0);
// A fresh store per test: the shared one keeps the file path of whichever test set it up first, whose directory
// may already be gone, and its in-memory values
host::host_preferences = &this->prefs_;
global_preferences = &this->prefs_;
this->ld2450_.set_uart_parent(&this->uart_);
hub = &this->ld2450_;
}
void TearDown() override {
hub = nullptr;
host::setup_preferences();
unsetenv("ESPHOME_PREFDIR");
fs::remove_all(this->temp_dir_);
}
/// Save a presence_timeout the way a previous boot did, and flush it
void save_timeout(float seconds) {
PresenceTimeoutNumber saved;
ESPPreferenceObject pref = saved.make_entity_preference<float>();
ASSERT_TRUE(pref.save(&seconds));
ASSERT_TRUE(global_preferences->sync());
}
/// Whether a frame without a target, now ms after the last one, clears presence
bool cleared_after(uint32_t last_target_ms, uint32_t now_ms) {
LoopBlockingGuard clock{nullptr, nullptr, now_ms};
return this->ld2450_.get_timeout_status_(last_target_ms);
}
host::HostPreferences prefs_;
MockUARTComponent uart_;
TestableLD2450 ld2450_;
PresenceTimeoutNumber timeout_number_;
fs::path temp_dir_;
};
TEST_F(LD2450PresenceTimeoutTest, NoNumberKeepsDefaultHold) {
boot(this->ld2450_, nullptr);
EXPECT_EQ(this->ld2450_.timeout_, 5000u);
EXPECT_FALSE(this->cleared_after(1000, 5999));
EXPECT_TRUE(this->cleared_after(1000, 6000));
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithNothingSavedPublishesDefault) {
boot(this->ld2450_, &this->timeout_number_);
ASSERT_TRUE(this->timeout_number_.has_state());
EXPECT_EQ(this->timeout_number_.state, 5.0f);
EXPECT_EQ(this->ld2450_.timeout_, 5000u);
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithSavedZeroPublishesZero) {
this->save_timeout(0);
boot(this->ld2450_, &this->timeout_number_);
ASSERT_TRUE(this->timeout_number_.has_state());
EXPECT_EQ(this->timeout_number_.state, 0.0f);
EXPECT_EQ(this->ld2450_.timeout_, 0u);
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithSavedValuePublishesIt) {
this->save_timeout(12);
boot(this->ld2450_, &this->timeout_number_);
EXPECT_EQ(this->timeout_number_.state, 12.0f);
EXPECT_EQ(this->ld2450_.timeout_, 12000u);
}
TEST_F(LD2450PresenceTimeoutTest, ControlZeroKeepsZeroAndHoldsNothing) {
boot(this->ld2450_, &this->timeout_number_);
ASSERT_EQ(this->timeout_number_.state, 5.0f);
auto call = this->timeout_number_.make_call();
call.set_value(0);
call.perform();
// The old code restored the saved 5 s as soon as the state read 0
EXPECT_EQ(this->timeout_number_.state, 0.0f);
EXPECT_EQ(this->ld2450_.timeout_, 0u);
EXPECT_TRUE(this->cleared_after(1000, 1000));
EXPECT_TRUE(this->cleared_after(1000, 1001));
}
TEST_F(LD2450PresenceTimeoutTest, ControlZeroIsSavedForTheNextBoot) {
boot(this->ld2450_, &this->timeout_number_);
auto call = this->timeout_number_.make_call();
call.set_value(0);
call.perform();
ASSERT_TRUE(global_preferences->sync());
TestableLD2450 rebooted;
PresenceTimeoutNumber rebooted_number;
rebooted.set_uart_parent(&this->uart_);
hub = &rebooted;
boot(rebooted, &rebooted_number);
ASSERT_TRUE(rebooted_number.has_state());
EXPECT_EQ(rebooted_number.state, 0.0f);
EXPECT_EQ(rebooted.timeout_, 0u);
}
TEST_F(LD2450PresenceTimeoutTest, ControlNonZeroSetsHold) {
boot(this->ld2450_, &this->timeout_number_);
auto call = this->timeout_number_.make_call();
call.set_value(3);
call.perform();
EXPECT_EQ(this->ld2450_.timeout_, 3000u);
EXPECT_FALSE(this->cleared_after(1000, 3999));
EXPECT_TRUE(this->cleared_after(1000, 4000));
}
} // namespace esphome::ld2450::testing
#endif // USE_HOST
-55
View File
@@ -1,55 +0,0 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld24xx::testing {
static std::string format_mac(const uint8_t (&mac)[MAC_ADDRESS_SIZE]) {
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return format_mac_str(mac, buffer);
}
TEST(LD24xxFormatMac, MulticastBitMacIsShownAsIs) {
// First byte 0x81 has the multicast bit set, and the radar reports it as its Bluetooth MAC.
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_EQ(format_mac(mac), "81:2C:62:BE:C9:96");
}
TEST(LD24xxFormatMac, UnicastMacIsShownAsIs) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x80, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_EQ(format_mac(mac), "80:2C:62:BE:C9:96");
}
TEST(LD24xxFormatMac, AllZeroMacIsUnknown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
EXPECT_EQ(format_mac(mac), "unknown");
}
TEST(LD24xxFormatMac, AllFfMacIsUnknown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
EXPECT_EQ(format_mac(mac), "unknown");
}
TEST(LD24xxFormatMac, MacWithSomeZeroOrFfBytesIsShown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x01};
EXPECT_EQ(format_mac(mac), "00:00:00:00:00:01");
const uint8_t ff_mac[MAC_ADDRESS_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE};
EXPECT_EQ(format_mac(ff_mac), "FF:FF:FF:FF:FF:FE");
}
TEST(LD24xxStoreMac, ReportedMacIsStored) {
const uint8_t reported[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
uint8_t mac[MAC_ADDRESS_SIZE] = {};
EXPECT_TRUE(store_mac(reported, mac));
EXPECT_THAT(mac, ::testing::ElementsAreArray(reported));
}
TEST(LD24xxStoreMac, NoMacClearsStoredMac) {
uint8_t mac[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_FALSE(store_mac(NO_MAC, mac));
EXPECT_THAT(mac, ::testing::Each(0));
}
} // namespace esphome::ld24xx::testing
-32
View File
@@ -182,29 +182,6 @@ climate:
name: LG Climate
transmitter_id: xmitr
datetime:
- platform: template
name: Date
id: test_date
type: date
initial_value: "2000-1-2"
set_action:
- logger.log: "set_value"
- platform: template
name: Time
id: test_time
type: time
initial_value: "12:34:56am"
set_action:
- logger.log: "set_value"
- platform: template
name: DateTime
id: test_datetime
type: datetime
initial_value: "2000-1-2 12:34:56"
set_action:
- logger.log: "set_value"
prometheus:
include_internal: true
relabel:
@@ -214,12 +191,3 @@ prometheus:
template_text_sensor1:
id: hello_text
name: Text Substitution
test_date:
id: prometheus_date
name: Prometheus Date
test_time:
id: prometheus_time
name: Prometheus Time
test_datetime:
id: prometheus_datetime
name: Prometheus DateTime
@@ -3,19 +3,21 @@
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#include <utility>
#ifdef USE_RUNTIME_IMAGE_JPEG
namespace esphome::runtime_image::testing {
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state moves to state_, which frees it.
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state is handed back before the base
// destructor runs, so the teardown stays with JpegDecoder.
class StatelessJpegDecoder : public JpegDecoder {
public:
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(std::move(this->jpeg_)) {}
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
this->jpeg_ = nullptr;
}
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
protected:
RAMUniquePtr<JPEGDEC> state_;
JPEGDEC *state_;
};
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
@@ -1,198 +0,0 @@
esphome:
name: uart-mock-ld2450-target-xy
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for
# positive values.
injections:
# Targets 1 and 2 move, one step per frame. Target 3 moves for 3 frames, then leaves while its
# later positions are still held back by the throttle
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x58, 0x02, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00,
0xC8, 0x80, 0xD0, 0x87, 0x00, 0x00, 0x64, 0x00,
0x2C, 0x01, 0xB8, 0x8B, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0x1A, 0x84, 0x00, 0x00, 0x64, 0x00,
0x18, 0x81, 0xEE, 0x87, 0x00, 0x00, 0x64, 0x00,
0xFA, 0x00, 0xEA, 0x8B, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x90, 0x01, 0x4C, 0x84, 0x00, 0x00, 0x64, 0x00,
0x68, 0x81, 0x0C, 0x88, 0x00, 0x00, 0x64, 0x00,
0xC8, 0x00, 0x1C, 0x8C, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x01, 0x7E, 0x84, 0x00, 0x00, 0x64, 0x00,
0xB8, 0x81, 0x2A, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xC8, 0x00, 0xB0, 0x84, 0x00, 0x00, 0x64, 0x00,
0x08, 0x82, 0x48, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x64, 0x00, 0xE2, 0x84, 0x00, 0x00, 0x64, 0x00,
0x58, 0x82, 0x66, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x14, 0x85, 0x00, 0x00, 0x64, 0x00,
0xA8, 0x82, 0x84, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x64, 0x80, 0x46, 0x85, 0x00, 0x00, 0x64, 0x00,
0xF8, 0x82, 0xA2, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xC8, 0x80, 0x78, 0x85, 0x00, 0x00, 0x64, 0x00,
0x48, 0x83, 0xC0, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0xAA, 0x85, 0x00, 0x00, 0x64, 0x00,
0x98, 0x83, 0xDE, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x90, 0x81, 0xDC, 0x85, 0x00, 0x00, 0x64, 0x00,
0xE8, 0x83, 0xFC, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x81, 0x0E, 0x86, 0x00, 0x00, 0x64, 0x00,
0x38, 0x84, 0x1A, 0x89, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Targets 1 and 2 stand still for 1.2 s (repeated frames are ignored), then leave
- delay: 1300ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
text_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
target_1:
xy:
name: "Target 1 XY"
filters:
- timeout:
timeout: 500ms
value: last
- throttle_with_priority:
timeout: 500ms
value: NA
target_2:
xy:
name: "Target 2 XY"
filters: []
target_3:
xy:
name: "Target 3 XY"
filters:
- timeout:
timeout: 500ms
value: last
- throttle_with_priority:
timeout: 500ms
value: NA
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -1,87 +0,0 @@
"""Integration test for the LD2450 target xy text sensor, with and without a throttle."""
from __future__ import annotations
import asyncio
from itertools import groupby
from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState
import pytest
from .state_utils import require_entity, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
# The positions of each target in the fixture, one per radar frame
TARGET_1 = [f"{-600 + 100 * i},{1000 + 50 * i}" for i in range(12)]
TARGET_2 = [f"{200 + 80 * i},{2000 + 30 * i}" for i in range(12)]
# Target 3 moves for 3 frames (300 ms) and leaves, with a 500 ms throttle
TARGET_3 = [f"{-300 + 50 * i},{3000 + 50 * i}" for i in range(3)]
# Frames are 100 ms apart: "NA" must come well before the 500 ms throttle ends
TARGET_3_LEAVES_WITHIN_S = 0.45
def _distinct(states: list[str]) -> list[str]:
"""Drop the repeat that the timeout filter sends after a pause."""
return [state for state, _ in groupby(states)]
@pytest.mark.asyncio
async def test_uart_mock_ld2450_target_xy(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the target xy text sensor with and without a throttle."""
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
target_1 = require_entity(entities, "target_1_xy", TextSensorInfo)
target_2 = require_entity(entities, "target_2_xy", TextSensorInfo)
target_3 = require_entity(entities, "target_3_xy", TextSensorInfo)
history: dict[int, list[str]] = {
target.key: [] for target in (target_1, target_2, target_3)
}
target_3_times: list[float] = []
loop = asyncio.get_running_loop()
both_left = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, TextSensorState) or state.key not in history:
return
history[state.key].append(state.state)
if state.key == target_3.key:
target_3_times.append(loop.time())
# Target 3 left long before, so only targets 1 and 2 end the scenario
left = all(
history[key] and history[key][-1] == "NA"
for key in (target_1.key, target_2.key)
)
if left and not both_left.done():
both_left.set_result(None)
await subscribe_after_initial_states(client, entities, on_state=on_state)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await asyncio.wait_for(both_left, timeout=10.0)
# Throttled: the first position at once, then a few, in order, and the final one before the target leaves
throttled = _distinct(history[target_1.key])
assert throttled[-1] == "NA"
positions = throttled[:-1]
assert positions[0] == TARGET_1[0]
assert positions[-1] == TARGET_1[-1], (
"the final position was held back for good"
)
indexes = [TARGET_1.index(position) for position in positions]
assert indexes == sorted(set(indexes)), f"out of order or repeated: {positions}"
assert 3 <= len(positions) <= 6, f"expected a few updates, got {positions}"
# Not throttled: every change is published
assert history[target_2.key] == [*TARGET_2, "NA"]
# "NA" bypasses the throttle and drops the held position: nothing follows it
assert _distinct(history[target_3.key]) == [TARGET_3[0], "NA"]
assert target_3_times[1] - target_3_times[0] < TARGET_3_LEAVES_WITHIN_S
+1 -2
View File
@@ -117,7 +117,6 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_LN882X,
PLATFORM_NRF52,
PLATFORM_RP2,
ErrorFormat,
@@ -6304,7 +6303,7 @@ def test_run_miniterm_no_stacktrace_analyzer(
"""Platforms without a stacktrace analyzer log an info and stream anyway."""
mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_LN882X}
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
+2 -2
View File
@@ -15,7 +15,7 @@ from unittest.mock import Mock
import pytest
from esphome import platform_hooks
from esphome.const import PLATFORM_ESP32, PLATFORM_LN882X, Platform
from esphome.const import PLATFORM_ESP32, PLATFORM_HOST, Platform
def test_no_unregistered_platform_exposes_a_hook() -> None:
@@ -165,7 +165,7 @@ def test_get_stacktrace_handler_reports_missing_analyzer(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO", logger="esphome.platform_hooks")
assert platform_hooks.get_stacktrace_handler(PLATFORM_LN882X) is None
assert platform_hooks.get_stacktrace_handler(PLATFORM_HOST) is None
assert "no compatible analyzer" in caplog.text
# A capability gap is ordinary; it must not warn.
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
+7 -1
View File
@@ -18,6 +18,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_LN882X,
PLATFORM_NRF52,
PLATFORM_RP2,
@@ -71,6 +72,10 @@ CRASH_SAMPLES: dict[str, dict[str, list[str]]] = {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x9B0073A4 (fault location)"],
},
PLATFORM_LN882X: {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x1001A2C4 (fault location)"],
},
PLATFORM_NRF52: {
"state_markers": ["Last crash:"],
"addresses": [
@@ -237,6 +242,7 @@ DECODER_PATTERNS: dict[str, list[str]] = {
PLATFORM_RP2: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_BK72XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_RTL87XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_LN882X: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"],
}
@@ -554,7 +560,7 @@ def test_processor_registry_miss_disables_at_construction(
caplog.set_level("INFO", logger="esphome.platform_hooks")
with patch.object(stacktrace.platform_hooks, "import_module") as mock_import:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_LN882X)
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_HOST)
processor.process_line("PC: 0x40104960")
mock_import.assert_not_called()