Compare commits

...
Author SHA1 Message Date
J. Nick Koston b37e046a52 Tighten the WISP, AP restart, Xiaomi advertiser and OTA timing checks 2026-10-26 04:22:44 -10:00
J. Nick Koston 3246311daa Move earlier OTA timing results aside at the start of a run 2026-10-26 04:17:19 -10:00
J. Nick Koston 659efd5fcb Count missing OTA uploads and benchmark results as failures, support API keys 2026-10-26 03:59:51 -10:00
J. Nick Koston bb3f175fa7 Add the OTA upload timing and inflate benchmark flows, address review
OTA timing A/B runner with deflate, raw and TCP modes, an API build check, a UF2
trim step for trees that have device_ota_uf2(), and an on-device inflate benchmark.
The WISP summary needs a successful lookup in every upstream on phase, the PMF AP
reads its config back, the advertiser counts failed GAP sends, and btdrive only
counts rounds that disconnected cleanly after a service read.
2026-10-26 00:09:37 -10:00
J. Nick Koston 223022e0a6 Merge remote-tracking branch 'origin/dev' into hardware-test-skill-sync-9 2026-10-26 00:04:36 -10:00
Bascht74andJ. Nick Koston 2d37ad6836 [api] Keep an entity's events in order under backpressure (#21567)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 23:57:11 -10:00
Clyde Stubbs d2f6b389d4 [runtime_image] Fix LVGL widgets on image resize (#21368) 2026-10-26 20:08:06 +11:00
Raphael Hehl 875b2076c0 [esp32_camera] Fix lost single image requests (#21613) 2026-10-25 22:04:24 -10:00
J. Nick Koston 3b0a1ba339 Address review: retry status, per-round latency, stretch timeout, one boot, released BK capture 2026-10-25 21:24:25 -10:00
c21660251c [modbus_client] Pass the request's address to the send handlers (#20461)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Dale Klein <49771139+dalklein@users.noreply.github.com>
Co-authored-by: Bonne Eggleston <bonne@exciton.com.au>
2026-10-25 21:05:10 -10:00
esphome[bot] 995acb94b4 [ci] Refresh integration test durations (#21640) 2026-10-26 05:58:30 +00:00
Manuel MauszandJ. Nick Koston 24b7eefaad [remote_base] Add support for Cardin S438 remotes (#21507)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 19:56:32 -10:00
J. Nick Koston 92655743c1 Address review: analyzer verdicts, central_bda guard, AP switch checks
Separate the Xiaomi after-bad temperature range and check the probe on the PR build,
guard central_bda.hpp, require every BLE round and latency 10, check each upstream AP
switch, fail on a missing BK7238 capture, a failed WPA3 switch, a missing DUT or a
BK7238 that never joined, and count the I2C NACK and stretch reads in RW OK.
2026-10-25 19:37:28 -10:00
Clyde StubbsandClaude Opus 5.5 2349e0be00 [esp32] Check that code is mapped when reporting a crash (#21630)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:49:16 +11:00
Clyde StubbsandClaude Opus 5.5 63349801b7 [ci] Include dependencies added by overrides of dependency components (#21635)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:18:17 -10:00
afdfa7b298 [gree] Add use_fahrenheit for Fahrenheit display on the unit's panel (#20751)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 17:09:04 -10:00
J. Nick Koston 5f79e92c14 [ci] Add Xiaomi BLE, WPA3 AP, BLE terminate and I2C slave flows to the hardware-test skill
Adds the flows proven since the last sync: Xiaomi, ATC and PVVX parsing by injected
or over the air frames, proxy disconnects against a peer with peripheral latency, a
fake I2C slave, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP
switching, and AP restarts with WPA3 mixed and LIGHT power save. Also adds the Thread
and BK7231N fallback AP facts to boards.md, the ItsyBitsy UF2 path to nrf52.md, and
the result comment format to the Report step.
2026-10-25 17:02:04 -10:00
JoHoenk 400407b135 [runtime_image] Allocate the JPEG decoder state from PSRAM (#21629) 2026-10-26 08:28:49 +11:00
Jonathan SwobodaandJ. Nick Koston 22b32b194a [core][scheduler] Count a cancelled item on a failed component as removed (#21626)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 15:36:17 -04:00
J. Nick Koston 6dcd989a7f [switch][number][text] Follow the source with plain binary sensors, sensors and text sensors (#21612) 2026-10-25 18:05:42 +00:00
J. Nick Koston 2016884210 [copy] Follow the source with plain sensors and text sensors (#21610) 2026-10-25 07:52:01 -10:00
120 changed files with 4878 additions and 642 deletions

No files matched your search

+12 -4
View File
@@ -67,9 +67,17 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
automations in the config itself.
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
no IP addresses.
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
```
> [!NOTE]
> Automated testing results
```
Then one short sentence saying what was tested (builds and SHAs, board, what was driven), a results table
(build SHA, board and framework, what was driven, counts, result, plus the `dev` control row), short bullets
for the findings, and the setup and exact flow in a collapsed `<details>` block. Answer any question asked on
the PR directly in the comment. No credentials, SSIDs, IP addresses, port names or other bench details.
## Flashing by chip family
@@ -115,7 +123,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header 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).
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`
@@ -125,6 +125,11 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
- Moving a BK7231N device (CB3S) that sits on its fallback AP to another network: join the host's Wi-Fi to
`<name>-fallback`, OTA to 192.168.4.1, then rejoin the host's own network. Moving it with `/wifisave` instead can
leave it in the captive portal's BLIND_RETRY state (no scan, `Beacon Timeout` every 46 s) until it reboots, and a
log-only serial adapter cannot reset it. Rejoining may change the host's address; update a syslog target that
points at the old one.
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
@@ -172,6 +177,12 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
a never-saved value.
- Thread: nRF52 `openthread:` accepts only `tlv:`; the individual keys (network_key, channel, ...) are rejected at
validation. To list nearby networks with channel and PAN ID, call
`otThreadDiscover(InstanceLock::acquire().get_instance(), 0, 0xFFFF, false, false, cb, nullptr)` from an
`interval:` lambda in an included header; results arrive on the OpenThread thread and log fine.
- A wrong network key on a real network's channel and PAN ID makes the device form its own partition as leader on
that PAN within about 30 s; take it off Thread at once. Never try guessed datasets against a network in use.
## Waveshare ESP32-S3-ETH with the OV5640 camera module
@@ -19,12 +19,12 @@ One file per component, named after it:
- [`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): overlap and lock, timeouts, deflate
- [`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
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
@@ -38,3 +38,6 @@ One file per component, named after it:
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
@@ -0,0 +1,17 @@
# BLE terminate with peripheral latency (#20232)
`scripts/ble_terminate`: an ESP32-S3 `bluetooth_proxy` (active) over Ethernet as the central (`btproxy.yaml`) and a
classic devkit `esp32_ble_server` peer (`btpeer.yaml`). The peer asks for 50 ms / latency 10 / 6 s with
`esp_ble_gap_update_conn_params` 4 s after connect; earlier requests are overwritten by the proxy's own "medium
conn params" after service discovery. Read the proxy's `Conn params updated` line (VERBOSE, API logs) to confirm;
the peer side never logs the event. The proxy only connects to a device its scanner has seen, so `btdrive.py`
subscribes to raw advertisements first.
Measure the peer's own disconnect (`serial_capture.py --ms`) after the client's disconnect: about 0.2 to 0.8 s when
the terminate arrived, about 6 s (the supervision timeout) when it was lost. The client side disconnect time is the
API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
`run.sh LABEL ROUNDS` writes `central_bda.hpp` from `PROXY_BT_MAC`, builds and flashes both boards, runs the rounds
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
latency and counts as inconclusive).
@@ -0,0 +1,12 @@
# Fake I2C slave (#21586)
`scripts/i2c_slave`: a D1 mini running an Arduino `Wire` slave at 0x40 (`i2cslave.yaml` with `i2cslave.hpp`): a fake
HDC1080 plus a 256 byte register file, and reads of 0x7E and 0x7F stretch the clock for 0.5 and 5 ms. It needs
`libraries: [Wire]` and 160 MHz. With jumper wires only and the internal pull ups it works to about 50 kHz; it cannot
keep up with a true 100 kHz hardware master.
The master (`i2cbk.yaml`, a BK7238 on P26/P24, needs the software I2C bus from that PR) writes a counter, reads it
back with a repeated start, probes an absent address and reads both stretch registers every 2 s, logging `RW OK`
(the read back matches, the absent address NACKs and both stretched reads succeed) or `RW BAD` with running totals.
Control: the same blocks on a classic ESP32 devkit (SDA GPIO21, SCL GPIO22) with hardware I2C at 25 and 50 kHz got
every repeated start read right, so failures on a bit banged master at a similar real clock point at the master.
@@ -4,3 +4,10 @@
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
## ItsyBitsy over UF2 when serial DFU fails (#20481)
Serial DFU to the ItsyBitsy nRF52840 fails about half the time ("Target is not in DFU mode"). Reliable path: a
1200 baud touch on the app port, wait for the `ITSY840BOOT` volume, convert `zephyr/zephyr.hex` with the SDK's
`zephyr/scripts/build/uf2conv.py -f 0xADA52840 -c` and copy the .uf2 to the volume (the copy ends in an I/O error
when the board reboots, which is normal). macOS BLE scans give UUIDs, not MAC addresses.
@@ -6,3 +6,46 @@ All overlap orders (a web upload during a native OTA and the reverse) must be re
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
`pkill -f "a\|b"` does not alternate; use two pkills.
## OTA upload timing A/B (#21639, #21641, #21642)
`scripts/ota_timing/run.sh N PHASE...` times native OTA uploads per build (phase) and per board. It builds every
board from `TREE_<PHASE>`, does one warm-up upload per board (it installs the phase's build and is left out of the
results), checks over the API that each board reports the compile time from that build's `build_info.json`
(`apiinfo.py`), then runs N rounds of every mode on every board. Boards are interleaved per round so they share the
same air; put a nearby ESP32 on the same Wi-Fi in `BOARDS` as a network control. `summ.py` prints medians: never
judge from single runs, slow Wi-Fi gave 33 to 235 s for the same upload in one night.
- `otatime.py` drives `espota2.run_ota()` from the tree on `PYTHONPATH` and prints one `RESULT` line with the
`Update took` split, the compressed size, whether UDP was used and the time at each tenth of the upload.
An upload that leaves no `RESULT` line (board gone, script crash, timeout) is recorded as `rc: -1`, and `summ.py`
fails when a board and mode has fewer than N rows or an upload time cannot be read.
- Boards with an `api: encryption: key` also encrypt OTA: set `API_KEY` for `otatime.py` and `apiinfo.py`. LibreTiny
LN882H listens for OTA on 8820, not 8892; BK72xx and RTL87xx use 8892.
- Raw mode leaves `CLIENT_FEATURE_SUPPORTS_DEFLATE` out of the client's offer. Never switch deflate off after the
offer (`if deflate and 0:`): once the device accepted deflate it expects a deflate stream.
- `-tcp` modes make `espota2._loss_reason` return None, so the client asks to stay on TCP. Clearing
`CLIENT_FEATURE_SUPPORTS_UDP` instead desyncs the handshake (`receiving UDP token response: timed out`).
- LibreTiny trimmed UF2 (#21642): the CLI sends what `esphome.components.libretiny.uf2.device_ota_uf2()` returns for
`firmware.uf2`; that module only exists with #21642, so on older trees the full UF2 goes out. Compare the sizes
in the `Compressed to` lines, and rely on the API compile time check to show that the trimmed image booted.
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409628227 (dev and LibreTiny 1.14.0 on a
CB3S), https://github.com/esphome/esphome/pull/21642#issuecomment-6412972562 (trimmed UF2 on CB3S, BK7238 and BW15),
https://github.com/esphome/esphome/pull/21641#issuecomment-6412976859 (new decoder on the same three).
## On-device inflate benchmark (#21639, #21641)
`scripts/ota_timing/inflate_bench`: `gen_streams.py NAME=IMAGE...` cuts a 128 KB slice from each image and writes
`bench_streams.hpp` with raw deflate streams (level 9, 4 KB and 32 KB windows). `bench.hpp` inflates every stream with
each ring size at least the encoder window, three times, and logs the best time plus input and output rates, then
`BENCHDONE`. `bench.sh CONFIG DEVICE` builds and uploads `bench-esp32s3.yaml` or `bench-bk72xx.yaml`, collects one pass
over syslog and fails when a case did not reach the end of its stream.
- LibreTiny defines `micros` as a macro, so the header calls `(esphome::micros)()`.
- Keep the streams near 130 KB on a BK7231N: an image with about 330 KB of extra incompressible data failed its
OTA with "Writing OTA data to flash memory failed". Use one image and `--windows 12` there.
- `bench.hpp` targets the decoder interface with `dest_limit`. #21641 drops it (the output runs into the window up
to `dest`); after that lands, remove the `dest_limit` line.
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409786224
@@ -37,3 +37,44 @@ AP start (the fix in that PR), the first AP start failed on every boot with `esp
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
## Reboot against a WPA2/WPA3 PMF access point (#21590)
- AP: `scripts/wpa3_ap/wdap.yaml` on a classic devkit. ESPHome `wifi: ap:` only, then an on_boot lambda switches it
to `WIFI_AUTH_WPA2_WPA3_PSK`, PMF capable, and registers a WIFI_EVENT handler that logs `STA JOIN` and
`STA LEAVE ... reason N` (the include header pulls in `esp_wifi.h`). It disables Wi-Fi after 420 s.
- AP leave reason 3 means the station sent a deauth; reason 2 means the AP found a stale association when the
station came back; several reason 2 before one join are rejected reconnects.
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the house
network at 0, and reboot 30 s after joining the AP and 150 s after joining the house network. So they move to the
AP while it is up and fall back to the house network (where they can be flashed over the air) once its window
ends. `run.sh BUILD` flashes the AP, does OTA for the CB3S and a serial flash for the BK7238, then captures one AP
window; `an.py` prints each station's join and leave sequence and fails on a rejected reconnect, an AP that did
not switch to WPA2/WPA3 (`AP CONFIG FAILED`), a DUT missing from the AP events (`DUT_MACS`) or a BK7238 that never
joined the AP.
- Gotchas: the LibreTiny build directory has two `.uf2` files, so flash `firmware.uf2`. The BK7238 serial flash
sometimes fails to link while the chip is in a reboot loop; retry it 3 or 4 times. OTA fails if the DUT reboots
mid-upload; retry.
- Finding: BDK 3.0.78 rejects about 2 reconnects per reboot when the station left without a deauth; BDK 3.0.33
does not.
## WISP and ALWAYS_ON AP rig (#20077)
`scripts/wisp_ap`: the upstream is an ESP32-S3 with Ethernet running a WISP bridge (`wisp-upstream.yaml`) with its AP on
10.40.0.1/24 and web buttons `ap off` and `ap on`. Its subnet must not be 192.168.4.0/24, the DUT AP's default, or
nothing routes. The DUT is a classic devkit (`dut-wisp.yaml` or `dut-always-on.yaml`); the client is a D1 mini on
the DUT's AP (`wispcli.yaml`) logging its DNS server, a name lookup, a GET by name, the AP itself and a LAN file by
IP. `run.sh DUT START SECONDS T:STATE...` switches the upstream AP at the given times and `sum.py` groups the client
results by phase. Capture the D1 mini without `--reset` (a CH340 floods garbage after an RTS reset). Clients keep
the captive portal's DNS answer for its TTL.
## AP restart with WPA3 mixed and LIGHT power save (#21531)
`scripts/wifi_restart`: the WISP upstream (`wisp-upstream-wpa3.yaml`) switched to WPA2/WPA3 mixed by a script that
runs again after every `ap on`, DUTs (`rebk.yaml`, `recb.yaml` on `re-common.yaml`) with a static IP on the bridge's
subnet, a fallback AP, `power_save_mode: LIGHT` and syslog to the host through the NAT. `run.sh BUILD` switches the
AP off and on three times; `an.py` fails on a reboot, a crash or a DUT that is not back on syslog within 120 s of an
AP start. After 19 minutes the DUTs run `wifi.configure` to the house network; that keeps the static IP, so the next
OTA needs the host to have an address on the bridge's subnet as well (an interface alias). The CB3S log is on UART1
(TX1), so its configs need `logger: hardware_uart: UART1`. Runners must `pkill` only their own ports; a global pkill
stops the other runs' captures.
@@ -0,0 +1,36 @@
# Xiaomi BLE
## Xiaomi, ATC and PVVX parsing without the sensors (#21483)
No Xiaomi devices are needed, and one ESP32 cannot hear its own advertising, so the frames are injected into the
tracker as scan results: everything after the radio runs as normal.
- `scripts/xiaomi_ble/gen.py` writes `frames.hpp` and `expected.json`: plain MiBeacon (0x50), encrypted v5 23 byte
with MAC and 19 byte without, real AES-CCM (`cryptography` AESCCM, tag 4, AAD 0x11, nonce = MAC reversed + pid +
cnt + 3 byte ext counter), ATC (13 byte, big endian) and PVVX (15 byte, little endian) 0x181A frames, a corrupted
tag every other cycle and a counter 0 boot frame. It models the old shared duplicate counter (`base`) and the per
device one (`pr`) and prints the expected publish counts.
- `xbinject.yaml` (ESP-IDF devkit) includes `frames.hpp`; an `interval:` builds a `BLEScanResult` and calls
`global_esp32_ble_tracker->gap_scan_event_handler(r)`. `count.py` counts the `Got Xiaomi` blocks (sensor
publishes are not logged at DEBUG) and compares them with `expected.json`.
- On ESP32 the tracker's own triggers sit in `listeners_`, which runs before `neutral_listeners_` (the
ble_device_base devices), so an `on_ble_service_data_advertise` never sees what a Xiaomi listener did to the
shared data. Check that directly: `xb_rewrite_probe(id(xa))` builds an `ESPBTDevice` with `parse_scan_rst()`,
calls the component's `parse_device()` and logs the service data before and after (`[probe` lines).
- `run.sh inject pr|base` builds, flashes, captures and prints PASS when the counts match that build's model and,
on the PR build, the probe's service data is the same before and after `parse_device()`.
Base dropped a second device's frame with the same counter, the frame after a bad tag and a first frame with
counter 0, and rewrote the shared data in place; the PR fixed all of them.
## Over the air with a second ESP32
- Advertiser: an ESP32-S3 with Ethernet (OTA over Ethernet), `xbadv.yaml` with `adv_frames.hpp` from
`gen.py --ota`. Per frame: stop advertising, `esp_ble_gap_set_rand_addr`, `config_adv_data_raw`, start
non-connectable advertising with `own_addr_type` random, 20 ms interval, 250 ms dwell. Random static addresses
need the top two bits set, so these use C4:C1:38 instead of A4. `esp32_ble:` alone brings up the stack. The
web_server button starts a run: `curl -X POST -d "" http://<advertiser>/button/start/press` (no body gives 411).
- Receiver: `xbrecv.yaml` on the devkit, passive scan with window = interval = 100 ms. `run.sh ota pr|base` (with
`ADV` set) flashes it, resets it with a capture and presses start 18 s later, so the counter 0 boot frame is the
first one it hears.
- Each frame arrives about 4 times; the counts matched the injection run exactly apart from one radio loss, so the
over the air comparison allows two missing publishes per tag.
@@ -0,0 +1,90 @@
"""Match each proxy disconnect with the peer's own disconnect line and count the links the peer kept.
Usage: an.py DIR LABEL [ROUNDS] reads LABEL-drive.txt, LABEL-peer.txt (serial_capture --ms) and LABEL-proxy.txt
(esphome logs, VERBOSE). A round counts only when the last `Conn params updated` the proxy logged since the previous
disconnect shows latency=10; otherwise it is inconclusive. Exits 1 when the peer kept a link past 3 s (the terminate
was lost), a round was inconclusive, or fewer than ROUNDS rounds completed."""
import datetime as dt
from pathlib import Path
import re
import sys
D, label = Path(sys.argv[1]), sys.argv[2]
ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else None
def read(name: str) -> list[str]:
return (
(D / f"{label}-{name}.txt")
.read_text(encoding="utf-8", errors="replace")
.splitlines()
)
def ptime(line: str) -> float | None:
"""Seconds since midnight from a leading [HH:MM:SS.mmm] stamp."""
m = re.match(r"\[(\d+):(\d+):(\d+)\.(\d+)\]", line)
return (
int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
)
pdisc = [
t
for line in read("peer")
if "PEER DISCONNECT" in line and (t := ptime(line)) is not None
]
params = [
(t, int(m[1]))
for line in read("proxy")
if "Conn params updated" in line
and (m := re.search(r"latency=(\d+)", line))
and (t := ptime(line)) is not None
]
res = []
prev = -1.0
for line in read("drive"):
m = re.search(
r"R(\d+) .*DISC_CALL ([\d.]+) DISC_DONE ([\d.]+) \(\+([\d.]+)s\)", line
)
if not m:
if line.startswith("R"):
print(" ", line)
continue
# Both captures stamp local wall clock time; compare as seconds since midnight.
t = dt.datetime.fromtimestamp(float(m[2])).astimezone()
tc = t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
after = [p - tc for p in pdisc if -1 < p - tc < 12]
window = [lat for pt, lat in params if prev < pt <= tc]
res.append(
(
int(m[1]),
float(m[4]),
after[0] if after else None,
window[-1] if window else None,
)
)
prev = tc
for r, done, pd, lat in res:
peer = (
f"peer disconnect +{pd:.2f}s" if pd is not None else "peer disconnect not seen"
)
print(f" R{r:2} central done +{done:.3f}s {peer} latency at disconnect {lat}")
late = sum(1 for _, _, pd, lat in res if lat == 10 and (pd is None or pd > 3))
inconclusive = sum(1 for *_, lat in res if lat != 10)
print(
f"{label}: rounds {len(res)}, peer kept the link (>3 s or not seen) {late}, inconclusive {inconclusive}"
)
failed = []
if not res:
failed.append("no round completed")
if ROUNDS is not None and len(res) < ROUNDS:
failed.append(f"only {len(res)} of {ROUNDS} rounds completed")
if late:
failed.append(f"{late} links kept")
if inconclusive:
failed.append(
f"INCONCLUSIVE: {inconclusive} rounds had no latency=10 update before the disconnect"
)
print(f"PASS: {label}" if not failed else f"FAIL: {label}: " + "; ".join(failed))
sys.exit(1 if failed else 0)
@@ -0,0 +1,100 @@
"""Connect to the peer through the proxy, read its services, idle, disconnect; print host timestamps per round.
Usage: btdrive.py PROXY KEY PEER_MAC ROUNDS IDLE_S"""
import asyncio
import sys
import time
from aioesphomeapi import APIClient, APIConnectionError
async def main(host: str, key: str, peer: str, rounds: int, idle: float) -> int:
addr = int(peer.replace(":", ""), 16)
cli = APIClient(host, 6053, None, noise_psk=key)
await cli.connect(login=True)
info = await cli.device_info()
print(f"proxy {info.name} bt_mac {info.bluetooth_mac_address}", flush=True)
seen = 0
def adv(msg) -> None:
nonlocal seen
seen += sum(a.address == addr for a in msg.advertisements)
# The proxy only connects to a device its scanner has seen.
cli.subscribe_bluetooth_le_raw_advertisements(adv)
await asyncio.sleep(5)
print(f"peer advertisements seen in 5 s: {seen}", flush=True)
if not seen:
print("FAIL: the proxy does not see the peer", flush=True)
await cli.disconnect()
return 1
done = 0
for r in range(1, rounds + 1):
state: asyncio.Queue = asyncio.Queue()
def cb(
connected: bool, mtu: int, err: int, state: asyncio.Queue = state
) -> None:
state.put_nowait((connected, err, time.time()))
t0 = time.time()
try:
cancel = await cli.bluetooth_device_connect(
addr, cb, timeout=20, address_type=0, feature_flags=0xFF
)
except (APIConnectionError, TimeoutError) as e:
print(f"R{r} CONNECT ERROR {type(e).__name__}", flush=True)
await asyncio.sleep(9)
continue
try:
c, err, t = await asyncio.wait_for(state.get(), 25)
except TimeoutError:
print(f"R{r} CONNECT TIMEOUT", flush=True)
cancel()
await asyncio.sleep(8)
continue
if not c:
print(f"R{r} CONNECT FAIL err {err} after {t - t0:.2f}s", flush=True)
cancel()
await asyncio.sleep(8)
continue
try:
n = len((await cli.bluetooth_gatt_get_services(addr)).services)
except (APIConnectionError, TimeoutError) as e:
n = f"err {e!r}"
await asyncio.sleep(idle)
td = time.time()
await cli.bluetooth_device_disconnect(addr)
try:
c2, err2, t2 = await asyncio.wait_for(state.get(), 15)
if c2 or not isinstance(n, int):
# an unexpected connect state or a failed service read: the round did not run as intended
print(
f"R{r} BAD ROUND services {n} connected {c2} err {err2}", flush=True
)
else:
print(
f"R{r} services {n} DISC_CALL {td:.3f} DISC_DONE {t2:.3f} (+{t2 - td:.3f}s) err {err2}",
flush=True,
)
done += 1
except TimeoutError:
print(
f"R{r} services {n} DISC_CALL {td:.3f} no disconnect state", flush=True
)
cancel()
await asyncio.sleep(
9
) # a peer that missed the terminate hits its 6 s supervision timeout in this gap
await cli.disconnect()
print(f"rounds completed {done} of {rounds}", flush=True)
return 0 if done == rounds else 1
sys.exit(
asyncio.run(
main(
sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
)
)
)
@@ -0,0 +1,17 @@
#pragma once
#include <cstring>
#include <esp_gap_ble_api.h>
#include "esphome/core/log.h"
#include "central_bda.hpp" // written by run.sh from PROXY_BT_MAC
// 50 ms interval, peripheral latency 10, 6 s supervision timeout
inline void btpeer_request_latency() {
esp_ble_conn_update_params_t p{};
memcpy(p.bda, CENTRAL_BDA, 6);
p.min_int = 40;
p.max_int = 40;
p.latency = 10;
p.timeout = 600;
esp_err_t err = esp_ble_gap_update_conn_params(&p);
ESP_LOGI("peer", "requested interval 50 ms latency 10 timeout 6 s: %d", err);
}
@@ -0,0 +1,36 @@
esphome:
name: btpeer
includes:
- btpeer.hpp
- central_bda.hpp
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
esp32_ble: VERBOSE
scheduler: DEBUG
component: DEBUG
esp32_ble:
max_connections: 1
esp32_ble_server:
manufacturer: test
on_connect:
- lambda: ESP_LOGI("peer", "PEER CONNECT conn %u", (unsigned) id);
- delay: 4s
- lambda: btpeer_request_latency();
on_disconnect:
- lambda: ESP_LOGI("peer", "PEER DISCONNECT conn %u", (unsigned) id);
services:
- uuid: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
advertise: true
characteristics:
- uuid: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
read: true
value: "peer"
@@ -0,0 +1,44 @@
substitutions:
name: btproxy
esphome:
name: ${name}
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
level: VERBOSE
logs:
scheduler: DEBUG
component: DEBUG
esp32_ble_tracker: DEBUG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
esp32_ble_tracker:
scan_parameters:
active: false
bluetooth_proxy:
active: true
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# usage: run.sh LABEL ROUNDS build both boards, then ROUNDS connect/disconnect rounds through the proxy
# Copy this directory into a scratch directory with a secrets.yaml (api_key, base64 Noise key) and run it there.
# env: TREE (esphome checkout to build the proxy from), PORT (peer devkit serial port), PROXY (proxy address, flashed
# over the air; the first flash of a fresh board goes over USB), PROXY_BT_MAC (the proxy's Bluetooth MAC, printed by
# btdrive.py as bt_mac), PEER_MAC (the peer's Bluetooth MAC, base MAC + 2 on a classic ESP32), SC (path to
# serial_capture.py), PY (checkout python), PEER_TREE (checkout for the peer, defaults to TREE)
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=${2:-12}; T=${TREE:?set TREE}; PT=${PEER_TREE:-$T}
P=${PORT:?set PORT}; S3=${PROXY:?set PROXY}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
PEER=${PEER_MAC:?set PEER_MAC}; CM=${PROXY_BT_MAC:?set PROXY_BT_MAC}
KEY=$(sed -n 's/^api_key: *"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$D/secrets.yaml")
[ -n "$KEY" ] || { echo "FAIL: no api_key in secrets.yaml"; exit 1; }
cd "$D" || exit 1
printf '#pragma once\n#include <cstdint>\nstatic const uint8_t CENTRAL_BDA[6] = {0x%s};\n' "${CM//:/, 0x}" > central_bda.hpp
(cd "$PT" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btpeer.yaml" --device "$P" > "$D/$L-peer-upload.log" 2>&1) \
|| { echo "FAIL: peer build or upload failed"; exit 1; }
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy-upload.log" 2>&1) \
|| { echo "FAIL: proxy build or upload failed"; exit 1; }
SECS=$((N * 26 + 40))
pkill -f "[s]erial_capture.py $P"
$PY "$SC" "$P" $SECS "$D/$L-peer.txt" --ms > /dev/null 2>&1 & CAP=$!
(cd "$T" && PYTHONUNBUFFERED=1 exec timeout $((SECS - 10)) $PY -m esphome logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy.txt" 2>&1) & LOGS=$!
trap 'kill $CAP $LOGS 2>/dev/null' EXIT
sleep 6
$PY "$D/btdrive.py" "$S3" "$KEY" "$PEER" "$N" 10 > "$D/$L-drive.txt" 2>&1; RC=$?
sleep 10
kill $CAP $LOGS 2>/dev/null; wait 2>/dev/null; trap - EXIT
[ -s "$D/$L-peer.txt" ] || { tail -3 "$D/$L-drive.txt"; echo "FAIL: peer capture got nothing"; exit 1; }
$PY "$D/an.py" "$D" "$L" "$N"; ARC=$?
[ "$RC" = 0 ] || { tail -1 "$D/$L-drive.txt"; echo "FAIL: driver rc $RC (not every round completed)"; exit 1; }
exit $ARC
@@ -0,0 +1,79 @@
# BK7238 bit banged master; needs the BK72xx software I2C bus from #21586 (dev rejects the pins).
# The ESP32 control is the same i2c, sensor and interval blocks on a devkit (sda GPIO21, scl GPIO22, 25 or 50 kHz).
substitutions:
frequency: 100kHz
esphome:
name: i2cbk
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
i2c:
id: bus
sda: P26
scl: P24
scan: true
frequency: ${frequency}
timeout: 20ms # clock stretch limit; the bus default (1 ms) is shorter than the slave's 5 ms stretch
sensor:
- platform: hdc1080
address: 0x40
update_interval: 5s
temperature:
name: fake temperature
humidity:
name: fake humidity
globals:
- id: n
type: uint8_t
initial_value: "0"
- id: n_ok
type: uint32_t
initial_value: "0"
- id: n_bad
type: uint32_t
initial_value: "0"
interval:
- interval: 2s
then:
- lambda: |-
uint8_t k = ++id(n);
uint8_t w[4] = {0x20, k, (uint8_t) ~k, 0xA5};
uint32_t t0 = micros();
auto e1 = id(bus)->write(0x40, w, 4);
uint8_t reg = 0x20, r[3] = {0, 0, 0};
auto e2 = id(bus)->write_readv(0x40, &reg, 1, r, 3);
uint32_t t1 = micros();
uint8_t x;
auto e3 = id(bus)->read(0x41, &x, 1);
uint8_t s1 = 0x7E, s2 = 0x7F, rr[2];
uint32_t t2 = micros();
auto e4 = id(bus)->write_readv(0x40, &s1, 1, rr, 2);
uint32_t t3 = micros();
auto e5 = id(bus)->write_readv(0x40, &s2, 1, rr, 2);
uint32_t t4 = micros();
// the absent address must NACK and both clock stretched reads must succeed
bool ok = e1 == 0 && e2 == 0 && r[0] == k && r[1] == (uint8_t) ~k && r[2] == 0xA5 && e3 != 0 && e4 == 0 &&
e5 == 0;
if (ok) id(n_ok)++; else id(n_bad)++;
ESP_LOGI("t", "RW %s e1 %d e2 %d got %02X %02X %02X want %02X %02X A5 | %u us | absent e %d | stretch500 e %d %u us | stretch5ms e %d %u us | ok %u bad %u",
ok ? "OK" : "BAD", e1, e2, r[0], r[1], r[2], k, (uint8_t) ~k, (unsigned) (t1 - t0), e3, e4,
(unsigned) (t3 - t2), e5, (unsigned) (t4 - t3), (unsigned) id(n_ok), (unsigned) id(n_bad));
@@ -0,0 +1,50 @@
#pragma once
#include <Wire.h>
#include "esphome/core/log.h"
// Fake HDC1080 at 0x40: 0x00 temperature (26.0 C), 0x01 humidity (50 %), other registers are a
// 256 byte file; reading 0x7E holds SCL for 500 us, reading 0x7F for 5 ms.
static volatile uint8_t SL_REGS[256];
static volatile uint8_t sl_ptr = 0;
static volatile uint32_t sl_rx = 0, sl_tx = 0, sl_stretch = 0;
static void sl_on_receive(int n) {
if (n < 1)
return;
sl_ptr = Wire.read();
uint8_t p = sl_ptr;
while (--n > 0 && Wire.available())
SL_REGS[p++] = Wire.read();
sl_rx++;
}
static void sl_on_request() {
uint8_t b[8];
uint8_t p = sl_ptr;
sl_tx++;
if (p == 0x7E || p == 0x7F) {
sl_stretch++;
delayMicroseconds(p == 0x7E ? 500 : 5000);
}
for (int i = 0; i < 8; i++)
b[i] = SL_REGS[(uint8_t) (p + i)];
if (p == 0x00) {
b[0] = 0x66;
b[1] = 0x66;
} else if (p == 0x01) {
b[0] = 0x80;
b[1] = 0x00;
}
Wire.write(b, 8);
}
inline void i2cslave_begin() {
Wire.begin(4, 5, 0x40);
Wire.onReceive(sl_on_receive);
Wire.onRequest(sl_on_request);
ESP_LOGI("slave", "I2C slave at 0x40 on SDA GPIO4 SCL GPIO5");
}
inline void i2cslave_report() {
ESP_LOGI("slave", "rx %u tx %u stretch %u", (unsigned) sl_rx, (unsigned) sl_tx, (unsigned) sl_stretch);
}
@@ -0,0 +1,29 @@
esphome:
name: i2cslave
libraries:
- Wire
includes:
- i2cslave.hpp
platformio_options:
board_build.f_cpu: 160000000L
on_boot:
priority: -100
then:
- lambda: i2cslave_begin();
esp8266:
board: d1_mini
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ota:
- platform: esphome
interval:
- interval: 10s
then:
- lambda: i2cslave_report();
@@ -0,0 +1,43 @@
"""Check over the API that each board runs the expected build.
Usage: apiinfo.py HOST[=BUILD_INFO_JSON]...
Prints name and compilation time per host. With a build_info.json (from the build directory), the board's
compilation time must equal that build's build_time_str. Exits 1 when a host fails or runs another build.
env: API_KEY (the boards' api encryption key, when they have one)"""
import asyncio
import json
import os
from pathlib import Path
import sys
from aioesphomeapi import APIClient, APIConnectionError
async def info(host: str) -> tuple[str, str]:
cli = APIClient(host, 6053, None, noise_psk=os.environ.get("API_KEY"))
await cli.connect(login=True)
try:
i = await cli.device_info()
finally:
await cli.disconnect()
return i.name, i.compilation_time
bad = 0
for arg in sys.argv[1:]:
host, _, build_info = arg.partition("=")
try:
name, built = asyncio.run(asyncio.wait_for(info(host), 30))
except (APIConnectionError, TimeoutError, OSError) as err:
print("API", host, "ERROR", repr(err), flush=True)
bad += 1
continue
want = (
json.loads(Path(build_info).read_text(encoding="utf-8"))["build_time_str"]
if build_info
else None
)
ok = want is None or built == want
bad += not ok
print("API", host, name, built, "" if ok else f"MISMATCH want {want}", flush=True)
sys.exit(1 if bad else 0)
@@ -0,0 +1,42 @@
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
substitutions:
name: inflbench-bk
board: cb3s
esphome:
name: ${name}
includes:
- bench_streams.hpp
- bench.hpp
bk72xx:
board: ${board}
logger:
hardware_uart: UART1
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
udp:
addresses:
- !secret syslog_ip
time:
- platform: sntp
syslog:
port: 5514
interval:
- interval: 90s
then:
- delay: 20s
- lambda: run_inflate_bench();
@@ -0,0 +1,44 @@
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
substitutions:
name: inflbench-s3
esphome:
name: ${name}
includes:
- bench_streams.hpp
- bench.hpp
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
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
udp:
addresses:
- !secret syslog_ip
time:
- platform: sntp
syslog:
port: 5514
interval:
- interval: 90s
then:
- delay: 20s
- lambda: run_inflate_bench();
@@ -0,0 +1,54 @@
#pragma once
// On-device inflate speed for the ESPHome OTA decoder. Targets the decoder interface with dest_limit (dev as of
// this writing); once the interface without dest_limit lands (#21641), drop the dest_limit line.
#include "esphome/components/esphome/ota/ota_esphome_inflate.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "bench_streams.hpp"
static uint8_t bench_ring[32768];
static OtaInflateState bench_state;
// LibreTiny defines micros as a macro; the parentheses call the ESPHome function.
inline uint32_t bench_now() { return (esphome::micros) (); }
inline void run_inflate_bench() {
unsigned expected = 0;
for (const auto &c : BENCH_CASES) {
for (unsigned ring : {4096u, 8192u, 16384u, 32768u})
expected += ring >= c.enc_window;
}
ESP_LOGI("bench", "BENCHSTART cases=%u", expected);
for (const auto &c : BENCH_CASES) {
for (unsigned ring : {4096u, 8192u, 16384u, 32768u}) {
if (ring < c.enc_window)
continue;
uint32_t best = UINT32_MAX;
size_t out = 0;
int res = 0;
for (int rep = 0; rep < 3; rep++) {
ota_inflate_init(&bench_state, bench_ring, ring);
bench_state.source = c.data;
bench_state.source_limit = c.data + c.len;
bench_state.source_read_cb = [](OtaInflateState *) -> int { return -1; };
out = 0;
uint32_t t0 = bench_now();
do {
bench_state.dest = bench_ring;
bench_state.dest_limit = bench_ring + ring;
res = ota_inflate(&bench_state);
out += bench_state.dest - bench_ring;
esphome::App.feed_wdt();
} while (res == OTA_INFLATE_OK);
uint32_t dt = bench_now() - t0;
if (dt < best)
best = dt;
}
ESP_LOGI("bench", "%s enc=%u ring=%u in=%u out=%u/%u res=%d best_us=%u out_kBps=%u in_kBps=%u", c.name,
c.enc_window, ring, (unsigned) c.len, (unsigned) out, (unsigned) c.raw, res, (unsigned) best,
(unsigned) ((uint64_t) out * 1000 / best), (unsigned) ((uint64_t) c.len * 1000 / best));
}
}
ESP_LOGI("bench", "BENCHDONE");
}
@@ -0,0 +1,42 @@
#!/bin/bash
# usage: bench.sh CONFIG DEVICE build and upload CONFIG (bench-bk72xx or bench-esp32s3) to DEVICE (OTA address or
# serial port), then collect one benchmark pass over syslog. Run gen_streams.py in this directory first.
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, syslog_ip = this
# host's address) and run it there. env: TREE (esphome checkout under test), PY (checkout python), WAIT (seconds to
# wait for a pass, default 300)
D=$(cd "$(dirname "$0")" && pwd); C=${1:?config name}; DEV=${2:?device}; T=${TREE:?set TREE}
PY=${PY:-$T/venv/bin/python}; WAIT=${WAIT:-300}; OUT=$D/$C-syslog.txt
[ -f "$D/bench_streams.hpp" ] || { echo "FAIL: run gen_streams.py first"; exit 1; }
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$C.yaml" --device "$DEV" > "$D/$C-upload.log" 2>&1) \
|| { echo "FAIL: build or upload failed, see $C-upload.log"; exit 1; }
$PY - "$OUT" "$WAIT" << 'PYEOF'
import socket
import sys
import time
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 5514))
s.settimeout(1)
end = time.time() + int(sys.argv[2])
started = False
with open(sys.argv[1], "w", encoding="utf-8") as out:
while time.time() < end:
try:
d, _ = s.recvfrom(2048)
except TimeoutError:
continue
line = d.decode(errors="replace").strip()
# keep one whole pass: lines from a pass already under way are dropped
started = started or "BENCHSTART" in line
if started and "bench" in line:
print(line, file=out, flush=True)
if "BENCHDONE" in line:
break
PYEOF
grep -q BENCHDONE "$OUT" || { echo "FAIL: no complete pass within $WAIT s"; exit 1; }
grep -o 'bench:[0-9]*\]: .*' "$OUT"
BAD=$(grep 'best_us=' "$OUT" | grep -v 'res=1 ' | wc -l | tr -d ' ')
SHORT=$(grep -o 'out=[0-9]*/[0-9]*' "$OUT" | awk -F'[=/]' '$2 != $3' | wc -l | tr -d ' ')
WANT=$(grep -o 'BENCHSTART cases=[0-9]*' "$OUT" | cut -d= -f2); GOT=$(grep -c 'best_us=' "$OUT")
[ "$BAD" = 0 ] && [ "$SHORT" = 0 ] && [ "$GOT" = "$WANT" ] && echo "PASS" \
|| { echo "FAIL: $BAD cases did not finish, $SHORT short outputs, $GOT of $WANT results (lost syslog lines)"; exit 1; }
@@ -0,0 +1,43 @@
"""Write bench_streams.hpp: one 128 KB slice per image, raw deflate at level 9 with 4 KB and 32 KB windows.
Usage: gen_streams.py NAME=IMAGE[@OFFSET]... [--windows 12,15]
Each image gives one case per window. Keep the total near 130 KB on a BK7231N: an image with about 330 KB of extra
incompressible data failed its OTA ("Writing OTA data to flash memory failed"); use one image and --windows 12 there."""
import argparse
from pathlib import Path
import zlib
SLICE = 128 * 1024
ap = argparse.ArgumentParser()
ap.add_argument("images", nargs="+")
ap.add_argument("--windows", default="12,15")
args = ap.parse_args()
arrays, cases, total = [], [], 0
for spec in args.images:
name, _, rest = spec.partition("=")
path, _, offset = rest.partition("@")
raw = Path(path).read_bytes()[int(offset or 0) :][:SLICE]
for wbits in (int(w) for w in args.windows.split(",")):
data = zlib.compress(raw, 9, -wbits)
total += len(data)
var = f"{name}_w{wbits}"
arrays.append(
f"static const uint8_t {var}[{len(data)}] = {{{','.join(map(str, data))}}};"
)
cases.append(f'{{"{name}", {var}, sizeof({var}), {len(raw)}u, {1 << wbits}u}}')
print(f"{var}: {len(raw)} -> {len(data)} bytes")
head = "#pragma once\n#include <cstddef>\n#include <cstdint>\n"
case_struct = (
"struct BenchCase {\n const char *name;\n const uint8_t *data;\n size_t len;\n size_t raw;\n"
" unsigned enc_window;\n};\n"
)
body = (
"\n".join(arrays)
+ "\n"
+ case_struct
+ "static const BenchCase BENCH_CASES[] = {"
+ ", ".join(cases)
+ "};\n"
)
Path("bench_streams.hpp").write_text(head + body, encoding="utf-8")
print(f"total {total} bytes of streams")
@@ -0,0 +1,77 @@
"""Time one ESPHome OTA upload and print one RESULT json line.
Usage: otatime.py HOST IMAGE MODE (MODE: deflate, raw, deflate-tcp or raw-tcp)
env: OTA_PORT (default 3232), OTA_PASSWORD, API_KEY (a board with an api encryption key also encrypts OTA),
TAG (label stored in the result)
Run it with the checkout under test first on PYTHONPATH, so its espota2 is the client being measured.
raw: the client leaves deflate out of its feature offer. Never patch the deflate branch off after the offer: once
the device accepted deflate it expects a deflate stream. -tcp: the link is reported clean, so the client asks to
stay on TCP. Clearing the UDP feature bit instead desyncs the handshake ("receiving UDP token response: timed out")."""
import json
import logging
import os
from pathlib import Path
import sys
import time
import esphome.espota2 as espota
host, image, mode = sys.argv[1], Path(sys.argv[2]), sys.argv[3]
if mode.startswith("raw"):
espota.CLIENT_FEATURE_SUPPORTS_DEFLATE = 0
if mode.endswith("-tcp"):
espota._loss_reason = lambda *a: None # noqa: SLF001
marks: list[tuple[float, float]] = []
class Recorder:
"""Stands in for the progress bar and records when each fraction went out."""
def __init__(self, *args) -> None:
pass
def update(self, progress: float) -> None:
marks.append((time.perf_counter(), progress))
def done(self) -> None:
pass
espota.ProgressBar = Recorder
lines: list[str] = []
class Keep(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
lines.append(record.getMessage())
logging.basicConfig(level=logging.INFO)
logging.getLogger().addHandler(Keep())
t0 = time.perf_counter()
rc, _ = espota.run_ota(
host,
int(os.environ.get("OTA_PORT", "3232")),
os.environ.get("OTA_PASSWORD"),
image,
noise_psk=os.environ.get("API_KEY"),
)
total = time.perf_counter() - t0
deciles = []
if marks:
start = marks[0][0]
for q in range(1, 11):
hit = next((t for t, f in marks if f >= q / 10 - 1e-9), None)
deciles.append(round(hit - start, 2) if hit is not None else None)
result = {
"board": os.environ.get("TAG", ""),
"mode": mode,
"rc": rc,
"total": round(total, 2),
"took": next((x for x in lines if x.startswith("Update took")), ""),
"size": next((x for x in lines if "Compressed to" in x), ""),
"udp": any("Using UDP" in x for x in lines),
"deciles": deciles,
}
print("RESULT", json.dumps(result), flush=True)
sys.exit(1 if rc else 0)
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# usage: run.sh N PHASE... OTA upload timing A/B: per phase build every board, one warm-up upload per board
# (installs the phase's build, not timed), an API check that each board booted that build, then N rounds of every
# MODE on every board, interleaved so the boards share the same air. summ.py prints medians at the end.
# Layout: $D/PHASE/NAME.yaml per phase and board (with its secrets.yaml); outputs go to $D/res.txt.
# env: BOARDS ("NAME,HOST,OTA_PORT ..."; add an ESP32 on the same Wi-Fi as a network control), TREE_<PHASE>
# (esphome checkout for each phase, e.g. TREE_base, TREE_pr), PY (python with esphome's requirements),
# MODES (default "deflate-tcp raw-tcp"), SETTLE (seconds after a board answers again, default 15), API_KEY (when the
# boards have an api encryption key; it also encrypts their OTA, so all boards in one run need the same key)
D=$(cd "$(dirname "$0")" && pwd); N=${1:?rounds}; shift; [ $# -gt 0 ] || { echo "usage: run.sh N PHASE..."; exit 2; }
B=${BOARDS:?set BOARDS}; PY=${PY:?set PY}; MODES=${MODES:-deflate-tcp raw-tcp}; SETTLE=${SETTLE:-15}
wait_up() { # wait_up HOST PORT: up to 5 minutes for the OTA port to answer
local i
for i in $(seq 60); do
"$PY" -c "import socket,sys; socket.create_connection((sys.argv[1], int(sys.argv[2])), 2).close()" "$1" "$2" 2>/dev/null \
&& { sleep "$SETTLE"; return 0; }
sleep 5
done
return 1
}
up() { # up PHASE NAME HOST PORT MODE TAG OUTFILE; returns 1 unless the upload succeeded
local tree=TREE_$1 out=""
if wait_up "$3" "$4"; then
out=$(TAG=$1-$2-$6 OTA_PORT=$4 PYTHONPATH=${!tree} timeout 900 "$PY" "$D/otatime.py" "$3" \
"$(cat "$D/img-$1-$2.txt")" "$5" 2>> "$D/stderr-$1.log" | grep RESULT)
else
echo "FAIL: $3 not answering on $4"
fi
# no RESULT line (board gone, otatime.py crashed or timed out): record a failed upload so summ.py counts it
[ -n "$out" ] || out="RESULT {\"board\": \"$1-$2-$6\", \"mode\": \"$5\", \"rc\": -1, \"total\": 0, \"took\": \"\", \"size\": \"\", \"udp\": false, \"deciles\": []}"
echo "$out" | tee -a "$7"
[[ $out == *'"rc": 0,'* ]]
}
# results from an earlier run would fill in for missing rows: move them aside
for f in res.txt warm.txt api.txt; do [ -f "$D/$f" ] && mv "$D/$f" "$D/$f.$(date +%Y%m%d-%H%M%S)"; done
for ph in "$@"; do
tree=TREE_$ph; [ -n "${!tree}" ] || { echo "FAIL: set $tree"; exit 1; }
checks=()
for b in $B; do
IFS=, read -r n h p <<< "$b"
(cd "${!tree}" && PYTHONUNBUFFERED=1 timeout 1500 "$PY" -m esphome compile "$D/$ph/$n.yaml") > "$D/build-$ph-$n.log" 2>&1 \
|| { echo "FAIL: build $ph/$n"; exit 1; }
# the image the CLI uploads (CORE.firmware_bin): ESP-IDF build, LibreTiny UF2, else the PlatformIO bin
bd=$D/$ph/.esphome/build/$n; : > "$D/img-$ph-$n.txt"
for f in "$bd/build/$n.bin" "$bd/.pioenvs/$n/firmware.uf2" "$bd/.pioenvs/$n/firmware.bin"; do
[ -f "$f" ] && { echo "$f" > "$D/img-$ph-$n.txt"; break; }
done
[ -s "$D/img-$ph-$n.txt" ] || { echo "FAIL: no firmware image under $bd"; exit 1; }
case $(cat "$D/img-$ph-$n.txt") in
*.uf2) PYTHONPATH=${!tree} "$PY" "$D/trim_uf2.py" "$(cat "$D/img-$ph-$n.txt")" > "$D/img-$ph-$n.tmp" \
&& mv "$D/img-$ph-$n.tmp" "$D/img-$ph-$n.txt" || { echo "FAIL: trim_uf2.py $ph/$n"; exit 1; } ;;
esac
checks+=("$h=$D/$ph/.esphome/build/$n/build_info.json")
done
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" deflate-tcp warm "$D/warm.txt" || exit 1; done
for b in $B; do IFS=, read -r n h p <<< "$b"; wait_up "$h" "$p"; done
"$PY" "$D/apiinfo.py" "${checks[@]}" | tee -a "$D/api.txt"; [ "${PIPESTATUS[0]}" = 0 ] || { echo "FAIL: $ph did not boot everywhere"; exit 1; }
for i in $(seq "$N"); do
for m in $MODES; do
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" "$m" "r$i" "$D/res.txt"; done
done
done
echo "PHASE DONE $ph"
done
"$PY" "$D/summ.py" "$D/res.txt" "$N"
@@ -0,0 +1,43 @@
"""Median upload time per board and mode from otatime.py RESULT lines.
Usage: summ.py RESULTS_FILE [ROUNDS]
Exits 1 when any upload failed, an upload time could not be read, or (with ROUNDS) a board and mode has fewer rows."""
import json
from pathlib import Path
import re
import statistics
import sys
rows = [
json.loads(line.split("RESULT ", 1)[1])
for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
if "RESULT " in line
]
by: dict[tuple[str, str], list[dict]] = {}
for r in rows:
m = re.search(r"upload ([\d.]+)", r["took"])
r["up"] = float(m[1]) if m else None
# TAG is PHASE-BOARD-ROUND; group per phase and board
by.setdefault((r["board"].rsplit("-", 1)[0], r["mode"]), []).append(r)
rounds = int(sys.argv[2]) if len(sys.argv) > 2 else 0
failed = unparsed = short = 0
for (board, mode), v in sorted(by.items()):
ok = sorted(r["up"] for r in v if r["rc"] == 0 and r["up"] is not None)
fails = sum(r["rc"] != 0 for r in v)
failed += fails
unparsed += sum(r["rc"] == 0 and r["up"] is None for r in v)
short += len(v) < rounds
udp = sum(r["udp"] for r in v if r["rc"] == 0)
med = statistics.median(ok) if ok else None
print(
f"{board:24} {mode:12} n={len(v)} fail={fails} udp={udp} median={med} upload s: {ok}"
)
if not rows:
print("FAIL: no results")
elif failed or unparsed or short:
print(
f"FAIL: {failed} failed uploads, {unparsed} without an upload time, {short} groups under {rounds} rows"
)
else:
print("PASS")
sys.exit(0 if rows and not (failed or unparsed or short) else 1)
@@ -0,0 +1,24 @@
"""Write the trimmed LibreTiny UF2 the CLI would send (esphome#21642) next to firmware.uf2 and print its path.
Usage: trim_uf2.py FIRMWARE_UF2 (run with the phase's checkout on PYTHONPATH)
Prints the input path unchanged when the tree has no device_ota_uf2() or there is nothing to drop."""
from pathlib import Path
import sys
src = Path(sys.argv[1])
try:
from esphome.components.libretiny.uf2 import device_ota_uf2
except ModuleNotFoundError as err:
if err.name != "esphome.components.libretiny.uf2":
raise
print("no device_ota_uf2() in this tree, sending the full UF2", file=sys.stderr)
print(src)
sys.exit(0)
data = src.read_bytes()
if (trimmed := device_ota_uf2(data)) is None:
print(src)
sys.exit(0)
out = src.with_name("firmware.ota.uf2")
out.write_bytes(trimmed)
print(f"trimmed {len(data)} -> {len(trimmed)} bytes", file=sys.stderr)
print(out)
@@ -0,0 +1,95 @@
"""Summarise the AP restart run: alive lines over syslog per DUT, reboots, gaps, recovery after each AP start.
Usage: an.py DIR BUILD [RECOVER_S] reads BUILD-marks.txt, BUILD-syslog.txt, BUILD-bk.txt, BUILD-cb.txt (optional)
and BUILD-up.txt (the bridge's API logs)
Exits 1 when a DUT rebooted, crashed, sent nothing, or had no syslog line within RECOVER_S (default 120) of an
AP start, when the BK7238 capture (or a CB3S capture that was started) is empty, or when the bridge did not switch
its AP to WPA2/WPA3 mixed after each AP start."""
from itertools import pairwise
from pathlib import Path
import re
import sys
D, b = Path(sys.argv[1]), sys.argv[2]
RECOVER = int(sys.argv[3]) if len(sys.argv) > 3 else 120
def read(suffix: str) -> str | None:
f = D / f"{b}-{suffix}.txt"
return f.read_text(encoding="utf-8", errors="replace") if f.exists() else None
def secs(t: str) -> int:
h, m, s = t.split(":")
return int(h) * 3600 + int(m) * 60 + int(float(s))
marks = [
(secs(line.split()[0]), line.split()[3])
for line in read("marks").splitlines()
if line.strip()
]
sl = (read("syslog") or "").splitlines()
bad = []
for dev in ("rebk", "recb"):
lines = [line for line in sl if re.search(rf"\b{dev}\b", line)]
ups = [
(secs(line.split()[0]), int(m[1]))
for line in lines
if (m := re.search(r"up (\d+) s", line))
]
if not ups:
print(f"== {dev}: no alive lines over syslog")
bad.append(f"{dev} silent")
continue
reboots = sum(1 for (_, u0), (_, u1) in pairwise(ups) if u1 < u0)
gaps = [t1 - t0 for (t0, _), (t1, _) in pairwise(ups) if t1 - t0 > 25]
print(
f"== {dev}: alive lines {len(ups)}, reboots {reboots}, syslog gaps over 25 s: {gaps}"
)
if reboots:
bad.append(f"{dev} rebooted")
for t, st in marks:
if st != "on":
continue
after = [x for x in ups if x[0] > t]
first = after[0][0] - t if after else None
print(
f" ap back at t={t % 86400 // 3600:02d}:{t % 3600 // 60:02d}:{t % 60:02d}, first syslog line {first} s later"
)
if first is None or first > RECOVER:
bad.append(f"{dev} not back {RECOVER} s after an AP start")
for dev, suffix in (("rebk", "bk"), ("recb", "cb")):
if (s := read(suffix)) is None and dev == "recb":
continue # the CB3S capture is optional
if not s:
bad.append(f"{dev} serial capture missing or empty")
continue
crashes = len(re.findall(r"(?i)hard fault|exception|abort|watchdog", s))
connected = len(re.findall(r"\[wifi:\d+\]: Connected", s))
print(
f"-- {dev} serial: lines {s.count(chr(10))}, overflow {s.count('queue overflow')}, fallback AP starts "
f"{s.count('Starting fallback AP')}, connected {connected}, boots {s.count('ESPHome version')}, crashes {crashes}"
)
if crashes:
bad.append(f"{dev} crashed")
up = read("up") or ""
switches = [
secs(m[1]) for m in re.finditer(r"\[(\d+:\d+:\d+)[.\d]*\].*PMF capable: 0", up)
]
# each AP start needs its own switch between that mark (less a few seconds of clock skew) and the next mark
ends = [t for t, _ in marks[1:]] + [float("inf")]
missed = [
t
for (t, st), end in zip(marks, ends, strict=True)
if st == "on" and not any(t - 5 <= s < end for s in switches)
]
starts = sum(1 for _, st in marks if st == "on")
print(
f"-- bridge: WPA2/WPA3 switches {len(switches)}, AP starts {starts} ({len(missed)} without a switch), "
f"failures {up.count('AP CONFIG FAILED')}"
)
if "AP CONFIG FAILED" in up or missed:
bad.append("the bridge did not switch its AP to WPA2/WPA3 mixed after every start")
print("PASS" if not bad else "FAIL: " + ", ".join(bad))
sys.exit(1 if bad else 0)
@@ -0,0 +1,58 @@
esphome:
name: ${name}
logger:
level: DEBUG
wifi:
networks:
- ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
manual_ip:
static_ip: ${ip}
gateway: 10.40.0.1
subnet: 255.255.255.0
dns1: 10.40.0.1
power_save_mode: LIGHT
ap:
ssid: ${name}-fallback
password: !secret fallback_ap_password
captive_portal:
web_server:
port: 80
api:
reboot_timeout: 0s
ota:
- platform: esphome
udp:
addresses:
- !secret syslog_ip
time:
- platform: sntp
syslog:
port: 5514
interval:
- interval: 10s
then:
- lambda: ESP_LOGI("alive", "up %u s", (unsigned) (millis() / 1000));
- interval: 30s
then:
- if:
condition:
lambda: |-
static bool done = false;
if (done || millis() < 19 * 60 * 1000) return false;
done = true;
return true;
then:
- lambda: ESP_LOGI("alive", "GOING HOME");
- wifi.configure:
ssid: !secret wifi_ssid
password: !secret wifi_password
save: false
@@ -0,0 +1,13 @@
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
substitutions:
name: rebk
ip: 10.40.0.21
packages:
common: !include re-common.yaml
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
@@ -0,0 +1,13 @@
# CB3S (BK7231N) DUT, flashed over the air by run.sh; its log port is UART1 (TX1)
substitutions:
name: recb
ip: 10.40.0.22
packages:
common: !include re-common.yaml
bk72xx:
board: cb3s
logger:
hardware_uart: UART1
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# usage: run.sh BUILD OTA the CB3S, serial flash the BK7238, then switch the upstream AP off and on three times
# while both DUTs send alive lines over syslog. BUILD is a label (pr, base) for the output files.
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wisp_up_ssid,
# wisp_up_password, fallback_ap_password, syslog_ip, api_key) and run it there. wisp-upstream-wpa3.yaml runs on the
# ethernet ESP32-S3 upstream; syslog_ip is this host's address as the DUTs reach it through the bridge's NAT.
# env: TREE (esphome checkout for the DUTs), BK_PORT (BK7238 serial), CB_PORT (CB3S log only serial, optional),
# CB_HOST (CB3S address to OTA), UPSTREAM (bridge address), SC (path to serial_capture.py), FLASH_BK (path to
# flash_bk72xx.py), LT (LibreTiny penv python), PY (checkout python), UP_TREE (checkout for the bridge's API logs)
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
BK=${BK_PORT:?set BK_PORT}; CBH=${CB_HOST:?set CB_HOST}; UP=${UPSTREAM:?set UPSTREAM}; SC=${SC:?set SC}
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}; CB=${CB_PORT:-}
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
# only this run's ports: a global pkill stops other runs' captures
pkill -f "[s]erial_capture.py $BK"; [ -n "$CB" ] && pkill -f "[s]erial_capture.py $CB"
[ "$(ap on)" = 200 ] || { echo "FAIL: upstream bridge did not take ap on"; exit 1; }
cb_upload() { build run --no-logs "$D/recb.yaml" --device "$CBH" > "$D/cb-$b-upload.log" 2>&1; }
retry 3 15 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
build compile "$D/rebk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
UF2=$D/.esphome/build/rebk/.pioenvs/rebk/firmware.uf2
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
PIDS=()
$PY - > "$D/$b-syslog.txt" 2>&1 << 'PYEOF' &
import socket
import time
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 5514))
s.settimeout(1)
end = time.time() + 1250
while time.time() < end:
try:
d, a = s.recvfrom(2048)
print(time.strftime("%H:%M:%S"), a[0], d.decode(errors="replace").strip(), flush=True)
except TimeoutError:
pass
PYEOF
PIDS+=($!)
$PY "$SC" "$BK" 1240 "$D/$b-bk.txt" --released > /dev/null 2>&1 & PIDS+=($!)
[ -n "$CB" ] && { $PY "$SC" "$CB" 1240 "$D/$b-cb.txt" --released > /dev/null 2>&1 & PIDS+=($!); }
(cd "${UP_TREE:-$T}" && PYTHONUNBUFFERED=1 exec timeout 1240 $PY -m esphome logs "$D/wisp-upstream-wpa3.yaml" --device "$UP" > "$D/$b-up.txt" 2>&1) & PIDS+=($!)
trap 'kill "${PIDS[@]}" 2>/dev/null' EXIT
t0=$(date +%s); : > "$D/$b-marks.txt"
for step in 180:off 330:on 480:off 630:on 780:off 930:on; do
t=${step%%:*}; st=${step#*:}
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
code=$(ap "$st")
echo "$(date +%H:%M:%S) t=$t ap $st $code" | tee -a "$D/$b-marks.txt"
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
done
wait "${PIDS[@]}"; trap - EXIT
$PY "$D/an.py" "$D" "$b"
@@ -0,0 +1,3 @@
#pragma once
#include <esp_wifi.h>
#include <esp_event.h>
@@ -0,0 +1,95 @@
esphome:
name: wisp-upstream
includes:
- wdap_inc.h
on_boot:
priority: -100
then:
- delay: 3s
- script.execute: wpa3_ap
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
wifi:
ap:
ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
ap_mode: WISP
manual_ip:
static_ip: 10.40.0.1
gateway: 10.40.0.1
subnet: 255.255.255.0
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
button:
- platform: template
name: ap off
on_press:
- wifi.disable:
- platform: template
name: ap on
on_press:
- wifi.enable:
- delay: 3s
- script.execute: wpa3_ap
script:
- id: wpa3_ap
then:
- lambda: |-
static bool registered = false;
wifi_config_t c{};
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
if (err == ESP_OK) {
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
c.ap.pmf_cfg.required = false;
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
err = esp_wifi_set_config(WIFI_IF_AP, &c);
}
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
wifi_config_t check{};
if (err == ESP_OK)
err = esp_wifi_get_config(WIFI_IF_AP, &check);
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
} else {
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
}
if (registered) return;
registered = true;
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
if (id == WIFI_EVENT_AP_STACONNECTED) {
auto *e = (wifi_event_ap_staconnected_t *) data;
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5]);
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
auto *e = (wifi_event_ap_stadisconnected_t *) data;
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X reason %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->reason);
}
}, nullptr);
@@ -0,0 +1,16 @@
substitutions:
name: wispdut-aoi
framework: esp-idf
packages:
common: !include dut-common.yaml
wifi:
networks:
- ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
post_connect_roaming: false
ap:
ssid: !secret dut_ap_ssid
password: !secret dut_ap_password
ap_mode: ALWAYS_ON
web_server:
port: 80
@@ -0,0 +1,16 @@
esphome:
name: ${name}
esp32:
board: esp32dev
framework:
type: ${framework}
logger:
level: DEBUG
api:
reboot_timeout: 0s
ota:
- platform: esphome
@@ -0,0 +1,14 @@
substitutions:
name: wispdut-wisp
framework: esp-idf
packages:
common: !include dut-common.yaml
wifi:
networks:
- ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
ap:
ssid: !secret dut_ap_ssid
password: !secret dut_ap_password
ap_mode: WISP
captive_portal:
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# usage: run.sh DUT START SECONDS T:STATE... e.g. run.sh dut-wisp on 600 120:off 300:on
# Builds and flashes DUT.yaml (dut-wisp or dut-always-on) over serial, sets the upstream AP to START, captures the
# DUT and the client, and switches the upstream AP at each T seconds; sum.py summarises the client per phase.
# Copy this directory into a scratch directory with a secrets.yaml (wisp_up_ssid, wisp_up_password, dut_ap_ssid,
# dut_ap_password, lan_probe_url, api_key) and run it there. wisp-upstream.yaml runs on an ethernet ESP32-S3 and
# wispcli.yaml on a D1 mini; flash both once (CLI_FLASH=1 builds and flashes the client too).
# env: TREE (esphome checkout for the DUT), PORT (DUT serial), CLI_PORT (client serial), UPSTREAM (upstream bridge
# address), SC (path to serial_capture.py), PY (checkout python)
D=$(cd "$(dirname "$0")" && pwd); n=${1:?dut yaml name}; start=${2:?on or off}; dur=${3:?seconds}; shift 3
T=${TREE:?set TREE}; P=${PORT:?set PORT}; CP=${CLI_PORT:?set CLI_PORT}; UP=${UPSTREAM:?set UPSTREAM}
SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
pkill -f "[s]erial_capture.py ($P|$CP)"
[ "$(ap "$start")" = 200 ] || { echo "FAIL: upstream bridge did not take ap $start"; exit 1; }
if [ "${CLI_FLASH:-0}" = 1 ]; then
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/wispcli.yaml" --device "$CP" > "$D/wispcli-upload.log" 2>&1) \
|| { echo "FAIL: client build or upload"; exit 1; }
fi
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$n.yaml" --device "$P" > "$D/$n-upload.log" 2>&1) \
|| { echo "FAIL: DUT build or upload"; exit 1; }
echo "$(date +%H:%M:%S) t=0 upstream $start start" > "$D/$n-marks.txt" # phase 1 is the start state
$PY "$SC" "$P" "$dur" "$D/$n-dut.txt" --reset > /dev/null 2>&1 & C1=$!
# no --reset on the client: a CH340 D1 mini floods garbage after an RTS reset
$PY "$SC" "$CP" "$dur" "$D/$n-cli.txt" > /dev/null 2>&1 & C2=$!
trap 'kill $C1 $C2 2>/dev/null' EXIT
t0=$(date +%s)
for step in "$@"; do
t=${step%%:*}; st=${step#*:}
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
code=$(ap "$st")
echo "$(date +%H:%M:%S) t=$t upstream $st $code" | tee -a "$D/$n-marks.txt"
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
done
wait $C1 $C2; trap - EXIT
$PY "$D/sum.py" "$D" "$n"
@@ -0,0 +1,80 @@
"""Summarise the client's results per upstream phase and the DUT's connects, AP starts, crashes and boots.
Usage: sum.py DIR NAME reads NAME-marks.txt, NAME-cli.txt and NAME-dut.txt
Phase N runs from mark N (the first mark is the start state). Exits 1 when the DUT crashed, the DUT capture does not
hold exactly one boot (empty or rebooted), the client had no successful result at all, or a phase with the upstream
on had no successful name lookup (an answer equal to the gateway is the captive portal and does not count)."""
from collections import defaultdict
from pathlib import Path
import re
import sys
D, n = Path(sys.argv[1]), sys.argv[2]
def read(suffix: str) -> str:
return (D / f"{n}-{suffix}.txt").read_text(encoding="utf-8", errors="replace")
mark_lines = [line.split() for line in read("marks").splitlines() if line.strip()]
marks = [m[0] for m in mark_lines]
upstream = {
i + 1: m[3] for i, m in enumerate(mark_lines)
} # "HH:MM:SS t=N upstream on|off CODE"
cli = [line for line in read("cli").splitlines() if "[cli" in line]
def phase(t: str) -> int:
return sum(1 for m in marks if t >= m)
stat: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
good: dict[int, int] = defaultdict(int) # successful name lookups per phase
any_ok = 0
for line in cli:
m = re.search(r"\]: (NOT CONNECTED|NET .*|NAME \S+|GW \S+|MAC \S+)", line)
if not m:
continue
s = m.group(1)
if s.startswith("NET"):
dns = re.search(r"dns (\S+)", s).group(1)
gw = re.search(r"gw (\S+)", s).group(1)
res = s.split("-> ")[1].strip()
# the captive portal answers every name with the AP's own address; that is not a working upstream
s = f"dns={dns} resolve={'CAPTIVE' if res == gw else res}"
if res not in ("FAIL", gw):
good[phase(line[1:9])] += 1
any_ok += 1
elif re.match(r"(NAME|GW|MAC) 200\b", s):
any_ok += 1
stat[phase(line[1:9])][s] += 1
for p in sorted(stat):
print(f"phase {p} (upstream {upstream.get(p, '?')}):", dict(stat[p]))
dead = [p for p, st in upstream.items() if st == "on" and not good[p]]
dut = read("dut")
crash = len(re.findall(r"Guru|panic|abort\(", dut))
boots = dut.count("ESPHome version")
print(
"DUT connected:",
len(re.findall(r"\[wifi:\d+\]: Connected", dut)),
"AP starts:",
dut.count("Starting AP"),
"crash:",
crash,
"boots:",
boots,
)
failed = []
if not cli:
failed.append("empty client log")
elif not any_ok:
failed.append("the client never got a successful result")
if dead:
failed.append(f"no successful lookup in upstream on phases {dead}")
# run.sh resets the DUT when the capture starts, so a good capture holds exactly one boot
if boots != 1:
failed.append(f"{boots} DUT boots captured, expected 1")
if crash:
failed.append(f"{crash} DUT crash lines")
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
sys.exit(1 if failed else 0)
@@ -0,0 +1,52 @@
esphome:
name: wisp-upstream
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
wifi:
ap:
ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
ap_mode: WISP
manual_ip:
static_ip: 10.40.0.1
gateway: 10.40.0.1
subnet: 255.255.255.0
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
button:
- platform: template
name: ap off
on_press:
- wifi.disable:
- platform: template
name: ap on
on_press:
- wifi.enable:
@@ -0,0 +1,77 @@
esphome:
name: wispcli
esp8266:
board: d1_mini
logger:
level: DEBUG
wifi:
networks:
- ssid: !secret dut_ap_ssid
password: !secret dut_ap_password
reboot_timeout: 0s
ota:
- platform: esphome
http_request:
timeout: 5s
verify_ssl: false
globals:
- id: tick
type: int
initial_value: "0"
interval:
- interval: 3s
then:
- lambda: |-
id(tick)++;
if (id(tick) % 4 != 1) return;
if (!WiFi.isConnected()) { ESP_LOGI("cli", "NOT CONNECTED"); return; }
IPAddress r;
bool ok = WiFi.hostByName("neverssl.com", r, 3000);
ESP_LOGI("cli", "NET ip %s gw %s dns %s | neverssl.com -> %s", WiFi.localIP().toString().c_str(),
WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP(0).toString().c_str(),
ok ? r.toString().c_str() : "FAIL");
- if:
condition:
lambda: return id(tick) % 4 == 2 && WiFi.isConnected();
then:
- http_request.get:
url: http://neverssl.com/
capture_response: true
max_response_buffer_size: 160
collect_headers:
- location
on_response:
- lambda: ESP_LOGI("cli", "NAME %d loc=%s body=%.60s", response->status_code, response->get_response_header("location").c_str(), body.c_str());
on_error:
- lambda: ESP_LOGI("cli", "NAME error");
- if:
condition:
lambda: return id(tick) % 4 == 3 && WiFi.isConnected();
then:
- http_request.get:
url: !lambda 'return std::string("http://") + WiFi.gatewayIP().toString().c_str() + "/";'
capture_response: true
max_response_buffer_size: 160
on_response:
- lambda: ESP_LOGI("cli", "GW %d body=%.60s", response->status_code, body.c_str());
on_error:
- lambda: ESP_LOGI("cli", "GW error");
- if:
condition:
lambda: return id(tick) % 4 == 0 && WiFi.isConnected();
then:
- http_request.get:
url: !secret lan_probe_url
capture_response: true
max_response_buffer_size: 64
on_response:
- lambda: ESP_LOGI("cli", "MAC %d body=%.20s", response->status_code, body.c_str());
on_error:
- lambda: ESP_LOGI("cli", "MAC error");
@@ -0,0 +1,56 @@
"""Summarise the PMF AP's join and leave events per station and the BK7238's joins.
Usage: an.py DIR BUILD [AP_SSID] reads DIR/ap-BUILD.txt and DIR/bk-BUILD.txt (AP_SSID defaults to wd-pmf-ap)
J = join, L<n> = leave with reason n. Reason 3: the station sent a deauth. Reason 2: the AP found a stale
association when the station came back; a run of reason 2 before one join is reconnects the AP rejected.
env DUT_MACS: comma separated station MACs that must appear in the AP events (default: at least two stations).
Exits 1 on a rejected reconnect, an AP that did not switch to WPA2/WPA3, a missing DUT, or a BK7238 that never
joined the AP."""
from itertools import pairwise
import os
from pathlib import Path
import re
import sys
D, b = Path(sys.argv[1]), sys.argv[2]
AP_SSID = sys.argv[3] if len(sys.argv) > 3 else "wd-pmf-ap"
failed = []
ap = (D / f"ap-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
if not any("PMF capable: 0" in line for line in ap) or any(
"AP CONFIG FAILED" in line for line in ap
):
failed.append("the AP did not switch to WPA2/WPA3 mixed")
ev = [line for line in ap if "STA JOIN" in line or "STA LEAVE" in line]
mac_re = re.compile(r"([0-9A-F]{2}(?::[0-9A-F]{2}){5})")
macs = sorted({m.group(1) for line in ev if (m := mac_re.search(line))})
rejected = 0
print(f"== {b}")
for m in macs:
seq = [
"J" if "JOIN" in line else "L" + re.search(r"reason (\d+)", line).group(1)
for line in ev
if m in line
]
# every L2 after the first in a run without a join in between is a rejected reconnect
rej = sum(1 for prev, cur in pairwise(seq) if prev == cur == "L2")
rejected += rej
print(" ", m, " ".join(seq), f"(rejected {rej})")
if rejected:
failed.append(f"{rejected} rejected reconnects")
if want := [
x.strip().upper() for x in os.environ.get("DUT_MACS", "").split(",") if x.strip()
]:
if missing := [x for x in want if x not in macs]:
failed.append(f"no AP events for {', '.join(missing)}")
elif len(macs) < 2:
failed.append(f"AP events for {len(macs)} stations, expected both DUTs")
bk = (D / f"bk-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
joins = [
m.groups() for line in bk if (m := re.search(r"JOINED (.+?) after (\d+) ms", line))
]
print(" bk7238 joins:", " ".join(f"{s}@{int(t) / 1000:.1f}s" for s, t in joins))
print(" bk7238 reboots:", sum("REBOOT on" in line for line in bk))
if not any(s == AP_SSID for s, _ in joins):
failed.append(f"the BK7238 never joined {AP_SSID}")
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
sys.exit(1 if failed else 0)
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# usage: run.sh BUILD flash the PMF AP, OTA the CB3S, serial flash the BK7238, then capture one AP window
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wd_ap_password) and
# run it there. BUILD is a label (pr, base) for the output files.
# env: TREE (esphome checkout for the DUTs), AP_PORT (classic devkit running wdap.yaml), BK_PORT (BK7238 serial),
# CB_HOST (CB3S address on the house network), SC (path to serial_capture.py), FLASH_BK (path to flash_bk72xx.py),
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), PY (checkout python), DUT_MACS
# (optional, comma separated station MACs of both DUTs for an.py)
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
AP=${AP_PORT:?set AP_PORT}; BK=${BK_PORT:?set BK_PORT}; CB=${CB_HOST:?set CB_HOST}; SC=${SC:?set SC}
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
pkill -f "[s]erial_capture.py ($AP|$BK)"
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
build run --no-logs "$D/wdap.yaml" --device "$AP" > "$D/ap-$b-upload.log" 2>&1 || { echo "FAIL: AP build or upload"; exit 1; }
# The DUT reboots 150 s after joining the house network; retry when it reboots mid-upload.
cb_upload() { build run --no-logs "$D/wdcb.yaml" --device "$CB" > "$D/cb-$b-upload.log" 2>&1; }
retry 3 20 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
build compile "$D/wdbk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
# The build directory has two .uf2 files; firmware.uf2 is the one to flash.
UF2=$D/.esphome/build/wdbk/.pioenvs/wdbk/firmware.uf2
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
sleep 20
# The reset restarts the AP's 420 s window.
$PY "$SC" "$AP" 450 "$D/ap-$b.txt" --reset > /dev/null 2>&1 & C1=$!
sleep 8
$PY "$SC" "$BK" 440 "$D/bk-$b.txt" --released > /dev/null 2>&1 & C2=$!
trap 'kill $C1 $C2 2>/dev/null' EXIT
wait $C1 $C2; trap - EXIT
$PY "$D/an.py" "$D" "$b"
@@ -0,0 +1,44 @@
esphome:
name: ${name}
logger:
level: DEBUG
globals:
- id: connect_ms
type: uint32_t
initial_value: "0"
wifi:
networks:
- ssid: ${ap_ssid}
password: !secret wd_ap_password
priority: 10
- ssid: !secret wifi_ssid
password: !secret wifi_password
priority: 0
on_connect:
- lambda: |-
id(connect_ms) = millis();
char buf[wifi::SSID_BUFFER_SIZE];
ESP_LOGI("wd", "JOINED %s after %u ms since boot", wifi::global_wifi_component->wifi_ssid_to(buf), (unsigned) millis());
api:
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 5s
then:
- lambda: |-
if (!wifi::global_wifi_component->is_connected() || id(connect_ms) == 0)
return;
char buf[wifi::SSID_BUFFER_SIZE];
bool on_ap = strcmp(wifi::global_wifi_component->wifi_ssid_to(buf), "${ap_ssid}") == 0;
uint32_t up = millis() - id(connect_ms);
if (up > (on_ap ? 30000u : 150000u)) {
ESP_LOGI("wd", "REBOOT on %s", on_ap ? "ap" : "home");
App.safe_reboot();
}
@@ -0,0 +1,59 @@
substitutions:
ap_ssid: wd-pmf-ap
esphome:
name: wdap
includes:
- wdap_inc.h
on_boot:
priority: -100
then:
- delay: 3s
- lambda: |-
wifi_config_t c{};
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
if (err == ESP_OK) {
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
c.ap.pmf_cfg.required = false;
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
c.ap.max_connection = 4;
err = esp_wifi_set_config(WIFI_IF_AP, &c);
}
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
wifi_config_t check{};
if (err == ESP_OK)
err = esp_wifi_get_config(WIFI_IF_AP, &check);
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
} else {
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
}
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
if (id == WIFI_EVENT_AP_STACONNECTED) {
auto *e = (wifi_event_ap_staconnected_t *) data;
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X aid %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, (unsigned) millis());
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
auto *e = (wifi_event_ap_stadisconnected_t *) data;
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X aid %u reason %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, e->reason, (unsigned) millis());
}
}, nullptr);
- delay: 420s
- lambda: ESP_LOGI("wdap", "AP WINDOW OVER");
- wifi.disable:
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ap:
ssid: ${ap_ssid}
password: !secret wd_ap_password
ap_timeout: 0s
ota:
- platform: esphome
@@ -0,0 +1,3 @@
#pragma once
#include <esp_wifi.h>
#include <esp_event.h>
@@ -0,0 +1,13 @@
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
substitutions:
name: wdbk
ap_ssid: wd-pmf-ap
packages:
common: !include wd-common.yaml
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
@@ -0,0 +1,13 @@
# CB3S (BK7231N) DUT, flashed over the air by run.sh
substitutions:
name: wdcb
ap_ssid: wd-pmf-ap
packages:
common: !include wd-common.yaml
bk72xx:
board: cb3s
logger:
hardware_uart: UART1
@@ -0,0 +1,76 @@
"""Count the Xiaomi, ATC and PVVX publishes in a capture and compare them with expected.json from gen.py.
Usage: count.py CAPTURE [pr|base] [--allow-loss N] [--probe]
--probe (injection run): the PR build must leave the probe frame's service data unchanged.
Exits 0 when every count matches the model (or falls short by at most N over the air), 1 otherwise."""
import argparse
import collections
import json
from pathlib import Path
import re
import sys
ap = argparse.ArgumentParser()
ap.add_argument("capture", type=Path)
ap.add_argument("build", nargs="?", choices=["pr", "base"])
ap.add_argument("--allow-loss", type=int, default=0)
ap.add_argument("--probe", action="store_true")
a = ap.parse_args()
cap, build, loss = a.capture, a.build, a.allow_loss
LETTER = {"0A": "A", "0B": "B", "0C": "C", "0D": "D", "0E": "E", "0F": "F", "10": "G"}
GOT = re.compile(
r"Got (?:Xiaomi \S+|ATC MiThermometer|PVVX MiThermometer) \([AC]4:C1:38:00:00:(\w\w)\)"
)
counts = collections.Counter()
cur = None
humidity = set()
probe = {}
for line in cap.read_text(encoding="utf-8", errors="replace").splitlines():
if m := GOT.search(line):
cur = LETTER[m.group(1)]
continue
if m := re.search(r"\[probe.*\b(before|after)\s+([0-9A-F]+)", line):
probe[m.group(1)] = m.group(2)
if "Humidity:" in line:
humidity.add(line.split("Humidity:")[1].strip())
if (m := re.search(r"Temperature: (-?[\d.]+)", line)) and cur:
t = float(m.group(1))
tag = cur
if (
cur == "A" and t >= 50
): # after-bad frames are 60.1 to 64.0, normal ones 22.1 to 26.0
tag = "A_after_bad"
if cur == "A" and abs(t - 33.3) < 0.01:
tag = "A_probe" # xb_rewrite_probe's own parse, not part of the model
if cur == "D" and abs(t - 11.1) < 0.01:
tag = "D_boot"
counts[tag] += 1
cur = None
print(cap.name, dict(sorted(counts.items())))
print(" humidity values", len(humidity))
if not counts:
print("FAIL: no Xiaomi publishes in the capture")
sys.exit(1)
if build is None:
sys.exit(0)
expected = json.loads(Path("expected.json").read_text(encoding="utf-8"))[build]
bad = 0
for tag in sorted((set(expected) | set(counts)) - {"A_probe"}):
want, got = expected.get(tag, 0), counts.get(tag, 0)
ok = want - loss <= got <= want
bad += not ok
print(f" {tag:12} want {want:3} got {got:3} {'ok' if ok else 'DIFF'}")
if a.probe:
before, after = probe.get("before"), probe.get("after")
print(f" probe before {before}\n probe after {after}")
if before is None or after is None:
print(" probe lines missing")
bad += 1
elif build == "pr" and before != after:
print(" the listener rewrote the shared service data")
bad += 1
print(
f"{'PASS' if not bad else 'FAIL'}: {build} model {'matches' if not bad else f'has {bad} failed checks'}"
)
sys.exit(1 if bad else 0)
@@ -0,0 +1,254 @@
"""Generate MiBeacon, ATC and PVVX advertisements, a C++ header that sends them and the expected publish counts.
Usage: gen.py [--ota]
Without --ota: frames.hpp for xbinject.yaml (scan results injected into the tracker, A4:C1:38 addresses).
With --ota: adv_frames.hpp for xbadv.yaml (a second ESP32 advertises them, C4:C1:38 random static addresses).
Both write expected.json ({"base": {...}, "pr": {...}}) for count.py."""
import json
from pathlib import Path
import struct
import sys
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
OTA = "--ota" in sys.argv[1:]
CYCLES = 40
K1 = bytes.fromhex("6c0d1a3e5b7f29c4a18e90d2f3b46a57")
K2 = bytes.fromhex("0f1e2d3c4b5a69788796a5b4c3d2e1f0")
# Random static addresses need the top two bits set, so the over the air run uses C4 instead of A4.
PREFIX = "C4C138" if OTA else "A4C138"
MACS = {
k: f"{PREFIX}0000{i + 10:02X}" for i, k in enumerate("ABCDEFG")
} # ...0A to ...10
MAC = {k: bytes.fromhex(v) for k, v in MACS.items()}
MIDX = {k: i for i, k in enumerate(MACS)}
def mibeacon_plain(dev, pid, cnt, obj):
return (
bytes([0x50, 0x20])
+ struct.pack("<H", pid)
+ bytes([cnt])
+ MAC[dev][::-1]
+ obj
)
def mibeacon_enc(dev, pid, cnt, obj, key, short, corrupt=False):
ext = bytes([cnt, 0x00, 0x00])
if short:
hdr = bytes([0x48, 0x59]) + struct.pack("<H", pid) + bytes([cnt])
else:
hdr = (
bytes([0x58, 0x58]) + struct.pack("<H", pid) + bytes([cnt]) + MAC[dev][::-1]
)
nonce = MAC[dev][::-1] + hdr[2:5] + ext
ct = AESCCM(key, tag_length=4).encrypt(nonce, obj, b"\x11")
body, tag = ct[:-4], ct[-4:]
if corrupt:
tag = bytes([tag[0] ^ 0xFF]) + tag[1:]
out = hdr + body + ext + tag
assert len(out) == (19 if short else 18 + len(obj)), len(out)
return out
def t16(v): # 0x1004 temperature object
return bytes([0x04, 0x10, 0x02]) + struct.pack("<h", round(v * 10))
def th(t, h): # 0x100D temperature + humidity
return bytes([0x0D, 0x10, 0x04]) + struct.pack("<hh", round(t * 10), round(h * 10))
frames = [] # (dev, kind 0 = FE95 / 1 = 181A, data, tag for the expectation)
frames.append(("D", 0, mibeacon_plain("D", 0x0347, 0, t16(11.1)), "D_boot"))
steps = [len(frames)]
for k in range(1, CYCLES + 1):
frames.append(("B", 0, mibeacon_plain("B", 0x01AA, k, th(20 + k / 10, 40)), "B"))
frames.append(("C", 0, mibeacon_plain("C", 0x0098, k, t16(21 + k / 10)), "C"))
frames.append(
("A", 0, mibeacon_enc("A", 0x055B, k + 100, t16(22 + k / 10), K1, False), "A")
)
if k % 2 == 0:
bad = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False, True)
frames.append(("A", 0, bad, "A_bad"))
good = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False)
frames.append(("A", 0, good, "A_after_bad"))
frames.append(
("E", 0, mibeacon_enc("E", 0x1203, k + 50, th(23 + k / 10, 50), K2, True), "E")
)
frames.append(("D", 0, mibeacon_plain("D", 0x0347, k + 200, t16(24 + k / 10)), "D"))
atc = (
MAC["F"]
+ struct.pack(">h", round((26 + k / 10) * 10))
+ bytes([45, 90])
+ struct.pack(">H", 2950)
)
frames.append(("F", 1, atc + bytes([k]), "F"))
pv = (
MAC["G"][::-1]
+ struct.pack("<hHH", round((27 + k / 10) * 100), 4600, 3010)
+ bytes([88, k, 0])
)
frames.append(("G", 1, pv, "G"))
steps.append(len(frames))
def simulate(pr):
"""Base keeps one counter shared by every device and checks it before the tag; the PR keeps one per device."""
last_shared = 0
last = {}
pub = {}
for dev, kind, data, tag in frames:
if kind == 1:
cnt = data[12 if dev == "F" else 13]
if last.get(("x", dev)) == cnt:
continue
last["x", dev] = cnt
elif pr:
if last.get(dev) == data[4] or tag == "A_bad":
continue
last[dev] = data[4]
else:
if last_shared == data[4]:
continue
last_shared = data[4]
if tag == "A_bad":
continue
pub[tag] = pub.get(tag, 0) + 1
return pub
expected = {"base": simulate(False), "pr": simulate(True)}
print("base", expected["base"])
print("pr ", expected["pr"])
Path("expected.json").write_text(json.dumps(expected), encoding="utf-8")
def c_rows(name, rows):
out = [f"static const uint8_t {name}[{len(rows)}][{len(rows[0][0])}] = {{"]
out += [
" {" + ",".join(f"0x{b:02X}" for b in row) + "}," + (f" // {c}" if c else "")
for row, c in rows
]
return "\n".join([*out, "};", ""])
mac_rows = [(MAC[k], "") for k in MACS]
frame_rows = [
((bytes([MIDX[d], kind, len(data)]) + data).ljust(28, b"\0"), tag)
for d, kind, data, tag in frames
]
INJECT = """
inline void xb_fill(esphome::esp32_ble::BLEScanResult &r, const uint8_t *mac, bool fe95, const uint8_t *data,
uint8_t len) {
memcpy(r.bda, mac, 6);
r.ble_addr_type = 0;
r.rssi = -50;
uint8_t n = 0;
r.ble_adv[n++] = 2;
r.ble_adv[n++] = 0x01;
r.ble_adv[n++] = 0x06;
r.ble_adv[n++] = len + 3;
r.ble_adv[n++] = 0x16;
r.ble_adv[n++] = fe95 ? 0x95 : 0x1A;
r.ble_adv[n++] = fe95 ? 0xFE : 0x18;
memcpy(r.ble_adv + n, data, len);
r.adv_data_len = n + len;
r.scan_rsp_len = 0;
r.search_evt = ESP_GAP_SEARCH_INQ_RES_EVT;
}
inline void xb_inject(size_t first, size_t last) {
for (size_t i = first; i < last; i++) {
const uint8_t *row = XB_FRAMES[i];
esphome::esp32_ble::BLEScanResult r{};
xb_fill(r, XB_MACS[row[0]], row[1] == 0, row + 3, row[2]);
esphome::esp32_ble_tracker::global_esp32_ble_tracker->gap_scan_event_handler(r);
}
}
inline void xb_hex(const char *label, const std::vector<uint8_t> &v) {
char buf[80];
size_t n = 0;
for (size_t i = 0; i < v.size() && n + 3 < sizeof(buf); i++)
n += snprintf(buf + n, sizeof(buf) - n, "%02X", v[i]);
ESP_LOGI("probe", "%s %s", label, buf);
}
// Parse one encrypted frame with the listener directly and show whether it rewrote the shared service data.
inline void xb_rewrite_probe(esphome::ble_device_base::ESPBTDeviceListener *listener) {
esphome::esp32_ble::BLEScanResult r{};
xb_fill(r, XB_MACS[0], true, XB_PROBE, sizeof(XB_PROBE));
esphome::ble_device_base::ESPBTDevice device;
device.parse_scan_rst(r);
xb_hex("before", device.get_service_datas()[0].data);
bool ok = listener->parse_device(device);
ESP_LOGI("probe", "parse_device returned %d", ok);
xb_hex("after ", device.get_service_datas()[0].data);
}
"""
ADVERTISE = """
static uint32_t af_failed = 0; // sends where a GAP call returned an error
// Returns false (and counts it) when the address, the data or the start was rejected.
inline bool af_send(int i) {
const uint8_t *row = AF_FRAMES[i];
uint8_t adv[31];
uint8_t n = 0;
adv[n++] = 2;
adv[n++] = 0x01;
adv[n++] = 0x06;
adv[n++] = row[2] + 3;
adv[n++] = 0x16;
adv[n++] = row[1] == 0 ? 0x95 : 0x1A;
adv[n++] = row[1] == 0 ? 0xFE : 0x18;
memcpy(adv + n, row + 3, row[2]);
n += row[2];
esp_ble_gap_stop_advertising(); // fails harmlessly when nothing is advertising yet
uint8_t addr[6];
memcpy(addr, AF_MACS[row[0]], 6);
esp_err_t e1 = esp_ble_gap_set_rand_addr(addr);
esp_err_t e2 = esp_ble_gap_config_adv_data_raw(adv, n);
esp_ble_adv_params_t p{};
p.adv_int_min = 0x20; // 20 ms
p.adv_int_max = 0x20;
p.adv_type = ADV_TYPE_NONCONN_IND;
p.own_addr_type = BLE_ADDR_TYPE_RANDOM;
p.channel_map = ADV_CHNL_ALL;
p.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY;
esp_err_t e3 = esp_ble_gap_start_advertising(&p);
if (e1 == ESP_OK && e2 == ESP_OK && e3 == ESP_OK)
return true;
af_failed++;
ESP_LOGW("af", "frame %d send failed: rand_addr %d adv_data %d start %d", i, e1, e2, e3);
return false;
}
inline void af_stop() { esp_ble_gap_stop_advertising(); }
"""
if OTA:
head = '#pragma once\n#include <esp_gap_ble_api.h>\n#include <cstring>\n#include "esphome/core/log.h"\n'
body = c_rows("AF_MACS", mac_rows) + c_rows("AF_FRAMES", frame_rows)
body += f"static constexpr int AF_COUNT = {len(frames)};\n" + ADVERTISE
Path("adv_frames.hpp").write_text(head + body, encoding="utf-8")
else:
head = '#pragma once\n#include <cstring>\n#include "esphome/components/esp32_ble/ble_scan_result.h"\n'
probe = mibeacon_enc("A", 0x055B, 0xF5, t16(33.3), K1, False)
body = c_rows("XB_MACS", mac_rows) + c_rows("XB_FRAMES", frame_rows)
body += (
f"static const uint16_t XB_STEPS[{len(steps)}] = {{"
+ ",".join(map(str, steps))
+ "};\n"
)
body += f"static constexpr size_t XB_NUM_STEPS = {len(steps)};\n"
body += (
"static const uint8_t XB_PROBE[] = {"
+ ",".join(f"0x{b:02X}" for b in probe)
+ "};\n"
)
Path("frames.hpp").write_text(head + body + INJECT, encoding="utf-8")
print("frames", len(frames), "longest", max(len(d) for _, _, d, _ in frames))
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# usage: run.sh inject|ota pr|base build and flash the receiver, capture its serial log, compare the counts
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, api_key) and run it there.
# inject: xbinject.yaml injects the frames into its own tracker. ota: xbrecv.yaml listens to xbadv.yaml, which must
# already run on a second ESP32 (python gen.py --ota, then flash xbadv.yaml once).
# env: TREE (esphome checkout to build from), PORT (receiver serial port), SC (path to serial_capture.py),
# PY (checkout python), ADV (advertiser address, ota only)
D=$(cd "$(dirname "$0")" && pwd); MODE=${1:?inject or ota}; B=${2:?pr or base}; T=${TREE:?set TREE}
P=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
cd "$D" || exit 1
if [ "$MODE" = ota ]; then
A=${ADV:?set ADV}; Y=xbrecv; SECS=130; LOSS=2
$PY gen.py --ota > gen.txt || { echo "gen.py failed"; exit 1; }
else
Y=xbinject; SECS=80; LOSS=0; PROBE=--probe
$PY gen.py > gen.txt || { echo "gen.py failed"; exit 1; }
fi
OUT=$D/$MODE-$B; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$Y.yaml" --device "$P" > "$OUT/upload.log" 2>&1) \
|| { echo "FAIL: build or upload failed, see $OUT/upload.log"; exit 1; }
$PY "$SC" "$P" $SECS "$OUT/cap.txt" --reset > /dev/null 2>&1 & CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
if [ "$MODE" = ota ]; then
# 18 s lets the receiver boot and start scanning, so the counter 0 boot frame is the first one it hears
sleep 18
CODE=$(curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$A/button/start/press")
[ "$CODE" = 200 ] || { echo "FAIL: advertiser start button returned $CODE"; exit 1; }
fi
wait $CAP; trap - EXIT
[ -s "$OUT/cap.txt" ] || { echo "FAIL: serial capture got nothing"; exit 1; }
CRASH=$(grep -acE 'Guru|abort\(\)|Backtrace:' "$OUT/cap.txt")
$PY count.py "$OUT/cap.txt" "$B" --allow-loss $LOSS $PROBE; RC=$?
if [ "$MODE" = ota ]; then
# the advertiser counts frames whose GAP calls failed; those are not radio loss
FAILED=$(curl -s -m 5 "http://$A/sensor/failed%20sends" | $PY -c "import json,sys; print(int(json.load(sys.stdin)['value']))")
echo "advertiser failed sends: ${FAILED:-unknown}"
[ "$FAILED" = 0 ] || RC=1
fi
echo "crash lines: $CRASH"
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
@@ -0,0 +1,76 @@
esphome:
name: xbadv
includes:
- adv_frames.hpp
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
esp32_ble:
globals:
- id: af_idx
type: int
initial_value: "-1"
button:
- platform: template
name: start
on_press:
- lambda: |-
id(af_idx) = 0;
af_failed = 0;
// an unfinished sequence reads as unknown, not as the 0 from an earlier run
id(failed_sends).publish_state(NAN);
ESP_LOGI("af", "sequence start");
interval:
- interval: 250ms
then:
- lambda: |-
int i = id(af_idx);
if (i < 0)
return;
if (i >= AF_COUNT) {
af_stop();
id(af_idx) = -1;
ESP_LOGI("af", "sequence done, %u failed sends", (unsigned) af_failed);
id(failed_sends).publish_state(af_failed);
return;
}
af_send(i);
id(af_idx) = i + 1;
sensor:
- platform: template
id: failed_sends
name: failed sends
accuracy_decimals: 0
update_interval: never
@@ -0,0 +1,107 @@
substitutions:
name: xbinject
esphome:
name: ${name}
includes:
- frames.hpp
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
globals:
- id: xb_step
type: int
initial_value: "0"
esp32_ble_tracker:
on_ble_service_data_advertise:
- mac_address: A4:C1:38:00:00:0A
service_uuid: FE95
then:
- lambda: |-
char buf[80];
size_t n = 0;
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
interval:
- interval: 1s
startup_delay: 15s
then:
- lambda: |-
if (id(xb_step) + 1 >= (int) XB_NUM_STEPS) {
if (id(xb_step) + 1 == (int) XB_NUM_STEPS) {
ESP_LOGI("xb", "INJECT DONE");
xb_rewrite_probe(id(xa));
id(xb_step)++;
}
return;
}
int s = id(xb_step);
ESP_LOGI("xb", "inject step %d frames %u..%u", s, XB_STEPS[s], XB_STEPS[s + 1]);
if (s == 0)
xb_inject(0, XB_STEPS[0]);
xb_inject(XB_STEPS[s], XB_STEPS[s + 1]);
id(xb_step)++;
sensor:
- platform: xiaomi_lywsd03mmc
id: xa
mac_address: A4:C1:38:00:00:0A
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
temperature:
name: "xA lywsd03mmc temp"
- platform: xiaomi_lywsdcgq
mac_address: A4:C1:38:00:00:0B
temperature:
name: "xB lywsdcgq temp"
humidity:
name: "xB lywsdcgq hum"
- platform: xiaomi_hhccjcy01
mac_address: A4:C1:38:00:00:0C
temperature:
name: "xC hhccjcy01 temp"
- platform: xiaomi_cgg1
mac_address: A4:C1:38:00:00:0D
temperature:
name: "xD cgg1 temp"
- platform: xiaomi_xmwsdj04mmc
mac_address: A4:C1:38:00:00:0E
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
temperature:
name: "xE xmwsdj04mmc temp"
humidity:
name: "xE xmwsdj04mmc hum"
- platform: atc_mithermometer
mac_address: A4:C1:38:00:00:0F
temperature:
name: "xF atc temp"
humidity:
name: "xF atc hum"
battery_voltage:
name: "xF atc volt"
- platform: pvvx_mithermometer
mac_address: A4:C1:38:00:00:10
temperature:
name: "xG pvvx temp"
humidity:
name: "xG pvvx hum"
battery_voltage:
name: "xG pvvx volt"
@@ -0,0 +1,83 @@
substitutions:
name: xbrecv
esphome:
name: ${name}
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
esp32_ble_tracker:
scan_parameters:
interval: 100ms
window: 100ms
active: false
on_ble_service_data_advertise:
- mac_address: C4:C1:38:00:00:0A
service_uuid: FE95
then:
- lambda: |-
char buf[80];
size_t n = 0;
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
sensor:
- platform: xiaomi_lywsd03mmc
mac_address: C4:C1:38:00:00:0A
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
temperature:
name: "xA lywsd03mmc temp"
- platform: xiaomi_lywsdcgq
mac_address: C4:C1:38:00:00:0B
temperature:
name: "xB lywsdcgq temp"
humidity:
name: "xB lywsdcgq hum"
- platform: xiaomi_hhccjcy01
mac_address: C4:C1:38:00:00:0C
temperature:
name: "xC hhccjcy01 temp"
- platform: xiaomi_cgg1
mac_address: C4:C1:38:00:00:0D
temperature:
name: "xD cgg1 temp"
- platform: xiaomi_xmwsdj04mmc
mac_address: C4:C1:38:00:00:0E
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
temperature:
name: "xE xmwsdj04mmc temp"
humidity:
name: "xE xmwsdj04mmc hum"
- platform: atc_mithermometer
mac_address: C4:C1:38:00:00:0F
temperature:
name: "xF atc temp"
humidity:
name: "xF atc hum"
battery_voltage:
name: "xF atc volt"
- platform: pvvx_mithermometer
mac_address: C4:C1:38:00:00:10
temperature:
name: "xG pvvx temp"
humidity:
name: "xG pvvx hum"
battery_voltage:
name: "xG pvvx volt"
+11 -2
View File
@@ -1555,8 +1555,17 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ
// Event is a special case - unlike other entities with simple state fields,
// events store their state in a member accessed via obj->get_last_event_type()
void APIConnection::send_event(event::Event *event) {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE,
event->get_last_event_type_index());
const uint8_t type_index = event->get_last_event_type_index();
const size_t size_before = this->deferred_batch_.size();
if (this->deferred_batch_.contains(event, EventResponse::MESSAGE_TYPE)) {
// Sending now would overtake the older event of this entity that still waits in the batch
this->schedule_message_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
} else {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
}
// Events are rare, so a queued one sends the batch now rather than after batch_delay
if (this->deferred_batch_.size() > size_before)
this->make_batch_due_now_();
}
uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size) {
+5 -68
View File
@@ -2,6 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_deferred_batch.h"
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
@@ -20,7 +21,6 @@
#include "esphome/core/string_ref.h"
#include <functional>
#include <limits>
#include <vector>
namespace esphome {
@@ -656,73 +656,6 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for message encoding
using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size);
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return; // Already queued
}
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
std::swap(this->items.front(), this->items.back());
}
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
// DeferredBatch here (16 bytes, 4-byte aligned)
DeferredBatch deferred_batch_;
@@ -795,6 +728,10 @@ class APIConnection final : public APIServerConnectionBase {
static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU
bool schedule_batch_();
// Sends the batch on the next loop, so a queued event does not wait for batch_delay; queued states go with it
void make_batch_due_now_() {
this->deferred_batch_.batch_start_time = App.get_loop_component_start_time() - this->get_batch_delay_ms_();
}
void process_batch_();
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
__attribute__((noinline));
@@ -0,0 +1,88 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_pb2.h"
#include "esphome/core/entity_base.h"
#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>
namespace esphome::api {
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// O(n) scan but optimized for RAM over performance
bool contains(EntityBase *entity, uint16_t message_type) const {
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return true;
}
return false;
}
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
if (this->contains(entity, message_type))
return; // Already queued
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Shift rather than swap, so the queued items keep their order (two events of one entity must not swap)
this->items.push_back({});
std::move_backward(this->items.begin(), this->items.end() - 1, this->items.end());
this->items.front() = {entity, message_type, estimated_size, AUX_DATA_UNUSED};
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
} // namespace esphome::api
#endif // USE_API
+5 -16
View File
@@ -13,19 +13,10 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(CopySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.sensor");
void CopySensor::setup() {
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::copy {
class CopySensor final : public sensor::Sensor, public Component {
public:
void set_source(sensor::Sensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
sensor::Sensor *source_;
};
} // namespace esphome::copy
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(CopyTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
void CopyTextSensor::setup() {
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::copy {
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
public:
void set_source(text_sensor::TextSensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
text_sensor::TextSensor *source_;
};
} // namespace esphome::copy
+16 -2
View File
@@ -31,11 +31,25 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) {
}
#if CONFIG_IDF_TARGET_ARCH_RISCV
// Bounds of this image's flash-mapped code, from the ESP-IDF linker script.
extern "C" char _instruction_reserved_start;
extern "C" char _instruction_reserved_end;
// Check that [start, end) lies in this image's flash-mapped code or in IRAM.
// The IROM range is larger than what the MMU maps, and reading an unmapped
// address faults.
static inline bool is_readable_code(uint32_t start, uint32_t end) {
auto irom_start = reinterpret_cast<uint32_t>(&_instruction_reserved_start);
auto irom_end = reinterpret_cast<uint32_t>(&_instruction_reserved_end);
return (start >= irom_start && end <= irom_end) || (start >= SOC_IRAM_LOW && end <= SOC_IRAM_HIGH);
}
// Check if a code address is a real return address by verifying the preceding
// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during
// panic) so flash cache is available and both IRAM and IROM are safely readable.
// panic) so flash cache is available. The address may come from an earlier
// build, so check that the bytes before it are mapped before reading them.
static inline bool is_return_addr(uint32_t addr) {
if (!is_code_addr(addr) || addr < 4)
if (!is_code_addr(addr) || !is_readable_code(addr - 4, addr))
return false;
// A return address on the stack points to the instruction after a call.
// Check for 4-byte JAL/JALR call instruction before this address.
@@ -271,7 +271,9 @@ void ESP32Camera::loop() {
fb->timestamp = timestamp;
}
#endif
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
// A request that arrives while this image is handed out is kept for the next frame
const uint8_t single_requesters = this->single_requesters_.exchange(0);
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, single_requesters | this->stream_requesters_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
@@ -291,7 +293,6 @@ void ESP32Camera::loop() {
listener->on_camera_image(this->current_image_);
}
this->last_update_ = now;
this->single_requesters_ = 0;
}
/* ---------------- constructors ---------------- */
+5 -1
View File
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import climate_ir
import esphome.config_validation as cv
from esphome.const import CONF_MODEL
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
from esphome.types import ConfigType
from . import gree_ns
@@ -19,6 +19,7 @@ MODELS = Model.options(
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
{
cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
)
@@ -26,5 +27,8 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_model(config[CONF_MODEL]))
if config[CONF_USE_FAHRENHEIT]:
cg.add_define("USE_GREE_FAHRENHEIT")
cg.add(var.set_use_fahrenheit(True))
if config[CONF_MODEL] == "yaw1f":
cg.add_define("USE_GREE_YAW1F")
+30 -9
View File
@@ -1,4 +1,6 @@
#include "gree.h"
#include <cmath>
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
@@ -34,6 +36,21 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
this->transmit_state();
}
#ifdef USE_GREE_FAHRENHEIT
uint8_t fahrenheit_half_steps(float target_celsius) {
if (!std::isfinite(target_celsius)) {
return 0;
}
// Whole degrees shown at k = 0 and at the top of the range
static constexpr int MIN_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MIN));
static constexpr int MAX_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MAX));
const int desired_f = static_cast<int>(roundf(clamp<float>(celsius_to_fahrenheit(target_celsius), MIN_F, MAX_F)));
// The display at k is (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10; pick the smallest k that reaches desired_f
const int tenths_above_min = 10 * desired_f - (10 * 32 + 18 * GREE_TEMP_MIN);
return static_cast<uint8_t>((tenths_above_min + 8) / 9);
}
#endif
void GreeClimate::transmit_state() {
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
const Model model = this->model_;
@@ -41,7 +58,12 @@ void GreeClimate::transmit_state() {
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
remote_state[0] = this->fan_speed_() | this->operation_mode_();
#ifdef USE_GREE_FAHRENHEIT
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
#else
remote_state[1] = this->temperature_();
#endif
if (model == GREE_YAN) {
remote_state[3] = 0x50; // bits 4..7 always 0101
@@ -107,16 +129,15 @@ void GreeClimate::transmit_state() {
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
}
// Calculate the checksum
if (model == GREE_YAN) {
remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0);
} else {
remote_state[7] =
((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
#ifdef USE_GREE_FAHRENHEIT
if (this->use_fahrenheit_) {
remote_state[3] |= GREE_FAHRENHEIT_BIT;
if (half_steps & 1)
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
}
#endif
remote_state[7] = frame_checksum(remote_state);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
+27
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/defines.h"
namespace esphome::gree {
@@ -90,6 +91,26 @@ static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
// Byte 3 Fahrenheit bits
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
/// nibble.
inline uint8_t frame_checksum(const uint8_t *remote_state) {
return (
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
}
#ifdef USE_GREE_FAHRENHEIT
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
uint8_t fahrenheit_half_steps(float target_celsius);
#endif
// Model codes
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
@@ -103,6 +124,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
void set_model(Model model);
#ifdef USE_GREE_FAHRENHEIT
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
#endif
void set_mode_bit(uint8_t bit_mask, bool enabled);
protected:
@@ -118,6 +142,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
Model model_{};
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
#ifdef USE_GREE_FAHRENHEIT
bool use_fahrenheit_{false};
#endif
};
} // namespace esphome::gree
+16
View File
@@ -1,6 +1,7 @@
#include "image.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -165,6 +166,21 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
}
#ifdef USE_LVGL
lv_image_dsc_t *Image::get_lv_image_dsc() {
// No pixels yet. A widget keeps this descriptor and reads it on every draw, and LVGL warns on every draw of an image
// without data, so give it a transparent pixel instead
static constexpr uint8_t TRANSPARENT_PIXEL = 0;
if (this->data_start_ == nullptr) {
if (this->dsc_.data != &TRANSPARENT_PIXEL) {
memset(&this->dsc_, 0, sizeof(this->dsc_));
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
this->dsc_.header.w = 1;
this->dsc_.header.h = 1;
this->dsc_.header.stride = 1;
this->dsc_.data_size = 1;
this->dsc_.data = &TRANSPARENT_PIXEL;
}
return &this->dsc_;
}
// lazily construct lvgl image_dsc.
if (this->dsc_.data != this->data_start_) {
this->dsc_.data = this->data_start_;
+6 -1
View File
@@ -43,7 +43,12 @@ class Image : public display::BaseImage {
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
#ifdef USE_LVGL
lv_image_dsc_t *get_lv_image_dsc();
virtual lv_image_dsc_t *get_lv_image_dsc();
#endif
#ifdef USE_LVGL_IMAGE
/// Called when an LVGL image widget is given this image. An image whose size can change keeps the widget, so it can
/// give the widget its source again when the size changes: the widget keeps the size it read from the descriptor.
virtual void add_lvgl_widget(lv_obj_t *obj) {}
#endif
protected:
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
+4 -1
View File
@@ -82,7 +82,10 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) {
::lv_image_set_src(obj, image->get_lv_image_dsc());
image->add_lvgl_widget(obj);
}
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
+14 -11
View File
@@ -69,8 +69,9 @@ PackedBits = modbus.modbus_ns.class_("PackedBits")
# The exception code passed to on_error handlers.
ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
# Lambda argument types for the reply handlers: the device address the send targeted, and the
# request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
_ADDRESS_ARG = (cg.uint8, "address")
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
@@ -135,11 +136,11 @@ _ACTION_BASE_SCHEMA = cv.Schema(
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient),
cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t),
# Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive
# later (fire-and-continue), so all run with the request/reply available - not the outer
# automation's variables.
# later (fire-and-continue), so all run with the address and request/reply available - not the
# outer automation's variables.
cv.Optional(CONF_ON_SENT): _handler_schema(),
cv.Optional(CONF_ON_ERROR): _handler_schema(),
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `request`,
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `address`/`request`,
# returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation
# form may also carry an optional `retry:` returning lambda to run actions AND decide the retry.
cv.Optional(CONF_ON_NO_RESPONSE): cv.All(
@@ -199,7 +200,7 @@ async def register_client_action(
)
if sent_conf := config.get(CONF_ON_SENT):
await automation.build_automation(
var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf
var.get_sent_trigger(), [_ADDRESS_ARG, (_PDU_SPAN, "request")], sent_conf
)
if response_conf := config.get(CONF_ON_RESPONSE):
await automation.build_automation(
@@ -217,7 +218,7 @@ async def register_client_action(
if error_conf := config.get(CONF_ON_ERROR):
await automation.build_automation(
var.get_error_trigger(),
[(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
error_conf,
)
if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None:
@@ -228,18 +229,20 @@ async def register_client_action(
else:
await automation.build_automation(
var.get_no_response_trigger(),
[(_PDU_SPAN, "request")],
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
no_response_conf,
)
retry_conf = no_response_conf.get(CONF_RETRY)
if retry_conf is not None:
retry_lambda = await cg.process_lambda(
retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_
retry_conf, [_ADDRESS_ARG, (_PDU_SPAN, "request")], return_type=cg.bool_
)
cg.add(var.set_retry(retry_lambda))
if not_sent_conf := config.get(CONF_ON_NOT_SENT):
await automation.build_automation(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
var.get_not_sent_trigger(),
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
not_sent_conf,
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter.
@@ -265,7 +268,7 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
var,
config,
args,
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
)
@@ -13,45 +13,51 @@ namespace esphome::modbus_client {
/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no
/// central client object and no request matching. The device address is templatable; it is stamped on the
/// device at play() time; the hub routes each reply by device pointer, so a changed address never
/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping
/// sends it could misreport, and the handler can recompute the expression it configured.)
/// mis-routes an earlier reply. The handlers wired here and send's on_response get `address` first: the
/// address of their own request, which the hub takes from its frame, so overlapping sends each report their own.
template<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
public:
TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address
Trigger<std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; }
Trigger<std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() {
return &this->error_trigger_;
}
Trigger<uint8_t, std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
/// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue
/// The retry decision for on_no_response: given the request (address, PDU), return true to have the hub re-queue
/// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with
/// the no_response trigger (actions run, then this decides the retry).
using retry_func_t = bool (*)(std::span<const uint8_t>);
using retry_func_t = bool (*)(uint8_t, std::span<const uint8_t>);
void set_retry(retry_func_t f) { this->retry_func_ = f; }
/// The frame was written to the wire: fires once per transmission, before any reply, and never for a
/// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data).
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_trigger_.trigger(request_pdu); }
void on_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->sent_trigger_.trigger(address, request_pdu);
}
/// Never reached the wire, from either of two sources. The hub calls this for a request it accepted
/// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus
/// device going offline, say. Everything the hub refuses at the door instead returns false from
/// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same
/// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder.
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); }
void on_not_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->not_sent_trigger_.trigger(address, request_pdu);
}
/// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing:
/// register_client_action() wires on_error for all of them, so a derived class must not have to
/// remember the override.
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(request_pdu, exception_code);
void on_error(uint8_t address, std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(address, request_pdu, exception_code);
}
/// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form),
/// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The
/// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry.
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(request_pdu);
bool on_no_response(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(address, request_pdu);
if (this->retry_func_ != nullptr)
return this->retry_func_(request_pdu);
return this->retry_func_(address, request_pdu);
return false;
}
/// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub
@@ -70,13 +76,13 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(pdu);
this->on_not_sent(this->address_, pdu);
}
Trigger<std::span<const uint8_t>> sent_trigger_;
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<std::span<const uint8_t>> no_response_trigger_;
Trigger<std::span<const uint8_t>> not_sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> no_response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> not_sent_trigger_;
retry_func_t retry_func_{nullptr};
};
@@ -119,7 +125,7 @@ template<typename... Ts> class WriteCommandOptions {
};
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
/// delivered raw - on_response(address, request, response) - deliberately bypassing the typed dispatch, so
/// non-standard/custom transactions pass through untouched.
/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
@@ -133,7 +139,7 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
return &this->response_trigger_;
}
@@ -143,12 +149,13 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
this->send_or_resolve_(this->pdu_.value(x...), options);
}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
void on_response(uint8_t address, std::span<const uint8_t> request_pdu,
std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(address, request_pdu, response_pdu);
}
protected:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
};
/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs
+10 -16
View File
@@ -2,24 +2,18 @@ import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Number, number_ns
from .. import Number
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(NumberSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
async def to_code(config):
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await sensor.new_sensor(config, source)
await cg.register_component(var, config)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
# Number has the same float state callback as Sensor
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "number_sensor.h"
#include "esphome/core/log.h"
namespace esphome::number {
ESPHOME_LOG_TAG(TAG, "number.sensor");
void NumberSensor::setup() {
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
} // namespace esphome::number
@@ -1,19 +0,0 @@
#pragma once
#include "../number.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::number {
class NumberSensor final : public sensor::Sensor, public Component {
public:
explicit NumberSensor(Number *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Number *source_;
};
} // namespace esphome::number
+3
View File
@@ -42,6 +42,7 @@ ONLINE_IMAGE_SCHEMA = (
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(runtime_image.CONF_DOUBLE_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
@@ -141,6 +142,8 @@ async def setup_online_image(config: ConfigType) -> None:
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
if settings.double_buffer:
cg.add(var.set_double_buffer(True))
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
@@ -428,9 +428,8 @@ void OnlineImage::finish_download_() {
void OnlineImage::end_connection_() {
// Abort any in-progress decode; the decoder object is kept warm for the next decode.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
RuntimeImage::release();
this->abort_decode();
}
if (this->downloader_) {
this->downloader_->end();
@@ -3004,3 +3004,73 @@ register_protocol_action(
(CONF_CHANNELS, "channels", cg.uint16),
(CONF_COMMAND, "command", cg.uint8),
)
# Cardin S438
(
CardinS438Data,
CardinS438BinarySensor,
CardinS438Trigger,
_,
CardinS438Dumper,
) = declare_protocol("CardinS438")
CARDIN_S438_CHANNELS = {
"A": 0x02,
"B": 0x00,
"C": 0x03,
"D": 0x01,
}
def _validate_cardin_s438_code(code):
"""Accept the DIP switches as a 14-character binary string."""
code = cv.string_strict(code)
if len(code) != 14 or any(bit not in "01" for bit in code):
raise cv.Invalid("code must be exactly 14 binary digits, e.g. '10100110101010'")
return int(code, 2)
CARDIN_S438_SCHEMA = cv.Schema(
{
cv.Required(CONF_CODE): _validate_cardin_s438_code,
cv.Optional(CONF_CHANNEL, default="A"): cv.enum(
CARDIN_S438_CHANNELS, upper=True
),
cv.Optional(CONF_COMMAND_REPEATS, default=3): cv.int_range(min=1, max=255),
}
)
@register_binary_sensor("cardin_s438", CardinS438BinarySensor, CARDIN_S438_SCHEMA)
def cardin_s438_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
CardinS438Data,
("code", config[CONF_CODE]),
("channel", config[CONF_CHANNEL]),
)
)
)
@register_trigger("cardin_s438", CardinS438Trigger, CardinS438Data)
def cardin_s438_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("cardin_s438", CardinS438Dumper)
def cardin_s438_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
register_protocol_action(
"cardin_s438",
"CardinS438",
CARDIN_S438_SCHEMA,
(CONF_CODE, "code", cg.uint16),
(CONF_CHANNEL, "channel", cg.uint8),
(CONF_COMMAND_REPEATS, "repeats", cg.uint8),
)
@@ -0,0 +1,159 @@
#include "cardin_s438_protocol.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::remote_base {
ESPHOME_LOG_TAG(TAG, "remote.cardin_s438");
namespace {
/*
* Cardin S438 wire format
*
* Frame: 6400 us sync mark + 40 data bits (5 bytes, LSB-first).
* 0 = 1460 us space, 750 us mark
* 1 = 750 us space, 1460 us mark
*
* Bytes:
* [0..2] Encoded pin fields and seed
* [2] (seed + marker 0x87), modulo 256
* Carries propagate into bytes 1 and 0.
* [3..4] Sum of bytes [0..2], little-endian
*
* Pin code: 14 bits, MSB-first, mapped to X/Y fields:
* Y7,Y6,X7,X6,Y5,X5,Y4,X4,Y3,X3,Y2,Y1,Y0,X2
* Channels in X bits 1:0: A=10, B=00, C=11, D=01.
*
* Three frames per transmission, with 63650 us LOW gaps.
* Seed chosen randomly; no pattern found in captures.
*/
// Frame format and timing, in microseconds.
constexpr uint8_t MARKER = 0x87;
constexpr uint16_t SYNC_MARK_US = 6400;
constexpr uint32_t FRAME_GAP_US = 63650;
constexpr uint8_t WIRE_SIZE = 5;
// Each bit is a space then a mark, and both lengths carry it
constexpr BitTimings CARDIN_TIMINGS{
.one_space = 750, .zero_space = 1460, .one_mark = 1460, .zero_mark = 750, .space_first = true};
// Maps pincode bits, most-significant first, to a bit of the X or Y field: PIN_Y marks Y, the low bits are the bit
// number. In flash, so it stays out of RAM on ESP8266.
constexpr uint8_t PIN_Y = 0x08;
constexpr uint8_t PINCODE_BITS = 14;
constexpr uint8_t PINCODE_MAP[PINCODE_BITS] PROGMEM = {
PIN_Y | 7, PIN_Y | 6, 7, 6, PIN_Y | 5, 5, PIN_Y | 4, 4, PIN_Y | 3, 3, PIN_Y | 2, PIN_Y | 1, PIN_Y | 0, 2,
};
// Convert the pincode and channel to the two decoded pin fields.
void encode_pin_fields(uint16_t pincode, uint8_t channel, uint8_t &plain0, uint8_t &plain1) {
uint8_t x = channel;
uint8_t y = 0;
for (uint8_t i = 0; i < PINCODE_BITS; ++i) {
if (((pincode >> (PINCODE_BITS - 1 - i)) & 1U) == 0)
continue;
const uint8_t entry = progmem_read_byte(&PINCODE_MAP[i]);
(entry & PIN_Y ? y : x) |= 1U << (entry & 0x07);
}
plain1 = ~y;
plain0 = plain1 - x + 1;
}
// Construct the wire bytes for one seed.
void build_wire(uint8_t wire[WIRE_SIZE], uint8_t seed, uint8_t plain0, uint8_t plain1) {
uint16_t sum = MARKER + seed;
wire[2] = sum;
uint8_t key = seed + (sum >> 8);
sum = plain1 + key;
wire[1] = sum;
key += sum >> 8;
wire[0] = plain0 + key;
// checksum over the first three bytes, little-endian.
const uint16_t checksum = sum16_bytes(wire, 3);
wire[3] = checksum;
wire[4] = checksum >> 8;
}
} // namespace
void CardinS438Protocol::encode(RemoteTransmitData *dst, const CardinS438Data &data) {
uint8_t plain0;
uint8_t plain1;
encode_pin_fields(data.code & 0x3FFF, data.channel & 0x03, plain0, plain1);
for (uint8_t frame = 0; frame < data.repeats; ++frame) {
uint8_t wire[WIRE_SIZE];
uint8_t seed = static_cast<uint8_t>(random_uint32() & 0xFF);
build_wire(wire, seed, plain0, plain1);
dst->mark(SYNC_MARK_US);
dst->item_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE);
dst->space(FRAME_GAP_US);
}
}
optional<CardinS438Data> CardinS438Protocol::decode(RemoteReceiveData src) {
while (src.is_valid()) {
// Find the frame's long sync mark.
if (!src.expect_mark(SYNC_MARK_US)) {
src.advance();
continue;
}
uint8_t wire[WIRE_SIZE];
if (!src.expect_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE) ||
encode_value_le<uint16_t>(wire + 3) != sum16_bytes(wire, 3)) {
continue;
}
// Recover the seed and undo the encoder's carry transform.
uint8_t seed = wire[2] - MARKER;
uint8_t key = seed;
key += (seed + MARKER) >> 8;
uint8_t plain1 = wire[1] - key;
key += (plain1 + key) >> 8;
uint8_t plain0 = wire[0] - key;
// Recover the original X and Y fields.
uint8_t x = plain1 - plain0 + 1;
uint8_t y = ~plain1;
// Reconstruct the 14-bit code in transmission order.
uint16_t code = 0;
for (const uint8_t &slot : PINCODE_MAP) {
const uint8_t entry = progmem_read_byte(&slot);
code = (code << 1) | (((entry & PIN_Y ? y : x) >> (entry & 0x07)) & 1U);
}
return CardinS438Data{
.code = code,
.channel = static_cast<uint8_t>(x & 0x03),
.seed = seed,
};
}
return {};
}
void CardinS438Protocol::dump(const CardinS438Data &data) {
// The DIP switches as the config takes them; channels as CARDIN_S438_CHANNELS in __init__.py maps them
char code[PINCODE_BITS + 1];
for (uint8_t i = 0; i < PINCODE_BITS; ++i)
code[i] = (data.code >> (PINCODE_BITS - 1 - i)) & 1U ? '1' : '0';
code[PINCODE_BITS] = '\0';
ESP_LOGI(TAG, "Received Cardin S438: code=%s channel=%c seed=0x%02X", code, "BDAC"[data.channel & 0x03], data.seed);
}
} // namespace esphome::remote_base
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::remote_base {
struct CardinS438Data {
uint16_t code{0};
uint8_t channel{0};
uint8_t seed{0};
uint8_t repeats{3};
bool operator==(const CardinS438Data &rhs) const { return code == rhs.code && channel == rhs.channel; }
};
class CardinS438Protocol : public RemoteProtocol<CardinS438Data> {
public:
void encode(RemoteTransmitData *dst, const CardinS438Data &data);
optional<CardinS438Data> decode(RemoteReceiveData src);
void dump(const CardinS438Data &data);
};
DECLARE_REMOTE_PROTOCOL(CardinS438)
} // namespace esphome::remote_base
@@ -19,6 +19,7 @@ AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
DOMAIN = "runtime_image"
CONF_DOUBLE_BUFFER = "double_buffer"
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
@@ -212,6 +213,7 @@ class RuntimeImageSettings:
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
double_buffer: bool
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
@@ -256,4 +258,5 @@ async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
double_buffer=config.get(CONF_DOUBLE_BUFFER, False),
)
@@ -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,7 +96,38 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
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);
}
}
int JpegDecoder::prepare(size_t expected_size) {
// Check before the base prepare() so a failure never leaves an active session
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
return ImageDecoder::prepare(expected_size);
}
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
@@ -105,22 +138,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
// If size unknown, try to decode and see if it's valid
// The JPEGDEC library will fail gracefully if data is incomplete
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
this->jpeg_->close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_.getJPEGType();
auto jpeg_type = this->jpeg_->getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
@@ -128,11 +161,12 @@ int JpegDecoder::start_() {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
this->jpeg_->getBpp());
this->jpeg_.setUserPointer(this);
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
this->jpeg_->setUserPointer(this);
const int width = this->jpeg_->getWidth();
const int height = this->jpeg_->getHeight();
// The tables for drawing are only built once the decoded size is known, below
if (!this->resize_image_(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -155,17 +189,17 @@ int JpegDecoder::start_() {
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_.setPixelType(RGB8888);
this->jpeg_->setPixelType(RGB8888);
}
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
const int error = this->jpeg_->getLastError();
this->jpeg_->close();
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
@@ -189,7 +223,7 @@ int JpegDecoder::fail_() {
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
}
ImageDecoder::reset();
@@ -218,6 +252,10 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
@@ -228,19 +266,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
@@ -248,7 +286,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_.decodeRows(1);
const int result = this->jpeg_->decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
@@ -259,7 +297,7 @@ int HOT JpegDecoder::decode_step() {
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
}
@@ -18,9 +18,10 @@ class JpegDecoder : public ImageDecoder {
*
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
~JpegDecoder() override {}
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
@@ -46,7 +47,8 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
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};
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <utility>
#include <cstdint>
#include <cstring>
#include <limits>
@@ -48,7 +49,14 @@ RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Tra
fixed_height_(fixed_height),
placeholder_(placeholder) {}
RuntimeImage::~RuntimeImage() { this->release(); }
RuntimeImage::~RuntimeImage() {
#ifdef USE_LVGL_IMAGE
for (lv_obj_t *obj : this->lvgl_widgets_)
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
this->lvgl_widgets_.clear();
#endif
this->release();
}
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
@@ -69,6 +77,7 @@ int RuntimeImage::resize(int width, int height) {
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
return result;
}
@@ -176,6 +185,52 @@ void RuntimeImage::map_chroma_key(Color &color) {
}
}
#ifdef USE_LVGL
lv_image_dsc_t *RuntimeImage::get_lv_image_dsc() {
if (this->data_start_ == nullptr && this->placeholder_ != nullptr) {
this->dsc_ = *this->placeholder_->get_lv_image_dsc();
return &this->dsc_;
}
return Image::get_lv_image_dsc();
}
#endif
#ifdef USE_LVGL_IMAGE
void RuntimeImage::add_lvgl_widget(lv_obj_t *obj) {
if (std::find(this->lvgl_widgets_.begin(), this->lvgl_widgets_.end(), obj) != this->lvgl_widgets_.end())
return;
this->lvgl_widgets_.push_back(obj);
lv_obj_add_event_cb(obj, lvgl_widget_deleted, LV_EVENT_DELETE, this);
}
void RuntimeImage::lvgl_widget_deleted(lv_event_t *event) {
auto *image = static_cast<RuntimeImage *>(lv_event_get_user_data(event));
auto &widgets = image->lvgl_widgets_;
widgets.erase(std::remove(widgets.begin(), widgets.end(), lv_event_get_target_obj(event)), widgets.end());
}
#endif
void RuntimeImage::refresh_lvgl_() {
#ifdef USE_LVGL
// Not a virtual call: this also runs from the destructor
lv_image_dsc_t *dsc = this->RuntimeImage::get_lv_image_dsc();
#ifdef USE_LVGL_IMAGE
auto &widgets = this->lvgl_widgets_;
for (size_t i = 0; i < widgets.size();) {
lv_obj_t *obj = widgets[i];
if (lv_image_get_src(obj) == dsc) {
// Setting the same source makes the widget read the size again, and redraws it
::lv_image_set_src(obj, dsc);
i++;
} else {
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
widgets.erase(widgets.begin() + i);
}
}
#endif
#endif
}
void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
// If we have a complete image, use the base class draw method
@@ -263,11 +318,21 @@ bool RuntimeImage::end_decode() {
}
// Finalize the image for display
if (!this->progressive_display_) {
if (this->is_double_buffered_()) {
if (this->buffer_ != nullptr) {
// Show the new image, and decode into the old one's buffer next time
std::swap(this->front_buffer_, this->buffer_);
std::swap(this->width_, this->buffer_width_);
std::swap(this->height_, this->buffer_height_);
this->data_start_ = this->front_buffer_;
this->refresh_lvgl_();
}
} else if (!this->progressive_display_) {
// Only now make the image visible
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
// End the session; the decoder object stays warm so the next decode can
@@ -284,6 +349,15 @@ bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && thi
void RuntimeImage::release() {
this->release_buffer_();
if (this->front_buffer_) {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->front_buffer_, this->get_buffer_size(this->width_, this->height_));
this->front_buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
// End any active decode session; decoders free the format-specific working buffers
// they can (PNG), while the decoder object itself is kept warm for the next decode.
if (this->decoder_) {
@@ -291,6 +365,16 @@ void RuntimeImage::release() {
}
}
void RuntimeImage::abort_decode() {
if (!this->is_double_buffered_()) {
this->release();
return;
}
if (this->decoder_) {
this->decoder_->reset();
}
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
if (this->external_buffer_) {
@@ -302,19 +386,25 @@ void RuntimeImage::release_buffer_() {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_));
}
const bool shown = this->data_start_ == this->buffer_;
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
#ifdef USE_LVGL
memset(&this->dsc_, 0, sizeof(this->dsc_));
#endif
// With double buffering the buffer decoded into is not the one on show
if (shown) {
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
}
}
bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) {
if (this->is_double_buffered_()) {
ESP_LOGE(TAG, "An external buffer cannot be double buffered");
return false;
}
this->release_buffer_();
if (buffer == nullptr || this->get_buffer_size(width, height) == 0) {
// Keep the released state rather than remembering a buffer that cannot be decoded into: an
@@ -3,6 +3,8 @@
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.h"
#include <vector>
#include "image_decoder.h"
#include "image_format.h"
@@ -54,6 +56,13 @@ class RuntimeImage : public image::Image {
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
#ifdef USE_LVGL
/// Until an image is loaded, the placeholder's descriptor if there is one
lv_image_dsc_t *get_lv_image_dsc() override;
#endif
#ifdef USE_LVGL_IMAGE
void add_lvgl_widget(lv_obj_t *obj) override;
#endif
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
@@ -118,7 +127,9 @@ class RuntimeImage : public image::Image {
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const { return this->buffer_ != nullptr; }
bool is_loaded() const {
return this->is_double_buffered_() ? this->front_buffer_ != nullptr : this->buffer_ != nullptr;
}
/**
* @brief Get the image format.
@@ -134,6 +145,14 @@ class RuntimeImage : public image::Image {
*/
void release();
/**
* @brief End a decode that will not be finished.
*
* With double buffering the image on show stays and the buffer decoded into is kept for the next
* decode. Otherwise the same as release(), since the image on show was being decoded over.
*/
void abort_decode();
/**
* @brief Decode into a buffer the caller owns, instead of one allocated here.
*
@@ -157,6 +176,8 @@ class RuntimeImage : public image::Image {
* A null buffer or dimensions the image cannot decode at are refused, leaving the image with
* no buffer at all.
*
* Refused when double buffering is on.
*
* @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes.
* @param width Width of the buffer in pixels.
* @param height Height of the buffer in pixels.
@@ -179,6 +200,13 @@ class RuntimeImage : public image::Image {
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
/**
* @brief Set whether to decode into a second buffer, keeping the image on show until the new one is complete.
*
* Both buffers are kept, so a new image of the same size allocates nothing. Ignored with progressive display.
*/
void set_double_buffer(bool double_buffer) { this->double_buffer_ = double_buffer; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
@@ -196,6 +224,11 @@ class RuntimeImage : public image::Image {
*/
void release_buffer_();
bool is_double_buffered_() const { return this->double_buffer_ && !this->progressive_display_; }
/// Bring the LVGL descriptor up to date after the image shown changed, and give it again to the widgets showing it
void refresh_lvgl_();
/**
* @brief Get the position in the buffer for a pixel.
*/
@@ -210,6 +243,8 @@ class RuntimeImage : public image::Image {
// Memory management
uint8_t *buffer_{nullptr};
/// With double buffering, the buffer on show, width_ by height_. buffer_ is then the one decoded into
uint8_t *front_buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
@@ -246,8 +281,14 @@ class RuntimeImage : public image::Image {
// Configuration
bool progressive_display_{false};
bool double_buffer_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
#ifdef USE_LVGL_IMAGE
static void lvgl_widget_deleted(lv_event_t *event);
/// Widgets given this image. One moved to another image is dropped at the next refresh
std::vector<lv_obj_t *> lvgl_widgets_;
#endif
friend class ImageDecoder;
};
@@ -2,29 +2,22 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.core.entity_helpers import FollowEntity
from esphome.types import ConfigType
from .. import Switch, switch_ns
from .. import Switch
CODEOWNERS = ["@ssieb"]
SwitchBinarySensor = switch_ns.class_(
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,15 +0,0 @@
#include "switch_binary_sensor.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
void SwitchBinarySensor::setup() {
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
this->publish_state(source_->state);
}
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
} // namespace esphome::switch_
@@ -1,19 +0,0 @@
#pragma once
#include "../switch.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::switch_ {
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
public:
void set_source(Switch *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
Switch *source_;
};
} // namespace esphome::switch_
@@ -4,23 +4,16 @@ import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Text, text_ns
from .. import Text
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(TextTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
async def to_code(config: ConfigType) -> None:
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await text_sensor.new_text_sensor(config, source)
await cg.register_component(var, config)
var = await text_sensor.new_text_sensor(config)
# Text has the same string state callback as TextSensor
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "text_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::text {
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
void TextTextSensor::setup() {
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
} // namespace esphome::text
@@ -1,19 +0,0 @@
#pragma once
#include "../text.h"
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::text {
class TextTextSensor final : public text_sensor::TextSensor, public Component {
public:
explicit TextTextSensor(Text *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Text *source_;
};
} // namespace esphome::text
+2
View File
@@ -96,6 +96,7 @@
#define USE_GPIO_SWITCH_INTERLOCK
#define USE_GRAPH
#define USE_GRAPHICAL_DISPLAY_MENU
#define USE_GREE_FAHRENHEIT
#define USE_GREE_YAW1F
#define USE_HOERMANN_HCP_IDENTITY
#define USE_HOMEASSISTANT_TIME
@@ -201,6 +202,7 @@
#define USE_REMOTE_PROTOCOL_BRENNENSTUHL
#define USE_REMOTE_PROTOCOL_BYRONSX
#define USE_REMOTE_PROTOCOL_CANALSAT
#define USE_REMOTE_PROTOCOL_CARDIN_S438
#define USE_REMOTE_PROTOCOL_COOLIX
#define USE_REMOTE_PROTOCOL_DISH
#define USE_REMOTE_PROTOCOL_DOOYA
+7 -7
View File
@@ -483,13 +483,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
// Check if item is marked for removal
// This handles two cases:
// 1. Item was marked for removal after cleanup_() but before we got here
@@ -514,6 +507,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
}
#endif
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
{
SchedulerNameLog name_log;
+21 -9
View File
@@ -326,6 +326,25 @@ def load_test_manifest_overrides(
ManifestOverrideLoader = Callable[[list[str]], None]
def resolve_dependencies_with_overrides(
components: list[str], manifest_override_loader: ManifestOverrideLoader
) -> list[str]:
"""Return the components and all their dependencies, with overrides applied.
Overrides are applied before each dependency search, so dependencies added by
any override_manifest(), including one of a component found only as a
dependency, are included. The loader is called again with components it has
already seen, so it must apply each override only once.
"""
resolved = sorted(components)
while True:
manifest_override_loader(resolved)
expanded = sorted(get_all_dependencies(set(resolved)))
if expanded == resolved:
return resolved
resolved = expanded
def compile_and_get_binary(
config: dict,
components: list[str],
@@ -366,19 +385,12 @@ def compile_and_get_binary(
else:
config.setdefault(key, value)
# Apply manifest overrides before dependency resolution so that any
# dependency additions made by override_manifest() are picked up.
manifest_override_loader(components)
# Obtain possible dependencies BEFORE validate_config, because
# get_all_dependencies calls CORE.reset() which clears build_path.
components_with_dependencies: list[str] = sorted(
get_all_dependencies(set(components))
components_with_dependencies = resolve_dependencies_with_overrides(
components, manifest_override_loader
)
# Apply overrides for any transitively discovered dependencies.
manifest_override_loader(components_with_dependencies)
CORE.config_path = tests_dir / "dummy.yaml"
CORE.dashboard = None
@@ -0,0 +1,49 @@
#include <gtest/gtest.h>
#include "esphome/components/api/api_deferred_batch.h"
namespace esphome::api::testing {
class DeferredBatchTest : public ::testing::Test {
protected:
static constexpr uint16_t STATE = 1;
static constexpr uint16_t PING = 2;
EntityBase entities_[3];
DeferredBatch batch_;
};
TEST_F(DeferredBatchTest, FrontItemKeepsTheQueuedOrder) {
for (auto &entity : this->entities_)
this->batch_.add_item(&entity, STATE, 8);
this->batch_.add_item_front(nullptr, PING, 4);
// A swap would have moved the oldest item to the back
std::vector<EntityBase *> order;
order.reserve(this->batch_.size());
for (size_t i = 0; i < this->batch_.size(); i++)
order.push_back(this->batch_[i].entity);
EXPECT_EQ(order, (std::vector<EntityBase *>{nullptr, &this->entities_[0], &this->entities_[1], &this->entities_[2]}));
EXPECT_EQ(this->batch_[0].message_type, PING);
}
TEST_F(DeferredBatchTest, FrontItemIntoAnEmptyBatch) {
this->batch_.add_item_front(nullptr, PING, 4);
ASSERT_EQ(this->batch_.size(), 1u);
EXPECT_EQ(this->batch_[0].message_type, PING);
}
TEST_F(DeferredBatchTest, StatesAreQueuedOnce) {
this->batch_.add_item(&this->entities_[0], STATE, 8);
this->batch_.add_item(&this->entities_[0], STATE, 8);
EXPECT_EQ(this->batch_.size(), 1u);
}
TEST_F(DeferredBatchTest, ContainsMatchesEntityAndMessageType) {
this->batch_.add_item(&this->entities_[0], STATE, 8);
EXPECT_TRUE(this->batch_.contains(&this->entities_[0], STATE));
EXPECT_FALSE(this->batch_.contains(&this->entities_[0], PING));
EXPECT_FALSE(this->batch_.contains(&this->entities_[1], STATE));
}
} // namespace esphome::api::testing
@@ -5,6 +5,8 @@
#include <gtest/gtest.h>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
namespace esphome::core::testing {
@@ -41,4 +43,40 @@ TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) {
scheduler.trim_freelist();
}
// A cancelled timeout of a component that has since failed has to be counted as removed when call()
// reaches it, or the leftover count makes the next few cancellations trigger a full cleanup early.
TEST(Scheduler, CancelledItemOfFailedComponentIsCountedAsRemoved) {
Scheduler scheduler;
SchedulerTestComponent healthy;
SchedulerTestComponent failing;
// The healthy item is due first, so cleanup_() stops at it and the cancelled item is only reached in the run loop
scheduler.set_timeout(&healthy, "first", 1, []() {});
scheduler.set_timeout(&failing, "cancelled", 2, []() {});
scheduler.process_to_add();
scheduler.cancel_timeout(&failing, "cancelled");
failing.mark_failed();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
scheduler.call(millis());
// Four cancelled items behind a live one stay below the full cleanup threshold of five, so their callbacks are
// released only once they reach the top of the heap
auto token = std::make_shared<int>(0);
scheduler.set_timeout(&healthy, "keep", 10000, []() {});
static constexpr const char *NAMES[] = {"c0", "c1", "c2", "c3"};
for (const char *name : NAMES)
scheduler.set_timeout(&healthy, name, 20000, [token]() {});
scheduler.process_to_add();
for (const char *name : NAMES)
scheduler.cancel_timeout(&healthy, name);
ASSERT_EQ(token.use_count(), 5);
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 5);
scheduler.cancel_timeout(&healthy, "keep");
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 1);
scheduler.trim_freelist();
}
} // namespace esphome::core::testing
+2
View File
@@ -8,6 +8,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Fahrenheit support is define-gated; emit it so the tests cover that path.
cg.add_define("USE_GREE_FAHRENHEIT")
# The YAW1F follow-up message is compiled in only for configs that use that model
cg.add_define("USE_GREE_YAW1F")
+7
View File
@@ -4,6 +4,13 @@ climate:
id: my_gree_ac
model: YAN
transmitter_id: xmitr
# YX1FF, the model use_fahrenheit was verified on; it has no mode switches, so they stay on YAN
- platform: gree
name: GREE Fahrenheit
id: my_gree_ac_fahrenheit
model: YX1FF
use_fahrenheit: true
transmitter_id: xmitr
switch:
- platform: gree
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more