mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 01:25:57 +00:00
Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904f0df920 | ||
|
|
7128d22f07 | ||
|
|
825e2c3dd0 | ||
|
|
eebe66da62 | ||
|
|
c032f58c6c | ||
|
|
ff3adb407e | ||
|
|
dcf3a6eb6e | ||
|
|
afbc7151d3 | ||
|
|
37b95ff544 | ||
|
|
6c556c178a | ||
|
|
9f197ca8d7 | ||
|
|
c2e8c872b1 | ||
|
|
9620899479 | ||
|
|
2a6a1a050e | ||
|
|
6a743eca57 | ||
|
|
46844d3631 | ||
|
|
8cad9c4035 | ||
|
|
76ff2d6c0f | ||
|
|
8b7d68b80f | ||
|
|
d40b7b12d5 | ||
|
|
32ac9f17f1 | ||
|
|
eee52fe419 | ||
|
|
915b75dcb8 | ||
|
|
df83d20fdc | ||
|
|
63decb1d68 | ||
|
|
5a04f4949a | ||
|
|
cc6db898e0 | ||
|
|
f6fa1d8e9e | ||
|
|
1442554df2 | ||
|
|
dceba4fa3b | ||
|
|
54f2f25f2d | ||
|
|
9245f20b31 | ||
|
|
483a4f6a73 | ||
|
|
2ef4e52637 | ||
|
|
122df49ef5 | ||
|
|
e96345f5dc | ||
|
|
4d758269a1 | ||
|
|
43d61df1fc | ||
|
|
9843763917 | ||
|
|
0b0b9033e5 | ||
|
|
deeb88a835 | ||
|
|
4d04cd6938 | ||
|
|
eef2ddca0d | ||
|
|
2bfa9d1524 | ||
|
|
610c9faca6 | ||
|
|
d09867c27e | ||
|
|
20d34660b8 | ||
|
|
c894b4514d | ||
|
|
e347dd957b | ||
|
|
2e60fa0f42 | ||
|
|
7684ccee06 | ||
|
|
dccb81b319 | ||
|
|
cdafeb5734 | ||
|
|
b09d62e6a3 | ||
|
|
c4a7b75596 | ||
|
|
2d0c1b07ec | ||
|
|
f216936fdd | ||
|
|
0240f57069 | ||
|
|
3248eeaf1f | ||
|
|
a2ccd64257 | ||
|
|
c22a7a4188 | ||
|
|
b03870b551 | ||
|
|
6e9b37ca0d | ||
|
|
f695f41a3d | ||
|
|
909ffd7313 | ||
|
|
4a965b8b32 | ||
|
|
d826a19eb1 | ||
|
|
fb54ec51c1 | ||
|
|
04b6c0d4ca | ||
|
|
abf282dd08 | ||
|
|
e097958ad9 | ||
|
|
8a8ad4d8da | ||
|
|
09b82388c9 | ||
|
|
ea5407925a | ||
|
|
97732df3a5 | ||
|
|
04482ae0d4 | ||
|
|
ca0dd8e292 | ||
|
|
e75ac64cc9 | ||
|
|
212c0c1ea4 | ||
|
|
3e2f9bf296 | ||
|
|
b997866e86 | ||
|
|
ae89db98e4 | ||
|
|
e9bb102c1f | ||
|
|
e95c1143af | ||
|
|
0d7a4bd87e | ||
|
|
bf6b0fd352 | ||
|
|
b273dfb829 | ||
|
|
0b6a78d206 | ||
|
|
0cf1bad9a7 | ||
|
|
9f74fcb820 | ||
|
|
666c60b158 | ||
|
|
408a24b6a5 | ||
|
|
047ff1aa15 | ||
|
|
e602b7aa53 | ||
|
|
4f1e2da537 | ||
|
|
e3c0ddb5df | ||
|
|
c8d038eada | ||
|
|
01d9547700 | ||
|
|
9c6fb6830d | ||
|
|
3919f4fcbc | ||
|
|
f544ac247b | ||
|
|
12f88f72bb | ||
|
|
f88f254ba0 | ||
|
|
10b503cb07 | ||
|
|
9aee2ea1ef | ||
|
|
434ba13986 | ||
|
|
f4c5d5f818 | ||
|
|
cfb76b1bc4 | ||
|
|
74dee7dd17 | ||
|
|
e0790a9063 | ||
|
|
9b26a15960 | ||
|
|
75b586fb57 | ||
|
|
6d95112b77 | ||
|
|
ff3075cb26 | ||
|
|
40a226f3fd | ||
|
|
182db18dd9 | ||
|
|
2a14d1375b | ||
|
|
8e0cb460ea | ||
|
|
ea5c8a2fbc | ||
|
|
4a6e43f9b3 | ||
|
|
8c2841d6f4 | ||
|
|
ac0dc6fe2e | ||
|
|
dc0cb537cc | ||
|
|
c3c4d70e0e | ||
|
|
f00fa15b54 | ||
|
|
34c4898771 | ||
|
|
19a332507d | ||
|
|
f71fcc1739 | ||
|
|
36fd800c83 | ||
|
|
3a3c390621 | ||
|
|
29875a9e03 | ||
|
|
9abc1812d9 | ||
|
|
a9c3c6d8d0 | ||
|
|
b03e03e465 | ||
|
|
6b2a9870d0 | ||
|
|
da06b29845 | ||
|
|
629dfe1c9a | ||
|
|
354f844843 | ||
|
|
c54b3b41d3 | ||
|
|
8412d6255a | ||
|
|
6b962b15d6 | ||
|
|
6d5400a80f | ||
|
|
f6a2241eb1 | ||
|
|
9d1071e2f8 | ||
|
|
74db3c0484 | ||
|
|
452b7017e6 | ||
|
|
b65cdb788c | ||
|
|
1558751b46 | ||
|
|
5b4d0069d2 | ||
|
|
44e39e6bca | ||
|
|
19e16144b6 | ||
|
|
029199b0ca | ||
|
|
e2b60adc2b | ||
|
|
71d9a995b6 | ||
|
|
9e69326b14 | ||
|
|
942faa87ef | ||
|
|
42cae195f8 | ||
|
|
f85ebd2e21 | ||
|
|
31af32a488 | ||
|
|
279e1dc48f | ||
|
|
dd641ff9d8 | ||
|
|
4d740dc1b4 | ||
|
|
292a646b03 | ||
|
|
429c3586bf | ||
|
|
0929b4d6be | ||
|
|
e24fe74baa | ||
|
|
1daba1b998 |
No files matched your search
@@ -40,6 +40,14 @@ Not shared code: the component's own platform files (`esphome/components/<c>/sen
|
||||
`text_sensor/__init__.py`, ...), its new `USE_*` lines in `esphome/core/defines.h`,
|
||||
and its `CODEOWNERS` entry.
|
||||
|
||||
## YAML language freeze
|
||||
|
||||
Until configurations move to yaml.rocks (esphome/backlog#201), flag any PR that adds to the YAML language: a new tag
|
||||
in `esphome/yaml_util.py`, new behaviour in the `packages` or `substitutions` components (a new entry in the Jinja
|
||||
`SAFE_GLOBALS` in `substitutions/jinja.py`, or a custom Jinja filter, counts), or new merge, override or remove
|
||||
semantics (`esphome/config_helpers.py`, `!extend`, `!remove`). Bug fixes to the existing features are fine. This
|
||||
covers how YAML files are read, included and combined, not a component's own configuration options.
|
||||
|
||||
## Memory and embedded constraints (highest value)
|
||||
|
||||
Heap allocation after `setup()` is treated as a reliability bug, not a
|
||||
|
||||
@@ -56,6 +56,9 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at
|
||||
74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version).
|
||||
`ports.py --wait 2e8a` blocks until a Pico re-enumerates.
|
||||
A change in validation or platform independent code needs no board: a `host:` build serves the API on
|
||||
port 6053 on every interface (reach it at 127.0.0.1, and add `api: encryption:` on a shared network); see
|
||||
recipes/homeassistant.md for one against a throwaway Home Assistant.
|
||||
2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need.
|
||||
One yaml per board per variant, file name equal to `name:`.
|
||||
3. **Compile** in the right tree:
|
||||
@@ -125,12 +128,13 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
||||
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
|
||||
or exits 1 when a build, flash or port step failed.
|
||||
|
||||
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
|
||||
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `heap_alloc/` (operator new caller
|
||||
recorder, dump saver and decoder, cover cycle driver), `online_image/`
|
||||
(server with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header,
|
||||
capture decoder, RTL8720C ROM dump versus report matcher), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer, frame stream harness with presence hold and link loss drivers, BT MAC driver), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (`ha_discovery/`, `disable/`, `retained_burst/` and `reconnect/` runners; broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner), `gpio_hold/` (ISR pin hold self test with ESP32 and S3 configs), `ble_event_inject/` (GAP and GATTC event injector header and proxy package), `light_sleep_wake/` (LEDC wave and edge timer for light sleep wakeup on a C3, external wave generator and per-call edge detail probe for peripheral power-down on the P4), `manual_ip/` (station and AP manual IP configs, DNS probe and runners), `web_handlers/` (late handler, web_server, captive_portal and prometheus variants, web OTA runner), `bk72xx_flash/` (flash readback with retries, per partition diff of two dumps), `ble_server_security/` (secure GATT server and pairing client configs, driver, summary and runner), `prometheus/` (datetime metrics configs and driver), `zephyr_ble_server/` (nRF52 server, looping ESP32 client and UF2 flash and count runner), `ble_adv_triggers/` (advertiser with six raw frames, ESP32 and BK72xx receivers with eleven triggers, runner and checker), `sun/` (longitude sweep package, runner and checker), `sensor_filters/` (every filter shape with a fixed input sequence, per-board packages and runner), `led_driver_sniff/` (SPI slave capture header with selftest, config generator, window comparer and runner), `hub_input_sim/` (keypad, wiegand and rdm6300 simulation on a D1 mini, API log runner), `value_range/` (on_value_range sequence with a restart, runner), `ezo_fake_bus/` (fake EZO I2C bus and step config, runner), `twai_self/` (TWAI self-reception header, on_frame config and counter runner), `ota_automations/` (OTA automation package for both OTA platforms, four case runner, repeated web upload runner and per case summary), `output_button/` (pad edge timer header, button package and runner), `homeassistant_button/` (raw plaintext API client for action calls and wizard inputs, package and runner), `uart_button/` (UART0 loopback package, API driver with TX and RX counts, runner), `interval/` (plain and delayed interval package, timing summary and runner), `smp_udp_ota/` (SMP reset fault injector, OTA runner, per mode result check, MCUboot swap limit, running image check and slot blanking UF2, Thread device config), `ld2420_sim/` (fake LD2420 answering the hub's commands on a UART0 loopback, API state recorder, two-build comparer and runner), `nrf52_tcp_probe/` (TCP and UDP socket probe config, build and log check, API check over Thread), `rs485_frame/` (simulated DLE/STX/ETX bus device, frame and gate cases, runner), `hx711/` (fake HXLine truncated
|
||||
capture decoder, RTL8720C ROM dump versus report matcher), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer, frame stream harness with presence hold and link loss drivers, BT MAC driver), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards, AT commands for the UART passthrough), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (`ha_discovery/`, `disable/`, `retained_burst/` and `reconnect/` runners; broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner), `gpio_hold/` (ISR pin hold self test with ESP32 and S3 configs), `ble_event_inject/` (GAP and GATTC event injector header and proxy package), `light_sleep_wake/` (LEDC wave and edge timer for light sleep wakeup on a C3, external wave generator and per-call edge detail probe for peripheral power-down on the P4), `manual_ip/` (station and AP manual IP configs, DNS probe and runners), `web_handlers/` (late handler, web_server, captive_portal and prometheus variants, web OTA runner), `bk72xx_flash/` (flash readback with retries, per partition diff of two dumps), `ble_server_security/` (secure GATT server and pairing client configs, driver, summary and runner), `prometheus/` (datetime metrics configs and driver), `zephyr_ble_server/` (nRF52 server, looping ESP32 client and UF2 flash and count runner), `ble_adv_triggers/` (advertiser with six raw frames, ESP32 and BK72xx receivers with eleven triggers, runner and checker), `sun/` (longitude sweep package, runner and checker), `sensor_filters/` (every filter shape with a fixed input sequence, per-board packages and runner), `led_driver_sniff/` (SPI slave capture header with selftest, config generator, window comparer and runner), `hub_input_sim/` (keypad, wiegand and rdm6300 simulation on a D1 mini, API log runner), `value_range/` (on_value_range sequence with a restart, runner), `ezo_fake_bus/` (fake EZO I2C bus and step config, runner), `twai_self/` (TWAI self-reception header, on_frame config and counter runner), `ota_automations/` (OTA automation package for both OTA platforms, four case runner, repeated web upload runner and per case summary), `output_button/` (pad edge timer header, button package and runner), `homeassistant_button/` (raw plaintext API client for action calls and wizard inputs, package and runner), `uart_button/` (UART0 loopback package, API driver with TX and RX counts, runner), `interval/` (plain and delayed interval package, timing summary and runner), `smp_udp_ota/` (SMP reset fault injector, OTA runner, per mode result check, MCUboot swap limit, running image check and slot blanking UF2, Thread device config), `ld2420_sim/` (fake LD2420 answering the hub's commands on a UART0 loopback, API state recorder, two-build comparer and runner), `nrf52_tcp_probe/` (TCP and UDP socket probe config, build and log check, API check over Thread), `rs485_frame/` (simulated DLE/STX/ETX bus device, frame and Line truncated
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and
|
||||
stall the link until the broker drops TCP.
|
||||
- `Arduino.h` already declares `_heap_start`; a lambda can use `&_heap_start` directly, and a second `extern` fails.
|
||||
- `Hardware Watchdog` with `wDev_ProcessFiq` in the stack means interrupts were held off too long for the Wi-Fi
|
||||
interrupt handler; `Software Watchdog` with `pm_send_nullfunc` means the loop was busy too long. Code that takes
|
||||
interrupt locks needs a load run to show either (`scripts/esp8266_lock_load/`, see recipes/one_wire.md).
|
||||
- `GPI` bits 16 to 31 are strapping bits (mask with 0xFFFF). GPIO16 has no interrupt; read `GP16O` for an output.
|
||||
|
||||
## ESP32 classic devkit
|
||||
|
||||
@@ -15,9 +15,9 @@ One file per component, named after it:
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar, presence hold and link loss on a frame stream, BT MAC readback
|
||||
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot with a ROM dump matcher, BK7238 BLE bond store safety with a full flash backup and per partition diff, BK7238 OTA versus the board layout and bootloader, pulse counting, RTL8710B edge interrupts and millis timing
|
||||
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
|
||||
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing, SIM7080G behaviour with no SIM and with no data plan through a raw UART passthrough
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
|
||||
- [`nrf52.md`](nrf52.md): dongle board support, zephyr_ble_server connection slot under reordered callbacks, SMP over UDP OTA over Thread with reset faults, MCUboot swap limit and stuck slot recovery, TCP stack probe
|
||||
- [`nrf52.md`](nrf52.md): dongle board support, zephyr_ble_server connection slot under reordered callbacks, SMP over UDP OTA over Thread with reset faults, MCUboot swap limit and stuck slot recovery, TCP stack probe, sdk-nrf 3.4.1 build and USB notes
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
|
||||
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
|
||||
- [`ota.md`](ota.md): upload without a configuration and the refusal checks, overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark, OTA automations on two platforms
|
||||
@@ -25,12 +25,14 @@ One file per component, named after it:
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B, interval startup delay and control from lambdas
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save, soft AP clients with the BLE tracker scanning, ESP8266 power save after captive portal provisioning
|
||||
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
- [`one_wire.md`](one_wire.md): ESP8266 interrupt locks under Wi-Fi load with a DS18B20 search and a busy-wait control
|
||||
- [`cc1101.md`](cc1101.md): sleep and wake checks read directly from the chip (CHIP_RDYn, PATABLE loss, registers per setter)
|
||||
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
|
||||
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls, wizard-linked entities on every platform
|
||||
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls, wizard-linked entities on every platform, homeassistant switch on a script with a host build
|
||||
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
|
||||
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
|
||||
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
|
||||
@@ -61,7 +63,7 @@ One file per component, named after it:
|
||||
- [`rs485_frame.md`](rs485_frame.md): DLE/STX/ETX bus on an ESP32-C3 with a cross-wired simulated bus device (frame cases, gate timing)
|
||||
- [`hx711.md`](hx711.md): fake HX711 driven from the other core, every published value matched to the served conversion
|
||||
- [`automation_trace.md`](automation_trace.md): automation codegen A/B, a marker trace of chains, conditions, loops and script modes over the API
|
||||
- [`bluetooth_proxy.md`](bluetooth_proxy.md): held GATT link after a half-open API session, frozen Home Assistant stand-in
|
||||
- [`bluetooth_proxy.md`](bluetooth_proxy.md): held GATT link after a half-open API session, frozen Home Assistant stand-in, out-of-order GATT discovery responses injected on the proxy
|
||||
- [`crash_report.md`](crash_report.md): crash buttons, fetching the previous boot's crash report over the API, per-chip abort facts
|
||||
- [`https_idf.md`](https_idf.md): web_server_idf HTTPS test component, listener switch, stalled TLS clients, bad key
|
||||
- [`mdns.md`](mdns.md): legacy unicast mDNS dump, hammer and latency straight to the device
|
||||
@@ -78,3 +80,6 @@ One file per component, named after it:
|
||||
- [`mcp23017.md`](mcp23017.md): MCP23017 IOCON aliasing and INT line behavior (open drain, push-pull, polling) on an ESP8266 with PA to PB jumpers
|
||||
- [`pwm_capture.md`](pwm_capture.md): every LEDC PWM period captured on the same ESP32-C6 through RMT receive and PCNT, with and without power save
|
||||
- [`spi.md`](spi.md): SPI bus capture on the same board with no chips (ESP32 SPI3 slave, edge interrupt snapshots on C3, ESP8266 and Pico 2 W), checked and diffed between builds
|
||||
- [`heap_alloc.md`](heap_alloc.md): operator new counting, and a ring of allocation callers decoded with addr2line to tell the first run after boot from repeats
|
||||
- [`wled.md`](wled.md): WLED realtime sender on a board's own access point
|
||||
- [`combination.md`](combination.md): combination sensor NaN behaviour from the last source
|
||||
@@ -56,3 +56,9 @@ include of random base64 lines with `include_secrets: true` gives four chunks (1
|
||||
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
|
||||
`secrets.yaml` beside it; a copy next to the package works around it.
|
||||
Results: https://github.com/esphome/esphome/pull/16445#issuecomment-6335435397
|
||||
|
||||
## Allocations of a connecting client (ratgdo/esphome-ratgdo#705)
|
||||
|
||||
An API client that connects allocates about 15 times on ESP32: the connection object, then the list entities and
|
||||
initial state batches growing the `DeferredBatch` vector. A log subscription adds more per line. Connect before a
|
||||
heap measurement and subscribe to logs only afterwards (`heap_alloc.md`).
|
||||
@@ -42,6 +42,7 @@ comparison accepted automatically) and a classic ESP32 devkit runs `esp32central
|
||||
phase B reads the MITM characteristic with no numeric comparison and no `Deleting unpaired device` line.
|
||||
- On ESP-IDF 5.5.6 every other reconnect pairs again (`Deleting unpaired device` on the disconnect after a bonded
|
||||
reconnect), the regression #22081 describes.
|
||||
#22559 fixes it: the bond survived 15 reconnects and a reset of both boards with no new pairing.
|
||||
|
||||
## Bluedroid hex parsing bench (#22081)
|
||||
|
||||
|
||||
@@ -26,3 +26,32 @@ swap fails when a round never connects. An owner that never got the link also fa
|
||||
- Before #22077, case H holds the link for good: 0 adverts, and the address stays in the slot report. With #22077 the
|
||||
link is released in the same millisecond as the `replaces` warning. In case 4 the first request logs `Connection
|
||||
request ignored, state: DISCONNECTING` (connected=false at 0.2 s) and the retry connects at 0.5 to 0.7 s.
|
||||
|
||||
## Out-of-order GATT discovery responses (#22518)
|
||||
|
||||
Some peripherals answer a characteristic discovery (Read By Type) with records out of handle order. ESP-IDF 5.5.6
|
||||
rejects that (`gatt_process_read_by_type_rsp invalid handle ...`) and ends the whole discovery; 5.5.5, Android and
|
||||
BlueZ accept it. `scripts/att_reorder/` reproduces it with no such device:
|
||||
|
||||
- `server.yaml` (ESP32-S3) serves `FFF0` with six characteristics, then `1826`.
|
||||
- `proxy.yaml` (classic ESP32, `idf` substitution; keep per-build files such as `example-proxy.yaml` in the same
|
||||
directory) links `components/attswap`, which wraps Bluedroid's
|
||||
`l2c_rcv_acl_data` (`-Wl,--wrap`); `attswap.hpp` swaps the first two records of the response whose first record
|
||||
declares `FFF1`, and logs a swap count.
|
||||
- `gattget.py PROXY PEER_MAC ROUNDS` reads the services through the proxy like Home Assistant (no cache);
|
||||
`run.sh PROXY_YAML [ROUNDS]` flashes both boards and prints the services per round and the discovery errors.
|
||||
|
||||
On 5.5.6 Home Assistant gets `fff0` with no characteristics and no `1826`, while the proxy still logs
|
||||
`Service discovery complete` (the proxy ignores the error status); on 5.5.5 every service arrives. To find the order a
|
||||
real device uses, decode an Android HCI snoop: a Read By Type response lists its records in the order sent.
|
||||
|
||||
- Handles, reads and notifies after a reordered response: flash `server-notify.yaml` (`SERVER_YAML` on `run.sh`) and run
|
||||
`gattcheck.py PROXY PEER_MAC`. It prints every service, characteristic and descriptor handle, reads FFF1, FFF2 and
|
||||
FFF3 by the received handles (each holds its own value), and enables notify on FFF1, FFF2 and 2ACD. Compare the table
|
||||
with a run of a proxy built without `attswap`. On ESP-IDF 5.5.5, which accepts the unsorted response, FFF1's
|
||||
descriptor range runs over FFF2's declaration, value and CCCD, so FFF2 has no CCCD (#22576).
|
||||
- `bluetooth_gatt_start_notify` only registers the notify on the proxy; the client writes the CCCD itself, as Home
|
||||
Assistant does. Without that write no notification arrives.
|
||||
- Give the proxy an `api` encryption key (`API_KEY` for the scripts). A Home Assistant on the same network connected to
|
||||
a plaintext test proxy and took over its Bluetooth subscription (`Subscription from Home Assistant ... replaces
|
||||
aioesphomeapi`), dropping the test client's connection mid-run.
|
||||
@@ -0,0 +1,17 @@
|
||||
# cc1101
|
||||
|
||||
## Sleep and wake checks read from the chip (#22519)
|
||||
|
||||
`scripts/cc1101_sleep/`: `cc1101-sleep.yaml` runs an ESP32-S3 (ESP-IDF) with an E07-M1101D module on SPI and runs
|
||||
tests T0 to T6 4 s after boot; `cctest.hpp` reads every config register and the PATABLE directly over SPI. The log
|
||||
ends with `DONE fails=N`, and T6 prints whether each setter woke a sleeping chip.
|
||||
|
||||
- Proof that the chip sleeps: with CSn low, SO (MISO) stays high until the crystal runs, so it is high after
|
||||
`power_down()` and low at once when awake. PATABLE entries 1 to 7 are also lost in SLEEP, so write marks to them
|
||||
before `power_down()` and check that they are gone.
|
||||
- Selecting the chip to check it wakes it, so after the check the chip is awake while the component still thinks it
|
||||
sleeps. Call `wake_up()` and `begin_rx()` before the next case.
|
||||
- Leave FSCAL3 to FSCAL1 (0x23 to 0x25), FSTEST (0x29) and AGCTEST (0x2B) out of register comparisons: calibration
|
||||
and the chip itself change them.
|
||||
- Results: on 7e6b90d08b, `set_frequency`, `set_channel` and `set_modulation_type` woke a sleeping chip and left it
|
||||
in RX. 9e65d0c327 fixed that; every other check passed on both, and 200 sleep and wake cycles took about 1 s.
|
||||
@@ -0,0 +1,7 @@
|
||||
# combination
|
||||
|
||||
## NaN from the last source (#22510)
|
||||
|
||||
A combination sensor defers each update and uses the value of the source that published last. A NaN from that
|
||||
source drops the whole update, so when testing NaN handling keep the last-publishing source finite, and compare
|
||||
rows one update late. Heap churn per update: see `heap_alloc.md`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Heap allocation counts and callers (#22509, #22510, ratgdo/esphome-ratgdo#705)
|
||||
|
||||
## Counting
|
||||
|
||||
Replace the global `operator new` in an `esphome: includes:` header on ESP32 (Arduino or ESP-IDF) and log the
|
||||
delta from an `interval`. Compare a PR build with its base on the same flow; the rate per second shows churn.
|
||||
- The ESP8266 core defines `operator new` non-weak. `-Wl,--wrap=_Znwj` worked in a sensor-only config but
|
||||
boot-looped a neopixelbus config (alignment fault in the wrapper), so report free heap only there.
|
||||
- `vec = {}` keeps a std::vector's capacity (it is an initializer_list assignment); only
|
||||
`std::vector<T>().swap(vec)` or `clear()` plus `shrink_to_fit()` frees it. Seen as heap that did not come back
|
||||
after a WLED effect stopped.
|
||||
|
||||
## Callers
|
||||
|
||||
`scripts/heap_alloc/`:
|
||||
- `allocring.hpp` counts every `operator new` and, once armed, keeps the first 1024 with their size, time and
|
||||
five caller PCs, about 28 KB of RAM. It disarms itself when full. The frame walker
|
||||
(`esp_backtrace_get_next_frame`) exists only on Xtensa (ESP32, S2, S3); on RISC-V chips entries keep size and
|
||||
time without callers. Aligned `operator new` (`std::align_val_t`) is not replaced, so it is not counted;
|
||||
nothrow `new` goes through the replaced one.
|
||||
- `allocring.yaml` (package, substitution `alloc_arm_s`) arms it that many seconds after boot and adds a
|
||||
"Dump allocations" button that logs what was kept. The press stops recording until the next reboot, so
|
||||
reboot (or reflash) before each run; a second press returns the same entries.
|
||||
- `alloc_marker(a, b)` from a lambda writes a marker entry, for example on each cover operation change, so the
|
||||
dump splits into phases.
|
||||
- `dump.py HOST OUT` presses the button and writes the dump to a fresh OUT, pressing again until the header
|
||||
and every entry arrived (a late log subscription or dropped log lines). `decode.py LOG ELF [ADDR2LINE]` prints each entry with its callers as function and file:line, and exits 2
|
||||
when the recorder never armed, an entry was still being written, entries are missing, repeated or out of
|
||||
range, no Xtensa entry has a caller, the ELF is from another build (the buffer address in the header differs), or the buffer
|
||||
filled. A PR build and its base can share that address, so copy each build's `firmware.elf` next to its dump. On RISC-V it prints sizes and times only. It reads only the newest dump in the file. The ESP-IDF ELF is `.esphome/build/<name>/build/firmware.elf`.
|
||||
- `cycles_cover.py HOST N OUT` waits for the "Allocation recorder armed" binary sensor, then moves the first
|
||||
cover to the far end and back N times (`cover_command(position=...)`, waiting for idle each way) with a states-only API client, then saves the dump the same way. It exits 2 when a move
|
||||
never reached its position.
|
||||
|
||||
Findings:
|
||||
- Arm well after boot: the scheduler regrows its storage once after the freelist trim (see `scheduler.md`).
|
||||
- Repeat the action several times in one session and count only what comes back. On a ratgdo cover (an external
|
||||
component, ESP32 ESP-IDF) the first door open after boot allocated 18 times: four 36 B SchedulerItems,
|
||||
`to_add_` and `items_` growth, and the API `DeferredBatch` vector. Cycles 2 and 3 allocated nothing.
|
||||
- Keep the API client out of the count (see `api.md`): connect before the run, subscribe to states only, and
|
||||
subscribe to logs only for the dump.
|
||||
@@ -66,3 +66,23 @@ only its current ID.
|
||||
`No entity ID set yet` check; nothing is sent either way.
|
||||
- Field 3 of LightStateResponse is brightness, not `missing_state`; SwitchStateResponse has no missing flag.
|
||||
- A light publishes only changes, so a report of the state it already has publishes nothing.
|
||||
|
||||
## homeassistant switch on a script, host build against a throwaway Home Assistant (#22548)
|
||||
|
||||
No board is needed when the change is in validation or platform independent code: an ESPHome `host:` build serves the
|
||||
API on 127.0.0.1:6053. `scripts/homeassistant_script_switch/`:
|
||||
|
||||
- `ha-configuration.yaml` is the Home Assistant config (port 8129, a 20 s `long_job` script that sets and clears
|
||||
`input_boolean.script_ran`, a `quick_job` toggle script). Onboard it with `scripts/mqtt/ha_discovery/onboard.py DIR`.
|
||||
- `hsw.yaml` mirrors both scripts and the flag with homeassistant switches, with template buttons that call
|
||||
`switch.turn_on` and `switch.turn_off`.
|
||||
- `haflow.py TOKENFILE` adds the device with the esphome config flow and turns on `allow_service_calls` in the options
|
||||
flow; without it the device's action calls are refused.
|
||||
- `drive.py TOKENFILE NAME` presses the buttons through the REST API and prints the script and flag states after each
|
||||
step.
|
||||
|
||||
Notes:
|
||||
- homeassistant import entities default to `internal: true`, so the mirrors are not Home Assistant entities; read
|
||||
them from the device's `Got state` log lines.
|
||||
- Turning a script off stops it mid run (the flag stays on). A script that finishes at once reports on, then off, in
|
||||
the same second.
|
||||
@@ -16,3 +16,18 @@ states, saves the device log). Before 8cc8df4a1f a modem only config with `api:`
|
||||
config dependent `AUTO_LOAD` added `network` after the dependency check; an explicit `network:` block was the
|
||||
workaround. Results: https://github.com/esphome/esphome/pull/6721#issuecomment-6347395159 and
|
||||
https://github.com/esphome/esphome/pull/6721#issuecomment-6348520416
|
||||
|
||||
## SIM7080G on an M5Stack Atom Lite (#6721)
|
||||
|
||||
The M5 Unit CatM (SIM7080G) on the Atom Lite's Grove port: RX GPIO32, TX GPIO26, `model: SIM7080`.
|
||||
`templates/modem/modem-passthrough.yaml` is a raw UART passthrough with both directions logged, and
|
||||
`scripts/modem/mdrive.py HOST LOGFILE text:AT+CPIN? wait:5 ...` sends AT commands through it and saves the
|
||||
replies, to check what the module does with no component in the way.
|
||||
|
||||
- With no SIM, the component sets the `SIM not ready` warning and polls `AT+CPIN?` once a second.
|
||||
- With a SIM that has no data plan, `AT+CGATT=1` blocks the AT channel for about 141 s and then answers `ERROR`.
|
||||
The module answers nothing meanwhile, so the component logs `Network attachment request refused` and then `Modem not
|
||||
responding`, and loops every 2 minutes 22 seconds.
|
||||
- `AT+CFUN=1,1` leaves the module silent for about 2.5 minutes. It comes back without a power cycle.
|
||||
- A module left in CMUX (`AT+CMUX=0`, then an ESP restart) recovered 10 of 10 times (`Autodetect mode: 5`).
|
||||
- PPP and a warm reboot during PPP need a SIM with a working data plan.
|
||||
@@ -101,3 +101,14 @@ once on a build without TCP, or `tcp ok udp ok` twice and no error with it. Add
|
||||
come back, and connect with `apicheck.py ADDR` over Thread. Measured on the ItsyBitsy: 12.9 KB less flash and 17.1 KB
|
||||
less RAM without TCP (Zephyr's default pools, 16/16 instead of 24/48), and SMP over UDP OTA still works with them.
|
||||
Results: https://github.com/esphome/esphome/pull/21910#issuecomment-6518287139
|
||||
|
||||
## sdk-nrf 3.4.1 on the ItsyBitsy (#22233, #22558)
|
||||
|
||||
- Set `framework: version: 3.4.1` and `board: adafruit_itsybitsy`.
|
||||
- Trees before #22233 include `<nrf.h>` in `logger_zephyr.cpp`, which 3.4.1 no longer has on the include path, so
|
||||
Adafruit bootloader boards fail to build (`nrf.h: No such file or directory`). To test such a tree on 3.4.1, change
|
||||
that include to `<nrfx.h>` locally.
|
||||
- On the new USB stack, `usb_cdc_acm` bulk echo ran at about half the legacy rate (67 against 121 KiB/s for 64 KiB),
|
||||
and `flush()` returned `3`. The logger, DTR, line coding and the 1200 baud touch worked.
|
||||
- The zephyr_ble_server flow above, on 3.4.1: before #22558 advertising failed to restart after the first disconnect
|
||||
(`Advertising failed to start (rc -12)`); with it the server took 39 connections in 120 s.
|
||||
@@ -0,0 +1,32 @@
|
||||
# one_wire
|
||||
|
||||
## ESP8266 interrupt locks under Wi-Fi load (#22452, #22453)
|
||||
|
||||
Code that holds interrupts off for long on the ESP8266 starves the Wi-Fi interrupt handler (reset signatures in
|
||||
`reference/boards.md`). `scripts/esp8266_lock_load/` is the shared harness for any component that takes such locks:
|
||||
|
||||
- `base.yaml` is the shared DUT base: reset reason and info logged at boot, `power_save_mode: none`, `web_server` for
|
||||
the HTTP load, `api: reboot_timeout: 0s`.
|
||||
- `one-wire.yaml` reads a DS18B20 on GPIO4 every 2 s and runs a bus search every `search_interval`; `busy_control:
|
||||
"true"` replaces the search with the same busy time (13 ms) and no 1-Wire traffic.
|
||||
- `ir-one-pin.yaml` is the remote_transmitter case (see [remote_base.md](remote_base.md)).
|
||||
- `wifi_load.py HOST SECONDS PPS` sends 512 byte UDP datagrams to port 9 at a fixed rate plus back to back GETs of
|
||||
the web_server page.
|
||||
- `run.sh` builds and flashes a DUT, captures the serial log, runs the load and prints every reset reason (usage in its
|
||||
header). A clean run has one BOOT line.
|
||||
|
||||
Make one file per build with `substitutions: {name: ...}` and `packages: {t: !include one-wire.yaml}`, with
|
||||
`secrets.yaml` next to it, and give dev and the PR different names.
|
||||
|
||||
Notes:
|
||||
- Run each build for 45 minutes at 500 packets per second. Shorter or lighter runs often show no reset on either
|
||||
build: one search every 5 s survived on both.
|
||||
- The busy-wait control separates the lock from the CPU time. On #22452 it had no Hardware Watchdog in 45 minutes,
|
||||
while the PR still had 2 (dev had 6), so the remaining resets came from the slot locks. All three builds had one
|
||||
Software Watchdog, which is the busy time plus the load.
|
||||
- A search with no sensor answering is much shorter than one with a sensor (about 190 slot locks per search with
|
||||
one DS18B20), so test with a real sensor on the bus.
|
||||
- A stale `secrets.yaml` gives `No networks found` and the run has no IP to load.
|
||||
- The 1-Wire bus code is shared by every platform, so check a lock change on an ESP32 too: the same DS18B20 search
|
||||
config on ESP-IDF with `bluetooth_proxy` scanning and the same load gave 0 resets and no missed searches on dev and
|
||||
on #22452.
|
||||
@@ -12,7 +12,8 @@ markers and prints what each sent frame decoded as.
|
||||
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
|
||||
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
|
||||
where no primary matched.
|
||||
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
|
||||
- ESP8266 before #22453: the transmitter sends the whole frame with interrupts off, so a receiver on the same pin
|
||||
sees nothing (from #22453 on it decodes, see below). For older trees, `softtx.hpp`
|
||||
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
|
||||
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
|
||||
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
|
||||
@@ -28,3 +29,18 @@ Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
|
||||
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
|
||||
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
|
||||
only the last line sees "Failed to upload" first, so rerun before blaming the build.
|
||||
|
||||
## ESP8266 transmitter to receiver on the same board (#22453)
|
||||
|
||||
- Wire the `remote_transmitter` pin to a `remote_receiver` pin (GPIO12 to GPIO14 on a D1 mini), `inverted: false`.
|
||||
- Keep the receiver's default `idle`. `idle: 4ms` is shorter than the NEC leader space (4.5 ms), so every frame is
|
||||
split and nothing decodes.
|
||||
- Dev before #22453 sends the whole frame inside one `InterruptLock`, so a receiver on the same chip decodes 0
|
||||
frames. That is expected on old builds, not a wiring fault. With locks per edge, 3,692 of 3,695 frames decoded
|
||||
under Wi-Fi load.
|
||||
- Count with `on_nec` matching a counter in the command, and log the counts on an `interval`.
|
||||
- Without a jumper, put both on GPIO12 with `allow_other_uses: true` and call `pinMode(12, OUTPUT)` before each frame
|
||||
(the receiver's setup leaves the shared pin as an input); the receiver's edge interrupt sees the transmitter's own
|
||||
output. `scripts/esp8266_lock_load/ir-one-pin.yaml` does this, and its `run.sh` adds Wi-Fi load (see
|
||||
[one_wire.md](one_wire.md)). Under load, the build that sends the whole frame in one lock hit a Hardware Watchdog in
|
||||
`wDev_ProcessFiq` after 11 minutes.
|
||||
@@ -21,3 +21,9 @@ captures 40 s from boot and runs `ivsum.py`, which prints the run count, first r
|
||||
runs), so do not compare those offsets between builds.
|
||||
- `main.cpp` shows the class each interval gets (`IntervalTrigger` or `DelayedIntervalTrigger` on #21799).
|
||||
- Results: https://github.com/esphome/esphome/pull/21799#issuecomment-6516798398
|
||||
|
||||
## Freelist trim after boot (ratgdo/esphome-ratgdo#705)
|
||||
|
||||
10 s after setup the scheduler frees its item pool and shrinks its `to_add_` and `items_` vectors
|
||||
(`SCHEDULER_FREELIST_TRIM_DELAY_MS` in `application.cpp`). The first burst of timers after that allocates new
|
||||
items and regrows the vectors once, so heap or timing measurements should skip the first run after boot.
|
||||
@@ -24,3 +24,8 @@ identical payloads share one table, a lambda is sent from its vector. An empty c
|
||||
(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old
|
||||
`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own
|
||||
head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263
|
||||
|
||||
## Two sockets on one port (#22509)
|
||||
|
||||
lwIP lets two sockets bind one UDP port when `SO_REUSEADDR` is set, and unicast then goes to the socket opened
|
||||
last; without it the second bind fails with errno 112.
|
||||
@@ -93,3 +93,27 @@ connected channel starts and ends on the bench channel with another channel in b
|
||||
- Dropping the AP under a connected device gives a `Beacon Timeout` link loss.
|
||||
- The 90 s interval fires once right after boot.
|
||||
- A `Disconnected ... Association Leave` right after `Connecting to` is the aborted attempt, not a missed connect.
|
||||
|
||||
## Soft AP with the BLE tracker scanning (#22534, #22566)
|
||||
|
||||
`scripts/ble_scan_soft_ap/`: `dut.yaml` runs only `wifi: ap:` with `captive_portal:` and `bluetooth_proxy:`, and
|
||||
`client.yaml` (on a second ESP32) joins that AP and fetches the portal page every 5 s, logging `JOIN` and `STAT`
|
||||
lines. `run.sh` erases and flashes the DUT, resets the client and prints the generated scan window, the first join, the
|
||||
page counts and the client's disconnect reasons (usage in its header).
|
||||
|
||||
- A scan window equal to the scan interval (320 ms of 320 ms) leaves the radio no time for the soft AP: on dev no
|
||||
client joined on the classic ESP32, S3, C3 or C6. With #22566 the tracker uses its 30 ms connection window while
|
||||
the AP runs, and every chip took a client in about 4 s.
|
||||
- Only one AP with the test SSID may be up at a time; list the other AP boards in `PARK` so they wait in their ROM
|
||||
bootloader.
|
||||
- To check that the full window comes back after provisioning, log `esp32_ble_tracker` at VERBOSE and provision
|
||||
with `templates/client-wifisave.yaml` (set `ap_ssid` to the DUT's and `ap_password: ""`, the DUT's AP is open): `Connection or AP active, using 48 unit scan window` must stop once the AP stops.
|
||||
- Some clients do not join an ESP8266 AP at all (the C6 failed on dev too with `Too Many Associations`); use a C3.
|
||||
|
||||
## ESP8266 power save after captive portal provisioning (#22542, #22564)
|
||||
|
||||
The DUT runs `ap:` plus `captive_portal:` with no station network and logs `wifi_get_sleep_type()` every 5 s (0 is
|
||||
`NONE_SLEEP_T`, 2 is `MODEM_SLEEP_T`); an ESP32-C3 with `templates/client-wifisave.yaml` posts the real credentials.
|
||||
Erase the flash before each run so no saved credentials skip the portal. On dev the sleep type stayed 2 after
|
||||
provisioning although the config asked for none; with #22564 it read 0 from the moment the AP started, and the
|
||||
`networks:` path still read 0.
|
||||
@@ -0,0 +1,9 @@
|
||||
# wled
|
||||
|
||||
## Realtime sender on the bench (#22509)
|
||||
|
||||
When the access point stops passing traffic between clients (lwIP `sendto` on the sender fails with errno 12
|
||||
because ARP never resolves), run the sender on a board instead of the host: an ESP32-S3 runs `wifi: ap:` and a
|
||||
25 ms interval lambda sends WARLS, DRGB or DNRGB frames with lwIP `sendto` to the devices under test, which join
|
||||
that AP with `manual_ip`. Two lights on the same port: see `udp.md`. Heap after an effect stops: see
|
||||
`heap_alloc.md`.
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
// Test only: swaps the first two records of an ATT Read By Type Response whose first record declares characteristic
|
||||
// 0xFFF1, the way some peripherals answer out of handle order (esphome#22518). Count of swaps in attswap_count.
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
struct AttSwapHdr {
|
||||
uint16_t event, len, offset, layer_specific;
|
||||
};
|
||||
extern "C" void __real_l2c_rcv_acl_data(void *p_msg);
|
||||
static volatile uint32_t attswap_count = 0;
|
||||
|
||||
extern "C" void __wrap_l2c_rcv_acl_data(void *p_msg) {
|
||||
auto *h = static_cast<AttSwapHdr *>(p_msg);
|
||||
uint8_t *p = reinterpret_cast<uint8_t *>(h + 1) + h->offset;
|
||||
// HCI ACL header (4), L2CAP header (4), then ATT: opcode, record length, records
|
||||
if (h->len >= 4 + 4 + 2 + 14) {
|
||||
uint16_t cid = p[6] | (p[7] << 8);
|
||||
uint8_t *att = p + 8;
|
||||
// record: handle(2) properties(1) value handle(2) uuid16(2)
|
||||
if (cid == 0x0004 && att[0] == 0x09 && att[1] == 7 && att[2 + 5] == 0xF1 && att[2 + 6] == 0xFF) {
|
||||
uint8_t tmp[7];
|
||||
memcpy(tmp, att + 2, 7);
|
||||
memcpy(att + 2, att + 9, 7);
|
||||
memcpy(att + 9, tmp, 7);
|
||||
attswap_count = attswap_count + 1;
|
||||
}
|
||||
}
|
||||
__real_l2c_rcv_acl_data(p_msg);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Test only: wrap Bluedroid's l2c_rcv_acl_data so attswap.h can reorder a Read By Type response."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({})
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_build_flag("-Wl,--wrap=l2c_rcv_acl_data")
|
||||
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
name: attprx6
|
||||
idf: 5.5.6
|
||||
packages:
|
||||
c: !include proxy.yaml
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Through the proxy (no cache): print the full GATT table, read FFF1/FFF2/FFF3 by handle, and enable notify on
|
||||
FFF1, FFF2 and 2ACD (writing the CCCD the way Home Assistant does), counting what arrives per handle for 6 s.
|
||||
usage: gattcheck.py PROXY PEER_MAC env: API_KEY (the proxy's api encryption key)
|
||||
Exits 1 when a characteristic or CCCD is missing, a read or notification carries another characteristic's value,
|
||||
a notify fails, or a notifying characteristic sends nothing."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient
|
||||
|
||||
SFX = "-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def short(u: str) -> str:
|
||||
return u[4:8] if u.endswith(SFX) else u
|
||||
|
||||
|
||||
async def main(host: str, peer: str) -> None:
|
||||
addr = int(peer.replace(":", ""), 16)
|
||||
cli = APIClient(host, 6053, None, noise_psk=os.environ["API_KEY"])
|
||||
await cli.connect(login=True)
|
||||
seen = asyncio.Event()
|
||||
cli.subscribe_bluetooth_le_raw_advertisements(
|
||||
lambda m: (
|
||||
seen.set() if any(a.address == addr for a in m.advertisements) else None
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(seen.wait(), 30)
|
||||
state: asyncio.Queue = asyncio.Queue()
|
||||
cancel = await cli.bluetooth_device_connect(
|
||||
addr,
|
||||
lambda c, mtu, err: state.put_nowait((c, mtu, err)),
|
||||
timeout=20,
|
||||
address_type=0,
|
||||
feature_flags=0xFF,
|
||||
has_cache=False,
|
||||
)
|
||||
c, mtu, err = await asyncio.wait_for(state.get(), 25)
|
||||
if not c:
|
||||
sys.exit(f"connect failed err={err}")
|
||||
svcs = await cli.bluetooth_gatt_get_services(addr)
|
||||
chars = {}
|
||||
for s in svcs.services:
|
||||
print(f"SVC {short(s.uuid)} 0x{s.handle:04X}", flush=True)
|
||||
for ch in s.characteristics:
|
||||
chars[short(ch.uuid)] = ch
|
||||
print(
|
||||
f" CHR {short(ch.uuid)} 0x{ch.handle:04X} props=0x{ch.properties:02X}",
|
||||
flush=True,
|
||||
)
|
||||
for d in ch.descriptors:
|
||||
print(f" DSC {short(d.uuid)} 0x{d.handle:04X}", flush=True)
|
||||
wanted = ("fff1", "fff2", "fff3", "2acc", "2acd")
|
||||
problems = [f"{u} missing" for u in wanted if u not in chars]
|
||||
for u in ("fff1", "fff2", "fff3", "2acc"):
|
||||
if u in chars:
|
||||
v = bytes(await cli.bluetooth_gatt_read(addr, chars[u].handle))
|
||||
text = v.decode(errors="replace")
|
||||
print(f"READ {u} 0x{chars[u].handle:04X} -> {text}", flush=True)
|
||||
# every server value starts with its own UUID, so a mixed-up handle shows here
|
||||
if not text.startswith(f"{u}-"):
|
||||
problems.append(f"{u} read returned {text!r}")
|
||||
got: dict[int, list[str]] = {}
|
||||
|
||||
def on_notify(handle: int, data: bytearray) -> None:
|
||||
got.setdefault(handle, []).append(bytes(data).decode(errors="replace"))
|
||||
|
||||
for u in ("fff1", "fff2", "2acd"):
|
||||
if u in chars:
|
||||
try:
|
||||
await cli.bluetooth_gatt_start_notify(addr, chars[u].handle, on_notify)
|
||||
# like Home Assistant's client: write the first CCCD listed under the characteristic
|
||||
cccd = next(
|
||||
(d for d in chars[u].descriptors if short(d.uuid) == "2902"), None
|
||||
)
|
||||
if cccd is None:
|
||||
print(
|
||||
f"NOTIFY {u} 0x{chars[u].handle:04X} NO CCCD in its descriptor list",
|
||||
flush=True,
|
||||
)
|
||||
problems.append(f"{u} has no CCCD")
|
||||
continue
|
||||
await cli.bluetooth_gatt_write_descriptor(
|
||||
addr, cccd.handle, b"\x01\x00"
|
||||
)
|
||||
print(
|
||||
f"NOTIFY {u} 0x{chars[u].handle:04X} enabled, CCCD 0x{cccd.handle:04X} written",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"NOTIFY {u} 0x{chars[u].handle:04X} FAILED {type(exc).__name__}: {exc}",
|
||||
flush=True,
|
||||
)
|
||||
problems.append(f"{u} notify failed")
|
||||
await asyncio.sleep(6)
|
||||
if not got:
|
||||
print("GOT nothing", flush=True)
|
||||
for h, vals in sorted(got.items()):
|
||||
name = next((u for u, ch in chars.items() if ch.handle == h), "?")
|
||||
print(
|
||||
f"GOT 0x{h:04X} ({name}) x{len(vals)}: {vals[0]} .. {vals[-1]}", flush=True
|
||||
)
|
||||
problems.extend(
|
||||
f"0x{h:04X} ({name}) notified {v!r}"
|
||||
for v in vals
|
||||
if name == "?" or not v.startswith(f"{name}-")
|
||||
)
|
||||
# the server notifies FFF1 and 2ACD every second
|
||||
problems.extend(
|
||||
f"{u} sent nothing"
|
||||
for u in ("fff1", "2acd")
|
||||
if u in chars and chars[u].handle not in got
|
||||
)
|
||||
await cli.bluetooth_device_disconnect(addr)
|
||||
cancel()
|
||||
await cli.disconnect()
|
||||
if problems:
|
||||
sys.exit("FAIL: " + ", ".join(problems))
|
||||
print("PASS", flush=True)
|
||||
|
||||
|
||||
asyncio.run(main(sys.argv[1], sys.argv[2]))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Connect to PEER_MAC through the proxy like Home Assistant (v3 without cache) and print the services it returns.
|
||||
usage: gattget.py PROXY PEER_MAC ROUNDS env: API_KEY (the proxy's api encryption key)"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient
|
||||
|
||||
|
||||
async def main(host: str, peer: str, rounds: int) -> None:
|
||||
addr = int(peer.replace(":", ""), 16)
|
||||
cli = APIClient(host, 6053, None, noise_psk=os.environ["API_KEY"])
|
||||
await cli.connect(login=True)
|
||||
seen = asyncio.Event()
|
||||
|
||||
def adv(msg) -> None:
|
||||
if any(a.address == addr for a in msg.advertisements):
|
||||
seen.set()
|
||||
|
||||
cli.subscribe_bluetooth_le_raw_advertisements(adv)
|
||||
await asyncio.wait_for(seen.wait(), 30)
|
||||
failed = 0
|
||||
for r in range(1, rounds + 1):
|
||||
state: asyncio.Queue = asyncio.Queue()
|
||||
cancel = await cli.bluetooth_device_connect(
|
||||
addr,
|
||||
lambda c, mtu, err, q=state: q.put_nowait((c, mtu, err)),
|
||||
timeout=20,
|
||||
address_type=0,
|
||||
feature_flags=0xFF,
|
||||
has_cache=False,
|
||||
)
|
||||
c, mtu, err = await asyncio.wait_for(state.get(), 25)
|
||||
if not c:
|
||||
print(f"R{r} connect failed err={err}", flush=True)
|
||||
failed += 1
|
||||
cancel()
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
svcs = await cli.bluetooth_gatt_get_services(addr)
|
||||
sfx = "-0000-1000-8000-00805f9b34fb"
|
||||
out = [
|
||||
f"{s.uuid[4:8] if s.uuid.endswith(sfx) else s.uuid}({len(s.characteristics)})"
|
||||
for s in svcs.services
|
||||
]
|
||||
print(f"R{r} mtu={mtu} services: {' '.join(out)}", flush=True)
|
||||
await cli.bluetooth_device_disconnect(addr)
|
||||
cancel()
|
||||
await asyncio.sleep(5)
|
||||
await cli.disconnect()
|
||||
if failed:
|
||||
sys.exit(f"{failed} of {rounds} rounds did not connect")
|
||||
|
||||
|
||||
asyncio.run(main(sys.argv[1], sys.argv[2], int(sys.argv[3])))
|
||||
@@ -0,0 +1,37 @@
|
||||
# Proxy (classic ESP32): bluetooth_proxy without service cache, encrypted API, the reorder wrapper.
|
||||
# Substitutions: name, idf.
|
||||
# Keep the per-build file in this directory: attswap.hpp and components/ are found relative to it.
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes: [attswap.hpp]
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: ${idf}
|
||||
logger:
|
||||
level: DEBUG
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
# a plaintext proxy can be taken over by any Home Assistant on the network mid-test
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
ota:
|
||||
- platform: esphome
|
||||
external_components:
|
||||
- source: {type: local, path: components}
|
||||
components: [attswap]
|
||||
attswap:
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: true
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
cache_services: false
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("swap", "attswap swaps=%u", (unsigned) attswap_count);'
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# run.sh PROXY_YAML [ROUNDS]: build server.yaml and PROXY_YAML from TREE, flash both, capture the proxy and read the
|
||||
# server's services through it ROUNDS times (default 3). Prints the services per round and the Bluedroid discovery
|
||||
# errors. Exit 0: every round got all services; 1: a round got a partial table (the bug); 2: setup failed, a round
|
||||
# did not connect, or the swap never fired (the result would be meaningless). PROXY_YAML must sit in this
|
||||
# directory, next to attswap.hpp and components/.
|
||||
# env: TREE, SERVER_YAML (default server.yaml), API_KEY (proxy api key), SERVER_PORT, PROXY_PORT, SERVER_BLE_MAC
|
||||
# (the server's BLE address), SC (path to serial_capture.py), PY
|
||||
D=$(cd "$(dirname "$0")" && pwd); Y=$(cd "$(dirname "${1:?proxy yaml}")" && pwd)/$(basename "$1"); R=${2:-3}
|
||||
W=${TREE:?set TREE}; SP=${SERVER_PORT:?set SERVER_PORT}; PP=${PROXY_PORT:?set PROXY_PORT}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}
|
||||
API_KEY=${API_KEY:?set API_KEY}; SC=${SC:?set SC}; PY=${PY:-$W/venv/bin/python}; N=$(basename "$Y" .yaml); O=$D/out-$N; mkdir -p "$O"
|
||||
(cd "$W" && PYTHONPATH=$W $PY -m esphome -s name attsrv run --no-logs "$D/${SERVER_YAML:-server.yaml}" --device "$SP") > "$O/server.log" 2>&1 ||
|
||||
{ echo "server build or upload failed"; exit 2; }
|
||||
(cd "$W" && PYTHONPATH=$W $PY -m esphome run --no-logs "$Y" --device "$PP") > "$O/proxy-upload.log" 2>&1 ||
|
||||
{ echo "$N build or upload failed"; exit 2; }
|
||||
rm -f "$O/proxy.txt"
|
||||
$PY "$SC" "$PP" $((R * 30 + 160)) "$O/proxy.txt" --reset > "$O/capture.log" 2>&1 &
|
||||
CAP=$!
|
||||
IP=""
|
||||
for _ in $(seq 40); do
|
||||
IP=$(LC_ALL=C grep -a -o -E "IP Address: [0-9.]+" "$O/proxy.txt" | tail -1 | cut -d' ' -f3)
|
||||
[ -n "$IP" ] && break; sleep 2
|
||||
done
|
||||
[ -n "$IP" ] || { kill "$CAP"; echo "$N no IP"; exit 2; }
|
||||
sleep 5
|
||||
timeout $((R * 30 + 60)) $PY "$D/gattget.py" "$IP" "$MAC" "$R" | tee "$O/client.txt"
|
||||
[ "${PIPESTATUS[0]}" = 0 ] || { kill "$CAP"; echo "$N client failed"; exit 2; }
|
||||
wait "$CAP" || { echo "$N capture failed (see $O/capture.log)"; exit 2; }
|
||||
echo "== $N"
|
||||
LC_ALL=C grep -a -E "invalid handle|Service discovery complete|attswap swaps=[1-9]" "$O/proxy.txt" | sed 's/^\[[0-9:]*\]//' |
|
||||
sort | uniq -c
|
||||
grep -a -q -E "attswap swaps=[1-9]" "$O/proxy.txt" || { echo "$N INVALID: the reorder never fired"; exit 2; }
|
||||
# every round must list the reordered service (fff0) and the one after it (1826) with all their characteristics
|
||||
grep -a -q "^R[0-9]* mtu=" "$O/client.txt" || { echo "$N no client output"; exit 2; }
|
||||
grep -a "^R[0-9]* mtu=" "$O/client.txt" | grep -v -q "fff0(6) 1826(2)" && exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,45 @@
|
||||
# Peripheral (ESP32-S3) for gattcheck.py: FFF1 to FFF6 each hold their own value, FFF1, FFF2 and 2ACD can notify,
|
||||
# and FFF1 and 2ACD send a changing value every second. Substitution: name.
|
||||
esphome:
|
||||
name: ${name}
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
esp32_ble:
|
||||
name: ${name}
|
||||
esp32_ble_server:
|
||||
manufacturer: ESPHome
|
||||
model: attcheck
|
||||
services:
|
||||
- uuid: FFF0
|
||||
advertise: true
|
||||
characteristics:
|
||||
- {id: c1, uuid: FFF1, read: true, notify: true, value: "fff1-v"}
|
||||
- {uuid: FFF2, read: true, notify: true, value: "fff2-v"}
|
||||
- {uuid: FFF3, read: true, value: "fff3-v"}
|
||||
- {uuid: FFF4, read: true, value: "fff4-v"}
|
||||
- {uuid: FFF5, read: true, value: "fff5-v"}
|
||||
- {uuid: FFF6, read: true, value: "fff6-v"}
|
||||
- uuid: "1826"
|
||||
characteristics:
|
||||
- {uuid: 2ACC, read: true, value: "2acc-v"}
|
||||
- {id: cd, uuid: 2ACD, read: true, notify: true, value: "2acd-0"}
|
||||
on_connect:
|
||||
- logger.log: {format: "SRV connected", level: INFO}
|
||||
globals:
|
||||
- {id: n, type: int, initial_value: "0"}
|
||||
interval:
|
||||
- interval: 1s
|
||||
then:
|
||||
- lambda: id(n)++;
|
||||
- ble_server.characteristic.set_value:
|
||||
id: cd
|
||||
value: !lambda 'std::string v = "2acd-" + to_string(id(n)); return std::vector<uint8_t>(v.begin(), v.end());'
|
||||
- ble_server.characteristic.notify: {id: cd}
|
||||
- ble_server.characteristic.set_value:
|
||||
id: c1
|
||||
value: !lambda 'std::string v = "fff1-n" + to_string(id(n)); return std::vector<uint8_t>(v.begin(), v.end());'
|
||||
- ble_server.characteristic.notify: {id: c1}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Peripheral (ESP32-S3): a 6-characteristic service 0xFFF0, then an FTMS-like 0x1826 service. Substitution: name.
|
||||
esphome:
|
||||
name: ${name}
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
esp32_ble:
|
||||
name: ${name}
|
||||
esp32_ble_server:
|
||||
manufacturer: ESPHome
|
||||
model: g22518
|
||||
services:
|
||||
- uuid: FFF0
|
||||
advertise: true
|
||||
characteristics:
|
||||
- {uuid: FFF1, read: true, value: [1]}
|
||||
- {uuid: FFF2, read: true, value: [2]}
|
||||
- {uuid: FFF3, read: true, value: [3]}
|
||||
- {uuid: FFF4, read: true, value: [4]}
|
||||
- {uuid: FFF5, read: true, value: [5]}
|
||||
- {uuid: FFF6, read: true, value: [6]}
|
||||
- uuid: "1826"
|
||||
characteristics:
|
||||
- {uuid: 2ACC, read: true, value: [0, 0, 0, 0]}
|
||||
- {uuid: 2ACD, read: true, notify: true, value: [0, 0]}
|
||||
on_connect:
|
||||
- logger.log: {format: "SRV connected", level: INFO}
|
||||
on_disconnect:
|
||||
- logger.log: {format: "SRV disconnected", level: INFO}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Joins the DUT's open AP, fetches the portal page every 5 s and logs JOIN and STAT lines. Substitutions: name, board,
|
||||
# logger_uart, ap_ssid (same as dut.yaml).
|
||||
substitutions:
|
||||
board: esp32-c3-devkitm-1
|
||||
logger_uart: USB_SERIAL_JTAG
|
||||
ap_ssid: bleaptest
|
||||
esphome:
|
||||
name: ${name}
|
||||
esp32:
|
||||
board: ${board}
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
hardware_uart: ${logger_uart}
|
||||
wifi:
|
||||
ssid: ${ap_ssid}
|
||||
reboot_timeout: 0s
|
||||
on_connect:
|
||||
- lambda: |-
|
||||
id(joins)++;
|
||||
ESP_LOGI("cli", "JOIN %d at %u ms", id(joins), (unsigned) millis());
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("cli", "LEFT at %u ms", (unsigned) millis());
|
||||
http_request:
|
||||
timeout: 5s
|
||||
globals:
|
||||
- {id: joins, type: int, initial_value: "0"}
|
||||
- {id: pages_ok, type: int, initial_value: "0"}
|
||||
- {id: pages_fail, type: int, initial_value: "0"}
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- if:
|
||||
condition: wifi.connected
|
||||
then:
|
||||
- http_request.get:
|
||||
url: http://192.168.4.1/
|
||||
on_response:
|
||||
then:
|
||||
- lambda: if (response->status_code == 200) id(pages_ok)++; else id(pages_fail)++;
|
||||
on_error:
|
||||
then:
|
||||
- lambda: id(pages_fail)++;
|
||||
- interval: 15s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("cli", "STAT up=%u joins=%d connected=%d pages_ok=%d pages_fail=%d", (unsigned) (millis() / 1000),
|
||||
id(joins), (int) wifi::global_wifi_component->is_connected(), id(pages_ok), id(pages_fail));
|
||||
@@ -0,0 +1,23 @@
|
||||
# Soft AP only (no station network) with captive_portal and bluetooth_proxy, as in #22534. Substitutions: name,
|
||||
# board, logger_uart (USB_SERIAL_JTAG on native USB chips, UART0 on a classic devkit), ap_ssid.
|
||||
substitutions:
|
||||
board: esp32dev
|
||||
logger_uart: UART0
|
||||
ap_ssid: bleaptest
|
||||
esphome:
|
||||
name: ${name}
|
||||
esp32:
|
||||
board: ${board}
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
hardware_uart: ${logger_uart}
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
ota:
|
||||
- platform: esphome
|
||||
wifi:
|
||||
ap:
|
||||
ssid: ${ap_ssid}
|
||||
captive_portal:
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# run.sh DUT_YAML SECONDS: erase and flash DUT_YAML from TREE, let it boot, then reset the client (already flashed with
|
||||
# client.yaml) and capture both for SECONDS. Prints the generated scan window, the client's first JOIN and last STAT
|
||||
# lines, its disconnect reasons and the DUT's resets. One AP with this SSID at a time: ports in PARK are held in their
|
||||
# ROM bootloader first. The yaml file name must equal its `name:` (the build directory is found by it).
|
||||
# Exit 0: pass; 1: the run finished and failed the checks; 2: setup or capture failed.
|
||||
# env: TREE, DUT_PORT, CLIENT_PORT, PARK (space separated ports, optional), SC (path to serial_capture.py), PY
|
||||
Y=$(cd "$(dirname "${1:?dut yaml}")" && pwd)/$(basename "$1"); T=${2:?seconds}; N=$(basename "$Y" .yaml)
|
||||
W=${TREE:?set TREE}; DUT=${DUT_PORT:?set DUT_PORT}; CLI=${CLIENT_PORT:?set CLIENT_PORT}; SC=${SC:?set SC}
|
||||
PY=${PY:-$W/venv/bin/python}; O=$(dirname "$Y")/out-$N; mkdir -p "$O"
|
||||
for p in $PARK; do $PY -m esptool --port "$p" --after no-reset chip-id > /dev/null 2>&1 || { echo "park $p failed"; exit 2; }; done
|
||||
(cd "$W" && PYTHONPATH=$W $PY -m esphome compile "$Y") > "$O/compile.log" 2>&1 || { echo "$N compile failed"; exit 2; }
|
||||
$PY -m esptool --port "$DUT" erase_flash > "$O/erase.log" 2>&1 || { echo "$N erase failed"; exit 2; }
|
||||
(cd "$W" && PYTHONPATH=$W $PY -m esphome upload "$Y" --device "$DUT") > "$O/upload.log" 2>&1 || { echo "$N upload failed"; exit 2; }
|
||||
$PY "$SC" "$DUT" $((T + 40)) "$O/dut.txt" --reset > "$O/dut-capture.log" 2>&1 &
|
||||
CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
sleep 30
|
||||
$PY "$SC" "$CLI" "$T" "$O/cli.txt" --reset > "$O/cli-capture.log" 2>&1 || { echo "$N client capture failed"; exit 2; }
|
||||
wait "$CAP" || { echo "$N DUT capture failed"; exit 2; }
|
||||
B=$(dirname "$Y")/.esphome/build/$N/src/main.cpp
|
||||
[ -f "$B" ] || { echo "$N: $B not found (file name differs from name:?)"; exit 2; }
|
||||
echo "== $N window=$(grep -o 'set_scan_window([0-9]*)' "$B" || echo none)"
|
||||
J=$(grep -a -m1 "JOIN 1" "$O/cli.txt" | cut -c1-100); echo "${J:-$N NO JOIN}"
|
||||
STAT=$(grep -a "STAT" "$O/cli.txt" | tail -1 | cut -c1-140); echo "$STAT"
|
||||
echo "client disconnect reasons: $(grep -a -o "reason='[^']*'" "$O/cli.txt" | sort | uniq -c | tr '\n' ' ')"
|
||||
echo "dut resets: $(grep -a -c 'rst:' "$O/dut.txt")"
|
||||
# pass: the client joined and fetched pages, and the DUT reset exactly once (the capture's own reset)
|
||||
[ -n "$J" ] && [ -n "$STAT" ] && [[ "$STAT" != *"pages_ok=0 "* ]] && [ "$(grep -a -c 'rst:' "$O/dut.txt")" = 1 ]
|
||||
@@ -0,0 +1,170 @@
|
||||
# CC1101 sleep and wake checks T0 to T6 on an ESP32-S3 (SPI on GPIO12/11/13, CS GPIO10, GDO0 GPIO9), run 4 s after
|
||||
# boot; the result is the "DONE fails=N" line. Substitution: name.
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes: [cctest.hpp]
|
||||
on_boot:
|
||||
- priority: -100
|
||||
then:
|
||||
- delay: 4s
|
||||
- script.execute: run_tests
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
spi:
|
||||
clk_pin: GPIO12
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO13
|
||||
cc1101:
|
||||
id: transceiver
|
||||
cs_pin: GPIO10
|
||||
gdo0_pin: GPIO9
|
||||
frequency: 433.92MHz
|
||||
modulation_type: GFSK
|
||||
symbol_rate: 4800
|
||||
packet_mode: true
|
||||
packet_length: 8
|
||||
crc_enable: true
|
||||
sync_mode: "16/16"
|
||||
output_power: 0
|
||||
on_packet:
|
||||
then:
|
||||
- lambda: ESP_LOGI("cct", "RX packet %s rssi %.1f", format_hex(x).c_str(), rssi);
|
||||
script:
|
||||
- id: run_tests
|
||||
then:
|
||||
- lambda: |-
|
||||
using namespace cct;
|
||||
auto *c = id(transceiver);
|
||||
Snap base, s;
|
||||
int fails = 0;
|
||||
snap(c, base);
|
||||
ESP_LOGI("cct", "T0 baseline MARCSTATE=%02X FREQ=%02X%02X%02X TEST2=%02X TEST1=%02X TEST0=%02X so_high_awake=%d",
|
||||
base.marc, base.reg[0x0D], base.reg[0x0E], base.reg[0x0F], base.reg[0x2C], base.reg[0x2D],
|
||||
base.reg[0x2E], cs_low_so_high(c));
|
||||
hexpa("T0 baseline", base.pa);
|
||||
// the sleep checks below need a probe that sees an awake chip as awake
|
||||
if (cs_low_so_high(c)) {
|
||||
ESP_LOGW("cct", "T0 FAIL: SO reads high on an awake chip, the sleep checks cannot be trusted");
|
||||
fails++;
|
||||
}
|
||||
|
||||
// T1: mark PATABLE 1-7, sleep, wake by hand: lost entries prove the chip slept
|
||||
const uint8_t marks[8] = {base.pa[0], 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77};
|
||||
write_pa(c, marks);
|
||||
snap(c, s); hexpa("T1 marked", s.pa);
|
||||
c->power_down();
|
||||
bool so = cs_low_so_high(c);
|
||||
snap(c, s);
|
||||
hexpa("T1 after raw wake", s.pa);
|
||||
bool lost = s.pa[1] != 0x11;
|
||||
ESP_LOGI("cct", "T1 sleep proof: so_high_on_cs=%d pa_lost=%d MARCSTATE=%02X TEST0=%02X -> %s", so, lost, s.marc,
|
||||
s.reg[0x2E], (so && lost) ? "PASS" : "FAIL");
|
||||
if (!(so && lost)) fails++;
|
||||
// the component still thinks it sleeps
|
||||
c->wake_up();
|
||||
snap(c, s);
|
||||
int d = diff(base, s, "T1 restore");
|
||||
ESP_LOGI("cct", "T1 restore after wake_up: %d differences, MARCSTATE=%02X -> %s", d, s.marc, d == 0 ? "PASS" : "FAIL");
|
||||
if (d) fails++;
|
||||
c->begin_rx();
|
||||
|
||||
// T2: change settings while asleep, compare with the same change made awake
|
||||
Snap awake;
|
||||
c->set_frequency(433.42e6); c->set_output_power(5); c->set_symbol_rate(9600);
|
||||
snap(c, awake);
|
||||
c->set_frequency(433.92e6); c->set_output_power(0); c->set_symbol_rate(4800);
|
||||
c->power_down();
|
||||
c->set_frequency(433.42e6); c->set_output_power(5); c->set_symbol_rate(9600);
|
||||
bool so2 = cs_low_so_high(c); // wakes the chip by hand; component still asleep
|
||||
c->wake_up();
|
||||
c->begin_rx();
|
||||
snap(c, s);
|
||||
awake.marc = s.marc;
|
||||
d = diff(awake, s, "T2 asleep vs awake");
|
||||
ESP_LOGI("cct", "T2 settings changed asleep: so_high=%d %d differences FREQ=%02X%02X%02X MARCSTATE=%02X -> %s", so2, d,
|
||||
s.reg[0x0D], s.reg[0x0E], s.reg[0x0F], s.marc, (so2 && d == 0) ? "PASS" : "FAIL");
|
||||
if (!(so2 && d == 0)) fails++;
|
||||
c->set_frequency(433.92e6); c->set_output_power(0); c->set_symbol_rate(4800);
|
||||
c->begin_rx();
|
||||
snap(c, base);
|
||||
|
||||
// T3: send while asleep wakes the radio by itself
|
||||
c->power_down();
|
||||
uint8_t pkt[8] = {1, 2, 3, 4, 5, 6, 7, 8};
|
||||
int err = (int) c->transmit_packet(pkt, sizeof(pkt));
|
||||
snap(c, s);
|
||||
d = diff(base, s, "T3 after send");
|
||||
ESP_LOGI("cct", "T3 send while asleep: err=%d MARCSTATE=%02X TXBYTES=%02X %d differences -> %s", err, s.marc,
|
||||
status(c, 0x3A), d, (err == 0 && d == 0) ? "PASS" : "FAIL");
|
||||
if (!(err == 0 && d == 0)) fails++;
|
||||
|
||||
// T4: begin_rx while asleep
|
||||
c->power_down();
|
||||
c->begin_rx();
|
||||
snap(c, s);
|
||||
d = diff(base, s, "T4 begin_rx");
|
||||
ESP_LOGI("cct", "T4 begin_rx while asleep: MARCSTATE=%02X %d differences -> %s", s.marc, d,
|
||||
(s.marc == 0x0D && d == 0) ? "PASS" : "FAIL");
|
||||
if (!(s.marc == 0x0D && d == 0)) fails++;
|
||||
|
||||
// T5: 200 cycles, each one proven asleep and restored
|
||||
int bad = 0, notslept = 0;
|
||||
uint32_t t0 = micros();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
write_pa(c, marks);
|
||||
c->power_down();
|
||||
if (!cs_low_so_high(c)) notslept++;
|
||||
c->wake_up();
|
||||
c->begin_rx();
|
||||
snap(c, s);
|
||||
if (diff(base, s, "T5") != 0) bad++;
|
||||
if ((i & 31) == 0) App.feed_wdt();
|
||||
}
|
||||
ESP_LOGI("cct", "T5 200 cycles: not_slept=%d restore_fail=%d in %u ms heap=%u -> %s", notslept, bad,
|
||||
(unsigned) ((micros() - t0) / 1000), (unsigned) esp_get_free_heap_size(),
|
||||
(notslept == 0 && bad == 0) ? "PASS" : "FAIL");
|
||||
if (notslept || bad) fails++;
|
||||
// T6: each setter on its own while asleep: does it wake the chip, and in which state does it leave it
|
||||
struct Case { const char *name; int kind; };
|
||||
const Case cases[] = {{"set_frequency", 0}, {"set_channel", 1}, {"set_modulation_type", 2}, {"set_symbol_rate", 3},
|
||||
{"set_output_power", 4}, {"set_filter_bandwidth", 5}, {"set_fsk_deviation", 6}};
|
||||
for (auto &k : cases) {
|
||||
c->begin_rx();
|
||||
c->power_down();
|
||||
switch (k.kind) {
|
||||
case 0: c->set_frequency(433.92e6); break;
|
||||
case 1: c->set_channel(0); break;
|
||||
case 2: c->set_modulation_type(cc1101::Modulation::MODULATION_GFSK); break;
|
||||
case 3: c->set_symbol_rate(4800); break;
|
||||
case 4: c->set_output_power(0); break;
|
||||
case 5: c->set_filter_bandwidth(203e3); break;
|
||||
case 6: c->set_fsk_deviation(1.5e3); break;
|
||||
}
|
||||
bool asleep = cs_low_so_high(c);
|
||||
uint8_t marc = status(c, 0x35) & 0x1F;
|
||||
ESP_LOGI("cct", "T6 %-20s while asleep: chip %s, MARCSTATE=%02X", k.name, asleep ? "still asleep" : "AWAKE", marc);
|
||||
if (!asleep) fails++;
|
||||
c->wake_up();
|
||||
}
|
||||
c->begin_rx();
|
||||
uint8_t agc_rx = rd(c, 0x2B);
|
||||
c->set_idle();
|
||||
uint8_t agc_idle = rd(c, 0x2B);
|
||||
ESP_LOGI("cct", "AGCTEST in RX=%02X in IDLE=%02X", agc_rx, agc_idle);
|
||||
c->begin_rx();
|
||||
ESP_LOGI("cct", "DONE fails=%d", fails);
|
||||
button:
|
||||
- platform: template
|
||||
id: yaml_actions
|
||||
name: Yaml actions
|
||||
on_press:
|
||||
- cc1101.power_down: transceiver
|
||||
- cc1101.wake_up: transceiver
|
||||
- cc1101.power_down: transceiver
|
||||
- cc1101.send_packet: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
|
||||
- lambda: ESP_LOGI("cct", "YAML actions done MARCSTATE=%02X", cct::status(id(transceiver), 0x35) & 0x1F);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Register and PATABLE snapshots plus the CHIP_RDYn sleep check for the cc1101 sleep tests in cc1101-sleep.yaml.
|
||||
#pragma once
|
||||
#include "esphome/components/cc1101/cc1101.h"
|
||||
#include "driver/gpio.h"
|
||||
#include <cstring>
|
||||
|
||||
namespace cct {
|
||||
using esphome::cc1101::CC1101Component;
|
||||
static constexpr gpio_num_t MISO = GPIO_NUM_13; // the SPI MISO pin in cc1101-sleep.yaml
|
||||
static constexpr uint8_t NREG = 0x2F; // IOCFG2..TEST0
|
||||
|
||||
struct Snap {
|
||||
uint8_t reg[NREG];
|
||||
uint8_t pa[8];
|
||||
uint8_t marc;
|
||||
};
|
||||
|
||||
// Chip ready flag: with CSn low, SO stays high until the crystal runs (SLEEP), low at once otherwise
|
||||
inline bool cs_low_so_high(CC1101Component *c) {
|
||||
c->enable();
|
||||
bool high = gpio_get_level(MISO);
|
||||
delayMicroseconds(300);
|
||||
c->disable();
|
||||
return high;
|
||||
}
|
||||
inline uint8_t rd(CC1101Component *c, uint8_t a) {
|
||||
c->enable();
|
||||
c->write_byte(a | 0x80);
|
||||
uint8_t v = c->read_byte();
|
||||
c->disable();
|
||||
return v;
|
||||
}
|
||||
inline uint8_t status(CC1101Component *c, uint8_t a) { return rd(c, a | 0x40); }
|
||||
inline void snap(CC1101Component *c, Snap &s) {
|
||||
for (uint8_t i = 0; i < NREG; i++)
|
||||
s.reg[i] = rd(c, i);
|
||||
c->enable();
|
||||
c->write_byte(0x3E | 0xC0);
|
||||
for (auto &b : s.pa)
|
||||
b = c->read_byte();
|
||||
c->disable();
|
||||
s.marc = status(c, 0x35) & 0x1F;
|
||||
}
|
||||
inline void write_pa(CC1101Component *c, const uint8_t *pa) {
|
||||
c->enable();
|
||||
c->write_byte(0x3E | 0x40);
|
||||
for (int i = 0; i < 8; i++)
|
||||
c->write_byte(pa[i]);
|
||||
c->disable();
|
||||
}
|
||||
inline int diff(const Snap &a, const Snap &b, const char *what) {
|
||||
int n = 0;
|
||||
for (uint8_t i = 0; i < NREG; i++)
|
||||
// FSCAL3..FSCAL1 hold calibration results, FSTEST and AGCTEST are chip owned test registers
|
||||
if (i != 0x23 && i != 0x24 && i != 0x25 && i != 0x29 && i != 0x2B && a.reg[i] != b.reg[i]) {
|
||||
ESP_LOGW("cct", "%s reg 0x%02X: %02X vs %02X", what, i, a.reg[i], b.reg[i]);
|
||||
n++;
|
||||
}
|
||||
for (int i = 0; i < 8; i++)
|
||||
if (a.pa[i] != b.pa[i]) {
|
||||
ESP_LOGW("cct", "%s pa[%d]: %02X vs %02X", what, i, a.pa[i], b.pa[i]);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
inline void hexpa(const char *tag, const uint8_t *pa) {
|
||||
ESP_LOGI("cct", "%s PA %02X %02X %02X %02X %02X %02X %02X %02X", tag, pa[0], pa[1], pa[2], pa[3], pa[4], pa[5], pa[6],
|
||||
pa[7]);
|
||||
}
|
||||
} // namespace cct
|
||||
@@ -0,0 +1,20 @@
|
||||
# Shared ESP8266 base for the lock load DUTs: reset reason at boot, no Wi-Fi sleep, web_server for the HTTP load.
|
||||
esphome:
|
||||
name: ${name}
|
||||
on_boot:
|
||||
- priority: 800
|
||||
then:
|
||||
- lambda: ESP_LOGI("lock", "BOOT reason=%s info=%s", ESP.getResetReason().c_str(), ESP.getResetInfo().c_str());
|
||||
esp8266:
|
||||
board: ${board}
|
||||
logger:
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
ota:
|
||||
- platform: esphome
|
||||
web_server:
|
||||
port: 80
|
||||
@@ -0,0 +1,39 @@
|
||||
# remote_transmitter and remote_receiver on the same pin (GPIO12), no jumper: one NEC frame every 250 ms, each one
|
||||
# checked by the receiver's edge interrupt. Substitutions: name, board.
|
||||
substitutions:
|
||||
board: d1_mini
|
||||
packages:
|
||||
base: !include base.yaml
|
||||
globals:
|
||||
- {id: sent, type: uint32_t, initial_value: "0"}
|
||||
- {id: rx_ok, type: uint32_t, initial_value: "0"}
|
||||
- {id: rx_bad, type: uint32_t, initial_value: "0"}
|
||||
remote_transmitter:
|
||||
id: irtx
|
||||
pin:
|
||||
number: GPIO12
|
||||
allow_other_uses: true
|
||||
carrier_duty_percent: 100%
|
||||
remote_receiver:
|
||||
pin:
|
||||
number: GPIO12
|
||||
allow_other_uses: true
|
||||
on_nec:
|
||||
then:
|
||||
- lambda: |-
|
||||
if (x.address == 0x1234 && x.command == (uint16_t) (0x7800 | (id(sent) & 0xFF))) id(rx_ok)++; else id(rx_bad)++;
|
||||
interval:
|
||||
- interval: 250ms
|
||||
then:
|
||||
# The receiver's setup leaves the shared pin as an input; drive it again before each frame
|
||||
- lambda: |-
|
||||
pinMode(12, OUTPUT);
|
||||
id(sent)++;
|
||||
- remote_transmitter.transmit_nec:
|
||||
address: 0x1234
|
||||
command: !lambda return (uint16_t) (0x7800 | (id(sent) & 0xFF));
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("lock", "ALIVE up=%u sent=%u rx_ok=%u rx_bad=%u heap=%u", (unsigned) (millis() / 1000),
|
||||
(unsigned) id(sent), (unsigned) id(rx_ok), (unsigned) id(rx_bad), (unsigned) ESP.getFreeHeap());
|
||||
@@ -0,0 +1,43 @@
|
||||
# DS18B20 on GPIO4 with a bus search every ${search_interval}. Set busy_control to "true" to replace the search with
|
||||
# the same busy time and no 1-Wire traffic (the control run). Substitutions: name, board, search_interval, busy_control.
|
||||
substitutions:
|
||||
board: d1_mini
|
||||
search_interval: 100ms
|
||||
busy_control: "false"
|
||||
packages:
|
||||
base: !include base.yaml
|
||||
one_wire:
|
||||
- platform: gpio
|
||||
pin: GPIO4
|
||||
id: owbus
|
||||
globals:
|
||||
- {id: searches, type: uint32_t, initial_value: "0"}
|
||||
- {id: search_bad, type: uint32_t, initial_value: "0"}
|
||||
- {id: reads_ok, type: uint32_t, initial_value: "0"}
|
||||
- {id: reads_nan, type: uint32_t, initial_value: "0"}
|
||||
sensor:
|
||||
- platform: dallas_temp
|
||||
one_wire_id: owbus
|
||||
id: t1
|
||||
name: Temp
|
||||
update_interval: 2s
|
||||
on_raw_value:
|
||||
- lambda: if (std::isnan(x)) id(reads_nan)++; else id(reads_ok)++;
|
||||
interval:
|
||||
- interval: ${search_interval}
|
||||
then:
|
||||
- lambda: |-
|
||||
if (${busy_control}) {
|
||||
delayMicroseconds(13000); // about one search with one sensor answering
|
||||
return;
|
||||
}
|
||||
id(owbus)->search();
|
||||
id(searches)++;
|
||||
if (id(owbus)->get_devices().size() != 1)
|
||||
id(search_bad)++;
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("lock", "ALIVE up=%u ok=%u nan=%u searches=%u search_bad=%u heap=%u t=%.2f",
|
||||
(unsigned) (millis() / 1000), (unsigned) id(reads_ok), (unsigned) id(reads_nan),
|
||||
(unsigned) id(searches), (unsigned) id(search_bad), (unsigned) ESP.getFreeHeap(), id(t1).state);
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# run.sh NAME SECONDS PPS: build NAME.yaml (next to this script, or a path) from TREE, flash it over serial, capture
|
||||
# the log for SECONDS plus 160 (boot, up to 120 s for the IP, the load) and run wifi_load.py at PPS packets per second
|
||||
# (0: no load). Prints the boot count, every reset reason and the last ALIVE line. Exit 0: a clean run (exactly one
|
||||
# BOOT line); 1: the run finished and saw resets; 2: setup failed or the run is invalid (no load, capture cut short,
|
||||
# or the sensor or IR loopback never worked).
|
||||
# env: TREE (checkout under test), PORT, SC (path to serial_capture.py), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); N=${1:?name}; T=${2:?seconds}; P=${3:?pps}
|
||||
W=${TREE:?set TREE}; PORT=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$W/venv/bin/python}
|
||||
Y=$N; [ -f "$Y" ] || Y=$D/$N.yaml; Y=$(cd "$(dirname "$Y")" && pwd)/$(basename "$Y"); N=$(basename "$Y" .yaml)
|
||||
O=$D/out-$N; mkdir -p "$O"
|
||||
pkill -f "[s]erial_capture.py $PORT "
|
||||
rm -f "$O/serial.txt" "$O/load.txt"
|
||||
(cd "$W" && PYTHONPATH=$W $PY -m esphome compile "$Y") > "$O/compile.log" 2>&1 || { echo "$N compile failed"; exit 2; }
|
||||
(cd "$W" && PYTHONPATH=$W $PY -m esphome upload "$Y" --device "$PORT") > "$O/upload.log" 2>&1 || { echo "$N upload failed"; exit 2; }
|
||||
$PY "$SC" "$PORT" $((T + 160)) "$O/serial.txt" --reset > "$O/capture.log" 2>&1 &
|
||||
CAP=$!
|
||||
for _ in $(seq 60); do
|
||||
IP=$(LC_ALL=C grep -a -o -E "IP Address: [0-9.]+" "$O/serial.txt" | tail -1 | cut -d' ' -f3)
|
||||
[ -n "$IP" ] && break; sleep 2
|
||||
done
|
||||
if [ "$P" -gt 0 ]; then
|
||||
[ -n "$IP" ] || { kill "$CAP"; echo "$N no IP (check the secrets file)"; exit 2; }
|
||||
python3 "$D/wifi_load.py" "$IP" "$T" "$P" > "$O/load.txt" 2>&1 || { echo "$N INVALID: load failed (see $O/load.txt)"; BAD=1; }
|
||||
else
|
||||
sleep "$T"
|
||||
fi
|
||||
wait "$CAP" || { echo "$N INVALID: capture failed (see $O/capture.log)"; BAD=1; }
|
||||
BOOTS=$(LC_ALL=C grep -a -c 'BOOT reason' "$O/serial.txt")
|
||||
echo "== $N pps=$P boots=$BOOTS"
|
||||
LC_ALL=C grep -a "BOOT reason" "$O/serial.txt" | cut -c1-150
|
||||
LC_ALL=C grep -a "ALIVE" "$O/serial.txt" | tail -1 | cut -c1-200
|
||||
tail -1 "$O/load.txt" 2>/dev/null
|
||||
ALIVE=$(LC_ALL=C grep -a "ALIVE" "$O/serial.txt" | tail -1)
|
||||
# the workload must have run: a sensor answering searches, or decoded IR frames
|
||||
case "$ALIVE" in
|
||||
*" ok=0 "*|*" rx_ok=0 "*|"") echo "$N INVALID: no ALIVE line or the workload did not run"; BAD=1 ;;
|
||||
esac
|
||||
# one-wire: the searches must find the sensor (busy_control runs no searches)
|
||||
S=$(echo "$ALIVE" | sed -n 's/.* searches=\([0-9]*\) search_bad=\([0-9]*\).*/\1 \2/p')
|
||||
if [ -n "$S" ]; then
|
||||
set -- $S
|
||||
[ "$1" -gt 0 ] && [ "$2" -ge "$1" ] && { echo "$N INVALID: no search found the sensor"; BAD=1; }
|
||||
fi
|
||||
# resets are a failure even when they also broke the load or the workload
|
||||
[ "$BOOTS" -gt 1 ] && exit 1
|
||||
[ -z "$BAD" ] && [ "$BOOTS" = 1 ] || exit 2
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Wi-Fi load on an ESP8266 under test: a fixed rate UDP flood to port 9 plus back to back web_server GETs.
|
||||
|
||||
usage: wifi_load.py HOST SECONDS PPS
|
||||
Prints the counts every minute and a final "done" line.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
host, secs, pps = sys.argv[1], float(sys.argv[2]), int(sys.argv[3])
|
||||
end = time.monotonic() + secs
|
||||
stats = {"udp": 0, "udp_err": 0, "http_ok": 0, "http_err": 0, "http_gap_s": 0}
|
||||
|
||||
|
||||
last_ok = [time.monotonic()]
|
||||
|
||||
|
||||
def http_load() -> None:
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{host}/", timeout=5) as r:
|
||||
r.read()
|
||||
stats["http_ok"] += 1
|
||||
now = time.monotonic()
|
||||
stats["http_gap_s"] = max(stats["http_gap_s"], round(now - last_ok[0]))
|
||||
last_ok[0] = now
|
||||
except Exception: # noqa: BLE001 any failure counts; the thread must not die
|
||||
stats["http_err"] += 1
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
threading.Thread(target=http_load, daemon=True).start()
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
payload = b"x" * 512
|
||||
nxt = last = time.monotonic()
|
||||
while (now := time.monotonic()) < end:
|
||||
try:
|
||||
s.sendto(payload, (host, 9))
|
||||
stats["udp"] += 1
|
||||
except OSError: # mostly ENOBUFS when the host outruns the network
|
||||
stats["udp_err"] += 1
|
||||
time.sleep(0.01)
|
||||
nxt += 1 / pps
|
||||
if (d := nxt - time.monotonic()) > 0:
|
||||
time.sleep(d)
|
||||
if now - last > 60:
|
||||
last = now
|
||||
print(time.strftime("%H:%M:%S"), stats, flush=True)
|
||||
stats["http_gap_s"] = max(stats["http_gap_s"], round(time.monotonic() - last_ok[0]))
|
||||
print("done", stats, flush=True)
|
||||
if (
|
||||
stats["http_ok"] == 0
|
||||
or stats["http_gap_s"] > 60 # the device stopped answering for over a minute
|
||||
or stats["http_err"] > stats["http_ok"]
|
||||
or stats["udp"] < secs * pps / 2
|
||||
):
|
||||
sys.exit("the load did not reach the device; the run is invalid")
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
#ifdef __XTENSA__
|
||||
#include "esp_cpu_utils.h"
|
||||
#include "esp_debug_helpers.h"
|
||||
#endif
|
||||
#include "esp_timer.h"
|
||||
// Counts every global operator new and, once armed, keeps the first ALLOC_CAP of them with five caller PCs
|
||||
// (callers on Xtensa only; RISC-V entries keep size and time). Aligned operator new is not counted.
|
||||
// See reference/recipes/heap_alloc.md.
|
||||
struct AllocEntry {
|
||||
uint32_t ms;
|
||||
uint32_t size; // ALLOC_MARKER for a marker: pc[0] and pc[1] then hold its two values
|
||||
uint32_t pc[5];
|
||||
};
|
||||
static constexpr uint32_t ALLOC_CAP = 1024;
|
||||
static constexpr uint32_t ALLOC_MARKER = UINT32_MAX;
|
||||
AllocEntry g_allocs[ALLOC_CAP];
|
||||
std::atomic<uint32_t> g_alloc_n{0};
|
||||
std::atomic<uint32_t> g_new_count{0};
|
||||
// seq_cst with g_alloc_busy: the dump stores false then loads busy, a recorder adds busy then loads this
|
||||
std::atomic<bool> g_alloc_armed{false};
|
||||
uint32_t g_alloc_armed_ms = 0; // when recording started, 0 if it never did
|
||||
// Recorders between claiming a slot and filling it; the dump waits for 0 after disarming.
|
||||
std::atomic<uint32_t> g_alloc_busy{0};
|
||||
|
||||
// Claims the next slot, or returns nullptr and disarms once the buffer is full.
|
||||
static inline AllocEntry *alloc_claim(uint32_t size) {
|
||||
uint32_t i = g_alloc_n.fetch_add(1);
|
||||
if (i >= ALLOC_CAP) {
|
||||
g_alloc_armed = false;
|
||||
return nullptr;
|
||||
}
|
||||
AllocEntry &e = g_allocs[i];
|
||||
e.ms = (uint32_t) (esp_timer_get_time() / 1000);
|
||||
e.size = size;
|
||||
return &e;
|
||||
}
|
||||
|
||||
void *operator new(size_t size) {
|
||||
g_new_count.fetch_add(1, std::memory_order_relaxed);
|
||||
g_alloc_busy.fetch_add(1); // before the armed check, so a dump that disarmed first sees it
|
||||
AllocEntry *e = g_alloc_armed ? alloc_claim(size) : nullptr;
|
||||
if (e != nullptr) {
|
||||
e->pc[0] = e->pc[1] = e->pc[2] = e->pc[3] = e->pc[4] = 0;
|
||||
#ifdef __XTENSA__
|
||||
esp_backtrace_frame_t f;
|
||||
esp_backtrace_get_start(&f.pc, &f.sp, &f.next_pc);
|
||||
// The start frame is this function; record the five callers above it.
|
||||
// Stop at the first frame that does not unwind; the slots after it stay 0.
|
||||
for (int k = 0; k < 5 && f.next_pc != 0 && esp_backtrace_get_next_frame(&f); k++)
|
||||
e->pc[k] = esp_cpu_process_stack_pc(f.pc);
|
||||
#endif
|
||||
}
|
||||
g_alloc_busy.fetch_sub(1);
|
||||
void *p = malloc(size ? size : 1);
|
||||
if (p == nullptr)
|
||||
abort();
|
||||
return p;
|
||||
}
|
||||
void *operator new[](size_t size) { return operator new(size); }
|
||||
void operator delete(void *p) noexcept { free(p); }
|
||||
void operator delete[](void *p) noexcept { free(p); }
|
||||
void operator delete(void *p, size_t) noexcept { free(p); }
|
||||
void operator delete[](void *p, size_t) noexcept { free(p); }
|
||||
|
||||
// Splits the dump into phases, for example on each change of an entity's state.
|
||||
static inline void alloc_marker(uint32_t a, uint32_t b) {
|
||||
g_alloc_busy.fetch_add(1);
|
||||
if (AllocEntry *e = g_alloc_armed ? alloc_claim(ALLOC_MARKER) : nullptr) {
|
||||
e->pc[0] = a;
|
||||
e->pc[1] = b;
|
||||
e->pc[2] = e->pc[3] = e->pc[4] = 0;
|
||||
}
|
||||
g_alloc_busy.fetch_sub(1);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Package for allocring.hpp: arms it ${alloc_arm_s} s after boot, reports that on a binary sensor and adds the
|
||||
# dump button. See heap_alloc.md.
|
||||
esphome:
|
||||
includes:
|
||||
- allocring.hpp
|
||||
on_boot:
|
||||
then:
|
||||
- delay: ${alloc_arm_s}s
|
||||
- lambda: |-
|
||||
g_alloc_armed_ms = millis();
|
||||
g_alloc_armed = true;
|
||||
ESP_LOGI("allocs", "ARMED total=%u", g_new_count.load());
|
||||
|
||||
binary_sensor:
|
||||
# Drivers wait for this before the first action, so no step runs before recording starts
|
||||
- platform: template
|
||||
name: Allocation recorder armed
|
||||
lambda: return g_alloc_armed.load();
|
||||
|
||||
interval:
|
||||
- interval: 30s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("allocs", "total=%u kept=%u heap=%u", g_new_count.load(), (unsigned) g_alloc_n.load(),
|
||||
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Dump allocations
|
||||
on_press:
|
||||
# Recording stops for good; reboot before the next run, or a second press returns the same entries.
|
||||
- lambda: |-
|
||||
g_alloc_armed = false;
|
||||
// let recorders on other tasks finish the entry they claimed
|
||||
for (int w = 0; w < 100 && g_alloc_busy.load() != 0; w++)
|
||||
delay(1);
|
||||
uint32_t claimed = g_alloc_n.load();
|
||||
uint32_t n = std::min<uint32_t>(claimed, ALLOC_CAP);
|
||||
// claimed > n means the buffer filled and recording stopped early
|
||||
// armed_ms=0: the recorder never ran; busy>0: an entry may still be half written;
|
||||
// buf is the buffer's address, which decode.py matches against the ELF
|
||||
ESP_LOGI("allocs", "DUMP n=%u claimed=%u total=%u armed_ms=%u busy=%u buf=%08x", n, claimed,
|
||||
g_new_count.load(), g_alloc_armed_ms, g_alloc_busy.load(), (unsigned) (uintptr_t) g_allocs);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
const AllocEntry &e = g_allocs[i];
|
||||
ESP_LOGI("allocs", "E %u %u %u %08x %08x %08x %08x %08x", i, e.ms, e.size, e.pc[0], e.pc[1], e.pc[2],
|
||||
e.pc[3], e.pc[4]);
|
||||
if ((i & 15) == 15) {
|
||||
delay(20); // let the log drain
|
||||
App.feed_wdt(); // a full dump over a 115200 baud UART log outlasts the task watchdog
|
||||
}
|
||||
}
|
||||
ESP_LOGI("allocs", "DUMP END");
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Move the device's first cover to the far end and back N times with a states-only client, then save the dump.
|
||||
|
||||
usage: cycles_cover.py HOST CYCLES OUT env: API_KEY (unset for a plaintext API)
|
||||
Waits for the recorder's armed binary sensor first. Exits 2 when a move did not reach its position (the
|
||||
cover never moved, so the dump proves nothing).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
BinarySensorInfo,
|
||||
BinarySensorState,
|
||||
CoverInfo,
|
||||
CoverOperation,
|
||||
CoverState,
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
from apiconn import connect # noqa: E402
|
||||
from heap_alloc.dump import dump # noqa: E402
|
||||
|
||||
# After each move, so allocations that come once the cover has stopped land in the same cycle
|
||||
SETTLE_S = 8
|
||||
ARM_WAIT_S = 600
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
host, cycles, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
||||
with pathlib.Path(out).open("w", encoding="utf-8") as f:
|
||||
|
||||
def w(s: str) -> None:
|
||||
f.write(time.strftime("%H:%M:%S ") + s + "\n")
|
||||
f.flush()
|
||||
|
||||
c = await connect(host)
|
||||
ents, _ = await c.list_entities_services()
|
||||
cover = next(e for e in ents if isinstance(e, CoverInfo))
|
||||
armed_info = next(
|
||||
e
|
||||
for e in ents
|
||||
if isinstance(e, BinarySensorInfo)
|
||||
and e.object_id == "allocation_recorder_armed"
|
||||
)
|
||||
states: asyncio.Queue = asyncio.Queue()
|
||||
armed = asyncio.Event()
|
||||
|
||||
def on_state(s) -> None:
|
||||
if isinstance(s, CoverState) and s.key == cover.key:
|
||||
states.put_nowait(s)
|
||||
elif (
|
||||
isinstance(s, BinarySensorState) and s.key == armed_info.key and s.state
|
||||
):
|
||||
armed.set()
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
latest = await asyncio.wait_for(states.get(), 10)
|
||||
# moves made before recording starts would be missing from the dump
|
||||
await asyncio.wait_for(armed.wait(), ARM_WAIT_S)
|
||||
w("ARMED")
|
||||
|
||||
def drain() -> CoverState:
|
||||
"""The newest queued state, so no stale one is read as this command's result."""
|
||||
nonlocal latest
|
||||
while not states.empty():
|
||||
latest = states.get_nowait()
|
||||
return latest
|
||||
|
||||
async def move(pos: float) -> bool:
|
||||
drain()
|
||||
w(f"CMD {pos}")
|
||||
c.cover_command(cover.key, position=pos)
|
||||
moved = False
|
||||
deadline = time.monotonic() + 60
|
||||
while (left := deadline - time.monotonic()) > 0:
|
||||
try:
|
||||
s = await asyncio.wait_for(states.get(), left)
|
||||
except TimeoutError:
|
||||
break
|
||||
idle = s.current_operation == CoverOperation.IDLE
|
||||
moved |= not idle
|
||||
if moved and idle and abs(s.position - pos) < 0.01:
|
||||
w(f"IDLE pos={s.position:.3f}")
|
||||
return True
|
||||
w("TIMEOUT")
|
||||
return False
|
||||
|
||||
timeouts = 0
|
||||
# start toward the far end, so the first command always moves the cover
|
||||
order = (0.0, 1.0) if drain().position > 0.5 else (1.0, 0.0)
|
||||
for i in range(cycles):
|
||||
w(f"CYCLE {i + 1}")
|
||||
for pos in order:
|
||||
timeouts += not await move(pos)
|
||||
await asyncio.sleep(SETTLE_S)
|
||||
if timeouts:
|
||||
# before the dump, which exits on its own failure
|
||||
print(f"{timeouts} moves timed out", file=sys.stderr)
|
||||
await dump(c, w)
|
||||
await c.disconnect()
|
||||
if timeouts:
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Decode an allocring dump into callers: decode.py LOG ELF [ADDR2LINE]
|
||||
|
||||
Prints each entry with its caller frames as function and file:line, and markers (size 0xFFFFFFFF, ALLOC_MARKER) as phase
|
||||
separators.
|
||||
ADDR2LINE defaults to an Xtensa one found in ESPHome's ESP-IDF tools cache, ~/.platformio or ~/.espressif.
|
||||
Exits 2 when the dump is incomplete (missing header or entries, or the buffer filled during the run).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ENTRY = re.compile(r"\bE (\d+) (\d+) (\d+) ((?:[0-9a-f]{8} ?){5})")
|
||||
HEADER = re.compile(
|
||||
r"\bDUMP n=(\d+) claimed=(\d+) total=\d+ armed_ms=(\d+) busy=(\d+) buf=([0-9a-f]{8})"
|
||||
)
|
||||
MARKER = 0xFFFFFFFF
|
||||
XTENSA = 94 # ELF e_machine; the recorder walks frames only on Xtensa
|
||||
|
||||
|
||||
def tool_roots() -> list[Path]:
|
||||
roots = [Path.home() / ".platformio", Path.home() / ".espressif"]
|
||||
try:
|
||||
from esphome.espidf.framework import get_idf_tools_path
|
||||
except ImportError:
|
||||
return roots
|
||||
return [get_idf_tools_path(), *roots]
|
||||
|
||||
|
||||
def is_xtensa(elf: str) -> bool:
|
||||
return int.from_bytes(Path(elf).read_bytes()[18:20], "little") == XTENSA
|
||||
|
||||
|
||||
def find_addr2line() -> str:
|
||||
for root in tool_roots():
|
||||
for path in root.glob("**/bin/xtensa-esp*-elf-addr2line"):
|
||||
if path.is_file():
|
||||
return str(path)
|
||||
sys.exit("no addr2line found; pass it as the third argument")
|
||||
|
||||
|
||||
def short(path: str) -> str:
|
||||
for marker in ("esphome/components/", "esphome/core/", "esp-idf/", "include/c++/"):
|
||||
if marker in path:
|
||||
return marker + path.split(marker, 1)[1]
|
||||
return path
|
||||
|
||||
|
||||
def decode(
|
||||
addr2line: str, elf: str, pcs: list[str]
|
||||
) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Every PC's frames (inlined ones too); -a prints each address before its frames."""
|
||||
out = subprocess.run(
|
||||
[addr2line, "-afiC", "-e", elf, *(f"0x{pc}" for pc in pcs)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.splitlines()
|
||||
names: dict[str, list[tuple[str, str]]] = {}
|
||||
frames: list[tuple[str, str]] = []
|
||||
i = 0
|
||||
while i < len(out):
|
||||
if out[i].startswith("0x"):
|
||||
frames = names.setdefault(f"{int(out[i], 16):08x}", [])
|
||||
i += 1
|
||||
else:
|
||||
frames.append((out[i], short(out[i + 1])))
|
||||
i += 2
|
||||
return names
|
||||
|
||||
|
||||
def last_dump(text: str) -> str:
|
||||
"""The newest dump in the file (a retried press or an appended file holds several); check() rejects it if cut off."""
|
||||
start = max((m.start() for m in HEADER.finditer(text)), default=len(text))
|
||||
return text[start:]
|
||||
|
||||
|
||||
def check(dump: str, entries: list, xtensa: bool) -> list[str]:
|
||||
"""Reasons the dump is incomplete, empty when it is whole."""
|
||||
if not (header := HEADER.search(dump)):
|
||||
return ["no DUMP header (log subscription started late?)"]
|
||||
n, claimed, armed_ms, busy = (int(header[i]) for i in range(1, 5))
|
||||
problems = []
|
||||
if not armed_ms:
|
||||
problems.append("the recorder was never armed (pressed before alloc_arm_s?)")
|
||||
if busy:
|
||||
problems.append(f"{busy} recorders were still writing an entry")
|
||||
indexes = [int(e[0]) for e in entries]
|
||||
if missing := sorted(set(range(n)) - set(indexes)):
|
||||
problems.append(f"{len(missing)} of {n} entries missing, first {missing[:10]}")
|
||||
if len(indexes) != len(set(indexes)):
|
||||
problems.append("duplicate entries")
|
||||
if stray := sorted(i for i in set(indexes) if i >= n):
|
||||
problems.append(f"entries past n={n}: {stray[:10]}")
|
||||
allocs = [pcs for _, _, size, pcs in entries if size != MARKER]
|
||||
if xtensa and allocs and all(pcs[0] == "00000000" for pcs in allocs):
|
||||
problems.append("no entry has a caller (frame walk failed?)")
|
||||
if claimed > n:
|
||||
problems.append(f"buffer filled: recording stopped after {n} entries")
|
||||
if "DUMP END" not in dump:
|
||||
problems.append("no DUMP END")
|
||||
return problems
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log, elf = sys.argv[1], sys.argv[2]
|
||||
text = last_dump(Path(log).read_text(encoding="utf-8", errors="replace"))
|
||||
entries = [
|
||||
(idx, int(ms), int(size), pcs.split())
|
||||
for idx, ms, size, pcs in ENTRY.findall(text)
|
||||
]
|
||||
wanted = {
|
||||
pc
|
||||
for _, _, size, pcs in entries
|
||||
if size != MARKER
|
||||
for pc in pcs
|
||||
if pc != "00000000"
|
||||
}
|
||||
# Only Xtensa dumps carry callers; RISC-V ones still print sizes and times
|
||||
names = {}
|
||||
if wanted:
|
||||
addr2line = sys.argv[3] if len(sys.argv) > 3 else find_addr2line()
|
||||
names = decode(addr2line, elf, sorted(wanted))
|
||||
for idx, ms, size, pcs in entries:
|
||||
if size == MARKER:
|
||||
print(
|
||||
f"--- marker at {ms / 1000:.3f} s: {int(pcs[0], 16)} {int(pcs[1], 16)}"
|
||||
)
|
||||
continue
|
||||
print(f"#{idx} {ms / 1000:.3f} s {size} B")
|
||||
for pc in pcs:
|
||||
if pc == "00000000":
|
||||
break
|
||||
for func, loc in names[pc]:
|
||||
print(f" {func[:110]} {loc}")
|
||||
problems = check(text, entries, is_xtensa(elf))
|
||||
# addr2line names any address, so check the ELF is the build that made the dump
|
||||
if wanted and (header := HEADER.search(text)):
|
||||
nm = addr2line[: -len("addr2line")] + "nm"
|
||||
if not Path(nm).is_file():
|
||||
problems.append(f"cannot check the ELF: no {nm} next to addr2line")
|
||||
elif not re.search(
|
||||
rf"^0*{int(header[5], 16):x} [bBdD] g_allocs$",
|
||||
subprocess.run(
|
||||
[nm, elf], capture_output=True, text=True, check=True
|
||||
).stdout,
|
||||
re.MULTILINE,
|
||||
):
|
||||
problems.append(
|
||||
"the ELF is not the build that made this dump (g_allocs address differs)"
|
||||
)
|
||||
if problems:
|
||||
print("INCOMPLETE DUMP: " + "; ".join(problems), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Press the "Dump allocations" button from allocring.yaml and save the dump.
|
||||
|
||||
usage: dump.py HOST OUT env: API_KEY (unset for a plaintext API)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import contextlib
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo
|
||||
from aioesphomeapi.model import LogLevel
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
from apiconn import connect, log_text # noqa: E402
|
||||
|
||||
HEADER = re.compile(r"\bDUMP n=(\d+) claimed=\d+ total=\d+ armed_ms=(\d+) busy=(\d+)")
|
||||
ENTRY = re.compile(r"\bE (\d+) \d+ \d+ ")
|
||||
|
||||
|
||||
async def dump(c: APIClient, write: Callable[[str], None], tries: int = 3) -> None:
|
||||
"""Subscribe to logs, press the dump button and pass every line to WRITE until DUMP END.
|
||||
|
||||
subscribe_logs does not confirm the subscription and the device drops log lines when busy, so the press is
|
||||
repeated (the dump is repeatable) until the header and all n entries arrived; exits 2 after TRIES presses.
|
||||
"""
|
||||
ents, _ = await c.list_entities_services()
|
||||
button = next(
|
||||
e
|
||||
for e in ents
|
||||
if isinstance(e, ButtonInfo) and e.object_id == "dump_allocations"
|
||||
)
|
||||
seen: dict = {"n": None, "idx": set()}
|
||||
done = asyncio.Event()
|
||||
|
||||
def on_log(msg) -> None:
|
||||
text = log_text(msg)
|
||||
write(text)
|
||||
if m := HEADER.search(text):
|
||||
seen["n"], seen["idx"] = int(m[1]), set()
|
||||
seen["armed"], seen["busy"] = int(m[2]) != 0, int(m[3])
|
||||
elif m := ENTRY.search(text):
|
||||
seen["idx"].add(int(m[1]))
|
||||
elif "DUMP END" in text and seen["n"] is not None:
|
||||
# without this press's header it is a late end from an earlier press
|
||||
done.set()
|
||||
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_INFO)
|
||||
for _ in range(tries):
|
||||
await asyncio.sleep(2)
|
||||
seen["n"] = None
|
||||
done.clear()
|
||||
c.button_command(button.key)
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(done.wait(), 120)
|
||||
# the device drops log lines when its send buffer is full, so require every entry
|
||||
if done.is_set() and seen["n"] is not None and not seen["armed"]:
|
||||
print(
|
||||
"the recorder was never armed (pressed before alloc_arm_s?)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if (
|
||||
done.is_set()
|
||||
and seen["n"] is not None
|
||||
and not seen["busy"]
|
||||
and seen["idx"] == set(range(seen["n"]))
|
||||
):
|
||||
return
|
||||
print(f"no whole dump after {tries} presses", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = await connect(sys.argv[1])
|
||||
with pathlib.Path(sys.argv[2]).open("w", encoding="utf-8") as f:
|
||||
await dump(c, lambda s: f.write(s + "\n"))
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Press the device's buttons through Home Assistant and print the script and flag states after each step.
|
||||
|
||||
usage: drive.py TOKENFILE NAME env: HA_URL (default http://127.0.0.1:8129)
|
||||
NAME is the device name from hsw.yaml (button entity ids are button.<NAME>_long_on and so on).
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(
|
||||
0, str(pathlib.Path(__file__).resolve().parent.parent / "mqtt" / "ha_discovery")
|
||||
)
|
||||
from ha_rest import req_or_exit # noqa: E402
|
||||
|
||||
tok = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
|
||||
name = sys.argv[2].replace("-", "_") # entity ids use underscores
|
||||
t0 = time.monotonic()
|
||||
|
||||
|
||||
def state(entity: str) -> str:
|
||||
return req_or_exit(f"/api/states/{entity}", token=tok)["state"]
|
||||
|
||||
|
||||
def show(tag: str) -> None:
|
||||
print(
|
||||
f"{time.monotonic() - t0:6.1f}s {tag:30s} long_job={state('script.long_job'):4s}"
|
||||
f" quick_job={state('script.quick_job'):4s} flag={state('input_boolean.script_ran')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def call(domain: str, action: str, entity: str, wait: float = 3) -> None:
|
||||
req_or_exit(f"/api/services/{domain}/{action}", {"entity_id": entity}, token=tok)
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
show("start")
|
||||
call("button", "press", f"button.{name}_long_on")
|
||||
show("device Long on")
|
||||
call("button", "press", f"button.{name}_long_off")
|
||||
show("device Long off")
|
||||
call("input_boolean", "turn_off", "input_boolean.script_ran", 1)
|
||||
call("button", "press", f"button.{name}_long_on")
|
||||
show("device Long on again")
|
||||
time.sleep(20)
|
||||
show("script finished on its own")
|
||||
call("button", "press", f"button.{name}_quick_on")
|
||||
show("device Quick on")
|
||||
call("script", "turn_on", "script.long_job")
|
||||
show("HA starts the script")
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Throwaway Home Assistant on port 8129: a 20 s script that sets and clears a flag, and a toggle script.
|
||||
http:
|
||||
server_port: 8129
|
||||
api:
|
||||
config:
|
||||
onboarding:
|
||||
frontend:
|
||||
input_boolean:
|
||||
script_ran:
|
||||
script:
|
||||
long_job:
|
||||
alias: Long job
|
||||
mode: single
|
||||
sequence:
|
||||
- action: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.script_ran
|
||||
- delay: 20
|
||||
- action: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.script_ran
|
||||
quick_job:
|
||||
alias: Quick job
|
||||
sequence:
|
||||
- action: input_boolean.toggle
|
||||
target:
|
||||
entity_id: input_boolean.script_ran
|
||||
logger:
|
||||
default: warning
|
||||
logs:
|
||||
homeassistant.components.esphome: debug
|
||||
homeassistant.components.script: debug
|
||||
homeassistant.helpers.script: info
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add a host build's API to a throwaway Home Assistant and allow it to call actions.
|
||||
|
||||
usage: haflow.py TOKENFILE env: HA_URL (default http://127.0.0.1:8129)
|
||||
Runs the esphome config flow against 127.0.0.1:6053, then turns on allow_service_calls in the options flow.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0, str(pathlib.Path(__file__).resolve().parent.parent / "mqtt" / "ha_discovery")
|
||||
)
|
||||
from ha_rest import req_or_exit # noqa: E402
|
||||
|
||||
tok = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
|
||||
flow = "/api/config/config_entries/flow"
|
||||
f = req_or_exit(flow, {"handler": "esphome"}, token=tok)
|
||||
r = req_or_exit(
|
||||
f"{flow}/{f['flow_id']}", {"host": "127.0.0.1", "port": 6053}, token=tok
|
||||
)
|
||||
if r.get("type") == "form" and r.get("errors"):
|
||||
sys.exit(f"config flow step {r.get('step_id')} failed: {r['errors']}")
|
||||
if r.get("type") == "form": # confirmation step
|
||||
r = req_or_exit(f"{flow}/{r['flow_id']}", {}, token=tok)
|
||||
if r.get("type") != "create_entry":
|
||||
sys.exit(
|
||||
f"config flow ended with {r.get('type')} {r.get('errors') or r.get('reason')}"
|
||||
)
|
||||
opts = "/api/config/config_entries/options/flow"
|
||||
o = req_or_exit(opts, {"handler": r["result"]["entry_id"]}, token=tok)
|
||||
o = req_or_exit(f"{opts}/{o['flow_id']}", {"allow_service_calls": True}, token=tok)
|
||||
if o.get("type") != "create_entry":
|
||||
sys.exit(
|
||||
f"options flow ended with {o.get('type')} {o.get('errors') or o.get('reason')}"
|
||||
)
|
||||
print("added", r.get("title"), "with allow_service_calls")
|
||||
@@ -0,0 +1,39 @@
|
||||
# Host build mirroring two Home Assistant scripts and the flag they set. Substitution: name.
|
||||
esphome:
|
||||
name: ${name}
|
||||
host:
|
||||
logger:
|
||||
level: DEBUG
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
switch:
|
||||
- platform: homeassistant
|
||||
id: sw_long
|
||||
name: "Long job mirror"
|
||||
entity_id: script.long_job
|
||||
on_turn_on:
|
||||
- logger.log: "EV long ON"
|
||||
on_turn_off:
|
||||
- logger.log: "EV long OFF"
|
||||
- platform: homeassistant
|
||||
id: sw_quick
|
||||
name: "Quick job mirror"
|
||||
entity_id: script.quick_job
|
||||
- platform: homeassistant
|
||||
id: sw_bool
|
||||
name: "Ran flag mirror"
|
||||
entity_id: input_boolean.script_ran
|
||||
on_turn_on:
|
||||
- logger.log: "EV flag ON"
|
||||
on_turn_off:
|
||||
- logger.log: "EV flag OFF"
|
||||
button:
|
||||
- platform: template
|
||||
name: "Long on"
|
||||
on_press: [switch.turn_on: sw_long]
|
||||
- platform: template
|
||||
name: "Long off"
|
||||
on_press: [switch.turn_off: sw_long]
|
||||
- platform: template
|
||||
name: "Quick on"
|
||||
on_press: [switch.turn_on: sw_quick]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Drive the modem test build over the API: press buttons, print sensor states, save the device log.
|
||||
usage: mdrive.py HOST LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | wait:SECONDS
|
||||
OP: list | press:NAME | text:VALUE (sets the first text entity, the AT line of modem-passthrough.yaml) | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -14,6 +14,7 @@ from aioesphomeapi import (
|
||||
LogLevel,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextInfo,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
@@ -38,6 +39,7 @@ async def main() -> None:
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
texts = [e for e in ents if isinstance(e, TextInfo)]
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
@@ -76,6 +78,14 @@ async def main() -> None:
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
elif kind == "text":
|
||||
if len(texts) != 1:
|
||||
sys.exit(
|
||||
f"text op needs exactly one text entity; the build has {len(texts)}"
|
||||
)
|
||||
print(f"{stamp()} >>> {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> {rest}\n")
|
||||
c.text_command(texts[0].key, rest)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Raw UART passthrough to a modem (M5Stack Atom Lite, modem on the Grove port: RX GPIO32, TX GPIO26): both
|
||||
# directions logged line by line, and a text entity whose value is sent with a CR. Drive with scripts/modem/mdrive.py text:CMD.
|
||||
esphome:
|
||||
name: ${name}
|
||||
esp32:
|
||||
board: m5stack-atom
|
||||
framework:
|
||||
type: esp-idf
|
||||
logger:
|
||||
level: DEBUG
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
uart:
|
||||
id: mu
|
||||
rx_pin: GPIO32
|
||||
tx_pin: GPIO26
|
||||
baud_rate: 115200
|
||||
rx_buffer_size: 2048
|
||||
debug:
|
||||
direction: BOTH
|
||||
dummy_receiver: true
|
||||
after:
|
||||
delimiter: "\n"
|
||||
sequence:
|
||||
- lambda: UARTDebug::log_string(direction, bytes);
|
||||
text:
|
||||
- platform: template
|
||||
id: at_cmd
|
||||
name: AT
|
||||
mode: text
|
||||
max_length: 120
|
||||
optimistic: true
|
||||
set_action:
|
||||
- lambda: |-
|
||||
std::string s = x + "\r";
|
||||
id(mu).write_array((const uint8_t *) s.data(), s.size());
|
||||
@@ -7,6 +7,7 @@ RUN git config --system --add safe.directory "*"
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y \
|
||||
libmbedtls-dev \
|
||||
protobuf-compiler
|
||||
|
||||
RUN pip install uv
|
||||
|
||||
@@ -18,7 +18,7 @@ workflows:
|
||||
- 'actions/download-artifact@v8.0.2'
|
||||
- 'actions/setup-python@v7.1.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'docker/login-action@v4.7.0'
|
||||
- 'docker/login-action@v4.8.0'
|
||||
- 'docker/setup-buildx-action@v4.4.1'
|
||||
'.github/workflows/ci-github-scripts.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
@@ -51,7 +51,7 @@ workflows:
|
||||
- 'actions/github-script@v9.0.0'
|
||||
'.github/workflows/codeql.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'github/codeql-action@v4.39.2'
|
||||
- 'github/codeql-action@v4.39.3'
|
||||
'.github/workflows/external-component-bot.yml':
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/github-script@v9.0.0'
|
||||
@@ -71,7 +71,7 @@ workflows:
|
||||
- 'actions/setup-python@v7.1.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'docker/build-push-action@v7.4.0'
|
||||
- 'docker/login-action@v4.7.0'
|
||||
- 'docker/login-action@v4.8.0'
|
||||
- 'docker/setup-buildx-action@v4.4.1'
|
||||
- 'pypa/gh-action-pypi-publish@v1.14.3'
|
||||
'.github/workflows/stale.yml': []
|
||||
@@ -86,12 +86,12 @@ workflows:
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/setup-python@v7.1.0'
|
||||
- 'astral-sh/setup-uv@v10.3.3'
|
||||
- 'peter-evans/create-pull-request@v8.1.1'
|
||||
- 'peter-evans/create-pull-request@v8.1.3'
|
||||
'.github/workflows/sync-integration-durations.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/setup-python@v7.1.0'
|
||||
- 'peter-evans/create-pull-request@v8.1.1'
|
||||
- 'peter-evans/create-pull-request@v8.1.3'
|
||||
'.github/workflows/verify-lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
dependencies:
|
||||
@@ -193,9 +193,9 @@ dependencies:
|
||||
commit: 'sha1-c3c9e263c25d99ce0380d002d59b67737d91b0dc'
|
||||
owner_id: 5429470
|
||||
repo_id: 241092383
|
||||
'docker/login-action@v4.7.0':
|
||||
ref: 'v4.7.0'
|
||||
commit: 'sha1-81c6f93d4bc991e510b342a00b419b3bb4efc5c6'
|
||||
'docker/login-action@v4.8.0':
|
||||
ref: 'v4.8.0'
|
||||
commit: 'sha1-8aa5831fb8d48d8f76594c7ddde61ae58019f550'
|
||||
owner_id: 5429470
|
||||
repo_id: 287743349
|
||||
'docker/setup-buildx-action@v4.4.1':
|
||||
@@ -203,9 +203,9 @@ dependencies:
|
||||
commit: 'sha1-f87e5991a6d7451dcb8d9637bfbc97413f497069'
|
||||
owner_id: 5429470
|
||||
repo_id: 288485773
|
||||
'github/codeql-action@v4.39.2':
|
||||
ref: 'v4.39.2'
|
||||
commit: 'sha1-1fc58d04a4d4374f921bdac573c3811c240f9c26'
|
||||
'github/codeql-action@v4.39.3':
|
||||
ref: 'v4.39.3'
|
||||
commit: 'sha1-33d7b85da1a04171e211befe2c739303b9a4c066'
|
||||
owner_id: 9919
|
||||
repo_id: 259445878
|
||||
'j178/prek-action@v3.1.0':
|
||||
@@ -213,9 +213,9 @@ dependencies:
|
||||
commit: 'sha1-44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d'
|
||||
owner_id: 10510431
|
||||
repo_id: 1052387486
|
||||
'peter-evans/create-pull-request@v8.1.1':
|
||||
ref: 'v8.1.1'
|
||||
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
'peter-evans/create-pull-request@v8.1.3':
|
||||
ref: 'v8.1.3'
|
||||
commit: 'sha1-3ffccacd46053766916f087ce41d627c0b8cf2d1'
|
||||
owner_id: 18365890
|
||||
repo_id: 197176365
|
||||
'pre-commit-ci/lite-action@v1.2.0':
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -227,6 +227,10 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
# The parent of the pull request merge commit is the target branch, which
|
||||
# ci-custom compares against to find constants new in this pull request.
|
||||
fetch-depth: 2
|
||||
- name: Restore Python
|
||||
uses: $/.github/actions/restore-python
|
||||
with:
|
||||
@@ -772,6 +776,11 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Install apt packages (cached)
|
||||
# libmbedtls-dev is needed by the dsmr and dlms_meter tests.
|
||||
timeout-minutes: 10
|
||||
uses: $/.github/actions/apt-packages
|
||||
|
||||
- name: Restore Python
|
||||
uses: $/.github/actions/restore-python
|
||||
with:
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.39.2
|
||||
uses: github/codeql-action/init@v4.39.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.39.2
|
||||
uses: github/codeql-action/analyze@v4.39.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
permissions:
|
||||
issues: write # issues.lock on closed issues
|
||||
pull-requests: write # issues.lock on closed pull requests
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@e1432c7b31fec7d24a13dc192953ebac836ed98a # 2026.10.3
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@b2c0cffae477d81175c67e6a44651a9bc2b49f6f # 2026.11.0
|
||||
@@ -126,12 +126,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -206,13 +206,13 @@ jobs:
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
run: python script/run-in-env.py prek run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@v8.1.1
|
||||
uses: peter-evans/create-pull-request@v8.1.3
|
||||
with:
|
||||
commit-message: "Synchronise Device Classes from Home Assistant"
|
||||
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@v8.1.1
|
||||
uses: peter-evans/create-pull-request@v8.1.3
|
||||
with:
|
||||
commit-message: "[ci] Refresh integration test durations"
|
||||
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.17.3
|
||||
rev: v0.17.4
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -430,6 +430,14 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
(`ESPDEPRECATED` in C++, `cv.rename_key(..., removed_in=...)` for config keys) and document the migration
|
||||
path in the PR description. The `breaking-changes` skill has the full policy, checklist and patterns.
|
||||
|
||||
* **The YAML language is frozen** until ESPHome configurations move to yaml.rocks
|
||||
([esphome/backlog#201](https://github.com/esphome/backlog/issues/201)). Do not add YAML tags beyond the existing
|
||||
`!include`, `!include_dir_*`, `!secret`, `!env_var`, `!lambda`, `!literal`, `!extend` and `!remove`, new
|
||||
`packages:` or `substitutions:` behaviour (including new Jinja globals or filters), or new ways to merge,
|
||||
override or remove parts of a configuration.
|
||||
Bug fixes to the existing features are still accepted. A component's own configuration options are not part of
|
||||
this freeze.
|
||||
|
||||
## 9. English Language
|
||||
|
||||
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
|
||||
|
||||
+4
-2
@@ -98,7 +98,6 @@ esphome/components/bme280_spi/* @apbodrov
|
||||
esphome/components/bme680_bsec/* @trvrnrth
|
||||
esphome/components/bme68x_bsec2/* @kbx81 @neffs
|
||||
esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs
|
||||
esphome/components/bmi160/* @flaviut
|
||||
esphome/components/bmi270/* @clydebarrow
|
||||
esphome/components/bmp280_base/* @ademuri
|
||||
esphome/components/bmp280_i2c/* @ademuri
|
||||
@@ -117,6 +116,7 @@ esphome/components/bq25820/* @beforeyouknowit
|
||||
esphome/components/bq27220/* @rggammon
|
||||
esphome/components/bq76972/* @limpkin
|
||||
esphome/components/bridge/* @kbx81
|
||||
esphome/components/brizlabs_led_string/* @TFenby
|
||||
esphome/components/bthome_mithermometer/* @nagyrobi
|
||||
esphome/components/button/* @esphome/core
|
||||
esphome/components/bytebuffer/* @clydebarrow
|
||||
@@ -198,6 +198,7 @@ esphome/components/ens160_base/* @latonita @vincentscode
|
||||
esphome/components/ens160_i2c/* @latonita
|
||||
esphome/components/ens160_spi/* @latonita
|
||||
esphome/components/ens210/* @itn3rd77
|
||||
esphome/components/entity_transport/* @clydebarrow
|
||||
esphome/components/epaper_spi/* @esphome/core
|
||||
esphome/components/es7210/* @kahrendt
|
||||
esphome/components/es7243/* @Chorty
|
||||
@@ -638,6 +639,7 @@ esphome/components/st7789v/* @kbx81
|
||||
esphome/components/st7920/* @marsjan155
|
||||
esphome/components/statsd/* @Links2004
|
||||
esphome/components/stcc4/* @j9brown
|
||||
esphome/components/stm32/* @rwrozelle
|
||||
esphome/components/store_yaml/* @bdraco
|
||||
esphome/components/stts22h/* @B48D81EFCC
|
||||
esphome/components/substitutions/* @esphome/core
|
||||
@@ -739,7 +741,7 @@ esphome/components/web_server_idf/* @dentra
|
||||
esphome/components/weikai/* @DrCoolZic
|
||||
esphome/components/weikai_i2c/* @DrCoolZic
|
||||
esphome/components/weikai_spi/* @DrCoolZic
|
||||
esphome/components/whirlpool/* @arpena @glmnet
|
||||
esphome/components/whirlpool/* @arpena @glmnet @Rodrigoah
|
||||
esphome/components/whynter/* @aeonsablaze
|
||||
esphome/components/wiegand/* @ssieb
|
||||
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.28.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.28.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+72
-4
@@ -333,7 +333,7 @@ def choose_upload_log_host(
|
||||
# Logs can stream over a network transport via the native API
|
||||
# or the web_server HTTP SSE feed.
|
||||
network_logging = has_api() or has_web_server_logging()
|
||||
# ensure IP adresses are used first
|
||||
# ensure IP addresses are used first
|
||||
if is_ip_address(CORE.address) and (
|
||||
(purpose == Purpose.LOGGING and network_logging)
|
||||
or (purpose == Purpose.UPLOADING and has_ota())
|
||||
@@ -1807,6 +1807,7 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
|
||||
ENV_OTA_PASSWORD = "ESPHOME_OTA_PASSWORD"
|
||||
ENV_API_KEY = "ESPHOME_API_KEY"
|
||||
OTA_KEY_PLATFORM_ERROR = (
|
||||
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
|
||||
"platform; to flash by serial instead, name the port with --device"
|
||||
@@ -1881,6 +1882,10 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
return exit_code
|
||||
|
||||
|
||||
def _is_network_device(device: str) -> bool:
|
||||
return device not in ("OTA", "SERIAL") and get_port_type(device) == PortType.NETWORK
|
||||
|
||||
|
||||
def command_upload_without_config(args: ArgsProtocol) -> int:
|
||||
"""Send a prebuilt image to a device over the esphome OTA platform."""
|
||||
from esphome import espota2
|
||||
@@ -1892,9 +1897,7 @@ def command_upload_without_config(args: ArgsProtocol) -> int:
|
||||
"prebuilt image without one"
|
||||
)
|
||||
if unsupported := [
|
||||
device
|
||||
for device in args.device
|
||||
if device in ("OTA", "SERIAL") or get_port_type(device) != PortType.NETWORK
|
||||
device for device in args.device if not _is_network_device(device)
|
||||
]:
|
||||
raise EsphomeError(
|
||||
f"Without a configuration, --device must be an IP address or a "
|
||||
@@ -2074,6 +2077,46 @@ def command_version(args: ArgsProtocol) -> int | None:
|
||||
return 0
|
||||
|
||||
|
||||
def command_recover_yaml(args: ArgsProtocol) -> int:
|
||||
"""Download the YAML that store_yaml put on a device into a new folder."""
|
||||
import asyncio
|
||||
|
||||
from esphome.api_client import async_recover_yaml
|
||||
from esphome.components.store_yaml import (
|
||||
SECRETS_SKELETON_HEADER,
|
||||
UNCAPTURED_NOTE_PATH,
|
||||
decode_stored_yaml,
|
||||
write_recovered_files,
|
||||
)
|
||||
|
||||
device = args.device
|
||||
if not _is_network_device(device):
|
||||
raise EsphomeError("--device must be an IP address or a hostname")
|
||||
out_dir = Path(args.output or f"{device}-recovered")
|
||||
if out_dir.exists() and (not out_dir.is_dir() or any(out_dir.iterdir())):
|
||||
raise EsphomeError(
|
||||
f"'{out_dir}' is not an empty folder; choose a new folder with --output"
|
||||
)
|
||||
key = args.env_api_key
|
||||
if key is None:
|
||||
key = read_secret_line("API encryption key (empty if the device has none): ")
|
||||
addresses: list[str] = []
|
||||
_add_network_device(device, addresses)
|
||||
blob = asyncio.run(async_recover_yaml(addresses, args.api_port, key or None))
|
||||
files = decode_stored_yaml(blob)
|
||||
written = write_recovered_files(files, out_dir)
|
||||
safe_print(f"Recovered {len(written)} files into {out_dir}:")
|
||||
for path in written:
|
||||
safe_print(f" {path}")
|
||||
safe_print(f"The main configuration is {written[0]}.")
|
||||
for path, content in zip(written, files.values(), strict=True):
|
||||
if content.startswith(SECRETS_SKELETON_HEADER.encode()):
|
||||
safe_print(f"Fill in the values in {path} before you install it.")
|
||||
if UNCAPTURED_NOTE_PATH in files:
|
||||
safe_print(f"{UNCAPTURED_NOTE_PATH} lists files that were not stored.")
|
||||
return 0
|
||||
|
||||
|
||||
def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import writer
|
||||
|
||||
@@ -2363,6 +2406,7 @@ PRE_CONFIG_ACTIONS = {
|
||||
"vscode": command_vscode,
|
||||
"update-all": command_update_all,
|
||||
"clean-all": command_clean_all,
|
||||
"recover-yaml": command_recover_yaml,
|
||||
}
|
||||
|
||||
POST_CONFIG_ACTIONS = {
|
||||
@@ -2741,6 +2785,25 @@ def parse_args(argv):
|
||||
|
||||
subparsers.add_parser("version", help="Print the ESPHome version and exit.")
|
||||
|
||||
parser_recover_yaml = subparsers.add_parser(
|
||||
"recover-yaml",
|
||||
help="Download the YAML configuration stored on a device by store_yaml. "
|
||||
f"The API encryption key is read from {ENV_API_KEY} or asked for.",
|
||||
)
|
||||
parser_recover_yaml.add_argument(
|
||||
"--device", required=True, help="The device's IP address or hostname."
|
||||
)
|
||||
parser_recover_yaml.add_argument(
|
||||
"--output",
|
||||
help="New folder for the files. Defaults to <device>-recovered.",
|
||||
)
|
||||
parser_recover_yaml.add_argument(
|
||||
"--api-port",
|
||||
type=int,
|
||||
default=6053,
|
||||
help="The device's API port. Default: %(default)s.",
|
||||
)
|
||||
|
||||
parser_clean = subparsers.add_parser(
|
||||
"clean", help="Delete all temporary build files."
|
||||
)
|
||||
@@ -2952,6 +3015,11 @@ def run_esphome(argv):
|
||||
# tool inherits a key a calling program handed over
|
||||
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
|
||||
args.env_ota_password = os.environ.pop(ENV_OTA_PASSWORD, None)
|
||||
args.env_api_key = os.environ.pop(ENV_API_KEY, None)
|
||||
if args.env_api_key is not None and args.command != "recover-yaml":
|
||||
_LOGGER.warning(
|
||||
"%s is set but the %s command does not use it", ENV_API_KEY, args.command
|
||||
)
|
||||
if args.env_ota_password is not None:
|
||||
if args.command != "upload":
|
||||
_LOGGER.warning(
|
||||
|
||||
+30
-2
@@ -12,11 +12,11 @@ with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi import APIClient, APIConnectionError, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.util import safe_print
|
||||
|
||||
@@ -194,3 +194,31 @@ def run_logs(
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_recover_yaml(
|
||||
addresses: list[str], port: int, noise_psk: str | None
|
||||
) -> bytes:
|
||||
"""Fetch the compressed YAML that store_yaml put on the device."""
|
||||
cli = APIClient(
|
||||
addresses[0],
|
||||
port,
|
||||
"",
|
||||
client_info=f"ESPHome recover-yaml {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses,
|
||||
)
|
||||
try:
|
||||
await cli.connect(login=True)
|
||||
if not (await cli.device_capabilities()).store_yaml.supported:
|
||||
raise EsphomeError(
|
||||
f"store_yaml is not enabled on {cli.log_name}; there is no YAML "
|
||||
"to recover"
|
||||
)
|
||||
return await cli.get_yaml()
|
||||
except APIConnectionError as err:
|
||||
raise EsphomeError(
|
||||
f"Could not fetch the YAML from {' or '.join(addresses)}: {err}"
|
||||
) from err
|
||||
finally:
|
||||
await cli.disconnect()
|
||||
@@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple):
|
||||
# Arduino core version -> its build in esphome-libs/arduino-esp8266
|
||||
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
|
||||
Version(3, 1, 2): FrameworkRelease(
|
||||
"3.1.2-esphome.3",
|
||||
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
|
||||
37185650,
|
||||
"3.1.2-esphome.4",
|
||||
"d215e439e5592ca6ecac283825d08044b2bfce9cf592e03cf33dcbcec69365e2",
|
||||
37192995,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
+175
-15
@@ -7,6 +7,8 @@ import re
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -457,6 +459,13 @@ def _names_parent(text: str) -> bool:
|
||||
return any(f == "parent" for _, f, _, _ in string.Formatter().parse(text))
|
||||
|
||||
|
||||
def _lambda_type(type_: Any, parent: str | None) -> Any:
|
||||
"""Raw C++ type text becomes an expression with ``{parent}`` filled in."""
|
||||
if isinstance(type_, str):
|
||||
return cg.RawExpression(type_.format(parent=parent))
|
||||
return type_
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyCall:
|
||||
"""One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``.
|
||||
@@ -543,6 +552,12 @@ IN_RANGE_CHECK = ApplyCall(
|
||||
)
|
||||
IN_RANGE_DEFAULTS = {CONF_ABOVE: math.nan, CONF_BELOW: math.nan}
|
||||
|
||||
ValueRange = cg.esphome_ns.struct("ValueRange")
|
||||
# The bounds of an on_value_range trigger, rendered by ``new_value_range_trigger``.
|
||||
_VALUE_RANGE_BOUNDS = ApplyCall(
|
||||
"ValueRange{{{}, {}}}", ((CONF_ABOVE, cg.float_), (CONF_BELOW, cg.float_))
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyBytes:
|
||||
@@ -586,7 +601,7 @@ def _dict_schema(schema: Any) -> Any:
|
||||
return cv.Schema(schema)
|
||||
if isinstance(getattr(schema, "schema", None), dict):
|
||||
return schema
|
||||
if isinstance(schema, cv.All):
|
||||
if isinstance(schema, vol.All):
|
||||
inner = schema.validators
|
||||
else:
|
||||
inner = (
|
||||
@@ -616,6 +631,13 @@ def _check_key_in_schema(
|
||||
schema = schema.schema[markers[part]]
|
||||
|
||||
|
||||
def _check_call_keys(
|
||||
name: str, schema: Any, members: list[tuple[Any, Any, Any]]
|
||||
) -> None:
|
||||
for conf_key, _, _ in members:
|
||||
_check_key_in_schema(name, schema, conf_key)
|
||||
|
||||
|
||||
def parent_ref(var: MockObj) -> MockObj:
|
||||
"""``var`` named from global scope, so a trigger argument cannot shadow it.
|
||||
|
||||
@@ -680,9 +702,17 @@ def apply_static_function(
|
||||
return_type: SafeExpType,
|
||||
params: TemplateArgsType,
|
||||
statements: list[str],
|
||||
share: bool = False,
|
||||
suffix: str = "fn",
|
||||
) -> MockObj:
|
||||
"""Emit the static function generated for ``id_`` and return an expression naming it."""
|
||||
return cg.static_function(f"esphome__{id_.id}__fn", return_type, params, statements)
|
||||
"""Emit the static function for ``id_``; ``suffix`` names a second one, ``share`` reuses an identical one."""
|
||||
return cg.shared_static_function(
|
||||
_apply_function_name(id_, suffix), return_type, params, statements, share=share
|
||||
)
|
||||
|
||||
|
||||
def _apply_function_name(id_: ID, suffix: str = "fn") -> str:
|
||||
return f"esphome__{id_.id}__{suffix}"
|
||||
|
||||
|
||||
# Comments, raw strings, strings and char literals; a ' after a digit is a digit separator.
|
||||
@@ -716,14 +746,15 @@ def _has_top_level_comma(code: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _call_user_lambda(
|
||||
async def call_user_lambda(
|
||||
value: Lambda, args: TemplateArgsType, return_type: SafeExpType
|
||||
) -> Expression:
|
||||
"""Call a config lambda in place; a value arg its body names is a copy it may modify.
|
||||
"""Call a config lambda in an apply function's statements; a value arg its body names is a copy it may modify.
|
||||
|
||||
The other args are the generated function's const references, so a body that names
|
||||
none of them is reduced to its expression, as ``call_lambda`` does. When unsure the
|
||||
The other args are the function's const references (``register_apply_action``, ``apply_statements_action``),
|
||||
so a body that names none of them is reduced to its expression, as ``call_lambda`` does. When unsure the
|
||||
name check counts a use: a needless copy is only a cost, a missed one fails to compile.
|
||||
The result is safe as an argument or initializer; parenthesize it before using it as an operand.
|
||||
"""
|
||||
code = _cpp_code_only(value.value)
|
||||
by_value = {
|
||||
@@ -765,9 +796,7 @@ async def _render_call(
|
||||
exprs: list[str] = []
|
||||
for (_, type_, const_fn), value in zip(members, values, strict=True):
|
||||
if isinstance(value, Lambda):
|
||||
if isinstance(type_, str):
|
||||
type_ = cg.RawExpression(type_.format(parent=parent))
|
||||
expr = await _call_user_lambda(value, args, type_)
|
||||
expr = await call_user_lambda(value, args, _lambda_type(type_, parent))
|
||||
bare = compare and isinstance(expr, cg.RawExpression)
|
||||
exprs.append(f"({expr})" if bare else str(expr))
|
||||
elif isinstance(value, ID):
|
||||
@@ -828,8 +857,7 @@ def apply_action_builder(
|
||||
continue
|
||||
c = f if isinstance(f, ApplyCall) else f.call()
|
||||
members = _default_std_string(c.members, flash_string)
|
||||
for conf_key, _, _ in members:
|
||||
_check_key_in_schema(name, schema, conf_key)
|
||||
_check_call_keys(name, schema, members)
|
||||
statements_spec.append((c.target, members))
|
||||
# Named like the generated function, so it cannot clash with an SDK symbol
|
||||
table_name = f"esphome__{name.replace('.', '_')}__bytes"
|
||||
@@ -888,7 +916,7 @@ async def _render_bytes(
|
||||
return f"{receiver}{target.format(data, parent=parent)};"
|
||||
|
||||
if isinstance(value, Lambda):
|
||||
call = await _call_user_lambda(value, args, _BYTE_VECTOR)
|
||||
call = await call_user_lambda(value, args, _BYTE_VECTOR)
|
||||
return [
|
||||
"{",
|
||||
f" const std::vector<uint8_t> esphome__bytes = {call};",
|
||||
@@ -923,6 +951,39 @@ def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -
|
||||
return not members or any(value is not None for value in values)
|
||||
|
||||
|
||||
async def new_value_range_trigger(
|
||||
trigger_id: ID, parent: MockObj, config: ConfigType
|
||||
) -> MockObj:
|
||||
"""Create ``trigger_id`` with ``parent`` and a generated ``ValueRange (*)(float x)``: the bounds at ``x``.
|
||||
|
||||
The trigger is declared before its bounds are rendered, so a bound may use the trigger's own id.
|
||||
A bound that names ``x`` gets its own copy, so it cannot change the value the other bound sees;
|
||||
the const ``x`` is a backstop if bounds are ever inlined again.
|
||||
"""
|
||||
trigger = cg.new_Pvariable(
|
||||
trigger_id, parent, cg.RawExpression(_apply_function_name(trigger_id))
|
||||
)
|
||||
config = {**IN_RANGE_DEFAULTS, **config}
|
||||
members = _VALUE_RANGE_BOUNDS.members
|
||||
bounds = await _render_call(
|
||||
"on_value_range",
|
||||
_VALUE_RANGE_BOUNDS.target,
|
||||
members,
|
||||
_apply_values(config, members),
|
||||
config,
|
||||
None,
|
||||
[(cg.float_, "x")],
|
||||
)
|
||||
# The trigger above already names this function, so it must keep this exact name
|
||||
cg.static_function(
|
||||
_apply_function_name(trigger_id),
|
||||
ValueRange,
|
||||
[(cg.RawExpression("const float"), "x")],
|
||||
[f"return {bounds};"],
|
||||
)
|
||||
return trigger
|
||||
|
||||
|
||||
def register_apply_condition(
|
||||
name: str,
|
||||
schema: cv.Schema,
|
||||
@@ -943,8 +1004,7 @@ def register_apply_condition(
|
||||
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
|
||||
members = _default_std_string(call.members, flash_compare)
|
||||
_check_key_in_schema(name, schema, id_key)
|
||||
for conf_key, _, _ in members:
|
||||
_check_key_in_schema(name, schema, conf_key)
|
||||
_check_call_keys(name, schema, members)
|
||||
|
||||
async def builder(
|
||||
config: ConfigType,
|
||||
@@ -970,6 +1030,106 @@ def register_apply_condition(
|
||||
register_condition(name, ApplyCondition, schema)(builder)
|
||||
|
||||
|
||||
# The key of a filter whose schema is a single value rather than a mapping
|
||||
WHOLE_CONFIG: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _apply_calls(name: str, statements: tuple[str | ApplyCall, ...]) -> list[ApplyCall]:
|
||||
if not statements:
|
||||
# A non-void function with no statement would never return
|
||||
raise ValueError(f"{name}: a filter needs at least one statement")
|
||||
return [s if isinstance(s, ApplyCall) else ApplyCall(s) for s in statements]
|
||||
|
||||
|
||||
async def _apply_value_function(
|
||||
filter_id: ID, index: int, value: Lambda, type_: SafeExpType
|
||||
) -> cg.RawExpression:
|
||||
"""A value lambda as its own function, called from the filter's function; inlined there,
|
||||
its ids would be shadowed by the filter's parameters, such as an id ``x``."""
|
||||
lambda_ = await cg.process_lambda(value, [], return_type=type_)
|
||||
fn = apply_static_function(
|
||||
filter_id, type_, [], lambda_.body_lines, suffix=f"value{index}"
|
||||
)
|
||||
return cg.RawExpression(f"{fn}()")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyFilterDomain:
|
||||
"""How one entity domain declares filters that are one generated function.
|
||||
|
||||
``filter_class`` is the domain's ``ApplyFilter``, templated on the function, whose
|
||||
``params`` and ``return_type`` follow what that class passes and expects.
|
||||
"""
|
||||
|
||||
registry: Registry
|
||||
filter_class: MockObjClass
|
||||
return_type: SafeExpType
|
||||
params: tuple[tuple[Any, str], ...]
|
||||
|
||||
def new(self, filter_id: ID, statements: list[str], share: bool = False) -> MockObj:
|
||||
"""Declare ``filter_id`` as the domain's ``ApplyFilter`` on a function of ``statements`` taking ``params``."""
|
||||
id_ = filter_id.copy()
|
||||
id_.type = self.filter_class
|
||||
fn = apply_static_function(
|
||||
id_, self.return_type, list(self.params), statements, share=share
|
||||
)
|
||||
return cg.new_Pvariable(id_, cg.TemplateArguments(fn))
|
||||
|
||||
async def build(
|
||||
self,
|
||||
name: str,
|
||||
config: Any,
|
||||
filter_id: ID,
|
||||
*statements: str | ApplyCall,
|
||||
schema: Any,
|
||||
) -> MockObj:
|
||||
"""Declare filter ``name`` as the function of ``statements`` over ``config``.
|
||||
|
||||
Value lambdas become their own functions; a statement whose keys are all absent is skipped,
|
||||
except the last; ``schema`` checks the statement keys, so a typo is not a skipped statement.
|
||||
"""
|
||||
calls = _apply_calls(name, statements)
|
||||
for c in calls:
|
||||
_check_call_keys(name, schema, c.members)
|
||||
body: list[str] = []
|
||||
lambdas = 0
|
||||
for index, c in enumerate(calls):
|
||||
values = _apply_values(config, c.members)
|
||||
if not _apply_call_active(c.members, values):
|
||||
if index == len(calls) - 1:
|
||||
raise ValueError(
|
||||
f"{name}: the last statement is skipped, so the function may not return"
|
||||
)
|
||||
continue
|
||||
members = list(c.members)
|
||||
for i, ((key, type_, _), value) in enumerate(
|
||||
zip(members, values, strict=True)
|
||||
):
|
||||
if isinstance(value, Lambda):
|
||||
type_ = _lambda_type(type_, None)
|
||||
# The call is already the argument text, so no const_fn applies
|
||||
members[i] = (key, type_, None)
|
||||
values[i] = await _apply_value_function(
|
||||
filter_id, lambdas, value, type_
|
||||
)
|
||||
lambdas += 1
|
||||
body.append(
|
||||
await _render_call(
|
||||
name, c.target, members, values, config, None, [], compare=True
|
||||
)
|
||||
)
|
||||
return self.new(filter_id, body, share=not lambdas)
|
||||
|
||||
def register(self, name: str, schema: Any, *statements: str | ApplyCall) -> None:
|
||||
"""Register filter ``name`` as the function of ``statements``; see ``build``."""
|
||||
calls = _apply_calls(name, statements)
|
||||
|
||||
async def builder(config: Any, filter_id: ID) -> MockObj:
|
||||
return await self.build(name, config, filter_id, *calls, schema=schema)
|
||||
|
||||
self.registry.register(name, self.filter_class, schema)(builder)
|
||||
|
||||
|
||||
def validate_potentially_and_condition(value):
|
||||
if isinstance(value, list):
|
||||
with cv.remove_prepend_path(["and"]):
|
||||
|
||||
@@ -33,6 +33,7 @@ def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
ZEPHYR_TOOLS_CACHE = ("ESPHOME_ZEPHYR_PREFIX", "zephyr")
|
||||
# The host backend installs nothing; the entry only holds its ccache dir
|
||||
HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host")
|
||||
# PlatformIO installs into its own dirs; the entry is its ccache dir itself
|
||||
@@ -41,6 +42,7 @@ TOOLS_CACHE_SPECS = (
|
||||
IDF_TOOLS_CACHE,
|
||||
SDK_NRF_TOOLS_CACHE,
|
||||
ARDUINO8266_TOOLS_CACHE,
|
||||
ZEPHYR_TOOLS_CACHE,
|
||||
HOST_TOOLS_CACHE,
|
||||
PLATFORMIO_CCACHE,
|
||||
)
|
||||
@@ -45,6 +45,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
set_cpp_standard,
|
||||
shared_progmem_array,
|
||||
shared_progmem_string,
|
||||
shared_static_function,
|
||||
statement,
|
||||
static_buffer,
|
||||
static_const_array,
|
||||
|
||||
@@ -24,8 +24,8 @@ namespace esphome::ade7880 {
|
||||
// signed 32-bit (int32_t)
|
||||
|
||||
uint8_t ADE7880::read_u8_register16_(uint16_t a_register) {
|
||||
uint8_t in;
|
||||
this->read_register16(a_register, &in, sizeof(in));
|
||||
uint8_t in{0};
|
||||
this->read_byte_reg16(a_register, &in);
|
||||
return in;
|
||||
}
|
||||
|
||||
@@ -60,9 +60,7 @@ uint32_t ADE7880::read_u32_register16_(uint16_t a_register) {
|
||||
return convert_big_endian(in);
|
||||
}
|
||||
|
||||
void ADE7880::write_u8_register16_(uint16_t a_register, uint8_t value) {
|
||||
this->write_register16(a_register, &value, sizeof(value));
|
||||
}
|
||||
void ADE7880::write_u8_register16_(uint16_t a_register, uint8_t value) { this->write_byte_reg16(a_register, value); }
|
||||
|
||||
void ADE7880::write_s10zp_register16_(uint16_t a_register, int16_t value) {
|
||||
int16_t out = convert_big_endian(value & 0x03FF);
|
||||
|
||||
@@ -11,12 +11,7 @@ void AdE7953I2c::dump_config() {
|
||||
LOG_I2C_DEVICE(this);
|
||||
ade7953_base::ADE7953::dump_config();
|
||||
}
|
||||
bool AdE7953I2c::ade_write_8(uint16_t reg, uint8_t value) {
|
||||
uint8_t data[3];
|
||||
put_be16(data, reg);
|
||||
data[2] = value;
|
||||
return this->write(data, 3) != i2c::ERROR_OK;
|
||||
}
|
||||
bool AdE7953I2c::ade_write_8(uint16_t reg, uint8_t value) { return !this->write_byte_reg16(reg, value); }
|
||||
bool AdE7953I2c::ade_write_16(uint16_t reg, uint16_t value) {
|
||||
uint8_t data[4];
|
||||
put_be16(data, reg);
|
||||
|
||||
@@ -30,7 +30,7 @@ void Alpha3::extract_publish_sensor_value_(const uint8_t *response, int16_t leng
|
||||
// offset inside current response packet
|
||||
auto rel_offset = value_offset - response_offset;
|
||||
if (rel_offset <= -value_length)
|
||||
return; // aready passed the value completly
|
||||
return; // already passed the value completely
|
||||
if (rel_offset >= length)
|
||||
return; // value not in this packet
|
||||
|
||||
@@ -86,9 +86,9 @@ void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) {
|
||||
extract_publish_sensor_value(GENI_RESPONSE_MOTOR_SPEED_OFFSET, this->speed_sensor_, 1.0F);
|
||||
extract_publish_sensor_value(GENI_RESPONSE_VOLTAGE_AC_OFFSET, this->voltage_sensor_, 1.0F);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "unkown GENI response Type %d %d %d %d %d %d %d %d", this->response_type_[0], this->response_type_[1],
|
||||
this->response_type_[2], this->response_type_[3], this->response_type_[4], this->response_type_[5],
|
||||
this->response_type_[6], this->response_type_[7]);
|
||||
ESP_LOGW(TAG, "unknown GENI response Type %d %d %d %d %d %d %d %d", this->response_type_[0],
|
||||
this->response_type_[1], this->response_type_[2], this->response_type_[3], this->response_type_[4],
|
||||
this->response_type_[5], this->response_type_[6], this->response_type_[7]);
|
||||
}
|
||||
this->response_offset_ += length;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class Animation final : public image::Image {
|
||||
|
||||
/** Selects a specific frame within the animation.
|
||||
*
|
||||
* @param frame If possitive, advance to the frame. If negative, recede to that frame from the end frame.
|
||||
* @param frame If positive, advance to the frame. If negative, recede to that frame from the end frame.
|
||||
*/
|
||||
void set_frame(int frame);
|
||||
|
||||
|
||||
@@ -467,7 +467,8 @@ message DeviceWizardRequest {
|
||||
//
|
||||
// {"version": 1,
|
||||
// "pages": [{"title": "...", "description": "...",
|
||||
// "entities": [{"key": 123, "device_id": 456, "description": "...", "role": "..."}],
|
||||
// "entities": [{"key": 123, "platform": "sensor", "device_id": 456,
|
||||
// "description": "...", "role": "..."}],
|
||||
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
// "entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
@@ -480,6 +481,10 @@ message DeviceWizardRequest {
|
||||
// An entity is an entity of this device that the page shows to the user (for example to
|
||||
// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its
|
||||
// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0.
|
||||
// Its platform is the platform of the entity (sensor, switch and so on; date, time or
|
||||
// datetime for a datetime entity). A key is only unique within its platform and device, so
|
||||
// a client matches an entity by platform, device_id and key together. A client that does
|
||||
// not know platform ignores it, so the version stays 1.
|
||||
// Its role, when set, is a stable name that the device's firmware gives the entity
|
||||
// (always <component>.<name>), so a client can recognise it. The user cannot change it,
|
||||
// and no two entities of a wizard share one.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#endif
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
@@ -2243,7 +2244,9 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes
|
||||
}
|
||||
|
||||
for (auto &it : this->parent_->get_state_subs()) {
|
||||
if (msg.entity_id != it.entity_id) {
|
||||
// The stored length rejects most entries without reading the entity id
|
||||
if (msg.entity_id.size() != it.entity_id_len ||
|
||||
memcmp(msg.entity_id.c_str(), it.entity_id, it.entity_id_len) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -4177,7 +4177,8 @@ uint8_t *InfraredRFReceiveEvent::encode_msg(const void *self, uint8_t *__restric
|
||||
#endif
|
||||
pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key);
|
||||
for (const auto &it : *msg.timings) {
|
||||
pos = ProtoEncode::encode_sint32_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it);
|
||||
pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24);
|
||||
pos = ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(it));
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#ifdef USE_API
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
#include "api_connection.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -597,6 +598,7 @@ void APIServer::add_state_subscription_(const char *entity_id, const char *attri
|
||||
.attribute = attribute,
|
||||
.callback = std::move(f),
|
||||
.once = once,
|
||||
.entity_id_len = static_cast<uint16_t>(strlen(entity_id)),
|
||||
// entity_id_dynamic_storage and attribute_dynamic_storage remain nullptr (no heap allocation)
|
||||
});
|
||||
}
|
||||
@@ -608,6 +610,7 @@ void APIServer::add_state_subscription_(std::string entity_id, optional<std::str
|
||||
// Allocate heap storage for the strings
|
||||
sub.entity_id_dynamic_storage = std::make_unique<std::string>(std::move(entity_id));
|
||||
sub.entity_id = sub.entity_id_dynamic_storage->c_str();
|
||||
sub.entity_id_len = static_cast<uint16_t>(sub.entity_id_dynamic_storage->size());
|
||||
|
||||
if (attribute.has_value()) {
|
||||
sub.attribute_dynamic_storage = std::make_unique<std::string>(std::move(attribute.value()));
|
||||
@@ -666,6 +669,15 @@ void APIServer::get_home_assistant_state(std::string entity_id, optional<std::st
|
||||
const std::vector<APIServer::HomeAssistantStateSubscription> &APIServer::get_state_subs() const {
|
||||
return this->state_subs_;
|
||||
}
|
||||
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
void APIServer::update_state_subscription_entity_id(const char *entity_id) {
|
||||
for (auto &it : this->state_subs_) {
|
||||
if (it.entity_id == entity_id)
|
||||
it.entity_id_len = static_cast<uint16_t>(strlen(entity_id));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
|
||||
@@ -241,6 +241,9 @@ class APIServer final : public Component
|
||||
const char *attribute; // Pointer to flash or nullptr (nullptr means no attribute)
|
||||
std::function<void(StringRef)> callback;
|
||||
bool once;
|
||||
// strlen(entity_id), so a state is matched without measuring every entry; fits in the padding after once.
|
||||
// Home Assistant entity ids are at most 255 bytes.
|
||||
uint16_t entity_id_len;
|
||||
|
||||
// Dynamic storage for external components using std::string API (custom_api_device.h)
|
||||
// These are only allocated when using the std::string overload (nullptr for const char* overload)
|
||||
@@ -265,6 +268,10 @@ class APIServer final : public Component
|
||||
std::function<void(const std::string &)> &&f);
|
||||
|
||||
const std::vector<HomeAssistantStateSubscription> &get_state_subs() const;
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
/// Measure again the subscriptions whose entity id is this buffer, after the wizard wrote a new id into it.
|
||||
void update_state_subscription_entity_id(const char *entity_id);
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
const std::vector<UserServiceDescriptor *> &get_user_services() const { return this->user_services_; }
|
||||
|
||||
@@ -95,6 +95,8 @@ void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg
|
||||
if (entity_id == nullptr)
|
||||
return;
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// The subscriptions to the buffer keep the length of the id it held
|
||||
this->parent_->update_state_subscription_entity_id(entity_id);
|
||||
// Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now
|
||||
for (auto &client : this->parent_->active_clients()) {
|
||||
client->resend_state_subscriptions(entity_id);
|
||||
|
||||
@@ -257,6 +257,47 @@ WIZARD_DOMAIN_LIMITED_PLATFORMS = (
|
||||
)
|
||||
|
||||
|
||||
# The C++ base class of each kind of entity a wizard page can show, and the platform an API client
|
||||
# knows it by. An entity key is only unique within its platform, so the wizard sends both.
|
||||
WIZARD_ENTITY_PLATFORMS: dict[str, str] = {
|
||||
"alarm_control_panel::AlarmControlPanel": "alarm_control_panel",
|
||||
"binary_sensor::BinarySensor": "binary_sensor",
|
||||
"button::Button": "button",
|
||||
"climate::Climate": "climate",
|
||||
"cover::Cover": "cover",
|
||||
"datetime::DateEntity": "date",
|
||||
"datetime::DateTimeEntity": "datetime",
|
||||
"datetime::TimeEntity": "time",
|
||||
"event::Event": "event",
|
||||
"fan::Fan": "fan",
|
||||
"infrared::Infrared": "infrared",
|
||||
"light::LightState": "light",
|
||||
"lock::Lock": "lock",
|
||||
"number::Number": "number",
|
||||
"radio_frequency::RadioFrequency": "radio_frequency",
|
||||
"select::Select": "select",
|
||||
"sensor::Sensor": "sensor",
|
||||
"switch_::Switch": "switch",
|
||||
"text::Text": "text",
|
||||
"text_sensor::TextSensor": "text_sensor",
|
||||
"update::UpdateEntity": "update",
|
||||
"valve::Valve": "valve",
|
||||
"water_heater::WaterHeater": "water_heater",
|
||||
}
|
||||
|
||||
|
||||
def _wizard_entity_platform(
|
||||
config: fv.FinalValidateConfig, entity_id: ID
|
||||
) -> str | None:
|
||||
"""The platform of an entity, from the C++ class it is declared with, as some entities are not
|
||||
declared under their platform (the sensors of gps:, for example)."""
|
||||
declared: ID = config.get_config_for_path(config.get_path_for_id(entity_id))
|
||||
for cls in (declared.type, *declared.type.parents):
|
||||
if (platform := WIZARD_ENTITY_PLATFORMS.get(str(cls))) is not None:
|
||||
return platform
|
||||
return None
|
||||
|
||||
|
||||
def wizard_input_ids(api_config: ConfigType) -> set[str]:
|
||||
"""The IDs of the entities that are linked inputs of the wizard in the given api config."""
|
||||
if (wizard := api_config.get(CONF_WIZARD)) is None:
|
||||
@@ -346,7 +387,8 @@ def _validate_wizard_input(conf: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
def _validate_wizard_entity_exposed(value: ID) -> ID:
|
||||
"""Reject entities that are internal, as they are not exposed over the API, or have no name.
|
||||
"""Reject entities that are internal, as they are not exposed over the API, have no name, or
|
||||
are of a platform the wizard does not know.
|
||||
|
||||
The key a client knows an entity by is a hash of its name. Without a name of its own, the
|
||||
device works the name out at runtime from its friendly name, which can add the MAC address,
|
||||
@@ -363,6 +405,10 @@ def _validate_wizard_entity_exposed(value: ID) -> ID:
|
||||
f"Entity '{value.id}' has no name of its own, so its key is not known "
|
||||
"when the wizard is built. Give it a name to use it in the wizard"
|
||||
)
|
||||
if _wizard_entity_platform(fv.full_config.get(), value) is None:
|
||||
raise cv.Invalid(
|
||||
f"Entity '{value.id}' is not of a platform the wizard can show"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@@ -431,7 +477,10 @@ def _entity_document(
|
||||
) -> ConfigType:
|
||||
"""An entity of the device that the page shows, keyed as ListEntitiesResponse keys it."""
|
||||
_, declaration = _wizard_input_declaration(config, conf[CONF_ID])
|
||||
document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])}
|
||||
document: ConfigType = {
|
||||
"key": fnv1_hash_object_id(declaration[CONF_NAME]),
|
||||
"platform": _wizard_entity_platform(config, conf[CONF_ID]),
|
||||
}
|
||||
if (device := declaration.get(CONF_DEVICE_ID)) is not None:
|
||||
document["device_id"] = fnv1a_32bit_hash(device.id)
|
||||
if description := conf.get(CONF_DESCRIPTION):
|
||||
@@ -461,7 +510,8 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
|
||||
|
||||
{"version": 1,
|
||||
"pages": [{"title": "...", "description": "...",
|
||||
"entities": [{"key": 123, "device_id": 456, "description": "...", "role": "..."}],
|
||||
"entities": [{"key": 123, "platform": "sensor", "device_id": 456,
|
||||
"description": "...", "role": "..."}],
|
||||
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
"entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
@@ -472,6 +522,10 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
|
||||
- An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the
|
||||
object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of
|
||||
the device it belongs to (esphome/core/config.py), and is left out for the main device.
|
||||
- An entity platform is the platform of the entity: sensor, switch and so on, or date, time or
|
||||
datetime for an entity of the datetime component. A key is only unique within its platform
|
||||
and device, so a client matches an entity by platform, device_id and key together. A client
|
||||
that does not know platform ignores it, so the version stays 1.
|
||||
- An entity role is a stable name, always <component>.<name>, that the component of the entity
|
||||
gives it with register_entity_role, so Home Assistant can recognise the entity. The user
|
||||
cannot set or change it, and no two entities of a wizard share one. A client that does not
|
||||
|
||||
@@ -291,10 +291,10 @@ bool AS3935Component::calibrate_oscillator() {
|
||||
// reg_val_trco &= CALIB_MASK;
|
||||
// reg_val_trco >>= 6;
|
||||
if (!reg_val_srco && !reg_val_trco) { // Zero upon success
|
||||
ESP_LOGI(TAG, "Calibration was succesful");
|
||||
ESP_LOGI(TAG, "Calibration was successful");
|
||||
return true;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Calibration was NOT succesful");
|
||||
ESP_LOGW(TAG, "Calibration was NOT successful");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +115,9 @@ bool AS7341Component::read_channels(uint16_t *data) {
|
||||
this->set_smux_low_channels(false);
|
||||
this->enable_spectral_measurement(true);
|
||||
this->wait_for_data();
|
||||
bool high_sucess = this->read_bytes_16_le(AS7341_CH0_DATA_L, &data[6], 6);
|
||||
bool high_success = this->read_bytes_16_le(AS7341_CH0_DATA_L, &data[6], 6);
|
||||
|
||||
return low_success && high_sucess;
|
||||
return low_success && high_success;
|
||||
}
|
||||
|
||||
void AS7341Component::set_smux_low_channels(bool enable) {
|
||||
@@ -146,8 +146,8 @@ void AS7341Component::configure_smux_low_channels() {
|
||||
this->write_byte(0x03, 0x00); // F8 left disabled
|
||||
this->write_byte(0x04, 0x00); // F6 left disabled
|
||||
this->write_byte(0x05, 0x42); // F4 left connected to ADC3/f2 left connected to ADC1
|
||||
this->write_byte(0x06, 0x00); // F5 left disbled
|
||||
this->write_byte(0x07, 0x00); // F7 left disbled
|
||||
this->write_byte(0x06, 0x00); // F5 left disabled
|
||||
this->write_byte(0x07, 0x00); // F7 left disabled
|
||||
this->write_byte(0x08, 0x50); // CLEAR connected to ADC4
|
||||
this->write_byte(0x09, 0x00); // F5 right disabled
|
||||
this->write_byte(0x0A, 0x00); // F7 right disabled
|
||||
|
||||
@@ -40,10 +40,14 @@ async def to_code(config: ConfigType) -> None:
|
||||
# https://github.com/ESP32Async/ESPAsyncTCP
|
||||
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")
|
||||
elif CORE.is_rp2:
|
||||
# https://github.com/ayushsharma82/RPAsyncTCP
|
||||
# RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better
|
||||
# ESPAsyncWebServer compatibility
|
||||
cg.add_library("ayushsharma82/RPAsyncTCP", "1.3.2")
|
||||
# https://github.com/esphome-libs/RPAsyncTCP, a fork of ayushsharma82/RPAsyncTCP
|
||||
# 1.3.2 that counts queued bytes before lwIP can send and ACK them, so
|
||||
# canSend() does not stay false and SSE streams do not stall (wired Ethernet)
|
||||
cg.add_library(
|
||||
"RPAsyncTCP",
|
||||
"1.3.2",
|
||||
"https://github.com/esphome-libs/RPAsyncTCP#1.3.2-esphome.1",
|
||||
)
|
||||
# Other platforms (host, etc) use socket-based implementation
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ void ATM90E26Component::setup() {
|
||||
return;
|
||||
}
|
||||
// TODO: 100 * <nominal voltage, e.g. 230> * sqrt(2) * <fraction of nominal, e.g. 0.9> / (4 * gain_voltage/32768)
|
||||
this->write16_(ATM90E26_REGISTER_SAGTH, 0x17DD); // Voltage sag threshhold 0x1F2F
|
||||
this->write16_(ATM90E26_REGISTER_SAGTH, 0x17DD); // Voltage sag threshold 0x1F2F
|
||||
|
||||
// Set metering calibration values
|
||||
this->write16_(ATM90E26_REGISTER_CALSTART, 0x5678); // CAL Metering calibration startup command
|
||||
|
||||
@@ -420,7 +420,7 @@ void ATM90E32Component::dump_config() {
|
||||
|
||||
float ATM90E32Component::get_setup_priority() const { return setup_priority::IO; }
|
||||
|
||||
// R/C registers can conly be cleared after the LastSPIData register is updated (register 78H)
|
||||
// R/C registers can only be cleared after the LastSPIData register is updated (register 78H)
|
||||
// Peakdetect period: 05H. Bit 15:8 are PeakDet_period in ms. 7:0 are Sag_period
|
||||
// Default is 143FH (20ms, 63ms)
|
||||
uint16_t ATM90E32Component::read16_(uint16_t a_register) {
|
||||
|
||||
@@ -52,7 +52,7 @@ class ATM90E32Component final : public PollingComponent,
|
||||
static const uint8_t PHASEB = 1;
|
||||
static const uint8_t PHASEC = 2;
|
||||
const char *phase_labels[3] = {"A", "B", "C"};
|
||||
// these registers are not sucessive, so we can't just do 'base + phase'
|
||||
// these registers are not successive, so we can't just do 'base + phase'
|
||||
const uint16_t voltage_gain_registers[3] = {ATM90E32_REGISTER_UGAINA, ATM90E32_REGISTER_UGAINB,
|
||||
ATM90E32_REGISTER_UGAINC};
|
||||
const uint16_t current_gain_registers[3] = {ATM90E32_REGISTER_IGAINA, ATM90E32_REGISTER_IGAINB,
|
||||
|
||||
@@ -249,7 +249,7 @@ def final_validate_audio_schema(
|
||||
audio_device_issue (bool, optional): Format the error message to indicate the problem is in the configuration for the ``audio_device`` component. Defaults to False.
|
||||
"""
|
||||
|
||||
def validate_audio_compatiblity(audio_config: ConfigType) -> ConfigType:
|
||||
def validate_audio_compatibility(audio_config: ConfigType) -> ConfigType:
|
||||
audio_schema = {}
|
||||
|
||||
if bits_per_sample is not cv.UNDEFINED:
|
||||
@@ -311,7 +311,7 @@ def final_validate_audio_schema(
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.Required(audio_device): fv.id_declaration_match_schema(
|
||||
validate_audio_compatiblity
|
||||
validate_audio_compatibility
|
||||
)
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
namespace esphome::audio {
|
||||
|
||||
class AudioStreamInfo {
|
||||
/* Class to respresent important parameters of the audio stream that also provides helper function to convert between
|
||||
/* Class to represent important parameters of the audio stream that also provides helper function to convert between
|
||||
* various audio related units.
|
||||
*
|
||||
* - An audio sample represents a unit of audio for one channel.
|
||||
|
||||
@@ -45,10 +45,10 @@ enum class AudioDecoderState : uint8_t {
|
||||
|
||||
// Only used within the AudioDecoder class; conveys the state of the particular file type decoder
|
||||
enum class FileDecoderState : uint8_t {
|
||||
MORE_TO_PROCESS, // Successsfully read a file chunk and more data is available to decode
|
||||
MORE_TO_PROCESS, // Successfully read a file chunk and more data is available to decode
|
||||
IDLE, // Not enough data to decode, waiting for more to be transferred
|
||||
POTENTIALLY_FAILED, // Decoder encountered a potentially recoverable error if more file data is available
|
||||
FAILED, // Decoder encoutnered an uncrecoverable error
|
||||
FAILED, // Decoder encountered an uncrecoverable error
|
||||
END_OF_FILE, // The specific file decoder knows its the end of the file
|
||||
};
|
||||
|
||||
@@ -75,13 +75,13 @@ class AudioDecoder {
|
||||
|
||||
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
/// @return ESP_OK if successful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
esp_err_t add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer);
|
||||
|
||||
#ifdef USE_SPEAKER
|
||||
/// @brief Adds a sink speaker for decoded audio.
|
||||
/// @param speaker pointer to speaker component
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
/// @return ESP_OK if successful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
esp_err_t add_sink(speaker::Speaker *speaker);
|
||||
#endif
|
||||
|
||||
@@ -104,7 +104,7 @@ class AudioDecoder {
|
||||
|
||||
/// @brief Decodes audio from the ring buffer source and writes to the sink.
|
||||
/// @param stop_gracefully If true, it indicates the file source is finished. The decoder will decode all the
|
||||
/// reamining data and then finish.
|
||||
/// remaining data and then finish.
|
||||
/// @return AudioDecoderState
|
||||
AudioDecoderState decode(bool stop_gracefully);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ AudioReader::~AudioReader() { this->cleanup_connection_(); }
|
||||
|
||||
esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer) {
|
||||
if (current_audio_file_ != nullptr) {
|
||||
// A transfer buffer isn't ncessary for a local file
|
||||
// A transfer buffer isn't necessary for a local file
|
||||
this->file_ring_buffer_ = output_ring_buffer.lock();
|
||||
if (this->file_ring_buffer_ == nullptr) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -278,7 +278,7 @@ AudioReaderState AudioReader::http_read_() {
|
||||
} else if (received_len <= 0) {
|
||||
// HTTP read error
|
||||
if (received_len == -1) {
|
||||
// A true connection error occured, no chance at recovery
|
||||
// A true connection error occurred, no chance at recovery
|
||||
this->cleanup_connection_();
|
||||
return AudioReaderState::FAILED;
|
||||
}
|
||||
|
||||
@@ -46,13 +46,13 @@ class AudioResampler {
|
||||
|
||||
/// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
/// @return ESP_OK if successful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
esp_err_t add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer);
|
||||
|
||||
#ifdef USE_SPEAKER
|
||||
/// @brief Adds a sink speaker for decoded audio.
|
||||
/// @param speaker pointer to speaker component
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
/// @return ESP_OK if successful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
esp_err_t add_sink(speaker::Speaker *speaker);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class AudioSinkCallback {
|
||||
|
||||
class AudioTransferBuffer {
|
||||
/*
|
||||
* @brief Class that facilitates tranferring data between a buffer and an audio source or sink.
|
||||
* @brief Class that facilitates transferring data between a buffer and an audio source or sink.
|
||||
* The transfer buffer is a typical C array that temporarily holds data for processing in other audio components.
|
||||
* Both sink and source transfer buffers can use a ring buffer as the sink/source.
|
||||
* - The ring buffer is stored in a shared_ptr, so destroying the transfer buffer object will release ownership.
|
||||
@@ -52,13 +52,13 @@ class AudioTransferBuffer {
|
||||
/// @brief Returns the transfer buffers allocated bytes
|
||||
size_t capacity() const { return this->buffer_size_; }
|
||||
|
||||
/// @brief Returns the transfer buffer's currrently free bytes available to write
|
||||
/// @brief Returns the transfer buffer's currently free bytes available to write
|
||||
size_t free() const;
|
||||
|
||||
/// @brief Clears data in the transfer buffer and, if possible, the source/sink.
|
||||
virtual void clear_buffered_data();
|
||||
|
||||
/// @brief Tests if there is any data in the tranfer buffer or the source/sink.
|
||||
/// @brief Tests if there is any data in the transfer buffer or the source/sink.
|
||||
/// @return True if there is data, false otherwise.
|
||||
virtual bool has_buffered_data() const;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
@@ -146,13 +147,10 @@ MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent")
|
||||
# Filters
|
||||
Filter = binary_sensor_ns.class_("Filter")
|
||||
TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter)
|
||||
DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter)
|
||||
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
|
||||
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
|
||||
InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter)
|
||||
AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter)
|
||||
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
|
||||
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
|
||||
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter)
|
||||
|
||||
_LOGGER = getLogger(__name__)
|
||||
@@ -165,6 +163,15 @@ def register_filter(name, filter_type, schema):
|
||||
return FILTER_REGISTRY.register(name, filter_type, schema)
|
||||
|
||||
|
||||
ApplyFilter = binary_sensor_ns.class_("ApplyFilter", Filter)
|
||||
APPLY_FILTER = automation.ApplyFilterDomain(
|
||||
FILTER_REGISTRY,
|
||||
ApplyFilter,
|
||||
cg.optional.template(cg.bool_),
|
||||
((Filter.operator("ptr"), "filter"), (cg.bool_, "x")),
|
||||
)
|
||||
|
||||
|
||||
@register_filter("invert", InvertFilter, {})
|
||||
async def invert_filter_to_code(config, filter_id):
|
||||
return cg.new_Pvariable(filter_id)
|
||||
@@ -182,38 +189,47 @@ async def timeout_filter_to_code(config, filter_id):
|
||||
return var
|
||||
|
||||
|
||||
_DELAY_EITHER = automation.ApplyCall(
|
||||
"return binary_sensor::delay_output(filter, {}, x);",
|
||||
((automation.WHOLE_CONFIG, cg.uint32),),
|
||||
)
|
||||
# The delay for the edge is picked in the generated code, so only that value is evaluated
|
||||
_DELAY_SCHEMA = cv.templatable(cv.positive_time_period_milliseconds)
|
||||
_DELAY_ON_OFF_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_TIME_ON): _DELAY_SCHEMA,
|
||||
cv.Required(CONF_TIME_OFF): _DELAY_SCHEMA,
|
||||
}
|
||||
)
|
||||
_DELAY_ON_OFF = automation.ApplyCall(
|
||||
"return binary_sensor::delay_output(filter, x ? {} : {}, x);",
|
||||
((CONF_TIME_ON, cg.uint32), (CONF_TIME_OFF, cg.uint32)),
|
||||
)
|
||||
|
||||
|
||||
@register_filter(
|
||||
"delayed_on_off",
|
||||
DelayedOnOffFilter,
|
||||
ApplyFilter,
|
||||
cv.Any(
|
||||
cv.templatable(cv.positive_time_period_milliseconds),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_TIME_ON): cv.templatable(
|
||||
cv.positive_time_period_milliseconds
|
||||
),
|
||||
cv.Required(CONF_TIME_OFF): cv.templatable(
|
||||
cv.positive_time_period_milliseconds
|
||||
),
|
||||
}
|
||||
),
|
||||
_DELAY_SCHEMA,
|
||||
_DELAY_ON_OFF_SCHEMA,
|
||||
msg="'delayed_on_off' filter requires either a delay time to be used for both "
|
||||
"turn-on and turn-off delays, or two parameters 'time_on' and 'time_off' if "
|
||||
"different delay times are required.",
|
||||
),
|
||||
)
|
||||
async def delayed_on_off_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id)
|
||||
if isinstance(config, dict):
|
||||
template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32)
|
||||
cg.add(var.set_on_delay(template_))
|
||||
template_ = await cg.templatable(config[CONF_TIME_OFF], [], cg.uint32)
|
||||
cg.add(var.set_off_delay(template_))
|
||||
else:
|
||||
template_ = await cg.templatable(config, [], cg.uint32)
|
||||
cg.add(var.set_on_delay(template_))
|
||||
cg.add(var.set_off_delay(template_))
|
||||
return var
|
||||
async def delayed_on_off_filter_to_code(config: Any, filter_id: ID) -> MockObj:
|
||||
if isinstance(config, core.Lambda):
|
||||
# One function per edge, so each keeps its own static state as before
|
||||
config = {CONF_TIME_ON: config, CONF_TIME_OFF: config}
|
||||
statement, schema = (
|
||||
(_DELAY_ON_OFF, _DELAY_ON_OFF_SCHEMA)
|
||||
if isinstance(config, dict)
|
||||
else (_DELAY_EITHER, _DELAY_SCHEMA)
|
||||
)
|
||||
return await APPLY_FILTER.build(
|
||||
"delayed_on_off", config, filter_id, statement, schema=schema
|
||||
)
|
||||
|
||||
|
||||
@register_filter(
|
||||
@@ -287,12 +303,20 @@ async def autorepeat_filter_to_code(config, filter_id):
|
||||
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
|
||||
|
||||
|
||||
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
|
||||
@register_filter("lambda", ApplyFilter, cv.returning_lambda)
|
||||
async def lambda_filter_to_code(config, filter_id):
|
||||
# The body is its own function of x, so it cannot see or clash with the filter parameter
|
||||
lambda_ = await cg.process_lambda(
|
||||
config, [(bool, "x")], return_type=cg.optional.template(bool)
|
||||
config, [(bool, "x")], return_type=APPLY_FILTER.return_type
|
||||
)
|
||||
return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter)
|
||||
fn = automation.apply_static_function(
|
||||
filter_id,
|
||||
APPLY_FILTER.return_type,
|
||||
[(cg.bool_, "x")],
|
||||
lambda_.body_lines,
|
||||
suffix="lambda",
|
||||
)
|
||||
return APPLY_FILTER.new(filter_id, [f"return {fn}(x);"])
|
||||
|
||||
|
||||
@register_filter(
|
||||
|
||||
@@ -32,21 +32,17 @@ void Filter::input(bool value) {
|
||||
}
|
||||
}
|
||||
|
||||
optional<bool> delay_output(Filter *filter, uint32_t delay, bool value) {
|
||||
App.scheduler.set_timeout(filter, delay, [filter, value]() { filter->output(value); });
|
||||
return {};
|
||||
}
|
||||
|
||||
void TimeoutFilter::input(bool value) {
|
||||
App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
|
||||
// Every input re-arms the timeout, including repeats, so this filter must not de-dup
|
||||
this->output(value);
|
||||
}
|
||||
|
||||
optional<bool> DelayedOnOffFilter::new_value(bool value) {
|
||||
if (value) {
|
||||
App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); });
|
||||
} else {
|
||||
App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
optional<bool> DelayedOnFilter::new_value(bool value) {
|
||||
if (value) {
|
||||
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); });
|
||||
|
||||
@@ -42,18 +42,6 @@ class TimeoutFilter : public Filter {
|
||||
TemplatableFn<uint32_t> timeout_delay_{};
|
||||
};
|
||||
|
||||
class DelayedOnOffFilter final : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override;
|
||||
|
||||
template<typename T> void set_on_delay(T delay) { this->on_delay_ = delay; }
|
||||
template<typename T> void set_off_delay(T delay) { this->off_delay_ = delay; }
|
||||
|
||||
protected:
|
||||
TemplatableFn<uint32_t> on_delay_{};
|
||||
TemplatableFn<uint32_t> off_delay_{};
|
||||
};
|
||||
|
||||
class DelayedOnFilter : public Filter {
|
||||
public:
|
||||
// User provided, not "= default": `new(p) DelayedOnFilter()` would zero-fill .bss that is already zero.
|
||||
@@ -136,19 +124,15 @@ class LambdaFilter : public Filter {
|
||||
std::function<optional<bool>(bool)> f_;
|
||||
};
|
||||
|
||||
/** Optimized lambda filter for stateless lambdas (no capture).
|
||||
*
|
||||
* Uses function pointer instead of std::function to reduce memory overhead.
|
||||
* Memory: 4 bytes (function pointer on 32-bit) vs 32 bytes (std::function).
|
||||
*/
|
||||
class StatelessLambdaFilter : public Filter {
|
||||
/// Codegen use only: output `value` from `filter` after `delay` ms, replacing its pending output, and return empty
|
||||
/// to stop the chain for now.
|
||||
optional<bool> delay_output(Filter *filter, uint32_t delay, bool value);
|
||||
|
||||
/// Codegen use only: the filter's work and constants are one generated function, so the object is only the base.
|
||||
/// The function gets the filter, so it can schedule output on it.
|
||||
template<auto Fn> class ApplyFilter final : public Filter {
|
||||
public:
|
||||
explicit StatelessLambdaFilter(optional<bool> (*f)(bool)) : f_(f) {}
|
||||
|
||||
optional<bool> new_value(bool value) override { return this->f_(value); }
|
||||
|
||||
protected:
|
||||
optional<bool> (*f_)(bool);
|
||||
optional<bool> new_value(bool value) override { return Fn(this, value); }
|
||||
};
|
||||
|
||||
class SettleFilter : public Filter {
|
||||
|
||||
@@ -81,7 +81,7 @@ void BinarySensorMap::process_bayesian_() {
|
||||
float posterior_probability = this->bayesian_prior_;
|
||||
uint64_t mask = 0x00;
|
||||
|
||||
// - compute the posterior probability by taking the product of the predicate probablities for each observation
|
||||
// - compute the posterior probability by taking the product of the predicate probabilities for each observation
|
||||
// - create a bitmask for the binary_sensor states on all channels/observations
|
||||
for (size_t i = 0; i < this->channels_.size(); i++) {
|
||||
auto bs = this->channels_[i];
|
||||
|
||||
@@ -31,7 +31,6 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_INTERVAL,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
|
||||
@@ -47,9 +46,6 @@ bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
|
||||
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
|
||||
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
|
||||
)
|
||||
|
||||
StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action)
|
||||
|
||||
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
|
||||
|
||||
|
||||
@@ -71,32 +67,9 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"bk72xx_ble_tracker.start_scan",
|
||||
StartScanAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
|
||||
# Optional with no default, unlike esp32_ble_tracker: omitting it
|
||||
# keeps whatever scan_parameters.continuous configured, instead of
|
||||
# silently forcing one-shot.
|
||||
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
ble_automation.register_start_scan_action(
|
||||
"bk72xx_ble_tracker.start_scan", BK72xxBLETracker
|
||||
)
|
||||
async def start_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
|
||||
template_ = await cg.templatable(continuous, args, cg.bool_)
|
||||
cg.add(var.set_continuous(template_))
|
||||
return var
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Automation triggers and actions for bk72xx_ble_tracker: triggers are the
|
||||
// neutral ble_device_base classes; only the scan-control actions are
|
||||
// platform-specific.
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
|
||||
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(bool, continuous)
|
||||
void play(const Ts &...x) override {
|
||||
// With continuous: set, the action wins. Without it, the configured value
|
||||
// is used - stop_scan() clears the runtime flag permanently, so a bare
|
||||
// stop_scan/start_scan pair would otherwise never resume continuous mode.
|
||||
const bool want =
|
||||
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
|
||||
if (this->parent_->scan_running()) {
|
||||
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
|
||||
// the duration window here would let a repeated action keep a one-shot
|
||||
// scan alive forever. A real mode switch re-anchors so a change to
|
||||
// one-shot runs a full duration from now.
|
||||
if (want != this->parent_->scan_continuous()) {
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->restart_scan_duration();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->start_scan();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -92,7 +92,7 @@ void BL0906::advance_stage_() {
|
||||
|
||||
void BL0906::setup() {
|
||||
this->discard_available();
|
||||
this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE));
|
||||
this->write_array(USR_WRPROT_WRITABLE, sizeof(USR_WRPROT_WRITABLE));
|
||||
// Calibration (1: register address; 2: value before calibration; 3: value after calibration)
|
||||
this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1
|
||||
this->bias_correction_(BL0906_RMSOS_2, 0.01500, 0);
|
||||
|
||||
@@ -33,7 +33,7 @@ static const float BL0906_KI = 12875 * 1 * (5.1 + 5.1) * 1000 / 2000 / 1.097;
|
||||
static const float BL0906_KP = 40.4125 * ((5.1 + 5.1) * 1000 / 2000) * 1 * 100 * 1 * 1000 / 1.097 / 1.097 /
|
||||
(20000 + 20000 + 20000 + 20000 + 20000);
|
||||
|
||||
static const uint8_t USR_WRPROT_WITABLE[6] = {0xCA, 0x9E, 0x55, 0x55, 0x00, 0xB7};
|
||||
static const uint8_t USR_WRPROT_WRITABLE[6] = {0xCA, 0x9E, 0x55, 0x55, 0x00, 0xB7};
|
||||
static const uint8_t USR_WRPROT_ONLYREAD[6] = {0xCA, 0x9E, 0x00, 0x00, 0x00, 0x61};
|
||||
|
||||
static const uint8_t BL0906_READ_COMMAND = 0x35;
|
||||
|
||||
@@ -10,17 +10,18 @@ ESPHOME_LOG_TAG(TAG, "bl0939");
|
||||
|
||||
// https://www.belling.com.cn/media/file_object/bel_product/BL0939/datasheet/BL0939_V1.2_cn.pdf
|
||||
// (unfortunately chinese, but the protocol can be understood with some translation tool)
|
||||
static const uint8_t BL0939_READ_COMMAND = 0x55; // 0x5{A4,A3,A2,A1}
|
||||
static const uint8_t BL0939_FULL_PACKET = 0xAA;
|
||||
static const uint8_t BL0939_PACKET_HEADER = 0x55;
|
||||
static constexpr uint8_t BL0939_READ_COMMAND = 0x55; // 0x5{A4,A3,A2,A1}
|
||||
static constexpr uint8_t BL0939_FULL_PACKET = 0xAA;
|
||||
static constexpr uint8_t BL0939_PACKET_HEADER = 0x55;
|
||||
|
||||
static const uint8_t BL0939_WRITE_COMMAND = 0xA5; // 0xA{A4,A3,A2,A1}
|
||||
static const uint8_t BL0939_REG_IA_FAST_RMS_CTRL = 0x10;
|
||||
static const uint8_t BL0939_REG_IB_FAST_RMS_CTRL = 0x1E;
|
||||
static const uint8_t BL0939_REG_MODE = 0x18;
|
||||
static const uint8_t BL0939_REG_SOFT_RESET = 0x19;
|
||||
static const uint8_t BL0939_REG_USR_WRPROT = 0x1A;
|
||||
static const uint8_t BL0939_REG_TPS_CTRL = 0x1B;
|
||||
static constexpr uint8_t BL0939_WRITE_COMMAND = 0xA5; // 0xA{A4,A3,A2,A1}
|
||||
static constexpr uint8_t BL0939_REG_IA_FAST_RMS_CTRL = 0x10;
|
||||
static constexpr uint8_t BL0939_REG_IB_FAST_RMS_CTRL = 0x1E;
|
||||
static constexpr uint8_t BL0939_REG_WA_CREEP = 0x17;
|
||||
static constexpr uint8_t BL0939_REG_MODE = 0x18;
|
||||
static constexpr uint8_t BL0939_REG_SOFT_RESET = 0x19;
|
||||
static constexpr uint8_t BL0939_REG_USR_WRPROT = 0x1A;
|
||||
static constexpr uint8_t BL0939_REG_TPS_CTRL = 0x1B;
|
||||
|
||||
static constexpr uint8_t BL0939_INIT[6][6] PROGMEM = {
|
||||
// Reset to default
|
||||
@@ -74,6 +75,12 @@ void BL0939::setup() {
|
||||
this->write_array(cmd, sizeof(cmd));
|
||||
delay(1);
|
||||
}
|
||||
if (this->anti_creep_threshold_ != BL0939_DEFAULT_ANTI_CREEP_THRESHOLD) {
|
||||
uint8_t creep_cmd[6] = {BL0939_WRITE_COMMAND, BL0939_REG_WA_CREEP, this->anti_creep_threshold_, 0x00, 0x00, 0x00};
|
||||
creep_cmd[5] = sum_bytes(creep_cmd, sizeof(creep_cmd) - 1) ^ 0xFF;
|
||||
this->write_array(creep_cmd, sizeof(creep_cmd));
|
||||
delay(1);
|
||||
}
|
||||
this->flush();
|
||||
}
|
||||
|
||||
@@ -119,9 +126,11 @@ void BL0939::received_package_(const DataPacket *data) const {
|
||||
void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity)
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BL0939:\n"
|
||||
" Mode: %s",
|
||||
" Mode: %s\n"
|
||||
" Anti-creep threshold: %u",
|
||||
this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer")
|
||||
: LOG_STR_LITERAL("direct"));
|
||||
: LOG_STR_LITERAL("direct"),
|
||||
this->anti_creep_threshold_);
|
||||
LOG_SENSOR("", "Voltage", this->voltage_sensor_);
|
||||
LOG_SENSOR("", "Current 1", this->current_1_sensor_);
|
||||
LOG_SENSOR("", "Current 2", this->current_2_sensor_);
|
||||
|
||||
Loaded 100 of 697 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user