Compare commits

..
Author SHA1 Message Date
J. Nick Koston d9b1ee34b0 Contain oi_server paths, merge the JPEG driver into pngdrive.py and ship the uart_sim run files 2026-10-23 20:18:02 -10:00
J. Nick Koston fd450bb6a7 [ci] Add UART simulation, online_image decode, http_request action, MQTT MAC suffix and other recent flows to the hardware-test skill 2026-10-23 19:31:52 -10:00
J. Nick Koston acb89a8676 [duco] Use the shared sntp_time id in the component test (#21548) 2026-10-23 19:03:42 -10:00
Keith BurzinskiandClaude Opus 5.5 08d231991b [core] Tighten SBOM source detection for local installs (#21529)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 22:55:48 -05:00
253db1e0d5 [hitachi_ac344] Add quiet and vertical swing mode (#18229)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 17:50:48 -10:00
J. Nick Koston bb07d04c90 [tests] Drive the clock in the UDP OTA no-progress test (#21192) 2026-10-23 17:41:33 -10:00
3c1e53628f [hitachi_ac344] Add mildew-proof switch (#18230)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 17:32:27 -10:00
d515e8b2e3 [sim800l] Add on_dtmf_received trigger (#21495)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 17:31:36 -10:00
Keith BurzinskiandClaude Opus 5.5 49d4b83dd2 [core] Add esphome.project.sbom for SBOM supplier and contact (#21455)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 22:29:53 -05:00
Jonathan Swoboda e04dbc1881 [tests] Exercise unused options on core entity actions (#21532) 2026-10-23 22:54:18 -04:00
Jonathan Swoboda 4c62713eb2 [alarm_control_panel] Reject a code on the pending and triggered actions (#21539) 2026-10-23 22:46:33 -04:00
J. Nick Koston b3633fe53c [text_sensor] Publish PROGMEM strings without copying them at the call site (#21536) 2026-10-23 16:40:25 -10:00
b6fdccfc65 [mqtt] Honor publish_nan_as_none for climate target states (#20598)
Co-authored-by: Javier Carrasco Reyes <javier.carrasco@brightstarlottery.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 16:37:22 -10:00
Jonathan Swoboda 1a11424475 [tests] Cover remaining untested triggers and lvgl update actions (#21530) 2026-10-23 22:32:32 -04:00
J. Nick Koston f35395b584 [mqtt] Rebuild subscription topics when needed instead of storing them (#20762) 2026-10-24 02:12:06 +00:00
Jonathan Swoboda 6d110fd981 [lvgl] Skip the creation handler when updating widgets without update options (#21535) 2026-10-23 22:03:52 -04:00
J. Nick Koston 7c69d1b63f [gpio_expander] Move eight expanders onto the shared interrupt and loop base (#21469) 2026-10-23 15:59:18 -10:00
J. Nick Koston 1546ff022c [gpio_expander] Add a shared component base for cached expanders with an interrupt pin (#21468) 2026-10-23 15:59:18 -10:00
mrtoy-meandClaude Opus 5.5 c2911a51bd [tas58xx] Add an activate switch (#21458)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:19:12 -10:00
J. Nick Koston db16d86ee2 [mqtt] Reject subscribed topics longer than the ESP8266 and LibreTiny MQTT client accepts (#20768) 2026-10-23 15:16:12 -10:00
b401a8bf17 [toshiba] Add support for RAS-25J2KVSG-ND (#21413)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-23 14:49:19 -10:00
minimicro34 051978212c [esp32_ble] Ignore unhandled BLE key GAP events (#21457) 2026-10-23 14:01:16 -10:00
Keith BurzinskiandClaude Opus 5.5 971035d6d1 [core] Write esp-idf-sbom manifests into ESP-IDF builds (#21454)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 18:53:10 -05:00
b90e1e43a3 [tuya] fan optimistic mode (#18525)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 13:52:15 -10:00
Jonathan Swoboda fc6accb8d9 [core] Declare component defines missing from defines.h so clang-tidy parses the code behind them (#21521) 2026-10-23 19:45:14 -04:00
Jonathan Swoboda ade01b2c71 [midea] Clamp the follow-me temperature before rounding (#21524) 2026-10-23 19:13:33 -04:00
Jonathan Swoboda b415f86847 [lvgl] Declare the log callback's strchr result as a pointer (#21523) 2026-10-23 13:11:00 -10:00
Jonathan Swoboda 8fa9932033 [ota] Name the protected partition table method per convention and mark the cache statics (#21525) 2026-10-23 13:10:26 -10:00
Jonathan Swoboda c464ef0cc4 [safe_mode] Avoid a duplicated branch when partition access is enabled (#21526) 2026-10-23 13:09:52 -10:00
J. Nick Koston 6f091e4cf2 [light] Use the shared LED strip helpers in five addressable drivers (#21447) 2026-10-23 13:05:11 -10:00
J. Nick Koston 4a9409e299 [light] Add shared helpers for addressable LED strip drivers (#21445) 2026-10-23 13:05:11 -10:00
J. Nick Koston 53357c92f8 [light] Keep strobe and color wipe colors in flash tables (#20222) 2026-10-23 13:04:50 -10:00
J. Nick Koston ed882741bc [remote_base] Let BitTimings vary the mark per bit and send the space first (#21511) 2026-10-23 13:03:05 -10:00
Jonathan Swoboda f994fbde04 [esphome] Reject the esphome OTA platform on targets without an OTA backend (#21515) 2026-10-23 18:51:12 -04:00
Jonathan Swoboda a72adc7573 [libretiny_pwm] Reject LN882H at config time (#21498) 2026-10-23 18:23:35 -04:00
Jonathan Swoboda 55a62993c8 [ezo_pmp] Guard entity pointer uses so the hub builds without sensors (#21516) 2026-10-23 11:56:03 -10:00
Jonathan Swoboda 31a43f13d7 [ecocomfort2] Only compile the fan when a fan platform is configured (#21517) 2026-10-23 11:55:42 -10:00
3c06d41c6a [modem] Add PPPoS networking over a UART cellular modem on ESP32 (#6721)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: Rapsssito <contact@rodrigomartin.dev>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 21:38:24 +00:00
Jonathan SwobodaandJ. Nick Koston 3be29eab35 [tests] Cover untested entity platforms, automations and platform-gated code (#21506)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-23 17:14:21 -04:00
J. Nick Koston 202087f919 [tests] Share the sntp id in the delta_wallbox test so it groups with other sntp users (#21510) 2026-10-23 10:53:19 -10:00
9ed59c29e2 [delta_wallbox] Delta AC wallbox (#21231)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 10:32:09 -10:00
dependabot[bot] d9548d142c Bump setuptools from 84.0.0 to 84.2.1 (#21499)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 10:30:41 -10:00
dependabot[bot] 34ef64186e Bump j178/prek-action from 3.0.2 to 3.1.0 (#21500)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 10:30:31 -10:00
Jonathan Swoboda e8a8d6cf95 [tests] Rename test files the runner never matched and drop orphaned common files (#21504) 2026-10-23 16:26:24 -04:00
Jonathan Swoboda 1babcbf371 [pn7160_i2c] Fix test configuring pn7150_i2c instead of pn7160_i2c (#21502) 2026-10-23 16:24:38 -04:00
Jonathan Swoboda f9ca09ab30 [libretiny_pwm] Accept frequency units in set_frequency action (#21497) 2026-10-23 16:22:53 -04:00
Jonathan Swoboda ff56667929 [ads1118][libretiny_pwm][statsd] Add missing component tests and fix statsd test dir name (#21496) 2026-10-23 16:22:23 -04:00
J. Nick Kostonandpre-commit-ci-lite[bot] ebdd9fe07e [sensor] Defer integration imports out of module scope (#18353)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-23 10:16:49 -10:00
J. Nick Koston 5a4025ee12 [output] Reject two outputs on the same channel of a hub (#21467) 2026-10-23 10:11:26 -10:00
J. Nick Koston 2b024114cf [core] Use the final-validate platform helpers in components (#21465) 2026-10-23 09:36:59 -10:00
J. Nick Koston a1316e1bfa [core] Resolve version-less library dependencies like PlatformIO (#21317) 2026-10-23 09:36:16 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] 9beb41e2ff [binary_sensor] Defer integration imports out of module scope (#18337)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-23 09:34:39 -10:00
J. Nick Koston b9f973ba7f [ci] Add the store_yaml, http_request update, modem and BLE MIDI flows to the hardware-test skill (#21414) 2026-10-23 09:31:24 -10:00
dependabot[bot] 3d42180dcb Bump pypa/gh-action-pypi-publish from 1.14.2 to 1.14.3 (#21501)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 09:29:58 -10:00
J. Nick KostonandJonathan Swoboda 8262e336cc [tests] Use subscribe_after_initial_states and next_state in the integration tests (#21438)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-23 14:36:14 -04:00
J. Nick Koston 616100803f [core] Gate ESP32 features on the ESP-IDF version, not the framework (#21415) 2026-10-23 14:28:03 -04:00
J. Nick Koston 8fb9dba023 [tests] Use LineWaiter expect and count in the remaining integration tests (#21439) 2026-10-23 14:26:19 -04:00
J. Nick Koston c57a2b9f73 [tests] Name the I2C bus in two relay test configs so they group with multiplexers (#21485) 2026-10-23 14:16:30 -04:00
J. Nick Koston 9f2be8f195 [weikai] Use StaticRingBuffer for the receive buffer (#21478) 2026-10-23 14:16:13 -04:00
J. Nick Koston aa6c06ac03 [core] Add final-validate helpers for a platform's entries and unique keys (#21462) 2026-10-23 14:15:46 -04:00
2caf6eb6d0 [voice_assistant] Wait for microphone to stop before speaking response (#21380)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Kevin Ahrendt <kahrendt@gmail.com>
2026-10-23 11:09:29 -04:00
Bonne Eggleston 5f90fb501b [epaper_spi] Skip the refresh when the frame is unchanged (#20333) 2026-10-23 16:44:37 +11:00
J. Nick Koston dfe87daa40 [components] Set or clear GPIO expander bits with assign_bits (#21185) 2026-10-22 19:12:07 -10:00
J. Nick Koston 29cb1e9039 [components] Set or clear display, audio and climate bits with assign_bits (#21187) 2026-10-22 19:10:55 -10:00
J. Nick Koston 831f10fdeb [st7305] Put model defaults in the per-model schema (#21436) 2026-10-22 19:04:36 -10:00
Ryan Schlesinger 8a2358d0e6 [max170xx] Shared helper; retry setup and fix sleep for max17043 and max17048 (#18594) 2026-10-22 18:49:50 -10:00
Pieter Kokx f416e1edd4 [duco] Component for Duco ventilation system (#7993) 2026-10-22 17:09:05 -10:00
8156de9613 [packet_interface] Add packet interface component (#21037)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 13:28:03 +11:00
Jonathan Swoboda 89c2cdb592 [core][hmac_sha256] Compare digests in constant time (#21443) 2026-10-22 22:24:30 -04:00
Jonathan Swoboda cb57148178 [syslog] Redact LOG_SECRET values before sending a message (#21444) 2026-10-22 22:13:30 -04:00
623 changed files with 20347 additions and 4772 deletions

No files matched your search

+8 -3
View File
@@ -111,11 +111,11 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
or exits 1 when a build, flash or port step failed.
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(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 and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards), `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).
## Templates in `$SKILL/templates`
@@ -132,7 +132,12 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`.
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`,
`online-image-s3-eth-dut.yaml`,
and the directories `store_yaml/` (package with secrets one directory up, per-board files),
`http_request_update/` (manifest URL setter shapes, per-board files), `modem/` (modem only and modem plus
Wi-Fi boot checks), `ble_midi/` (fake BLE MIDI instrument and the client), `http_request_action/` (request
action package with per-board files) and `uart_sim/` (simulator header, common package and per-build parts).
## When something looks wrong
@@ -50,6 +50,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
while Wi-Fi is off).
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
- A board left in the ROM bootloader after an upload (BOOT held, or the USB JTAG reset landing in download mode
again) starts its app without a replug through `esptool --port <port> --after watchdog-reset chip-id`; flash a
minimal Wi-Fi plus API image first when recovering from a crash-looping one.
## RP2040 / RP2350 (Pico W, Pico 2 W)
@@ -83,6 +86,8 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
Fault dumps print PC, LR and R0 to R3.
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
- The `rtw_is_adapter_up` bus fault after `Disabling AP` happens on LibreTiny 1.13.0 and 1.14.0 alike
(2 of 4 and 3 of 3 runs in #21531); run at least four rounds per build before blaming a PR for it.
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
@@ -113,6 +118,13 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception
handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit`
placed there after `.itcm.code` works for a crash record.
- Pick the board that matches the module's flash layout. A NiceMCU XH-WB3S (BK7238) needs `board: xh-wb3s` (plain
Beken layout, RF calibration at 0x1E0000). Built as `generic-bk7238-tuya` (calibration at 0x1E3000, download
partition over 0x1E0000) the firmware boots and may even join a known network, but scans come back empty and its
AP is invisible to clients, with no warning in the log. If scans are empty, check the layout first.
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
@@ -4,17 +4,20 @@ Each flow names the pull requests it was used on, so their result tables show wh
One file per component, named after it:
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client, store_yaml recovery over Noise or plaintext
- [`ble_midi.md`](ble_midi.md): BLE MIDI client against a fake ESP32 instrument, every packet shape and the send actions
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
@@ -31,3 +34,6 @@ One file per component, named after it:
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
@@ -40,3 +40,19 @@ length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSIN
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
## store_yaml recovery over the native API (#16445)
`scripts/api/syrecover.py HOST KEY OUTDIR [ORIGDIR]` (KEY `-` for plaintext) builds the client library's
`APINoiseFrameHelper` around a stub `APIConnection` subclass (the compiled helper type checks the connection, and
`ConnectionParams` needs every field), sends Hello, Authentication (never answered without a password) and
`DeviceCapabilitiesRequest`, then `GetYamlRequest` (161), collects `GetYamlResponse` (162) frames by field number
(1 data, 2 done, 3 total_size, 4 encoding) until done, requests again, decompresses with `compression.zstd` (Python 3.14, or the `zstandard` package) and
unpacks the envelope with the tree's `unpack_envelope` (run with `PYTHONPATH` on that tree). Configs in
`templates/store_yaml/`: entry files in `boards/`, a package one directory above them with `!secret` and an inline
OTA password,
so the redaction and the `..` paths are exercised. Repetitive YAML compresses into one chunk even at 22 KB; a 6 KB
include of random base64 lines with `include_secrets: true` gives four chunks (1390 byte packets). Rough edge at
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
@@ -0,0 +1,19 @@
# ble_midi
## BLE MIDI client against a fake instrument (#18508)
No MIDI hardware needed: `templates/ble_midi/midiperi.yaml` turns a second ESP32 (an S3 with ethernet, so the
API stays reachable while BLE is busy) into a BLE MIDI peripheral with `esp32_ble_server`: the MIDI service
advertised, the MIDI characteristic with read, write, write without response and notify, an `on_write` that logs
the bytes, buttons that notify hand built packets (channel messages, two messages in one packet, running status,
SysEx in one packet, split over two, oversize, cut short, clock, aftertouch, orphan data bytes, a 50 packet burst)
and a restart button for the disconnect test. `midicli.yaml` is the component test config on a classic devkit with
`wifi:`, `api:`, the peripheral's BLE address in the `peri_mac` substitution, given on the command line with
`-s peri_mac <mac>` (the base MAC plus 2, printed by `esp32_ble` in `dump_config`), `on_sysex` and `on_message` lambdas that print the bytes, debug sensors and out of range send
buttons. Drive both over the plaintext API with `scripts/modem/mdrive.py HOST LOGFILE press:NAME wait:S` 3 s
apart and compare the peripheral's `TX` and `RX` lines with the client's decoded lines. Gotcha:
`ble_server.characteristic.set_value` latches the value of its first play and re-applies it before every notify,
so the peripheral script sets the value from a lambda (`id(midi_io).set_value(std::vector<uint8_t>(pkt));
id(midi_io).notify();`). At VERBOSE the client logs four lines per message, so a burst shows an
`esp32_ble took a long time` of a couple of seconds that is the serial port, not the parser.
Results: https://github.com/esphome/esphome/pull/18508#issuecomment-6348272717
@@ -0,0 +1,13 @@
# GPIO expanders
## Interrupt driven base class (#21469, #21468)
Any device with a PCF8574 input expander whose INT line goes to an ESP32 GPIO works; the inputs should change
during the test (end switches on a gate or cover are ideal). Before flashing, check with `nm` that
`gpio_expander::interrupt_pin_isr` lands in IRAM (0x4008xxxx on a classic ESP32). Then confirm `dump_config` lists
the interrupt pin and the input states match reality, and that the `pcf8574` entry is absent from the runtime stats
while idle (its loop is disabled). Drive the inputs (for a cover, `cover_command(position=1.0)` and then `0.0` over
the API, waiting for each end state): every input edge should log, `pcf8574` should show a small count in that
minute's stats and vanish in the next. For a device that cannot be reached physically, boot the same code on a
bench board first and keep a merge base image built as the fallback; an ESP32 bootloader without rollback support
leaves only `safe_mode` to recover a bad image.
@@ -0,0 +1,36 @@
# http_request
## Update manifest URL changed at run time (#21373)
`scripts/http_request/manifest_server.py PORT [DELAY]` serves `stable.json`, `beta.json` and `slow.json` (answered
after DELAY s) with both chip families and logs every request. `templates/http_request_update/upd-common.yaml`
(secret `manifest_base` is the server URL) has the update entity on the stable manifest, buttons for every
`set_source_url` shape (`std::string` local, literal, temporary, `.c_str()` of a local, the current URL back in),
`Check Then Swap` (ESP32 task path: check the slow manifest and replace the URL in the same lambda), `Arm Swap In
Check` plus a `logger: on_message` lambda that replaces the URL from inside the inline check (ESP8266; the log line
from inside `on_message` is swallowed, judge by the next check's result), and a `Heap` button. `upd-base-common.yaml`
keeps only the shapes that compile on a tree with the bare pointer setter (for the base build, copy a board file
and point its `common` include at `../upd-base-common.yaml` with a different `name:`); `boards/upd8266.yaml` and `boards/upd32.yaml` are the
per-board files, one directory below the package their `../` include points at. Drive with `scripts/http_request/updrive.py HOST KEY LOGFILE press:NAME wait:S`, which prints every
update state (latest version, summary). The ESP8266 Arduino client gets `Error reading manifest: -1` on roughly a
quarter of the fetches from this Python server on every build, so count checks against the base before blaming a
PR. `http_request` on the ESP8266 needs `verify_ssl: false` even for plain http URLs.
Results: https://github.com/esphome/esphome/pull/21373#issuecomment-6337544933
## Non-blocking request actions (#21486)
`scripts/http_request/hsrv.py PORT` serves `/fast`, `/slow?d=S`, `/big` and `/chunked` (`n` bytes at `rate` B/s,
deterministic bytes `(i*7+3)&0xFF`), `/post` (echoes length and byte sum), `/redirect` and `/hang`.
`templates/http_request_action/hr-common.yaml` (substitutions `name`, `bufsize`; per-board files `hr32.yaml` and
`hr66.yaml`) has API actions `req_get`, `req_post`, `burst` (N runs of a `mode: parallel` script on one action),
`cancel` (`mode: single` script run twice, then `script.stop`) and `reboot_during`, and logs
`RESULT n status len sum took maxgap`, where maxgap is the longest main loop gap during the request (an
`interval: 1ms` lambda plus the time since its last pass, so a fully blocking request counts too). Drive with
`scripts/http_request/hrdrive.py IP http://HOST:PORT LABEL [https]` (`ONLY=case,case` and `ROUNDS=n` env; the
`cross` case runs two actions on one component). `burstonly.py IP URL N` checks concurrency against the server's
completion times, `reboottest.py IP URL` times `App.safe_reboot()` with requests in flight, `updcheck.py IP LABEL N`
runs N update checks over the API (for real device configs that use `update: platform: http_request`) and
`otadrive.py` triggers `ota.http_request.flash` (the API variable is `sum`; `md5` is a reserved variable name).
`execute_service` must be awaited on current aioesphomeapi or nothing is sent. Keep the ESP32 `bufsize` at 32 KB or
less: the action copies the body into a `std::string` while the buffer is still held, and 80 KB aborts with out of
memory on dev and on the PR alike. `http_request` refuses BK72xx and RTL87xx at validation.
@@ -0,0 +1,10 @@
# Addressable LED strips (#21447, #21445)
- To exercise a failed strip allocation, use a classic ESP32 without PSRAM with `num_leds: 65535` and
`channel_colors: GRBW` (262 kB, bigger than any internal block). `num_leds` is uint16 in the drivers, larger
values wrap. On dev such a strip boot-loops the board.
- Never force allocation failures by hogging RAM instead: Wi-Fi then aborts in `operator new` or the loop task
trips the task watchdog.
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
add effects; restore its own config afterwards.
@@ -0,0 +1,18 @@
# modem
## PPPoS modem component with no modem attached (#6721)
A boot check on a classic ESP32 devkit with the modem UART pins left open. `templates/modem/modem-common.yaml` is
the package (`api:`, debug `loop_time` and `free` sensors, uptime, buttons for `modem.enable`, `modem.disable` and
`modem.send_at`, a 20 s alive log); `modemo.yaml` is the modem only build with `reboot_timeout: 2min` and the
component test config's `on_boot` block at `priority: -100` (earlier, `modem.send_at` runs before the modem's
`setup()` and logs `Modem DCE not ready`); `modemw.yaml` adds `wifi:` with `reboot_timeout: 0s` so the API stays
reachable. Expect `Modem not responding at 115200 baud` once per pass with each pass blocking the loop about 1 s,
the reboot at the configured timeout (`SW_CPU_RESET`), and a `modem.disable` with no modem answering to block
the loop about 37 s (`Failed to leave data mode`, then the radio off timeout) without tripping the 30 s watchdog
floor because the component feeds it in between. Drive the Wi-Fi build with
`scripts/modem/mdrive.py HOST LOGFILE list press:NAME wait:S` (plaintext API; prints sensor and text sensor
states, saves the device log). Before 8cc8df4a1f a modem only config with `api:` failed validation because a
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
@@ -51,3 +51,15 @@ context rejects a CA that lacks a `keyUsage` extension (`CA cert does not includ
on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one
`Last esp-tls error: 0x8008` on every build, TLS or not. Results:
https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827
## MAC suffix topics (#9315)
`scripts/mqtt/mac_suffix/run.sh X BUILD` flashes variant X (a defaults, b custom `topic_prefix`, c explicit topics,
d no suffix) of BUILD (`pr` or `base`) on an ESP32 and a D1 mini, records the broker with
`mosquitto_sub -F '@H:@M:@S r=%r %t %p'`, presses Restart through the `cmd_t` found in the discovery payloads, then
holds both chips in the bootloader for 40 s (`esptool --after no-reset chip-id`) so the broker sends the will; a
plain reset only gives "session taken over" and no will. The two boards' configs live in separate folders because
variants a to c share a `name` (the MAC suffix tells them apart), and the same name in one folder shares a build
directory. A D1 mini CH340 can garble the serial capture after an RTS reset, so read its topics from the broker.
`esphome logs --device MQTT` on a current tree needs paho-mqtt 2.x; an older venv with 1.6.1 fails, so use a venv
made from the tree's requirements.txt. A Restart press never delivered the shutdown message on any build tested.
@@ -18,3 +18,47 @@ ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
A case expected as `any` passes on `finished` or `error`, never on no outcome.
## PNG decode time limit (#21364, #21391)
The same server also serves any plain file in its www directory by name (404 for a missing file or a path
outside it). `scripts/online_image/make_big_pngs.py <www>` (after `make_images.py`) writes a 320x240 gradient (618 B, also as `ok.png` so the misbehaviour cases carry a
real multi-row PNG), a flat 640x480 PNG (1.6 KB for 307200 pixels), 231 KB of noise and a JPEG. The device is
`templates/online-image-dut.yaml` with `type: BINARY` on the main image (a 640x480 RGB565 does not fit a classic
ESP32 without PSRAM) and `debug` loop time and heap sensors at 2 s; the 256 byte buffer image stays RGB565, so do
not point big images at it (out of memory on every build). `scripts/online_image/pngdrive.py HOST KEY LOGFILE
BASEURL LABEL` (default case set `png`; `CASES=short` for the six case list) calls the actions, waits for the
finished or error line and reports the case time, the highest loop time and the lowest free heap. The per pass `online_image.image took a long time` values come from
the serial capture and only print on a new maximum, so read the first fetch of each image. The pass that decodes a
whole buffer is the decoder cost (before the change 510 ms gradient and 1980 ms flat, after it 77 and 140 ms); for
the noisy PNG the longest pass is the blocking 64 KB read on both builds (0.7 to 3.7 s). Alternate PR, base, PR
for a timing claim.
For a replacement PR, compare against the parent PR head, not dev. `CASES=noise` fetches the noisy PNG three times
(run it right after a boot, alternating builds); `CASES=end` runs flat and gradient as plain, `noclen_`, `chunked_`,
`half_` and `stall_` through the prefixed server cases below.
## JPEG decoded as it downloads (#21342)
`oi_server.py` serves prefixed cases on any file: `half_<file>` (first half, matching Content-Length),
`noclen_<file>` (close delimited), `chunked_<file>` (1 KB chunks), `stall_<file>` (a third, then the socket held
40 s). `scripts/online_image/make_jpegs.py <www>` writes noise, gradient, 12 KB EXIF and progressive JPEGs. Device:
`templates/online-image-dut.yaml` with `type: BINARY` on both entities and `buffer_size: 4096` on the second;
`CASES=jpeg scripts/online_image/pngdrive.py HOST KEY LOGFILE BASEURL LABEL` runs the JPEG list; every case set
prints case time, highest loop time and lowest free heap. Before the change a JPEG needs a contiguous block of its file size (233 KB never
allocates on a classic ESP32, and after a few failures 69 KB does not either until a reboot); after it the 64 KB
buffer stays 64 KB and 4 KB is enough for a baseline JPEG. The first pass still reads up to `buffer_size` in one
blocking read, so the 64 KB entity blocks 1.7 to 4 s and the 4 KB one 272 ms at most; judge the decode budget on
the small buffer entity. Already on dev: a JPEG without Content-Length (plain or chunked) ends with `Could not open
image for decoding: 4` then `Invalid type`.
## BMP and QOI decode time limit (#21392)
Use a wired board (`templates/online-image-s3-eth-dut.yaml`: Waveshare ESP32-S3-ETH, W5500, PSRAM, `type: RGB565`)
rather than a classic ESP32 on Wi-Fi: with a weak link the reads block up to the 4.5 s timeout and the per case loop
numbers are noise. `scripts/online_image/make_bmp_qoi.py <www>` writes 1 bit BMP 640x480, 8 and 24 bit BMP 320x240,
QOI 640x480 one colour (all runs) and checkerboard, QOI 320x240 gradient and noise. `pngdrive.py` with
`CASES=bmpqoi` runs each as plain, `noclen_`, `chunked_`, `half_` plus stalls. The `loop_time` sensor sits at 75
to 160 ms even for a 16x16 image, so for decode passes use `scripts/online_image/firstpass.py HOST KEY BASEURL
LABEL IMAGE...`: it reboots the device before each fetch and reports that fetch's `took a long time` maximum. A
`half_` file with matching Content-Length reports `download finished` with "not caching it" on dev too.
@@ -0,0 +1,19 @@
# Simulated UART sensors on a classic ESP32 (#21449, #21446)
No sensor needed. `templates/uart_sim/sim.hpp` (included with `esphome: includes:`) has frame builders for wts01,
ds1603l, sm300d2 and ufm01, `sim::loopback(id(uart))` (ESP-IDF `uart_set_loop_back` on the component's own UART),
`sim::send()` that logs the bytes, and `sim::probe`, a SEN0465 responder polled from a 10 ms interval. The run files
`fra.yaml`, `frb.yaml` and `frc.yaml` set the name and include `fr-common.yaml` plus their `fr*-parts.yaml` as
packages; copy them with a different name (`frabase` and so on) for the base build.
- Push-style sensors: put the component on a UART, switch it to loopback in `on_boot`, and inject frames from
template buttons (`fra-parts.yaml`, `frb-parts.yaml`). ufm01 enters its active stream by itself when a 1 s
interval streams frames during its 2 s startup wait.
- Request/response sensors: cross-wire two UARTs through the GPIO matrix (UART1 tx 25 rx 26, UART2 tx 26 rx 25,
`allow_other_uses: true` on all four pins); the second UART runs the simulated device (`frc-parts.yaml`).
A select sets the probe's misbehaviour (tail, FF tail, silent, split, wrong gas).
- Polled readers: run `update()` from a script and log `micros()` around it to measure blocking
(sm300d2 on dev blocks about 104 ms on an empty buffer).
- The logger stays on UART0, so three builds cover five components. Drive with `scripts/uart_sim/frdrive.py HOST
LOG press:NAME sel:NAME=OPT num:NAME=V wait:S`, or `scripts/uart_sim/run.sh NAME TREE SECONDS OPS...` (flash,
capture, drive; `PORT` and `SC` in the environment).
@@ -0,0 +1,221 @@
"""Pull the store_yaml blob off a device over the native API (plaintext or Noise) and unpack it.
usage: syrecover.py HOST KEY OUTDIR [ORIGDIR] (KEY of - means a plaintext API)
Prints the capability flag, chunk count, advertised and received sizes, encoding, the envelope's files, and
when ORIGDIR is given compares each recovered file with ORIGDIR/<path>.
Run with PYTHONPATH pointing at the esphome tree that has store_yaml (for unpack_envelope). Needs Python 3.14
(compression.zstd) or the zstandard package.
"""
import asyncio
from pathlib import Path
import sys
import time
from aioesphomeapi import api_pb2
from aioesphomeapi._frame_helper.noise import APINoiseFrameHelper
from aioesphomeapi._frame_helper.plain_text import APIPlaintextFrameHelper
from aioesphomeapi.connection import APIConnection, ConnectionParams
from aioesphomeapi.zeroconf import ZeroconfManager
from esphome.components.store_yaml import unpack_envelope
try:
from compression import zstd
except ImportError:
try:
import zstandard as zstd
except ImportError:
sys.exit("needs Python 3.14 (compression.zstd) or the zstandard package")
HOST, KEY, OUT = sys.argv[1], sys.argv[2], Path(sys.argv[3])
ORIG = Path(sys.argv[4]) if len(sys.argv) > 4 else None
HELLO, HELLO_RESP, CONNECT, CONNECT_RESP = 1, 2, 3, 4
DISCONNECT_REQ = 5
FATAL = -1 # queued by report_fatal_error so a waiting expect() stops at once
CAPS_REQ, CAPS_RESP = 149, 150
GET_YAML_REQ, GET_YAML_RESP = 161, 162
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
value = shift = 0
while True:
b = buf[pos]
pos += 1
value |= (b & 0x7F) << shift
if not b & 0x80:
return value, pos
shift += 7
def fields(payload: bytes) -> dict[int, list]:
out: dict[int, list] = {}
pos = 0
while pos < len(payload):
key, pos = decode_varint(payload, pos)
fnum, wt = key >> 3, key & 7
if wt == 0:
v, pos = decode_varint(payload, pos)
elif wt == 2:
ln, pos = decode_varint(payload, pos)
v = payload[pos : pos + ln]
pos += ln
elif wt == 5:
v = payload[pos : pos + 4]
pos += 4
else:
raise ValueError(f"wire type {wt}")
out.setdefault(fnum, []).append(v)
return out
async def main() -> None:
loop = asyncio.get_running_loop()
queue: asyncio.Queue[tuple[int, bytes]] = asyncio.Queue()
errors: list[Exception] = []
class Shim(APIConnection):
"""Only the two callbacks the frame helper uses; the real connection logic is bypassed."""
def process_packet(self, msg_type_proto, data) -> None:
queue.put_nowait((msg_type_proto, bytes(data)))
def report_fatal_error(self, err: Exception) -> None:
errors.append(err)
queue.put_nowait((FATAL, b""))
params = ConnectionParams(
addresses=[HOST],
port=6053,
password=None,
client_info="syrecover",
keepalive=15,
zeroconf_manager=ZeroconfManager(),
noise_psk=None if KEY == "-" else KEY,
expected_name=None,
expected_mac=None,
timezone=None,
provide_time=True,
)
shim = Shim(params, None, False, HOST)
if KEY == "-":
helper = APIPlaintextFrameHelper(
connection=shim, client_info="syrecover", log_name=HOST
)
else:
helper = APINoiseFrameHelper(
connection=shim,
noise_psk=KEY,
expected_name=None,
expected_mac=None,
client_info="syrecover",
log_name=HOST,
)
await loop.create_connection(lambda: helper, HOST, 6053)
try:
await asyncio.wait_for(helper.ready_future, 10)
except Exception as exc: # noqa: BLE001
sys.exit(f"handshake failed: {exc!r} {errors}")
print(f"transport {'plaintext' if KEY == '-' else 'noise'}: handshake done")
async def expect(msg_type: int, timeout: float = 10) -> bytes:
while True:
t, d = await asyncio.wait_for(queue.get(), timeout)
if t == msg_type:
return d
if t == FATAL:
sys.exit(
f"transport error while waiting for message {msg_type}: {errors}"
)
if t == DISCONNECT_REQ:
sys.exit(
f"device closed the connection while waiting for message {msg_type}"
)
print(f"skipping message type {t} while waiting for {msg_type}")
hello = api_pb2.HelloRequest(
client_info="syrecover", api_version_major=1, api_version_minor=10
)
helper.write_packets([(HELLO, hello.SerializeToString())], False)
await expect(HELLO_RESP)
# Sent like the client library does; a device without a password never answers it
helper.write_packets(
[(CONNECT, api_pb2.AuthenticationRequest().SerializeToString())], False
)
helper.write_packets(
[(CAPS_REQ, api_pb2.DeviceCapabilitiesRequest().SerializeToString())], False
)
caps = fields(await expect(CAPS_RESP))
supported = None
if 7 in caps:
supported = fields(caps[7][0]).get(1) == [1]
print(f"capabilities: store_yaml field present={7 in caps} supported={supported}")
t0 = time.monotonic()
helper.write_packets([(GET_YAML_REQ, b"")], False)
chunks: list[bytes] = []
total = encoding = None
done = False
while not done:
f = fields(await expect(GET_YAML_RESP, 20))
if 1 in f:
chunks.append(f[1][0])
done = f.get(2) == [1]
if 3 in f:
total = f[3][0]
if 4 in f:
encoding = f[4][0].decode()
dt = time.monotonic() - t0
compressed = b"".join(chunks)
print(f"chunks {len(chunks)} sizes {[len(c) for c in chunks]}")
print(
f"received {len(compressed)} bytes, advertised {total}, encoding {encoding!r}, {dt:.2f} s"
)
# a second request right after: the device must answer again, not stay stuck
helper.write_packets([(GET_YAML_REQ, b"")], False)
again = 0
done = False
while not done:
f = fields(await expect(GET_YAML_RESP, 20))
again += len(f.get(1, [b""])[0])
done = f.get(2) == [1]
print(f"second request: {again} bytes")
helper.close()
if errors:
sys.exit(f"transport errors: {errors}")
if encoding != "zstd":
sys.exit(f"unexpected encoding {encoding!r}, expected 'zstd'")
if total is not None and len(compressed) != total:
sys.exit(f"received {len(compressed)} bytes but the device advertised {total}")
if again != len(compressed):
sys.exit(
f"the second request returned {again} bytes, the first {len(compressed)}"
)
envelope = zstd.decompress(compressed)
files = unpack_envelope(envelope)
print(
f"envelope {len(envelope)} bytes, {len(files)} files, ratio {len(compressed) / len(envelope):.2f}"
)
OUT.mkdir(parents=True, exist_ok=True)
for rel, content in files.items():
target = OUT / rel.replace("../", "up_/")
if not target.resolve().is_relative_to(OUT.resolve()):
print("skipping a path outside the output directory:", rel)
continue
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
note = ""
if ORIG is not None:
orig = (ORIG / rel).resolve()
if orig.exists():
note = (
"IDENTICAL to original"
if orig.read_bytes() == content
else f"DIFFERS from original ({orig.stat().st_size} bytes)"
)
else:
note = "no original"
print(f" {rel}: {len(content)} bytes {note}")
asyncio.run(main())
@@ -0,0 +1,31 @@
import asyncio
import re
import sys
import time
import aioesphomeapi
IP, URL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
t0 = time.monotonic()
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if "hrt" in t or "Too many" in t:
print(f"{time.monotonic() - t0:6.2f} {t[:120]}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
await asyncio.sleep(1)
t0 = time.monotonic()
await c.execute_service(s["burst"], {"url": URL, "count": N})
await asyncio.sleep(14)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,109 @@
"""Drive the http_request action test device over the API and collect its hrt log lines.
Usage: hrdrive.py DEVICE_IP SERVER_BASE LABEL [https]"""
import asyncio
import os
import re
import sys
import time
import aioesphomeapi
IP, BASE, LABEL = sys.argv[1], sys.argv[2], sys.argv[3]
HTTPS = len(sys.argv) > 4
CASES = [
("get", "fast", BASE + "/fast"),
("get", "slow2s", BASE + "/slow?d=2"),
("get", "big70k", BASE + "/big?n=70000&rate=20000"),
("get", "chunked70k", BASE + "/chunked?n=70000&rate=20000"),
("post", "post500", BASE + "/post"),
("get", "redirect", BASE + "/redirect"),
("get", "hang", BASE + "/hang?d=20"),
("get", "unreachable", "http://192.0.2.1:81/"),
]
if HTTPS:
CASES += [
("get", "https_get", "https://www.howsmyssl.com/a/check"),
("post", "https_post", "https://httpbin.org/post"),
]
ONLY = os.environ.get("ONLY")
if ONLY:
CASES = [c for c in CASES if c[1] in ONLY.split(",")]
ROUNDS = int(os.environ.get("ROUNDS", "2"))
EXTRAS = not ONLY
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
_, services = await c.list_entities_services()
svc = {s.name: s for s in services}
lines = []
def on_log(m):
t = (
m.message.decode(errors="replace")
if isinstance(m.message, bytes)
else m.message
)
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
if (
"[hrt" in t
or "http_request" in t
or "Script" in t
or "already running" in t
):
lines.append(t)
print(f"{LABEL} {t}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(1)
for rnd in range(1, ROUNDS + 1):
for kind, name, url in CASES:
n0 = len(lines)
print(f"{LABEL} == {name} round {rnd}", flush=True)
if kind == "get":
await c.execute_service(svc["req_get"], {"url": url})
else:
await c.execute_service(
svc["req_post"], {"url": url, "payload": "x" * 500}
)
end = time.monotonic() + 40
while time.monotonic() < end and not any(
"RESULT" in ln for ln in lines[n0:]
):
await asyncio.sleep(0.2)
await asyncio.sleep(1.5)
if not EXTRAS:
continue
print(f"{LABEL} == burst round {rnd}", flush=True)
n0 = len(lines)
await c.execute_service(svc["burst"], {"url": BASE + "/slow?d=1", "count": 6})
end = time.monotonic() + 30
while (
time.monotonic() < end
and sum(("BURSTRESP" in ln or "BURSTERR" in ln) for ln in lines[n0:]) < 6
):
await asyncio.sleep(0.2)
await asyncio.sleep(2)
print(f"{LABEL} == cancel round {rnd}", flush=True)
await c.execute_service(svc["cancel"], {"url": BASE + "/slow?d=2"})
await asyncio.sleep(9)
print(f"{LABEL} == cross round {rnd}", flush=True)
n0 = len(lines)
await c.execute_service(svc["req_get"], {"url": BASE + "/slow?d=2&tag=cross"})
await c.execute_service(
svc["burst"], {"url": BASE + "/slow?d=1&tag=crossburst", "count": 3}
)
end = time.monotonic() + 30
while time.monotonic() < end and (
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) < 3
or not any("RESULT" in ln for ln in lines[n0:])
):
await asyncio.sleep(0.2)
await asyncio.sleep(2)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,78 @@
"""Test server for http_request actions: deterministic bodies, slow and chunked responses."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import sys
import time
from urllib.parse import parse_qs, urlparse
def body_bytes(n):
return bytes((i * 7 + 3) & 0xFF for i in range(n))
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *a):
sys.stdout.write(f"{time.time():.3f} {self.client_address[0]} {fmt % a}\n")
sys.stdout.flush()
def _q(self):
u = urlparse(self.path)
return u.path, {k: v[0] for k, v in parse_qs(u.query).items()}
def _send(self, data, code=200, ctype="application/octet-stream"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
got = self.rfile.read(n)
self._send(
b"posted %d %d" % (len(got), sum(got) & 0xFFFFFFFF), ctype="text/plain"
)
def do_GET(self):
path, q = self._q()
if path == "/fast":
self._send(b"hello", ctype="text/plain")
elif path == "/slow":
time.sleep(float(q.get("d", "2")))
self._send(b"slow ok", ctype="text/plain")
elif path == "/redirect":
self.send_response(302)
self.send_header("Location", "/fast")
self.send_header("Content-Length", "0")
self.end_headers()
elif path == "/hang":
time.sleep(float(q.get("d", "30")))
elif path in ("/big", "/chunked"):
n = int(q.get("n", "70000"))
rate = float(q.get("rate", "20000"))
data = body_bytes(n)
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
if path == "/big":
self.send_header("Content-Length", str(n))
else:
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
step = 1000
for i in range(0, n, step):
part = data[i : i + step]
if path == "/big":
self.wfile.write(part)
else:
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
self.wfile.flush()
time.sleep(len(part) / rate)
if path == "/chunked":
self.wfile.write(b"0\r\n\r\n")
else:
self._send(b"nope", 404)
ThreadingHTTPServer(("0.0.0.0", int(sys.argv[1])), H).serve_forever()
@@ -0,0 +1,58 @@
"""Serve update manifests: stable.json, beta.json and slow.json (answered after a delay); logs every request."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import sys
import time
PORT = int(sys.argv[1])
DELAY = float(sys.argv[2]) if len(sys.argv) > 2 else 4.0
def manifest(name: str, version: str) -> bytes:
builds = [
{
"chipFamily": fam,
"ota": {
"path": f"/{name}.bin",
"md5": "d41d8cd98f00b204e9800998ecf8427e",
"summary": f"{name} channel",
},
}
for fam in ("ESP8266", "ESP32")
]
return json.dumps(
{"name": f"Bench {name}", "version": version, "builds": builds}
).encode()
DOCS = {
"/stable.json": manifest("stable", "1.0.0"),
"/beta.json": manifest("beta", "2.0.0-beta1"),
"/slow.json": manifest("slow", "3.0.0-slow"),
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
body = DOCS.get(self.path)
print(
f"{time.strftime('%H:%M:%S')} {self.client_address[0]} GET {self.path} -> {404 if body is None else 200}",
flush=True,
)
if body is None:
self.send_error(404)
return
if self.path == "/slow.json":
time.sleep(DELAY)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args) -> None: # quiet default log
return
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
@@ -0,0 +1,52 @@
"""Trigger ota.http_request.flash over the API, optionally during a burst of action requests.
Usage: otadrive.py IP FWURL MD5 BASE [burst]"""
import asyncio
import contextlib
import re
import sys
import time
import aioesphomeapi
IP, FW, SUM, BASE = sys.argv[1:5]
BURST = len(sys.argv) > 5
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
i = await c.device_info()
print("before:", i.name, i.compilation_time, flush=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
t0 = time.monotonic()
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if re.search(r"hrt|http_request|ota|OTA|Too many", t):
print(f"{time.monotonic() - t0:7.2f} {t[:170]}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
await asyncio.sleep(1)
if BURST:
await c.execute_service(s["burst"], {"url": BASE + "/slow?d=1", "count": 6})
await asyncio.sleep(0.5)
await c.execute_service(s["otaflash"], {"url": FW, "sum": SUM})
await asyncio.sleep(45)
with contextlib.suppress(Exception):
await c.disconnect()
for _ in range(30):
try:
c2 = aioesphomeapi.APIClient(IP, 6053, None)
await c2.connect(login=True)
i = await c2.device_info()
print("after:", i.name, i.compilation_time, flush=True)
await c2.disconnect()
return
except Exception: # noqa: BLE001
await asyncio.sleep(2)
print("after: not reachable")
asyncio.run(main())
@@ -0,0 +1,51 @@
"""Call reboot_during (a slow request in flight, another waiting, then App.safe_reboot) and time the reboot.
Usage: reboottest.py IP URL"""
import asyncio
import re
import sys
import time
import aioesphomeapi
IP, URL = sys.argv[1], sys.argv[2]
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
_, svcs = await c.list_entities_services()
s = {x.name: x for x in svcs}
t0 = time.monotonic()
def on_log(m):
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
if re.search(
r"hrt|http_request|safe_mode|shutdown|Rebooting|reboot", t, re.IGNORECASE
):
print(f"{time.monotonic() - t0:7.2f} {t[:150]}", flush=True)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(1)
t0 = time.monotonic()
await c.execute_service(s["reboot_during"], {"url": URL})
while time.monotonic() - t0 < 30:
try:
await asyncio.wait_for(c.device_info(), 1.0)
await asyncio.sleep(0.2)
except Exception: # noqa: BLE001
print(f"{time.monotonic() - t0:7.2f} API connection gone", flush=True)
break
for _ in range(60):
try:
c2 = aioesphomeapi.APIClient(IP, 6053, None)
await asyncio.wait_for(c2.connect(login=True), 3)
i = await c2.device_info()
print(f"{time.monotonic() - t0:7.2f} back: {i.name}", flush=True)
await c2.disconnect()
return
except Exception: # noqa: BLE001
await asyncio.sleep(1)
asyncio.run(main())
@@ -0,0 +1,59 @@
"""Run N update checks over the API and print the update state plus http_request/update/long-operation log lines.
Usage: updcheck.py IP LABEL N"""
import asyncio
import re
import sys
import time
import aioesphomeapi
IP, LABEL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
async def main():
c = aioesphomeapi.APIClient(IP, 6053, None)
await c.connect(login=True)
info = await c.device_info()
print(
f"{LABEL} device {info.name} {info.esphome_version} {info.compilation_time}",
flush=True,
)
ents, _ = await c.list_entities_services()
upd = next(e for e in ents if isinstance(e, aioesphomeapi.UpdateInfo))
states = []
def on_state(s):
if s.key == upd.key:
states.append(s)
print(
f"{LABEL} {time.monotonic() - t0:7.2f} UPDATE in_progress={s.in_progress} current={s.current_version} latest={s.latest_version} title={s.title!r}",
flush=True,
)
def on_log(m):
t = (
m.message.decode(errors="replace")
if isinstance(m.message, bytes)
else m.message
)
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
if re.search(
r"http_request|update|took a long time|Guru|abort|heap|Free",
t,
re.IGNORECASE,
):
print(f"{LABEL} {time.monotonic() - t0:7.2f} {t[:200]}", flush=True)
t0 = time.monotonic()
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(3)
for i in range(N):
print(f"{LABEL} {time.monotonic() - t0:7.2f} CHECK {i + 1}", flush=True)
c.update_command(upd.key, aioesphomeapi.UpdateCommand.CHECK)
await asyncio.sleep(25)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,129 @@
"""Drive the http_request update harness over the API: press buttons, print every update state and save the log.
usage: updrive.py HOST KEY(- for plaintext) LOGFILE OP [OP ...]
OP: list | press:NAME | set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS
"""
import asyncio
import pathlib
import sys
import time
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
LogLevel,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
SensorInfo,
SensorState,
TextSensorInfo,
TextSensorState,
UpdateState,
)
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
OPS = sys.argv[4:]
T0 = time.time()
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def main() -> None:
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
info = await c.device_info()
print(
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
)
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)}
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None:
return
if isinstance(s, TextSensorState):
print(
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
flush=True,
)
elif isinstance(s, UpdateState):
print(
f"{stamp()} UPD {e.name} latest={s.latest_version!r} title={s.title!r} summary={s.release_summary!r} in_progress={s.in_progress}{' (missing)' if s.missing_state else ''}",
flush=True,
)
elif isinstance(s, BinarySensorState):
print(
f"{stamp()} BIN {e.name} = {s.state}{' (missing)' if s.missing_state else ''}",
flush=True,
)
elif isinstance(s, NumberState):
print(
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}",
flush=True,
)
elif isinstance(s, SelectState):
print(
f"{stamp()} SEL {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
flush=True,
)
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
print(f"{stamp()} SEN {e.name} = {s.state:.0f}", flush=True)
def on_log(msg) -> None:
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
await asyncio.sleep(1.5)
for op in OPS:
if op == "list":
for n in sorted(buttons):
print("button:", n)
for n in sorted(selects):
print("select:", n, selects[n].options)
for e in ents:
if isinstance(e, TextSensorInfo):
print("text_sensor:", e.name)
continue
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
continue
if kind == "press":
if rest not in buttons:
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
print(f"{stamp()} >>> press {rest}", flush=True)
log.write(f"{stamp()} >>> press {rest}\n")
c.button_command(buttons[rest].key)
elif kind == "set":
name, _, value = rest.partition("=")
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
if name not in numbers:
sys.exit(f"no number named {name!r}")
print(f"{stamp()} >>> set {name} = {value}", flush=True)
log.write(f"{stamp()} >>> set {name} = {value}\n")
c.number_command(numbers[name].key, float(value))
elif kind == "sel":
name, _, value = rest.partition("=")
if name not in selects:
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
print(f"{stamp()} >>> select {name} = {value}", flush=True)
log.write(f"{stamp()} >>> select {name} = {value}\n")
c.select_command(selects[name].key, value)
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(2.0)
await asyncio.sleep(1)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,86 @@
"""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
"""
import asyncio
import pathlib
import sys
import time
from aioesphomeapi import (
APIClient,
ButtonInfo,
LogLevel,
SensorInfo,
SensorState,
TextSensorInfo,
TextSensorState,
)
HOST, LOGFILE = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def main() -> None:
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
c = APIClient(HOST, 6053, None)
await c.connect(login=True)
info = await c.device_info()
print(
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
)
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)}
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None:
return
if isinstance(s, TextSensorState):
print(
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
flush=True,
)
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
print(f"{stamp()} SEN {e.name} = {s.state:.1f}", flush=True)
def on_log(msg) -> None:
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
log.flush()
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(1.5)
for op in OPS:
if op == "list":
for n in sorted(buttons):
print("button:", n)
for e in ents:
if isinstance(e, (TextSensorInfo, SensorInfo)):
print("entity:", type(e).__name__, e.name)
continue
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
continue
if kind == "press":
if rest not in buttons:
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
print(f"{stamp()} >>> press {rest}", flush=True)
log.write(f"{stamp()} >>> press {rest}\n")
c.button_command(buttons[rest].key)
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(2.0)
await asyncio.sleep(1)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,8 @@
substitutions: {name: mqabase, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,9 @@
substitutions: {name: mqbbase, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre32
@@ -0,0 +1,18 @@
substitutions: {name: mqcbase, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit32/log
birth_message:
topic: explicit32/avail
payload: up
will_message:
topic: explicit32/avail
payload: down
shutdown_message:
topic: explicit32/avail
payload: bye
@@ -0,0 +1,8 @@
substitutions: {name: mqd32base, suffix: "false"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,7 @@
substitutions: {name: mqabase, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,8 @@
substitutions: {name: mqbbase, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre66
@@ -0,0 +1,17 @@
substitutions: {name: mqcbase, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit66/log
birth_message:
topic: explicit66/avail
payload: up
will_message:
topic: explicit66/avail
payload: down
shutdown_message:
topic: explicit66/avail
payload: bye
@@ -0,0 +1,7 @@
substitutions: {name: mqd66base, suffix: "false"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,24 @@
# Package for esphome#9315, included by the per-variant files under pr/ and base/ (substitutions name, suffix).
esphome:
name: ${name}
name_add_mac_suffix: ${suffix}
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
ota:
- platform: esphome
button:
- platform: restart
name: Restart
interval:
- interval: 10s
then:
- logger.log: "alive"
@@ -0,0 +1,8 @@
substitutions: {name: mqapr, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,9 @@
substitutions: {name: mqbpr, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre32
@@ -0,0 +1,18 @@
substitutions: {name: mqcpr, suffix: "true"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit32/log
birth_message:
topic: explicit32/avail
payload: up
will_message:
topic: explicit32/avail
payload: down
shutdown_message:
topic: explicit32/avail
payload: bye
@@ -0,0 +1,8 @@
substitutions: {name: mqd32pr, suffix: "false"}
esp32:
board: esp32dev
framework: {type: esp-idf}
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,7 @@
substitutions: {name: mqapr, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,8 @@
substitutions: {name: mqbpr, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
topic_prefix: custpre66
@@ -0,0 +1,17 @@
substitutions: {name: mqcpr, suffix: "true"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
log_topic: explicit66/log
birth_message:
topic: explicit66/avail
payload: up
will_message:
topic: explicit66/avail
payload: down
shutdown_message:
topic: explicit66/avail
payload: bye
@@ -0,0 +1,7 @@
substitutions: {name: mqd66pr, suffix: "false"}
esp8266:
board: d1_mini
packages: {mq: !include ../../mq-common.yaml}
mqtt:
broker: !secret broker_ip
reboot_timeout: 0s
@@ -0,0 +1,36 @@
#!/bin/bash
# run.sh X BUILD : flash variant X (a..d) of BUILD (pr|base) on both boards, record broker traffic, restart, hard reset
# Copy this directory to a scratch directory and run it there; secrets.yaml (wifi_ssid, wifi_password, broker_ip)
# goes next to mq-common.yaml and in every board folder.
# env: TREE (esphome checkout for BUILD), P32 and P66 (ESP32 and ESP8266 serial ports), SC (path to
# serial_capture.py), PY (checkout python)
M=$(cd "$(dirname "$0")" && pwd); X=$1; V=$2; T=${TREE:?set TREE to the checkout under test}
P32=${P32:?set P32}; P66=${P66:?set P66}; SC=${SC:?set SC}
PY=${PY:-$T/venv/bin/python}; OUT=$M/out-$X-$V; mkdir -p $OUT
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"; pkill -f "[m]osquitto_sub -h 127.0.0.1"
(cd $T && $PY -m esphome upload $M/$V/32/mq${X}32$V.yaml --device $P32 > $OUT/u32.log 2>&1) &
(cd $T && $PY -m esphome upload $M/$V/66/mq${X}66$V.yaml --device $P66 > $OUT/u66.log 2>&1) &
wait; echo "upload32: $(tail -1 $OUT/u32.log)"; echo "upload66: $(tail -1 $OUT/u66.log)"
mosquitto_sub -h 127.0.0.1 -v -t '#' -F '@H:@M:@S r=%r %t %p' > $OUT/broker.txt 2>&1 &
SUB=$!
$PY $SC $P32 170 $OUT/s32.txt --reset > /dev/null 2>&1 &
$PY $SC $P66 170 $OUT/s66.txt > /dev/null 2>&1 &
sleep 60
for t in $(grep "homeassistant/button/" $OUT/broker.txt | grep "\"name\":\"mq${X}" | grep -o '"cmd_t":"[^"]*"' | cut -d'"' -f4 | sort -u); do
echo "$(date +%T) press $t"; mosquitto_pub -h 127.0.0.1 -t "$t" -m PRESS
done
sleep 45
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"; sleep 1
echo "$(date +%T) hold both in the bootloader for 40 s (will test)"
$PY -m esptool --port $P32 --after no-reset chip-id > $OUT/hold32.log 2>&1 &
$PY -m esptool --port $P66 --after no-reset chip-id > $OUT/hold66.log 2>&1 &
wait $(jobs -p | grep -v "^$SUB$") 2>/dev/null; sleep 40
echo "$(date +%T) release both"
$PY $SC $P32 40 $OUT/s32b.txt --reset > /dev/null 2>&1 &
$PY $SC $P66 40 $OUT/s66b.txt --reset > /dev/null 2>&1 &
sleep 42
kill $SUB
for f in s32 s66; do echo "== $f"; grep -a -E "Topic Prefix|Log Topic|Availability|Client ID" $OUT/$f.txt | head -4 | sed 's/^\[[0-9:]*\]//'; done
echo "== broker (excluding discovery and entity state)"
grep -v -E " homeassistant/| [^ ]*/(sensor|button|binary_sensor)/| esphome/discover/" $OUT/broker.txt | awk '$3 ~ /(debug|log)$/ { if (seen[$3]++) next; print $1, $2, $3, "(log lines:", "see file)"; next } { print }' | head -40
echo "== log line counts per topic"; awk '$3 ~ /(debug|log)$/ {c[$3]++} END {for (t in c) print t, c[t]}' $OUT/broker.txt
@@ -0,0 +1,71 @@
"""Reboot the DUT before each fetch and report the longest online_image loop pass of that one fetch.
usage: firstpass.py HOST KEY BASEURL LABEL IMAGE...
The component warning prints only on a new maximum, so a fresh boot per image gives that image's own maximum.
"""
import asyncio
import re
import sys
import time
from aioesphomeapi import APIClient, ButtonInfo, LogLevel, UserService
HOST, KEY, BASE, LABEL = sys.argv[1:5]
IMAGES = sys.argv[5:]
TOOK = re.compile(r"online_image\.image took a long time for an operation \((\d+) ms\)")
OUTCOME = re.compile(r"download (finished|error): img\b")
async def connect() -> tuple[APIClient, dict, int]:
for _ in range(30):
c = APIClient(HOST, 6053, None, noise_psk=KEY)
try:
await c.connect(login=True)
ents, services = await c.list_entities_services()
reboot = next(
e.key for e in ents if isinstance(e, ButtonInfo) and e.name == "Reboot"
)
return (
c,
{s.name: s for s in services if isinstance(s, UserService)},
reboot,
)
except Exception: # noqa: BLE001
await asyncio.sleep(2)
raise RuntimeError("device did not come back")
async def main() -> None:
for img in IMAGES:
c, actions, reboot = await connect()
c.button_command(reboot)
await asyncio.sleep(1)
await c.disconnect()
await asyncio.sleep(8)
c, actions, reboot = await connect()
lines: list[str] = []
c.subscribe_logs(
lambda m, lines=lines: lines.append(m.message.decode(errors="replace")),
log_level=LogLevel.LOG_LEVEL_DEBUG,
)
await asyncio.sleep(3)
lines.clear()
t0 = time.monotonic()
await c.execute_service(actions["fetch"], {"url": f"{BASE}/{img}"})
outcome = None
while time.monotonic() - t0 < 60 and outcome is None:
await asyncio.sleep(0.2)
outcome = next(
(m.group(1) for ln in lines if (m := OUTCOME.search(ln))), None
)
took = time.monotonic() - t0
await asyncio.sleep(1)
passes = [int(m.group(1)) for ln in lines if (m := TOOK.search(ln))]
print(
f"{LABEL} {img:22} {outcome or 'none':9} {took:5.1f}s longest pass {max(passes) if passes else '<50'} ms {passes}",
flush=True,
)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,32 @@
"""Build the large PNGs for the decode time test: a compressible gradient (also saved as ok.png so every server case
carries a multi-row PNG), a flat 640x480 image whose few KB hold 307200 pixels, incompressible noise, and a JPEG."""
from pathlib import Path
import random
import sys
from PIL import Image
www = Path(sys.argv[1])
im = Image.new("RGB", (320, 240))
px = im.load()
for y in range(240):
for x in range(320):
px[x, y] = (x * 255 // 319, y * 255 // 239, 128)
im.save(www / "ok.png", "PNG", optimize=True)
im.save(www / "gradient.png", "PNG", optimize=True)
im.save(www / "gradient.jpg", "JPEG", quality=85)
Image.new("RGB", (640, 480), (40, 90, 200)).save(www / "flat.png", "PNG", optimize=True)
random.seed(1)
noise = Image.new("RGB", (320, 240))
npx = noise.load()
for y in range(240):
for x in range(320):
npx[x, y] = (
random.randrange(256),
random.randrange(256),
random.randrange(256),
)
noise.save(www / "noise.png", "PNG")
for p in sorted(www.iterdir()):
print(p.name, p.stat().st_size)
@@ -0,0 +1,93 @@
"""Write big BMP (1, 8, 24 bit) and QOI test images into a www directory."""
from pathlib import Path
import random
import struct
import sys
from PIL import Image
www = Path(sys.argv[1])
def gradient(w, h):
im = Image.new("RGB", (w, h))
im.putdata(
[
((x * 255) // w, (y * 255) // h, ((x + y) * 255) // (w + h))
for y in range(h)
for x in range(w)
]
)
return im
def checker(w, h):
im = Image.new("1", (w, h))
im.putdata(
[255 if ((x // 16) + (y // 16)) % 2 else 0 for y in range(h) for x in range(w)]
)
return im
checker(640, 480).save(www / "bmp1_640.bmp")
gradient(320, 240).convert("P", palette=Image.ADAPTIVE).save(www / "bmp8_320.bmp")
gradient(320, 240).save(www / "bmp24_320.bmp")
def qoi(im, path):
im = im.convert("RGBA")
w, h = im.size
out = bytearray(b"qoif" + struct.pack(">IIBB", w, h, 4, 0))
idx = [(0, 0, 0, 0)] * 64
prev = (0, 0, 0, 255)
run = 0
px = list(im.getdata())
for i, p in enumerate(px):
if p == prev:
run += 1
if run == 62 or i == len(px) - 1:
out.append(0xC0 | (run - 1))
run = 0
continue
if run:
out.append(0xC0 | (run - 1))
run = 0
h_ = (p[0] * 3 + p[1] * 5 + p[2] * 7 + p[3] * 11) % 64
if idx[h_] == p:
out.append(h_)
else:
idx[h_] = p
if p[3] == prev[3]:
dr, dg, db = (
((p[0] - prev[0] + 128) % 256) - 128,
((p[1] - prev[1] + 128) % 256) - 128,
((p[2] - prev[2] + 128) % 256) - 128,
)
drg, dbg = dr - dg, db - dg
if -2 <= dr <= 1 and -2 <= dg <= 1 and -2 <= db <= 1:
out.append(0x40 | (dr + 2) << 4 | (dg + 2) << 2 | (db + 2))
elif -32 <= dg <= 31 and -8 <= drg <= 7 and -8 <= dbg <= 7:
out += bytes([0x80 | (dg + 32), (drg + 8) << 4 | (dbg + 8)])
else:
out += bytes([0xFE, p[0], p[1], p[2]])
else:
out += bytes([0xFF, *p])
prev = p
out += b"\x00" * 7 + b"\x01"
path.write_bytes(out)
qoi(
Image.new("RGB", (640, 480), (0, 0, 0)).copy(), www / "qoi_flat.qoi"
) # all runs, 62 pixels a byte
qoi(checker(640, 480).convert("RGB"), www / "qoi_check.qoi")
qoi(gradient(320, 240), www / "qoi_grad.qoi")
random.seed(1)
noise = Image.frombytes(
"RGB", (320, 240), bytes(random.getrandbits(8) for _ in range(320 * 240 * 3))
)
qoi(noise, www / "qoi_noise.qoi")
for f in sorted(www.glob("*_*.*")):
print(f.name, f.stat().st_size)
@@ -0,0 +1,42 @@
"""Build the JPEGs for the streaming decode test: noise 320x240 (also ok.jpg) and 640x480, a 640x480 gradient, the
320x240 noise with a 12 KB EXIF segment in front of the frame header, and a progressive one."""
from pathlib import Path
import random
import sys
from PIL import Image
www = Path(sys.argv[1])
random.seed(2)
def noise(w: int, h: int) -> Image.Image:
im = Image.new("RGB", (w, h))
px = im.load()
for y in range(h):
for x in range(w):
px[x, y] = (
random.randrange(256),
random.randrange(256),
random.randrange(256),
)
return im
n320 = noise(320, 240)
n320.save(www / "noise320.jpg", "JPEG", quality=90)
n320.save(www / "ok.jpg", "JPEG", quality=90)
noise(640, 480).save(www / "noise640.jpg", "JPEG", quality=85)
g = Image.new("RGB", (640, 480))
px = g.load()
for y in range(480):
for x in range(640):
px[x, y] = (x * 255 // 639, y * 255 // 479, 128)
g.save(www / "gradient640.jpg", "JPEG", quality=85)
n320.save(
www / "bigexif320.jpg", "JPEG", quality=90, exif=b"Exif\x00\x00" + bytes(12000)
)
n320.save(www / "progressive320.jpg", "JPEG", quality=90, progressive=True)
for p in sorted(www.iterdir()):
print(p.name, p.stat().st_size)
@@ -5,7 +5,16 @@ from pathlib import Path
import sys
import time
WWW = Path(sys.argv[1])
WWW = Path(sys.argv[1]).resolve()
NOT_FOUND = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
def www_file(rel: str) -> Path | None:
"""The file under WWW named by a request path, or None if missing or outside WWW."""
file = (WWW / rel).resolve()
return file if file.is_relative_to(WWW) and file.is_file() else None
PORT = int(sys.argv[2])
LOG = Path(sys.argv[3]).open("a", encoding="utf-8", buffering=1) # noqa: SIM115
@@ -108,8 +117,34 @@ async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str:
if name == "huge": # claims 20 MB so the JPEG buffer resize fails
w.write(headers(ctype, [b"Content-Length: 20000000"]) + body)
return f"claims 20000000, sent {len(body)} bytes"
w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
return "404"
prefix, _, rest = name.partition("_")
if prefix in ("half", "noclen", "chunked", "stall") and rest:
# 404 when the base file is missing, so a typo never passes as the misbehaviour under test
file = www_file(rest + "." + ext)
else:
prefix, file = "", www_file(path.lstrip("/"))
if file is None:
w.write(NOT_FOUND)
return "404"
data = file.read_bytes()
if prefix == "half": # the first half of a file, Content-Length matching
cut = data[: len(data) // 2]
w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut)
return f"half {len(cut)} of {len(data)} bytes"
if prefix == "noclen": # a file without Content-Length, delimited by close
w.write(headers(ctype, []) + data)
return f"no content-length, full {len(data)} bytes"
if prefix == "chunked": # a file in 1 KB chunks
w.write(headers(ctype, [b"Transfer-Encoding: chunked"]) + chunked(data, 1024))
return f"chunked full {len(data)} bytes"
if prefix == "stall": # claims the full length, sends a third, holds the socket
part = data[: len(data) // 3]
w.write(headers(ctype, [b"Content-Length: %d" % len(data)]) + part)
await w.drain()
await asyncio.sleep(40)
return f"stalled after {len(part)} of {len(data)} bytes"
w.write(headers(ctype, [b"Content-Length: %d" % len(data), *tag]) + data)
return f"file {file.name} {len(data)} bytes"
async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
@@ -0,0 +1,228 @@
"""Fetch images through the online_image API actions and report outcome, time, loop time maximum and heap minimum per case.
usage: [CASES=png|noise|end|bmpqoi|short|jpeg] pngdrive.py HOST KEY LOGFILE BASEURL LABEL
"""
import asyncio
import contextlib
import os
import pathlib
import re
import sys
import time
from aioesphomeapi import APIClient, LogLevel, SensorInfo, SensorState, UserService
HOST, KEY, LOGFILE, BASE, LABEL = sys.argv[1:6]
CAP_S = 60
OUTCOME = re.compile(r"download (finished|error): (\w+)")
INTEREST = re.compile(
r"took a long time|Error|error|Download ended|No data received|Waiting for more|Decoded|cut off|already being|headers do not fit|buffer_size|Downloading image|fully downloaded|allocat|not caching"
)
PNG = [
("gradient 320x240 png", "fetch", "gradient.png", "finished"),
("gradient again", "fetch", "gradient.png", "finished"),
("gradient again", "fetch", "gradient.png", "finished"),
("flat 640x480 png", "fetch", "flat.png", "finished"),
("flat again", "fetch", "flat.png", "finished"),
("flat again", "fetch", "flat.png", "finished"),
("gradient jpeg", "fetch", "gradient.jpg", "finished"),
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
("ok png 16x16 body = gradient", "fetch", "ok.png", "finished"),
("chunked png", "fetch", "chunked.png", "finished"),
("chunked png cut in half", "fetch", "chunked_cut.png", "any"),
("closed early png", "fetch", "closed_early.png", "error"),
("stalled png", "fetch", "stall.png", "error"),
("slow png", "fetch", "slow.png", "finished"),
("empty png", "fetch", "empty.png", "error"),
("no content-length png", "fetch", "noclen.png", "any"),
("truncated bmp", "fetch", "truncated.bmp", "error"),
("gradient after errors", "fetch", "gradient.png", "finished"),
("gradient refetch 304", "refetch", "", "finished"),
("noise 320x240 png", "fetch", "noise.png", "finished"),
("noise again", "fetch", "noise.png", "finished"),
]
NOISE = [("noise 320x240 png", "fetch", "noise.png", "finished")] * 3
END = [
(f"{pre}{img}", "fetch", f"{pre}{img}", exp)
for img in ("flat.png", "gradient.png")
for pre, exp in (
("", "finished"),
("noclen_", "finished"),
("chunked_", "finished"),
("half_", "any"),
("stall_", "error"),
)
] + [("gradient after", "fetch", "gradient.png", "finished")]
BMPQOI = [
(f"{pre}{img}", "fetch", f"{pre}{img}", exp)
for img in (
"bmp1_640.bmp",
"bmp8_320.bmp",
"bmp24_320.bmp",
"qoi_flat.qoi",
"qoi_check.qoi",
"qoi_grad.qoi",
"qoi_noise.qoi",
)
for pre, exp in (
("", "finished"),
("", "finished"),
("noclen_", "any"),
("chunked_", "any"),
("half_", "error"),
)
] + [
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
("ok qoi 16x16", "fetch", "ok.qoi", "finished"),
("truncated bmp", "fetch", "truncated.bmp", "error"),
("stall_bmp1_640.bmp", "fetch", "stall_bmp1_640.bmp", "error"),
("stall_qoi_flat.qoi", "fetch", "stall_qoi_flat.qoi", "error"),
("qoi_flat after", "fetch", "qoi_flat.qoi", "finished"),
]
SHORT = [
("gradient 320x240 png", "fetch", "gradient.png", "finished"),
("gradient again", "fetch", "gradient.png", "finished"),
("noise 320x240 png", "fetch", "noise.png", "finished"),
("noise again", "fetch", "noise.png", "finished"),
("noise again", "fetch", "noise.png", "finished"),
("gradient after noise", "fetch", "gradient.png", "finished"),
]
JPEG = [
("noise 320x240 jpeg 69 KB", "fetch", "noise320.jpg", "finished"),
("noise 320x240 again", "fetch", "noise320.jpg", "finished"),
("noise 320x240 again", "fetch", "noise320.jpg", "finished"),
("noise 640x480 jpeg 233 KB", "fetch", "noise640.jpg", "finished"),
("noise 640x480 again", "fetch", "noise640.jpg", "finished"),
("gradient 640x480 jpeg 14 KB", "fetch", "gradient640.jpg", "finished"),
("4 KB buffer: noise 320x240", "fetch_small", "noise320.jpg", "finished"),
("4 KB buffer: noise 640x480", "fetch_small", "noise640.jpg", "finished"),
("4 KB buffer: 12 KB exif", "fetch_small", "bigexif320.jpg", "any"),
("64 KB buffer: 12 KB exif", "fetch", "bigexif320.jpg", "finished"),
("progressive jpeg", "fetch", "progressive320.jpg", "any"),
("no content-length", "fetch", "noclen_noise320.jpg", "finished"),
("chunked", "fetch", "chunked_noise320.jpg", "finished"),
("half with matching length", "fetch", "half_noise320.jpg", "error"),
("closed early", "fetch", "closed_early.jpg", "error"),
("stalled after a third", "fetch", "stall_noise320.jpg", "error"),
("empty", "fetch", "empty.jpg", "error"),
("noise 320x240 after errors", "fetch", "noise320.jpg", "finished"),
("refetch 304", "refetch", "", "finished"),
("ok png 16x16", "fetch", "ok.png", "finished"),
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
("4 KB buffer: noise 640x480 again", "fetch_small", "noise640.jpg", "finished"),
("4 KB buffer: half", "fetch_small", "half_noise320.jpg", "error"),
("4 KB buffer: stalled", "fetch_small", "stall_noise320.jpg", "error"),
(
"4 KB buffer: noise 320x240 after errors",
"fetch_small",
"noise320.jpg",
"finished",
),
]
CASE_SETS = {
"png": PNG,
"noise": NOISE,
"end": END,
"bmpqoi": BMPQOI,
"short": SHORT,
"jpeg": JPEG,
}
CASES = CASE_SETS[os.environ.get("CASES", "png")]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, services = await c.list_entities_services()
actions = {s.name: s for s in services if isinstance(s, UserService)}
loop_key = next(
e.key for e in ents if isinstance(e, SensorInfo) and e.name == "Loop Time"
)
heap_key = next(
e.key for e in ents if isinstance(e, SensorInfo) and e.name == "Heap Free"
)
state = {"loop": [], "heap": None, "heap_min": None, "lines": []}
def on_state(s) -> None:
if isinstance(s, SensorState) and s.key == loop_key:
state["loop"].append(s.state)
elif isinstance(s, SensorState) and s.key == heap_key:
state["heap"] = s.state
if state["heap_min"] is None or s.state < state["heap_min"]:
state["heap_min"] = s.state
def on_log(msg) -> None:
line = msg.message.decode(errors="replace")
log.write(f"{time.strftime('%H:%M:%S')} {line}\n")
state["lines"].append(line)
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(3)
print(f"{LABEL}: heap at start {state['heap']}")
rows = []
for label, action, path, expect in CASES:
img = "img_small" if action.endswith("_small") else "img"
state["loop"].clear()
state["lines"].clear()
state["heap_min"] = None
args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"}
t0 = time.monotonic()
log.write(f">>> {label} {path}\n")
try:
await c.execute_service(actions[action], args)
except Exception as exc: # noqa: BLE001
print(f" api call failed ({exc!r}); reconnecting", flush=True)
with contextlib.suppress(Exception):
await c.disconnect()
await asyncio.sleep(10)
await c.connect(login=True)
ents, services = await c.list_entities_services()
actions = {s.name: s for s in services if isinstance(s, UserService)}
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
await asyncio.sleep(2)
state["lines"].clear()
t0 = time.monotonic()
await c.execute_service(actions[action], args)
outcome = "no outcome"
while time.monotonic() - t0 < CAP_S:
await asyncio.sleep(0.25)
for line in state["lines"]:
m = OUTCOME.search(line)
if m and m.group(2) == img:
outcome = m.group(1)
if outcome != "no outcome":
break
took = time.monotonic() - t0
await asyncio.sleep(4.5) # two loop time reports after the case
notes = [
ln for ln in state["lines"] if INTEREST.search(ln) and "Loop" not in ln
]
loop_max = max(state["loop"]) if state["loop"] else float("nan")
ok = outcome == expect or (expect == "any" and outcome != "no outcome")
rows.append((label, path, expect, outcome, took, loop_max, ok))
print(
f"{'PASS' if ok else 'FAIL'} {label:30s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s loop max {loop_max:7.0f} ms heap min {state['heap_min']}",
flush=True,
)
for n in notes[:8]:
print(f" {n.strip()}", flush=True)
await c.disconnect()
print(
"\nSUMMARY",
"all expectations met"
if all(r[6] for r in rows)
else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[6]),
)
asyncio.run(main())
@@ -0,0 +1,96 @@
"""Drive the FrameReader simulation builds over the API: press buttons, set selects/numbers, print states.
usage: frdrive.py HOST LOGFILE OP [OP ...]
OP: list | press:NAME | sel:NAME=OPTION | num:NAME=VALUE | wait:SECONDS
"""
import asyncio
import math
import pathlib
import sys
import time
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
LogLevel,
NumberInfo,
SelectInfo,
SensorInfo,
SensorState,
)
HOST, LOGFILE = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
SKIP = {"Heap Free", "Loop Time"}
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def main() -> None:
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
c = APIClient(HOST, 6053, None)
await c.connect(login=True)
info = await c.device_info()
print(f"device {info.name} compiled {info.compilation_time}", flush=True)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
def find(kind: type, name: str):
for e in ents:
if isinstance(e, kind) and e.name == name:
return e
sys.exit(f"no {kind.__name__} named {name!r}")
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None or e.name in SKIP:
return
if isinstance(s, SensorState) and isinstance(e, SensorInfo):
v = "nan" if math.isnan(s.state) else f"{s.state:.2f}"
line = f"{stamp()} SEN {e.name} = {v}"
elif isinstance(s, BinarySensorState):
line = f"{stamp()} BIN {e.name} = {s.state}"
else:
return
print(line, flush=True)
log.write(line + "\n")
def on_log(msg) -> None:
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
log.flush()
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
await asyncio.sleep(2)
for op in OPS:
kind, _, rest = op.partition(":")
if kind == "list":
for e in ents:
print(type(e).__name__, e.name)
continue
if kind == "wait":
await asyncio.sleep(float(rest))
continue
line = f"{stamp()} >>> {op}"
print(line, flush=True)
log.write(line + "\n")
if kind == "press":
c.button_command(find(ButtonInfo, rest).key)
elif kind == "sel":
name, _, value = rest.partition("=")
c.select_command(find(SelectInfo, name).key, value)
elif kind == "num":
name, _, value = rest.partition("=")
c.number_command(find(NumberInfo, name).key, float(value))
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(2.5)
await asyncio.sleep(1)
await c.disconnect()
asyncio.run(main())
+12
View File
@@ -0,0 +1,12 @@
#!/bin/zsh
# usage: run.sh NAME TREE SECONDS OP... flash over serial, capture serial, drive over the API
# Copy this, frdrive.py and the templates/uart_sim files into one scratch directory and run it there.
# env: PORT (the devkit serial port), SC (path to serial_capture.py); TREE is the esphome checkout to build from
D=${0:A:h}; N=$1; T=$2; S=$3; shift 3; P=${PORT:?set PORT}; PY=$T/venv/bin/python
(cd $T && PYTHONUNBUFFERED=1 timeout 300 venv/bin/python -m esphome upload $D/$N.yaml --device $P > $D/$N-upload.log 2>&1) || { echo "upload failed"; exit 1; }
$PY ${SC:?set SC} $P $S $D/$N-cap.txt > $D/$N-cap.err 2>&1 &
CAP=$!
for i in $(seq 1 40); do $PY -c "import socket;socket.create_connection(('$N.local',6053),2)" 2>/dev/null && break; sleep 2; done
cd $D && PYTHONUNBUFFERED=1 timeout $((S - 5)) $PY frdrive.py $N.local $N-api.log "$@" > $N-drive.txt 2>&1; echo "drive rc=$?"
wait $CAP
grep -acE 'Guru|abort\(\)|rst:0x' $D/$N-cap.txt
@@ -0,0 +1,150 @@
substitutions:
# the fake instrument's BLE address; pass it with `-s peri_mac AA:BB:CC:DD:EE:FF`
peri_mac: "00:00:00:00:00:00"
esphome:
name: midicli
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
esp32_ble_tracker: DEBUG
ble_client: DEBUG
esp32_ble: DEBUG
api: DEBUG
scheduler: DEBUG
sensor: DEBUG
wifi: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
esp32_ble_tracker:
ble_client:
- mac_address: ${peri_mac}
id: midi_client
ble_midi:
id: test_midi
ble_client_id: midi_client
max_sysex_size: 64
pair: false
on_connect:
- logger.log: "MIDI device connected"
- ble_midi.send_program_change:
id: test_midi
program: 0
on_disconnect:
- logger.log: "MIDI device disconnected"
on_note_on:
- logger.log:
format: "Note on %u velocity %u channel %u"
args: [note, velocity, channel]
on_note_off:
- logger.log:
format: "Note off %u channel %u"
args: [note, channel]
on_control_change:
- logger.log:
format: "CC %u = %u on channel %u"
args: [control_number, value, channel]
on_program_change:
- logger.log:
format: "Program %u on channel %u"
args: [program, channel]
on_pitch_bend:
- logger.log:
format: "Pitch bend %d on channel %u"
args: [value, channel]
on_sysex:
- lambda: |-
ESP_LOGI("midi", "SysEx of %u bytes: %s", (unsigned) data.size(), format_hex_pretty(data.data(), data.size()).c_str());
on_message:
- lambda: |-
ESP_LOGI("midi", "Raw message of %u bytes: %s", (unsigned) data.size(), format_hex_pretty(data.data(), data.size()).c_str());
sensor:
- platform: debug
free:
name: Heap Free
loop_time:
name: Loop Time
debug:
update_interval: 30s
button:
- platform: template
name: "MIDI send tests"
on_press:
- ble_midi.send_note_on:
id: test_midi
note: 60
- ble_midi.send_note_on:
id: test_midi
note: !lambda "return 60;"
velocity: !lambda "return 100;"
channel: !lambda "return 1;"
- ble_midi.send_note_off:
id: test_midi
note: 60
velocity: 0
channel: 1
- ble_midi.send_control_change:
id: test_midi
control_number: 7
value: 64
- ble_midi.send_pitch_bend:
id: test_midi
value: -8192
- ble_midi.send_sysex:
id: test_midi
payload: [0x7E, 0x7F, 0x06, 0x01]
- ble_midi.send_raw:
id: test_midi
data: !lambda "return std::vector<uint8_t>{0x90, 0x3C, 0x40};"
- if:
condition:
ble_midi.connected:
id: test_midi
then:
- logger.log: "Still connected"
else:
- logger.log: "Not connected"
- platform: template
name: "MIDI send out of range"
on_press:
- ble_midi.send_note_on:
id: test_midi
note: !lambda "return 200;"
- ble_midi.send_control_change:
id: test_midi
control_number: 1
value: 1
channel: !lambda "return 16;"
- ble_midi.send_pitch_bend:
id: test_midi
value: 8191
- ble_midi.send_sysex:
id: test_midi
payload: [0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7]
- ble_midi.send_raw:
id: test_midi
data: !lambda "return std::vector<uint8_t>(140, 0x00);"
- platform: template
name: "MIDI send long sysex"
on_press:
- ble_midi.send_sysex:
id: test_midi
payload: !lambda "return std::vector<uint8_t>(100, 0x11);"
@@ -0,0 +1,177 @@
esphome:
name: midiperi
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
level: DEBUG
api:
reboot_timeout: 0s
ota:
- platform: esphome
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
esp32_ble_server:
id: ble_server
manufacturer: FakeMIDI
on_connect:
- lambda: ESP_LOGI("peri", "central connected %d", id);
on_disconnect:
- lambda: ESP_LOGI("peri", "central disconnected %d", id);
services:
- uuid: 03B80E5A-EDE8-4B33-A751-6CE34EC4C700
advertise: true
characteristics:
- id: midi_io
uuid: 7772E5DB-3868-4112-A1A9-F2669D106BF3
read: true
write: true
write_no_response: true
notify: true
on_write:
- lambda: |-
ESP_LOGI("peri", "RX %u bytes: %s", (unsigned) x.size(), format_hex_pretty(x.data(), x.size()).c_str());
script:
- id: emit
parameters:
pkt: std::vector<uint8_t>
then:
- lambda: |-
ESP_LOGI("peri", "TX %u bytes: %s", (unsigned) pkt.size(), format_hex_pretty(pkt.data(), pkt.size()).c_str());
id(midi_io).set_value(std::vector<uint8_t>(pkt));
id(midi_io).notify();
button:
- platform: restart
name: P Restart
- platform: template
name: P Note On
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x3C, 0x64};'
- platform: template
name: P Note Off
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0x80, 0x3C, 0x00};'
- platform: template
name: P Control Change
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xB0, 0x07, 0x40};'
- platform: template
name: P Program Change
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xC5, 0x05};'
- platform: template
name: P Pitch Bend
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xE0, 0x7F, 0x7F};'
- platform: template
name: P Two Notes One Packet
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x3C, 0x64, 0x81, 0x91, 0x3E, 0x50};'
- platform: template
name: P Running Status
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x40, 0x64, 0x41, 0x64};'
- platform: template
name: P Sysex One Packet
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x7E, 0x7F, 0x06, 0x01, 0x80, 0xF7};'
- platform: template
name: P Sysex Split
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x41, 0x10, 0x42};'
- delay: 200ms
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x12, 0x00, 0x40, 0x80, 0xF7};'
- platform: template
name: P Sysex Oversize
on_press:
- script.execute:
id: emit
pkt: !lambda 'std::vector<uint8_t> v{0x80, 0x80, 0xF0}; for (int i = 0; i < 17; i++) v.push_back(i); return v;'
- delay: 200ms
- script.execute:
id: emit
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x20 + i); return v;'
- delay: 200ms
- script.execute:
id: emit
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x40 + i); return v;'
- delay: 200ms
- script.execute:
id: emit
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x60 + i); return v;'
- delay: 200ms
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xF7};'
- platform: template
name: P Sysex Cut Short
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x41, 0x10, 0x80, 0x90, 0x3C, 0x64};'
- platform: template
name: P Clock
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xF8};'
- platform: template
name: P Aftertouch
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0xD3, 0x45};'
- platform: template
name: P Orphan Data Byte
on_press:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0x3C, 0x64};'
- platform: template
name: P Burst
on_press:
- repeat:
count: 50
then:
- script.execute:
id: emit
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x30, 0x40, 0x80, 0x80, 0x30, 0x00};'
- delay: 20ms
@@ -0,0 +1,159 @@
# Package for the http_request action test (esphome#21486). Needs substitutions: name, bufsize.
esphome:
name: ${name}
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
actions:
- action: req_get
variables:
url: string
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); id(tag)++; ESP_LOGI("hrt", "START %d %s %s", id(tag), "GET", url.c_str());'
- http_request.get:
url: !lambda return url;
capture_response: true
max_response_buffer_size: ${bufsize}
on_response:
then:
- lambda: |-
uint32_t sum = 0;
for (char c : body) sum += (uint8_t) c;
ESP_LOGI("hrt", "RESULT %d status=%d len=%u sum=%u took=%u maxgap=%u head=%.20s", id(tag),
response->status_code, (unsigned) body.size(), (unsigned) sum,
(unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)), body.c_str());
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "RESULT %d error took=%u maxgap=%u", id(tag), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
- lambda: 'ESP_LOGI("hrt", "CHAIN %d after send", id(tag));'
- action: req_post
variables:
url: string
payload: string
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); id(tag)++; ESP_LOGI("hrt", "START %d %s %s", id(tag), "POST", url.c_str());'
- http_request.post:
url: !lambda return url;
body: !lambda return payload;
capture_response: true
max_response_buffer_size: ${bufsize}
on_response:
then:
- lambda: |-
uint32_t sum = 0;
for (char c : body) sum += (uint8_t) c;
ESP_LOGI("hrt", "RESULT %d status=%d len=%u sum=%u took=%u maxgap=%u head=%.20s", id(tag),
response->status_code, (unsigned) body.size(), (unsigned) sum,
(unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)), body.c_str());
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "RESULT %d error took=%u maxgap=%u", id(tag), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
- lambda: 'ESP_LOGI("hrt", "CHAIN %d after send", id(tag));'
- action: burst
variables:
url: string
count: int
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); ESP_LOGI("hrt", "BURST %d", count);'
- repeat:
count: !lambda return count;
then:
- script.execute:
id: burst_script
url: !lambda return url;
- action: cancel
variables:
url: string
then:
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); ESP_LOGI("hrt", "CANCEL start");'
- script.execute:
id: single_script
url: !lambda return url;
- delay: 300ms
- script.execute:
id: single_script
url: !lambda return url;
- delay: 300ms
- script.stop: single_script
- lambda: 'ESP_LOGI("hrt", "CANCEL stopped running=%d", id(single_script).is_running());'
- delay: 4s
- lambda: 'ESP_LOGI("hrt", "CANCEL done maxgap=%u", (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
- action: reboot_during
variables:
url: string
then:
- lambda: 'ESP_LOGI("hrt", "REBOOT_DURING start");'
- script.execute:
id: single_script
url: !lambda return url;
- script.execute:
id: burst_script
url: !lambda return url;
- delay: 500ms
- lambda: 'ESP_LOGI("hrt", "REBOOT_DURING rebooting"); App.safe_reboot();'
script:
- id: burst_script
mode: parallel
max_runs: 10
parameters:
url: string
then:
- http_request.get:
url: !lambda return url;
capture_response: true
max_response_buffer_size: 256
on_response:
then:
- lambda: 'ESP_LOGI("hrt", "BURSTRESP status=%d len=%u took=%u maxgap=%u", response->status_code, (unsigned) body.size(), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
on_error:
then:
- lambda: 'ESP_LOGI("hrt", "BURSTERR took=%u", (unsigned) (millis() - id(t_start)));'
- id: single_script
parameters:
url: string
then:
- lambda: 'ESP_LOGI("hrt", "SINGLE begin");'
- http_request.get:
url: !lambda return url;
capture_response: true
max_response_buffer_size: 256
on_response:
then:
- lambda: 'ESP_LOGI("hrt", "SINGLERESP status=%d took=%u", response->status_code, (unsigned) (millis() - id(t_start)));'
- lambda: 'ESP_LOGI("hrt", "SINGLE end");'
http_request:
timeout: 8s
globals:
- id: max_gap
type: uint32_t
initial_value: "0"
- id: last_tick
type: uint32_t
initial_value: "0"
- id: t_start
type: uint32_t
initial_value: "0"
- id: tag
type: int
initial_value: "0"
interval:
- interval: 1ms
then:
- lambda: |-
const uint32_t now = millis();
if (id(last_tick) != 0 && now - id(last_tick) > id(max_gap))
id(max_gap) = now - id(last_tick);
id(last_tick) = now;
ota:
- platform: esphome
@@ -0,0 +1,6 @@
substitutions: {name: hr32pr, bufsize: "32768"}
esp32:
board: esp32dev
framework: {type: esp-idf}
logger:
packages: {hr: !include hr-common.yaml}
@@ -0,0 +1,7 @@
substitutions: {name: hr66pr, bufsize: "8192"}
esp8266:
board: d1_mini
logger:
packages: {hr: !include hr-common.yaml}
http_request:
verify_ssl: false
@@ -0,0 +1,10 @@
esphome:
name: upd32
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
common: !include ../upd-common.yaml
@@ -0,0 +1,8 @@
esphome:
name: upd8266
esp8266:
board: d1_mini
packages:
common: !include ../upd-common.yaml
@@ -0,0 +1,52 @@
substitutions:
base: !secret manifest_base
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
api:
reboot_timeout: 0s
http_request:
verify_ssl: false
timeout: 10s
ota:
- platform: http_request
id: ota_http
update:
- platform: http_request
id: upd
name: Firmware
source: ${base}/stable.json
update_interval: 1h
button:
- platform: template
name: Check
on_press:
- lambda: id(upd).check();
- platform: template
name: URL Slow Cstr
on_press:
- lambda: |-
std::string url = "${base}/slow.json";
id(upd).set_source_url(url.c_str());
- platform: template
name: URL Stable Literal
on_press:
- lambda: id(upd).set_source_url("${base}/stable.json");
- platform: template
name: Heap
on_press:
- lambda: |-
#ifdef USE_ESP8266
ESP_LOGI("harness", "heap free %u", ESP.getFreeHeap());
#else
ESP_LOGI("harness", "heap free %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
#endif
@@ -0,0 +1,95 @@
substitutions:
base: !secret manifest_base
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
on_message:
- level: DEBUG
then:
- lambda: |-
// Inline check path: replace the URL from inside the check (ESP8266 runs the check on the main loop)
if (id(swap_in_check) && strstr(message, "Content-Length") != nullptr) {
id(swap_in_check) = false;
std::string url = "${base}/beta.json";
id(upd).set_source_url(url);
ESP_LOGI("harness", "URL replaced from inside the check");
}
api:
reboot_timeout: 0s
http_request:
verify_ssl: false
timeout: 10s
ota:
- platform: http_request
id: ota_http
update:
- platform: http_request
id: upd
name: Firmware
source: ${base}/stable.json
update_interval: 1h
globals:
- id: swap_in_check
type: bool
initial_value: "false"
button:
- platform: template
name: Check
on_press:
- lambda: id(upd).check();
- platform: template
name: URL Beta String
on_press:
- lambda: |-
std::string url = "${base}/beta.json";
id(upd).set_source_url(url);
- platform: template
name: URL Slow Cstr
on_press:
- lambda: |-
std::string url = "${base}/slow.json";
id(upd).set_source_url(url.c_str());
- platform: template
name: URL Stable Literal
on_press:
- lambda: id(upd).set_source_url("${base}/stable.json");
- platform: template
name: URL Temporary
on_press:
- lambda: id(upd).set_source_url(std::string("${base}/beta.json"));
- platform: template
name: Same URL Back
on_press:
- lambda: id(upd).set_source_url(id(upd).get_source_url());
- platform: template
name: Check Then Swap
on_press:
- lambda: |-
// ESP32: the check task reads slow.json while the URL is replaced under it
id(upd).check();
std::string url = "${base}/beta.json";
id(upd).set_source_url(url);
- platform: template
name: Arm Swap In Check
on_press:
- lambda: id(swap_in_check) = true;
- platform: template
name: Heap
on_press:
- lambda: |-
#ifdef USE_ESP8266
ESP_LOGI("harness", "heap free %u", ESP.getFreeHeap());
#else
ESP_LOGI("harness", "heap free %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
#endif
@@ -0,0 +1,65 @@
esp32:
board: esp32dev
framework:
type: esp-idf
watchdog_timeout: 30s
logger:
level: DEBUG
api:
reboot_timeout: 0s
modem:
id: atmodem
rx_pin: GPIO26
tx_pin: GPIO27
baud_rate: 115200
model: SIM7600
apn: internet
pin_code: "0000"
init_at:
- ATI
urc:
name: Modem URC
reboot_timeout: ${modem_reboot_timeout}
on_connect:
logger.log: "modem connected"
on_disconnect:
logger.log: "modem disconnected"
on_sync:
logger.log: "modem synced"
sensor:
- platform: uptime
name: Uptime
update_interval: 30s
- platform: debug
free:
name: Heap Free
loop_time:
name: Loop Time
debug:
update_interval: 30s
button:
- platform: template
name: Modem Enable
on_press:
- modem.enable
- platform: template
name: Modem Disable
on_press:
- modem.disable
- platform: template
name: Modem Send AT
on_press:
- modem.send_at: "AT+CGSN"
interval:
- interval: 20s
then:
- lambda: |-
ESP_LOGI("alive", "uptime %u s enabled=%d connected=%d", (unsigned) (millis() / 1000),
id(atmodem).is_enabled(), id(atmodem).is_connected());
@@ -0,0 +1,26 @@
substitutions:
modem_reboot_timeout: 2min
esphome:
name: modemo
on_boot:
priority: -100
then:
- modem.disable
- modem.enable
- if:
condition:
modem.enabled:
then:
modem.send_at: "AT+CGSN"
else:
modem.enable:
- if:
condition:
not:
modem.connected:
then:
logger.log: modem not connected at boot
packages:
common: !include modem-common.yaml
@@ -0,0 +1,12 @@
substitutions:
modem_reboot_timeout: 0s
esphome:
name: modemw
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
packages:
common: !include modem-common.yaml
@@ -0,0 +1,105 @@
esphome:
name: oi-s3eth
friendly_name: oi-s3eth
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
logger:
level: DEBUG
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
actions:
- action: fetch
variables:
url: string
then:
- online_image.set_url:
id: img
url: !lambda 'return url;'
update: false
- component.update: img
- action: refetch
then:
- component.update: img
- action: fetch_small
variables:
url: string
then:
- online_image.set_url:
id: img_small
url: !lambda 'return url;'
update: false
- component.update: img_small
- action: refetch_small
then:
- component.update: img_small
http_request:
timeout: 4.5s
verify_ssl: false
display:
image:
- platform: online_image
id: img
url: http://127.0.0.1/none.bmp
format: AUTO
type: RGB565
update_interval: never
on_download_finished:
logger.log:
format: "download finished: img cached=%d"
args: [cached]
on_error:
logger.log: "download error: img"
- platform: online_image
id: img_small
url: http://127.0.0.1/none.bmp
format: AUTO
type: RGB565
buffer_size: 256
update_interval: never
on_download_finished:
logger.log:
format: "download finished: img_small cached=%d"
args: [cached]
on_error:
logger.log: "download error: img_small"
button:
- platform: restart
id: reboot_btn
name: Reboot
debug:
update_interval: 2s
sensor:
- platform: debug
loop_time:
name: Loop Time
free:
name: Heap Free
@@ -0,0 +1,15 @@
esphome:
name: sy8266
esp8266:
board: d1_mini
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
packages:
common: !include ../sy-common.yaml
store_yaml:
@@ -0,0 +1,16 @@
esphome:
name: sy8266s
esp8266:
board: d1_mini
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
packages:
common: !include ../sy-common.yaml
store_yaml:
include_secrets: true
@@ -0,0 +1,24 @@
# shared package for the store_yaml hardware test
substitutions:
greeting: "hello from the package"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
ota:
- platform: esphome
password: recoverme123
sensor:
- platform: template
name: Package Sensor
lambda: return 42.0;
update_interval: 60s
packages:
extra: !include sy-extra.yaml
@@ -0,0 +1,6 @@
# second level include
text_sensor:
- platform: template
name: Greeting
lambda: return std::string("${greeting}");
update_interval: 60s
@@ -0,0 +1,35 @@
esp32:
board: esp32dev
framework:
type: esp-idf
esphome:
includes:
- sim.hpp
logger:
level: VERBOSE
logs:
component: DEBUG
api.service: DEBUG
api.connection: DEBUG
scheduler: DEBUG
sensor: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
debug:
update_interval: 10s
sensor:
- platform: debug
free:
name: Heap Free
loop_time:
name: Loop Time
@@ -0,0 +1,86 @@
esphome:
on_boot:
priority: -100
then:
- lambda: |-
sim::loopback(id(u_w));
sim::loopback(id(u_d));
uart:
- id: u_w
tx_pin: GPIO25
rx_pin: GPIO26
baud_rate: 9600
- id: u_d
tx_pin: GPIO32
rx_pin: GPIO33
baud_rate: 9600
sensor:
- platform: wts01
id: wts
name: WTS01 Temperature
uart_id: u_w
accuracy_decimals: 2
filters: []
- platform: ds1603l
name: DS1603L Distance
uart_id: u_d
button:
- platform: template
name: W Good
on_press:
- lambda: sim::send(id(u_w), "W Good 22.18", sim::wts01(22, 18));
- platform: template
name: W Negative
on_press:
- lambda: sim::send(id(u_w), "W Negative -5.50", sim::wts01(0x85, 50));
- platform: template
name: W Extra 55
on_press:
- lambda: sim::send(id(u_w), "W Extra 55 then 23.25", sim::cat({0x55}, sim::wts01(23, 25)));
- platform: template
name: W 55 Breaks Header
on_press:
- lambda: sim::send(id(u_w), "W 55 01 01 then 26.00", sim::cat({0x55, 0x01, 0x01}, sim::wts01(26, 0)));
- platform: template
name: W Bad Checksum
on_press:
- lambda: sim::send(id(u_w), "W bad checksum 30.00", sim::wts01(30, 0, true));
- platform: template
name: W Garbage Then Frame
on_press:
- lambda: sim::send(id(u_w), "W garbage then 24.50", sim::cat({0x01, 0x02, 0x55, 0x01, 0x99}, sim::wts01(24, 50)));
- platform: template
name: W Split
on_press:
- lambda: sim::send(id(u_w), "W split head 27.75", sim::part(sim::wts01(27, 75), 0, 5));
- delay: 300ms
- lambda: sim::send(id(u_w), "W split rest", sim::part(sim::wts01(27, 75), 5, 9));
- platform: template
name: W Two Frames
on_press:
- lambda: sim::send(id(u_w), "W 20.01 then 20.02", sim::cat(sim::wts01(20, 1), sim::wts01(20, 2)));
- platform: template
name: D Good
on_press:
- lambda: sim::send(id(u_d), "D Good 1000", sim::ds1603l(1000));
- platform: template
name: D Misaligned
on_press:
- lambda: sim::send(id(u_d), "D FF 01 then 1234", sim::cat({0xFF, 0x01}, sim::ds1603l(1234)));
- platform: template
name: D Bad Checksum
on_press:
- lambda: sim::send(id(u_d), "D bad checksum 2000", sim::ds1603l(2000, true));
- platform: template
name: D Garbage Then Frame
on_press:
- lambda: sim::send(id(u_d), "D garbage then 500", sim::cat({0x12, 0x34}, sim::ds1603l(500)));
- platform: template
name: D Split
on_press:
- lambda: sim::send(id(u_d), "D split head 777", sim::part(sim::ds1603l(777), 0, 2));
- delay: 300ms
- lambda: sim::send(id(u_d), "D split rest", sim::part(sim::ds1603l(777), 2, 4));
@@ -0,0 +1,6 @@
# Run file for build a; copy it as frabase.yaml with name frabase for the base build.
esphome:
name: fra
packages:
common: !include fr-common.yaml
parts: !include fra-parts.yaml
@@ -0,0 +1,104 @@
esphome:
on_boot:
priority: 200
then:
- lambda: |-
sim::loopback(id(u_s));
sim::loopback(id(u_f));
uart:
- id: u_s
tx_pin: GPIO25
rx_pin: GPIO26
baud_rate: 9600
- id: u_f
tx_pin: GPIO32
rx_pin: GPIO33
baud_rate: 2400
parity: EVEN
sensor:
- platform: sm300d2
id: sm
uart_id: u_s
update_interval: never
co2:
name: SM CO2
temperature:
name: SM Temperature
- platform: ufm01
temperature:
name: UFM Temperature
accumulated_flow:
name: UFM Accumulated Flow
ufm01:
id: ufm
uart_id: u_f
interval:
- interval: 1s
then:
- lambda: id(u_f).write_array(sim::ufm01(0x21, 0x50).data(), 32);
script:
- id: sm_update
then:
- delay: 100ms
- lambda: |-
uint32_t t0 = micros();
id(sm).update();
ESP_LOGI("sim", "sm300d2 update() took %u us", (unsigned) (micros() - t0));
button:
- platform: template
name: S Good
on_press:
- lambda: sim::send(id(u_s), "S Good co2 600 23.4", sim::sm300d2(600, 23, 4));
- script.execute: sm_update
- platform: template
name: S Empty Update
on_press:
- script.execute: sm_update
- platform: template
name: S Wrong Second Header
on_press:
- lambda: sim::send(id(u_s), "S 3C 05 then co2 700", sim::cat({0x3C, 0x05}, sim::sm300d2(700, 24, 0)));
- script.execute: sm_update
- platform: template
name: S Garbage Then Frame
on_press:
- lambda: sim::send(id(u_s), "S garbage then co2 800", sim::cat({0x01, 0x3C, 0x99}, sim::sm300d2(800, 25, 0)));
- script.execute: sm_update
- platform: template
name: S Cut Frame
on_press:
- lambda: sim::send(id(u_s), "S first 10 bytes of co2 900", sim::part(sim::sm300d2(900, 26, 0), 0, 10));
- script.execute: sm_update
- platform: template
name: S Two Frames
on_press:
- lambda: sim::send(id(u_s), "S co2 1001 then 1002", sim::cat(sim::sm300d2(1001, 27, 0), sim::sm300d2(1002, 28, 0)));
- script.execute: sm_update
- delay: 1s
- script.execute: sm_update
- platform: template
name: S Bad Checksum
on_press:
- lambda: |-
auto f = sim::sm300d2(1100, 29, 0);
f[16] += 0x11;
sim::send(id(u_s), "S bad checksum co2 1100", f);
- script.execute: sm_update
- platform: template
name: F Cut Then Frame
on_press:
- lambda: sim::send(id(u_f), "F 3C 32 00 00 then 33.33", sim::cat({0x3C, 0x32, 0x00, 0x00}, sim::ufm01(0x33, 0x33)));
- platform: template
name: F Bad Checksum Then Frame
on_press:
- lambda: sim::send(id(u_f), "F bad checksum 66.66 then 44.44", sim::cat(sim::ufm01(0x66, 0x66, true), sim::ufm01(0x44, 0x44)));
- platform: template
name: F Garbage Then Frame
on_press:
- lambda: sim::send(id(u_f), "F garbage then 55.55", sim::cat({0x16, 0x3C, 0x00}, sim::ufm01(0x55, 0x55)));
@@ -0,0 +1,6 @@
# Run file for build b; copy it as frbbase.yaml with name frbbase for the base build.
esphome:
name: frb
packages:
common: !include fr-common.yaml
parts: !include frb-parts.yaml
@@ -0,0 +1,64 @@
uart:
- id: u_a
tx_pin:
number: GPIO25
allow_other_uses: true
rx_pin:
number: GPIO26
allow_other_uses: true
baud_rate: 9600
- id: u_b
tx_pin:
number: GPIO26
allow_other_uses: true
rx_pin:
number: GPIO25
allow_other_uses: true
baud_rate: 9600
esphome:
on_boot:
priority: 1000
then:
- lambda: sim::probe.u = id(u_b);
interval:
- interval: 10ms
then:
- lambda: sim::probe.poll();
sensor:
- platform: dfrobot_sen0465_uart
id: gas
uart_id: u_a
model: SEN0466
alarm:
threshold: 35
type: high
update_interval: 5s
concentration:
name: CO Concentration
temperature:
name: CO Temperature
select:
- platform: template
name: Probe Mode
optimistic: true
options: ["0 normal", "1 tail", "2 FF tail", "3 silent", "4 split", "5 wrong gas"]
initial_option: "0 normal"
set_action:
- lambda: |-
sim::probe.mode = x.c_str()[0] - '0';
ESP_LOGI("sim", "probe mode %d", sim::probe.mode);
number:
- platform: template
name: Probe Conc
optimistic: true
min_value: 0
max_value: 1000
step: 1
initial_value: 25
set_action:
- lambda: sim::probe.conc = (uint16_t) x;
@@ -0,0 +1,6 @@
# Run file for build c; copy it as frcbase.yaml with name frcbase for the base build.
esphome:
name: frc
packages:
common: !include fr-common.yaml
parts: !include frc-parts.yaml
@@ -0,0 +1,155 @@
#pragma once
// Simulated UART sensors for the FrameReader hardware test: frame builders, UART loopback and a SEN0465 responder.
#include <driver/uart.h>
#include <cstring>
#include <vector>
#include "esphome/components/uart/uart_component_esp_idf.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace sim {
using esphome::uart::IDFUARTComponent;
static const char *const TAG = "sim";
inline void loopback(IDFUARTComponent *u) {
esp_err_t err = uart_set_loop_back((uart_port_t) u->get_hw_serial_number(), true);
ESP_LOGI(TAG, "UART%u loopback %s", u->get_hw_serial_number(),
err == ESP_OK ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("FAILED"));
}
inline void send(IDFUARTComponent *u, const char *name, std::vector<uint8_t> bytes) {
char hex[200] = {0};
size_t n = 0;
for (uint8_t b : bytes) {
if (n + 4 > sizeof(hex))
break;
n += snprintf(hex + n, sizeof(hex) - n, "%02X ", b);
}
ESP_LOGI(TAG, ">>> %s: %s", name, hex);
u->write_array(bytes.data(), bytes.size());
}
inline uint8_t sum(const std::vector<uint8_t> &v, size_t from, size_t to) {
uint8_t s = 0;
for (size_t i = from; i < to; i++)
s += v[i];
return s;
}
// wts01: 55 01 01 04 01 11 T Td Ck
inline std::vector<uint8_t> wts01(uint8_t t, uint8_t td, bool bad_ck = false) {
std::vector<uint8_t> f = {0x55, 0x01, 0x01, 0x04, 0x01, 0x11, t, td, 0};
f[8] = sum(f, 0, 8) + (bad_ck ? 1 : 0);
return f;
}
// ds1603l: FF H L Ck
inline std::vector<uint8_t> ds1603l(uint16_t mm, bool bad_ck = false) {
std::vector<uint8_t> f = {0xFF, (uint8_t) (mm >> 8), (uint8_t) mm, 0};
f[3] = sum(f, 0, 3) + (bad_ck ? 1 : 0);
return f;
}
// sm300d2: 3C 02 co2 hcho tvoc pm25 pm10 (16 bit each) temp temp.dec hum hum.dec Ck
inline std::vector<uint8_t> sm300d2(uint16_t co2, uint8_t temp, uint8_t temp_dec) {
std::vector<uint8_t> f = {
0x3C, 0x02, (uint8_t) (co2 >> 8), (uint8_t) co2, 0, 10, 0, 20, 0, 5, 0, 8, temp, temp_dec, 45, 6, 0};
f[16] = sum(f, 0, 16);
return f;
}
// ufm01 active frame, temperature t.tt as BCD (t_int, t_frac)
inline std::vector<uint8_t> ufm01(uint8_t t_int_bcd, uint8_t t_frac_bcd, bool bad_ck = false) {
std::vector<uint8_t> f(32, 0);
f[0] = 0x3C;
f[1] = 0x32;
f[2] = 0x01;
f[4] = 0x14;
f[5] = 0x07;
f[6] = 0x23;
f[7] = 0x01;
f[9] = 0x56; // accumulated flow 0.056
f[15] = 0x0B;
f[16] = 0x50; // flow
f[21] = 0x0C;
f[24] = 0x0D;
f[25] = t_frac_bcd;
f[26] = t_int_bcd;
f[31] = 0x16;
f[30] = sum(f, 0, 30) + (bad_ck ? 1 : 0);
return f;
}
inline std::vector<uint8_t> cat(std::vector<uint8_t> a, const std::vector<uint8_t> &b) {
a.insert(a.end(), b.begin(), b.end());
return a;
}
inline std::vector<uint8_t> part(const std::vector<uint8_t> &v, size_t from, size_t to) {
return std::vector<uint8_t>(v.begin() + from, v.begin() + to);
}
// SEN0465 probe on the other end of a cross-wired UART pair. Modes: 0 normal, 1 tail before the answer,
// 2 tail with an FF before the answer, 3 silent, 4 answer split in two 300 ms apart, 5 wrong gas type.
struct Probe {
IDFUARTComponent *u{nullptr};
int mode{0};
uint8_t req[9];
uint8_t n{0};
std::vector<uint8_t> rest;
uint32_t rest_at{0};
uint16_t conc{25};
static uint8_t ck(const std::vector<uint8_t> &f) { return (uint8_t) (~sum(f, 1, 7) + 1); }
void poll() {
if (!this->rest.empty() && millis() - this->rest_at >= 300) {
send(this->u, "probe answer rest", this->rest);
this->rest.clear();
}
uint8_t c;
while (this->u->available() && this->u->read_byte(&c)) {
if (this->n == 0 && c != 0xFF)
continue;
this->req[this->n++] = c;
if (this->n < 9)
continue;
this->n = 0;
this->answer_(this->req[2]);
}
}
void answer_(uint8_t cmd) {
ESP_LOGI(TAG, "probe got request 0x%02X (mode %d)", cmd, this->mode);
std::vector<uint8_t> f;
if (cmd == 0x88) {
f = {0xFF,
0x88,
(uint8_t) (this->conc >> 8),
(uint8_t) this->conc,
(uint8_t) (this->mode == 5 ? 0x05 : 0x04),
0x00,
0x02,
0x00,
0};
} else {
f = {0xFF, cmd, 0x01, 0, 0, 0, 0, 0, 0};
}
f[8] = ck(f);
switch (this->mode) {
case 1:
send(this->u, "probe tail+answer", cat({0x12, 0x34, 0x56}, f));
break;
case 2:
send(this->u, "probe FF tail+answer", cat({0x12, 0xFF, 0x34}, f));
break;
case 3:
ESP_LOGI(TAG, "probe silent");
break;
case 4:
send(this->u, "probe answer head", part(f, 0, 5));
this->rest = part(f, 5, 9);
this->rest_at = millis();
break;
default:
send(this->u, "probe answer", f);
}
}
};
inline Probe probe;
} // namespace sim
+1 -1
View File
@@ -286,7 +286,7 @@ jobs:
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Run prek
uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2
uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
with:
prek-version: ${{ steps.prek.outputs.version }}
# This job only runs on pull requests, so nothing ever populates
+1 -1
View File
@@ -94,7 +94,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
skip-existing: true
+9 -2
View File
@@ -209,7 +209,8 @@ file does, and it is the authority when they disagree. The most useful starting
registered with `automation.ApplyBytes` (see Actions below), which keeps a constant payload in flash;
`TEMPLATABLE_BYTES` is for a `play()` that does more with the bytes.
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
logged with `get_log_str` and `LOG_STR_ARG`, published on a text sensor with
`publish_state(Table::get_progmem_str(idx, fallback))`, which copies it out of flash on ESP8266.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`); pick
@@ -649,7 +650,13 @@ file does, and it is the authority when they disagree. The most useful starting
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`, `cv.only_with_esp_idf`.
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
* **Schema Extensions:**
```python
CONFIG_SCHEMA = cv.Schema({ ... })
+12 -1
View File
@@ -158,6 +158,7 @@ esphome/components/dashboard_import/* @esphome/core
esphome/components/datetime/* @jesserockz @rfdarter
esphome/components/debug/* @esphome/core
esphome/components/delonghi/* @grob6000
esphome/components/delta_wallbox/* @epicRE
esphome/components/dew_point/* @CFlix
esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
@@ -175,6 +176,11 @@ esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duco/* @kokx
esphome/components/duco/number/* @kokx
esphome/components/duco/select/* @kokx
esphome/components/duco/sensor/* @kokx
esphome/components/duco/text_sensor/* @kokx
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
@@ -374,7 +380,8 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC
esphome/components/max17048/* @B48D81EFCC @ryansch
esphome/components/max170xx/* @ryansch
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
@@ -429,6 +436,7 @@ esphome/components/modbus_controller/sensor/* @martgras
esphome/components/modbus_controller/switch/* @martgras
esphome/components/modbus_controller/text_sensor/* @martgras
esphome/components/modbus_server/* @exciton
esphome/components/modem/* @oarcher
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
esphome/components/mopeka_pro_check/* @spbrogan
esphome/components/mopeka_std_check/* @Fabian-Schmidt
@@ -462,6 +470,8 @@ esphome/components/openthread/* @mrene
esphome/components/opt3001/* @ccutrer
esphome/components/ota/* @esphome/core
esphome/components/output/* @esphome/core
esphome/components/packet_interface/* @clydebarrow
esphome/components/packet_interface/packet_transport/* @clydebarrow
esphome/components/packet_transport/* @clydebarrow
esphome/components/pca6416a/* @Mat931
esphome/components/pca9505/* @ajfriesen @ssieb
@@ -682,6 +692,7 @@ esphome/components/tuya/water_heater/* @iago-veiga
esphome/components/uart/* @esphome/core
esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
esphome/components/uart/packet_interface/* @clydebarrow
esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
+2
View File
@@ -84,3 +84,5 @@ FROM base-${BUILD_TYPE} AS final
# Copy esphome and install
COPY . /esphome
RUN uv pip install --no-cache-dir -e /esphome
# The tree has no .git; lets SBOM manifests describe it as the release it was built from
ENV ESPHOME_IS_DOCKER_IMAGE=true
-2
View File
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
normalize_dependencies,
parse_library_json,
parse_library_properties,
warn_properties_depends,
)
_LOGGER = logging.getLogger(__name__)
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
"resolved automatically; add them with add_library() if needed",
name,
)
warn_properties_depends(name, data)
build = data.get("build")
if isinstance(build, dict) and build.get("extraScript"):
# Scripts only run on the converted path; building without
+1
View File
@@ -49,6 +49,7 @@ from esphome.cpp_generator import ( # noqa: F401
static_const_array,
static_function,
templatable,
unique_global_id,
variable,
with_local_variable,
)
@@ -91,17 +91,19 @@ def alarm_control_panel_schema(
ALARM_CONTROL_PANEL_ACTION_SCHEMA = maybe_simple_id(
{
cv.GenerateID(): cv.use_id(AlarmControlPanel),
}
)
ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA = maybe_simple_id(
{
cv.GenerateID(): cv.use_id(AlarmControlPanel),
cv.Optional(CONF_CODE): cv.templatable(cv.string),
}
)
ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
{
cv.GenerateID(): cv.use_id(AlarmControlPanel),
}
)
ALARM_CONTROL_PANEL_CONDITION_SCHEMA = ALARM_CONTROL_PANEL_ACTION_SCHEMA
_CALLBACK_AUTOMATIONS = (
@@ -194,7 +196,7 @@ for _name, _arm in (
):
automation.register_apply_action(
_name,
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA,
automation.ApplyCall(_arm),
automation.ApplyField(CONF_CODE, "set_code", cg.std_string),
call="make_call",
@@ -293,14 +293,9 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
}
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
// protect from refreshing too often
uint32_t now = micros();
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
(now - this->last_refresh_) < *this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
if (this->throttle_show_(now, this->last_refresh_, this->max_refresh_rate_))
return;
}
this->last_refresh_ = now;
this->mark_shown_();
@@ -369,17 +364,6 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
this->status_clear_warning();
}
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
void BekenSPILEDStripLightOutput::dump_config() {
ESP_LOGCONFIG(TAG,
"Beken SPI LED Strip:\n"
@@ -390,7 +374,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
" Channel colors: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_, this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -18,15 +18,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
float get_setup_priority() const override;
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
}
return traits;
}
light::LightTraits get_traits() override { return this->channel_colors_.traits(); }
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
@@ -38,14 +30,16 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++)
this->effect_data_[i] = 0;
if (this->effect_data_ != nullptr)
memset(this->effect_data_, 0, this->num_leds_);
}
void dump_config() override;
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
light::ESPColorView get_view_internal(int32_t index) const override {
return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_);
}
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
@@ -62,7 +56,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
uint32_t max_refresh_rate_{0};
};
} // namespace esphome::beken_spi_led_strip
+11 -13
View File
@@ -3,7 +3,6 @@ from logging import getLogger
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_ON_STATE_CHANGE
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
@@ -29,7 +28,6 @@ from esphome.const import (
CONF_STATE,
CONF_TIMING,
CONF_TRIGGER_ID,
CONF_WEB_SERVER,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_CARBON_MONOXIDE,
@@ -61,10 +59,12 @@ from esphome.const import (
DEVICE_CLASS_VIBRATION,
DEVICE_CLASS_WINDOW,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity_helpers
from esphome.core.entity_helpers import (
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
mqtt_component_class,
queue_entity_register,
setup_device_class,
setup_entity,
@@ -433,14 +433,14 @@ def validate_publish_initial_state(value):
_BINARY_SENSOR_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMPONENT_SCHEMA)
.extend(zigbee.BINARY_SENSOR_SCHEMA)
.extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA)
.extend(
{
cv.GenerateID(): cv.declare_id(BinarySensor),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt.MQTTBinarySensorComponent
mqtt_component_class("MQTTBinarySensorComponent")
),
cv.Exclusive(
CONF_PUBLISH_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
@@ -508,7 +508,7 @@ _BINARY_SENSOR_SCHEMA = (
_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor"))
_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor)
_BINARY_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_binary_sensor"))
def binary_sensor_schema(
@@ -619,14 +619,12 @@ async def setup_binary_sensor_core_(var, config):
CORE.add_job(_build_binary_sensor_automations, var, config)
if mqtt_id := config.get(CONF_MQTT_ID):
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
await entity_helpers.setup_entity_integrations(var, config)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
if "zigbee" in CORE.loaded_integrations:
from esphome.components import zigbee
await zigbee.setup_binary_sensor(var, config)
await zigbee.setup_binary_sensor(var, config)
async def register_binary_sensor(var, config):
+5
View File
@@ -20,6 +20,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
).extend(cv.COMPONENT_SCHEMA)
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
platform="bp1658cj", hub_key=CONF_BP1658CJ_ID
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
+5
View File
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
).extend(cv.COMPONENT_SCHEMA)
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
platform="bp5758d", hub_key=CONF_BP5758D_ID
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
+2 -5
View File
@@ -1,4 +1,5 @@
#include "ch422g.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ch422g {
@@ -59,11 +60,7 @@ bool CH422GComponent::digital_read(uint8_t pin) {
}
void CH422GComponent::digital_write(uint8_t pin, bool value) {
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
assign_bits(this->output_bits_, 1 << pin, value);
this->write_outputs_();
}
+2 -5
View File
@@ -1,4 +1,5 @@
#include "ch423.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -62,11 +63,7 @@ bool CH423Component::digital_read(uint8_t pin) {
}
void CH423Component::digital_write(uint8_t pin, bool value) {
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
assign_bits(this->output_bits_, 1 << pin, value);
this->write_outputs_();
}
+3 -2
View File
@@ -24,7 +24,9 @@ climate::ClimateTraits ClimateIR::traits() {
return traits;
}
void ClimateIR::setup() {
void ClimateIR::setup() { this->setup_(this->restore_state_()); }
void ClimateIR::setup_(optional<climate::ClimateDeviceRestoreState> restore) {
if (this->sensor_) {
this->sensor_->add_on_state_callback([this](float state) {
this->current_temperature = state;
@@ -43,7 +45,6 @@ void ClimateIR::setup() {
}
// restore set points
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(this);
} else {
@@ -60,6 +60,10 @@ class ClimateIR : public Component,
/// Transmit via IR the state of this climate controller.
virtual void transmit_state() = 0;
/// setup() with the restored state already loaded, for subclasses that read it first. Load it only once:
/// every restore_state_() call takes a new preference slot on some platforms.
void setup_(optional<climate::ClimateDeviceRestoreState> restore);
// Dummy implement on_receive so implementation is optional for inheritors
bool on_receive(remote_base::RemoteReceiveData data) override { return false; };
+5
View File
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
)
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
platform="dac7678", hub_key=CONF_DAC7678_ID
)
async def to_code(config: ConfigType) -> MockObj:
paren = await cg.get_variable(config[CONF_DAC7678_ID])
var = cg.new_Pvariable(config[CONF_ID])
@@ -0,0 +1,42 @@
import esphome.codegen as cg
from esphome.components import ble_client, time
import esphome.config_validation as cv
from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
from esphome.types import ConfigType
CODEOWNERS = ["@epicRE"]
DEPENDENCIES = ["ble_client"]
DOMAIN = "delta_wallbox"
MULTI_CONF = True
CONF_DELTA_WALLBOX_ID = "delta_wallbox_id"
CONF_USER = "user"
delta_wallbox_ns = cg.esphome_ns.namespace("delta_wallbox")
DeltaWallbox = delta_wallbox_ns.class_(
"DeltaWallbox", ble_client.BLEClientNode, cg.PollingComponent
)
CONFIG_SCHEMA = (
cv.COMPONENT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(DeltaWallbox),
cv.Required(CONF_PASSWORD): cv.All(cv.string_strict, cv.Length(max=22)),
cv.Optional(CONF_PIN): cv.int_range(min=0, max=999999),
cv.Optional(CONF_USER, default="admin"): cv.All(
cv.string_strict, cv.Length(max=6)
),
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
}
)
.extend(ble_client.BLE_CLIENT_SCHEMA)
.extend(cv.polling_component_schema("15s"))
)
async def to_code(config: ConfigType) -> None:
var = await ble_client.new_ble_node_component(
config, config[CONF_USER], config[CONF_PASSWORD]
)
cg.add_optional(var.set_pin, config.get(CONF_PIN))
await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID))
@@ -0,0 +1,30 @@
from esphome.components import button
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
DEPENDENCIES = ["delta_wallbox"]
CONF_START_CHARGING = "start_charging"
CONF_STOP_CHARGING = "stop_charging"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
cv.Optional(CONF_START_CHARGING): button.button_schema(
button.ApplyButton,
icon="mdi:play-circle-outline",
),
cv.Optional(CONF_STOP_CHARGING): button.button_schema(
button.ApplyButton,
icon="mdi:stop-circle-outline",
),
}
)
async def to_code(config: ConfigType) -> None:
buttons = button.sub_buttons(config, parent=config[CONF_DELTA_WALLBOX_ID])
await buttons(CONF_START_CHARGING, None, apply="start_charging()")
await buttons(CONF_STOP_CHARGING, None, apply="stop_charging()")
@@ -0,0 +1,691 @@
#include "delta_wallbox.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#ifdef USE_ESP32
namespace esphome::delta_wallbox {
ESPHOME_LOG_TAG(TAG, "delta_wallbox");
// Modbus function codes
static constexpr uint8_t FC_READ_HOLDING_REGISTERS = 0x03;
static constexpr uint8_t FC_WRITE_SINGLE_REGISTER = 0x06;
static constexpr uint8_t FC_WRITE_MULTIPLE_REGISTERS = 0x10;
static constexpr uint8_t FC_EXCEPTION_FLAG = 0x80;
// Response lengths in bytes, CRC included
static constexpr size_t READ_RESPONSE_OVERHEAD = 5;
static constexpr size_t WRITE_RESPONSE_LEN = 8;
static constexpr size_t EXCEPTION_RESPONSE_LEN = 5;
// Frame bodies without CRC
static constexpr size_t LOGIN_BODY_LEN = 35;
static constexpr size_t LOGIN_PASS_LEN = 22;
static constexpr size_t LOGIN_USER_LEN = 6;
static constexpr size_t TIME_SYNC_BODY_LEN = 15;
static constexpr uint8_t REGISTER_BYTE_COUNT = 2;
static constexpr uint8_t TWO_REGISTER_BYTE_COUNT = 4;
static constexpr uint8_t CHARGING_INFO_BYTE_COUNT = 18;
static constexpr uint8_t SERIAL_TEXT_BYTE_COUNT = 14;
static constexpr uint32_t RETRANSMIT_TIMEOUT_MS = 4000;
static constexpr uint32_t RETRANSMIT_TIMEOUT_ID = 0;
static constexpr uint8_t MAX_PAIRING_ATTEMPTS = 5;
static constexpr uint16_t PAIRING_STATUS_READY = 0x0007;
static constexpr uint16_t PAIRING_STATUS_WAITING = 0x0008;
static constexpr uint8_t LOGIN_ACCEPTED = 0x01;
static constexpr uint8_t CMD_POLL_CHARGING[] = {0x01, 0x03, 0x01, 0x1B, 0x00, 0x09};
static constexpr uint8_t CMD_GET_MODEL[] = {0x01, 0x03, 0x01, 0x85, 0x00, 0x0E};
static constexpr uint8_t CMD_GET_BT_FW_VERSION[] = {0x02, 0x03, 0x01, 0x00, 0x00, 0x01};
static constexpr uint8_t CMD_CHECK_PAIRING[] = {0x02, 0x03, 0x01, 0x00, 0x00, 0x03};
static constexpr uint8_t CMD_GET_LOGIN_USER[] = {0x01, 0x03, 0x01, 0x7D, 0x00, 0x03};
static constexpr uint8_t CMD_READ_LOGIN_STATUS[] = {0x01, 0x03, 0x01, 0x80, 0x00, 0x01};
static constexpr uint8_t CMD_GET_LOGIN_ROLE[] = {0x01, 0x03, 0x01, 0x81, 0x00, 0x01};
static constexpr uint8_t CMD_GET_CHARGER_FW_VERSION[] = {0x01, 0x03, 0x01, 0x10, 0x00, 0x02};
static constexpr uint8_t CMD_GET_MAX_CURRENT[] = {0x01, 0x03, 0x01, 0x12, 0x00, 0x02};
static constexpr uint8_t CMD_GET_SERIAL_NUMBER[] = {0x01, 0x03, 0x01, 0x09, 0x00, 0x01};
static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0x01};
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 0x00, 0x01};
// Indexed by DeltaState
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
"GET_SERIAL_NUMBER", "HALTED", "IDLE", "SEND_LOGIN_CREDENTIALS", "TIME_SYNC");
static_assert(DeltaStateStrings::LAST_INDEX == static_cast<uint8_t>(DeltaState::DELTA_STATE_TIME_SYNC),
"DeltaStateStrings must list every DeltaState");
static const LogString *delta_state_to_string(DeltaState state) {
return DeltaStateStrings::get_log_str(static_cast<uint8_t>(state), DeltaStateStrings::LAST_INDEX);
}
// The function code a handshake step waits for; 0 where any frame is handled
static uint8_t expected_function_code(DeltaState state) {
switch (state) {
case DeltaState::DELTA_STATE_IDLE:
case DeltaState::DELTA_STATE_HALTED:
case DeltaState::DELTA_STATE_CONNECTED:
return 0;
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS:
case DeltaState::DELTA_STATE_TIME_SYNC:
return FC_WRITE_MULTIPLE_REGISTERS;
default:
return FC_READ_HOLDING_REGISTERS;
}
}
// UUID references
static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID =
esp32_ble_tracker::ESPBTUUID::from_raw("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
static const esp32_ble_tracker::ESPBTUUID TX_UUID =
esp32_ble_tracker::ESPBTUUID::from_raw("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
static const esp32_ble_tracker::ESPBTUUID RX_UUID =
esp32_ble_tracker::ESPBTUUID::from_raw("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
// Appends the little-endian Modbus CRC-16 to the len bytes in frame and returns the new length.
static size_t append_crc(uint8_t *frame, size_t len) {
const uint16_t crc = crc16(frame, static_cast<uint16_t>(len));
frame[len] = static_cast<uint8_t>(crc & 0xFF);
frame[len + 1] = static_cast<uint8_t>(crc >> 8);
return len + 2;
}
static bool verify_crc(const uint8_t *data, size_t len) {
if (len < EXCEPTION_RESPONSE_LEN)
return false;
const uint16_t calculated = crc16(data, static_cast<uint16_t>(len - 2));
const uint16_t received = data[len - 2] | (data[len - 1] << 8);
return calculated == received;
}
// Login write body: 22-byte password and 6-byte user, both left-padded with zeros
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
0x01, 0x10, 0x01, 0x72, 0x00, 0x0E, 0x1C, // Unit, FC16, register 0x0172, 14 registers, 28 bytes
};
const size_t pass_len = std::min(password.size(), LOGIN_PASS_LEN);
std::copy_n(password.c_str(), pass_len, frame.begin() + 7 + (LOGIN_PASS_LEN - pass_len));
uint8_t *user_chunk = frame.data() + 7 + LOGIN_PASS_LEN;
bool is_digit = !user.empty();
for (size_t i = 0; i < user.size() && is_digit; i++) {
is_digit = isdigit(static_cast<unsigned char>(user.c_str()[i]));
}
if (is_digit) {
const uint64_t val = strtoull(user.c_str(), nullptr, 10);
for (size_t i = 0; i < LOGIN_USER_LEN; i++) {
user_chunk[LOGIN_USER_LEN - 1 - i] = static_cast<uint8_t>((val >> (8 * i)) & 0xFF);
}
} else {
const size_t user_len = std::min(user.size(), LOGIN_USER_LEN);
std::copy_n(user.c_str(), user_len, user_chunk + LOGIN_USER_LEN - user_len);
}
return frame;
}
static std::array<uint8_t, TIME_SYNC_BODY_LEN> build_time_sync_body(int year, int month, int day, int hour, int minute,
int second, int weekday) {
const uint8_t weekday_bitmap = static_cast<uint8_t>(1 << weekday); // Sunday=bit0, Mon=bit1...
return {
0x01,
0x10,
0x01,
0x14,
0x00,
0x04,
0x08, // Unit, FC16, register 0x0114, 4 registers, 8 bytes
static_cast<uint8_t>(year % 100),
static_cast<uint8_t>(month),
weekday_bitmap,
static_cast<uint8_t>(day),
static_cast<uint8_t>(hour),
static_cast<uint8_t>(minute),
static_cast<uint8_t>(second),
0x00,
};
}
void DeltaWallbox::dump_config() {
ESP_LOGCONFIG(TAG, "Delta Wallbox:");
ESP_LOGCONFIG(TAG, " User: %s", this->user_.c_str());
ESP_LOGCONFIG(TAG, " BLE PIN configured: %s", this->has_pin_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"));
LOG_UPDATE_INTERVAL(this);
}
void DeltaWallbox::update() {
if (this->is_connected()) {
// Start poll flow sequence
ESP_LOGD(TAG, "Polling charging info...");
this->send_frame_(CMD_POLL_CHARGING, sizeof(CMD_POLL_CHARGING));
}
}
void DeltaWallbox::write_frame_(const uint8_t *frame, size_t len) {
if (this->tx_char_handle_ == 0) {
ESP_LOGW(TAG, "TX characteristic handle is not ready while in state %s. Dropping %u-byte frame.",
LOG_STR_ARG(delta_state_to_string(this->state_)), (unsigned int) len);
return;
}
ESP_LOGD(TAG, "Sending %u-byte frame in state %s (conn_id=%u, tx_handle=0x%04X).", (unsigned int) len,
LOG_STR_ARG(delta_state_to_string(this->state_)), this->parent_->get_conn_id(), this->tx_char_handle_);
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->tx_char_handle_, len,
const_cast<uint8_t *>(frame), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status != ESP_GATT_OK) {
ESP_LOGW(TAG, "esp_ble_gattc_write_char failed, status=%d", status);
}
}
// Copies body into a stack buffer, appends the CRC and writes the frame.
void DeltaWallbox::send_frame_(const uint8_t *body, size_t len) {
std::array<uint8_t, MAX_FRAME_LEN> frame{};
std::copy_n(body, len, frame.begin());
this->write_frame_(frame.data(), append_crc(frame.data(), len));
}
void DeltaWallbox::drop_rx_bytes_(size_t count) {
count = std::min(count, this->rx_len_);
std::memmove(this->rx_buffer_.data(), this->rx_buffer_.data() + count, this->rx_len_ - count);
this->rx_len_ -= count;
}
void DeltaWallbox::send_next_state_command_() {
ESP_LOGD(TAG, "Preparing command for state %s.", LOG_STR_ARG(delta_state_to_string(this->state_)));
switch (this->state_) {
case DeltaState::DELTA_STATE_GET_MODEL:
ESP_LOGI(TAG, "Requesting charger model name...");
this->send_frame_(CMD_GET_MODEL, sizeof(CMD_GET_MODEL));
break;
case DeltaState::DELTA_STATE_GET_BT_FW_VERSION:
ESP_LOGI(TAG, "Requesting BT firmware version...");
this->send_frame_(CMD_GET_BT_FW_VERSION, sizeof(CMD_GET_BT_FW_VERSION));
break;
case DeltaState::DELTA_STATE_CHECK_PAIRING:
ESP_LOGI(TAG, "Checking pairing status...");
this->send_frame_(CMD_CHECK_PAIRING, sizeof(CMD_CHECK_PAIRING));
break;
case DeltaState::DELTA_STATE_GET_LOGIN_USER:
ESP_LOGI(TAG, "Querying user profile...");
this->send_frame_(CMD_GET_LOGIN_USER, sizeof(CMD_GET_LOGIN_USER));
break;
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT0:
ESP_LOGI(TAG, "Checking pre-login status register...");
this->send_frame_(CMD_READ_LOGIN_STATUS, sizeof(CMD_READ_LOGIN_STATUS));
break;
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS: {
ESP_LOGI(TAG, "Transmitting login credentials...");
const auto body = build_login_body(this->user_, this->password_);
this->send_frame_(body.data(), body.size());
break;
}
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT:
ESP_LOGI(TAG, "Verifying login result...");
this->send_frame_(CMD_READ_LOGIN_STATUS, sizeof(CMD_READ_LOGIN_STATUS));
break;
case DeltaState::DELTA_STATE_GET_LOGIN_ROLE:
ESP_LOGI(TAG, "Checking role privileges...");
this->send_frame_(CMD_GET_LOGIN_ROLE, sizeof(CMD_GET_LOGIN_ROLE));
break;
case DeltaState::DELTA_STATE_TIME_SYNC: {
#ifdef USE_TIME
// Only a valid time is written; the charger keeps its own clock otherwise
if (this->time_id_ != nullptr) {
auto tm = this->time_id_->now();
if (tm.is_valid()) {
ESP_LOGI(TAG, "Synchronizing wallbox clock...");
// ESPTime day_of_week is 1 (Sunday) to 7 (Saturday); the charger counts from 0
const auto body = build_time_sync_body(tm.year, tm.month, tm.day_of_month, tm.hour, tm.minute, tm.second,
tm.day_of_week - 1);
this->send_frame_(body.data(), body.size());
break;
}
}
#endif
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
break;
}
case DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION:
ESP_LOGI(TAG, "Requesting charger main firmware version...");
this->send_frame_(CMD_GET_CHARGER_FW_VERSION, sizeof(CMD_GET_CHARGER_FW_VERSION));
break;
case DeltaState::DELTA_STATE_GET_SERIAL_NUMBER:
ESP_LOGI(TAG, "Requesting wallbox serial number...");
this->send_frame_(CMD_GET_SERIAL_NUMBER, sizeof(CMD_GET_SERIAL_NUMBER));
break;
default:
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
return;
}
this->set_timeout(RETRANSMIT_TIMEOUT_ID, RETRANSMIT_TIMEOUT_MS, [this]() { this->retransmit_(); });
}
void DeltaWallbox::retransmit_() {
ESP_LOGW(TAG, "No answer in login state %s within %u ms. Sending the command again...",
LOG_STR_ARG(delta_state_to_string(this->state_)), (unsigned int) RETRANSMIT_TIMEOUT_MS);
this->send_next_state_command_();
}
void DeltaWallbox::advance_to_(DeltaState next) {
ESP_LOGD(TAG, "State transition: %s -> %s", LOG_STR_ARG(delta_state_to_string(this->state_)),
LOG_STR_ARG(delta_state_to_string(next)));
this->state_ = next;
this->send_next_state_command_();
}
void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
ESP_LOGD(TAG, "Received valid frame in state %s: func=0x%02X len=%u",
LOG_STR_ARG(delta_state_to_string(this->state_)), frame[1], (unsigned int) len);
if (frame[1] & FC_EXCEPTION_FLAG) {
ESP_LOGE(TAG, "Modbus exception received in state %s! Error code: 0x%02X",
LOG_STR_ARG(delta_state_to_string(this->state_)), frame[2]);
if (this->state_ == DeltaState::DELTA_STATE_TIME_SYNC) {
// The clock write is optional, so a charger that refuses it is used without it
ESP_LOGW(TAG, "Charger refused the clock sync; continuing without it");
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
} else if (expected_function_code(this->state_) != 0) {
// The charger refused a handshake step; sending the same request again would not change that
this->halt_(LOG_STR("Modbus exception during login"));
}
return;
}
const uint8_t expected = expected_function_code(this->state_);
if (expected != 0 && frame[1] != expected) {
// A late answer to a retransmitted request; the pending one is answered or sent again by the timer
ESP_LOGD(TAG, "Ignoring func=0x%02X reply while waiting for 0x%02X", frame[1], expected);
return;
}
switch (this->state_) {
case DeltaState::DELTA_STATE_IDLE:
break; // No command is pending; ignore the frame.
case DeltaState::DELTA_STATE_GET_MODEL: {
char model[MAX_FRAME_LEN];
const size_t data_len = std::min<size_t>(frame[2], len - READ_RESPONSE_OVERHEAD);
size_t model_len = 0;
for (size_t i = 0; i < data_len; i++) {
if (frame[3 + i] != '\0')
model[model_len++] = static_cast<char>(frame[3 + i]);
}
model[model_len] = '\0';
ESP_LOGI(TAG, "Discovered Wallbox Model Name: %s", model);
if (this->model_name_text_sensor_ != nullptr) {
this->model_name_text_sensor_->publish_state(model);
}
this->advance_to_(DeltaState::DELTA_STATE_GET_BT_FW_VERSION);
break;
}
case DeltaState::DELTA_STATE_GET_BT_FW_VERSION: {
if (frame[2] >= REGISTER_BYTE_COUNT) {
uint8_t major = frame[3] % 16;
uint8_t minor = frame[4];
char ver[16];
snprintf(ver, sizeof(ver), "%d.%d", major, minor);
ESP_LOGI(TAG, "Discovered Wallbox BT FW: %s", ver);
if (this->bt_fw_version_text_sensor_ != nullptr) {
this->bt_fw_version_text_sensor_->publish_state(ver);
}
}
this->advance_to_(DeltaState::DELTA_STATE_CHECK_PAIRING);
break;
}
case DeltaState::DELTA_STATE_CHECK_PAIRING: {
if (frame[2] >= REGISTER_BYTE_COUNT) {
uint16_t status = (frame[3] << 8) | frame[4];
ESP_LOGI(TAG, "Pairing Status Register: 0x%04X", status);
if (status == PAIRING_STATUS_READY) {
this->pairing_in_progress_ = false;
this->pairing_attempts_ = 0;
ESP_LOGI(TAG, "Pairing ready and confirmed!");
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_USER);
} else if (status == PAIRING_STATUS_WAITING) {
ESP_LOGW(TAG, "Wallbox is waiting to be paired by the system controller (attempt %u, in_progress=%s).",
this->pairing_attempts_ + 1,
this->pairing_in_progress_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"));
this->pairing_attempts_++;
if (!this->pairing_in_progress_) {
ESP_LOGI(TAG, "Initiating BLE pairing with wallbox.");
auto err = this->parent_->pair();
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to initiate BLE pairing, err=%d", err);
} else {
this->pairing_in_progress_ = true;
}
}
} else {
ESP_LOGW(TAG, "Unexpected pairing status 0x%04X", status);
this->pairing_attempts_++;
}
if (this->pairing_attempts_ >= MAX_PAIRING_ATTEMPTS) {
this->halt_(LOG_STR("Too many failed/waiting pairing checks"));
}
} else {
ESP_LOGW(TAG, "Pairing status response too short: byte_count=%u frame_len=%u", frame[2], (unsigned int) len);
}
break;
}
case DeltaState::DELTA_STATE_GET_LOGIN_USER: {
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_RESULT0);
break;
}
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT0: {
if (frame[2] == REGISTER_BYTE_COUNT) {
this->advance_to_(DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS);
}
break;
}
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS: {
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_RESULT);
break;
}
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT: {
// Only the one-register status answer counts; a late reply to another read has a different length
if (frame[2] == REGISTER_BYTE_COUNT) {
uint8_t login_val = frame[4]; // respondent data_lo
if (login_val == LOGIN_ACCEPTED) {
ESP_LOGI(TAG, "Credentials accepted! Logged in as %s.", this->user_.c_str());
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_ROLE);
} else {
ESP_LOGE(TAG, "Login validation failed (0x%02X). Check password.", login_val);
this->halt_(LOG_STR("Bad credentials"));
}
}
break;
}
case DeltaState::DELTA_STATE_HALTED: {
break; // Authentication is halted; ignore any late replies.
}
case DeltaState::DELTA_STATE_GET_LOGIN_ROLE: {
if (frame[2] == REGISTER_BYTE_COUNT) {
this->advance_to_(DeltaState::DELTA_STATE_TIME_SYNC);
}
break;
}
case DeltaState::DELTA_STATE_TIME_SYNC: {
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
break;
}
case DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION: {
if (frame[2] >= TWO_REGISTER_BYTE_COUNT) {
char ver[16];
snprintf(ver, sizeof(ver), "%d.%d", frame[3], frame[4]);
ESP_LOGI(TAG, "Discovered Charger Main FW: %s", ver);
if (this->fw_version_text_sensor_ != nullptr) {
this->fw_version_text_sensor_->publish_state(ver);
}
}
this->advance_to_(DeltaState::DELTA_STATE_GET_SERIAL_NUMBER);
break;
}
case DeltaState::DELTA_STATE_GET_SERIAL_NUMBER: {
// Serial read (0x0109) returns 14 ASCII bytes instead of one register
if (frame[2] >= SERIAL_TEXT_BYTE_COUNT) {
char serial[SERIAL_TEXT_BYTE_COUNT + 1];
size_t serial_len = 0;
for (size_t i = 0; i < SERIAL_TEXT_BYTE_COUNT; i++) {
if (frame[3 + i] != '\0')
serial[serial_len++] = static_cast<char>(frame[3 + i]);
}
serial[serial_len] = '\0';
ESP_LOGI(TAG, "Wallbox serial text: %s", serial);
if (this->serial_number_text_sensor_ != nullptr)
this->serial_number_text_sensor_->publish_state(serial);
}
this->advance_to_(DeltaState::DELTA_STATE_CONNECTED);
ESP_LOGI(TAG, "Authentication complete. Core parameters loaded.");
this->status_clear_warning();
this->update(); // perform first poll update immediately
break;
}
case DeltaState::DELTA_STATE_CONNECTED: {
// Connected polling responses
if (frame[1] == FC_READ_HOLDING_REGISTERS) {
uint8_t byte_count = frame[2];
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
// Parsing 0x011B charging info (9 registers)
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
uint8_t minutes = frame[15];
uint8_t hours = frame[16];
uint8_t state_byte = frame[17];
float power_kw = power_raw / 1000.0f;
float energy_kwh = energy_raw / 1000.0f;
float charging_minutes = hours * 60 + minutes;
if (this->power_sensor_ != nullptr)
this->power_sensor_->publish_state(power_kw);
if (this->energy_sensor_ != nullptr)
this->energy_sensor_->publish_state(energy_kwh);
if (this->charging_time_sensor_ != nullptr)
this->charging_time_sensor_->publish_state(charging_minutes);
if (this->charger_state_sensor_ != nullptr)
this->charger_state_sensor_->publish_state(state_byte);
// Retrieve maximum current output setting next
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
// Parsing 0x0112 max output setting (2 registers)
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
float amps = current_raw / 10.0f;
if (this->max_current_sensor_ != nullptr) {
this->max_current_sensor_->publish_state(amps);
}
// Poll the error code register every cycle
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
} else if (byte_count == REGISTER_BYTE_COUNT) {
// Error status register (0x01B5)
uint16_t reg_val = (frame[3] << 8) | frame[4];
char hex_str[16];
snprintf(hex_str, sizeof(hex_str), "%04X", reg_val);
if (this->error_code_text_sensor_ != nullptr)
this->error_code_text_sensor_->publish_state(hex_str);
}
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
// Confirmation frame of Start/Stop write actions
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
}
break;
}
}
}
void DeltaWallbox::reset_state_() {
ESP_LOGW(TAG, "Resetting component state from %s. pairing_attempts=%u, pairing_in_progress=%s, rx_buffer=%u bytes",
LOG_STR_ARG(delta_state_to_string(this->state_)), this->pairing_attempts_,
this->pairing_in_progress_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"), (unsigned int) this->rx_len_);
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
this->rx_len_ = 0;
this->state_ = DeltaState::DELTA_STATE_IDLE;
this->pairing_attempts_ = 0;
this->pairing_in_progress_ = false;
this->status_set_warning();
}
void DeltaWallbox::halt_(const LogString *reason) {
ESP_LOGE(TAG, "Halting authentication: %s. A reconnect is required to retry.", LOG_STR_ARG(reason));
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
this->rx_len_ = 0;
this->state_ = DeltaState::DELTA_STATE_HALTED;
this->pairing_attempts_ = 0;
this->pairing_in_progress_ = false;
this->status_set_warning(reason);
}
void DeltaWallbox::set_charging_state(bool state) {
if (!this->is_connected()) {
ESP_LOGW(TAG, "Cannot send charging command while wallbox is not fully connected.");
return;
}
if (state) {
ESP_LOGI(TAG, "Sending remote START charging command...");
this->send_frame_(CMD_START_CHARGING, sizeof(CMD_START_CHARGING));
} else {
ESP_LOGI(TAG, "Sending remote STOP charging command...");
this->send_frame_(CMD_STOP_CHARGING, sizeof(CMD_STOP_CHARGING));
}
}
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
void DeltaWallbox::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_DISCONNECT_EVT: {
ESP_LOGW(TAG, "Disconnected from Bluetooth wallbox client (conn_id=%u, reason=%d). Resetting state.",
param->disconnect.conn_id, param->disconnect.reason);
this->reset_state_();
this->node_state = esp32_ble_tracker::ClientState::IDLE;
break;
}
case ESP_GATTC_OPEN_EVT: {
ESP_LOGI(TAG, "GATT open event received: status=%d conn_id=%u", param->open.status, param->open.conn_id);
break;
}
case ESP_GATTC_SEARCH_CMPL_EVT: {
ESP_LOGI(TAG, "GATT service discovery completed: status=%d", param->search_cmpl.status);
auto *tx_char = this->parent_->get_characteristic(SERVICE_UUID, TX_UUID);
auto *rx_char = this->parent_->get_characteristic(SERVICE_UUID, RX_UUID);
if (tx_char == nullptr || rx_char == nullptr) {
ESP_LOGE(TAG, "Could not discover matching NUS TX/RX service on this BLE device.");
this->reset_state_();
break;
}
this->tx_char_handle_ = tx_char->handle;
this->rx_char_handle_ = rx_char->handle;
esp_err_t err = this->parent_->register_for_notify(this->rx_char_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register_for_notify failed, status=%d", err);
}
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
ESP_LOGI(TAG, "Register-for-notify event: status=%d handle=0x%04X", param->reg_for_notify.status,
param->reg_for_notify.handle);
if (param->reg_for_notify.handle == this->rx_char_handle_) {
ESP_LOGI(TAG, "Subscribed to RX notification handle 0x%04X successfully.", this->rx_char_handle_);
this->node_state = esp32_ble_tracker::ClientState::ESTABLISHED;
if (this->state_ == DeltaState::DELTA_STATE_IDLE) {
ESP_LOGI(TAG, "GATT connection established. Starting authentication state machine.");
this->advance_to_(DeltaState::DELTA_STATE_GET_MODEL);
}
}
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle != this->rx_char_handle_)
break;
const size_t value_len = param->notify.value_len;
ESP_LOGD(TAG, "Notification received on handle 0x%04X with %u bytes. Buffer before append=%u bytes.",
param->notify.handle, param->notify.value_len, (unsigned int) this->rx_len_);
if (value_len > MAX_FRAME_LEN - this->rx_len_) {
ESP_LOGW(TAG, "Notification overflows the %u-byte RX buffer. Discarding buffered data.",
(unsigned int) MAX_FRAME_LEN);
this->rx_len_ = 0;
if (value_len > MAX_FRAME_LEN)
break;
}
std::memcpy(this->rx_buffer_.data() + this->rx_len_, param->notify.value, value_len);
this->rx_len_ += value_len;
bool progress = true;
while (progress && this->rx_len_ >= 3) {
progress = false;
uint8_t func_code = this->rx_buffer_[1];
size_t expected_len = 0;
if (func_code == FC_READ_HOLDING_REGISTERS) {
expected_len = this->rx_buffer_[2] + READ_RESPONSE_OVERHEAD;
} else if (func_code == FC_WRITE_MULTIPLE_REGISTERS || func_code == FC_WRITE_SINGLE_REGISTER) {
expected_len = WRITE_RESPONSE_LEN;
} else if (func_code == (FC_EXCEPTION_FLAG | FC_READ_HOLDING_REGISTERS) ||
func_code == (FC_EXCEPTION_FLAG | FC_WRITE_MULTIPLE_REGISTERS) ||
func_code == (FC_EXCEPTION_FLAG | FC_WRITE_SINGLE_REGISTER)) {
expected_len = EXCEPTION_RESPONSE_LEN;
}
if (expected_len > 0) {
if (this->rx_len_ >= expected_len) {
if (verify_crc(this->rx_buffer_.data(), expected_len)) {
// The frame stays in rx_buffer_ during handle_frame_(); it is dropped afterwards.
this->handle_frame_(this->rx_buffer_.data(), expected_len);
this->drop_rx_bytes_(expected_len);
progress = true;
} else {
ESP_LOGW(TAG, "CRC mismatch while parsing notification data in state %s. Dropping one byte from buffer.",
LOG_STR_ARG(delta_state_to_string(this->state_)));
// Shift by one byte to realign with the next possible frame start
this->drop_rx_bytes_(1);
progress = true;
}
}
} else {
ESP_LOGW(TAG,
"Unsupported function code 0x%02X while parsing notification data in state %s. Advancing buffer.",
func_code, LOG_STR_ARG(delta_state_to_string(this->state_)));
this->drop_rx_bytes_(1);
progress = true;
}
}
break;
}
default:
break;
}
}
void DeltaWallbox::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
switch (event) {
case ESP_GAP_BLE_PASSKEY_REQ_EVT: {
if (!this->parent_->check_addr(param->ble_security.ble_req.bd_addr))
return;
if (!this->has_pin_) {
ESP_LOGW(TAG, "Wallbox requested a BLE passkey, but no pin is configured. Set pin: under delta_wallbox:.");
return;
}
ESP_LOGI(TAG, "Wallbox requested a BLE passkey. Replying with configured YAML pin.");
esp_err_t err = esp_ble_passkey_reply(param->ble_security.ble_req.bd_addr, true, this->pin_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to send BLE passkey reply: %s", esp_err_to_name(err));
}
break;
}
case ESP_GAP_BLE_AUTH_CMPL_EVT:
if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr))
return;
this->pairing_in_progress_ = false;
if (param->ble_security.auth_cmpl.success) {
ESP_LOGI(TAG,
"BLE pairing completed successfully. auth_mode=%d addr_type=%d. Re-checking wallbox pairing status.",
param->ble_security.auth_cmpl.auth_mode, param->ble_security.auth_cmpl.addr_type);
this->pairing_attempts_ = 0;
if (this->node_state == esp32_ble_tracker::ClientState::ESTABLISHED &&
this->state_ == DeltaState::DELTA_STATE_CHECK_PAIRING) {
this->advance_to_(DeltaState::DELTA_STATE_CHECK_PAIRING);
}
} else {
ESP_LOGW(TAG, "BLE pairing failed, reason=%d", param->ble_security.auth_cmpl.fail_reason);
}
break;
default:
break;
}
}
} // namespace esphome::delta_wallbox
#endif
@@ -0,0 +1,103 @@
#pragma once
#ifdef USE_ESP32
#include <array>
#include "esphome/core/component.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#ifdef USE_TIME
#include "esphome/components/time/real_time_clock.h"
#endif
namespace esphome::delta_wallbox {
// Largest FC03 response: unit, function, byte count, 255 data bytes, CRC.
static constexpr size_t MAX_FRAME_LEN = 260;
enum class DeltaState : uint8_t {
DELTA_STATE_CHECK_PAIRING,
DELTA_STATE_CONNECTED,
DELTA_STATE_GET_CHARGER_FW_VERSION,
DELTA_STATE_GET_BT_FW_VERSION,
DELTA_STATE_GET_LOGIN_RESULT,
DELTA_STATE_GET_LOGIN_RESULT0,
DELTA_STATE_GET_LOGIN_ROLE,
DELTA_STATE_GET_LOGIN_USER,
DELTA_STATE_GET_MODEL,
DELTA_STATE_GET_SERIAL_NUMBER,
DELTA_STATE_HALTED,
DELTA_STATE_IDLE,
DELTA_STATE_SEND_LOGIN_CREDENTIALS,
DELTA_STATE_TIME_SYNC,
};
class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingComponent {
public:
DeltaWallbox(const char *user, const char *password) : user_(user), password_(password) {}
void update() override;
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
void dump_config() override;
void set_pin(uint32_t pin) {
this->pin_ = pin;
this->has_pin_ = true;
}
#ifdef USE_TIME
void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; }
#endif
SUB_SENSOR(power)
SUB_SENSOR(energy)
SUB_SENSOR(charging_time)
SUB_SENSOR(charger_state)
SUB_SENSOR(max_current)
SUB_TEXT_SENSOR(serial_number)
SUB_TEXT_SENSOR(fw_version)
SUB_TEXT_SENSOR(bt_fw_version)
SUB_TEXT_SENSOR(model_name)
SUB_TEXT_SENSOR(error_code)
void set_charging_state(bool state);
void start_charging();
void stop_charging();
bool is_connected() {
return this->node_state == esp32_ble_tracker::ClientState::ESTABLISHED &&
this->state_ == DeltaState::DELTA_STATE_CONNECTED;
}
protected:
void write_frame_(const uint8_t *frame, size_t len);
void send_frame_(const uint8_t *body, size_t len);
void send_next_state_command_();
void retransmit_();
void advance_to_(DeltaState next);
void handle_frame_(const uint8_t *frame, size_t len);
void drop_rx_bytes_(size_t count);
void reset_state_();
// Stops the handshake until the next BLE disconnect; used for failures a retry can't fix.
void halt_(const LogString *reason);
// Members ordered by size so nothing is padded
StringRef user_;
StringRef password_;
uint32_t pin_{0};
#ifdef USE_TIME
time::RealTimeClock *time_id_{nullptr};
#endif
size_t rx_len_{0};
uint16_t rx_char_handle_{0};
uint16_t tx_char_handle_{0};
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
uint8_t pairing_attempts_{0};
bool has_pin_{false};
bool pairing_in_progress_{false};
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
};
} // namespace esphome::delta_wallbox
#endif
@@ -0,0 +1,66 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ENERGY,
CONF_MAX_CURRENT,
CONF_POWER,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_MINUTE,
)
from esphome.types import ConfigType
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
DEPENDENCIES = ["delta_wallbox"]
CONF_CHARGING_TIME = "charging_time"
CONF_CHARGER_STATE = "charger_state"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
cv.Optional(CONF_CHARGER_STATE): sensor.sensor_schema(
accuracy_decimals=0,
),
cv.Optional(CONF_MAX_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=1,
),
cv.Optional(CONF_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ENERGY): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=2,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_CHARGING_TIME): sensor.sensor_schema(
unit_of_measurement=UNIT_MINUTE,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_CHARGER_STATE, hub.set_charger_state_sensor)
await sensors(CONF_MAX_CURRENT, hub.set_max_current_sensor)
await sensors(CONF_POWER, hub.set_power_sensor)
await sensors(CONF_ENERGY, hub.set_energy_sensor)
await sensors(CONF_CHARGING_TIME, hub.set_charging_time_sensor)
@@ -0,0 +1,34 @@
import esphome.codegen as cg
from esphome.components import text_sensor
from esphome.components.const import CONF_ERROR_CODE, CONF_SERIAL_NUMBER
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
DEPENDENCIES = ["delta_wallbox"]
CONF_FW_VERSION = "fw_version"
CONF_BT_FW_VERSION = "bt_fw_version"
CONF_MODEL_NAME = "model_name"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(),
cv.Optional(CONF_FW_VERSION): text_sensor.text_sensor_schema(),
cv.Optional(CONF_BT_FW_VERSION): text_sensor.text_sensor_schema(),
cv.Optional(CONF_MODEL_NAME): text_sensor.text_sensor_schema(),
cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number_text_sensor)
await text_sensors(CONF_FW_VERSION, hub.set_fw_version_text_sensor)
await text_sensors(CONF_BT_FW_VERSION, hub.set_bt_fw_version_text_sensor)
await text_sensors(CONF_MODEL_NAME, hub.set_model_name_text_sensor)
await text_sensors(CONF_ERROR_CODE, hub.set_error_code_text_sensor)
+87
View File
@@ -0,0 +1,87 @@
import esphome.codegen as cg
from esphome.components import time, uart
import esphome.config_validation as cv
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
from esphome.core import ID
from esphome.types import ConfigType
CODEOWNERS = ["@kokx"]
DEPENDENCIES = ["uart"]
DOMAIN = "duco"
MULTI_CONF = True
CONF_DISABLE_CRC = "disable_crc"
CONF_DUCO_ID = "duco_id"
duco_ns = cg.esphome_ns.namespace("duco")
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
# Base of every class that sends requests through the hub
DucoDevice = duco_ns.class_(
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
)
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
# Entities of the platforms take the hub with this
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
cv.COMPONENT_SCHEMA
)
TIME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(DucoTime),
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
}
).extend(cv.polling_component_schema("15min"))
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Duco),
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
cv.Optional(CONF_TIME): TIME_SCHEMA,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
DOMAIN,
baud_rate=57600,
require_tx=True,
require_rx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
"""Register a device that sends requests, and give it an open request slot on its hub."""
await cg.register_component(var, config)
hub = await cg.get_variable(hub_id)
await cg.register_parented(var, hub)
_request_device_slot(str(hub_id))
cg.add(hub.register_device(var))
async def to_code(config: ConfigType) -> None:
var = await uart.new_uart_component(config)
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
if (discovery := config.get(CONF_DISCOVERY)) is not None:
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
await register_duco_device(discovery_var, discovery, config[CONF_ID])
if (time_config := config.get(CONF_TIME)) is not None:
clock = await cg.get_variable(time_config[CONF_TIME_ID])
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
await cg.register_component(time_var, time_config)
await cg.register_parented(time_var, var)
+273
View File
@@ -0,0 +1,273 @@
#include "duco.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco");
static constexpr uint8_t HEADER_START = 0xAA;
static constexpr uint8_t HEADER_END = 0x55;
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
static constexpr uint32_t FRAME_GAP_MS = 50;
static constexpr uint32_t DISCOVERY_START_ID = 0;
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
static constexpr uint32_t DISCOVERY_START_MS = 16000;
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
void Duco::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
this->read_available([this, now](uint8_t byte) {
if (this->parse_byte_(byte)) {
this->last_byte_ = now;
} else {
this->reset_frame_();
}
});
// The last message of a burst has no header after it, so it ends on silence
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
this->finalize_message_();
this->reset_frame_();
}
}
void Duco::reset_frame_() {
this->rx_size_ = 0;
this->escaped_ = false;
}
void Duco::close_request(DucoDevice *device) {
#ifdef DUCO_DEVICE_COUNT
for (auto &pending : this->pending_) {
if (pending.device == device) {
pending.function = 0;
pending.missed = false;
this->update_warning_();
return;
}
}
#endif
}
bool Duco::parse_byte_(uint8_t byte) {
const size_t at = this->rx_size_;
if (at >= MAX_FRAME_SIZE) {
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
return false;
}
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
return false; // header of the next message
if (at >= 2) {
const bool escaped = this->escaped_;
this->escaped_ = false;
if (prev == HEADER_START && !escaped) {
if (byte == STUFFING) {
this->escaped_ = true;
return true;
}
if (at >= 3 && byte == HEADER_END) {
this->rx_size_--; // drop the 0xAA of the header
this->finalize_message_();
return false;
}
}
}
this->rx_buffer_[this->rx_size_++] = byte;
return true;
}
void Duco::finalize_message_() {
const uint8_t *raw = this->rx_buffer_.data();
// length (function, id and data), function, id, data, CRC low, CRC high
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
return;
}
const uint8_t length = raw[0];
const uint16_t computed_crc = crc16(raw, length + 1);
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
if (computed_crc != remote_crc) {
if (!this->disable_crc_) {
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
return;
}
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
}
#ifdef DUCO_DEVICE_COUNT
// The box first acknowledges a request with function + 1, then answers with function + 2
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
for (auto &pending : this->pending_) {
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
pending.function = 0;
pending.missed = false;
this->update_warning_();
pending.device->receive_response(message);
return;
}
}
// Acknowledgements (odd function codes) are expected and never match
if ((raw[1] & 1) == 0) {
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
}
#endif
}
#ifdef DUCO_DEVICE_COUNT
void Duco::update_warning_() {
for (const auto &pending : this->pending_) {
if (pending.missed) {
this->status_set_warning(LOG_STR("No answer from the box"));
return;
}
}
this->status_clear_warning();
}
#endif
void Duco::dump_config() {
ESP_LOGCONFIG(TAG,
"Duco:\n"
" CRC Disabled: %s",
YESNO(this->disable_crc_));
}
uint8_t Duco::next_id_() {
do {
this->last_id_++;
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
return this->last_id_;
}
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
if (data.size() > MAX_REQUEST_DATA) {
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
return;
}
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
// unverified.
const size_t size = data.size();
const uint8_t id = this->next_id_();
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
std::copy_n(data.begin(), size, frame + 5);
const uint16_t crc = crc16(frame + 2, size + 3);
frame[size + 5] = crc & 0xFF;
frame[size + 6] = crc >> 8;
this->write_array(frame, size + 7);
this->flush();
#ifdef DUCO_DEVICE_COUNT
for (auto &pending : this->pending_) {
if (pending.device == device) {
pending.missed = pending.function != 0;
if (pending.missed) {
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
this->update_warning_();
}
pending.id = id;
pending.function = function;
break;
}
}
#endif
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
}
static const LogString *node_type_name(uint8_t type) {
switch (type) {
case 8:
return LOG_STR("UCBAT");
case 9:
return LOG_STR("UC");
case 10:
return LOG_STR("UCRH");
case 12:
return LOG_STR("UCCO2");
case 13:
return LOG_STR("VLV");
case 17:
return LOG_STR("BOX");
case 18:
return LOG_STR("SWITCH");
default:
return LOG_STR("UNKNOWN");
}
}
void DucoDiscovery::setup() {
// Give the box time to start before walking its node addresses
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
});
}
void DucoDiscovery::update() {
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
this->next_node_++;
this->attempts_ = 0;
}
if (this->next_node_ >= NODE_COUNT) {
this->cancel_interval(DISCOVERY_STEP_ID);
this->parent_->close_request(this);
return;
}
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
if (this->attempts_ > 0)
this->parent_->close_request(this);
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
this->attempts_++;
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
}
void DucoDiscovery::receive_response(const DucoMessage &message) {
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
if (message.data[0] != 0) {
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
}
this->node_types_[this->next_node_++] = message.data[0];
this->attempts_ = 0;
}
}
void DucoDiscovery::dump_config() {
ESP_LOGCONFIG(TAG, "Discovered nodes:");
for (uint8_t node = 0; node < NODE_COUNT; node++) {
const uint8_t type = this->node_types_[node];
if (type != 0) {
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
}
}
if (this->next_node_ < NODE_COUNT) {
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
}
}
#ifdef USE_TIME
void DucoTime::update() {
const ESPTime now = this->clock_->now();
if (!now.is_valid()) {
ESP_LOGV(TAG, "Time is not valid yet");
return;
}
const uint32_t ts = now.timestamp;
this->parent_->send(nullptr, FUNCTION_PARAMETER,
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
}
#endif
} // namespace esphome::duco
+113
View File
@@ -0,0 +1,113 @@
#pragma once
#include <array>
#include <initializer_list>
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_TIME
#include "esphome/components/time/real_time_clock.h"
#endif
namespace esphome::duco {
// Function codes of the requests; the box answers a request with function + 2
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
/// A received message; data points into the receive buffer and is valid only during the callback.
struct DucoMessage {
uint8_t id;
const uint8_t *data;
uint8_t size;
};
class DucoDevice;
/// The open request of one device: the answer to it is function + 2 with the same message id.
struct DucoPendingRequest {
DucoDevice *device;
uint8_t id;
uint8_t function; // 0 while nothing is open
bool missed; // the request before this one got no answer; sits in padding
};
class Duco : public uart::UARTDevice, public Component {
public:
void loop() override;
void dump_config() override;
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
#ifdef DUCO_DEVICE_COUNT
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
#endif
/// Forget device's open request, for a device that will not send again.
void close_request(DucoDevice *device);
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
/// open one, so an answer is never lost to another device's traffic.
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
protected:
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
static constexpr size_t MAX_FRAME_SIZE = 32;
static constexpr size_t MAX_REQUEST_DATA = 9;
bool parse_byte_(uint8_t byte);
void reset_frame_();
#ifdef DUCO_DEVICE_COUNT
void update_warning_(); // warn while any device's previous request went unanswered
#endif
void finalize_message_();
uint8_t next_id_();
#ifdef DUCO_DEVICE_COUNT
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
#endif
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
uint32_t last_byte_{0};
uint8_t rx_size_{0};
uint8_t last_id_{10};
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
bool disable_crc_{false};
};
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
class DucoDevice : public PollingComponent, public Parented<Duco> {
public:
virtual void receive_response(const DucoMessage &message) = 0;
};
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
class DucoDiscovery : public DucoDevice {
public:
void setup() override;
void dump_config() override;
void update() override; // one discovery step, run by the step interval
void receive_response(const DucoMessage &message) override;
protected:
static constexpr uint8_t NODE_COUNT = 0x45;
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
uint8_t next_node_{0};
uint8_t attempts_{0};
};
#ifdef USE_TIME
class DucoTime : public PollingComponent, public Parented<Duco> {
public:
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
void update() override;
protected:
time::RealTimeClock *clock_;
};
#endif
} // namespace esphome::duco
Loaded 100 of 623 files, more files were not shown because too many files have changed in this diff. Show more