mirror of
https://github.com/esphome/esphome.git
synced 2026-10-25 07:25:35 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b569491d | ||
|
|
71b1b58e8d | ||
|
|
4ab67d867f | ||
|
|
a2a46944ab | ||
|
|
537d1643cb | ||
|
|
07744bb53e | ||
|
|
c5d69ce22b | ||
|
|
21fb25a45f | ||
|
|
a208cf2264 | ||
|
|
a57dff1ea1 | ||
|
|
1f8c762f83 | ||
|
|
a339f2cec6 | ||
|
|
bbb978f8a0 | ||
|
|
e6b4b80ff8 | ||
|
|
e17c205863 | ||
|
|
485ce83703 | ||
|
|
0bb4097de4 | ||
|
|
90a90455ed | ||
|
|
893b60e7a0 | ||
|
|
978dbf9b41 | ||
|
|
b8df87350f | ||
|
|
146107c93f | ||
|
|
fe6f19a6f5 | ||
|
|
6d989d60aa | ||
|
|
263256b548 | ||
|
|
97fb058d60 | ||
|
|
ebb170e3e2 | ||
|
|
c307754c62 | ||
|
|
b60fc37cbc | ||
|
|
55940fac30 | ||
|
|
6dbd140922 | ||
|
|
4da3706c55 | ||
|
|
4a1b837ddb | ||
|
|
1488ca32b4 | ||
|
|
5e0e32552b | ||
|
|
809bc71649 | ||
|
|
7bec8aeb3d | ||
|
|
2897963905 | ||
|
|
ace4651476 | ||
|
|
b8d35764e7 | ||
|
|
afcf345264 | ||
|
|
19ce03f4bf | ||
|
|
83f460e964 | ||
|
|
e964a4d11a | ||
|
|
29a928ed5a | ||
|
|
2cbc1bc02d | ||
|
|
701e5746b0 | ||
|
|
9d30bcc721 |
No files matched your search
@@ -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, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver), `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), `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, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
@@ -133,9 +133,20 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
|
||||
`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`,
|
||||
`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) and `ble_midi/` (fake BLE MIDI instrument and the client).
|
||||
Wi-Fi boot checks), `ble_midi/` (fake BLE MIDI instrument and the client), `http_request_action/` (request
|
||||
action package with per-board files and an OTA flash variant) `uart_sim/` (simulator header, common package, per-build parts and run files) and `remote_base/` (common
|
||||
package, transmit script, per-board templates and the ESP8266 software sender).
|
||||
|
||||
## When the PR keeps moving
|
||||
|
||||
Record every head in the stack (`gh pr view N --json headRefOid`), build, and recheck before flashing; if anything
|
||||
moved, wait five minutes and check again so the test runs on a settled head. When a head moved only by merging dev,
|
||||
compare the PR's own diff (`git diff $(git merge-base OLD origin/dev) OLD` against the same for NEW); if it is
|
||||
identical and the dev commits do not touch the code under test, earlier results still describe it, but rebuild so the
|
||||
comment quotes the exact commits. Fetch `origin dev` before computing a merge base.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ One file per component, named after it:
|
||||
- [`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
|
||||
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||
- [`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
|
||||
- [`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
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads
|
||||
- [`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
|
||||
@@ -34,3 +34,7 @@ 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
|
||||
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
|
||||
@@ -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.
|
||||
@@ -16,3 +16,33 @@ update state (latest version, summary). The ESP8266 Arduino client gets `Error r
|
||||
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`, `-s name hr32base` on the command line for the base build; `hrota32.yaml` adds the `otaflash` action
|
||||
for `otadrive.py`) 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.
|
||||
|
||||
## OTA request cases with basic auth (#21556)
|
||||
|
||||
`scripts/http_request/ota_auth/`: `ho-common.yaml` (API actions for `ota.http_request.flash` with `md5_url`, `md5`
|
||||
and basic auth, `flash()` with no URL, an invalid URL, the `md5_computed()`/`md5_expected()` getters), `fwsrv.py`
|
||||
(serves a www dir, `/auth/` needs `otauser:otapass`), `hodrive.py` (11 cases, reconnects after each good flash) and
|
||||
`run.sh BUILD BOARD` (compiles from `TREE`, serial flash, serves that exact image; `TREE`, `P32` or `P66`, `SC` and `HOST_IP` in
|
||||
the environment). On the D1 mini the authenticated MD5 read fails
|
||||
with `Error reading MD5: -1` on dev too, and stored credentials carry over to the next flash on both builds.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21556#issuecomment-6381067561
|
||||
@@ -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.
|
||||
@@ -51,3 +51,31 @@ 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` compiles (from `TREE`) and 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.
|
||||
|
||||
## Shutdown message delivery (#21566)
|
||||
|
||||
`scripts/mqtt/shutdown/`: `sd-common.yaml` sets birth/will/shutdown payloads `hello`/`dead`/`bye` so the shutdown
|
||||
message and the will cannot be confused, and takes `restart`, `disable` and `sleep` on `<name>/cmd`.
|
||||
`sdrun.sh V N` (`TREE`, `P32` and `P66` in the environment) compiles and flashes the ESP32 and D1 mini, restarts each N times, disables MQTT once and deep sleeps the ESP32 three
|
||||
times (never the D1 mini: without the GPIO16 to RST wire it never wakes); `match.py OUT` counts a `bye` within 5 s of
|
||||
each command. Dev: ESP32 0/10 restarts, D1 mini 1 to 3/10. Put the broker address (`broker_ip`) in the secrets.yaml next to
|
||||
the included package, and quote `"${name}/status"` inside flow mappings.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6381448061
|
||||
|
||||
Retest at c9cf3c2813 (shutdown message sent at QoS 1, teardown waits for the broker's ack): 10/10 restarts on both
|
||||
boards and 3/3 deep sleeps, against 1/10, 3/10 and 0/3 on base. An earlier 100 ms hold gave 3/10, 6/10 and 2/3, so
|
||||
judge a delivery fix by counts over 10 or more restarts, never one or two presses.
|
||||
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6382076613
|
||||
@@ -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,30 @@
|
||||
# remote_base / remote_receiver
|
||||
|
||||
## Loopback on one pin, no IR hardware (#21560 to #21563)
|
||||
|
||||
`templates/remote_base/`: `rb-common.yaml` (API action `tx_all`, a dumper registered from `on_boot` through
|
||||
`register_dumper()` standing in for an external component), `rb-txscript.yaml` (14 `remote_transmitter.transmit_*`
|
||||
frames with `tx <proto>` log markers), `rb32.tmpl` and `rb66.tmpl` (`@V@` is the build label).
|
||||
`scripts/remote_base/run.sh LABEL B N` (`TREE`, `P32` or `P66`, `SC` in the environment) writes the run file from the
|
||||
template, builds and flashes it from `TREE` (`esphome run --no-logs`), finds the IP and runs `rbdrive.py IP N`, which segments the API log by the
|
||||
markers and prints what each sent frame decoded as.
|
||||
|
||||
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
|
||||
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
|
||||
where no primary matched.
|
||||
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
|
||||
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
|
||||
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
|
||||
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
|
||||
- API log lines can drop under bursts; rerun with more rounds before calling a shortfall.
|
||||
- `register_dumper()` returns nothing, so the config logs `ext dumper register called`, not whether it stuck. On dev
|
||||
the list has a fixed size and a dumper beyond it is dropped with `No dumper slot` (ERROR); judge by that line and by
|
||||
`ext dumper saw` counts in the `rbdrive.py` table, which only appear when the dumper is really registered.
|
||||
`rbdrive.py` exits 1 when the external dumper saw no frame, so a build that rejects the registration fails the
|
||||
drive; set `EXPECT_REGISTER=1` on `run.sh` to also fail the run on any `No dumper slot` line.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
|
||||
|
||||
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
|
||||
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
|
||||
only the last line sees "Failed to upload" first, so rerun before blaming the build.
|
||||
@@ -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...` (build
|
||||
and flash from TREE with `esphome run --no-logs`, capture, drive; `PORT` and `SC` in the environment).
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Run the burst action once and print each queued request as it completes.
|
||||
Usage: burstonly.py IP URL N"""
|
||||
|
||||
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()
|
||||
done = []
|
||||
errors = []
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if "BURSTRESP status=200 " in t:
|
||||
done.append(t)
|
||||
elif "BURSTRESP" in t or "BURSTERR" in t:
|
||||
errors.append(t)
|
||||
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()
|
||||
print(
|
||||
f"{len(done)} of {N} burst requests answered, {len(errors)} failed", flush=True
|
||||
)
|
||||
return 0 if len(done) >= N else 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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"))
|
||||
# cases whose request must fail (past the 8 s timeout, or an address that never answers)
|
||||
EXPECT_ERROR = {"hang", "unreachable"}
|
||||
EXTRAS = not ONLY
|
||||
|
||||
|
||||
timeouts: list[str] = []
|
||||
mismatches: list[str] = []
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
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)
|
||||
|
||||
def bad_burst(ln: str) -> bool:
|
||||
return "BURSTERR" in ln or ("BURSTRESP" in ln and "status=200 " not in ln)
|
||||
|
||||
def mismatch(what: str, why: str) -> None:
|
||||
print(f"{LABEL} {what} MISMATCH: {why}", flush=True)
|
||||
mismatches.append(what)
|
||||
|
||||
async def wait_until(done, secs: float, what: str) -> None:
|
||||
"""Poll done() until it holds; print a TIMEOUT marker when the deadline passes first."""
|
||||
end = time.monotonic() + secs
|
||||
while not done():
|
||||
if time.monotonic() >= end:
|
||||
print(f"{LABEL} {what} TIMEOUT", flush=True)
|
||||
timeouts.append(what)
|
||||
return
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
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}
|
||||
)
|
||||
await wait_until(
|
||||
lambda n0=n0: any("RESULT" in ln for ln in lines[n0:]),
|
||||
40,
|
||||
f"{name} round {rnd}",
|
||||
)
|
||||
result = next((ln for ln in lines[n0:] if "RESULT" in ln), None)
|
||||
if result is not None and (
|
||||
("status=200 " not in result)
|
||||
if name not in EXPECT_ERROR
|
||||
else (" error " not in result)
|
||||
):
|
||||
mismatch(f"{name} round {rnd}", result)
|
||||
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})
|
||||
await wait_until(
|
||||
lambda n0=n0: (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 6
|
||||
),
|
||||
30,
|
||||
f"burst round {rnd}",
|
||||
)
|
||||
if any(bad_burst(ln) for ln in lines[n0:]):
|
||||
mismatch(f"burst round {rnd}", "a burst request failed or was not 200")
|
||||
await asyncio.sleep(2)
|
||||
print(f"{LABEL} == cancel round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["cancel"], {"url": BASE + "/slow?d=2"})
|
||||
await asyncio.sleep(9)
|
||||
if not any("CANCEL stopped running=0" in ln for ln in lines[n0:]):
|
||||
mismatch(
|
||||
f"cancel round {rnd}", "script.stop did not leave the script stopped"
|
||||
)
|
||||
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}
|
||||
)
|
||||
await wait_until(
|
||||
lambda n0=n0: (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 3
|
||||
and any("RESULT" in ln for ln in lines[n0:])
|
||||
),
|
||||
30,
|
||||
f"cross round {rnd}",
|
||||
)
|
||||
if any(
|
||||
bad_burst(ln) or ("RESULT" in ln and "status=200 " not in ln)
|
||||
for ln in lines[n0:]
|
||||
):
|
||||
mismatch(f"cross round {rnd}", "a request failed")
|
||||
await asyncio.sleep(2)
|
||||
await c.disconnect()
|
||||
if timeouts:
|
||||
print(f"{LABEL} TIMEOUTS: {', '.join(timeouts)}", flush=True)
|
||||
if mismatches:
|
||||
print(f"{LABEL} MISMATCHES: {', '.join(mismatches)}", flush=True)
|
||||
return 1 if timeouts or mismatches else 0
|
||||
|
||||
|
||||
sys.exit(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,10 @@
|
||||
esphome:
|
||||
name: ho32base
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: ho66base
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Serve a www dir for ota.http_request tests; /auth/ needs basic auth otauser:otapass.
|
||||
Usage: fwsrv.py WWW PORT"""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import http.server
|
||||
import sys
|
||||
|
||||
WWW, PORT = sys.argv[1], int(sys.argv[2])
|
||||
GOOD = "Basic " + base64.b64encode(b"otauser:otapass").decode()
|
||||
|
||||
|
||||
class H(http.server.SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/auth/") and self.headers.get("Authorization") != GOOD:
|
||||
self.send_response(401)
|
||||
self.send_header("WWW-Authenticate", 'Basic realm="ota"')
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(
|
||||
f"{self.address_string()} auth={'Authorization' in self.headers} {fmt % args}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
http.server.ThreadingHTTPServer(
|
||||
("0.0.0.0", PORT), functools.partial(H, directory=WWW)
|
||||
).serve_forever()
|
||||
@@ -0,0 +1,60 @@
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: fl_md5url
|
||||
variables: {url: string, md5url: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5_url: !lambda return md5url;
|
||||
- action: fl_md5
|
||||
variables: {url: string, sum: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5: !lambda return sum;
|
||||
- action: fl_auth
|
||||
variables: {url: string, md5url: string, user: string, pw: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5_url: !lambda return md5url;
|
||||
username: !lambda return user;
|
||||
password: !lambda return pw;
|
||||
- action: fl_nourl
|
||||
then:
|
||||
- lambda: id(hota).flash();
|
||||
- action: fl_badurl
|
||||
then:
|
||||
- lambda: |-
|
||||
id(hota).set_url("not a url");
|
||||
id(hota).flash();
|
||||
- action: getters
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("hot", "getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
|
||||
id(hota).md5_expected().c_str());
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("hot", "boot getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
|
||||
id(hota).md5_expected().c_str());
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
- platform: http_request
|
||||
id: hota
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Drive the ota.http_request cases over the API.
|
||||
Usage: hodrive.py IP BASEURL MD5FILE (BASEURL serves fw.bin, fw.md5, bad.md5, auth/fw.bin, auth/fw.md5)"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
IP, BASE = sys.argv[1:3]
|
||||
SUM = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8").strip()
|
||||
T0 = time.monotonic()
|
||||
lines: list[str] = []
|
||||
failed: list[str] = []
|
||||
|
||||
|
||||
def out(s: str) -> None:
|
||||
print(f"{time.monotonic() - T0:7.2f} {s}", flush=True)
|
||||
|
||||
|
||||
async def connect() -> tuple[aioesphomeapi.APIClient, dict]:
|
||||
last: Exception | None = None
|
||||
for _ in range(90):
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
except (
|
||||
aioesphomeapi.InvalidAuthAPIError,
|
||||
aioesphomeapi.InvalidEncryptionKeyAPIError,
|
||||
aioesphomeapi.RequiresEncryptionAPIError,
|
||||
):
|
||||
raise # a key or password problem never fixes itself
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
i = await c.device_info()
|
||||
out(f"connected {i.name} {i.compilation_time}")
|
||||
_, svcs = await c.list_entities_services()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if re.search(r"\[hot|http_request|ota|OTA|MD5|md5", t):
|
||||
lines.append(t)
|
||||
out(t[:170])
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
return c, {x.name: x for x in svcs}
|
||||
raise SystemExit(f"device never came back (last error {last!r})")
|
||||
|
||||
|
||||
async def case(c, s, name, args, want, timeout=60.0):
|
||||
out(f"=== {name} {args} (want {want})")
|
||||
start = len(lines)
|
||||
await c.execute_service(s[name], args)
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if any(re.search(want, x) for x in lines[start:]):
|
||||
out(f"RESULT {name} PASS")
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
out(f"RESULT {name} FAIL (no '{want}')")
|
||||
failed.append(name)
|
||||
return False
|
||||
|
||||
|
||||
async def flash_ok(c, s, name, args):
|
||||
"""A good flash: wait for the device to drop and come back."""
|
||||
out(f"=== {name} {args} (want reboot)")
|
||||
start = len(lines)
|
||||
await c.execute_service(s[name], args)
|
||||
end = time.monotonic() + 120
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and c._connection is not None
|
||||
and c._connection.is_connected
|
||||
): # noqa: SLF001
|
||||
await asyncio.sleep(0.5)
|
||||
# a good flash needs both the device's own success line and the connection dropping for the reboot
|
||||
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
|
||||
logged = any(
|
||||
re.search(r"(?i)update complete|rebooting|successful", x) for x in lines[start:]
|
||||
)
|
||||
if not dropped:
|
||||
out(f"{name}: the device never dropped the connection within 120 s")
|
||||
ok = logged and dropped
|
||||
await c.disconnect()
|
||||
await asyncio.sleep(5)
|
||||
c, s = await connect()
|
||||
out(f"RESULT {name} {'PASS' if ok else 'FAIL'}")
|
||||
if not ok:
|
||||
failed.append(name)
|
||||
return c, s
|
||||
|
||||
|
||||
async def main():
|
||||
c, s = await connect()
|
||||
u, m = f"{BASE}/fw.bin", f"{BASE}/fw.md5"
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await case(c, s, "fl_nourl", {}, r"URL not set")
|
||||
await case(c, s, "fl_badurl", {}, r"URL not set")
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await case(c, s, "fl_md5", {"url": u, "sum": "0" * 32}, r"MD5 mismatch")
|
||||
await case(c, s, "getters", {}, r"getters computed=")
|
||||
await case(
|
||||
c,
|
||||
s,
|
||||
"fl_auth",
|
||||
{
|
||||
"url": f"{BASE}/auth/fw.bin",
|
||||
"md5url": f"{BASE}/auth/fw.md5",
|
||||
"user": "otauser",
|
||||
"pw": "wrong",
|
||||
},
|
||||
r"HTTP Request failed; URL: \S*/auth/fw\.md5; Code: 401",
|
||||
)
|
||||
c, s = await flash_ok(
|
||||
c,
|
||||
s,
|
||||
"fl_auth",
|
||||
{
|
||||
"url": f"{BASE}/auth/fw.bin",
|
||||
"md5url": f"{BASE}/auth/fw.md5",
|
||||
"user": "otauser",
|
||||
"pw": "otapass",
|
||||
},
|
||||
)
|
||||
c, s = await flash_ok(c, s, "fl_md5url", {"url": u, "md5url": m})
|
||||
c, s = await flash_ok(c, s, "fl_md5", {"url": u, "sum": SUM})
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await c.disconnect()
|
||||
out(
|
||||
f"SUMMARY {'all cases passed' if not failed else 'FAILED: ' + ', '.join(failed)}"
|
||||
)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: ho32pr
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: ho66pr
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# run.sh BUILD(pr|base) BOARD(32|66) : serial flash ho<BOARD><BUILD>, serve its own image, drive the cases
|
||||
# Copy this directory to a scratch directory (secrets.yaml next to ho-common.yaml) and run it there.
|
||||
# env: TREE (esphome checkout for BUILD), P32 or P66 (serial port of the board), SC (path to serial_capture.py),
|
||||
# HOST_IP (this host's address as the device reaches it), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); V=$1; B=$2; N=ho$B$V; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
SC=${SC:?set SC}; HOST=${HOST_IP:?set HOST_IP}
|
||||
if [ "$B" = 32 ]; then P=${P32:?set P32}; else P=${P66:?set P66}; fi
|
||||
OUT=$D/out-$V-$B; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome compile "$D/$V/$B/$N.yaml" > "$OUT/compile.log" 2>&1) || { echo "compile failed"; exit 1; }
|
||||
(cd "$T" && $PY -m esphome upload "$D/$V/$B/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "upload failed"; exit 1; }
|
||||
# the exact image of this config, just compiled, never one found elsewhere
|
||||
if [ "$B" = 32 ]; then IMG=$D/$V/$B/.esphome/build/$N/build/firmware.ota.bin
|
||||
else IMG=$D/$V/$B/.esphome/build/$N/.pioenvs/$N/firmware.bin; fi
|
||||
[ -f "$IMG" ] || { echo "no image for $N at $IMG"; exit 1; }
|
||||
echo "image $IMG"
|
||||
W=$OUT/www; mkdir -p "$W/auth"; cp "$IMG" "$W/fw.bin"; cp "$IMG" "$W/auth/fw.bin"
|
||||
$PY -c "import hashlib,sys;print(hashlib.md5(open(sys.argv[1],'rb').read()).hexdigest())" "$IMG" > "$W/fw.md5"
|
||||
cp "$W/fw.md5" "$W/auth/fw.md5"; echo 00000000000000000000000000000000 > "$W/bad.md5"
|
||||
$PY "$SC" "$P" 400 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP $SRV 2>/dev/null' EXIT
|
||||
pkill -f "[f]wsrv.py"; $PY "$D/fwsrv.py" "$W" 8093 > "$OUT/server.txt" 2>&1 & SRV=$!
|
||||
for i in $(seq 60); do IP=$(grep -a -o 'IP Address: [0-9.]*' "$OUT/serial.txt" | tail -1 | cut -d' ' -f3); [ -z "$IP" ] && IP=$(python3 -c "import socket;print(socket.gethostbyname('$N.local'))" 2>/dev/null); [ -n "$IP" ] && break; sleep 2; done
|
||||
[ -n "$IP" ] || { echo "device not found"; exit 1; }
|
||||
echo "device up"
|
||||
$PY "$D/hodrive.py" "$IP" "http://$HOST:8093" "$W/fw.md5" > "$OUT/drive.txt" 2>&1; RC=$?
|
||||
kill $SRV; pkill -f "[s]erial_capture.py $P"
|
||||
grep -E 'RESULT|SUMMARY|never came back' "$OUT/drive.txt"; echo "server requests: $(grep -c . "$OUT/server.txt")"
|
||||
exit $RC
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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()
|
||||
seen: list[str] = []
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
seen.append(t)
|
||||
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})
|
||||
end = time.monotonic() + 45
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and c._connection is not None # noqa: SLF001
|
||||
and c._connection.is_connected # noqa: SLF001
|
||||
):
|
||||
await asyncio.sleep(0.5)
|
||||
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
|
||||
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()
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
print("RESULT FAIL: not reachable after the flash", flush=True)
|
||||
return 1
|
||||
if not dropped:
|
||||
print("RESULT FAIL: the device never rebooted", flush=True)
|
||||
return 1
|
||||
if any("OTAFLASH returned" in t for t in seen):
|
||||
print("RESULT FAIL: the flash action returned, the update failed", flush=True)
|
||||
return 1
|
||||
if not any(re.search(r"(?i)update complete|rebooting|successful", t) for t in seen):
|
||||
print("RESULT FAIL: no update complete line before the reboot", flush=True)
|
||||
return 1
|
||||
print("RESULT PASS", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""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 contextlib
|
||||
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})
|
||||
dropped = False
|
||||
while time.monotonic() - t0 < 30:
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 1.0)
|
||||
await asyncio.sleep(0.2)
|
||||
except TimeoutError:
|
||||
continue # a slow reply is not a reboot; only a closed connection counts
|
||||
except Exception: # noqa: BLE001
|
||||
dropped = True
|
||||
print(f"{time.monotonic() - t0:7.2f} API connection gone", flush=True)
|
||||
break
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
if not dropped:
|
||||
print(f"{time.monotonic() - t0:7.2f} NO REBOOT within 30 s", flush=True)
|
||||
return 1
|
||||
last: Exception | None = None
|
||||
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 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(1)
|
||||
print(
|
||||
f"{time.monotonic() - t0:7.2f} NOT BACK after reboot (last error {last!r})",
|
||||
flush=True,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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)
|
||||
missing = []
|
||||
for i in range(N):
|
||||
print(f"{LABEL} {time.monotonic() - t0:7.2f} CHECK {i + 1}", flush=True)
|
||||
before = len(states)
|
||||
c.update_command(upd.key, aioesphomeapi.UpdateCommand.CHECK)
|
||||
await asyncio.sleep(25)
|
||||
if not any(not st.in_progress for st in states[before:]):
|
||||
print(f"{LABEL} CHECK {i + 1} got no finished update state", flush=True)
|
||||
missing.append(i + 1)
|
||||
await c.disconnect()
|
||||
print(f"{LABEL} {N - len(missing)} of {N} checks reported a state", flush=True)
|
||||
return 1 if missing else 0
|
||||
|
||||
|
||||
sys.exit(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,51 @@
|
||||
#!/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"
|
||||
# compile one at a time (shared tool caches), then flash both boards at once; upload alone never builds
|
||||
for b in 32 66; do
|
||||
(cd $T && $PY -m esphome compile $M/$V/$b/mq${X}$b$V.yaml > $OUT/c$b.log 2>&1) || { echo "compile$b failed, see $OUT/c$b.log"; exit 1; }
|
||||
done
|
||||
(cd $T && $PY -m esphome upload $M/$V/32/mq${X}32$V.yaml --device $P32 > $OUT/u32.log 2>&1) & U32=$!
|
||||
(cd $T && $PY -m esphome upload $M/$V/66/mq${X}66$V.yaml --device $P66 > $OUT/u66.log 2>&1) & U66=$!
|
||||
wait $U32 || { echo "upload32 failed, see $OUT/u32.log"; exit 1; }
|
||||
wait $U66 || { echo "upload66 failed, see $OUT/u66.log"; exit 1; }
|
||||
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
|
||||
TOPICS=$(grep "homeassistant/button/" $OUT/broker.txt | grep "\"name\":\"mq${X}" | grep -o '"cmd_t":"[^"]*"' | cut -d'"' -f4 | sort -u)
|
||||
ERR=0
|
||||
[ "$(echo "$TOPICS" | grep -c .)" -ge 2 ] || { echo "ERROR: expected a Restart command topic per board, found: ${TOPICS:-none}"; ERR=1; }
|
||||
for t in $TOPICS; do
|
||||
mosquitto_pub -h 127.0.0.1 -t "$t" -m PRESS && echo "$(date +%T) press $t" || { echo "$(date +%T) ERROR: publish to $t failed"; ERR=1; }
|
||||
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 & H32=$!
|
||||
$PY -m esptool --port $P66 --after no-reset chip-id > $OUT/hold66.log 2>&1 & H66=$!
|
||||
wait $H32 || { echo "ERROR: ESP32 not held in the bootloader, no will expected (see $OUT/hold32.log)"; ERR=1; }
|
||||
wait $H66 || { echo "ERROR: ESP8266 not held in the bootloader, no will expected (see $OUT/hold66.log)"; ERR=1; }
|
||||
sleep 40
|
||||
REL=$(date +%T); echo "$REL 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
|
||||
# each board must publish its birth message (default "online", explicit variant "up") after the release;
|
||||
# a missing will is the measured result and does not fail the run
|
||||
BIRTHS=$(awk -v t="$REL" '$1 >= t && ($4 == "online" || $4 == "up") {print $3}' $OUT/broker.txt | sort -u | grep -c .)
|
||||
[ "$BIRTHS" -ge 2 ] || { echo "ERROR: $BIRTHS of 2 boards published a birth message after the release"; ERR=1; }
|
||||
exit $ERR
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions: {name: sd32base}
|
||||
esphome:
|
||||
name: sd32base
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions: {name: sd66base}
|
||||
esphome:
|
||||
name: sd66base
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""match.py OUTDIR: for each event, say whether a bye/dead for that board arrived within 5 s."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
d = pathlib.Path(sys.argv[1])
|
||||
|
||||
|
||||
def secs(stamp: str) -> int:
|
||||
h, m, s = (int(x) for x in stamp.split(":"))
|
||||
return h * 3600 + m * 60 + s
|
||||
|
||||
|
||||
ev = [ln.split() for ln in (d / "events.txt").read_text(encoding="utf-8").splitlines()]
|
||||
br = [
|
||||
f
|
||||
for f in (
|
||||
ln.split() for ln in (d / "broker.txt").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
if len(f) >= 3 and f[2] in ("bye", "dead")
|
||||
]
|
||||
rows = {}
|
||||
for ts, kind, b in ev:
|
||||
hit = [
|
||||
p
|
||||
for s, top, p in br
|
||||
if top.startswith(f"sd{b}") and 0 <= secs(s) - secs(ts) <= 5
|
||||
]
|
||||
rows.setdefault((b, kind), []).append(
|
||||
"bye" if "bye" in hit else (hit[0] if hit else "-")
|
||||
)
|
||||
for (b, kind), r in sorted(rows.items()):
|
||||
print(f"{b} {kind:8s} {r.count('bye')}/{len(r)} bye {' '.join(r)}")
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions: {name: sd32pr}
|
||||
esphome:
|
||||
name: sd32pr
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions: {name: sd66pr}
|
||||
esphome:
|
||||
name: sd66pr
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -0,0 +1,40 @@
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
discovery: false
|
||||
birth_message: {topic: "${name}/status", payload: hello}
|
||||
will_message: {topic: "${name}/status", payload: dead}
|
||||
shutdown_message: {topic: "${name}/status", payload: bye}
|
||||
on_message:
|
||||
- topic: ${name}/cmd
|
||||
payload: restart
|
||||
then:
|
||||
- button.press: restart_btn
|
||||
- topic: ${name}/cmd
|
||||
payload: disable
|
||||
then:
|
||||
- mqtt.disable:
|
||||
- delay: 5s
|
||||
- mqtt.enable:
|
||||
- topic: ${name}/cmd
|
||||
payload: sleep
|
||||
then:
|
||||
- deep_sleep.enter:
|
||||
id: ds
|
||||
sleep_duration: 5s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
id: restart_btn
|
||||
name: Restart
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# sdrun.sh V(pr|base) [N] : flash both boards, restart each N times over MQTT, then disable, then deep sleep (ESP32)
|
||||
# Copy this directory to a scratch directory (secrets.yaml with wifi_ssid, wifi_password and broker_ip next to
|
||||
# sd-common.yaml) and run it there with a local mosquitto on this host.
|
||||
# env: TREE (esphome checkout for V), P32 and P66 (ESP32 and ESP8266 serial ports), PY (checkout python)
|
||||
S=$(cd "$(dirname "$0")" && pwd); V=$1; N=${2:-10}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
P32=${P32:?set P32}; P66=${P66:?set P66}; OUT=$S/out-$V; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"
|
||||
# compile one at a time (shared tool caches), then flash both boards at once; upload alone never builds
|
||||
for b in 32 66; do
|
||||
(cd "$T" && $PY -m esphome compile "$S/$V/sd$b$V.yaml" > "$OUT/c$b.log" 2>&1) || { echo "compile$b failed, see $OUT/c$b.log"; exit 1; }
|
||||
done
|
||||
(cd "$T" && $PY -m esphome upload "$S/$V/sd32$V.yaml" --device "$P32" > "$OUT/u32.log" 2>&1) & U32=$!
|
||||
(cd "$T" && $PY -m esphome upload "$S/$V/sd66$V.yaml" --device "$P66" > "$OUT/u66.log" 2>&1) & U66=$!
|
||||
wait $U32 || { echo "upload32 failed"; exit 1; }; wait $U66 || { echo "upload66 failed"; exit 1; }
|
||||
mosquitto_sub -h 127.0.0.1 -R -v -t "sd32$V/status" -t "sd66$V/status" -F '@H:@M:@S %t %p' > "$OUT/broker.txt" 2>&1 & SUB=$!
|
||||
# cmd KIND BOARD: publish the command and record the event only when the publish worked
|
||||
ERR=0
|
||||
cmd() {
|
||||
local ts; ts=$(date +%T)
|
||||
if mosquitto_pub -h 127.0.0.1 -t "sd$2$V/cmd" -m "$1"; then echo "$ts $1 $2" >> "$OUT/events.txt"
|
||||
else echo "$ts ERROR: publish $1 to board $2 failed"; ERR=1; fi
|
||||
}
|
||||
sleep 40
|
||||
for b in 32 66; do
|
||||
grep -q "sd$b$V/status hello" "$OUT/broker.txt" || { echo "ERROR: sd$b$V never sent its birth message"; kill $SUB; exit 1; }
|
||||
done
|
||||
for i in $(seq "$N"); do
|
||||
for b in 32 66; do cmd restart $b; done
|
||||
sleep 25
|
||||
done
|
||||
for b in 32 66; do cmd disable $b; done
|
||||
sleep 30
|
||||
for i in 1 2 3; do cmd sleep 32; sleep 30; done
|
||||
kill -0 $SUB 2>/dev/null || { echo "ERROR: the broker subscriber died, counts are incomplete"; ERR=1; }
|
||||
kill $SUB 2>/dev/null
|
||||
for b in 32 66; do echo "sd$b$V: $(grep -c "sd$b$V/status hello" "$OUT/broker.txt") hello, $(grep -c "sd$b$V/status bye" "$OUT/broker.txt") bye, $(grep -c "sd$b$V/status dead" "$OUT/broker.txt") dead"; done
|
||||
# each restart, the disable (a reconnect) and each ESP32 deep sleep is followed by a new birth message
|
||||
for b in 32 66; do
|
||||
want=$((N + 2)); [ $b = 32 ] && want=$((want + 3))
|
||||
got=$(grep -c "sd$b$V/status hello" "$OUT/broker.txt")
|
||||
[ "$got" -ge "$want" ] || { echo "ERROR: sd$b$V sent $got birth messages, expected $want (a board did not come back)"; ERR=1; }
|
||||
done
|
||||
exit $ERR
|
||||
@@ -0,0 +1,115 @@
|
||||
"""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 contextlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
RequiresEncryptionAPIError,
|
||||
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]:
|
||||
last: Exception | None = None
|
||||
for _ in range(30):
|
||||
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
break
|
||||
except (
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
):
|
||||
raise # a wrong key never fixes itself
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
raise RuntimeError(f"device did not come back (last error {last!r})")
|
||||
ents, services = await c.list_entities_services()
|
||||
reboot = next(
|
||||
(e.key for e in ents if isinstance(e, ButtonInfo) and e.name == "Reboot"), None
|
||||
)
|
||||
if reboot is None:
|
||||
raise RuntimeError("the device has no button named Reboot")
|
||||
return c, {s.name: s for s in services if isinstance(s, UserService)}, reboot
|
||||
|
||||
|
||||
async def rebooted(c: APIClient, reboot: int) -> bool:
|
||||
"""Press Reboot and wait up to 15 s for the API connection to drop."""
|
||||
c.button_command(reboot)
|
||||
end = time.monotonic() + 15
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 1.0)
|
||||
except TimeoutError:
|
||||
continue # a slow reply is not a reboot; only a closed connection counts
|
||||
except Exception: # noqa: BLE001
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
failed = []
|
||||
for img in IMAGES:
|
||||
c, actions, reboot = await connect()
|
||||
ok = await rebooted(c, reboot)
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
if not ok:
|
||||
print(f"{LABEL} {img:22} SKIPPED: no reboot within 15 s", flush=True)
|
||||
failed.append(img)
|
||||
continue
|
||||
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,
|
||||
)
|
||||
if (
|
||||
outcome != "finished"
|
||||
): # a decode timing is only meaningful for a complete image
|
||||
failed.append(img)
|
||||
await c.disconnect()
|
||||
if failed:
|
||||
print(f"{LABEL} not finished: {', '.join(failed)}", flush=True)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
sys.exit(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,261 @@
|
||||
"""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
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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")]
|
||||
|
||||
|
||||
def missing_base_files() -> list[str]:
|
||||
"""Base files behind prefixed cases that the server does not serve (any HTTP error, or no server).
|
||||
|
||||
A failed fetch ends as a download error on the device, which would pass the half_ and stall_ cases untested.
|
||||
"""
|
||||
builtin = {"chunked_cut", "closed_early", "large_header"}
|
||||
missing = []
|
||||
for _, _, path, _ in CASES:
|
||||
stem = path.rsplit(".", 1)[0]
|
||||
prefix, _, rest = path.partition("_")
|
||||
if (
|
||||
stem in builtin
|
||||
or prefix not in ("half", "noclen", "chunked", "stall")
|
||||
or not rest
|
||||
):
|
||||
continue
|
||||
try:
|
||||
with urllib.request.urlopen(f"{BASE}/{rest}", timeout=10):
|
||||
pass
|
||||
except (urllib.error.URLError, OSError) as exc: # HTTPError is a URLError
|
||||
missing.append(f"{rest} ({exc})")
|
||||
return sorted(set(missing))
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
if missing := missing_base_files():
|
||||
print(
|
||||
f"server has no {', '.join(missing)}; run the image generators first",
|
||||
flush=True,
|
||||
)
|
||||
return 2
|
||||
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]),
|
||||
)
|
||||
return 0 if all(r[6] for r in rows) else 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Run the tx_all script N times over the API and count what each sent frame decoded as.
|
||||
Usage: rbdrive.py IP N"""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import re
|
||||
import sys
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, N = sys.argv[1], int(sys.argv[2])
|
||||
lines: list[str] = []
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
i = await c.device_info()
|
||||
print("device", i.name, i.compilation_time, flush=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
|
||||
def on_log(m):
|
||||
lines.append(re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace")))
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1)
|
||||
complete = 0
|
||||
for rnd in range(1, N + 1):
|
||||
start = len(lines)
|
||||
await c.execute_service(s["tx_all"], {})
|
||||
for _ in range(200):
|
||||
if any("tx done" in x for x in lines[start:]):
|
||||
complete += 1
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
print(f"round {rnd}: no tx done within 20 s", flush=True)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
# per sent protocol: what was decoded
|
||||
table: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||
cur = None
|
||||
for x in lines:
|
||||
if m := re.search(r"\]: tx (\w+)$", x):
|
||||
cur = m[1]
|
||||
continue
|
||||
if cur is None or cur == "done":
|
||||
continue
|
||||
if m := re.search(r"Received ([A-Za-z0-9_ ]+?):", x):
|
||||
table[cur][m[1]] += 1
|
||||
elif "ext dumper saw" in x:
|
||||
table[cur]["ext"] += 1
|
||||
print(f"{complete} of {N} rounds complete", flush=True)
|
||||
for proto, cnt in table.items():
|
||||
print(
|
||||
f"{proto:11s} " + ", ".join(f"{k}={v}" for k, v in sorted(cnt.items())),
|
||||
flush=True,
|
||||
)
|
||||
for x in lines:
|
||||
if "ext dumper saw" not in x and re.search(
|
||||
r"rbt|error|Error|overflow|Guru|panic", x
|
||||
):
|
||||
print(" ", x[:160])
|
||||
|
||||
if not any(k != "ext" for cnt in table.values() for k in cnt):
|
||||
print("nothing decoded", flush=True)
|
||||
return 1
|
||||
if not any(cnt["ext"] for cnt in table.values()):
|
||||
print("the external dumper saw no frame", flush=True)
|
||||
return 1
|
||||
return 0 if complete == N else 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# run.sh LABEL B(32|66) [N] : write rb<B><LABEL> from templates/remote_base on every run, flash it, run rbdrive.py for N rounds
|
||||
# Copy this directory and templates/remote_base into one scratch directory (secrets.yaml next to rb-common.yaml).
|
||||
# env: TREE (esphome checkout to build from), P32 or P66 (serial port of the board), SC (path to
|
||||
# serial_capture.py), PY (checkout python), EXPECT_REGISTER=1 (fail when the dumper registration was rejected)
|
||||
R=$(cd "$(dirname "$0")" && pwd); V=$1; B=$2; N=${3:-3}; NAME=rb$B$V; T=${TREE:?set TREE}
|
||||
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
|
||||
if [ "$B" = 32 ]; then P=${P32:?set P32}; else P=${P66:?set P66}; fi
|
||||
mkdir -p "$R/$V"; sed "s/@V@/$V/g" "$R/rb$B.tmpl" > "$R/$V/$NAME.yaml" # always from the current template
|
||||
OUT=$R/out-$V-$B; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome run --no-logs "$R/$V/$NAME.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "build or upload failed"; exit 1; }
|
||||
$PY "$SC" "$P" 150 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
for i in $(seq 60); do IP=$(grep -a -o 'IP Address: [0-9.]*' "$OUT/serial.txt" 2>/dev/null | tail -1 | cut -d' ' -f3); [ -z "$IP" ] && IP=$(python3 -c "import socket;print(socket.gethostbyname('$NAME.local'))" 2>/dev/null); [ -n "$IP" ] && break; sleep 2; done
|
||||
[ -n "$IP" ] || { echo "device not found"; exit 1; }
|
||||
$PY "$R/rbdrive.py" "$IP" "$N" > "$OUT/drive.txt" 2>&1; RC=$?
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
[ -s "$OUT/serial.txt" ] || { echo "serial capture got nothing, crash check invalid"; exit 1; }
|
||||
CRASH=$(grep -a -c -i 'guru\|panic\|exception\|rst cause' "$OUT/serial.txt")
|
||||
REJECTED=$(grep -a -c 'No dumper slot' "$OUT/serial.txt")
|
||||
cat "$OUT/drive.txt"; echo "serial: $(grep -a -c 'ext dumper register called' "$OUT/serial.txt") register calls, $REJECTED rejected, $CRASH crash lines"
|
||||
if [ "${EXPECT_REGISTER:-0}" = 1 ] && [ "$REJECTED" != 0 ]; then echo "the dumper registration was rejected"; exit 1; fi
|
||||
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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() -> int:
|
||||
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")
|
||||
states.append(line)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
log.flush()
|
||||
|
||||
states: list[str] = []
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||
await asyncio.sleep(2)
|
||||
states.clear() # count only states published while the ops ran
|
||||
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()
|
||||
if any(not op.startswith(("list", "wait")) for op in OPS) and not states:
|
||||
print("no sensor state was published while the ops ran", flush=True)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# 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), PY (checkout python); TREE is the esphome
|
||||
# checkout to build from
|
||||
D=$(cd "$(dirname "$0")" && pwd); N=$1; T=$2; S=$3; shift 3; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}
|
||||
(cd $T && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs $D/$N.yaml --device $P > $D/$N-upload.log 2>&1) || { echo "build or upload failed"; exit 1; }
|
||||
$PY ${SC:?set SC} $P $S $D/$N-cap.txt > $D/$N-cap.err 2>&1 &
|
||||
CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
UP=0
|
||||
for i in $(seq 1 40); do $PY -c "import socket;socket.create_connection(('$N.local',6053),2)" 2>/dev/null && { UP=1; break; }; sleep 2; done
|
||||
[ $UP = 1 ] || { echo "device $N.local not reachable on port 6053"; exit 1; }
|
||||
cd $D && PYTHONUNBUFFERED=1 timeout $((S - 5)) $PY frdrive.py $N.local $N-api.log "$@" > $N-drive.txt 2>&1; RC=$?; echo "drive rc=$RC"
|
||||
wait $CAP; CAPRC=$?; trap - EXIT
|
||||
[ "$CAPRC" = 0 ] || { echo "serial capture failed (rc $CAPRC), crash check invalid"; exit 1; }
|
||||
CRASH=$(grep -acE 'Guru|abort\(\)|rst:0x' $D/$N-cap.txt)
|
||||
echo "crash lines: $CRASH"
|
||||
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
|
||||
@@ -0,0 +1,162 @@
|
||||
# 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)));'
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "SINGLEERR took=%u", (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,7 @@
|
||||
# Run file for the PR build; for the base build pass `-s name hr32base` to esphome (or copy it with that name).
|
||||
substitutions: {name: hr32pr, bufsize: "32768"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
logger:
|
||||
packages: {hr: !include hr-common.yaml}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Run file for the PR build; for the base build pass `-s name hr66base` to esphome (or copy it with that name).
|
||||
substitutions: {name: hr66pr, bufsize: "8192"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
logger:
|
||||
packages: {hr: !include hr-common.yaml}
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,22 @@
|
||||
# Same as hr32.yaml plus the otaflash action otadrive.py calls; pass -s name hrota32base for the base build.
|
||||
substitutions: {name: hrota32, bufsize: "32768"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
logger:
|
||||
packages: {hr: !include hr-common.yaml}
|
||||
ota:
|
||||
- platform: http_request
|
||||
id: ota_http
|
||||
api:
|
||||
actions:
|
||||
- action: otaflash
|
||||
variables:
|
||||
url: string
|
||||
sum: string
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "OTAFLASH start %s", url.c_str());'
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5: !lambda return sum;
|
||||
- lambda: 'ESP_LOGI("hrt", "OTAFLASH returned (failed)");'
|
||||
@@ -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,33 @@
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: tx_all
|
||||
then:
|
||||
- script.execute: tx_all
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
// A dumper registered at runtime, the way external components do it from setup()
|
||||
struct ExtDumper : remote_base::RemoteReceiverDumperBase {
|
||||
bool dump(remote_base::RemoteReceiveData src) override {
|
||||
ESP_LOGI("rbt", "ext dumper saw %d", (int) src.size());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
static ExtDumper ext;
|
||||
id(rx_all).register_dumper(&ext);
|
||||
ESP_LOGI("rbt", "ext dumper register called");
|
||||
@@ -0,0 +1,46 @@
|
||||
script:
|
||||
- id: tx_all
|
||||
then:
|
||||
- logger.log: "tx nec"
|
||||
- remote_transmitter.transmit_nec: {address: 0x1234, command: 0x78}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx samsung"
|
||||
- remote_transmitter.transmit_samsung: {data: 0xE0E040BF}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx sony"
|
||||
- remote_transmitter.transmit_sony: {data: 0xA90, nbits: 12}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx lg"
|
||||
- remote_transmitter.transmit_lg: {data: 0x20DF10EF, nbits: 32}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx jvc"
|
||||
- remote_transmitter.transmit_jvc: {data: 0xC5E8}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx panasonic"
|
||||
- remote_transmitter.transmit_panasonic: {address: 0x4004, command: 0x0100BCBD}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx rc5"
|
||||
- remote_transmitter.transmit_rc5: {address: 0x1C, command: 0x05}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx rc6"
|
||||
- remote_transmitter.transmit_rc6: {address: 0x00, command: 0x0C}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx dish"
|
||||
- remote_transmitter.transmit_dish: {address: 1, command: 16}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx coolix"
|
||||
- remote_transmitter.transmit_coolix: {first: 0xB23FE4}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx pioneer"
|
||||
- remote_transmitter.transmit_pioneer: {rc_code_1: 0xA556}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx samsung36"
|
||||
- remote_transmitter.transmit_samsung36: {address: 0x0400, command: 0x000E00FF}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx magiquest"
|
||||
- remote_transmitter.transmit_magiquest: {wand_id: 0x01234567}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx raw"
|
||||
- remote_transmitter.transmit_raw: {code: [3000, -1000, 700, -2900, 1500, -400, 2200, -1700]}
|
||||
- delay: 400ms
|
||||
- logger.log: "tx done"
|
||||
@@ -0,0 +1,32 @@
|
||||
esphome:
|
||||
name: rb32@V@
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../rb-common.yaml
|
||||
txscript: !include ../rb-txscript.yaml
|
||||
|
||||
remote_transmitter:
|
||||
pin:
|
||||
number: GPIO4
|
||||
allow_other_uses: true
|
||||
carrier_duty_percent: 100%
|
||||
rmt_symbols: 64
|
||||
|
||||
remote_receiver:
|
||||
- id: rx_all
|
||||
pin:
|
||||
number: GPIO4
|
||||
allow_other_uses: true
|
||||
dump: all
|
||||
rmt_symbols: 192
|
||||
- id: rx_nec_raw
|
||||
pin:
|
||||
number: GPIO4
|
||||
allow_other_uses: true
|
||||
dump: [nec, raw]
|
||||
rmt_symbols: 192
|
||||
@@ -0,0 +1,69 @@
|
||||
esphome:
|
||||
name: rb66@V@
|
||||
includes:
|
||||
- ../softtx.hpp
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../rb-common.yaml
|
||||
|
||||
remote_receiver:
|
||||
- id: rx_all
|
||||
pin: GPIO5
|
||||
dump: all
|
||||
|
||||
# the receiver leaves its pin an input; drive it so the software sender reaches the pad
|
||||
interval:
|
||||
- interval: 1h
|
||||
startup_delay: 0s
|
||||
then:
|
||||
- lambda: pinMode(5, OUTPUT);
|
||||
|
||||
script:
|
||||
- id: tx_all
|
||||
then:
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
NECProtocol().encode(&d, {0x1234, 0x78, 1});
|
||||
softtx::send(d, "nec");
|
||||
- delay: 400ms
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
SamsungProtocol().encode(&d, {0xE0E040BF, 32});
|
||||
softtx::send(d, "samsung");
|
||||
- delay: 400ms
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
SonyProtocol().encode(&d, {0xA90, 12});
|
||||
softtx::send(d, "sony");
|
||||
- delay: 400ms
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
LGProtocol().encode(&d, {0x20DF10EF, 32});
|
||||
softtx::send(d, "lg");
|
||||
- delay: 400ms
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
JVCProtocol().encode(&d, {0xC5E8});
|
||||
softtx::send(d, "jvc");
|
||||
- delay: 400ms
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
RC5Protocol().encode(&d, {0x1C, 0x05});
|
||||
softtx::send(d, "rc5");
|
||||
- delay: 400ms
|
||||
- lambda: |-
|
||||
remote_base::RemoteTransmitData d;
|
||||
using namespace remote_base;
|
||||
d.item(3000, 1000); d.item(700, 2900); d.item(1500, 400); d.mark(2200);
|
||||
softtx::send(d, "raw");
|
||||
- delay: 400ms
|
||||
- logger.log: "tx done"
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
// Bit-bang a frame with interrupts left on, so a receiver on the same pin sees every edge.
|
||||
// The ESP8266 remote_transmitter disables interrupts while it sends, which hides the edges.
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
|
||||
namespace softtx {
|
||||
inline void send(const esphome::remote_base::RemoteTransmitData &d, const char *name) {
|
||||
ESP_LOGI("rbt", "tx %s", name);
|
||||
for (int32_t v : d.get_data()) {
|
||||
digitalWrite(5, v > 0 ? HIGH : LOW);
|
||||
delayMicroseconds(v > 0 ? v : -v);
|
||||
}
|
||||
digitalWrite(5, LOW);
|
||||
}
|
||||
} // namespace softtx
|
||||
@@ -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,158 @@
|
||||
#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);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "UART%u loopback FAILED: %s", u->get_hw_serial_number(), esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "UART%u loopback on", u->get_hw_serial_number());
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -16,6 +16,8 @@ esphome/components/a01nyub/* @MrSuicideParrot
|
||||
esphome/components/a02yyuw/* @TH-Braemer
|
||||
esphome/components/absolute_humidity/* @DAVe3283
|
||||
esphome/components/ac_dimmer/* @glmnet
|
||||
esphome/components/adafruit_ble/* @omersiar
|
||||
esphome/components/adafruit_ble/ota/* @omersiar
|
||||
esphome/components/adc/* @esphome/core
|
||||
esphome/components/adc128s102/* @DeerMaximum
|
||||
esphome/components/addressable_light/* @justfalter
|
||||
@@ -86,6 +88,7 @@ esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_midi/* @bogdanr
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
@@ -195,6 +198,7 @@ esphome/components/ens160_spi/* @latonita
|
||||
esphome/components/ens210/* @itn3rd77
|
||||
esphome/components/epaper_spi/* @esphome/core
|
||||
esphome/components/es7210/* @kahrendt
|
||||
esphome/components/es7243/* @Chorty
|
||||
esphome/components/es7243e/* @kbx81
|
||||
esphome/components/es8156/* @kbx81
|
||||
esphome/components/es8311/* @kahrendt @kroimon
|
||||
@@ -697,6 +701,7 @@ esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
esphome/components/udp/* @clydebarrow
|
||||
esphome/components/udp/packet_interface/* @clydebarrow
|
||||
esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
|
||||
@@ -348,8 +348,9 @@ class ApplyField:
|
||||
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
|
||||
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
|
||||
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
|
||||
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
|
||||
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
|
||||
is not the right spelling, or the value depends on another entity's config, such as the unit
|
||||
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
|
||||
``type_``. An absent key emits nothing.
|
||||
"""
|
||||
|
||||
conf_key: str | tuple[str, ...]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DOMAIN = "adafruit_ble"
|
||||
@@ -0,0 +1,603 @@
|
||||
"""Host-side Adafruit nRF52 bootloader BLE OTA (Nordic legacy DFU).
|
||||
|
||||
The Adafruit bootloader only serves its DFU service while it is in DFU mode, so
|
||||
an update has two steps: ask the running application to reboot into the
|
||||
bootloader (the trigger service built into the ``adafruit_ble`` OTA component),
|
||||
then push the DFU package to the bootloader itself.
|
||||
|
||||
Reference implementation of the protocol:
|
||||
https://github.com/recrof/nrf_dfu_py/blob/main/dfu_lib.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from bleak.exc import (
|
||||
BleakDBusError,
|
||||
BleakDeviceNotFoundError,
|
||||
BleakError,
|
||||
BleakGATTProtocolError,
|
||||
BleakGATTProtocolErrorCode,
|
||||
)
|
||||
|
||||
from esphome.components.nrf52.ble_logger import MAC_ADDRESS_PATTERN
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.espota2 import ProgressBar
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Nordic legacy DFU service, used by the Adafruit nRF52 bootloader.
|
||||
DFU_SERVICE_UUID = "00001530-1212-efde-1523-785feabcd123"
|
||||
DFU_CONTROL_POINT_UUID = "00001531-1212-efde-1523-785feabcd123"
|
||||
DFU_PACKET_UUID = "00001532-1212-efde-1523-785feabcd123"
|
||||
|
||||
# ESPHome DFU trigger service, exposed by the application (ota_adafruit_ble.cpp).
|
||||
# Kept distinct from the bootloader's DFU service so the two can be told apart.
|
||||
TRIGGER_SERVICE_UUID = "e5b10001-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||
TRIGGER_CHARACTERISTIC_UUID = "e5b10002-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||
TRIGGER_MAGIC = b"DFU"
|
||||
|
||||
_OP_START_DFU = 0x01
|
||||
_OP_INIT_DFU_PARAMS = 0x02
|
||||
_OP_RECEIVE_FIRMWARE_IMAGE = 0x03
|
||||
_OP_VALIDATE = 0x04
|
||||
_OP_ACTIVATE_AND_RESET = 0x05
|
||||
_OP_RESET = 0x06
|
||||
_OP_PACKET_RECEIPT_NOTIF_REQ = 0x08
|
||||
_OP_RESPONSE_CODE = 0x10
|
||||
_OP_PACKET_RECEIPT_NOTIF = 0x11
|
||||
|
||||
_UPLOAD_MODE_APPLICATION = 0x04
|
||||
_RESPONSE_SUCCESS = 0x01
|
||||
_RESPONSE_INVALID_STATE = 0x02
|
||||
_RESPONSE_STATUS_NAMES = {
|
||||
0x02: "invalid state",
|
||||
0x03: "not supported",
|
||||
0x04: "data size exceeds limit",
|
||||
0x05: "CRC error",
|
||||
0x06: "operation failed",
|
||||
}
|
||||
|
||||
# The bootloader runs out of memory when more than 8 packets are unacknowledged.
|
||||
_PRN = 8
|
||||
# The bootloader needs a moment between START_DFU and the image size packet;
|
||||
# sending the size too early leaves the start command unanswered.
|
||||
_START_DELAY = 0.6
|
||||
# A dropped link mid-update is usually transient, so the sequence is retried.
|
||||
_ATTEMPTS = 3
|
||||
_RETRY_DELAY = 3.0
|
||||
# ATT payload cap; the negotiated MTU is normally smaller.
|
||||
_MAX_CHUNK_SIZE = 244
|
||||
# The bootloader rejects image sizes and data packets that are not whole words.
|
||||
_WORD_SIZE = 4
|
||||
_SCAN_TIMEOUT = 10.0
|
||||
_RESPONSE_TIMEOUT = 30.0
|
||||
# Starting the update erases flash and finishing it writes the last pages.
|
||||
_LONG_RESPONSE_TIMEOUT = 60.0
|
||||
_RECEIPT_TIMEOUT = 2.0
|
||||
# Time the bootloader needs to start advertising after the DFU request.
|
||||
_REBOOT_TIMEOUT = 20.0
|
||||
# Without MAC addresses (macOS), the bootloader is the one DFU device that was
|
||||
# not there before the request: the scan that lists those before it, and how
|
||||
# long to keep watching for a second new one after the first appears.
|
||||
_DFU_SNAPSHOT_TIMEOUT = 3.0
|
||||
_NEW_DEVICE_SETTLE = 3.0
|
||||
# macOS reports a device once per scan session, so a fresh session is needed to
|
||||
# see whether the application still advertises.
|
||||
_APP_CHECK_TIMEOUT = 3.0
|
||||
# Error responses to the DFU mode request that pairing the device would fix.
|
||||
_PAIRING_ERRORS = {
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHENTICATION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHORIZATION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION_KEY_SIZE,
|
||||
}
|
||||
|
||||
BOOTLOADER_HINT = (
|
||||
"A device left in its bootloader by a failed upload advertises at its MAC "
|
||||
"address + 1; pass that address with --device (on macOS, the UUID of the "
|
||||
"DFU device)."
|
||||
)
|
||||
|
||||
|
||||
class _DfuRejectedError(EsphomeError):
|
||||
"""The bootloader answered with a failure status; retrying will not help."""
|
||||
|
||||
|
||||
class _DfuStaleStateError(_DfuRejectedError):
|
||||
"""START found the bootloader mid-update from an interrupted transfer.
|
||||
|
||||
The failed START resets the bootloader, so one more attempt can succeed.
|
||||
"""
|
||||
|
||||
|
||||
def _rejected_error(request_op: int, step: str, status: int) -> _DfuRejectedError:
|
||||
name = _RESPONSE_STATUS_NAMES.get(status, "unknown")
|
||||
error = (
|
||||
_DfuStaleStateError
|
||||
if request_op == _OP_START_DFU and status == _RESPONSE_INVALID_STATE
|
||||
else _DfuRejectedError
|
||||
)
|
||||
return error(
|
||||
f"DFU {step} (opcode {request_op:#04x}) rejected by the bootloader "
|
||||
f"with status {status:#04x} ({name})"
|
||||
)
|
||||
|
||||
|
||||
class _DfuSession:
|
||||
"""Control point notifications and packet receipts of one DFU run."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._responses: asyncio.Queue[tuple[int, int]] = asyncio.Queue()
|
||||
self._receipt = asyncio.Event()
|
||||
self._rejection: tuple[int, int] | None = None
|
||||
self.receipts = 0
|
||||
|
||||
def notification_handler(self, _sender: int, data: bytearray) -> None:
|
||||
if not data:
|
||||
_LOGGER.debug("DFU: ignoring an empty notification")
|
||||
return
|
||||
opcode = data[0]
|
||||
if opcode == _OP_RESPONSE_CODE and len(data) >= 3:
|
||||
self._responses.put_nowait((data[1], data[2]))
|
||||
if data[2] != _RESPONSE_SUCCESS:
|
||||
# Wakes a receipt wait, which no receipt would end after a rejection
|
||||
self._rejection = (data[1], data[2])
|
||||
self._receipt.set()
|
||||
elif opcode == _OP_PACKET_RECEIPT_NOTIF:
|
||||
self.receipts += 1
|
||||
self._receipt.set()
|
||||
else:
|
||||
_LOGGER.debug("DFU: ignoring notification %s", data.hex())
|
||||
|
||||
async def expect_success(
|
||||
self, request_op: int, step: str, timeout: float = _RESPONSE_TIMEOUT
|
||||
) -> None:
|
||||
try:
|
||||
response_op, status = await asyncio.wait_for(self._responses.get(), timeout)
|
||||
except TimeoutError as err:
|
||||
raise EsphomeError(
|
||||
f"DFU timed out waiting for the response to opcode {request_op:#04x}"
|
||||
) from err
|
||||
if response_op != request_op:
|
||||
raise EsphomeError(
|
||||
f"DFU got a response for opcode {response_op:#04x}, "
|
||||
f"expected {request_op:#04x}"
|
||||
)
|
||||
if status != _RESPONSE_SUCCESS:
|
||||
raise _rejected_error(request_op, step, status)
|
||||
|
||||
def raise_if_rejected(self, step: str) -> None:
|
||||
"""Raise if the bootloader already answered a command with a failure."""
|
||||
if self._rejection is not None:
|
||||
request_op, status = self._rejection
|
||||
raise _rejected_error(request_op, step, status)
|
||||
|
||||
async def wait_for_receipts(self, count: int, step: str) -> None:
|
||||
"""Wait until ``count`` packet receipts have arrived in total."""
|
||||
try:
|
||||
async with asyncio.timeout(_RECEIPT_TIMEOUT):
|
||||
while self.receipts < count:
|
||||
self.raise_if_rejected(step)
|
||||
# Cleared only while the count is short, so no receipt is lost
|
||||
self._receipt.clear()
|
||||
await self._receipt.wait()
|
||||
except TimeoutError as err:
|
||||
raise EsphomeError(
|
||||
f"DFU timed out waiting for packet receipt {count}"
|
||||
) from err
|
||||
|
||||
|
||||
def _service_uuids(advertisement) -> set[str]:
|
||||
return {uuid.lower() for uuid in (advertisement.service_uuids or [])}
|
||||
|
||||
|
||||
def _next_address(address: str) -> str | None:
|
||||
"""The Adafruit bootloader can come up one address above the application."""
|
||||
if not MAC_ADDRESS_PATTERN.match(address):
|
||||
return None
|
||||
prefix, last = address[:-2], int(address[-2:], 16)
|
||||
return f"{prefix}{((last + 1) & 0xFF):02X}"
|
||||
|
||||
|
||||
async def _dfu_devices() -> set[str]:
|
||||
"""Addresses of the devices advertising the DFU service right now."""
|
||||
found = await BleakScanner.discover(timeout=_DFU_SNAPSHOT_TIMEOUT, return_adv=True)
|
||||
return {
|
||||
device.address
|
||||
for device, advertisement in found.values()
|
||||
if DFU_SERVICE_UUID in _service_uuids(advertisement)
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DfuPackage:
|
||||
"""The application image and init packet of an Adafruit DFU package."""
|
||||
|
||||
name: str
|
||||
image: bytes
|
||||
init_packet: bytes
|
||||
|
||||
|
||||
def load_firmware(firmware: Path) -> DfuPackage:
|
||||
"""Read and check a DFU package, before the device is asked to reboot."""
|
||||
if not firmware.is_file():
|
||||
raise EsphomeError(
|
||||
f"DFU package not found: {firmware}. Compile the firmware first."
|
||||
)
|
||||
try:
|
||||
return _read_package(firmware)
|
||||
except (zipfile.BadZipFile, KeyError, ValueError) as err:
|
||||
raise EsphomeError(f"Invalid DFU package {firmware}: {err}") from err
|
||||
|
||||
|
||||
def _read_package(firmware: Path) -> DfuPackage:
|
||||
with zipfile.ZipFile(firmware) as archive:
|
||||
names = archive.namelist()
|
||||
if "manifest.json" in names:
|
||||
bin_file, dat_file = _manifest_files(
|
||||
firmware, json.loads(archive.read("manifest.json"))
|
||||
)
|
||||
else:
|
||||
bins = [name for name in names if name.endswith(".bin")]
|
||||
dats = [name for name in names if name.endswith(".dat")]
|
||||
if len(bins) > 1 or len(dats) > 1:
|
||||
raise ValueError("more than one .bin or .dat file and no manifest")
|
||||
if not bins or not dats:
|
||||
raise EsphomeError(
|
||||
f"Could not find the application image in {firmware}"
|
||||
)
|
||||
bin_file, dat_file = bins[0], dats[0]
|
||||
image = archive.read(bin_file)
|
||||
init_packet = archive.read(dat_file)
|
||||
if not image:
|
||||
raise EsphomeError(f"Empty application image in {firmware}")
|
||||
if len(image) % _WORD_SIZE:
|
||||
raise EsphomeError(
|
||||
f"The application image in {firmware} is {len(image)} bytes, "
|
||||
f"which the bootloader rejects as it is not a multiple of {_WORD_SIZE}"
|
||||
)
|
||||
return DfuPackage(firmware.name, image, init_packet)
|
||||
|
||||
|
||||
def _manifest_files(firmware: Path, manifest: object) -> tuple[str, str]:
|
||||
"""The image and init packet names of the application in ``manifest``."""
|
||||
if isinstance(manifest, dict):
|
||||
manifest = manifest.get("manifest", manifest)
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("manifest.json is not a JSON object")
|
||||
application = manifest.get("application")
|
||||
if application is None:
|
||||
raise EsphomeError(f"{firmware} is not an application update package")
|
||||
if not isinstance(application, dict):
|
||||
raise ValueError("the manifest application entry is not a JSON object")
|
||||
bin_file = application.get("bin_file")
|
||||
dat_file = application.get("dat_file")
|
||||
if not isinstance(bin_file, str) or not isinstance(dat_file, str):
|
||||
raise ValueError("the manifest names no bin_file and dat_file")
|
||||
return bin_file, dat_file
|
||||
|
||||
|
||||
class _DeviceNotFoundError(EsphomeError):
|
||||
"""The device did not answer at the address it was asked for."""
|
||||
|
||||
|
||||
def _raise_if_not_paired(address: str, err: Exception) -> None:
|
||||
if isinstance(err, BleakDBusError) and "NotPermitted" in err.dbus_error:
|
||||
raise EsphomeError(
|
||||
f"Cannot connect to {address}: Make sure the device is paired."
|
||||
) from err
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(address: str) -> AsyncIterator[BleakClient]:
|
||||
client = BleakClient(address, timeout=_SCAN_TIMEOUT)
|
||||
try:
|
||||
await client.connect()
|
||||
except BleakDeviceNotFoundError as err:
|
||||
raise _DeviceNotFoundError(f"Device {address} was not found") from err
|
||||
except (BleakError, OSError) as err:
|
||||
_raise_if_not_paired(address, err)
|
||||
raise EsphomeError(f"Could not connect to {address}: {err}") from err
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
async def enter_dfu_mode(address: str) -> str:
|
||||
"""Return the address of the bootloader in DFU mode.
|
||||
|
||||
A running application is first asked to reboot into the bootloader. A
|
||||
device that is not found may still be in its bootloader from a failed
|
||||
upload, which can advertise one address above the application.
|
||||
"""
|
||||
request_error: Exception | None = None
|
||||
known_dfu: set[str] | None = None
|
||||
try:
|
||||
async with _connect(address) as client:
|
||||
services = {str(service.uuid).lower() for service in client.services}
|
||||
if DFU_SERVICE_UUID in services:
|
||||
return address
|
||||
if TRIGGER_SERVICE_UUID not in services:
|
||||
raise EsphomeError(
|
||||
f"{address} does not offer an OTA service. Put the device "
|
||||
"into DFU mode and try again."
|
||||
)
|
||||
if not MAC_ADDRESS_PATTERN.match(address):
|
||||
# macOS hides MAC addresses and gives the bootloader its own UUID
|
||||
known_dfu = await _dfu_devices()
|
||||
_LOGGER.info("Requesting DFU mode from %s...", address)
|
||||
try:
|
||||
await client.write_gatt_char(
|
||||
TRIGGER_CHARACTERISTIC_UUID, TRIGGER_MAGIC, response=True
|
||||
)
|
||||
except (BleakError, OSError) as err:
|
||||
_raise_if_not_paired(address, err)
|
||||
# The application reboots as soon as the write is handled, so a
|
||||
# disconnect while waiting for the response is expected. Windows
|
||||
# reports that as a cancelled operation (OSError), not a BleakError.
|
||||
# The error is only reported if the bootloader then does not show
|
||||
# up, except an error response (find_dfu_bootloader).
|
||||
_LOGGER.debug("DFU mode request ended with %s", err)
|
||||
request_error = err
|
||||
except _DeviceNotFoundError:
|
||||
_LOGGER.info("%s was not found, looking for its DFU bootloader", address)
|
||||
return await find_dfu_bootloader(address, request_error, known_dfu)
|
||||
|
||||
|
||||
async def _find_new_dfu_devices(
|
||||
known_dfu: set[str], app_address: str
|
||||
) -> tuple[list[str], bool]:
|
||||
"""Scan for DFU devices that are not in ``known_dfu``.
|
||||
|
||||
Also reports whether ``app_address`` still advertised after one appeared: an application that really restarted
|
||||
into its bootloader stops advertising, so a new DFU device seen while it keeps going belongs to another board.
|
||||
"""
|
||||
known = {address.lower() for address in known_dfu}
|
||||
app = app_address.lower()
|
||||
new: dict[str, str] = {}
|
||||
appeared = asyncio.Event()
|
||||
app_still_advertising = False
|
||||
|
||||
def _on_advertisement(device: BLEDevice, advertisement: AdvertisementData) -> None:
|
||||
nonlocal app_still_advertising
|
||||
key = device.address.lower()
|
||||
if key == app:
|
||||
app_still_advertising = app_still_advertising or appeared.is_set()
|
||||
return
|
||||
if DFU_SERVICE_UUID in _service_uuids(advertisement) and key not in known:
|
||||
new.setdefault(key, device.address)
|
||||
appeared.set()
|
||||
|
||||
async with BleakScanner(detection_callback=_on_advertisement):
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(appeared.wait(), _REBOOT_TIMEOUT)
|
||||
if appeared.is_set():
|
||||
await asyncio.sleep(_NEW_DEVICE_SETTLE)
|
||||
return list(new.values()), app_still_advertising
|
||||
|
||||
|
||||
async def _is_advertising(address: str) -> bool:
|
||||
"""Whether ``address`` shows up in a fresh scan session."""
|
||||
wanted = address.lower()
|
||||
device = await BleakScanner.find_device_by_filter(
|
||||
lambda device, _advertisement: device.address.lower() == wanted,
|
||||
timeout=_APP_CHECK_TIMEOUT,
|
||||
)
|
||||
return device is not None
|
||||
|
||||
|
||||
def _raise_if_request_rejected(address: str, request_error: Exception | None) -> None:
|
||||
"""Raise if the application answered the DFU mode request with an error.
|
||||
|
||||
An error response comes from the running application, so it did not
|
||||
restart. A dropped link stays acceptable, as the reboot can look like that.
|
||||
"""
|
||||
if not isinstance(request_error, BleakGATTProtocolError):
|
||||
return
|
||||
message = f"{address} rejected the DFU mode request: {request_error.args[-1]}."
|
||||
if request_error.code in _PAIRING_ERRORS:
|
||||
message += " Make sure the device is paired."
|
||||
raise EsphomeError(message) from request_error
|
||||
|
||||
|
||||
async def find_dfu_bootloader(
|
||||
address: str,
|
||||
request_error: Exception | None = None,
|
||||
known_dfu: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Scan for the bootloader of the device that was asked to enter DFU mode.
|
||||
|
||||
Only ``address`` and the address above it are accepted, so a different
|
||||
device that happens to be in DFU mode nearby is never flashed. With
|
||||
``known_dfu`` (the DFU devices seen before the request, for hosts without
|
||||
MAC addresses) the one DFU device that is new is accepted instead, and only
|
||||
if exactly one appeared and the application no longer advertises. A request
|
||||
the application rejected fails at once, without scanning.
|
||||
"""
|
||||
_raise_if_request_rejected(address, request_error)
|
||||
_LOGGER.info("Scanning for the DFU bootloader...")
|
||||
if known_dfu is not None:
|
||||
new, app_still_advertising = await _find_new_dfu_devices(known_dfu, address)
|
||||
if len(new) == 1 and not app_still_advertising:
|
||||
app_still_advertising = await _is_advertising(address)
|
||||
if not app_still_advertising:
|
||||
return new[0]
|
||||
if app_still_advertising:
|
||||
_LOGGER.warning(
|
||||
"%s kept advertising, so it did not restart into its bootloader",
|
||||
address,
|
||||
)
|
||||
raise _not_found_error(address, request_error, known_dfu | set(new))
|
||||
wanted = {address.lower()}
|
||||
if (next_address := _next_address(address)) is not None:
|
||||
wanted.add(next_address.lower())
|
||||
others: set[str] = set()
|
||||
|
||||
def _is_bootloader(device: BLEDevice, advertisement: AdvertisementData) -> bool:
|
||||
if DFU_SERVICE_UUID not in _service_uuids(advertisement):
|
||||
return False
|
||||
if device.address.lower() in wanted:
|
||||
return True
|
||||
others.add(device.address)
|
||||
return False
|
||||
|
||||
device = await BleakScanner.find_device_by_filter(
|
||||
_is_bootloader, timeout=_REBOOT_TIMEOUT
|
||||
)
|
||||
if device is not None:
|
||||
return device.address
|
||||
raise _not_found_error(address, request_error, others)
|
||||
|
||||
|
||||
def _not_found_error(
|
||||
address: str, request_error: Exception | None, others: set[str]
|
||||
) -> EsphomeError:
|
||||
message = f"The DFU bootloader of {address} was not found."
|
||||
if request_error is not None:
|
||||
message += f" The DFU mode request failed: {request_error}."
|
||||
if others:
|
||||
message += f" Other devices in DFU mode: {', '.join(sorted(others))}."
|
||||
return EsphomeError(f"{message} {BOOTLOADER_HINT}")
|
||||
|
||||
|
||||
async def _write_control(client: BleakClient, op: int, *payload: int) -> None:
|
||||
await client.write_gatt_char(
|
||||
DFU_CONTROL_POINT_UUID, bytes([op, *payload]), response=True
|
||||
)
|
||||
|
||||
|
||||
async def _stream_firmware(
|
||||
client: BleakClient, session: _DfuSession, image: bytes
|
||||
) -> None:
|
||||
# Every data packet must be whole words; the image size is too, so the last
|
||||
# packet is as well.
|
||||
chunk_size = max(20, min(client.mtu_size - 3, _MAX_CHUNK_SIZE))
|
||||
chunk_size -= chunk_size % _WORD_SIZE
|
||||
_LOGGER.debug("DFU uses %d byte chunks for %d bytes", chunk_size, len(image))
|
||||
progress = ProgressBar("Uploading")
|
||||
progress.update(0)
|
||||
try:
|
||||
for packet, offset in enumerate(range(0, len(image), chunk_size), start=1):
|
||||
session.raise_if_rejected("upload")
|
||||
chunk = image[offset : offset + chunk_size]
|
||||
await client.write_gatt_char(DFU_PACKET_UUID, chunk, response=False)
|
||||
progress.update((offset + len(chunk)) / len(image))
|
||||
# The packet that completes the image is answered with the
|
||||
# RECEIVE_FIRMWARE_IMAGE response instead of a receipt.
|
||||
if packet % _PRN == 0 and offset + len(chunk) < len(image):
|
||||
await session.wait_for_receipts(packet // _PRN, "upload")
|
||||
finally:
|
||||
progress.done()
|
||||
|
||||
|
||||
async def _perform_update(
|
||||
client: BleakClient, image: bytes, init_packet: bytes
|
||||
) -> None:
|
||||
session = _DfuSession()
|
||||
await client.start_notify(DFU_CONTROL_POINT_UUID, session.notification_handler)
|
||||
try:
|
||||
_LOGGER.debug("DFU: starting the update")
|
||||
await _write_control(client, _OP_START_DFU, _UPLOAD_MODE_APPLICATION)
|
||||
await asyncio.sleep(_START_DELAY)
|
||||
await client.write_gatt_char(
|
||||
DFU_PACKET_UUID, struct.pack("<III", 0, 0, len(image)), response=False
|
||||
)
|
||||
try:
|
||||
await session.expect_success(
|
||||
_OP_START_DFU, "start", timeout=_LONG_RESPONSE_TIMEOUT
|
||||
)
|
||||
except EsphomeError:
|
||||
with suppress(BleakError, OSError):
|
||||
await _write_control(client, _OP_RESET)
|
||||
raise
|
||||
|
||||
_LOGGER.debug("DFU: sending the init packet")
|
||||
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x00)
|
||||
await client.write_gatt_char(DFU_PACKET_UUID, init_packet, response=False)
|
||||
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x01)
|
||||
await session.expect_success(_OP_INIT_DFU_PARAMS, "init packet")
|
||||
|
||||
await _write_control(
|
||||
client, _OP_PACKET_RECEIPT_NOTIF_REQ, *struct.pack("<H", _PRN)
|
||||
)
|
||||
_LOGGER.debug("DFU: streaming the image")
|
||||
await _write_control(client, _OP_RECEIVE_FIRMWARE_IMAGE)
|
||||
await _stream_firmware(client, session, image)
|
||||
await session.expect_success(
|
||||
_OP_RECEIVE_FIRMWARE_IMAGE, "upload", timeout=_LONG_RESPONSE_TIMEOUT
|
||||
)
|
||||
|
||||
await _write_control(client, _OP_VALIDATE)
|
||||
await session.expect_success(_OP_VALIDATE, "validation")
|
||||
|
||||
# The bootloader resets as soon as it handles this, so the link drops
|
||||
# before the write can be acknowledged (an OSError on Windows). An error
|
||||
# response means it answered and refused.
|
||||
try:
|
||||
await _write_control(client, _OP_ACTIVATE_AND_RESET)
|
||||
except BleakGATTProtocolError as err:
|
||||
raise _DfuRejectedError(
|
||||
f"DFU activation rejected by the bootloader: {err}"
|
||||
) from err
|
||||
except (BleakError, OSError) as err:
|
||||
_LOGGER.debug("DFU activation ended with %s", err)
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.stop_notify(DFU_CONTROL_POINT_UUID)
|
||||
|
||||
|
||||
async def _dfu_attempt(address: str, image: bytes, init_packet: bytes) -> None:
|
||||
"""Run the whole DFU sequence once."""
|
||||
async with _connect(address) as client:
|
||||
try:
|
||||
await _perform_update(client, image, init_packet)
|
||||
except (BleakError, OSError) as err:
|
||||
raise EsphomeError(f"DFU failed: {err}") from err
|
||||
|
||||
|
||||
async def dfu_upload(address: str, package: DfuPackage) -> None:
|
||||
"""Flash ``package`` (from ``load_firmware``) to ``address``."""
|
||||
stale_state_retried = False
|
||||
for attempt in range(1, _ATTEMPTS + 1):
|
||||
_LOGGER.info(
|
||||
"Uploading %s to %s (%d bytes)%s...",
|
||||
package.name,
|
||||
address,
|
||||
len(package.image),
|
||||
f", attempt {attempt}/{_ATTEMPTS}" if attempt > 1 else "",
|
||||
)
|
||||
try:
|
||||
await _dfu_attempt(address, package.image, package.init_packet)
|
||||
except _DfuStaleStateError as err:
|
||||
if stale_state_retried or attempt == _ATTEMPTS:
|
||||
raise
|
||||
stale_state_retried = True
|
||||
_LOGGER.warning(
|
||||
"%s; it was left mid-update and has been reset, retrying", err
|
||||
)
|
||||
await asyncio.sleep(_RETRY_DELAY)
|
||||
except _DfuRejectedError:
|
||||
raise
|
||||
except EsphomeError as err:
|
||||
if attempt == _ATTEMPTS:
|
||||
raise
|
||||
_LOGGER.warning("DFU attempt %d/%d failed: %s", attempt, _ATTEMPTS, err)
|
||||
await asyncio.sleep(_RETRY_DELAY)
|
||||
else:
|
||||
_LOGGER.info("DFU finished; the device restarts into the new firmware")
|
||||
return
|
||||
@@ -0,0 +1,83 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
||||
from esphome.components.ota import (
|
||||
BASE_OTA_SCHEMA,
|
||||
CONF_ON_ABORT,
|
||||
CONF_ON_END,
|
||||
CONF_ON_PROGRESS,
|
||||
OTAComponent,
|
||||
ota_to_code,
|
||||
)
|
||||
from esphome.components.zephyr import zephyr_data
|
||||
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||
from esphome.components.zephyr_ble_server import (
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
||||
from esphome.core import CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DEPENDENCIES = ["zephyr", "zephyr_ble_server"]
|
||||
|
||||
AdafruitBLEOTAComponent = cg.esphome_ns.namespace("adafruit_ble").class_(
|
||||
"OTAComponent", OTAComponent
|
||||
)
|
||||
|
||||
# The device reboots into the bootloader before the transfer starts, so only
|
||||
# on_begin and on_state_change can fire.
|
||||
_UNSUPPORTED_TRIGGERS = (CONF_ON_PROGRESS, CONF_ON_END, CONF_ON_ERROR, CONF_ON_ABORT)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
BASE_OTA_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AdafruitBLEOTAComponent),
|
||||
**{
|
||||
cv.Optional(key): cv.invalid(
|
||||
f"'{key}' never fires with Adafruit BLE OTA: the bootloader "
|
||||
"runs the update after the device restarts into it"
|
||||
)
|
||||
for key in _UNSUPPORTED_TRIGGERS
|
||||
},
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_with_framework(Framework.ZEPHYR),
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
# Every Adafruit bootloader variant is named adafruit*
|
||||
bootloader = zephyr_data().get(KEY_BOOTLOADER, "")
|
||||
if not bootloader.startswith(BOOTLOADER_ADAFRUIT):
|
||||
raise cv.Invalid(
|
||||
f"Adafruit BLE OTA requires an Adafruit bootloader, but '{bootloader}' "
|
||||
"is configured. Set 'bootloader:' to an Adafruit one."
|
||||
)
|
||||
server = fv.full_config.get().get(ZEPHYR_BLE_SERVER_DOMAIN) or {}
|
||||
if CONF_ON_NUMERIC_COMPARISON_REQUEST not in server:
|
||||
_LOGGER.warning(
|
||||
"Adafruit BLE OTA without pairing lets any nearby device restart this "
|
||||
"one into the bootloader; add '%s' to '%s' to require pairing",
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# The Adafruit bootloader serves the update itself; the component provides
|
||||
# the BLE service that asks the application to restart into it.
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
# Registered before ota_to_code waits past safe_mode, so safe mode keeps the trigger
|
||||
await cg.register_component(var, config)
|
||||
await ota_to_code(var, config)
|
||||
@@ -0,0 +1,73 @@
|
||||
#include "ota_adafruit_ble.h"
|
||||
|
||||
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <hal/nrf_power.h>
|
||||
#include <zephyr/bluetooth/gatt.h>
|
||||
|
||||
namespace esphome::adafruit_ble {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "adafruit_ble");
|
||||
|
||||
// Written by the upload tool (dfu.py) to ask for a restart into the bootloader,
|
||||
// which then serves the DFU transfer. The bootloader only advertises its own DFU
|
||||
// service while it is in DFU mode, so this service is what identifies the
|
||||
// running application.
|
||||
static constexpr uint8_t DFU_TRIGGER_MAGIC[] = {'D', 'F', 'U'};
|
||||
// GPREGRET value the Adafruit nRF52 bootloader reads to enter OTA (BLE DFU) mode
|
||||
static constexpr uint8_t DFU_GPREGRET_OTA = 0xA8;
|
||||
|
||||
// Matches the MCUmgr BLE transport default: a paired (authenticated) link once pairing is enabled
|
||||
#ifdef CONFIG_BT_SMP
|
||||
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE_AUTHEN;
|
||||
#else
|
||||
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE;
|
||||
#endif
|
||||
|
||||
static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static ssize_t dfu_trigger_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len,
|
||||
uint16_t offset, uint8_t flags) {
|
||||
if (offset != 0) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
|
||||
}
|
||||
if (len != sizeof(DFU_TRIGGER_MAGIC) || memcmp(buf, DFU_TRIGGER_MAGIC, sizeof(DFU_TRIGGER_MAGIC)) != 0) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
|
||||
}
|
||||
global_ota_component->request_dfu_mode();
|
||||
return len;
|
||||
}
|
||||
|
||||
// Registered by bt_enable(), which zephyr_ble_server calls during its setup.
|
||||
BT_GATT_SERVICE_DEFINE( // NOLINT
|
||||
dfu_trigger_svc,
|
||||
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10001, 0x9c3a, 0x4f5d, 0x8a1b,
|
||||
0x2c3d4e5f6071))),
|
||||
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10002, 0x9c3a, 0x4f5d, 0x8a1b, 0x2c3d4e5f6071)),
|
||||
BT_GATT_CHRC_WRITE, DFU_TRIGGER_PERM, nullptr, dfu_trigger_write, nullptr));
|
||||
|
||||
OTAComponent::OTAComponent() { global_ota_component = this; }
|
||||
|
||||
void OTAComponent::request_dfu_mode() {
|
||||
// Restart from the main loop, so the OTA listeners run there and the write is answered first
|
||||
this->defer([this]() {
|
||||
ESP_LOGI(TAG, "DFU mode requested, restarting into the bootloader");
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
// NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_POWER is MMIO at a fixed address
|
||||
NRF_POWER->GPREGRET = DFU_GPREGRET_OTA;
|
||||
App.reboot();
|
||||
});
|
||||
}
|
||||
|
||||
void OTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Over-The-Air Updates:\n"
|
||||
" bootloader: Adafruit nRF52 (BLE DFU)");
|
||||
}
|
||||
|
||||
} // namespace esphome::adafruit_ble
|
||||
#endif
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
|
||||
namespace esphome::adafruit_ble {
|
||||
|
||||
// The update itself is served by the Adafruit bootloader; this component only
|
||||
// hosts the BLE trigger service defined in ota_adafruit_ble.cpp.
|
||||
class OTAComponent : public ota::OTAComponent {
|
||||
public:
|
||||
OTAComponent();
|
||||
void dump_config() override;
|
||||
// Called from the Bluetooth thread when the trigger is written
|
||||
void request_dfu_mode();
|
||||
};
|
||||
|
||||
} // namespace esphome::adafruit_ble
|
||||
#endif
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import airthings_wave_base, sensor
|
||||
from esphome.components.const import CONF_DEVICE_TYPE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CO2,
|
||||
@@ -27,7 +28,6 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_(
|
||||
"AirthingsWavePlus", airthings_wave_base.AirthingsWaveBase
|
||||
)
|
||||
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType")
|
||||
DEVICE_TYPES = WaveDeviceType.options("WAVE_PLUS", "WAVE_GEN2")
|
||||
|
||||
|
||||
@@ -1,129 +1,127 @@
|
||||
#include "am43_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::am43 {
|
||||
|
||||
const uint8_t START_PACKET[5] = {0x00, 0xff, 0x00, 0x00, 0x9a};
|
||||
ESPHOME_LOG_TAG(TAG, "am43");
|
||||
|
||||
Am43Packet *Am43Encoder::get_battery_level_request() {
|
||||
static constexpr uint8_t START_PACKET[5] = {0x00, 0xff, 0x00, 0x00, 0x9a};
|
||||
|
||||
Am43Packet Am43Encoder::get_battery_level_request() {
|
||||
uint8_t data = 0x1;
|
||||
return this->encode_(0xA2, &data, 1);
|
||||
return encode(CMD_GET_BATTERY_LEVEL, &data, 1);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_light_level_request() {
|
||||
Am43Packet Am43Encoder::get_light_level_request() {
|
||||
uint8_t data = 0x1;
|
||||
return this->encode_(0xAA, &data, 1);
|
||||
return encode(CMD_GET_LIGHT_LEVEL, &data, 1);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_position_request() {
|
||||
Am43Packet Am43Encoder::get_position_request() {
|
||||
uint8_t data = 0x1;
|
||||
return this->encode_(CMD_GET_POSITION, &data, 1);
|
||||
return encode(CMD_GET_POSITION, &data, 1);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_send_pin_request(uint16_t pin) {
|
||||
Am43Packet Am43Encoder::get_send_pin_request(uint16_t pin) {
|
||||
uint8_t data[2];
|
||||
data[0] = (pin & 0xFF00) >> 8;
|
||||
data[1] = pin & 0xFF;
|
||||
return this->encode_(CMD_SEND_PIN, data, 2);
|
||||
return encode(CMD_SEND_PIN, data, 2);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_open_request() {
|
||||
Am43Packet Am43Encoder::get_open_request() {
|
||||
uint8_t data = 0xDD;
|
||||
return this->encode_(CMD_SET_STATE, &data, 1);
|
||||
return encode(CMD_SET_STATE, &data, 1);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_close_request() {
|
||||
Am43Packet Am43Encoder::get_close_request() {
|
||||
uint8_t data = 0xEE;
|
||||
return this->encode_(CMD_SET_STATE, &data, 1);
|
||||
return encode(CMD_SET_STATE, &data, 1);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_stop_request() {
|
||||
Am43Packet Am43Encoder::get_stop_request() {
|
||||
uint8_t data = 0xCC;
|
||||
return this->encode_(CMD_SET_STATE, &data, 1);
|
||||
return encode(CMD_SET_STATE, &data, 1);
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::get_set_position_request(uint8_t position) {
|
||||
return this->encode_(CMD_SET_POSITION, &position, 1);
|
||||
}
|
||||
Am43Packet Am43Encoder::get_set_position_request(uint8_t position) { return encode(CMD_SET_POSITION, &position, 1); }
|
||||
|
||||
void Am43Encoder::checksum_() {
|
||||
uint8_t checksum = 0;
|
||||
int i = 0;
|
||||
for (i = 0; i < this->packet_.length; i++)
|
||||
checksum = checksum ^ this->packet_.data[i];
|
||||
this->packet_.data[i] = checksum ^ 0xff;
|
||||
this->packet_.length++;
|
||||
Am43Packet Am43Encoder::encode(uint8_t command, const uint8_t *data, uint8_t length) {
|
||||
Am43Packet packet;
|
||||
memcpy(packet.data, START_PACKET, sizeof(START_PACKET));
|
||||
packet.data[5] = command;
|
||||
packet.data[6] = length;
|
||||
memcpy(&packet.data[7], data, length);
|
||||
packet.length = length + 7;
|
||||
// The device checks the inverted XOR of everything before it
|
||||
packet.data[packet.length] = xor_bytes(packet.data, packet.length, 0xff);
|
||||
packet.length++;
|
||||
char hex_buf[format_hex_size(sizeof(packet.data))];
|
||||
ESP_LOGV(TAG, "ENC(%d): 0x%s", packet.length, format_hex_to(hex_buf, packet.data, packet.length));
|
||||
return packet;
|
||||
}
|
||||
|
||||
Am43Packet *Am43Encoder::encode_(uint8_t command, uint8_t *data, uint8_t length) {
|
||||
memcpy(this->packet_.data, START_PACKET, 5);
|
||||
this->packet_.data[5] = command;
|
||||
this->packet_.data[6] = length;
|
||||
memcpy(&this->packet_.data[7], data, length);
|
||||
this->packet_.length = length + 7;
|
||||
this->checksum_();
|
||||
char hex_buf[format_hex_size(sizeof(this->packet_.data))];
|
||||
ESP_LOGV("am43", "ENC(%d): 0x%s", this->packet_.length,
|
||||
format_hex_to(hex_buf, this->packet_.data, this->packet_.length));
|
||||
return &this->packet_;
|
||||
}
|
||||
|
||||
#define VERIFY_MIN_LENGTH(x) \
|
||||
if (length < (x)) \
|
||||
return;
|
||||
|
||||
void Am43Decoder::decode(const uint8_t *data, uint16_t length) {
|
||||
// Reset so a decoder reused across frames reports only the latest one
|
||||
this->has_battery_level_ = false;
|
||||
this->has_light_level_ = false;
|
||||
this->has_set_position_response_ = false;
|
||||
this->has_set_state_response_ = false;
|
||||
this->has_position_ = false;
|
||||
this->has_pin_response_ = false;
|
||||
char hex_buf[format_hex_size(24)]; // Max expected packet size
|
||||
ESP_LOGV("am43", "DEC(%d): 0x%s", length, format_hex_to(hex_buf, data, length));
|
||||
char hex_buf[format_hex_size(sizeof(Am43Packet::data))]; // Max expected packet size
|
||||
ESP_LOGV(TAG, "DEC(%d): 0x%s", length, format_hex_to(hex_buf, data, length));
|
||||
|
||||
if (length < 2 || data[0] != 0x9a)
|
||||
return;
|
||||
switch (data[1]) {
|
||||
case CMD_GET_BATTERY_LEVEL: {
|
||||
VERIFY_MIN_LENGTH(8);
|
||||
if (length < 8)
|
||||
return;
|
||||
this->battery_level_ = data[7];
|
||||
this->has_battery_level_ = true;
|
||||
break;
|
||||
}
|
||||
case CMD_GET_LIGHT_LEVEL: {
|
||||
VERIFY_MIN_LENGTH(5);
|
||||
if (length < 5)
|
||||
return;
|
||||
this->light_level_ = 100 * ((float) data[4] / 9);
|
||||
this->has_light_level_ = true;
|
||||
break;
|
||||
}
|
||||
case CMD_GET_POSITION: {
|
||||
VERIFY_MIN_LENGTH(6);
|
||||
if (length < 6)
|
||||
return;
|
||||
this->position_ = data[5];
|
||||
this->has_position_ = true;
|
||||
break;
|
||||
}
|
||||
case CMD_NOTIFY_POSITION: {
|
||||
VERIFY_MIN_LENGTH(5);
|
||||
if (length < 5)
|
||||
return;
|
||||
this->position_ = data[4];
|
||||
this->has_position_ = true;
|
||||
break;
|
||||
}
|
||||
case CMD_SEND_PIN: {
|
||||
VERIFY_MIN_LENGTH(4);
|
||||
if (length < 4)
|
||||
return;
|
||||
this->pin_ok_ = data[3] == RESPONSE_ACK;
|
||||
this->has_pin_response_ = true;
|
||||
break;
|
||||
}
|
||||
case CMD_SET_POSITION: {
|
||||
VERIFY_MIN_LENGTH(4);
|
||||
if (length < 4)
|
||||
return;
|
||||
this->set_position_ok_ = data[3] == RESPONSE_ACK;
|
||||
this->has_set_position_response_ = true;
|
||||
break;
|
||||
}
|
||||
case CMD_SET_STATE: {
|
||||
VERIFY_MIN_LENGTH(4);
|
||||
if (length < 4)
|
||||
return;
|
||||
this->set_state_ok_ = data[3] == RESPONSE_ACK;
|
||||
this->has_set_state_response_ = true;
|
||||
break;
|
||||
@@ -131,6 +129,6 @@ void Am43Decoder::decode(const uint8_t *data, uint16_t length) {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace esphome::am43
|
||||
@@ -1,59 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::am43 {
|
||||
|
||||
static const uint16_t AM43_SERVICE_UUID = 0xFE50;
|
||||
static const uint16_t AM43_CHARACTERISTIC_UUID = 0xFE51;
|
||||
//
|
||||
static constexpr uint16_t AM43_SERVICE_UUID = 0xFE50;
|
||||
static constexpr uint16_t AM43_CHARACTERISTIC_UUID = 0xFE51;
|
||||
// Tuya identifiers, only to detect and warn users as they are incompatible.
|
||||
static const uint16_t AM43_TUYA_SERVICE_UUID = 0x1910;
|
||||
static const uint16_t AM43_TUYA_CHARACTERISTIC_UUID = 0x2b11;
|
||||
static constexpr uint16_t AM43_TUYA_SERVICE_UUID = 0x1910;
|
||||
static constexpr uint16_t AM43_TUYA_CHARACTERISTIC_UUID = 0x2b11;
|
||||
|
||||
struct Am43Packet {
|
||||
uint8_t length;
|
||||
uint8_t data[24];
|
||||
};
|
||||
|
||||
static const uint8_t CMD_GET_BATTERY_LEVEL = 0xA2;
|
||||
static const uint8_t CMD_GET_LIGHT_LEVEL = 0xAA;
|
||||
static const uint8_t CMD_GET_POSITION = 0xA7;
|
||||
static const uint8_t CMD_SEND_PIN = 0x17;
|
||||
static const uint8_t CMD_SET_STATE = 0x0A;
|
||||
static const uint8_t CMD_SET_POSITION = 0x0D;
|
||||
static const uint8_t CMD_NOTIFY_POSITION = 0xA1;
|
||||
static constexpr uint8_t CMD_GET_BATTERY_LEVEL = 0xA2;
|
||||
static constexpr uint8_t CMD_GET_LIGHT_LEVEL = 0xAA;
|
||||
static constexpr uint8_t CMD_GET_POSITION = 0xA7;
|
||||
static constexpr uint8_t CMD_SEND_PIN = 0x17;
|
||||
static constexpr uint8_t CMD_SET_STATE = 0x0A;
|
||||
static constexpr uint8_t CMD_SET_POSITION = 0x0D;
|
||||
static constexpr uint8_t CMD_NOTIFY_POSITION = 0xA1;
|
||||
|
||||
static const uint8_t RESPONSE_ACK = 0x5A;
|
||||
static const uint8_t RESPONSE_NACK = 0xA5;
|
||||
static constexpr uint8_t RESPONSE_ACK = 0x5A;
|
||||
static constexpr uint8_t RESPONSE_NACK = 0xA5;
|
||||
|
||||
/// Builds request packets; each returns a packet the caller sends from its own stack.
|
||||
class Am43Encoder {
|
||||
public:
|
||||
Am43Packet *get_battery_level_request();
|
||||
Am43Packet *get_light_level_request();
|
||||
Am43Packet *get_position_request();
|
||||
Am43Packet *get_send_pin_request(uint16_t pin);
|
||||
Am43Packet *get_open_request();
|
||||
Am43Packet *get_close_request();
|
||||
Am43Packet *get_stop_request();
|
||||
Am43Packet *get_set_position_request(uint8_t position);
|
||||
static Am43Packet get_battery_level_request();
|
||||
static Am43Packet get_light_level_request();
|
||||
static Am43Packet get_position_request();
|
||||
static Am43Packet get_send_pin_request(uint16_t pin);
|
||||
static Am43Packet get_open_request();
|
||||
static Am43Packet get_close_request();
|
||||
static Am43Packet get_stop_request();
|
||||
static Am43Packet get_set_position_request(uint8_t position);
|
||||
|
||||
protected:
|
||||
void checksum_();
|
||||
Am43Packet *encode_(uint8_t command, uint8_t *data, uint8_t length);
|
||||
Am43Packet packet_;
|
||||
static Am43Packet encode(uint8_t command, const uint8_t *data, uint8_t length);
|
||||
};
|
||||
|
||||
/// Decodes one notification; construct it on the stack for each frame.
|
||||
class Am43Decoder {
|
||||
public:
|
||||
void decode(const uint8_t *data, uint16_t length);
|
||||
bool has_battery_level() { return this->has_battery_level_; }
|
||||
bool has_light_level() { return this->has_light_level_; }
|
||||
bool has_set_position_response() { return this->has_set_position_response_; }
|
||||
bool has_set_state_response() { return this->has_set_state_response_; }
|
||||
bool has_position() { return this->has_position_; }
|
||||
bool has_pin_response() { return this->has_pin_response_; }
|
||||
bool has_battery_level() const { return this->has_battery_level_; }
|
||||
bool has_light_level() const { return this->has_light_level_; }
|
||||
bool has_set_position_response() const { return this->has_set_position_response_; }
|
||||
bool has_set_state_response() const { return this->has_set_state_response_; }
|
||||
bool has_position() const { return this->has_position_; }
|
||||
bool has_pin_response() const { return this->has_pin_response_; }
|
||||
|
||||
union {
|
||||
uint8_t position_;
|
||||
@@ -65,12 +63,12 @@ class Am43Decoder {
|
||||
};
|
||||
|
||||
protected:
|
||||
bool has_battery_level_;
|
||||
bool has_light_level_;
|
||||
bool has_set_position_response_;
|
||||
bool has_set_state_response_;
|
||||
bool has_position_;
|
||||
bool has_pin_response_;
|
||||
bool has_battery_level_{false};
|
||||
bool has_light_level_{false};
|
||||
bool has_set_position_response_{false};
|
||||
bool has_set_state_response_{false};
|
||||
bool has_position_{false};
|
||||
bool has_pin_response_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::am43
|
||||
@@ -17,25 +17,12 @@ void Am43Component::dump_config() {
|
||||
this->pin_, (int) this->invert_position_);
|
||||
}
|
||||
|
||||
void Am43Component::setup() {
|
||||
this->position = COVER_OPEN;
|
||||
this->encoder_ = make_unique<Am43Encoder>();
|
||||
this->decoder_ = make_unique<Am43Decoder>();
|
||||
this->logged_in_ = false;
|
||||
}
|
||||
void Am43Component::setup() { this->position = COVER_OPEN; }
|
||||
|
||||
void Am43Component::loop() {
|
||||
if (this->node_state == espbt::ClientState::ESTABLISHED && !this->logged_in_) {
|
||||
auto *packet = this->encoder_->get_send_pin_request(this->pin_);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
ESP_LOGI(TAG, "[%s] Logging into AM43", LOG_STR_ARG(this->get_log_name()));
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] Error writing set_pin to device, error = %d", LOG_STR_ARG(this->get_log_name()), status);
|
||||
} else {
|
||||
this->logged_in_ = true;
|
||||
}
|
||||
this->logged_in_ = this->write_packet_(Am43Encoder::get_send_pin_request(this->pin_), LOG_STR("set_pin"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,29 +40,15 @@ void Am43Component::control(const CoverCall &call) {
|
||||
ESP_LOGW(TAG, "[%s] Cannot send cover control, not connected", LOG_STR_ARG(this->get_log_name()));
|
||||
return;
|
||||
}
|
||||
if (call.get_stop()) {
|
||||
auto *packet = this->encoder_->get_stop_request();
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] Error writing stop command to device, error = %d", LOG_STR_ARG(this->get_log_name()), status);
|
||||
}
|
||||
}
|
||||
if (call.get_stop())
|
||||
this->write_packet_(Am43Encoder::get_stop_request(), LOG_STR("stop"));
|
||||
auto opt_pos = call.get_position();
|
||||
if (opt_pos.has_value()) {
|
||||
auto pos = *opt_pos;
|
||||
|
||||
if (this->invert_position_)
|
||||
pos = 1 - pos;
|
||||
auto *packet = this->encoder_->get_set_position_request(100 - (uint8_t) (pos * 100));
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] Error writing set_position command to device, error = %d", LOG_STR_ARG(this->get_log_name()),
|
||||
status);
|
||||
}
|
||||
this->write_packet_(Am43Encoder::get_set_position_request(100 - (uint8_t) (pos * 100)), LOG_STR("set_position"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +86,11 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->char_handle_)
|
||||
break;
|
||||
this->decoder_->decode(param->notify.value, param->notify.value_len);
|
||||
Am43Decoder decoder;
|
||||
decoder.decode(param->notify.value, param->notify.value_len);
|
||||
|
||||
if (this->decoder_->has_position()) {
|
||||
this->position = ((float) this->decoder_->position_ / 100.0f);
|
||||
if (decoder.has_position()) {
|
||||
this->position = ((float) decoder.position_ / 100.0f);
|
||||
if (!this->invert_position_)
|
||||
this->position = 1 - this->position;
|
||||
if (this->position > 0.97f)
|
||||
@@ -126,27 +100,20 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
if (this->decoder_->has_pin_response()) {
|
||||
if (this->decoder_->pin_ok_) {
|
||||
if (decoder.has_pin_response()) {
|
||||
if (decoder.pin_ok_) {
|
||||
ESP_LOGI(TAG, "[%s] AM43 pin accepted.", LOG_STR_ARG(this->get_log_name()));
|
||||
auto *packet = this->encoder_->get_position_request();
|
||||
auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_, packet->length, packet->data,
|
||||
ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] Error writing set_position to device, error = %d", LOG_STR_ARG(this->get_log_name()),
|
||||
status);
|
||||
}
|
||||
this->write_packet_(Am43Encoder::get_position_request(), LOG_STR("get_position"));
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] AM43 pin rejected!", LOG_STR_ARG(this->get_log_name()));
|
||||
}
|
||||
}
|
||||
|
||||
if (this->decoder_->has_set_position_response() && !this->decoder_->set_position_ok_) {
|
||||
if (decoder.has_set_position_response() && !decoder.set_position_ok_) {
|
||||
ESP_LOGW(TAG, "[%s] Got nack after sending set_position. Bad pin?", LOG_STR_ARG(this->get_log_name()));
|
||||
}
|
||||
|
||||
if (this->decoder_->has_set_state_response() && !this->decoder_->set_state_ok_) {
|
||||
if (decoder.has_set_state_response() && !decoder.set_state_ok_) {
|
||||
ESP_LOGW(TAG, "[%s] Got nack after sending set_state. Bad pin?", LOG_STR_ARG(this->get_log_name()));
|
||||
}
|
||||
break;
|
||||
@@ -156,6 +123,19 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
}
|
||||
}
|
||||
|
||||
bool Am43Component::write_packet_(const Am43Packet &packet, const LogString *what) {
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) the IDF API takes a mutable buffer it only reads
|
||||
auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_, packet.length, const_cast<uint8_t *>(packet.data),
|
||||
ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] Error writing %s to device, error = %d", LOG_STR_ARG(this->get_log_name()), LOG_STR_ARG(what),
|
||||
status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::am43
|
||||
|
||||
#endif
|
||||
@@ -27,14 +27,12 @@ class Am43Component final : public cover::Cover, public esphome::ble_client::BLE
|
||||
|
||||
protected:
|
||||
void control(const cover::CoverCall &call) override;
|
||||
uint16_t char_handle_;
|
||||
uint16_t pin_;
|
||||
bool invert_position_;
|
||||
std::unique_ptr<Am43Encoder> encoder_;
|
||||
std::unique_ptr<Am43Decoder> decoder_;
|
||||
bool logged_in_;
|
||||
|
||||
float position_;
|
||||
/// Writes a request to the control characteristic; logs and returns false on failure.
|
||||
bool write_packet_(const Am43Packet &packet, const LogString *what);
|
||||
uint16_t char_handle_{0};
|
||||
uint16_t pin_{0};
|
||||
bool invert_position_{false};
|
||||
bool logged_in_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::am43
|
||||
|
||||
@@ -14,24 +14,9 @@ void Am43::dump_config() {
|
||||
LOG_SENSOR(" ", "Illuminance", this->illuminance_);
|
||||
}
|
||||
|
||||
void Am43::setup() {
|
||||
this->encoder_ = make_unique<Am43Encoder>();
|
||||
this->decoder_ = make_unique<Am43Decoder>();
|
||||
this->logged_in_ = false;
|
||||
this->last_battery_update_ = 0;
|
||||
this->current_sensor_ = 0;
|
||||
}
|
||||
|
||||
void Am43::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_OPEN_EVT: {
|
||||
if (param->open.status == ESP_GATT_OK) {
|
||||
this->logged_in_ = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_DISCONNECT_EVT: {
|
||||
this->logged_in_ = false;
|
||||
this->node_state = espbt::ClientState::IDLE;
|
||||
if (this->battery_ != nullptr)
|
||||
this->battery_->publish_state(NAN);
|
||||
@@ -60,28 +45,21 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->char_handle_)
|
||||
break;
|
||||
this->decoder_->decode(param->notify.value, param->notify.value_len);
|
||||
Am43Decoder decoder;
|
||||
decoder.decode(param->notify.value, param->notify.value_len);
|
||||
|
||||
if (this->battery_ != nullptr && this->decoder_->has_battery_level() &&
|
||||
millis() - this->last_battery_update_ > 10000) {
|
||||
this->battery_->publish_state(this->decoder_->battery_level_);
|
||||
if (this->battery_ != nullptr && decoder.has_battery_level() && millis() - this->last_battery_update_ > 10000) {
|
||||
this->battery_->publish_state(decoder.battery_level_);
|
||||
this->last_battery_update_ = millis();
|
||||
}
|
||||
|
||||
if (this->illuminance_ != nullptr && this->decoder_->has_light_level()) {
|
||||
this->illuminance_->publish_state(this->decoder_->light_level_);
|
||||
if (this->illuminance_ != nullptr && decoder.has_light_level()) {
|
||||
this->illuminance_->publish_state(decoder.light_level_);
|
||||
}
|
||||
|
||||
if (this->current_sensor_ > 0) {
|
||||
if (this->illuminance_ != nullptr) {
|
||||
auto *packet = this->encoder_->get_light_level_request();
|
||||
auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_, packet->length, packet->data,
|
||||
ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
if (this->illuminance_ != nullptr)
|
||||
this->write_packet_(Am43Encoder::get_light_level_request());
|
||||
this->current_sensor_ = 0;
|
||||
}
|
||||
break;
|
||||
@@ -97,19 +75,22 @@ void Am43::update() {
|
||||
return;
|
||||
}
|
||||
if (this->current_sensor_ == 0) {
|
||||
if (this->battery_ != nullptr) {
|
||||
auto *packet = this->encoder_->get_battery_level_request();
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
if (this->battery_ != nullptr)
|
||||
this->write_packet_(Am43Encoder::get_battery_level_request());
|
||||
this->current_sensor_++;
|
||||
}
|
||||
}
|
||||
|
||||
void Am43::write_packet_(const Am43Packet &packet) {
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) the IDF API takes a mutable buffer it only reads
|
||||
auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_, packet.length, const_cast<uint8_t *>(packet.data),
|
||||
ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::am43
|
||||
|
||||
#endif
|
||||
@@ -16,7 +16,6 @@ namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent {
|
||||
public:
|
||||
void setup() override;
|
||||
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;
|
||||
@@ -25,16 +24,15 @@ class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComp
|
||||
void set_illuminance(sensor::Sensor *illuminance) { illuminance_ = illuminance; }
|
||||
|
||||
protected:
|
||||
uint16_t char_handle_;
|
||||
std::unique_ptr<Am43Encoder> encoder_;
|
||||
std::unique_ptr<Am43Decoder> decoder_;
|
||||
bool logged_in_;
|
||||
/// Writes a request to the control characteristic; logs on failure.
|
||||
void write_packet_(const Am43Packet &packet);
|
||||
sensor::Sensor *battery_{nullptr};
|
||||
sensor::Sensor *illuminance_{nullptr};
|
||||
uint8_t current_sensor_;
|
||||
// The AM43 often gets into a state where it spams loads of battery update
|
||||
// notifications. Here we will limit to no more than every 10s.
|
||||
uint32_t last_battery_update_;
|
||||
uint32_t last_battery_update_{0};
|
||||
uint16_t char_handle_{0};
|
||||
uint8_t current_sensor_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::am43
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
DOMAIN = "as7341"
|
||||
|
||||
DEPRECATED_COMPONENT = """
|
||||
The 'as7341' component is deprecated in favour of 'as734x', which covers the AS7341, AS7343 and TCS3448.
|
||||
New features and fixes should target 'as734x'. It will be removed in ESPHome 2027.5.0.
|
||||
"""
|
||||
@@ -110,12 +110,12 @@ bool AS7341Component::read_channels(uint16_t *data) {
|
||||
this->set_smux_low_channels(true);
|
||||
this->enable_spectral_measurement(true);
|
||||
this->wait_for_data();
|
||||
bool low_success = this->read_bytes_16(AS7341_CH0_DATA_L, data, 6);
|
||||
bool low_success = this->read_bytes_16_le(AS7341_CH0_DATA_L, data, 6);
|
||||
|
||||
this->set_smux_low_channels(false);
|
||||
this->enable_spectral_measurement(true);
|
||||
this->wait_for_data();
|
||||
bool high_sucess = this->read_bytes_16(AS7341_CH0_DATA_L, &data[6], 6);
|
||||
bool high_sucess = this->read_bytes_16_le(AS7341_CH0_DATA_L, &data[6], 6);
|
||||
|
||||
return low_success && high_sucess;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensor
|
||||
from esphome.components.const import UNIT_COUNTS
|
||||
@@ -11,6 +13,8 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@mrgnr"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
@@ -96,6 +100,12 @@ SENSORS = {
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Remove before 2027.5.0
|
||||
_LOGGER.warning(
|
||||
"The 'as7341' component is deprecated and will be removed in ESPHome 2027.5.0, migrate to an "
|
||||
"'as734x:' hub with 'type: AS7341' and a 'sensor:' entry with 'platform: as734x' and the bands "
|
||||
"under 'counts:'."
|
||||
)
|
||||
var = await i2c.new_i2c_component(config)
|
||||
|
||||
cg.add(var.set_gain(config[CONF_GAIN]))
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_client
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_DATA,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_ON_MESSAGE,
|
||||
CONF_PAYLOAD,
|
||||
CONF_VALUE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@bogdanr"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "ble_midi"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_CONTROL_NUMBER = "control_number"
|
||||
CONF_MAX_SYSEX_SIZE = "max_sysex_size"
|
||||
CONF_NOTE = "note"
|
||||
CONF_ON_CONTROL_CHANGE = "on_control_change"
|
||||
CONF_ON_NOTE_OFF = "on_note_off"
|
||||
CONF_ON_NOTE_ON = "on_note_on"
|
||||
CONF_ON_PITCH_BEND = "on_pitch_bend"
|
||||
CONF_ON_PROGRAM_CHANGE = "on_program_change"
|
||||
CONF_ON_SYSEX = "on_sysex"
|
||||
CONF_PAIR = "pair"
|
||||
CONF_PROGRAM = "program"
|
||||
CONF_SYSEX_BUFFER_ID = "sysex_buffer_id"
|
||||
CONF_VELOCITY = "velocity"
|
||||
|
||||
ble_midi_ns = cg.esphome_ns.namespace("ble_midi")
|
||||
BLEMidi = ble_midi_ns.class_("BLEMidi", cg.Component, ble_client.BLEClientNode)
|
||||
# The message bytes as a view: data.size() and data[i] work, and nothing is copied
|
||||
ByteSpan = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
# MIDI data bytes are 7-bit and channels are 4-bit, matching what goes on the wire and what the triggers report.
|
||||
# Channel 0 is the channel that MIDI hardware usually labels "1".
|
||||
midi_data_byte = cv.int_range(min=0, max=127)
|
||||
midi_channel = cv.int_range(min=0, max=15)
|
||||
|
||||
_NOTE_ARGS = [
|
||||
(cg.uint8, CONF_NOTE),
|
||||
(cg.uint8, CONF_VELOCITY),
|
||||
(cg.uint8, CONF_CHANNEL),
|
||||
]
|
||||
_BYTES_ARGS = [(ByteSpan, CONF_DATA)]
|
||||
# data points into a notification or a reused buffer, so these automations cannot wait
|
||||
_BYTES_TRIGGERS = (CONF_ON_SYSEX, CONF_ON_MESSAGE)
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_NOTE_ON, "add_on_note_on_callback", _NOTE_ARGS
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_NOTE_OFF, "add_on_note_off_callback", _NOTE_ARGS
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_CONTROL_CHANGE,
|
||||
"add_on_control_change_callback",
|
||||
[
|
||||
(cg.uint8, CONF_CONTROL_NUMBER),
|
||||
(cg.uint8, CONF_VALUE),
|
||||
(cg.uint8, CONF_CHANNEL),
|
||||
],
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PROGRAM_CHANGE,
|
||||
"add_on_program_change_callback",
|
||||
[(cg.uint8, CONF_PROGRAM), (cg.uint8, CONF_CHANNEL)],
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PITCH_BEND,
|
||||
"add_on_pitch_bend_callback",
|
||||
[(cg.int16, CONF_VALUE), (cg.uint8, CONF_CHANNEL)],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_SYSEX, "add_on_sysex_callback", _BYTES_ARGS),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_MESSAGE, "add_on_message_callback", _BYTES_ARGS
|
||||
),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEMidi),
|
||||
cv.GenerateID(CONF_SYSEX_BUFFER_ID): cv.declare_id(cg.uint8),
|
||||
cv.Optional(CONF_MAX_SYSEX_SIZE, default=256): cv.int_range(
|
||||
min=8, max=2048
|
||||
),
|
||||
cv.Optional(CONF_PAIR, default=False): cv.boolean,
|
||||
**{
|
||||
cv.Optional(entry.conf_key): (
|
||||
automation.validate_synchronous_automation(
|
||||
f"ble_midi {entry.conf_key}", "data"
|
||||
)
|
||||
if entry.conf_key in _BYTES_TRIGGERS
|
||||
else automation.validate_automation({})
|
||||
)
|
||||
for entry in _CALLBACK_AUTOMATIONS
|
||||
},
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
size = config[CONF_MAX_SYSEX_SIZE]
|
||||
# SysEx messages are reassembled in a static array, so nothing is allocated at run time
|
||||
buf = config[CONF_SYSEX_BUFFER_ID].id
|
||||
cg.add_global(cg.RawStatement(f"static uint8_t {buf}[{size}];"))
|
||||
var = await ble_client.new_ble_node_component(
|
||||
config, cg.RawExpression(buf), size, config[CONF_PAIR]
|
||||
)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
def _action_schema(keys: dict) -> cv.Schema:
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||
**keys,
|
||||
cv.Optional(CONF_CHANNEL, default=0): cv.templatable(midi_channel),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_NOTE_ACTION_SCHEMA = _action_schema(
|
||||
{
|
||||
cv.Required(CONF_NOTE): cv.templatable(midi_data_byte),
|
||||
cv.Optional(CONF_VELOCITY, default=64): cv.templatable(midi_data_byte),
|
||||
}
|
||||
)
|
||||
|
||||
# Wider than the wire values so the C++ range check sees a lambda's result before it is narrowed
|
||||
_CHANNEL = (CONF_CHANNEL, cg.int32)
|
||||
_NOTE_CALL_ARGS = ((CONF_NOTE, cg.int32), (CONF_VELOCITY, cg.int32), _CHANNEL)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_note_on",
|
||||
_NOTE_ACTION_SCHEMA,
|
||||
automation.ApplyCall("send_note_on({}, {}, {})", _NOTE_CALL_ARGS),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_note_off",
|
||||
_NOTE_ACTION_SCHEMA,
|
||||
automation.ApplyCall("send_note_off({}, {}, {})", _NOTE_CALL_ARGS),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_control_change",
|
||||
_action_schema(
|
||||
{
|
||||
cv.Required(CONF_CONTROL_NUMBER): cv.templatable(midi_data_byte),
|
||||
cv.Required(CONF_VALUE): cv.templatable(midi_data_byte),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_control_change({}, {}, {})",
|
||||
((CONF_CONTROL_NUMBER, cg.int32), (CONF_VALUE, cg.int32), _CHANNEL),
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_program_change",
|
||||
_action_schema({cv.Required(CONF_PROGRAM): cv.templatable(midi_data_byte)}),
|
||||
automation.ApplyCall(
|
||||
"send_program_change({}, {})", ((CONF_PROGRAM, cg.int32), _CHANNEL)
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_pitch_bend",
|
||||
_action_schema(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.int_range(min=-8192, max=8191)),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall("send_pitch_bend({}, {})", ((CONF_VALUE, cg.int32), _CHANNEL)),
|
||||
)
|
||||
|
||||
|
||||
def _bytes_action_schema(key: str) -> cv.Schema:
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||
cv.Required(key): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_sysex",
|
||||
_bytes_action_schema(CONF_PAYLOAD),
|
||||
automation.ApplyBytes(CONF_PAYLOAD, "send_sysex({})"),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_raw",
|
||||
_bytes_action_schema(CONF_DATA),
|
||||
automation.ApplyBytes(CONF_DATA, "send_raw({})"),
|
||||
)
|
||||
|
||||
automation.register_apply_condition(
|
||||
"ble_midi.connected", cv.Schema({cv.GenerateID(): cv.use_id(BLEMidi)}), "is_ready()"
|
||||
)
|
||||
@@ -0,0 +1,310 @@
|
||||
#include "ble_midi.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_midi");
|
||||
|
||||
// Standard BLE-MIDI service and characteristic, as defined by the BLE-MIDI
|
||||
// specification.
|
||||
static constexpr const char *SERVICE_UUID = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700";
|
||||
static constexpr const char *CHARACTERISTIC_UUID = "7772E5DB-3868-4112-A1A9-F2669D106BF3";
|
||||
|
||||
// MIDI status bytes for the messages this component can transmit.
|
||||
static constexpr uint8_t STATUS_NOTE_OFF = 0x80;
|
||||
static constexpr uint8_t STATUS_NOTE_ON = 0x90;
|
||||
static constexpr uint8_t STATUS_CONTROL_CHANGE = 0xB0;
|
||||
static constexpr uint8_t STATUS_PROGRAM_CHANGE = 0xC0;
|
||||
static constexpr uint8_t STATUS_PITCH_BEND = 0xE0;
|
||||
|
||||
// Number of received bytes included in verbose packet logs.
|
||||
static constexpr size_t LOG_MAX_BYTES = 32;
|
||||
|
||||
void BLEMidi::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE MIDI:\n"
|
||||
" MAC address: %s\n"
|
||||
" Maximum SysEx size: %u\n"
|
||||
" Pair on connect: %s",
|
||||
this->parent()->address_str(), this->parser_.sysex_capacity(), YESNO(this->pair_));
|
||||
}
|
||||
|
||||
void BLEMidi::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_OPEN_EVT: {
|
||||
if (param->open.status != ESP_GATT_OK)
|
||||
break;
|
||||
// A warning left by an earlier session no longer applies
|
||||
this->status_clear_warning();
|
||||
this->parser_.reset();
|
||||
if (this->pair_) {
|
||||
esp_err_t err = this->parent()->pair();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Pairing failed, error=%d", err);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CFG_MTU_EVT: {
|
||||
if (param->cfg_mtu.status == ESP_GATT_OK)
|
||||
this->mtu_ = param->cfg_mtu.mtu;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_DISCONNECT_EVT:
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
this->parser_.reset();
|
||||
this->characteristic_handle_ = 0;
|
||||
this->cccd_handle_ = 0;
|
||||
this->mtu_ = 23;
|
||||
// Both events can arrive for the same disconnect, so the automation runs
|
||||
// only for the one that follows an established MIDI connection.
|
||||
const bool was_ready = this->notify_registered_;
|
||||
this->notify_registered_ = false;
|
||||
this->node_state = espbt::ClientState::IDLE;
|
||||
if (was_ready)
|
||||
this->disconnect_callback_.call();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
if (param->search_cmpl.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Service discovery failed, status=%d", param->search_cmpl.status);
|
||||
this->fail_session_();
|
||||
break;
|
||||
}
|
||||
auto *characteristic = this->parent()->get_characteristic(espbt::ESPBTUUID::from_raw(SERVICE_UUID),
|
||||
espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID));
|
||||
// Reconnecting would not help a device without them, so the link is left as it is
|
||||
if (characteristic == nullptr) {
|
||||
this->status_set_warning(LOG_STR("No BLE MIDI characteristic found on this device"));
|
||||
break;
|
||||
}
|
||||
auto *cccd = characteristic->get_descriptor(ESP_GATT_UUID_CHAR_CLIENT_CONFIG);
|
||||
if (cccd == nullptr) {
|
||||
this->status_set_warning(LOG_STR("BLE MIDI characteristic has no notification descriptor"));
|
||||
break;
|
||||
}
|
||||
this->characteristic_handle_ = characteristic->handle;
|
||||
this->cccd_handle_ = cccd->handle;
|
||||
this->write_without_response_ = (characteristic->properties & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) != 0;
|
||||
esp_err_t err = this->parent()->register_for_notify(characteristic->handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register_for_notify failed, error=%d", err);
|
||||
this->fail_session_();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
if (param->reg_for_notify.handle != this->characteristic_handle_)
|
||||
break;
|
||||
if (param->reg_for_notify.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Registering for notifications failed, status=%d", param->reg_for_notify.status);
|
||||
this->fail_session_();
|
||||
}
|
||||
// On success the parent writes the descriptor; the device has accepted it once WRITE_DESCR_EVT reports OK
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_DESCR_EVT: {
|
||||
if (this->cccd_handle_ == 0 || param->write.handle != this->cccd_handle_ || this->notify_registered_)
|
||||
break;
|
||||
if (param->write.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Device refused to enable notifications, status=%d", param->write.status);
|
||||
this->fail_session_();
|
||||
break;
|
||||
}
|
||||
this->notify_registered_ = true;
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
ESP_LOGI(TAG, "MIDI notifications enabled");
|
||||
// The device can only be sent to once notifications are enabled, so this
|
||||
// is the point where on_connect is useful.
|
||||
this->connect_callback_.call();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_CHAR_EVT: {
|
||||
// A write with response reports its outcome here; one without response reports nothing
|
||||
if (param->write.handle == this->characteristic_handle_ && param->write.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Write rejected, status=%d", param->write.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->characteristic_handle_ || param->notify.value_len == 0)
|
||||
break;
|
||||
// format_hex_to() truncates to the buffer size, so long packets are logged
|
||||
// only in part.
|
||||
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||
ESP_LOGV(TAG, "Received %u bytes: %s", param->notify.value_len,
|
||||
format_hex_to(hex, param->notify.value, param->notify.value_len));
|
||||
this->parser_.feed(param->notify.value, param->notify.value_len, BLEMidi::dispatch_message, this);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void BLEMidi::fail_session_() {
|
||||
this->status_set_warning();
|
||||
// ble_client reconnects, which runs the setup again
|
||||
this->parent()->disconnect();
|
||||
}
|
||||
|
||||
void BLEMidi::handle_message_(const MidiMessage &message) {
|
||||
ESP_LOGV(
|
||||
TAG, "Decoded %s",
|
||||
LOG_STR_ARG(MessageTypeNames::get_log_str(static_cast<uint8_t>(message.type), MessageTypeNames::LAST_INDEX)));
|
||||
// Only decoded messages reach on_message
|
||||
if (message.type == MessageType::SYSEX_INCOMPLETE) {
|
||||
ESP_LOGW(TAG, "SysEx of %u bytes dropped: cut short, or longer than max_sysex_size (%u)", message.length,
|
||||
this->parser_.sysex_capacity());
|
||||
return;
|
||||
}
|
||||
if (message.type == MessageType::INCOMPLETE) {
|
||||
ESP_LOGW(TAG, "MIDI message 0x%02X dropped: cut short by another status byte", message.status());
|
||||
return;
|
||||
}
|
||||
if (message.type == MessageType::UNKNOWN) {
|
||||
ESP_LOGW(TAG, "Undecodable MIDI byte 0x%02X", message.status());
|
||||
return;
|
||||
}
|
||||
this->message_callback_.call(ByteSpan(message.data, message.length));
|
||||
|
||||
switch (message.type) {
|
||||
case MessageType::NOTE_ON:
|
||||
this->note_on_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::NOTE_OFF:
|
||||
this->note_off_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::CONTROL_CHANGE:
|
||||
this->control_change_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::PROGRAM_CHANGE:
|
||||
this->program_change_callback_.call(message.data1(), message.channel());
|
||||
break;
|
||||
case MessageType::PITCH_BEND:
|
||||
this->pitch_bend_callback_.call(message.pitch_bend(), message.channel());
|
||||
break;
|
||||
case MessageType::SYSEX:
|
||||
// The payload without the 0xF0/0xF7 wrappers; a complete SysEx always has both
|
||||
this->sysex_callback_.call(ByteSpan(message.data + 1, message.length - 2));
|
||||
break;
|
||||
default:
|
||||
// Polyphonic and channel aftertouch, system common and real-time messages
|
||||
// are only reported through on_message.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BLEMidi::write_packet_(uint8_t *packet, size_t midi_length) {
|
||||
if (!this->is_ready()) {
|
||||
ESP_LOGW(TAG, "Cannot send: not connected");
|
||||
return false;
|
||||
}
|
||||
// Every BLE-MIDI packet has to fit one ATT packet
|
||||
const size_t packet_length = midi_length + 2;
|
||||
const size_t limit = std::min<size_t>(MAX_TX_MIDI_BYTES + 2, this->mtu_ - 3);
|
||||
if (packet_length > limit) {
|
||||
ESP_LOGW(TAG, "Cannot send: message of %u bytes exceeds the %u byte limit", static_cast<unsigned>(midi_length),
|
||||
static_cast<unsigned>(limit - 2));
|
||||
return false;
|
||||
}
|
||||
packet[0] = PACKET_HEADER;
|
||||
packet[1] = PACKET_TIMESTAMP;
|
||||
esp_err_t err = esp_ble_gattc_write_char(
|
||||
this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->characteristic_handle_, packet_length,
|
||||
packet, this->write_without_response_ ? ESP_GATT_WRITE_TYPE_NO_RSP : ESP_GATT_WRITE_TYPE_RSP,
|
||||
ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Write failed, error=%d", err);
|
||||
return false;
|
||||
}
|
||||
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||
ESP_LOGV(TAG, "Sent %u bytes: %s", static_cast<unsigned>(packet_length), format_hex_to(hex, packet, packet_length));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BLEMidi::write_midi_(const uint8_t *midi_bytes, size_t length) {
|
||||
if (length == 0 || length > MAX_TX_MIDI_BYTES) {
|
||||
ESP_LOGW(TAG, "Cannot send: message of %u bytes, the limit is %u", static_cast<unsigned>(length),
|
||||
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||
return false;
|
||||
}
|
||||
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||
std::memcpy(packet + 2, midi_bytes, length);
|
||||
return this->write_packet_(packet, length);
|
||||
}
|
||||
|
||||
void BLEMidi::send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length) {
|
||||
// Validation covers constants only; a lambda can return any value, and masking it would send a different message.
|
||||
// The values arrive unnarrowed so a negative or wrapped one is caught here (the unsigned compare covers negatives).
|
||||
if (static_cast<uint32_t>(channel) > 0x0F || static_cast<uint32_t>(data1) > 0x7F ||
|
||||
static_cast<uint32_t>(data2) > 0x7F) {
|
||||
ESP_LOGW(TAG, "Cannot send: channel %" PRId32 " or value %" PRId32 "/%" PRId32 " out of range", channel, data1,
|
||||
data2);
|
||||
return;
|
||||
}
|
||||
const uint8_t bytes[3] = {static_cast<uint8_t>(status | channel), static_cast<uint8_t>(data1),
|
||||
static_cast<uint8_t>(data2)};
|
||||
this->write_midi_(bytes, length);
|
||||
}
|
||||
|
||||
void BLEMidi::send_note_on(int32_t note, int32_t velocity, int32_t channel) {
|
||||
this->send_channel_(STATUS_NOTE_ON, channel, note, velocity, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_note_off(int32_t note, int32_t velocity, int32_t channel) {
|
||||
this->send_channel_(STATUS_NOTE_OFF, channel, note, velocity, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_control_change(int32_t control_number, int32_t value, int32_t channel) {
|
||||
this->send_channel_(STATUS_CONTROL_CHANGE, channel, control_number, value, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_program_change(int32_t program, int32_t channel) {
|
||||
this->send_channel_(STATUS_PROGRAM_CHANGE, channel, program, 0, 2);
|
||||
}
|
||||
|
||||
void BLEMidi::send_pitch_bend(int32_t value, int32_t channel) {
|
||||
if (value < -8192 || value > 8191) {
|
||||
ESP_LOGW(TAG, "Cannot send: pitch bend %" PRId32 " out of range", value);
|
||||
return;
|
||||
}
|
||||
const uint16_t unsigned_value = static_cast<uint16_t>(value + 8192);
|
||||
this->send_channel_(STATUS_PITCH_BEND, channel, unsigned_value & 0x7F, unsigned_value >> 7, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_sysex(const uint8_t *payload, size_t length) {
|
||||
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||
const size_t framed = frame_sysex(payload, length, packet + 2, MAX_TX_MIDI_BYTES);
|
||||
if (framed == 0) {
|
||||
ESP_LOGW(TAG, "Cannot send: SysEx of %u bytes is empty or exceeds the %u byte limit", static_cast<unsigned>(length),
|
||||
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||
return;
|
||||
}
|
||||
this->write_packet_(packet, framed);
|
||||
}
|
||||
|
||||
void BLEMidi::send_raw(const uint8_t *bytes, size_t length) {
|
||||
// The packet carries one timestamp, in front of the first byte, so the payload has to be a single complete
|
||||
// message with its status byte first; a SysEx goes through send_sysex
|
||||
bool one_message = length > 0 && (bytes[0] & 0x80) != 0 && bytes[0] != STATUS_SYSEX_START &&
|
||||
bytes[0] != STATUS_SYSEX_END && length == 1u + MidiParser::expected_data_bytes(bytes[0]);
|
||||
for (size_t i = 1; one_message && i < length; i++) {
|
||||
one_message = (bytes[i] & 0x80) == 0;
|
||||
}
|
||||
if (!one_message) {
|
||||
ESP_LOGW(TAG, "Cannot send: raw payload must be one message, a status byte followed by data bytes");
|
||||
return;
|
||||
}
|
||||
this->write_midi_(bytes, length);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,138 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include "midi_parser.h"
|
||||
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
/// The bytes of a received message, valid for the duration of the automation.
|
||||
using ByteSpan = std::span<const uint8_t>;
|
||||
|
||||
/// Longest MIDI message that can be transmitted in a single BLE-MIDI packet, header and timestamp excluded.
|
||||
static constexpr size_t MAX_TX_MIDI_BYTES = 128;
|
||||
|
||||
/// Client for devices implementing the standard BLE-MIDI service, e.g. wireless
|
||||
/// MIDI keyboards and pad controllers. Incoming messages are exposed as
|
||||
/// automation triggers; outgoing messages are sent with the ble_midi.send_*
|
||||
/// actions.
|
||||
///
|
||||
/// Specification: "Specification for MIDI over Bluetooth Low Energy
|
||||
/// (BLE-MIDI)", MIDI Association, document M1-2017-11-01.
|
||||
class BLEMidi final : public Component, public ble_client::BLEClientNode {
|
||||
public:
|
||||
/// sysex_buffer holds sysex_capacity bytes; longer SysEx messages are truncated.
|
||||
BLEMidi(uint8_t *sysex_buffer, uint16_t sysex_capacity, bool pair)
|
||||
: parser_(sysex_buffer, sysex_capacity), pair_(pair) {}
|
||||
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override;
|
||||
|
||||
/// True once the MIDI characteristic has been discovered and notifications
|
||||
/// are enabled.
|
||||
bool is_ready() const { return this->notify_registered_; }
|
||||
|
||||
// Channel is 0-15, note/velocity/controller/value/program are 0-127; anything else is refused with a warning.
|
||||
void send_note_on(int32_t note, int32_t velocity, int32_t channel);
|
||||
void send_note_off(int32_t note, int32_t velocity, int32_t channel);
|
||||
void send_control_change(int32_t control_number, int32_t value, int32_t channel);
|
||||
void send_program_change(int32_t program, int32_t channel);
|
||||
/// Pitch bend value is -8192 to 8191, centered on zero.
|
||||
void send_pitch_bend(int32_t value, int32_t channel);
|
||||
/// Send a System Exclusive message. The 0xF0/0xF7 wrappers are added if the
|
||||
/// payload does not already carry them.
|
||||
void send_sysex(const uint8_t *payload, size_t length);
|
||||
/// Send raw MIDI bytes (status byte followed by its data bytes).
|
||||
void send_raw(const uint8_t *bytes, size_t length);
|
||||
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (note, velocity, channel)
|
||||
template<typename F> void add_on_note_on_callback(F &&callback) {
|
||||
this->note_on_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (note, velocity, channel); a Note On with velocity zero is reported here as well
|
||||
template<typename F> void add_on_note_off_callback(F &&callback) {
|
||||
this->note_off_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (control_number, value, channel)
|
||||
template<typename F> void add_on_control_change_callback(F &&callback) {
|
||||
this->control_change_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (program, channel)
|
||||
template<typename F> void add_on_program_change_callback(F &&callback) {
|
||||
this->program_change_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (value, channel), value being -8192 to 8191
|
||||
template<typename F> void add_on_pitch_bend_callback(F &&callback) {
|
||||
this->pitch_bend_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// The SysEx payload without the 0xF0/0xF7 wrappers
|
||||
template<typename F> void add_on_sysex_callback(F &&callback) {
|
||||
this->sysex_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// The raw bytes of every decoded message
|
||||
template<typename F> void add_on_message_callback(F &&callback) {
|
||||
this->message_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
static void dispatch_message(void *ctx, const MidiMessage &message) {
|
||||
static_cast<BLEMidi *>(ctx)->handle_message_(message);
|
||||
}
|
||||
void handle_message_(const MidiMessage &message);
|
||||
/// Wrap MIDI bytes in a BLE-MIDI packet and write them to the MIDI characteristic.
|
||||
bool write_midi_(const uint8_t *midi_bytes, size_t length);
|
||||
/// Write a packet whose MIDI bytes already sit at packet + 2; the header and timestamp are filled in.
|
||||
bool write_packet_(uint8_t *packet, size_t midi_length);
|
||||
/// Channel-voice message: the status byte takes the channel; out-of-range values are refused.
|
||||
void send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length);
|
||||
/// Set a warning and drop the link so that ble_client reconnects and the setup runs again.
|
||||
void fail_session_();
|
||||
|
||||
MidiParser parser_;
|
||||
LazyCallbackManager<void()> connect_callback_;
|
||||
LazyCallbackManager<void()> disconnect_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_on_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_off_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> control_change_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t)> program_change_callback_;
|
||||
LazyCallbackManager<void(int16_t, uint8_t)> pitch_bend_callback_;
|
||||
LazyCallbackManager<void(ByteSpan)> sysex_callback_;
|
||||
LazyCallbackManager<void(ByteSpan)> message_callback_;
|
||||
uint16_t characteristic_handle_{0};
|
||||
// Notification descriptor of the MIDI characteristic; the link is ready once the device accepts the write to it
|
||||
uint16_t cccd_handle_{0};
|
||||
// The ATT MTU the link negotiated; every packet has to fit it
|
||||
uint16_t mtu_{23};
|
||||
bool notify_registered_{false};
|
||||
// Write without response has lower latency and is preferred when the
|
||||
// characteristic supports it.
|
||||
bool write_without_response_{false};
|
||||
const bool pair_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "midi_parser.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
static MessageType type_for_status(uint8_t status) {
|
||||
if (status >= 0xF8)
|
||||
return MessageType::SYSTEM_REAL_TIME;
|
||||
if (status >= 0xF1 && status <= 0xF6)
|
||||
return MessageType::SYSTEM_COMMON;
|
||||
switch (status & 0xF0) {
|
||||
case 0x80:
|
||||
return MessageType::NOTE_OFF;
|
||||
case 0x90:
|
||||
return MessageType::NOTE_ON;
|
||||
case 0xA0:
|
||||
return MessageType::POLY_AFTERTOUCH;
|
||||
case 0xB0:
|
||||
return MessageType::CONTROL_CHANGE;
|
||||
case 0xC0:
|
||||
return MessageType::PROGRAM_CHANGE;
|
||||
case 0xD0:
|
||||
return MessageType::CHANNEL_AFTERTOUCH;
|
||||
case 0xE0:
|
||||
return MessageType::PITCH_BEND;
|
||||
default:
|
||||
return MessageType::UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// Data bytes that follow a channel-voice or system-common status byte
|
||||
uint8_t MidiParser::expected_data_bytes(uint8_t status) {
|
||||
if (status >= 0xF0) {
|
||||
switch (status) {
|
||||
case 0xF1: // MIDI Time Code Quarter Frame
|
||||
case 0xF3: // Song Select
|
||||
return 1;
|
||||
case 0xF2: // Song Position Pointer
|
||||
return 2;
|
||||
default: // 0xF4, 0xF5 undefined, 0xF6 Tune Request
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
switch (status & 0xF0) {
|
||||
case 0xC0: // Program Change
|
||||
case 0xD0: // Channel Aftertouch
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
void MidiParser::reset() {
|
||||
this->running_status_ = 0;
|
||||
this->sysex_active_ = false;
|
||||
this->sysex_length_ = 0;
|
||||
this->current_length_ = 0;
|
||||
}
|
||||
|
||||
void MidiParser::sysex_push_(uint8_t byte) {
|
||||
if (this->sysex_length_ < this->sysex_capacity_) {
|
||||
this->sysex_buffer_[this->sysex_length_++] = byte;
|
||||
} else {
|
||||
this->sysex_overflow_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ends the SysEx being assembled; it is complete when 0xF7 closed it and every byte fit the buffer
|
||||
void MidiParser::end_sysex_(bool complete, MidiMessageHandler handler, void *ctx) {
|
||||
this->sysex_active_ = false;
|
||||
this->running_status_ = 0;
|
||||
this->emit_(complete && !this->sysex_overflow_ ? MessageType::SYSEX : MessageType::SYSEX_INCOMPLETE,
|
||||
this->sysex_buffer_, this->sysex_length_, handler, ctx);
|
||||
}
|
||||
|
||||
void MidiParser::emit_current_(MidiMessageHandler handler, void *ctx) {
|
||||
const uint8_t status = this->current_message_[0];
|
||||
MessageType type = type_for_status(status);
|
||||
// A Note On with velocity zero is canonically a Note Off.
|
||||
if (type == MessageType::NOTE_ON && this->current_message_[2] == 0)
|
||||
type = MessageType::NOTE_OFF;
|
||||
// System Common clears running status; channel-voice messages set it.
|
||||
this->running_status_ = status >= 0xF0 ? 0 : status;
|
||||
const uint16_t length = this->current_length_;
|
||||
this->current_length_ = 0;
|
||||
this->emit_(type, this->current_message_, length, handler, ctx);
|
||||
}
|
||||
|
||||
// Takes a data byte for the message being assembled, or starts one from the running status
|
||||
void MidiParser::take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx) {
|
||||
if (this->current_length_ == 0) {
|
||||
if (this->running_status_ == 0) {
|
||||
// Data byte without any status context.
|
||||
this->emit_(MessageType::UNKNOWN, byte, 1, handler, ctx);
|
||||
return;
|
||||
}
|
||||
this->current_message_[0] = this->running_status_;
|
||||
this->current_length_ = 1;
|
||||
this->current_expected_ = expected_data_bytes(this->running_status_);
|
||||
}
|
||||
this->current_message_[this->current_length_++] = *byte;
|
||||
if (this->current_length_ > this->current_expected_)
|
||||
this->emit_current_(handler, ctx);
|
||||
}
|
||||
|
||||
void MidiParser::feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx) {
|
||||
// A BLE-MIDI packet is at least a header byte plus one timestamp or SysEx
|
||||
// continuation byte, and the header byte always has bit 7 set. Anything else
|
||||
// is malformed.
|
||||
if (length < 2 || (data[0] & 0x80) == 0) {
|
||||
for (size_t i = 0; i < length; i++)
|
||||
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// A timestamp byte precedes every status byte, even a real-time byte inside another message, so a byte with
|
||||
// bit 7 set is a status byte only right after a timestamp and a timestamp everywhere else
|
||||
bool expect_timestamp = true;
|
||||
for (size_t i = 1; i < length; i++) {
|
||||
const uint8_t byte = data[i];
|
||||
if ((byte & 0x80) == 0) {
|
||||
// A data byte continues the SysEx, the message being assembled, or the running status
|
||||
if (this->sysex_active_) {
|
||||
this->sysex_push_(byte);
|
||||
} else {
|
||||
this->take_data_(data + i, handler, ctx);
|
||||
}
|
||||
expect_timestamp = true;
|
||||
continue;
|
||||
}
|
||||
if (expect_timestamp) {
|
||||
expect_timestamp = false;
|
||||
continue;
|
||||
}
|
||||
expect_timestamp = true;
|
||||
if (byte >= 0xF8) {
|
||||
// Real-time messages are single bytes that interrupt anything, a SysEx included, without affecting it
|
||||
this->emit_(MessageType::SYSTEM_REAL_TIME, data + i, 1, handler, ctx);
|
||||
continue;
|
||||
}
|
||||
// Any other status byte cuts a message still being assembled short
|
||||
if (this->current_length_ > 0) {
|
||||
const uint16_t length = this->current_length_;
|
||||
this->current_length_ = 0;
|
||||
this->emit_(MessageType::INCOMPLETE, this->current_message_, length, handler, ctx);
|
||||
}
|
||||
if (byte == STATUS_SYSEX_END) {
|
||||
if (this->sysex_active_) {
|
||||
this->sysex_push_(STATUS_SYSEX_END);
|
||||
this->end_sysex_(true, handler, ctx);
|
||||
} else {
|
||||
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Any status byte but 0xF7 ends a SysEx still open
|
||||
if (this->sysex_active_)
|
||||
this->end_sysex_(false, handler, ctx);
|
||||
if (byte == STATUS_SYSEX_START) {
|
||||
this->sysex_active_ = true;
|
||||
this->sysex_length_ = 0;
|
||||
this->sysex_overflow_ = false;
|
||||
this->sysex_push_(STATUS_SYSEX_START);
|
||||
continue;
|
||||
}
|
||||
this->current_message_[0] = byte;
|
||||
this->current_length_ = 1;
|
||||
this->current_expected_ = expected_data_bytes(byte);
|
||||
// A status byte without data bytes, e.g. Tune Request, is a message on its own
|
||||
if (this->current_expected_ == 0)
|
||||
this->emit_current_(handler, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size) {
|
||||
if (length == 0)
|
||||
return 0;
|
||||
const size_t skip_start = payload[0] == STATUS_SYSEX_START ? 1 : 0;
|
||||
const size_t skip_end = payload[length - 1] == STATUS_SYSEX_END ? 1 : 0;
|
||||
const size_t body = length - skip_start - skip_end;
|
||||
// 0xF0, the body, then the timestamp before 0xF7
|
||||
const size_t framed = body + 3;
|
||||
if (framed > out_size)
|
||||
return 0;
|
||||
// A body byte with bit 7 set would be read as a timestamp or status byte
|
||||
for (size_t i = 0; i < body; i++) {
|
||||
if (payload[skip_start + i] & 0x80)
|
||||
return 0;
|
||||
}
|
||||
out[0] = STATUS_SYSEX_START;
|
||||
std::memcpy(out + 1, payload + skip_start, body);
|
||||
out[body + 1] = PACKET_TIMESTAMP;
|
||||
out[body + 2] = STATUS_SYSEX_END;
|
||||
return framed;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
// Decoded MIDI message types. Status bytes have the high bit set; the low nibble of a channel-voice status byte
|
||||
// (0x80-0xEF) is the channel, system messages (0xF0-0xFF) have none.
|
||||
enum class MessageType : uint8_t {
|
||||
NOTE_OFF, // 0x80
|
||||
NOTE_ON, // 0x90
|
||||
POLY_AFTERTOUCH, // 0xA0
|
||||
CONTROL_CHANGE, // 0xB0
|
||||
PROGRAM_CHANGE, // 0xC0
|
||||
CHANNEL_AFTERTOUCH, // 0xD0
|
||||
PITCH_BEND, // 0xE0
|
||||
SYSEX, // 0xF0 ... 0xF7, possibly spread over several BLE packets
|
||||
SYSEX_INCOMPLETE, // a SysEx cut short by another status byte or longer than the buffer
|
||||
SYSTEM_COMMON, // 0xF1-0xF6, e.g. quarter frame, song position
|
||||
SYSTEM_REAL_TIME, // 0xF8-0xFF, e.g. clock, start, stop
|
||||
INCOMPLETE, // a channel or system common message cut short by another status byte
|
||||
UNKNOWN, // malformed/undecodable byte
|
||||
};
|
||||
|
||||
PROGMEM_STRING_TABLE(MessageTypeNames, "NOTE_OFF", "NOTE_ON", "POLY_AFTERTOUCH", "CONTROL_CHANGE", "PROGRAM_CHANGE",
|
||||
"CHANNEL_AFTERTOUCH", "PITCH_BEND", "SYSEX", "SYSEX_INCOMPLETE", "SYSTEM_COMMON",
|
||||
"SYSTEM_REAL_TIME", "INCOMPLETE", "UNKNOWN");
|
||||
|
||||
/// A decoded MIDI message. `data` points into the parser's storage or the packet being fed, so it is only valid
|
||||
/// for the duration of the handler.
|
||||
struct MidiMessage {
|
||||
// Status byte followed by its data bytes; a SysEx includes the 0xF0/0xF7 wrappers
|
||||
const uint8_t *data{nullptr};
|
||||
uint16_t length{0};
|
||||
MessageType type{MessageType::UNKNOWN};
|
||||
|
||||
uint8_t status() const { return this->data[0]; }
|
||||
uint8_t channel() const { return this->data[0] & 0x0F; }
|
||||
// The data bytes of a complete channel-voice message: note and velocity, controller and value, or program
|
||||
uint8_t data1() const { return this->data[1]; }
|
||||
uint8_t data2() const { return this->data[2]; }
|
||||
// PITCH_BEND: 14-bit value re-centered on zero (-8192..8191).
|
||||
int16_t pitch_bend() const {
|
||||
return static_cast<int16_t>((static_cast<uint16_t>(this->data[2]) << 7) | this->data[1]) - 8192;
|
||||
}
|
||||
};
|
||||
|
||||
/// Called once per decoded message with the context passed to feed().
|
||||
using MidiMessageHandler = void (*)(void *ctx, const MidiMessage &message);
|
||||
|
||||
/// Decoder for the BLE-MIDI packet format (Apple/MMA "Bluetooth Low Energy
|
||||
/// MIDI" specification, section 3).
|
||||
///
|
||||
/// Packet layout:
|
||||
/// [header] [timestamp] [MIDI 0] ... ([timestamp] [MIDI n])*
|
||||
///
|
||||
/// - header : 0b10hhhhhh, bit 7 set, bits 5-0 hold the high bits of the
|
||||
/// timestamp
|
||||
/// - timestamp : 0b1lllllll, bit 7 set, bits 6-0 hold the low bits of the
|
||||
/// timestamp
|
||||
///
|
||||
/// A timestamp byte precedes every status byte, except where running status
|
||||
/// applies. SysEx messages may span several BLE packets and may be interrupted
|
||||
/// by real-time messages (0xF8-0xFF).
|
||||
///
|
||||
/// Timestamps are used for jitter reconstruction by MIDI hosts and are ignored
|
||||
/// here.
|
||||
///
|
||||
/// This class holds no BLE or ESPHome state so that it can be exercised in
|
||||
/// isolation.
|
||||
class MidiParser final {
|
||||
public:
|
||||
/// `sysex_buffer` reassembles SysEx messages; one longer than `sysex_capacity` bytes is reported incomplete.
|
||||
MidiParser(uint8_t *sysex_buffer, uint16_t sysex_capacity)
|
||||
: sysex_buffer_(sysex_buffer), sysex_capacity_(sysex_capacity) {}
|
||||
|
||||
uint16_t sysex_capacity() const { return this->sysex_capacity_; }
|
||||
|
||||
/// Drop all parser state. Call on disconnect so running status and partial
|
||||
/// messages do not leak into the next session.
|
||||
void reset();
|
||||
|
||||
/// Feed one BLE notification payload. Undecodable bytes are reported as MessageType::UNKNOWN so the
|
||||
/// caller can log them.
|
||||
void feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx);
|
||||
|
||||
/// Number of data bytes that follow a status byte other than SysEx.
|
||||
static uint8_t expected_data_bytes(uint8_t status);
|
||||
|
||||
protected:
|
||||
void emit_(MessageType type, const uint8_t *data, uint16_t length, MidiMessageHandler handler, void *ctx) {
|
||||
handler(ctx, MidiMessage{data, length, type});
|
||||
}
|
||||
void emit_current_(MidiMessageHandler handler, void *ctx);
|
||||
void take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx);
|
||||
void end_sysex_(bool complete, MidiMessageHandler handler, void *ctx);
|
||||
void sysex_push_(uint8_t byte);
|
||||
|
||||
// SysEx reassembly across packets.
|
||||
uint8_t *const sysex_buffer_;
|
||||
const uint16_t sysex_capacity_;
|
||||
uint16_t sysex_length_{0};
|
||||
bool sysex_active_{false};
|
||||
bool sysex_overflow_{false};
|
||||
|
||||
// Last channel-voice status byte, sticky across bytes and packets until a new
|
||||
// status byte or a system-common message clears it (MIDI running status).
|
||||
uint8_t running_status_{0};
|
||||
|
||||
// Message being assembled: status byte plus up to two data bytes.
|
||||
uint8_t current_message_[3]{};
|
||||
uint8_t current_length_{0};
|
||||
uint8_t current_expected_{0};
|
||||
};
|
||||
|
||||
// A BLE-MIDI packet starts with a header byte and a timestamp byte, both with bit 7 set. The remaining
|
||||
// timestamp bits serve jitter reconstruction only and are left at zero when transmitting.
|
||||
static constexpr uint8_t PACKET_HEADER = 0x80;
|
||||
static constexpr uint8_t PACKET_TIMESTAMP = 0x80;
|
||||
static constexpr uint8_t STATUS_SYSEX_START = 0xF0;
|
||||
static constexpr uint8_t STATUS_SYSEX_END = 0xF7;
|
||||
|
||||
/// Frame a SysEx payload for one BLE-MIDI packet: 0xF0 and 0xF7 are added when the payload lacks them, with
|
||||
/// the timestamp byte the specification requires before 0xF7. Returns the bytes written to `out`, or 0 when
|
||||
/// the payload is empty, holds a byte with bit 7 set, or does not fit `out_size` once framed.
|
||||
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size);
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
@@ -92,7 +92,7 @@ void BQ25186Component::setup() {
|
||||
// The poll below puts the pin in output mode; without a restore mode the switch takes its level from it
|
||||
auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode();
|
||||
if (initial.has_value())
|
||||
this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin
|
||||
this->pg_gpo_switch_->publish_state(*initial != this->pg_gpo_switch_->is_inverted()); // the first poll writes it
|
||||
}
|
||||
#endif
|
||||
// The poller first runs after a random share of the interval; the entities get a state now
|
||||
@@ -112,7 +112,11 @@ void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool
|
||||
const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0;
|
||||
if (!this->pg_gpo_switch_->has_state()) {
|
||||
this->pg_gpo_switch_->publish_state(level);
|
||||
} else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) {
|
||||
return;
|
||||
}
|
||||
// state is what the switch shows; an inverted switch drives the opposite level
|
||||
const bool wanted = this->pg_gpo_switch_->state != this->pg_gpo_switch_->is_inverted();
|
||||
if (level != wanted && !this->write_pg_gpo_level(wanted)) {
|
||||
this->status_set_warning(LOG_STR("PG/GPO level write failed"));
|
||||
pending = true;
|
||||
}
|
||||
|
||||
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user