mirror of
https://github.com/esphome/esphome.git
synced 2026-10-26 07:52:14 +00:00
Compare commits
282
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ce475ccef | ||
|
|
eb3394e332 | ||
|
|
88eedeec3f | ||
|
|
c9fe8b6b45 | ||
|
|
b7e652399b | ||
|
|
955998021c | ||
|
|
5a6983822d | ||
|
|
11a3372843 | ||
|
|
e388c459b3 | ||
|
|
c83ea861eb | ||
|
|
955afc4847 | ||
|
|
7a01c71022 | ||
|
|
9f254c69a1 | ||
|
|
1b6cc0bf18 | ||
|
|
0801211ba1 | ||
|
|
5a2f059be7 | ||
|
|
14998c0905 | ||
|
|
d69a1e9bf9 | ||
|
|
8eb5d17f3d | ||
|
|
808afc024d | ||
|
|
0bb98b715f | ||
|
|
90af765fae | ||
|
|
f17b18d51a | ||
|
|
5f11c430cc | ||
|
|
baf57195e2 | ||
|
|
27a97f36af | ||
|
|
0a28a5b023 | ||
|
|
5c6e62ffe0 | ||
|
|
0801b29617 | ||
|
|
b00ad7ca8a | ||
|
|
7ad51b4b8f | ||
|
|
65c1b06f7f | ||
|
|
e158205e4c | ||
|
|
f5debdad32 | ||
|
|
2a95fa44dc | ||
|
|
314557f83b | ||
|
|
e4e464a3b9 | ||
|
|
a41dbed69c | ||
|
|
ba10832ed7 | ||
|
|
d6b35f8ccf | ||
|
|
03efcf6bb5 | ||
|
|
0d46b1047e | ||
|
|
456f5b3042 | ||
|
|
83c9603b4b | ||
|
|
ad9a41ed61 | ||
|
|
e6b7b3a028 | ||
|
|
9a4a550fb9 | ||
|
|
42dc4025c9 | ||
|
|
901fd35a7e | ||
|
|
4344ffd682 | ||
|
|
1c671be1bd | ||
|
|
52dc51c7c6 | ||
|
|
671b6216a7 | ||
|
|
5764ca607d | ||
|
|
273c80ecd3 | ||
|
|
1933126ad7 | ||
|
|
64034b5b23 | ||
|
|
830854d661 | ||
|
|
87b7076cd1 | ||
|
|
5ec7047151 | ||
|
|
45d921f77a | ||
|
|
321b00143d | ||
|
|
e6e9016ed4 | ||
|
|
a292aa2654 | ||
|
|
4353f5ac9c | ||
|
|
4925f6648b | ||
|
|
47bd8f5ffc | ||
|
|
31771da92c | ||
|
|
1978f203bc | ||
|
|
ae906ada94 | ||
|
|
6a2465da63 | ||
|
|
9a5aab63d5 | ||
|
|
deeec17d0d | ||
|
|
735f810f08 | ||
|
|
cd48023a56 | ||
|
|
9330204908 | ||
|
|
45501056d8 | ||
|
|
2e6ad94bd1 | ||
|
|
5994105d34 | ||
|
|
0ac9a0f8bd | ||
|
|
77b8b384bd | ||
|
|
3728669fa6 | ||
|
|
578e4774a9 | ||
|
|
8696255ccc | ||
|
|
594931265b | ||
|
|
b0f753a7a2 | ||
|
|
fc69d8543e | ||
|
|
d586a6c8d4 | ||
|
|
9301391792 | ||
|
|
1b801427c4 | ||
|
|
be391feaa9 | ||
|
|
7e369f332c | ||
|
|
a40ecfdf9c | ||
|
|
9a6765eb8f | ||
|
|
1d0a87daa8 | ||
|
|
c98dd74e11 | ||
|
|
aceff27948 | ||
|
|
211337cd6d | ||
|
|
4276815734 | ||
|
|
3c57064bf2 | ||
|
|
f9be82d1a6 | ||
|
|
10a0bf0ff8 | ||
|
|
484674b85d | ||
|
|
49c1547fda | ||
|
|
7b4353b417 | ||
|
|
5f20346d54 | ||
|
|
356c1d9134 | ||
|
|
9534a1df55 | ||
|
|
fb64b18047 | ||
|
|
5572bf77dd | ||
|
|
d8ec76e02b | ||
|
|
cf5faa454c | ||
|
|
55d14c6fbf | ||
|
|
d86bdb36cf | ||
|
|
c64684dd00 | ||
|
|
ed002feee9 | ||
|
|
38f31d85b6 | ||
|
|
d04ef2378e | ||
|
|
5e26633231 | ||
|
|
6c78b480c4 | ||
|
|
3d33a16b71 | ||
|
|
e3aa8773e6 | ||
|
|
be5e00aefd | ||
|
|
9c5ebdffc9 | ||
|
|
9554e1a212 | ||
|
|
28491f83b4 | ||
|
|
2f71ec16d7 | ||
|
|
3daa1d2450 | ||
|
|
a513cef4e2 | ||
|
|
fa17215053 | ||
|
|
073d9a72a0 | ||
|
|
3a24d0b03b | ||
|
|
b229bd4d8a | ||
|
|
c8e240198d | ||
|
|
0512339c58 | ||
|
|
e95bebb7d0 | ||
|
|
084ff9d6e3 | ||
|
|
4a9e5f5da9 | ||
|
|
f6158597ac | ||
|
|
8924640593 | ||
|
|
26c000c417 | ||
|
|
7cb32f9546 | ||
|
|
13de0275c8 | ||
|
|
22af86a65b | ||
|
|
83b099d708 | ||
|
|
6957e2ef15 | ||
|
|
743a10fa3e | ||
|
|
f7b1e9ae65 | ||
|
|
53a5c7b051 | ||
|
|
54c50c07c5 | ||
|
|
64df0c1da1 | ||
|
|
9c31e08e95 | ||
|
|
93124f1f1f | ||
|
|
8abb5f102d | ||
|
|
12fb1b0697 | ||
|
|
81216da8e7 | ||
|
|
ed2cdad9b4 | ||
|
|
be818a5bf2 | ||
|
|
444a217ee7 | ||
|
|
93f9d4a1ad | ||
|
|
ac7d39a8c1 | ||
|
|
612033062f | ||
|
|
f9dbd24a2a | ||
|
|
833a835e0f | ||
|
|
399dc26236 | ||
|
|
0d9aea9594 | ||
|
|
f2261d6632 | ||
|
|
d224a82e38 | ||
|
|
ce436ff8b4 | ||
|
|
cbda1e71be | ||
|
|
ceabfeb6f4 | ||
|
|
5e11a886f1 | ||
|
|
e771b2522a | ||
|
|
ee96293c4c | ||
|
|
fa18f5a0ad | ||
|
|
269d52ac2e | ||
|
|
06a52f682f | ||
|
|
409ff027cc | ||
|
|
3c7e5000a5 | ||
|
|
7af211ab54 | ||
|
|
cc81069d02 | ||
|
|
ba567cecc3 | ||
|
|
ee7fb83334 | ||
|
|
9db31db24d | ||
|
|
ceca66c01a | ||
|
|
dc97c5a161 | ||
|
|
97bbcf55d9 | ||
|
|
d135ac97e5 | ||
|
|
296dd19361 | ||
|
|
138304cfbe | ||
|
|
9fef1862be | ||
|
|
80758f11aa | ||
|
|
9d8362738f | ||
|
|
b2c22b5802 | ||
|
|
f62009ecc9 | ||
|
|
c09e3e0d45 | ||
|
|
cdd7bef74a | ||
|
|
0e1321a813 | ||
|
|
022b612366 | ||
|
|
2dc9fbec5e | ||
|
|
9dafff6a61 | ||
|
|
3c7a1062c8 | ||
|
|
8164cdd018 | ||
|
|
4ea491d46f | ||
|
|
7fdb6cd2d5 | ||
|
|
2d50e07e90 | ||
|
|
78a451a1c0 | ||
|
|
2483621500 | ||
|
|
4f086b6b25 | ||
|
|
c4850a9320 | ||
|
|
91bcb14c09 | ||
|
|
85e4a6cc33 | ||
|
|
149d7cd801 | ||
|
|
ce2e113a6d | ||
|
|
ae5b6d271f | ||
|
|
e1572d7520 | ||
|
|
536afce394 | ||
|
|
152b6a5d50 | ||
|
|
f68ebcf7d1 | ||
|
|
ca5fa6dfaf | ||
|
|
962a82d157 | ||
|
|
c6d4ca7377 | ||
|
|
465a6a3596 | ||
|
|
03c9b001f4 | ||
|
|
d680200e5a | ||
|
|
98dae49440 | ||
|
|
7ec366c1ba | ||
|
|
b1e1b0d7c5 | ||
|
|
ec49eb12dd | ||
|
|
949474efc6 | ||
|
|
708f095b6f | ||
|
|
bf4af703b6 | ||
|
|
541616e19c | ||
|
|
952c3e6584 | ||
|
|
2ea55bee7b | ||
|
|
1931ef32ee | ||
|
|
f2da6c1a56 | ||
|
|
6e9e83bdfd | ||
|
|
fe29a53b71 | ||
|
|
fa2dafcbca | ||
|
|
dc18fced19 | ||
|
|
389d3629c2 | ||
|
|
36a4d87611 | ||
|
|
65db614f22 | ||
|
|
591856e7dc | ||
|
|
b680482092 | ||
|
|
7fe447a4a2 | ||
|
|
8fae4f6767 | ||
|
|
3d458bd9c8 | ||
|
|
fd7510d6e5 | ||
|
|
ffe55f7691 | ||
|
|
40ac344bf2 | ||
|
|
667ffe98d9 | ||
|
|
270f776675 | ||
|
|
965bbd4425 | ||
|
|
e485e5af90 | ||
|
|
c795d0734d | ||
|
|
0e77d47df4 | ||
|
|
47c2f8f31a | ||
|
|
fe53d24e46 | ||
|
|
daf988fde8 | ||
|
|
6604a9cca7 | ||
|
|
ddd45e393b | ||
|
|
0974e8a1f5 | ||
|
|
1478307280 | ||
|
|
fc77cdcb7d | ||
|
|
fcb9da489e | ||
|
|
ab458fffab | ||
|
|
c0c3afe6b1 | ||
|
|
b092f64744 | ||
|
|
6b2938c1f7 | ||
|
|
0e5d902c95 | ||
|
|
11fc8e4c07 | ||
|
|
d9f0f40373 | ||
|
|
e671a602e9 | ||
|
|
7f44b9b826 | ||
|
|
47804f8d08 | ||
|
|
67cc43c8ef | ||
|
|
3e9808162b | ||
|
|
a6f81f3185 | ||
|
|
5296539849 | ||
|
|
de9074fb5e |
No files matched your search
@@ -67,17 +67,9 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
automations in the config itself.
|
||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
||||
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
|
||||
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
|
||||
|
||||
```
|
||||
> [!NOTE]
|
||||
> Automated testing results
|
||||
```
|
||||
|
||||
Then one short sentence saying what was tested (builds and SHAs, board, what was driven), a results table
|
||||
(build SHA, board and framework, what was driven, counts, result, plus the `dev` control row), short bullets
|
||||
for the findings, and the setup and exact flow in a collapsed `<details>` block. Answer any question asked on
|
||||
the PR directly in the comment. No credentials, SSIDs, IP addresses, port names or other bench details.
|
||||
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
|
||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
||||
no IP addresses.
|
||||
|
||||
## Flashing by chip family
|
||||
|
||||
@@ -123,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary).
|
||||
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`
|
||||
|
||||
|
||||
@@ -125,11 +125,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
|
||||
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
|
||||
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
|
||||
- Moving a BK7231N device (CB3S) that sits on its fallback AP to another network: join the host's Wi-Fi to
|
||||
`<name>-fallback`, OTA to 192.168.4.1, then rejoin the host's own network. Moving it with `/wifisave` instead can
|
||||
leave it in the captive portal's BLIND_RETRY state (no scan, `Beacon Timeout` every 46 s) until it reboots, and a
|
||||
log-only serial adapter cannot reset it. Rejoining may change the host's address; update a syslog target that
|
||||
points at the old one.
|
||||
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
||||
radio enable 160 to 278 ms, disable about 200 ms.
|
||||
|
||||
@@ -177,12 +172,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
|
||||
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
|
||||
a never-saved value.
|
||||
- Thread: nRF52 `openthread:` accepts only `tlv:`; the individual keys (network_key, channel, ...) are rejected at
|
||||
validation. To list nearby networks with channel and PAN ID, call
|
||||
`otThreadDiscover(InstanceLock::acquire().get_instance(), 0, 0xFFFF, false, false, cb, nullptr)` from an
|
||||
`interval:` lambda in an included header; results arrive on the OpenThread thread and log fine.
|
||||
- A wrong network key on a real network's channel and PAN ID makes the device form its own partition as leader on
|
||||
that PAN within about 30 s; take it off Thread at once. Never try guessed datasets against a network in use.
|
||||
|
||||
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ One file per component, named after it:
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream
|
||||
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
@@ -38,6 +38,3 @@ One file per component, named after it:
|
||||
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
|
||||
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
|
||||
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
|
||||
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
|
||||
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
|
||||
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
|
||||
@@ -1,17 +0,0 @@
|
||||
# BLE terminate with peripheral latency (#20232)
|
||||
|
||||
`scripts/ble_terminate`: an ESP32-S3 `bluetooth_proxy` (active) over Ethernet as the central (`btproxy.yaml`) and a
|
||||
classic devkit `esp32_ble_server` peer (`btpeer.yaml`). The peer asks for 50 ms / latency 10 / 6 s with
|
||||
`esp_ble_gap_update_conn_params` 4 s after connect; earlier requests are overwritten by the proxy's own "medium
|
||||
conn params" after service discovery. Read the proxy's `Conn params updated` line (VERBOSE, API logs) to confirm;
|
||||
the peer side never logs the event. The proxy only connects to a device its scanner has seen, so `btdrive.py`
|
||||
subscribes to raw advertisements first.
|
||||
|
||||
Measure the peer's own disconnect (`serial_capture.py --ms`) after the client's disconnect: about 0.2 to 0.8 s when
|
||||
the terminate arrived, about 6 s (the supervision timeout) when it was lost. The client side disconnect time is the
|
||||
API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
|
||||
|
||||
`run.sh LABEL ROUNDS` writes `central_bda.hpp` from `PROXY_BT_MAC`, builds and flashes both boards, runs the rounds
|
||||
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
|
||||
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
|
||||
latency and counts as inconclusive).
|
||||
@@ -1,12 +0,0 @@
|
||||
# Fake I2C slave (#21586)
|
||||
|
||||
`scripts/i2c_slave`: a D1 mini running an Arduino `Wire` slave at 0x40 (`i2cslave.yaml` with `i2cslave.hpp`): a fake
|
||||
HDC1080 plus a 256 byte register file, and reads of 0x7E and 0x7F stretch the clock for 0.5 and 5 ms. It needs
|
||||
`libraries: [Wire]` and 160 MHz. With jumper wires only and the internal pull ups it works to about 50 kHz; it cannot
|
||||
keep up with a true 100 kHz hardware master.
|
||||
|
||||
The master (`i2cbk.yaml`, a BK7238 on P26/P24, needs the software I2C bus from that PR) writes a counter, reads it
|
||||
back with a repeated start, probes an absent address and reads both stretch registers every 2 s, logging `RW OK`
|
||||
(the read back matches, the absent address NACKs and both stretched reads succeed) or `RW BAD` with running totals.
|
||||
Control: the same blocks on a classic ESP32 devkit (SDA GPIO21, SCL GPIO22) with hardware I2C at 25 and 50 kHz got
|
||||
every repeated start read right, so failures on a bit banged master at a similar real clock point at the master.
|
||||
@@ -4,10 +4,3 @@
|
||||
|
||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
||||
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
||||
|
||||
## ItsyBitsy over UF2 when serial DFU fails (#20481)
|
||||
|
||||
Serial DFU to the ItsyBitsy nRF52840 fails about half the time ("Target is not in DFU mode"). Reliable path: a
|
||||
1200 baud touch on the app port, wait for the `ITSY840BOOT` volume, convert `zephyr/zephyr.hex` with the SDK's
|
||||
`zephyr/scripts/build/uf2conv.py -f 0xADA52840 -c` and copy the .uf2 to the volume (the copy ends in an I/O error
|
||||
when the board reboots, which is normal). macOS BLE scans give UUIDs, not MAC addresses.
|
||||
@@ -37,44 +37,3 @@ AP start (the fix in that PR), the first AP start failed on every boot with `esp
|
||||
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
|
||||
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
|
||||
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
|
||||
|
||||
## Reboot against a WPA2/WPA3 PMF access point (#21590)
|
||||
|
||||
- AP: `scripts/wpa3_ap/wdap.yaml` on a classic devkit. ESPHome `wifi: ap:` only, then an on_boot lambda switches it
|
||||
to `WIFI_AUTH_WPA2_WPA3_PSK`, PMF capable, and registers a WIFI_EVENT handler that logs `STA JOIN` and
|
||||
`STA LEAVE ... reason N` (the include header pulls in `esp_wifi.h`). It disables Wi-Fi after 420 s.
|
||||
- AP leave reason 3 means the station sent a deauth; reason 2 means the AP found a stale association when the
|
||||
station came back; several reason 2 before one join are rejected reconnects.
|
||||
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the house
|
||||
network at 0, and reboot 30 s after joining the AP and 150 s after joining the house network. So they move to the
|
||||
AP while it is up and fall back to the house network (where they can be flashed over the air) once its window
|
||||
ends. `run.sh BUILD` flashes the AP, does OTA for the CB3S and a serial flash for the BK7238, then captures one AP
|
||||
window; `an.py` prints each station's join and leave sequence and fails on a rejected reconnect, an AP that did
|
||||
not switch to WPA2/WPA3 (`AP CONFIG FAILED`), a DUT missing from the AP events (`DUT_MACS`) or a BK7238 that never
|
||||
joined the AP.
|
||||
- Gotchas: the LibreTiny build directory has two `.uf2` files, so flash `firmware.uf2`. The BK7238 serial flash
|
||||
sometimes fails to link while the chip is in a reboot loop; retry it 3 or 4 times. OTA fails if the DUT reboots
|
||||
mid-upload; retry.
|
||||
- Finding: BDK 3.0.78 rejects about 2 reconnects per reboot when the station left without a deauth; BDK 3.0.33
|
||||
does not.
|
||||
|
||||
## WISP and ALWAYS_ON AP rig (#20077)
|
||||
|
||||
`scripts/wisp_ap`: the upstream is an ESP32-S3 with Ethernet running a WISP bridge (`wisp-upstream.yaml`) with its AP on
|
||||
10.40.0.1/24 and web buttons `ap off` and `ap on`. Its subnet must not be 192.168.4.0/24, the DUT AP's default, or
|
||||
nothing routes. The DUT is a classic devkit (`dut-wisp.yaml` or `dut-always-on.yaml`); the client is a D1 mini on
|
||||
the DUT's AP (`wispcli.yaml`) logging its DNS server, a name lookup, a GET by name, the AP itself and a LAN file by
|
||||
IP. `run.sh DUT START SECONDS T:STATE...` switches the upstream AP at the given times and `sum.py` groups the client
|
||||
results by phase. Capture the D1 mini without `--reset` (a CH340 floods garbage after an RTS reset). Clients keep
|
||||
the captive portal's DNS answer for its TTL.
|
||||
|
||||
## AP restart with WPA3 mixed and LIGHT power save (#21531)
|
||||
|
||||
`scripts/wifi_restart`: the WISP upstream (`wisp-upstream-wpa3.yaml`) switched to WPA2/WPA3 mixed by a script that
|
||||
runs again after every `ap on`, DUTs (`rebk.yaml`, `recb.yaml` on `re-common.yaml`) with a static IP on the bridge's
|
||||
subnet, a fallback AP, `power_save_mode: LIGHT` and syslog to the host through the NAT. `run.sh BUILD` switches the
|
||||
AP off and on three times; `an.py` fails on a reboot, a crash or a DUT that is not back on syslog within 120 s of an
|
||||
AP start. After 19 minutes the DUTs run `wifi.configure` to the house network; that keeps the static IP, so the next
|
||||
OTA needs the host to have an address on the bridge's subnet as well (an interface alias). The CB3S log is on UART1
|
||||
(TX1), so its configs need `logger: hardware_uart: UART1`. Runners must `pkill` only their own ports; a global pkill
|
||||
stops the other runs' captures.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Xiaomi BLE
|
||||
|
||||
## Xiaomi, ATC and PVVX parsing without the sensors (#21483)
|
||||
|
||||
No Xiaomi devices are needed, and one ESP32 cannot hear its own advertising, so the frames are injected into the
|
||||
tracker as scan results: everything after the radio runs as normal.
|
||||
|
||||
- `scripts/xiaomi_ble/gen.py` writes `frames.hpp` and `expected.json`: plain MiBeacon (0x50), encrypted v5 23 byte
|
||||
with MAC and 19 byte without, real AES-CCM (`cryptography` AESCCM, tag 4, AAD 0x11, nonce = MAC reversed + pid +
|
||||
cnt + 3 byte ext counter), ATC (13 byte, big endian) and PVVX (15 byte, little endian) 0x181A frames, a corrupted
|
||||
tag every other cycle and a counter 0 boot frame. It models the old shared duplicate counter (`base`) and the per
|
||||
device one (`pr`) and prints the expected publish counts.
|
||||
- `xbinject.yaml` (ESP-IDF devkit) includes `frames.hpp`; an `interval:` builds a `BLEScanResult` and calls
|
||||
`global_esp32_ble_tracker->gap_scan_event_handler(r)`. `count.py` counts the `Got Xiaomi` blocks (sensor
|
||||
publishes are not logged at DEBUG) and compares them with `expected.json`.
|
||||
- On ESP32 the tracker's own triggers sit in `listeners_`, which runs before `neutral_listeners_` (the
|
||||
ble_device_base devices), so an `on_ble_service_data_advertise` never sees what a Xiaomi listener did to the
|
||||
shared data. Check that directly: `xb_rewrite_probe(id(xa))` builds an `ESPBTDevice` with `parse_scan_rst()`,
|
||||
calls the component's `parse_device()` and logs the service data before and after (`[probe` lines).
|
||||
- `run.sh inject pr|base` builds, flashes, captures and prints PASS when the counts match that build's model and,
|
||||
on the PR build, the probe's service data is the same before and after `parse_device()`.
|
||||
Base dropped a second device's frame with the same counter, the frame after a bad tag and a first frame with
|
||||
counter 0, and rewrote the shared data in place; the PR fixed all of them.
|
||||
|
||||
## Over the air with a second ESP32
|
||||
|
||||
- Advertiser: an ESP32-S3 with Ethernet (OTA over Ethernet), `xbadv.yaml` with `adv_frames.hpp` from
|
||||
`gen.py --ota`. Per frame: stop advertising, `esp_ble_gap_set_rand_addr`, `config_adv_data_raw`, start
|
||||
non-connectable advertising with `own_addr_type` random, 20 ms interval, 250 ms dwell. Random static addresses
|
||||
need the top two bits set, so these use C4:C1:38 instead of A4. `esp32_ble:` alone brings up the stack. The
|
||||
web_server button starts a run: `curl -X POST -d "" http://<advertiser>/button/start/press` (no body gives 411).
|
||||
- Receiver: `xbrecv.yaml` on the devkit, passive scan with window = interval = 100 ms. `run.sh ota pr|base` (with
|
||||
`ADV` set) flashes it, resets it with a capture and presses start 18 s later, so the counter 0 boot frame is the
|
||||
first one it hears.
|
||||
- Each frame arrives about 4 times; the counts matched the injection run exactly apart from one radio loss, so the
|
||||
over the air comparison allows two missing publishes per tag.
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Match each proxy disconnect with the peer's own disconnect line and count the links the peer kept.
|
||||
Usage: an.py DIR LABEL [ROUNDS] reads LABEL-drive.txt, LABEL-peer.txt (serial_capture --ms) and LABEL-proxy.txt
|
||||
(esphome logs, VERBOSE). A round counts only when the last `Conn params updated` the proxy logged since the previous
|
||||
disconnect shows latency=10; otherwise it is inconclusive. Exits 1 when the peer kept a link past 3 s (the terminate
|
||||
was lost), a round was inconclusive, or fewer than ROUNDS rounds completed."""
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, label = Path(sys.argv[1]), sys.argv[2]
|
||||
ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
|
||||
|
||||
def read(name: str) -> list[str]:
|
||||
return (
|
||||
(D / f"{label}-{name}.txt")
|
||||
.read_text(encoding="utf-8", errors="replace")
|
||||
.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def ptime(line: str) -> float | None:
|
||||
"""Seconds since midnight from a leading [HH:MM:SS.mmm] stamp."""
|
||||
m = re.match(r"\[(\d+):(\d+):(\d+)\.(\d+)\]", line)
|
||||
return (
|
||||
int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
|
||||
)
|
||||
|
||||
|
||||
pdisc = [
|
||||
t
|
||||
for line in read("peer")
|
||||
if "PEER DISCONNECT" in line and (t := ptime(line)) is not None
|
||||
]
|
||||
params = [
|
||||
(t, int(m[1]))
|
||||
for line in read("proxy")
|
||||
if "Conn params updated" in line
|
||||
and (m := re.search(r"latency=(\d+)", line))
|
||||
and (t := ptime(line)) is not None
|
||||
]
|
||||
res = []
|
||||
prev = -1.0
|
||||
for line in read("drive"):
|
||||
m = re.search(
|
||||
r"R(\d+) .*DISC_CALL ([\d.]+) DISC_DONE ([\d.]+) \(\+([\d.]+)s\)", line
|
||||
)
|
||||
if not m:
|
||||
if line.startswith("R"):
|
||||
print(" ", line)
|
||||
continue
|
||||
# Both captures stamp local wall clock time; compare as seconds since midnight.
|
||||
t = dt.datetime.fromtimestamp(float(m[2])).astimezone()
|
||||
tc = t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
|
||||
after = [p - tc for p in pdisc if -1 < p - tc < 12]
|
||||
window = [lat for pt, lat in params if prev < pt <= tc]
|
||||
res.append(
|
||||
(
|
||||
int(m[1]),
|
||||
float(m[4]),
|
||||
after[0] if after else None,
|
||||
window[-1] if window else None,
|
||||
)
|
||||
)
|
||||
prev = tc
|
||||
for r, done, pd, lat in res:
|
||||
peer = (
|
||||
f"peer disconnect +{pd:.2f}s" if pd is not None else "peer disconnect not seen"
|
||||
)
|
||||
print(f" R{r:2} central done +{done:.3f}s {peer} latency at disconnect {lat}")
|
||||
late = sum(1 for _, _, pd, lat in res if lat == 10 and (pd is None or pd > 3))
|
||||
inconclusive = sum(1 for *_, lat in res if lat != 10)
|
||||
print(
|
||||
f"{label}: rounds {len(res)}, peer kept the link (>3 s or not seen) {late}, inconclusive {inconclusive}"
|
||||
)
|
||||
failed = []
|
||||
if not res:
|
||||
failed.append("no round completed")
|
||||
if ROUNDS is not None and len(res) < ROUNDS:
|
||||
failed.append(f"only {len(res)} of {ROUNDS} rounds completed")
|
||||
if late:
|
||||
failed.append(f"{late} links kept")
|
||||
if inconclusive:
|
||||
failed.append(
|
||||
f"INCONCLUSIVE: {inconclusive} rounds had no latency=10 update before the disconnect"
|
||||
)
|
||||
print(f"PASS: {label}" if not failed else f"FAIL: {label}: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Connect to the peer through the proxy, read its services, idle, disconnect; print host timestamps per round.
|
||||
Usage: btdrive.py PROXY KEY PEER_MAC ROUNDS IDLE_S"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
|
||||
async def main(host: str, key: str, peer: str, rounds: int, idle: float) -> int:
|
||||
addr = int(peer.replace(":", ""), 16)
|
||||
cli = APIClient(host, 6053, None, noise_psk=key)
|
||||
await cli.connect(login=True)
|
||||
info = await cli.device_info()
|
||||
print(f"proxy {info.name} bt_mac {info.bluetooth_mac_address}", flush=True)
|
||||
seen = 0
|
||||
|
||||
def adv(msg) -> None:
|
||||
nonlocal seen
|
||||
seen += sum(a.address == addr for a in msg.advertisements)
|
||||
|
||||
# The proxy only connects to a device its scanner has seen.
|
||||
cli.subscribe_bluetooth_le_raw_advertisements(adv)
|
||||
await asyncio.sleep(5)
|
||||
print(f"peer advertisements seen in 5 s: {seen}", flush=True)
|
||||
if not seen:
|
||||
print("FAIL: the proxy does not see the peer", flush=True)
|
||||
await cli.disconnect()
|
||||
return 1
|
||||
done = 0
|
||||
for r in range(1, rounds + 1):
|
||||
state: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def cb(
|
||||
connected: bool, mtu: int, err: int, state: asyncio.Queue = state
|
||||
) -> None:
|
||||
state.put_nowait((connected, err, time.time()))
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
cancel = await cli.bluetooth_device_connect(
|
||||
addr, cb, timeout=20, address_type=0, feature_flags=0xFF
|
||||
)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
print(f"R{r} CONNECT ERROR {type(e).__name__}", flush=True)
|
||||
await asyncio.sleep(9)
|
||||
continue
|
||||
try:
|
||||
c, err, t = await asyncio.wait_for(state.get(), 25)
|
||||
except TimeoutError:
|
||||
print(f"R{r} CONNECT TIMEOUT", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
if not c:
|
||||
print(f"R{r} CONNECT FAIL err {err} after {t - t0:.2f}s", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
try:
|
||||
n = len((await cli.bluetooth_gatt_get_services(addr)).services)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
n = f"err {e!r}"
|
||||
await asyncio.sleep(idle)
|
||||
td = time.time()
|
||||
await cli.bluetooth_device_disconnect(addr)
|
||||
try:
|
||||
_, err2, t2 = await asyncio.wait_for(state.get(), 15)
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} DISC_DONE {t2:.3f} (+{t2 - td:.3f}s) err {err2}",
|
||||
flush=True,
|
||||
)
|
||||
done += 1
|
||||
except TimeoutError:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} no disconnect state", flush=True
|
||||
)
|
||||
cancel()
|
||||
await asyncio.sleep(
|
||||
9
|
||||
) # a peer that missed the terminate hits its 6 s supervision timeout in this gap
|
||||
await cli.disconnect()
|
||||
print(f"rounds completed {done} of {rounds}", flush=True)
|
||||
return 0 if done == rounds else 1
|
||||
|
||||
|
||||
sys.exit(
|
||||
asyncio.run(
|
||||
main(
|
||||
sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
#pragma once
|
||||
#include <cstring>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include "esphome/core/log.h"
|
||||
#include "central_bda.hpp" // written by run.sh from PROXY_BT_MAC
|
||||
|
||||
// 50 ms interval, peripheral latency 10, 6 s supervision timeout
|
||||
inline void btpeer_request_latency() {
|
||||
esp_ble_conn_update_params_t p{};
|
||||
memcpy(p.bda, CENTRAL_BDA, 6);
|
||||
p.min_int = 40;
|
||||
p.max_int = 40;
|
||||
p.latency = 10;
|
||||
p.timeout = 600;
|
||||
esp_err_t err = esp_ble_gap_update_conn_params(&p);
|
||||
ESP_LOGI("peer", "requested interval 50 ms latency 10 timeout 6 s: %d", err);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
esphome:
|
||||
name: btpeer
|
||||
includes:
|
||||
- btpeer.hpp
|
||||
- central_bda.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble: VERBOSE
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 1
|
||||
|
||||
esp32_ble_server:
|
||||
manufacturer: test
|
||||
on_connect:
|
||||
- lambda: ESP_LOGI("peer", "PEER CONNECT conn %u", (unsigned) id);
|
||||
- delay: 4s
|
||||
- lambda: btpeer_request_latency();
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("peer", "PEER DISCONNECT conn %u", (unsigned) id);
|
||||
services:
|
||||
- uuid: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
|
||||
advertise: true
|
||||
characteristics:
|
||||
- uuid: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
|
||||
read: true
|
||||
value: "peer"
|
||||
@@ -1,44 +0,0 @@
|
||||
substitutions:
|
||||
name: btproxy
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: VERBOSE
|
||||
logs:
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
esp32_ble_tracker: DEBUG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh LABEL ROUNDS build both boards, then ROUNDS connect/disconnect rounds through the proxy
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (api_key, base64 Noise key) and run it there.
|
||||
# env: TREE (esphome checkout to build the proxy from), PORT (peer devkit serial port), PROXY (proxy address, flashed
|
||||
# over the air; the first flash of a fresh board goes over USB), PROXY_BT_MAC (the proxy's Bluetooth MAC, printed by
|
||||
# btdrive.py as bt_mac), PEER_MAC (the peer's Bluetooth MAC, base MAC + 2 on a classic ESP32), SC (path to
|
||||
# serial_capture.py), PY (checkout python), PEER_TREE (checkout for the peer, defaults to TREE)
|
||||
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=${2:-12}; T=${TREE:?set TREE}; PT=${PEER_TREE:-$T}
|
||||
P=${PORT:?set PORT}; S3=${PROXY:?set PROXY}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
PEER=${PEER_MAC:?set PEER_MAC}; CM=${PROXY_BT_MAC:?set PROXY_BT_MAC}
|
||||
KEY=$(sed -n 's/^api_key: *"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$D/secrets.yaml")
|
||||
[ -n "$KEY" ] || { echo "FAIL: no api_key in secrets.yaml"; exit 1; }
|
||||
cd "$D" || exit 1
|
||||
printf '#pragma once\n#include <cstdint>\nstatic const uint8_t CENTRAL_BDA[6] = {0x%s};\n' "${CM//:/, 0x}" > central_bda.hpp
|
||||
(cd "$PT" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btpeer.yaml" --device "$P" > "$D/$L-peer-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: peer build or upload failed"; exit 1; }
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: proxy build or upload failed"; exit 1; }
|
||||
SECS=$((N * 26 + 40))
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
$PY "$SC" "$P" $SECS "$D/$L-peer.txt" --ms > /dev/null 2>&1 & CAP=$!
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 exec timeout $((SECS - 10)) $PY -m esphome logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy.txt" 2>&1) & LOGS=$!
|
||||
trap 'kill $CAP $LOGS 2>/dev/null' EXIT
|
||||
sleep 6
|
||||
$PY "$D/btdrive.py" "$S3" "$KEY" "$PEER" "$N" 10 > "$D/$L-drive.txt" 2>&1; RC=$?
|
||||
sleep 10
|
||||
kill $CAP $LOGS 2>/dev/null; wait 2>/dev/null; trap - EXIT
|
||||
[ -s "$D/$L-peer.txt" ] || { tail -3 "$D/$L-drive.txt"; echo "FAIL: peer capture got nothing"; exit 1; }
|
||||
$PY "$D/an.py" "$D" "$L" "$N"; ARC=$?
|
||||
[ "$RC" = 0 ] || { tail -1 "$D/$L-drive.txt"; echo "FAIL: driver rc $RC (not every round completed)"; exit 1; }
|
||||
exit $ARC
|
||||
@@ -1,79 +0,0 @@
|
||||
# BK7238 bit banged master; needs the BK72xx software I2C bus from #21586 (dev rejects the pins).
|
||||
# The ESP32 control is the same i2c, sensor and interval blocks on a devkit (sda GPIO21, scl GPIO22, 25 or 50 kHz).
|
||||
substitutions:
|
||||
frequency: 100kHz
|
||||
|
||||
esphome:
|
||||
name: i2cbk
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
i2c:
|
||||
id: bus
|
||||
sda: P26
|
||||
scl: P24
|
||||
scan: true
|
||||
frequency: ${frequency}
|
||||
timeout: 20ms # clock stretch limit; the bus default (1 ms) is shorter than the slave's 5 ms stretch
|
||||
|
||||
sensor:
|
||||
- platform: hdc1080
|
||||
address: 0x40
|
||||
update_interval: 5s
|
||||
temperature:
|
||||
name: fake temperature
|
||||
humidity:
|
||||
name: fake humidity
|
||||
|
||||
globals:
|
||||
- id: n
|
||||
type: uint8_t
|
||||
initial_value: "0"
|
||||
- id: n_ok
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: n_bad
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 2s
|
||||
then:
|
||||
- lambda: |-
|
||||
uint8_t k = ++id(n);
|
||||
uint8_t w[4] = {0x20, k, (uint8_t) ~k, 0xA5};
|
||||
uint32_t t0 = micros();
|
||||
auto e1 = id(bus)->write(0x40, w, 4);
|
||||
uint8_t reg = 0x20, r[3] = {0, 0, 0};
|
||||
auto e2 = id(bus)->write_readv(0x40, ®, 1, r, 3);
|
||||
uint32_t t1 = micros();
|
||||
uint8_t x;
|
||||
auto e3 = id(bus)->read(0x41, &x, 1);
|
||||
uint8_t s1 = 0x7E, s2 = 0x7F, rr[2];
|
||||
uint32_t t2 = micros();
|
||||
auto e4 = id(bus)->write_readv(0x40, &s1, 1, rr, 2);
|
||||
uint32_t t3 = micros();
|
||||
auto e5 = id(bus)->write_readv(0x40, &s2, 1, rr, 2);
|
||||
uint32_t t4 = micros();
|
||||
// the absent address must NACK and both clock stretched reads must succeed
|
||||
bool ok = e1 == 0 && e2 == 0 && r[0] == k && r[1] == (uint8_t) ~k && r[2] == 0xA5 && e3 != 0 && e4 == 0 &&
|
||||
e5 == 0;
|
||||
if (ok) id(n_ok)++; else id(n_bad)++;
|
||||
ESP_LOGI("t", "RW %s e1 %d e2 %d got %02X %02X %02X want %02X %02X A5 | %u us | absent e %d | stretch500 e %d %u us | stretch5ms e %d %u us | ok %u bad %u",
|
||||
ok ? "OK" : "BAD", e1, e2, r[0], r[1], r[2], k, (uint8_t) ~k, (unsigned) (t1 - t0), e3, e4,
|
||||
(unsigned) (t3 - t2), e5, (unsigned) (t4 - t3), (unsigned) id(n_ok), (unsigned) id(n_bad));
|
||||
@@ -1,50 +0,0 @@
|
||||
#pragma once
|
||||
#include <Wire.h>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Fake HDC1080 at 0x40: 0x00 temperature (26.0 C), 0x01 humidity (50 %), other registers are a
|
||||
// 256 byte file; reading 0x7E holds SCL for 500 us, reading 0x7F for 5 ms.
|
||||
static volatile uint8_t SL_REGS[256];
|
||||
static volatile uint8_t sl_ptr = 0;
|
||||
static volatile uint32_t sl_rx = 0, sl_tx = 0, sl_stretch = 0;
|
||||
|
||||
static void sl_on_receive(int n) {
|
||||
if (n < 1)
|
||||
return;
|
||||
sl_ptr = Wire.read();
|
||||
uint8_t p = sl_ptr;
|
||||
while (--n > 0 && Wire.available())
|
||||
SL_REGS[p++] = Wire.read();
|
||||
sl_rx++;
|
||||
}
|
||||
|
||||
static void sl_on_request() {
|
||||
uint8_t b[8];
|
||||
uint8_t p = sl_ptr;
|
||||
sl_tx++;
|
||||
if (p == 0x7E || p == 0x7F) {
|
||||
sl_stretch++;
|
||||
delayMicroseconds(p == 0x7E ? 500 : 5000);
|
||||
}
|
||||
for (int i = 0; i < 8; i++)
|
||||
b[i] = SL_REGS[(uint8_t) (p + i)];
|
||||
if (p == 0x00) {
|
||||
b[0] = 0x66;
|
||||
b[1] = 0x66;
|
||||
} else if (p == 0x01) {
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x00;
|
||||
}
|
||||
Wire.write(b, 8);
|
||||
}
|
||||
|
||||
inline void i2cslave_begin() {
|
||||
Wire.begin(4, 5, 0x40);
|
||||
Wire.onReceive(sl_on_receive);
|
||||
Wire.onRequest(sl_on_request);
|
||||
ESP_LOGI("slave", "I2C slave at 0x40 on SDA GPIO4 SCL GPIO5");
|
||||
}
|
||||
|
||||
inline void i2cslave_report() {
|
||||
ESP_LOGI("slave", "rx %u tx %u stretch %u", (unsigned) sl_rx, (unsigned) sl_tx, (unsigned) sl_stretch);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
esphome:
|
||||
name: i2cslave
|
||||
libraries:
|
||||
- Wire
|
||||
includes:
|
||||
- i2cslave.hpp
|
||||
platformio_options:
|
||||
board_build.f_cpu: 160000000L
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: i2cslave_begin();
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: i2cslave_report();
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Summarise the AP restart run: alive lines over syslog per DUT, reboots, gaps, recovery after each AP start.
|
||||
Usage: an.py DIR BUILD [RECOVER_S] reads BUILD-marks.txt, BUILD-syslog.txt, BUILD-bk.txt, BUILD-cb.txt (optional)
|
||||
and BUILD-up.txt (the bridge's API logs)
|
||||
Exits 1 when a DUT rebooted, crashed, sent nothing, or had no syslog line within RECOVER_S (default 120) of an
|
||||
AP start, when the BK7238 capture is missing or empty, or when the bridge did not switch its AP to WPA2/WPA3 mixed
|
||||
after every AP start."""
|
||||
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, b = Path(sys.argv[1]), sys.argv[2]
|
||||
RECOVER = int(sys.argv[3]) if len(sys.argv) > 3 else 120
|
||||
|
||||
|
||||
def read(suffix: str) -> str | None:
|
||||
f = D / f"{b}-{suffix}.txt"
|
||||
return f.read_text(encoding="utf-8", errors="replace") if f.exists() else None
|
||||
|
||||
|
||||
def secs(t: str) -> int:
|
||||
h, m, s = t.split(":")
|
||||
return int(h) * 3600 + int(m) * 60 + int(float(s))
|
||||
|
||||
|
||||
marks = [
|
||||
(secs(line.split()[0]), line.split()[3])
|
||||
for line in read("marks").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
sl = (read("syslog") or "").splitlines()
|
||||
bad = []
|
||||
for dev in ("rebk", "recb"):
|
||||
lines = [line for line in sl if re.search(rf"\b{dev}\b", line)]
|
||||
ups = [
|
||||
(secs(line.split()[0]), int(m[1]))
|
||||
for line in lines
|
||||
if (m := re.search(r"up (\d+) s", line))
|
||||
]
|
||||
if not ups:
|
||||
print(f"== {dev}: no alive lines over syslog")
|
||||
bad.append(f"{dev} silent")
|
||||
continue
|
||||
reboots = sum(1 for (_, u0), (_, u1) in pairwise(ups) if u1 < u0)
|
||||
gaps = [t1 - t0 for (t0, _), (t1, _) in pairwise(ups) if t1 - t0 > 25]
|
||||
print(
|
||||
f"== {dev}: alive lines {len(ups)}, reboots {reboots}, syslog gaps over 25 s: {gaps}"
|
||||
)
|
||||
if reboots:
|
||||
bad.append(f"{dev} rebooted")
|
||||
for t, st in marks:
|
||||
if st != "on":
|
||||
continue
|
||||
after = [x for x in ups if x[0] > t]
|
||||
first = after[0][0] - t if after else None
|
||||
print(
|
||||
f" ap back at t={t % 86400 // 3600:02d}:{t % 3600 // 60:02d}:{t % 60:02d}, first syslog line {first} s later"
|
||||
)
|
||||
if first is None or first > RECOVER:
|
||||
bad.append(f"{dev} not back {RECOVER} s after an AP start")
|
||||
for dev, suffix in (("rebk", "bk"), ("recb", "cb")):
|
||||
if not (s := read(suffix)):
|
||||
if dev == "rebk":
|
||||
bad.append("BK7238 serial capture missing or empty")
|
||||
continue
|
||||
crashes = len(re.findall(r"(?i)hard fault|exception|abort|watchdog", s))
|
||||
connected = len(re.findall(r"\[wifi:\d+\]: Connected", s))
|
||||
print(
|
||||
f"-- {dev} serial: lines {s.count(chr(10))}, overflow {s.count('queue overflow')}, fallback AP starts "
|
||||
f"{s.count('Starting fallback AP')}, connected {connected}, boots {s.count('ESPHome version')}, crashes {crashes}"
|
||||
)
|
||||
if crashes:
|
||||
bad.append(f"{dev} crashed")
|
||||
up = read("up") or ""
|
||||
starts = sum(1 for _, st in marks if st == "on")
|
||||
switched = up.count("PMF capable: 0")
|
||||
print(
|
||||
f"-- bridge: WPA2/WPA3 switches {switched} for {starts} AP starts, failures {up.count('AP CONFIG FAILED')}"
|
||||
)
|
||||
if "AP CONFIG FAILED" in up or switched < starts:
|
||||
bad.append("the bridge did not switch its AP to WPA2/WPA3 mixed after every start")
|
||||
print("PASS" if not bad else "FAIL: " + ", ".join(bad))
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -1,58 +0,0 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
manual_ip:
|
||||
static_ip: ${ip}
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
dns1: 10.40.0.1
|
||||
power_save_mode: LIGHT
|
||||
ap:
|
||||
ssid: ${name}-fallback
|
||||
password: !secret fallback_ap_password
|
||||
|
||||
captive_portal:
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
time:
|
||||
- platform: sntp
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: ESP_LOGI("alive", "up %u s", (unsigned) (millis() / 1000));
|
||||
- interval: 30s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: |-
|
||||
static bool done = false;
|
||||
if (done || millis() < 19 * 60 * 1000) return false;
|
||||
done = true;
|
||||
return true;
|
||||
then:
|
||||
- lambda: ESP_LOGI("alive", "GOING HOME");
|
||||
- wifi.configure:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
save: false
|
||||
@@ -1,13 +0,0 @@
|
||||
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
|
||||
substitutions:
|
||||
name: rebk
|
||||
ip: 10.40.0.21
|
||||
|
||||
packages:
|
||||
common: !include re-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -1,13 +0,0 @@
|
||||
# CB3S (BK7231N) DUT, flashed over the air by run.sh; its log port is UART1 (TX1)
|
||||
substitutions:
|
||||
name: recb
|
||||
ip: 10.40.0.22
|
||||
|
||||
packages:
|
||||
common: !include re-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: cb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh BUILD OTA the CB3S, serial flash the BK7238, then switch the upstream AP off and on three times
|
||||
# while both DUTs send alive lines over syslog. BUILD is a label (pr, base) for the output files.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wisp_up_ssid,
|
||||
# wisp_up_password, fallback_ap_password, syslog_ip, api_key) and run it there. wisp-upstream-wpa3.yaml runs on the
|
||||
# ethernet ESP32-S3 upstream; syslog_ip is this host's address as the DUTs reach it through the bridge's NAT.
|
||||
# env: TREE (esphome checkout for the DUTs), BK_PORT (BK7238 serial), CB_PORT (CB3S log only serial, optional),
|
||||
# CB_HOST (CB3S address to OTA), UPSTREAM (bridge address), SC (path to serial_capture.py), FLASH_BK (path to
|
||||
# flash_bk72xx.py), LT (LibreTiny penv python), PY (checkout python), UP_TREE (checkout for the bridge's API logs)
|
||||
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
BK=${BK_PORT:?set BK_PORT}; CBH=${CB_HOST:?set CB_HOST}; UP=${UPSTREAM:?set UPSTREAM}; SC=${SC:?set SC}
|
||||
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}; CB=${CB_PORT:-}
|
||||
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
|
||||
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
|
||||
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
|
||||
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
|
||||
# only this run's ports: a global pkill stops other runs' captures
|
||||
pkill -f "[s]erial_capture.py $BK"; [ -n "$CB" ] && pkill -f "[s]erial_capture.py $CB"
|
||||
[ "$(ap on)" = 200 ] || { echo "FAIL: upstream bridge did not take ap on"; exit 1; }
|
||||
cb_upload() { build run --no-logs "$D/recb.yaml" --device "$CBH" > "$D/cb-$b-upload.log" 2>&1; }
|
||||
retry 3 15 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
|
||||
build compile "$D/rebk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
|
||||
UF2=$D/.esphome/build/rebk/.pioenvs/rebk/firmware.uf2
|
||||
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
|
||||
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
|
||||
PIDS=()
|
||||
$PY - > "$D/$b-syslog.txt" 2>&1 << 'PYEOF' &
|
||||
import socket
|
||||
import time
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("0.0.0.0", 5514))
|
||||
s.settimeout(1)
|
||||
end = time.time() + 1250
|
||||
while time.time() < end:
|
||||
try:
|
||||
d, a = s.recvfrom(2048)
|
||||
print(time.strftime("%H:%M:%S"), a[0], d.decode(errors="replace").strip(), flush=True)
|
||||
except TimeoutError:
|
||||
pass
|
||||
PYEOF
|
||||
PIDS+=($!)
|
||||
$PY "$SC" "$BK" 1240 "$D/$b-bk.txt" --released > /dev/null 2>&1 & PIDS+=($!)
|
||||
[ -n "$CB" ] && { $PY "$SC" "$CB" 1240 "$D/$b-cb.txt" --released > /dev/null 2>&1 & PIDS+=($!); }
|
||||
(cd "${UP_TREE:-$T}" && PYTHONUNBUFFERED=1 exec timeout 1240 $PY -m esphome logs "$D/wisp-upstream-wpa3.yaml" --device "$UP" > "$D/$b-up.txt" 2>&1) & PIDS+=($!)
|
||||
trap 'kill "${PIDS[@]}" 2>/dev/null' EXIT
|
||||
t0=$(date +%s); : > "$D/$b-marks.txt"
|
||||
for step in 180:off 330:on 480:off 630:on 780:off 930:on; do
|
||||
t=${step%%:*}; st=${step#*:}
|
||||
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
|
||||
code=$(ap "$st")
|
||||
echo "$(date +%H:%M:%S) t=$t ap $st $code" | tee -a "$D/$b-marks.txt"
|
||||
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
|
||||
done
|
||||
wait "${PIDS[@]}"; trap - EXIT
|
||||
$PY "$D/an.py" "$D" "$b"
|
||||
@@ -1,3 +0,0 @@
|
||||
#pragma once
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_event.h>
|
||||
@@ -1,89 +0,0 @@
|
||||
esphome:
|
||||
name: wisp-upstream
|
||||
includes:
|
||||
- wdap_inc.h
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- script.execute: wpa3_ap
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap_mode: WISP
|
||||
manual_ip:
|
||||
static_ip: 10.40.0.1
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: ap off
|
||||
on_press:
|
||||
- wifi.disable:
|
||||
- platform: template
|
||||
name: ap on
|
||||
on_press:
|
||||
- wifi.enable:
|
||||
- delay: 3s
|
||||
- script.execute: wpa3_ap
|
||||
|
||||
script:
|
||||
- id: wpa3_ap
|
||||
then:
|
||||
- lambda: |-
|
||||
static bool registered = false;
|
||||
wifi_config_t c{};
|
||||
esp_wifi_get_config(WIFI_IF_AP, &c);
|
||||
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
|
||||
c.ap.pmf_cfg.required = false;
|
||||
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &c);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE("wdap", "AP CONFIG FAILED: %d", err);
|
||||
} else {
|
||||
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
|
||||
}
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
auto *e = (wifi_event_ap_staconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5]);
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
auto *e = (wifi_event_ap_stadisconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X reason %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->reason);
|
||||
}
|
||||
}, nullptr);
|
||||
@@ -1,16 +0,0 @@
|
||||
substitutions:
|
||||
name: wispdut-aoi
|
||||
framework: esp-idf
|
||||
packages:
|
||||
common: !include dut-common.yaml
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
post_connect_roaming: false
|
||||
ap:
|
||||
ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
ap_mode: ALWAYS_ON
|
||||
web_server:
|
||||
port: 80
|
||||
@@ -1,16 +0,0 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: ${framework}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -1,14 +0,0 @@
|
||||
substitutions:
|
||||
name: wispdut-wisp
|
||||
framework: esp-idf
|
||||
packages:
|
||||
common: !include dut-common.yaml
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap:
|
||||
ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
ap_mode: WISP
|
||||
captive_portal:
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh DUT START SECONDS T:STATE... e.g. run.sh dut-wisp on 600 120:off 300:on
|
||||
# Builds and flashes DUT.yaml (dut-wisp or dut-always-on) over serial, sets the upstream AP to START, captures the
|
||||
# DUT and the client, and switches the upstream AP at each T seconds; sum.py summarises the client per phase.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wisp_up_ssid, wisp_up_password, dut_ap_ssid,
|
||||
# dut_ap_password, lan_probe_url, api_key) and run it there. wisp-upstream.yaml runs on an ethernet ESP32-S3 and
|
||||
# wispcli.yaml on a D1 mini; flash both once (CLI_FLASH=1 builds and flashes the client too).
|
||||
# env: TREE (esphome checkout for the DUT), PORT (DUT serial), CLI_PORT (client serial), UPSTREAM (upstream bridge
|
||||
# address), SC (path to serial_capture.py), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); n=${1:?dut yaml name}; start=${2:?on or off}; dur=${3:?seconds}; shift 3
|
||||
T=${TREE:?set TREE}; P=${PORT:?set PORT}; CP=${CLI_PORT:?set CLI_PORT}; UP=${UPSTREAM:?set UPSTREAM}
|
||||
SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
|
||||
pkill -f "[s]erial_capture.py ($P|$CP)"
|
||||
[ "$(ap "$start")" = 200 ] || { echo "FAIL: upstream bridge did not take ap $start"; exit 1; }
|
||||
if [ "${CLI_FLASH:-0}" = 1 ]; then
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/wispcli.yaml" --device "$CP" > "$D/wispcli-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: client build or upload"; exit 1; }
|
||||
fi
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$n.yaml" --device "$P" > "$D/$n-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: DUT build or upload"; exit 1; }
|
||||
: > "$D/$n-marks.txt"
|
||||
$PY "$SC" "$P" "$dur" "$D/$n-dut.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
# no --reset on the client: a CH340 D1 mini floods garbage after an RTS reset
|
||||
$PY "$SC" "$CP" "$dur" "$D/$n-cli.txt" > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null' EXIT
|
||||
t0=$(date +%s)
|
||||
for step in "$@"; do
|
||||
t=${step%%:*}; st=${step#*:}
|
||||
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
|
||||
code=$(ap "$st")
|
||||
echo "$(date +%H:%M:%S) t=$t upstream $st $code" | tee -a "$D/$n-marks.txt"
|
||||
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
|
||||
done
|
||||
wait $C1 $C2; trap - EXIT
|
||||
$PY "$D/sum.py" "$D" "$n"
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Summarise the client's results per upstream phase and the DUT's connects, AP starts, crashes and boots.
|
||||
Usage: sum.py DIR NAME reads NAME-marks.txt, NAME-cli.txt and NAME-dut.txt
|
||||
Phase 0 is before the first mark. Exits 1 when the DUT crashed, the DUT capture does not hold exactly one boot
|
||||
(empty or rebooted), or the client logged nothing."""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, n = Path(sys.argv[1]), sys.argv[2]
|
||||
|
||||
|
||||
def read(suffix: str) -> str:
|
||||
return (D / f"{n}-{suffix}.txt").read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
marks = [line.split()[0] for line in read("marks").splitlines() if line.strip()]
|
||||
cli = [line for line in read("cli").splitlines() if "[cli" in line]
|
||||
|
||||
|
||||
def phase(t: str) -> int:
|
||||
return sum(1 for m in marks if t >= m)
|
||||
|
||||
|
||||
stat: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
for line in cli:
|
||||
m = re.search(r"\]: (NOT CONNECTED|NET .*|NAME \S+|GW \S+|MAC \S+)", line)
|
||||
if not m:
|
||||
continue
|
||||
s = m.group(1)
|
||||
if s.startswith("NET"):
|
||||
dns = re.search(r"dns (\S+)", s).group(1)
|
||||
s = f"dns={dns} resolve={s.split('-> ')[1].strip()}"
|
||||
stat[phase(line[1:9])][s] += 1
|
||||
for p in sorted(stat):
|
||||
print(f"phase {p}:", dict(stat[p]))
|
||||
dut = read("dut")
|
||||
crash = len(re.findall(r"Guru|panic|abort\(", dut))
|
||||
boots = dut.count("ESPHome version")
|
||||
print(
|
||||
"DUT connected:",
|
||||
len(re.findall(r"\[wifi:\d+\]: Connected", dut)),
|
||||
"AP starts:",
|
||||
dut.count("Starting AP"),
|
||||
"crash:",
|
||||
crash,
|
||||
"boots:",
|
||||
boots,
|
||||
)
|
||||
# run.sh resets the DUT when the capture starts, so a good capture holds exactly one boot
|
||||
ok = cli and not crash and boots == 1
|
||||
print(
|
||||
f"{'PASS' if ok else 'FAIL'}: {'no crash or reboot' if ok else 'crash, not exactly one DUT boot, or an empty client log'}"
|
||||
)
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -1,52 +0,0 @@
|
||||
esphome:
|
||||
name: wisp-upstream
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap_mode: WISP
|
||||
manual_ip:
|
||||
static_ip: 10.40.0.1
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: ap off
|
||||
on_press:
|
||||
- wifi.disable:
|
||||
- platform: template
|
||||
name: ap on
|
||||
on_press:
|
||||
- wifi.enable:
|
||||
@@ -1,77 +0,0 @@
|
||||
esphome:
|
||||
name: wispcli
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
http_request:
|
||||
timeout: 5s
|
||||
verify_ssl: false
|
||||
|
||||
globals:
|
||||
- id: tick
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 3s
|
||||
then:
|
||||
- lambda: |-
|
||||
id(tick)++;
|
||||
if (id(tick) % 4 != 1) return;
|
||||
if (!WiFi.isConnected()) { ESP_LOGI("cli", "NOT CONNECTED"); return; }
|
||||
IPAddress r;
|
||||
bool ok = WiFi.hostByName("neverssl.com", r, 3000);
|
||||
ESP_LOGI("cli", "NET ip %s gw %s dns %s | neverssl.com -> %s", WiFi.localIP().toString().c_str(),
|
||||
WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP(0).toString().c_str(),
|
||||
ok ? r.toString().c_str() : "FAIL");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 2 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: http://neverssl.com/
|
||||
capture_response: true
|
||||
max_response_buffer_size: 160
|
||||
collect_headers:
|
||||
- location
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "NAME %d loc=%s body=%.60s", response->status_code, response->get_response_header("location").c_str(), body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "NAME error");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 3 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !lambda 'return std::string("http://") + WiFi.gatewayIP().toString().c_str() + "/";'
|
||||
capture_response: true
|
||||
max_response_buffer_size: 160
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "GW %d body=%.60s", response->status_code, body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "GW error");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 0 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !secret lan_probe_url
|
||||
capture_response: true
|
||||
max_response_buffer_size: 64
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "MAC %d body=%.20s", response->status_code, body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "MAC error");
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Summarise the PMF AP's join and leave events per station and the BK7238's joins.
|
||||
Usage: an.py DIR BUILD [AP_SSID] reads DIR/ap-BUILD.txt and DIR/bk-BUILD.txt (AP_SSID defaults to wd-pmf-ap)
|
||||
J = join, L<n> = leave with reason n. Reason 3: the station sent a deauth. Reason 2: the AP found a stale
|
||||
association when the station came back; a run of reason 2 before one join is reconnects the AP rejected.
|
||||
env DUT_MACS: comma separated station MACs that must appear in the AP events (default: at least two stations).
|
||||
Exits 1 on a rejected reconnect, an AP that did not switch to WPA2/WPA3, a missing DUT, or a BK7238 that never
|
||||
joined the AP."""
|
||||
|
||||
from itertools import pairwise
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, b = Path(sys.argv[1]), sys.argv[2]
|
||||
AP_SSID = sys.argv[3] if len(sys.argv) > 3 else "wd-pmf-ap"
|
||||
failed = []
|
||||
ap = (D / f"ap-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if not any("PMF capable: 0" in line for line in ap) or any(
|
||||
"AP CONFIG FAILED" in line for line in ap
|
||||
):
|
||||
failed.append("the AP did not switch to WPA2/WPA3 mixed")
|
||||
ev = [line for line in ap if "STA JOIN" in line or "STA LEAVE" in line]
|
||||
mac_re = re.compile(r"([0-9A-F]{2}(?::[0-9A-F]{2}){5})")
|
||||
macs = sorted({m.group(1) for line in ev if (m := mac_re.search(line))})
|
||||
rejected = 0
|
||||
print(f"== {b}")
|
||||
for m in macs:
|
||||
seq = [
|
||||
"J" if "JOIN" in line else "L" + re.search(r"reason (\d+)", line).group(1)
|
||||
for line in ev
|
||||
if m in line
|
||||
]
|
||||
# every L2 after the first in a run without a join in between is a rejected reconnect
|
||||
rej = sum(1 for prev, cur in pairwise(seq) if prev == cur == "L2")
|
||||
rejected += rej
|
||||
print(" ", m, " ".join(seq), f"(rejected {rej})")
|
||||
if rejected:
|
||||
failed.append(f"{rejected} rejected reconnects")
|
||||
if want := [
|
||||
x.strip().upper() for x in os.environ.get("DUT_MACS", "").split(",") if x.strip()
|
||||
]:
|
||||
if missing := [x for x in want if x not in macs]:
|
||||
failed.append(f"no AP events for {', '.join(missing)}")
|
||||
elif len(macs) < 2:
|
||||
failed.append(f"AP events for {len(macs)} stations, expected both DUTs")
|
||||
bk = (D / f"bk-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
joins = [
|
||||
m.groups() for line in bk if (m := re.search(r"JOINED (.+?) after (\d+) ms", line))
|
||||
]
|
||||
print(" bk7238 joins:", " ".join(f"{s}@{int(t) / 1000:.1f}s" for s, t in joins))
|
||||
print(" bk7238 reboots:", sum("REBOOT on" in line for line in bk))
|
||||
if not any(s == AP_SSID for s, _ in joins):
|
||||
failed.append(f"the BK7238 never joined {AP_SSID}")
|
||||
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh BUILD flash the PMF AP, OTA the CB3S, serial flash the BK7238, then capture one AP window
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wd_ap_password) and
|
||||
# run it there. BUILD is a label (pr, base) for the output files.
|
||||
# env: TREE (esphome checkout for the DUTs), AP_PORT (classic devkit running wdap.yaml), BK_PORT (BK7238 serial),
|
||||
# CB_HOST (CB3S address on the house network), SC (path to serial_capture.py), FLASH_BK (path to flash_bk72xx.py),
|
||||
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), PY (checkout python), DUT_MACS
|
||||
# (optional, comma separated station MACs of both DUTs for an.py)
|
||||
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
AP=${AP_PORT:?set AP_PORT}; BK=${BK_PORT:?set BK_PORT}; CB=${CB_HOST:?set CB_HOST}; SC=${SC:?set SC}
|
||||
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
|
||||
pkill -f "[s]erial_capture.py ($AP|$BK)"
|
||||
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
|
||||
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
|
||||
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
|
||||
build run --no-logs "$D/wdap.yaml" --device "$AP" > "$D/ap-$b-upload.log" 2>&1 || { echo "FAIL: AP build or upload"; exit 1; }
|
||||
# The DUT reboots 150 s after joining the house network; retry when it reboots mid-upload.
|
||||
cb_upload() { build run --no-logs "$D/wdcb.yaml" --device "$CB" > "$D/cb-$b-upload.log" 2>&1; }
|
||||
retry 3 20 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
|
||||
build compile "$D/wdbk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
|
||||
# The build directory has two .uf2 files; firmware.uf2 is the one to flash.
|
||||
UF2=$D/.esphome/build/wdbk/.pioenvs/wdbk/firmware.uf2
|
||||
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
|
||||
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
|
||||
sleep 20
|
||||
# The reset restarts the AP's 420 s window.
|
||||
$PY "$SC" "$AP" 450 "$D/ap-$b.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
sleep 8
|
||||
$PY "$SC" "$BK" 440 "$D/bk-$b.txt" --released > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null' EXIT
|
||||
wait $C1 $C2; trap - EXIT
|
||||
$PY "$D/an.py" "$D" "$b"
|
||||
@@ -1,44 +0,0 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: connect_ms
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: ${ap_ssid}
|
||||
password: !secret wd_ap_password
|
||||
priority: 10
|
||||
- ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
priority: 0
|
||||
on_connect:
|
||||
- lambda: |-
|
||||
id(connect_ms) = millis();
|
||||
char buf[wifi::SSID_BUFFER_SIZE];
|
||||
ESP_LOGI("wd", "JOINED %s after %u ms since boot", wifi::global_wifi_component->wifi_ssid_to(buf), (unsigned) millis());
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: |-
|
||||
if (!wifi::global_wifi_component->is_connected() || id(connect_ms) == 0)
|
||||
return;
|
||||
char buf[wifi::SSID_BUFFER_SIZE];
|
||||
bool on_ap = strcmp(wifi::global_wifi_component->wifi_ssid_to(buf), "${ap_ssid}") == 0;
|
||||
uint32_t up = millis() - id(connect_ms);
|
||||
if (up > (on_ap ? 30000u : 150000u)) {
|
||||
ESP_LOGI("wd", "REBOOT on %s", on_ap ? "ap" : "home");
|
||||
App.safe_reboot();
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
substitutions:
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
esphome:
|
||||
name: wdap
|
||||
includes:
|
||||
- wdap_inc.h
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- lambda: |-
|
||||
wifi_config_t c{};
|
||||
esp_wifi_get_config(WIFI_IF_AP, &c);
|
||||
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
|
||||
c.ap.pmf_cfg.required = false;
|
||||
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
c.ap.max_connection = 4;
|
||||
esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &c);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE("wdap", "AP CONFIG FAILED: %d", err);
|
||||
} else {
|
||||
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
|
||||
}
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
auto *e = (wifi_event_ap_staconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X aid %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, (unsigned) millis());
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
auto *e = (wifi_event_ap_stadisconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X aid %u reason %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, e->reason, (unsigned) millis());
|
||||
}
|
||||
}, nullptr);
|
||||
- delay: 420s
|
||||
- lambda: ESP_LOGI("wdap", "AP WINDOW OVER");
|
||||
- wifi.disable:
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: ${ap_ssid}
|
||||
password: !secret wd_ap_password
|
||||
ap_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -1,3 +0,0 @@
|
||||
#pragma once
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_event.h>
|
||||
@@ -1,13 +0,0 @@
|
||||
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
|
||||
substitutions:
|
||||
name: wdbk
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
packages:
|
||||
common: !include wd-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -1,13 +0,0 @@
|
||||
# CB3S (BK7231N) DUT, flashed over the air by run.sh
|
||||
substitutions:
|
||||
name: wdcb
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
packages:
|
||||
common: !include wd-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: cb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Count the Xiaomi, ATC and PVVX publishes in a capture and compare them with expected.json from gen.py.
|
||||
Usage: count.py CAPTURE [pr|base] [--allow-loss N] [--probe]
|
||||
--probe (injection run): the PR build must leave the probe frame's service data unchanged.
|
||||
Exits 0 when every count matches the model (or falls short by at most N over the air), 1 otherwise."""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("capture", type=Path)
|
||||
ap.add_argument("build", nargs="?", choices=["pr", "base"])
|
||||
ap.add_argument("--allow-loss", type=int, default=0)
|
||||
ap.add_argument("--probe", action="store_true")
|
||||
a = ap.parse_args()
|
||||
cap, build, loss = a.capture, a.build, a.allow_loss
|
||||
LETTER = {"0A": "A", "0B": "B", "0C": "C", "0D": "D", "0E": "E", "0F": "F", "10": "G"}
|
||||
GOT = re.compile(
|
||||
r"Got (?:Xiaomi \S+|ATC MiThermometer|PVVX MiThermometer) \([AC]4:C1:38:00:00:(\w\w)\)"
|
||||
)
|
||||
|
||||
counts = collections.Counter()
|
||||
cur = None
|
||||
humidity = set()
|
||||
probe = {}
|
||||
for line in cap.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if m := GOT.search(line):
|
||||
cur = LETTER[m.group(1)]
|
||||
continue
|
||||
if m := re.search(r"\[probe.*\b(before|after)\s+([0-9A-F]+)", line):
|
||||
probe[m.group(1)] = m.group(2)
|
||||
if "Humidity:" in line:
|
||||
humidity.add(line.split("Humidity:")[1].strip())
|
||||
if (m := re.search(r"Temperature: (-?[\d.]+)", line)) and cur:
|
||||
t = float(m.group(1))
|
||||
tag = cur
|
||||
if (
|
||||
cur == "A" and t >= 50
|
||||
): # after-bad frames are 60.1 to 64.0, normal ones 22.1 to 26.0
|
||||
tag = "A_after_bad"
|
||||
if cur == "A" and abs(t - 33.3) < 0.01:
|
||||
tag = "A_probe" # xb_rewrite_probe's own parse, not part of the model
|
||||
if cur == "D" and abs(t - 11.1) < 0.01:
|
||||
tag = "D_boot"
|
||||
counts[tag] += 1
|
||||
cur = None
|
||||
print(cap.name, dict(sorted(counts.items())))
|
||||
print(" humidity values", len(humidity))
|
||||
if not counts:
|
||||
print("FAIL: no Xiaomi publishes in the capture")
|
||||
sys.exit(1)
|
||||
if build is None:
|
||||
sys.exit(0)
|
||||
expected = json.loads(Path("expected.json").read_text(encoding="utf-8"))[build]
|
||||
bad = 0
|
||||
for tag in sorted((set(expected) | set(counts)) - {"A_probe"}):
|
||||
want, got = expected.get(tag, 0), counts.get(tag, 0)
|
||||
ok = want - loss <= got <= want
|
||||
bad += not ok
|
||||
print(f" {tag:12} want {want:3} got {got:3} {'ok' if ok else 'DIFF'}")
|
||||
if a.probe:
|
||||
before, after = probe.get("before"), probe.get("after")
|
||||
print(f" probe before {before}\n probe after {after}")
|
||||
if before is None or after is None:
|
||||
print(" probe lines missing")
|
||||
bad += 1
|
||||
elif build == "pr" and before != after:
|
||||
print(" the listener rewrote the shared service data")
|
||||
bad += 1
|
||||
print(
|
||||
f"{'PASS' if not bad else 'FAIL'}: {build} model {'matches' if not bad else f'has {bad} failed checks'}"
|
||||
)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -1,246 +0,0 @@
|
||||
"""Generate MiBeacon, ATC and PVVX advertisements, a C++ header that sends them and the expected publish counts.
|
||||
Usage: gen.py [--ota]
|
||||
Without --ota: frames.hpp for xbinject.yaml (scan results injected into the tracker, A4:C1:38 addresses).
|
||||
With --ota: adv_frames.hpp for xbadv.yaml (a second ESP32 advertises them, C4:C1:38 random static addresses).
|
||||
Both write expected.json ({"base": {...}, "pr": {...}}) for count.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
|
||||
|
||||
OTA = "--ota" in sys.argv[1:]
|
||||
CYCLES = 40
|
||||
K1 = bytes.fromhex("6c0d1a3e5b7f29c4a18e90d2f3b46a57")
|
||||
K2 = bytes.fromhex("0f1e2d3c4b5a69788796a5b4c3d2e1f0")
|
||||
# Random static addresses need the top two bits set, so the over the air run uses C4 instead of A4.
|
||||
PREFIX = "C4C138" if OTA else "A4C138"
|
||||
MACS = {
|
||||
k: f"{PREFIX}0000{i + 10:02X}" for i, k in enumerate("ABCDEFG")
|
||||
} # ...0A to ...10
|
||||
MAC = {k: bytes.fromhex(v) for k, v in MACS.items()}
|
||||
MIDX = {k: i for i, k in enumerate(MACS)}
|
||||
|
||||
|
||||
def mibeacon_plain(dev, pid, cnt, obj):
|
||||
return (
|
||||
bytes([0x50, 0x20])
|
||||
+ struct.pack("<H", pid)
|
||||
+ bytes([cnt])
|
||||
+ MAC[dev][::-1]
|
||||
+ obj
|
||||
)
|
||||
|
||||
|
||||
def mibeacon_enc(dev, pid, cnt, obj, key, short, corrupt=False):
|
||||
ext = bytes([cnt, 0x00, 0x00])
|
||||
if short:
|
||||
hdr = bytes([0x48, 0x59]) + struct.pack("<H", pid) + bytes([cnt])
|
||||
else:
|
||||
hdr = (
|
||||
bytes([0x58, 0x58]) + struct.pack("<H", pid) + bytes([cnt]) + MAC[dev][::-1]
|
||||
)
|
||||
nonce = MAC[dev][::-1] + hdr[2:5] + ext
|
||||
ct = AESCCM(key, tag_length=4).encrypt(nonce, obj, b"\x11")
|
||||
body, tag = ct[:-4], ct[-4:]
|
||||
if corrupt:
|
||||
tag = bytes([tag[0] ^ 0xFF]) + tag[1:]
|
||||
out = hdr + body + ext + tag
|
||||
assert len(out) == (19 if short else 18 + len(obj)), len(out)
|
||||
return out
|
||||
|
||||
|
||||
def t16(v): # 0x1004 temperature object
|
||||
return bytes([0x04, 0x10, 0x02]) + struct.pack("<h", round(v * 10))
|
||||
|
||||
|
||||
def th(t, h): # 0x100D temperature + humidity
|
||||
return bytes([0x0D, 0x10, 0x04]) + struct.pack("<hh", round(t * 10), round(h * 10))
|
||||
|
||||
|
||||
frames = [] # (dev, kind 0 = FE95 / 1 = 181A, data, tag for the expectation)
|
||||
frames.append(("D", 0, mibeacon_plain("D", 0x0347, 0, t16(11.1)), "D_boot"))
|
||||
steps = [len(frames)]
|
||||
for k in range(1, CYCLES + 1):
|
||||
frames.append(("B", 0, mibeacon_plain("B", 0x01AA, k, th(20 + k / 10, 40)), "B"))
|
||||
frames.append(("C", 0, mibeacon_plain("C", 0x0098, k, t16(21 + k / 10)), "C"))
|
||||
frames.append(
|
||||
("A", 0, mibeacon_enc("A", 0x055B, k + 100, t16(22 + k / 10), K1, False), "A")
|
||||
)
|
||||
if k % 2 == 0:
|
||||
bad = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False, True)
|
||||
frames.append(("A", 0, bad, "A_bad"))
|
||||
good = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False)
|
||||
frames.append(("A", 0, good, "A_after_bad"))
|
||||
frames.append(
|
||||
("E", 0, mibeacon_enc("E", 0x1203, k + 50, th(23 + k / 10, 50), K2, True), "E")
|
||||
)
|
||||
frames.append(("D", 0, mibeacon_plain("D", 0x0347, k + 200, t16(24 + k / 10)), "D"))
|
||||
atc = (
|
||||
MAC["F"]
|
||||
+ struct.pack(">h", round((26 + k / 10) * 10))
|
||||
+ bytes([45, 90])
|
||||
+ struct.pack(">H", 2950)
|
||||
)
|
||||
frames.append(("F", 1, atc + bytes([k]), "F"))
|
||||
pv = (
|
||||
MAC["G"][::-1]
|
||||
+ struct.pack("<hHH", round((27 + k / 10) * 100), 4600, 3010)
|
||||
+ bytes([88, k, 0])
|
||||
)
|
||||
frames.append(("G", 1, pv, "G"))
|
||||
steps.append(len(frames))
|
||||
|
||||
|
||||
def simulate(pr):
|
||||
"""Base keeps one counter shared by every device and checks it before the tag; the PR keeps one per device."""
|
||||
last_shared = 0
|
||||
last = {}
|
||||
pub = {}
|
||||
for dev, kind, data, tag in frames:
|
||||
if kind == 1:
|
||||
cnt = data[12 if dev == "F" else 13]
|
||||
if last.get(("x", dev)) == cnt:
|
||||
continue
|
||||
last["x", dev] = cnt
|
||||
elif pr:
|
||||
if last.get(dev) == data[4] or tag == "A_bad":
|
||||
continue
|
||||
last[dev] = data[4]
|
||||
else:
|
||||
if last_shared == data[4]:
|
||||
continue
|
||||
last_shared = data[4]
|
||||
if tag == "A_bad":
|
||||
continue
|
||||
pub[tag] = pub.get(tag, 0) + 1
|
||||
return pub
|
||||
|
||||
|
||||
expected = {"base": simulate(False), "pr": simulate(True)}
|
||||
print("base", expected["base"])
|
||||
print("pr ", expected["pr"])
|
||||
Path("expected.json").write_text(json.dumps(expected), encoding="utf-8")
|
||||
|
||||
|
||||
def c_rows(name, rows):
|
||||
out = [f"static const uint8_t {name}[{len(rows)}][{len(rows[0][0])}] = {{"]
|
||||
out += [
|
||||
" {" + ",".join(f"0x{b:02X}" for b in row) + "}," + (f" // {c}" if c else "")
|
||||
for row, c in rows
|
||||
]
|
||||
return "\n".join([*out, "};", ""])
|
||||
|
||||
|
||||
mac_rows = [(MAC[k], "") for k in MACS]
|
||||
frame_rows = [
|
||||
((bytes([MIDX[d], kind, len(data)]) + data).ljust(28, b"\0"), tag)
|
||||
for d, kind, data, tag in frames
|
||||
]
|
||||
|
||||
INJECT = """
|
||||
inline void xb_fill(esphome::esp32_ble::BLEScanResult &r, const uint8_t *mac, bool fe95, const uint8_t *data,
|
||||
uint8_t len) {
|
||||
memcpy(r.bda, mac, 6);
|
||||
r.ble_addr_type = 0;
|
||||
r.rssi = -50;
|
||||
uint8_t n = 0;
|
||||
r.ble_adv[n++] = 2;
|
||||
r.ble_adv[n++] = 0x01;
|
||||
r.ble_adv[n++] = 0x06;
|
||||
r.ble_adv[n++] = len + 3;
|
||||
r.ble_adv[n++] = 0x16;
|
||||
r.ble_adv[n++] = fe95 ? 0x95 : 0x1A;
|
||||
r.ble_adv[n++] = fe95 ? 0xFE : 0x18;
|
||||
memcpy(r.ble_adv + n, data, len);
|
||||
r.adv_data_len = n + len;
|
||||
r.scan_rsp_len = 0;
|
||||
r.search_evt = ESP_GAP_SEARCH_INQ_RES_EVT;
|
||||
}
|
||||
|
||||
inline void xb_inject(size_t first, size_t last) {
|
||||
for (size_t i = first; i < last; i++) {
|
||||
const uint8_t *row = XB_FRAMES[i];
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
xb_fill(r, XB_MACS[row[0]], row[1] == 0, row + 3, row[2]);
|
||||
esphome::esp32_ble_tracker::global_esp32_ble_tracker->gap_scan_event_handler(r);
|
||||
}
|
||||
}
|
||||
|
||||
inline void xb_hex(const char *label, const std::vector<uint8_t> &v) {
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < v.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", v[i]);
|
||||
ESP_LOGI("probe", "%s %s", label, buf);
|
||||
}
|
||||
|
||||
// Parse one encrypted frame with the listener directly and show whether it rewrote the shared service data.
|
||||
inline void xb_rewrite_probe(esphome::ble_device_base::ESPBTDeviceListener *listener) {
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
xb_fill(r, XB_MACS[0], true, XB_PROBE, sizeof(XB_PROBE));
|
||||
esphome::ble_device_base::ESPBTDevice device;
|
||||
device.parse_scan_rst(r);
|
||||
xb_hex("before", device.get_service_datas()[0].data);
|
||||
bool ok = listener->parse_device(device);
|
||||
ESP_LOGI("probe", "parse_device returned %d", ok);
|
||||
xb_hex("after ", device.get_service_datas()[0].data);
|
||||
}
|
||||
"""
|
||||
|
||||
ADVERTISE = """
|
||||
inline void af_send(int i) {
|
||||
const uint8_t *row = AF_FRAMES[i];
|
||||
uint8_t adv[31];
|
||||
uint8_t n = 0;
|
||||
adv[n++] = 2;
|
||||
adv[n++] = 0x01;
|
||||
adv[n++] = 0x06;
|
||||
adv[n++] = row[2] + 3;
|
||||
adv[n++] = 0x16;
|
||||
adv[n++] = row[1] == 0 ? 0x95 : 0x1A;
|
||||
adv[n++] = row[1] == 0 ? 0xFE : 0x18;
|
||||
memcpy(adv + n, row + 3, row[2]);
|
||||
n += row[2];
|
||||
esp_ble_gap_stop_advertising();
|
||||
uint8_t addr[6];
|
||||
memcpy(addr, AF_MACS[row[0]], 6);
|
||||
esp_ble_gap_set_rand_addr(addr);
|
||||
esp_ble_gap_config_adv_data_raw(adv, n);
|
||||
esp_ble_adv_params_t p{};
|
||||
p.adv_int_min = 0x20; // 20 ms
|
||||
p.adv_int_max = 0x20;
|
||||
p.adv_type = ADV_TYPE_NONCONN_IND;
|
||||
p.own_addr_type = BLE_ADDR_TYPE_RANDOM;
|
||||
p.channel_map = ADV_CHNL_ALL;
|
||||
p.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY;
|
||||
esp_ble_gap_start_advertising(&p);
|
||||
}
|
||||
|
||||
inline void af_stop() { esp_ble_gap_stop_advertising(); }
|
||||
"""
|
||||
|
||||
if OTA:
|
||||
head = '#pragma once\n#include <esp_gap_ble_api.h>\n#include <cstring>\n#include "esphome/core/log.h"\n'
|
||||
body = c_rows("AF_MACS", mac_rows) + c_rows("AF_FRAMES", frame_rows)
|
||||
body += f"static constexpr int AF_COUNT = {len(frames)};\n" + ADVERTISE
|
||||
Path("adv_frames.hpp").write_text(head + body, encoding="utf-8")
|
||||
else:
|
||||
head = '#pragma once\n#include <cstring>\n#include "esphome/components/esp32_ble/ble_scan_result.h"\n'
|
||||
probe = mibeacon_enc("A", 0x055B, 0xF5, t16(33.3), K1, False)
|
||||
body = c_rows("XB_MACS", mac_rows) + c_rows("XB_FRAMES", frame_rows)
|
||||
body += (
|
||||
f"static const uint16_t XB_STEPS[{len(steps)}] = {{"
|
||||
+ ",".join(map(str, steps))
|
||||
+ "};\n"
|
||||
)
|
||||
body += f"static constexpr size_t XB_NUM_STEPS = {len(steps)};\n"
|
||||
body += (
|
||||
"static const uint8_t XB_PROBE[] = {"
|
||||
+ ",".join(f"0x{b:02X}" for b in probe)
|
||||
+ "};\n"
|
||||
)
|
||||
Path("frames.hpp").write_text(head + body + INJECT, encoding="utf-8")
|
||||
print("frames", len(frames), "longest", max(len(d) for _, _, d, _ in frames))
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh inject|ota pr|base build and flash the receiver, capture its serial log, compare the counts
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, api_key) and run it there.
|
||||
# inject: xbinject.yaml injects the frames into its own tracker. ota: xbrecv.yaml listens to xbadv.yaml, which must
|
||||
# already run on a second ESP32 (python gen.py --ota, then flash xbadv.yaml once).
|
||||
# env: TREE (esphome checkout to build from), PORT (receiver serial port), SC (path to serial_capture.py),
|
||||
# PY (checkout python), ADV (advertiser address, ota only)
|
||||
D=$(cd "$(dirname "$0")" && pwd); MODE=${1:?inject or ota}; B=${2:?pr or base}; T=${TREE:?set TREE}
|
||||
P=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
cd "$D" || exit 1
|
||||
if [ "$MODE" = ota ]; then
|
||||
A=${ADV:?set ADV}; Y=xbrecv; SECS=130; LOSS=2
|
||||
$PY gen.py --ota > gen.txt || { echo "gen.py failed"; exit 1; }
|
||||
else
|
||||
Y=xbinject; SECS=80; LOSS=0; PROBE=--probe
|
||||
$PY gen.py > gen.txt || { echo "gen.py failed"; exit 1; }
|
||||
fi
|
||||
OUT=$D/$MODE-$B; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$Y.yaml" --device "$P" > "$OUT/upload.log" 2>&1) \
|
||||
|| { echo "FAIL: build or upload failed, see $OUT/upload.log"; exit 1; }
|
||||
$PY "$SC" "$P" $SECS "$OUT/cap.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
if [ "$MODE" = ota ]; then
|
||||
# 18 s lets the receiver boot and start scanning, so the counter 0 boot frame is the first one it hears
|
||||
sleep 18
|
||||
CODE=$(curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$A/button/start/press")
|
||||
[ "$CODE" = 200 ] || { echo "FAIL: advertiser start button returned $CODE"; exit 1; }
|
||||
fi
|
||||
wait $CAP; trap - EXIT
|
||||
[ -s "$OUT/cap.txt" ] || { echo "FAIL: serial capture got nothing"; exit 1; }
|
||||
CRASH=$(grep -acE 'Guru|abort\(\)|Backtrace:' "$OUT/cap.txt")
|
||||
$PY count.py "$OUT/cap.txt" "$B" --allow-loss $LOSS $PROBE; RC=$?
|
||||
echo "crash lines: $CRASH"
|
||||
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
|
||||
@@ -1,65 +0,0 @@
|
||||
esphome:
|
||||
name: xbadv
|
||||
includes:
|
||||
- adv_frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
esp32_ble:
|
||||
|
||||
globals:
|
||||
- id: af_idx
|
||||
type: int
|
||||
initial_value: "-1"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: start
|
||||
on_press:
|
||||
- lambda: |-
|
||||
id(af_idx) = 0;
|
||||
ESP_LOGI("af", "sequence start");
|
||||
|
||||
interval:
|
||||
- interval: 250ms
|
||||
then:
|
||||
- lambda: |-
|
||||
int i = id(af_idx);
|
||||
if (i < 0)
|
||||
return;
|
||||
if (i >= AF_COUNT) {
|
||||
af_stop();
|
||||
id(af_idx) = -1;
|
||||
ESP_LOGI("af", "sequence done");
|
||||
return;
|
||||
}
|
||||
af_send(i);
|
||||
id(af_idx) = i + 1;
|
||||
@@ -1,107 +0,0 @@
|
||||
substitutions:
|
||||
name: xbinject
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
globals:
|
||||
- id: xb_step
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
esp32_ble_tracker:
|
||||
on_ble_service_data_advertise:
|
||||
- mac_address: A4:C1:38:00:00:0A
|
||||
service_uuid: FE95
|
||||
then:
|
||||
- lambda: |-
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
|
||||
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
|
||||
|
||||
interval:
|
||||
- interval: 1s
|
||||
startup_delay: 15s
|
||||
then:
|
||||
- lambda: |-
|
||||
if (id(xb_step) + 1 >= (int) XB_NUM_STEPS) {
|
||||
if (id(xb_step) + 1 == (int) XB_NUM_STEPS) {
|
||||
ESP_LOGI("xb", "INJECT DONE");
|
||||
xb_rewrite_probe(id(xa));
|
||||
id(xb_step)++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
int s = id(xb_step);
|
||||
ESP_LOGI("xb", "inject step %d frames %u..%u", s, XB_STEPS[s], XB_STEPS[s + 1]);
|
||||
if (s == 0)
|
||||
xb_inject(0, XB_STEPS[0]);
|
||||
xb_inject(XB_STEPS[s], XB_STEPS[s + 1]);
|
||||
id(xb_step)++;
|
||||
|
||||
sensor:
|
||||
- platform: xiaomi_lywsd03mmc
|
||||
id: xa
|
||||
mac_address: A4:C1:38:00:00:0A
|
||||
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
|
||||
temperature:
|
||||
name: "xA lywsd03mmc temp"
|
||||
- platform: xiaomi_lywsdcgq
|
||||
mac_address: A4:C1:38:00:00:0B
|
||||
temperature:
|
||||
name: "xB lywsdcgq temp"
|
||||
humidity:
|
||||
name: "xB lywsdcgq hum"
|
||||
- platform: xiaomi_hhccjcy01
|
||||
mac_address: A4:C1:38:00:00:0C
|
||||
temperature:
|
||||
name: "xC hhccjcy01 temp"
|
||||
- platform: xiaomi_cgg1
|
||||
mac_address: A4:C1:38:00:00:0D
|
||||
temperature:
|
||||
name: "xD cgg1 temp"
|
||||
- platform: xiaomi_xmwsdj04mmc
|
||||
mac_address: A4:C1:38:00:00:0E
|
||||
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
|
||||
temperature:
|
||||
name: "xE xmwsdj04mmc temp"
|
||||
humidity:
|
||||
name: "xE xmwsdj04mmc hum"
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:00:00:0F
|
||||
temperature:
|
||||
name: "xF atc temp"
|
||||
humidity:
|
||||
name: "xF atc hum"
|
||||
battery_voltage:
|
||||
name: "xF atc volt"
|
||||
- platform: pvvx_mithermometer
|
||||
mac_address: A4:C1:38:00:00:10
|
||||
temperature:
|
||||
name: "xG pvvx temp"
|
||||
humidity:
|
||||
name: "xG pvvx hum"
|
||||
battery_voltage:
|
||||
name: "xG pvvx volt"
|
||||
@@ -1,83 +0,0 @@
|
||||
substitutions:
|
||||
name: xbrecv
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 100ms
|
||||
active: false
|
||||
on_ble_service_data_advertise:
|
||||
- mac_address: C4:C1:38:00:00:0A
|
||||
service_uuid: FE95
|
||||
then:
|
||||
- lambda: |-
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
|
||||
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
|
||||
|
||||
sensor:
|
||||
- platform: xiaomi_lywsd03mmc
|
||||
mac_address: C4:C1:38:00:00:0A
|
||||
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
|
||||
temperature:
|
||||
name: "xA lywsd03mmc temp"
|
||||
- platform: xiaomi_lywsdcgq
|
||||
mac_address: C4:C1:38:00:00:0B
|
||||
temperature:
|
||||
name: "xB lywsdcgq temp"
|
||||
humidity:
|
||||
name: "xB lywsdcgq hum"
|
||||
- platform: xiaomi_hhccjcy01
|
||||
mac_address: C4:C1:38:00:00:0C
|
||||
temperature:
|
||||
name: "xC hhccjcy01 temp"
|
||||
- platform: xiaomi_cgg1
|
||||
mac_address: C4:C1:38:00:00:0D
|
||||
temperature:
|
||||
name: "xD cgg1 temp"
|
||||
- platform: xiaomi_xmwsdj04mmc
|
||||
mac_address: C4:C1:38:00:00:0E
|
||||
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
|
||||
temperature:
|
||||
name: "xE xmwsdj04mmc temp"
|
||||
humidity:
|
||||
name: "xE xmwsdj04mmc hum"
|
||||
- platform: atc_mithermometer
|
||||
mac_address: C4:C1:38:00:00:0F
|
||||
temperature:
|
||||
name: "xF atc temp"
|
||||
humidity:
|
||||
name: "xF atc hum"
|
||||
battery_voltage:
|
||||
name: "xF atc volt"
|
||||
- platform: pvvx_mithermometer
|
||||
mac_address: C4:C1:38:00:00:10
|
||||
temperature:
|
||||
name: "xG pvvx temp"
|
||||
humidity:
|
||||
name: "xG pvvx hum"
|
||||
battery_voltage:
|
||||
name: "xG pvvx volt"
|
||||
@@ -1,11 +1,23 @@
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Callable, Coroutine
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
from esphome.components import ble_device_base, bluetooth_connection
|
||||
from esphome.components.ble_device_base import (
|
||||
BT_UUID16_FORMAT as bt_uuid16_format,
|
||||
BT_UUID32_FORMAT as bt_uuid32_format,
|
||||
BT_UUID128_FORMAT as bt_uuid128_format,
|
||||
as_hex,
|
||||
as_reversed_hex_array,
|
||||
bt_uuid,
|
||||
)
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHARACTERISTIC_UUID,
|
||||
@@ -18,15 +30,55 @@ from esphome.const import (
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_VALUE,
|
||||
PLATFORM_ESP32,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client"]
|
||||
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily inside
|
||||
# the esp32 schema/codegen arms: importing those modules registers esp32-only
|
||||
# automations as a side effect, which must not leak into the neutral
|
||||
# platforms' registries (the bluetooth_proxy pattern).
|
||||
|
||||
|
||||
def _legacy_engine() -> bool:
|
||||
"""True when the build uses the legacy raw-gattc engine - one line to
|
||||
flip when esp32 moves to the neutral engine (with
|
||||
USE_BLE_CLIENT_LEGACY_ENGINE in _to_code_esp32)."""
|
||||
return CORE.is_esp32
|
||||
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
"""The engine's closure per platform: the legacy esp32 engine builds on
|
||||
esp32_ble_client plus bluetooth_connection (the shared service-table
|
||||
materializer; its sources compile empty in builds without a neutral
|
||||
node), the neutral engine on the bluetooth_connection backend. The
|
||||
platform-less arm is the union for manifest-resolving tooling."""
|
||||
if _legacy_engine() or CORE.target_platform is None:
|
||||
return ["bluetooth_connection", "esp32_ble_client"]
|
||||
return ["bluetooth_connection"]
|
||||
|
||||
|
||||
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
DOMAIN = "ble_client"
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
{
|
||||
"ble_client.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
# Every framework of every non-esp32 registry platform: a platform
|
||||
# that validates the neutral arm must also compile the neutral engine.
|
||||
"ble_client_gatt.cpp": frameworks_for_platforms(
|
||||
set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
@@ -62,16 +114,12 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
ble_client_ns = cg.esphome_ns.namespace("ble_client")
|
||||
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
|
||||
# One codegen class for both engines: the exclusively-gated headers resolve
|
||||
# the name to exactly one C++ definition per build.
|
||||
BLEClient = ble_client_ns.class_("BLEClient", cg.Component)
|
||||
BLEClientNode = ble_client_ns.class_("BLEClientNode")
|
||||
BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const")
|
||||
# Triggers
|
||||
BLEClientConnectTrigger = ble_client_ns.class_(
|
||||
"BLEClientConnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
|
||||
)
|
||||
BLEClientDisconnectTrigger = ble_client_ns.class_(
|
||||
"BLEClientDisconnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
|
||||
)
|
||||
BLEClientPasskeyRequestTrigger = ble_client_ns.class_(
|
||||
"BLEClientPasskeyRequestTrigger", automation.Trigger.template(BLEClientNodeConstRef)
|
||||
)
|
||||
@@ -109,62 +157,175 @@ CONF_AUTO_CONNECT = "auto_connect"
|
||||
|
||||
MULTI_CONF = True
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEClient),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientConnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientDisconnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyNotificationTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientNumericComparisonRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
esp32_ble.consume_connection_slots(1, "ble_client"),
|
||||
# Keys shared by both engines' schemas.
|
||||
_COMMON_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEClient),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
# Both engines register these on the client; the Bluedroid-security triggers below stay trigger classes
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _esp32_config_schema() -> cv.All:
|
||||
"""The legacy engine's schema, byte-compatible with what esp32 always had
|
||||
(including the Bluedroid security triggers)."""
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
return cv.All(
|
||||
_COMMON_SCHEMA.extend(
|
||||
{
|
||||
# Accepted-but-unused legacy key; not propagated to the
|
||||
# neutral schema.
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_PASSKEY_NOTIFICATION
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyNotificationTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientNumericComparisonRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
bluetooth_connection.consume_gatt_slot("ble_client"),
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _gatt_config_schema(platform: str) -> cv.All:
|
||||
"""The neutral engine's schema: the shared keys plus the hub reference
|
||||
(parsed-advertisement sightings) and the GATT backend declaration.
|
||||
Keyed by platform - the backend fragment differs per platform."""
|
||||
return cv.All(
|
||||
_COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend(
|
||||
bluetooth_connection.gatt_client_schema(platform)
|
||||
),
|
||||
bluetooth_connection.consume_gatt_slot("ble_client"),
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# Deliberate gap (the bluetooth_proxy pattern): the dumper gets only
|
||||
# this shape, so the neutral arm's ble_hub_id is absent from editor
|
||||
# schemas and the esp32-only keys are advertised on every platform.
|
||||
# The language-schema dumper runs without a platform; expose the
|
||||
# esp32 (legacy-engine) shape.
|
||||
return _esp32_config_schema()
|
||||
if _legacy_engine():
|
||||
return _esp32_config_schema()(config)
|
||||
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
|
||||
return _gatt_config_schema(CORE.target_platform)(config)
|
||||
raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}")
|
||||
|
||||
|
||||
CONFIG_SCHEMA = _validate_platform
|
||||
|
||||
CONF_BLE_CLIENT_ID = "ble_client_id"
|
||||
|
||||
|
||||
class BLEClientFeatures(StrEnum):
|
||||
"""Per-platform engine capabilities consumers declare against."""
|
||||
|
||||
# The platform-neutral node interface (on_connected/table + completion
|
||||
# callbacks) - every platform with a ble_client engine.
|
||||
GATT_NODE = "gatt_node"
|
||||
# The raw esp32 GATT client event stream (gattc/gap handlers,
|
||||
# node_state) - the legacy engine only.
|
||||
RAW_GATTC = "raw_gattc"
|
||||
# Pairing dialog replies and bond management (Bluedroid GAP/SMP).
|
||||
SECURITY = "security"
|
||||
|
||||
|
||||
def _engine_features() -> set[BLEClientFeatures]:
|
||||
"""Features the validated platform's engine provides."""
|
||||
if _legacy_engine():
|
||||
return {
|
||||
BLEClientFeatures.GATT_NODE,
|
||||
BLEClientFeatures.RAW_GATTC,
|
||||
BLEClientFeatures.SECURITY,
|
||||
}
|
||||
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
|
||||
return {BLEClientFeatures.GATT_NODE}
|
||||
return set()
|
||||
|
||||
|
||||
def requires_feature(
|
||||
feature: BLEClientFeatures, description: str
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Validator gating a consumer to platforms whose engine provides
|
||||
`feature`, naming the missing capability in the error."""
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
features = _engine_features()
|
||||
if feature not in features:
|
||||
available = (
|
||||
f"; this platform's engine provides: {', '.join(sorted(features))}"
|
||||
if features
|
||||
else ""
|
||||
)
|
||||
raise cv.Invalid(
|
||||
f"{description} requires the ble_client '{feature}' feature, "
|
||||
f"which {CORE.target_platform} does not provide{available}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# The one choke point for every node component still on the raw esp32 event
|
||||
# stream; migrating to the neutral interface (NODE_BLE_CLIENT_SCHEMA +
|
||||
# register_gatt_node) lifts it.
|
||||
_legacy_engine_only = requires_feature(
|
||||
BLEClientFeatures.RAW_GATTC,
|
||||
"This component drives the raw ESP32 GATT client events and has not "
|
||||
"been migrated to the platform-neutral node interface yet; it",
|
||||
)
|
||||
|
||||
BLE_CLIENT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient),
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
|
||||
cv.use_id(BLEClient), _legacy_engine_only
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# For node components on the neutral interface: valid wherever ble_client
|
||||
# itself is.
|
||||
NODE_BLE_CLIENT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
|
||||
cv.use_id(BLEClient),
|
||||
requires_feature(BLEClientFeatures.GATT_NODE, "This component"),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,6 +335,26 @@ async def register_ble_node(var, config):
|
||||
cg.add(parent.register_ble_node(var))
|
||||
|
||||
|
||||
def _request_gatt_node_build() -> None:
|
||||
"""Node storage and the one define meaning "the neutral node surface is
|
||||
compiled in", plus the esp32 bridge/materializer defines."""
|
||||
_request_node_slot()
|
||||
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
|
||||
if _legacy_engine():
|
||||
# Deliberately not ble_device_base.request_gatt_client(): that would
|
||||
# claim a phantom backend slot on combined proxy builds.
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
cg.add_define("USE_BLE_GATT_BACKEND_BLUEDROID")
|
||||
cg.add_define("USE_BLUEDROID_GATT_SERVICE_TABLE")
|
||||
|
||||
|
||||
async def register_gatt_node(var, config):
|
||||
"""Register a node on the platform-neutral interface (both engines)."""
|
||||
parent = await cg.get_variable(config[CONF_BLE_CLIENT_ID])
|
||||
_request_gatt_node_build()
|
||||
cg.add(parent.register_gatt_node(var))
|
||||
|
||||
|
||||
def new_ble_node_component(
|
||||
config: ConfigType, *args: Any
|
||||
) -> Coroutine[Any, Any, cg.MockObj]:
|
||||
@@ -184,8 +365,8 @@ def new_ble_node_component(
|
||||
BLE_WRITE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
|
||||
cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid,
|
||||
cv.Required(CONF_SERVICE_UUID): bt_uuid,
|
||||
cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid,
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||
}
|
||||
)
|
||||
@@ -196,25 +377,34 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id(
|
||||
}
|
||||
)
|
||||
|
||||
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
|
||||
}
|
||||
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
|
||||
}
|
||||
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
}
|
||||
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -242,6 +432,8 @@ automation.register_simple_action(
|
||||
)
|
||||
async def ble_write_to_code(config, action_id, template_arg, args):
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
# The action registers itself as a neutral node in its constructor.
|
||||
_request_gatt_node_build()
|
||||
var = cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
value = config[CONF_VALUE]
|
||||
@@ -256,38 +448,20 @@ async def ble_write_to_code(config, action_id, template_arg, args):
|
||||
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*value))
|
||||
cg.add(var.set_value_simple(arr, len(value)))
|
||||
|
||||
if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
|
||||
cg.add(
|
||||
var.set_service_uuid16(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
|
||||
)
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid32_format):
|
||||
cg.add(
|
||||
var.set_service_uuid32(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
|
||||
)
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid128_format):
|
||||
uuid128 = esp32_ble_tracker.as_reversed_hex_array(config[CONF_SERVICE_UUID])
|
||||
if len(config[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(var.set_service_uuid16(as_hex(config[CONF_SERVICE_UUID])))
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid32_format):
|
||||
cg.add(var.set_service_uuid32(as_hex(config[CONF_SERVICE_UUID])))
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid128_format):
|
||||
uuid128 = as_reversed_hex_array(config[CONF_SERVICE_UUID])
|
||||
cg.add(var.set_service_uuid128(uuid128))
|
||||
|
||||
if len(config[CONF_CHARACTERISTIC_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
|
||||
cg.add(
|
||||
var.set_char_uuid16(
|
||||
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
|
||||
)
|
||||
)
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
|
||||
esp32_ble_tracker.bt_uuid32_format
|
||||
):
|
||||
cg.add(
|
||||
var.set_char_uuid32(
|
||||
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
|
||||
)
|
||||
)
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
|
||||
esp32_ble_tracker.bt_uuid128_format
|
||||
):
|
||||
uuid128 = esp32_ble_tracker.as_reversed_hex_array(
|
||||
config[CONF_CHARACTERISTIC_UUID]
|
||||
)
|
||||
if len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(var.set_char_uuid16(as_hex(config[CONF_CHARACTERISTIC_UUID])))
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid32_format):
|
||||
cg.add(var.set_char_uuid32(as_hex(config[CONF_CHARACTERISTIC_UUID])))
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid128_format):
|
||||
uuid128 = as_reversed_hex_array(config[CONF_CHARACTERISTIC_UUID])
|
||||
cg.add(var.set_char_uuid128(uuid128))
|
||||
|
||||
return var
|
||||
@@ -341,22 +515,48 @@ automation.register_simple_action(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def _to_code_esp32(config: ConfigType) -> cg.MockObj:
|
||||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
|
||||
# Register the loggers this component needs
|
||||
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
cg.add_define("USE_BLE_CLIENT_LEGACY_ENGINE")
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await esp32_ble_tracker.register_client(var, config)
|
||||
return var
|
||||
|
||||
|
||||
# Sizes the neutral client's node storage; the client itself requests a
|
||||
# baseline slot so the define exists on every build that compiles the engine.
|
||||
_request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
|
||||
|
||||
|
||||
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
|
||||
# The engine always carries the node surface (the client itself owns the
|
||||
# baseline slot).
|
||||
_request_gatt_node_build()
|
||||
backend = await bluetooth_connection.new_gatt_backend(config)
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_backend(backend))
|
||||
# Sighting-gated connects: the client listens for the peer's parsed
|
||||
# advertisements through the hub.
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if _legacy_engine():
|
||||
var = await _to_code_esp32(config)
|
||||
else:
|
||||
var = await _to_code_gatt(config)
|
||||
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
|
||||
cg.add(var.set_auto_connect(config[CONF_AUTO_CONNECT]))
|
||||
for conf in config.get(CONF_ON_CONNECT, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_DISCONNECT, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
for conf in config.get(CONF_ON_PASSKEY_REQUEST, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
@@ -1,27 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
|
||||
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
// placeholder class for static TAG .
|
||||
class Automation {
|
||||
public:
|
||||
// could be made inline with C++17
|
||||
static const char *const TAG;
|
||||
};
|
||||
|
||||
// Base for nodes that never read the parent's services.
|
||||
// The parent releases its services only once every node reports Established, so a node that never
|
||||
// reports it keeps that memory allocated for the life of the connection.
|
||||
@@ -39,45 +26,6 @@ class BLEClientServicelessNode : public BLEClientNode {
|
||||
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
|
||||
};
|
||||
|
||||
// implement on_connect automation.
|
||||
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() 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 {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->trigger();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// on_disconnect automation
|
||||
class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() 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 {
|
||||
// test for CLOSE and not DISCONNECT - DISCONNECT can occur even if no virtual connection (OPEN event) occurred.
|
||||
// So this will not trigger unless a complete open has previously succeeded.
|
||||
switch (event) {
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
this->trigger();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
|
||||
public:
|
||||
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
@@ -110,144 +58,6 @@ class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>,
|
||||
}
|
||||
};
|
||||
|
||||
// implement the ble_client.ble_write action.
|
||||
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientWriteAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
|
||||
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->value_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
// Store pointer to static data in flash (no RAM copy)
|
||||
void set_value_simple(const uint8_t *data, size_t len) {
|
||||
this->value_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->var_ = std::make_tuple(x...);
|
||||
|
||||
bool result;
|
||||
if (this->len_ >= 0) {
|
||||
// Static mode: write directly from flash pointer
|
||||
result = this->write(this->value_.data, this->len_);
|
||||
} else {
|
||||
// Template mode: call function and write the vector
|
||||
std::vector<uint8_t> value = this->value_.func(x...);
|
||||
result = this->write(value);
|
||||
}
|
||||
|
||||
// on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work.
|
||||
if (!result)
|
||||
this->play_next_(x...);
|
||||
}
|
||||
|
||||
/**
|
||||
* Note about logging: the esph_log_X macros are used here because the CI checks complain about use of the ESP LOG
|
||||
* macros in header files (Can't even write it in a comment!)
|
||||
* Not sure why, because they seem to work just fine.
|
||||
* The problem is that the implementation of a templated class can't be placed in a .cpp file when using C++ less than
|
||||
* 17, so the methods have to be here. The esph_log_X macros are equivalent in function, but don't trigger the CI
|
||||
* errors.
|
||||
*/
|
||||
// initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event.
|
||||
bool write(const uint8_t *data, size_t len) {
|
||||
if (this->node_state != espbt::ClientState::ESTABLISHED) {
|
||||
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
|
||||
return false;
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
|
||||
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
|
||||
#endif
|
||||
esp_err_t err =
|
||||
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len,
|
||||
const_cast<uint8_t *>(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_OK) {
|
||||
esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write(const std::vector<uint8_t> &value) { return this->write(value.data(), value.size()); }
|
||||
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
switch (event) {
|
||||
case ESP_GATTC_WRITE_CHAR_EVT:
|
||||
// upstream code checked the MAC address, verify the characteristic.
|
||||
if (param->write.handle == this->char_handle_)
|
||||
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
break;
|
||||
case ESP_GATTC_DISCONNECT_EVT:
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
break;
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
auto *chr = this->parent()->get_characteristic(this->service_uuid_, this->char_uuid_);
|
||||
if (chr == nullptr) {
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
char service_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s",
|
||||
this->char_uuid_.to_str(char_buf), this->service_uuid_.to_str(service_buf));
|
||||
break;
|
||||
}
|
||||
this->char_handle_ = chr->handle;
|
||||
this->char_props_ = chr->properties;
|
||||
if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE) {
|
||||
this->write_type_ = ESP_GATT_WRITE_TYPE_RSP;
|
||||
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_RSP");
|
||||
} else if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) {
|
||||
this->write_type_ = ESP_GATT_WRITE_TYPE_NO_RSP;
|
||||
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_NO_RSP");
|
||||
} else {
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
|
||||
break;
|
||||
}
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
|
||||
ble_client_->address_str());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Value {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} value_;
|
||||
espbt::ESPBTUUID service_uuid_;
|
||||
espbt::ESPBTUUID char_uuid_;
|
||||
std::tuple<Ts...> var_{};
|
||||
uint16_t char_handle_{};
|
||||
esp_gatt_char_prop_t char_props_{};
|
||||
esp_gatt_write_type_t write_type_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientPasskeyReplyAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Neutral twins of the shared ble_client automations. Class names, namespace,
|
||||
// and codegen-visible signatures are IDENTICAL to automation.h so generated
|
||||
// main.cpp compiles against whichever engine the build gates in; only the
|
||||
// internals differ (client callbacks and the neutral node interface instead
|
||||
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
|
||||
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include <tuple>
|
||||
|
||||
#include "ble_client_gatt.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientConnectAction(BLEClient *ble_client) {
|
||||
ble_client_ = ble_client;
|
||||
ble_client->add_on_connect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
// A connect attempt that dies (or a later disconnect) terminates the
|
||||
// chain - unless a fresh attempt is already in flight, meaning a user
|
||||
// trigger on this same list started this action (an on_disconnect that
|
||||
// reconnects) and the wait belongs to that attempt. Legacy split these:
|
||||
// it aborted on DISCONNECT_EVT, the trigger fired on the later CLOSE_EVT.
|
||||
auto terminate = [this]() {
|
||||
if (this->num_running_ != 0 && this->ble_client_->idle())
|
||||
this->stop_complex();
|
||||
};
|
||||
ble_client->add_on_connect_failed_callback(terminate);
|
||||
ble_client->add_on_disconnect_callback(terminate);
|
||||
}
|
||||
|
||||
// not used since we override play_complex_
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
// it makes no sense to have multiple instances of this running at the
|
||||
// same time; cancel a re-trigger while still running.
|
||||
if (this->num_running_ != 0) {
|
||||
this->stop_complex();
|
||||
return;
|
||||
}
|
||||
this->num_running_++;
|
||||
if (this->ble_client_->connected()) {
|
||||
this->play_next_(x...);
|
||||
} else {
|
||||
this->var_ = std::make_tuple(x...);
|
||||
// No-op while already connecting; the callback resolves the wait.
|
||||
this->ble_client_->connect();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientDisconnectAction(BLEClient *ble_client) {
|
||||
ble_client_ = ble_client;
|
||||
// Both terminal outcomes resolve the wait: a completed teardown and a
|
||||
// connect attempt that died on the way down.
|
||||
ble_client->add_on_disconnect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
ble_client->add_on_connect_failed_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
}
|
||||
|
||||
// not used since we override play_complex_
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
if (this->ble_client_->idle()) {
|
||||
this->play_next_(x...);
|
||||
} else {
|
||||
this->var_ = std::make_tuple(x...);
|
||||
this->ble_client_->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -2,10 +2,16 @@
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
@@ -30,6 +36,10 @@ void BLEClient::dump_config() {
|
||||
bool BLEClient::parse_device(const espbt::ESPBTDevice &device) {
|
||||
if (!this->enabled)
|
||||
return false;
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
if (device.address_uint64() == this->address_ && this->gatt_backoff_.holding_off())
|
||||
return false;
|
||||
#endif
|
||||
return BLEClientBase::parse_device(device);
|
||||
}
|
||||
|
||||
@@ -40,24 +50,65 @@ void BLEClient::set_enabled(bool enabled) {
|
||||
if (!enabled) {
|
||||
ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str());
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// A re-enable clears the backoff (neutral-engine parity).
|
||||
this->gatt_backoff_.reset();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Bridge-initiated registrations bypass the base's REG_FOR_NOTIFY handling:
|
||||
// its automatic CCCD write would double the node's own.
|
||||
// Handle-keyed: mixed legacy/neutral subscriptions to one characteristic
|
||||
// are unsupported during the migration window.
|
||||
if (event == ESP_GATTC_REG_FOR_NOTIFY_EVT && esp_gattc_if == this->gattc_if_ &&
|
||||
this->take_pending_gatt_reg_(param->reg_for_notify.handle)) {
|
||||
if (this->pending_notify_regs_ > 0)
|
||||
this->pending_notify_regs_--;
|
||||
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
|
||||
this->notify_state_to_gatt_nodes_(param->reg_for_notify.handle, true, err);
|
||||
// A retiring last registration must still release the cache.
|
||||
this->maybe_release_services_();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
|
||||
return false;
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Before the legacy fan-out so gatt nodes resolve before any trigger fires.
|
||||
if (!this->gatt_nodes_.empty()) {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
|
||||
// A failed discovery tears the link down; the on_connect trigger must
|
||||
// not fire into the teardown.
|
||||
if (!this->handle_gatt_search_cmpl_(param->search_cmpl.status))
|
||||
return true;
|
||||
} else {
|
||||
this->dispatch_gatt_event_(event, param);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
for (auto *node : this->nodes_)
|
||||
node->gattc_event_handler(event, esp_gattc_if, param);
|
||||
// The automations run after the nodes; on_connect runs before the release so it can still use the services
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
|
||||
this->connect_callbacks_.call();
|
||||
this->maybe_release_services_();
|
||||
if (event == ESP_GATTC_CLOSE_EVT)
|
||||
this->disconnect_callbacks_.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::maybe_release_services_() {
|
||||
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
|
||||
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
|
||||
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
|
||||
this->release_services();
|
||||
ESP_LOGD(TAG, "All clients established, services released");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
@@ -65,10 +116,19 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
|
||||
|
||||
for (auto *node : this->nodes_)
|
||||
node->gap_event_handler(event, param);
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
if (event == ESP_GAP_BLE_AUTH_CMPL_EVT && this->check_addr(param->ble_security.auth_cmpl.bd_addr)) {
|
||||
int status = param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason;
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_pairing_result(status);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BLEClient::set_state(espbt::ClientState state) {
|
||||
BLEClientBase::set_state(state);
|
||||
// ESTABLISHED never flows through here; gatt nodes are promoted after the
|
||||
// on_connected fan-out.
|
||||
for (auto &node : nodes_)
|
||||
node->node_state = state;
|
||||
}
|
||||
@@ -83,6 +143,221 @@ bool BLEClient::all_nodes_established_() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
void BLEClient::register_gatt_node(BLEClientNode *node) {
|
||||
// Parent before the capacity check so a dropped node still has a usable
|
||||
// parent() (neutral-engine parity).
|
||||
node->set_ble_client_parent(this);
|
||||
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
|
||||
// push_back past capacity is a silent no-op; an undersized slot count
|
||||
// must be loud at boot, not an unresolvable node at runtime.
|
||||
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str());
|
||||
this->status_set_error(LOG_STR("node capacity exceeded"));
|
||||
return;
|
||||
}
|
||||
this->gatt_nodes_.push_back(node);
|
||||
// nodes_ covers the shared state bookkeeping; gatt_nodes_ is the neutral
|
||||
// fan-out subset.
|
||||
this->register_ble_node(node);
|
||||
}
|
||||
|
||||
int BLEClient::find_pending_gatt_reg_(uint16_t handle) const {
|
||||
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
|
||||
if (this->pending_gatt_regs_[i] == handle)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
|
||||
int i = this->find_pending_gatt_reg_(handle);
|
||||
if (i < 0)
|
||||
return false;
|
||||
// No duplicates (notify_characteristic refuses a re-push); swap-with-last.
|
||||
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str(),
|
||||
enabled ? "enable" : "disable", handle, error);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_notify_state(handle, enabled, error);
|
||||
}
|
||||
|
||||
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_READ_CHAR_EVT:
|
||||
case ESP_GATTC_READ_DESCR_EVT: {
|
||||
bool ok = param->read.status == ESP_GATT_OK;
|
||||
if (!ok) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str(), param->read.handle,
|
||||
param->read.status);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
|
||||
ok ? 0 : param->read.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_CHAR_EVT:
|
||||
case ESP_GATTC_WRITE_DESCR_EVT:
|
||||
if (param->write.status != ESP_GATT_OK) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str(), param->write.handle,
|
||||
param->write.status);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
|
||||
}
|
||||
break;
|
||||
case ESP_GATTC_NOTIFY_EVT:
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_notify(param->notify.handle, param->notify.value, param->notify.value_len);
|
||||
}
|
||||
break;
|
||||
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT:
|
||||
// The base does no CCCD work for unregister; no interception needed.
|
||||
this->notify_state_to_gatt_nodes_(
|
||||
param->unreg_for_notify.handle, false,
|
||||
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
|
||||
// The base ignores the search status; the neutral contract must not.
|
||||
uint16_t service_total = 0;
|
||||
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
|
||||
this->gattc_if_, this->conn_id_, &service_total);
|
||||
if (!counted || service_total == 0) {
|
||||
// A failed search poisons the whole discovery, legacy nodes included.
|
||||
ESP_LOGW(TAG, "[%s] Discovery failed (status=%d, services=%u)", this->address_str(), status, service_total);
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
this->disconnect();
|
||||
return false;
|
||||
}
|
||||
// Stack-owned; nodes copy their handles during on_connected().
|
||||
bluetooth_connection::BluedroidServiceTable table;
|
||||
if (!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
|
||||
if (!this->has_legacy_nodes_()) {
|
||||
ESP_LOGW(TAG, "[%s] Service table build failed; treating as failed discovery", this->address_str());
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
this->disconnect();
|
||||
return false;
|
||||
}
|
||||
// Only the table build failed; legacy nodes read the base's services_
|
||||
// and keep the link. Gatt nodes catch the next connection.
|
||||
ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str());
|
||||
this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed"));
|
||||
// A peer whose database never walks must not retry the build at
|
||||
// advertisement cadence once the legacy nodes drop the link.
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
} else {
|
||||
this->gatt_connected_ = true;
|
||||
auto view = table.view();
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_connected(view);
|
||||
if (this->state() != espbt::ClientState::ESTABLISHED) {
|
||||
// The node tore the link down; remaining nodes get on_disconnected
|
||||
// with no preceding on_connected, so leave a trace of why.
|
||||
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->gatt_backoff_.reset();
|
||||
this->status_clear_warning();
|
||||
}
|
||||
// Promote so the legacy release condition can fire.
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->node_state = espbt::ClientState::ESTABLISHED;
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::on_disconnect_complete(esp_err_t reason) {
|
||||
this->pending_gatt_reg_count_ = 0;
|
||||
if (!this->gatt_connected_)
|
||||
return; // Never-established links report nothing (neutral parity).
|
||||
this->gatt_connected_ = false;
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_disconnected();
|
||||
}
|
||||
|
||||
int BLEClient::check_and_log_error_(const char *operation, esp_err_t err) {
|
||||
if (err != ESP_OK)
|
||||
this->log_gattc_warning_(operation, err);
|
||||
return err;
|
||||
}
|
||||
|
||||
int BLEClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char",
|
||||
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
|
||||
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
|
||||
ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::read_characteristic(uint16_t handle) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
|
||||
handle, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::read_descriptor(uint16_t handle) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_read_char_descr",
|
||||
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char_descr",
|
||||
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
|
||||
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::notify_characteristic(uint16_t handle, bool enable) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
if (enable) {
|
||||
if (this->find_pending_gatt_reg_(handle) >= 0) {
|
||||
// ESP_OK: the in-flight registration's completion fans out to all nodes.
|
||||
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (this->pending_gatt_reg_count_ == MAX_PENDING_NOTIFY_REGS) {
|
||||
// An untracked registration would let the base's auto-CCCD through.
|
||||
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
// The base helper's pending count holds the service-release until the
|
||||
// (intercepted) completion.
|
||||
esp_err_t err = this->register_for_notify(handle);
|
||||
if (err == ESP_OK)
|
||||
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
|
||||
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
|
||||
}
|
||||
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
|
||||
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
|
||||
}
|
||||
|
||||
int BLEClient::unpair() { return bluetooth_connection::unpair_device(this->get_address()); }
|
||||
|
||||
#endif // USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
@@ -1,18 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#include "ble_client_node.h"
|
||||
#include "connect_backoff.h"
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <esp_bt_defs.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gatt_common_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
@@ -21,34 +23,6 @@ namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
using namespace esp32_ble_client;
|
||||
|
||||
class BLEClient;
|
||||
|
||||
class BLEClientNode {
|
||||
public:
|
||||
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {};
|
||||
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
|
||||
virtual void loop() {}
|
||||
void set_address(uint64_t address) { address_ = address; }
|
||||
espbt::ESPBTClient *client;
|
||||
// This should be transitioned to Established once the node no longer needs
|
||||
// the services/descriptors/characteristics of the parent client. This will
|
||||
// allow some memory to be freed.
|
||||
// The parent frees the peer's GATT cache once every node reports Established.
|
||||
// Never report Established while an operation that reads that cache is outstanding.
|
||||
// - esp_ble_gattc_register_for_notify() completes asynchronously.
|
||||
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
|
||||
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
|
||||
espbt::ClientState node_state;
|
||||
|
||||
BLEClient *parent() { return this->parent_; }
|
||||
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
|
||||
|
||||
protected:
|
||||
BLEClient *parent_;
|
||||
uint64_t address_;
|
||||
};
|
||||
|
||||
class BLEClient final : public BLEClientBase {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -64,19 +38,75 @@ class BLEClient final : public BLEClientBase {
|
||||
void set_enabled(bool enabled);
|
||||
|
||||
void register_ble_node(BLEClientNode *node) {
|
||||
node->client = this;
|
||||
node->set_ble_client_parent(this);
|
||||
this->nodes_.push_back(node);
|
||||
}
|
||||
|
||||
// on_connect fires once discovery completes; on_disconnect on CLOSE_EVT, so only after a completed open
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
bool enabled;
|
||||
|
||||
void set_state(espbt::ClientState state) override;
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// ---- the neutral node surface (signatures shared with the non-esp32
|
||||
// engine, so nodes on the neutral interface compile against either) ----
|
||||
void register_gatt_node(BLEClientNode *node);
|
||||
|
||||
bool idle() const { return this->state() == espbt::ClientState::IDLE; }
|
||||
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
|
||||
int read_characteristic(uint16_t handle);
|
||||
int read_descriptor(uint16_t handle);
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
|
||||
/// Local registration only; per the neutral contract the CCCD write is the
|
||||
/// node's job (the legacy auto-CCCD is suppressed for these handles).
|
||||
int notify_characteristic(uint16_t handle, bool enable);
|
||||
// pair() comes from BLEClientBase, matching the neutral engine's.
|
||||
int unpair();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool all_nodes_established_();
|
||||
void maybe_release_services_();
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
int check_and_log_error_(const char *operation, esp_err_t err);
|
||||
int find_pending_gatt_reg_(uint16_t handle) const;
|
||||
void notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error);
|
||||
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
|
||||
// False = failed discovery: the link comes down and the caller suppresses
|
||||
// the legacy fan-out.
|
||||
bool handle_gatt_search_cmpl_(esp_gatt_status_t status);
|
||||
bool take_pending_gatt_reg_(uint16_t handle);
|
||||
void on_disconnect_complete(esp_err_t reason) override;
|
||||
#endif
|
||||
|
||||
std::vector<BLEClientNode *> nodes_;
|
||||
LazyCallbackManager<void()> connect_callbacks_;
|
||||
LazyCallbackManager<void()> disconnect_callbacks_;
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Raise if a migrated node needs more concurrent registrations.
|
||||
static constexpr uint8_t MAX_PENDING_NOTIFY_REGS = 4;
|
||||
|
||||
// Nodes on the neutral surface; fed the translated callbacks and
|
||||
// auto-established after the on_connected fan-out. Every gatt node is
|
||||
// also in nodes_ (registration pushes into both).
|
||||
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
|
||||
bool has_legacy_nodes_() const { return this->nodes_.size() > this->gatt_nodes_.size(); }
|
||||
// Reconnect backoff after materializer failures.
|
||||
ConnectBackoff gatt_backoff_;
|
||||
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
|
||||
uint16_t pending_gatt_regs_[MAX_PENDING_NOTIFY_REGS];
|
||||
uint8_t pending_gatt_reg_count_{0};
|
||||
// on_connected fan-out started; on_disconnected is owed at teardown.
|
||||
bool gatt_connected_{false};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "ble_client_gatt.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_client");
|
||||
|
||||
void BLEClient::register_ble_node(BLEClientNode *node) {
|
||||
node->set_ble_client_parent(this);
|
||||
if (this->nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
|
||||
// push_back past capacity is a silent no-op; an undersized slot count
|
||||
// must be loud at boot, not an unresolvable node at runtime.
|
||||
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str_);
|
||||
this->status_set_error(LOG_STR("node capacity exceeded"));
|
||||
return;
|
||||
}
|
||||
this->nodes_.push_back(node);
|
||||
}
|
||||
|
||||
void BLEClient::set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
uint8_t mac[6];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
format_mac_addr_upper(mac, this->address_str_);
|
||||
}
|
||||
|
||||
void BLEClient::set_enabled(bool enabled) {
|
||||
if (enabled == this->enabled)
|
||||
return;
|
||||
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
|
||||
this->enabled = enabled;
|
||||
if (!enabled) {
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
// A re-enable clears the backoff; the next sighting connects (legacy
|
||||
// parity: enabling does not itself connect).
|
||||
this->backoff_.reset();
|
||||
}
|
||||
|
||||
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != this->address_)
|
||||
return false;
|
||||
// The sighting is the source of truth for the address type.
|
||||
this->address_type_ = device.get_address_type();
|
||||
this->address_type_known_ = true;
|
||||
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
|
||||
return true;
|
||||
if (this->backoff_.holding_off())
|
||||
return true;
|
||||
this->attempt_connect_();
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::connect() {
|
||||
if (this->state_ != State::IDLE) {
|
||||
ESP_LOGD(TAG, "[%s] Connect requested while busy, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
// An absent peer can inhibit scanning for the backend's full connect
|
||||
// timeout, so this is worth a breadcrumb - but it is a supported action.
|
||||
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
|
||||
if (!this->address_type_known_) {
|
||||
// Legacy parity: without a sighting the address type defaults to
|
||||
// public, which never matches a random-static peer.
|
||||
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
|
||||
}
|
||||
this->attempt_connect_();
|
||||
}
|
||||
|
||||
void BLEClient::attempt_connect_() {
|
||||
int err = this->backend_->connect(this->address_, this->address_type_);
|
||||
if (err != 0) {
|
||||
// A refused connect never produces a callback: stay idle, charge the
|
||||
// backoff, and resolve any waiting connect action through the failure
|
||||
// path so its chain terminates.
|
||||
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
this->defer([this]() { this->connect_failed_callbacks_.call(); });
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
|
||||
this->state_ = State::CONNECTING;
|
||||
}
|
||||
|
||||
void BLEClient::disconnect() {
|
||||
if (this->state_ == State::IDLE) {
|
||||
ESP_LOGD(TAG, "[%s] Disconnect requested while idle, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
if (this->cancel_requested_) {
|
||||
// The accepted teardown still reports; a refused second gatt_disconnect()
|
||||
// would settle the link twice (phantom connect failure, backoff charge).
|
||||
ESP_LOGD(TAG, "[%s] Disconnect already in flight, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
// A deliberate teardown's failure report must not feed the backoff.
|
||||
this->cancel_requested_ = true;
|
||||
int err = this->backend_->gatt_disconnect();
|
||||
if (err != 0) {
|
||||
// Refused synchronously: backend and client disagree about the link
|
||||
// state. Warn, then settle through the deliberate-cancel path.
|
||||
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
|
||||
this->on_connection_state(false, 0, err);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
|
||||
if (connected) {
|
||||
this->state_ = State::DISCOVERING;
|
||||
int discover_err = this->backend_->discover_services();
|
||||
if (discover_err != 0) {
|
||||
// Synchronous refusal: no discovery completion will follow.
|
||||
ESP_LOGW(TAG, "[%s] Service discovery refused, err=%d", this->address_str_, discover_err);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
// Deliberate teardown: its report must not charge the backoff again.
|
||||
this->disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
bool was_connected = this->state_ == State::CONNECTED;
|
||||
bool cancelled = this->cancel_requested_;
|
||||
this->cancel_requested_ = false;
|
||||
this->state_ = State::IDLE;
|
||||
if (was_connected) {
|
||||
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_disconnected();
|
||||
}
|
||||
// Continuations leave the backend's event-drain stack first.
|
||||
this->defer([this]() { this->disconnect_callbacks_.call(); });
|
||||
} else {
|
||||
if (cancelled) {
|
||||
// status carries the refusal code when the teardown settled
|
||||
// synchronously; 0 on a backend-completed cancel.
|
||||
ESP_LOGD(TAG, "[%s] Connect attempt cancelled, status=%d", this->address_str_, error);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
}
|
||||
this->defer([this]() { this->connect_failed_callbacks_.call(); });
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_service_discovery_done(int error) {
|
||||
if (this->state_ != State::DISCOVERING || this->cancel_requested_) {
|
||||
// Raced a teardown; the terminal report settles the link.
|
||||
ESP_LOGD(TAG, "[%s] Discovery completed during teardown, ignoring", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
// The teardown is deliberate: do not charge the backoff again for its
|
||||
// connection report.
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
ble_device_base::GattServiceTable table{};
|
||||
if (!this->nodes_.empty()) {
|
||||
// Materialize only when a node will read it: a client with no nodes
|
||||
// would pay the build/free cycle on every (re)connect for nothing.
|
||||
table = this->backend_->get_service_table();
|
||||
if (table.service_count == 0) {
|
||||
// A failed materialization is indistinguishable from a service-less
|
||||
// peer, and a real GATT peer always exposes at least GAP/GATT: fail
|
||||
// the discovery before CONNECTED so the teardown resolves through
|
||||
// connect_failed, never a spurious on_disconnect.
|
||||
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// CONNECTED before the fan-out so nodes may consult connected() from
|
||||
// their own on_connected().
|
||||
this->state_ = State::CONNECTED;
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_connected(table);
|
||||
if (this->state_ != State::CONNECTED || this->cancel_requested_) {
|
||||
// A node tore the link down mid-fan-out: on_disconnect fires with no
|
||||
// preceding on_connect, so leave a trace of why.
|
||||
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->backend_->release_services();
|
||||
this->backoff_.reset();
|
||||
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
|
||||
this->defer([this]() { this->connect_callbacks_.call(); });
|
||||
}
|
||||
|
||||
void BLEClient::on_write_result(uint16_t handle, int error) {
|
||||
if (error != 0) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str_, handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_write_result(handle, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
|
||||
if (error != 0) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str_, handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_read_result(handle, data, len, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
// Every node sees every notification and filters by handle (legacy parity).
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_notify(handle, data, len);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_notify_state(uint16_t handle, bool enabled, int error) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str_,
|
||||
enabled ? "enable" : "disable", handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_notify_state(handle, enabled, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_pairing_result(int status) {
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Pairing failed, status=%d", this->address_str_, status);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "[%s] Paired", this->address_str_);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_pairing_result(status);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE Client:\n"
|
||||
" Address: %s\n"
|
||||
" Auto connect: %s",
|
||||
this->address_str_, YESNO(this->auto_connect_));
|
||||
if (this->enabled && this->state_ == State::IDLE) {
|
||||
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -0,0 +1,151 @@
|
||||
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
|
||||
//
|
||||
// Compiled on every platform with a GATT backend except esp32, which keeps
|
||||
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
|
||||
// family migrates - the exclusive gates make the same class names resolve to
|
||||
// exactly one definition per build, so codegen is shared.
|
||||
//
|
||||
// Connects are sighting-gated like the legacy engine: the client is a parsed
|
||||
// advertisement listener, captures the peer's address type from the sighting,
|
||||
// and asks the backend to connect only when enabled and idle.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include "ble_client_node.h"
|
||||
#include "connect_backoff.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClient : public Component,
|
||||
public ble_device_base::ESPBTDeviceListener,
|
||||
public ble_device_base::GattClientListener {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
// Public field for legacy parity (the switch platform republishes it).
|
||||
bool enabled{true};
|
||||
|
||||
void set_backend(ble_device_base::BLEGattConnection *backend) {
|
||||
this->backend_ = backend;
|
||||
backend->set_listener(this);
|
||||
}
|
||||
void set_address(uint64_t address);
|
||||
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
|
||||
void set_enabled(bool enabled);
|
||||
const char *address_str() const { return this->address_str_; }
|
||||
|
||||
void register_ble_node(BLEClientNode *node);
|
||||
// One registration spelling shared with the esp32 engine's bridge.
|
||||
void register_gatt_node(BLEClientNode *node) { this->register_ble_node(node); }
|
||||
|
||||
// An accepted teardown already owns the link: the pending report cannot be
|
||||
// a connect. state_ stays put so that report still fires on_disconnect.
|
||||
bool connected() const { return this->state_ == State::CONNECTED && !this->cancel_requested_; }
|
||||
bool idle() const { return this->state_ == State::IDLE; }
|
||||
|
||||
/// Action-initiated connect (no sighting needed; uses the last captured
|
||||
/// address type, public until a sighting arrives). No-op unless idle.
|
||||
void connect();
|
||||
void disconnect();
|
||||
|
||||
/// Legacy-named deferral used by the automation twins: neutral listener
|
||||
/// callbacks run inside the backend's event drain, so automation chain
|
||||
/// continuations must leave that stack first.
|
||||
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
|
||||
|
||||
// Backend ops for nodes and actions - the frozen node-facing surface.
|
||||
// Only write_characteristic has an in-tree caller; subscribing means
|
||||
// notify_characteristic plus a CCCD write_descriptor (the caller's job
|
||||
// per the contract).
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
return this->backend_->write_characteristic(handle, data, len, response);
|
||||
}
|
||||
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
|
||||
int read_descriptor(uint16_t handle) { return this->backend_->read_descriptor(handle); }
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
return this->backend_->write_descriptor(handle, data, len);
|
||||
}
|
||||
int notify_characteristic(uint16_t handle, bool enable) {
|
||||
return this->backend_->notify_characteristic(handle, enable);
|
||||
}
|
||||
int pair() { return this->backend_->pair(); }
|
||||
int unpair() { return bluetooth_connection::unpair_device(this->address_); }
|
||||
|
||||
// Automation callback registration.
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
// Fired when a connect attempt dies before being established; the user
|
||||
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
|
||||
template<typename F> void add_on_connect_failed_callback(F &&callback) {
|
||||
this->connect_failed_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
// ---- ble_device_base::ESPBTDeviceListener ----
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
|
||||
// ---- ble_device_base::GattClientListener ----
|
||||
void on_connection_state(bool connected, uint16_t mtu, int error) override;
|
||||
void on_service_discovery_done(int error) override;
|
||||
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
|
||||
void on_write_result(uint16_t handle, int error) override;
|
||||
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
|
||||
void on_notify_state(uint16_t handle, bool enabled, int error) override;
|
||||
void on_pairing_result(int status) override;
|
||||
|
||||
protected:
|
||||
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
|
||||
|
||||
void attempt_connect_();
|
||||
|
||||
// Group 1: pointers / containers
|
||||
ble_device_base::BLEGattConnection *backend_{nullptr};
|
||||
// Codegen-sized (ESPHOME_BLE_CLIENT_MAX_NODES); filled during setup.
|
||||
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> nodes_;
|
||||
|
||||
// Group 2: 8-byte types
|
||||
uint64_t address_{0};
|
||||
|
||||
// Group 3: callback managers (pointer-sized when empty)
|
||||
LazyCallbackManager<void()> connect_callbacks_;
|
||||
LazyCallbackManager<void()> disconnect_callbacks_;
|
||||
LazyCallbackManager<void()> connect_failed_callbacks_;
|
||||
|
||||
// Group 4: 4-byte types
|
||||
// Backoff so an undiscoverable database or a dead peer cannot produce a
|
||||
// battery-draining connect loop.
|
||||
ConnectBackoff backoff_;
|
||||
|
||||
// Group 5: arrays
|
||||
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
|
||||
|
||||
// Group 6: 1-byte types
|
||||
State state_{State::IDLE};
|
||||
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
|
||||
// Distinguishes a captured public type from the never-sighted default.
|
||||
bool address_type_known_{false};
|
||||
bool auto_connect_{true};
|
||||
// A user-initiated teardown in flight; its failure report is not a
|
||||
// connect failure and must not feed the backoff.
|
||||
bool cancel_requested_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -0,0 +1,68 @@
|
||||
// The single BLEClientNode both ble_client engines share. The neutral
|
||||
// callback surface is the one interface node components build on; the raw
|
||||
// esp32 surface below it remains for components that have not migrated yet.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#endif
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClient;
|
||||
|
||||
class BLEClientNode {
|
||||
public:
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Neutral surface, delivered by both engines. The table is borrowed: copy
|
||||
// handles during on_connected(). All nodes see all completions; filter by
|
||||
// handle.
|
||||
// A node that disconnects from inside on_connected() aborts the fan-out;
|
||||
// the user's on_disconnect may then fire without a preceding on_connect.
|
||||
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
|
||||
virtual void on_disconnected() {}
|
||||
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
|
||||
// One in-flight registration per handle; its completion fans out to every
|
||||
// node, so a refused duplicate request still sees on_notify_state.
|
||||
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
|
||||
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
|
||||
virtual void on_write_result(uint16_t handle, int error) {}
|
||||
virtual void on_pairing_result(int status) {}
|
||||
#endif
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
// Legacy raw surface; components overriding these need the legacy engine
|
||||
// until migrated to the neutral surface above.
|
||||
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {}
|
||||
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
|
||||
virtual void loop() {}
|
||||
// This should be transitioned to Established once the node no longer needs
|
||||
// the services/descriptors/characteristics of the parent client. This will
|
||||
// allow some memory to be freed.
|
||||
// The parent frees the peer's GATT cache once every node reports Established.
|
||||
// Never report Established while an operation that reads that cache is outstanding.
|
||||
// - esp_ble_gattc_register_for_notify() completes asynchronously.
|
||||
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
|
||||
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
|
||||
esp32_ble_tracker::ClientState node_state;
|
||||
#endif
|
||||
|
||||
BLEClient *parent() const { return this->parent_; }
|
||||
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
|
||||
|
||||
protected:
|
||||
BLEClient *parent_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
@@ -0,0 +1,172 @@
|
||||
// The ble_client.ble_write action: a node on the platform-neutral interface,
|
||||
// so one implementation serves both engines (the esp32 bridge and the
|
||||
// neutral engine).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
// One of the two engine headers resolves per build.
|
||||
#include "ble_client.h"
|
||||
#include "ble_client_gatt.h"
|
||||
#include "ble_client_node.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
static const char *const BLE_WRITE_TAG = "ble_client.automation";
|
||||
|
||||
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
|
||||
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
|
||||
|
||||
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientWriteAction(BLEClient *ble_client) {
|
||||
ble_client->register_gatt_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
|
||||
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->value_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
// Store pointer to static data in flash (no RAM copy)
|
||||
void set_value_simple(const uint8_t *data, size_t len) {
|
||||
this->value_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->var_ = std::make_tuple(x...);
|
||||
|
||||
bool result;
|
||||
if (this->len_ >= 0) {
|
||||
result = this->write(this->value_.data, this->len_);
|
||||
} else {
|
||||
std::vector<uint8_t> value = this->value_.func(x...);
|
||||
result = this->write(value.data(), value.size());
|
||||
}
|
||||
|
||||
// on write failure, continue the automation chain rather than stopping so
|
||||
// that e.g. disconnect can work.
|
||||
if (!result)
|
||||
this->play_next_(x...);
|
||||
}
|
||||
|
||||
// Initiate the write; the completion arrives in on_write_result. The
|
||||
// response-less path can complete synchronously inside the call, so the
|
||||
// handle is armed before the backend is touched.
|
||||
bool write(const uint8_t *data, size_t len) {
|
||||
if (!this->ble_client_->connected()) {
|
||||
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
|
||||
return false;
|
||||
}
|
||||
if (!this->resolved_) {
|
||||
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
|
||||
return false;
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
|
||||
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
|
||||
#endif
|
||||
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
|
||||
if (err != 0) {
|
||||
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void on_connected(const ble_device_base::GattServiceTable &table) override {
|
||||
// Resolution comes from this table only; a re-discovery must not keep a
|
||||
// stale handle.
|
||||
this->resolved_ = false;
|
||||
this->char_handle_ = 0;
|
||||
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
|
||||
const auto *chr =
|
||||
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
|
||||
if (chr == nullptr) {
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
char service_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
|
||||
this->service_uuid_.to_str(service_buf));
|
||||
return;
|
||||
}
|
||||
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
|
||||
this->write_response_ = true;
|
||||
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
|
||||
this->write_response_ = false;
|
||||
} else {
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
|
||||
return;
|
||||
}
|
||||
this->char_handle_ = chr->value_handle;
|
||||
this->resolved_ = true;
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
|
||||
this->ble_client_->address_str());
|
||||
}
|
||||
|
||||
void on_disconnected() override {
|
||||
this->resolved_ = false;
|
||||
this->char_handle_ = 0;
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
}
|
||||
|
||||
void on_write_result(uint16_t handle, int error) override {
|
||||
if (this->num_running_ == 0) {
|
||||
return;
|
||||
}
|
||||
if (!this->resolved_ || handle != this->char_handle_) {
|
||||
// A parked chain waiting on a completion that never matches would
|
||||
// otherwise stall silently until disconnect.
|
||||
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
|
||||
this->char_handle_);
|
||||
return;
|
||||
}
|
||||
if (error != 0) {
|
||||
// Continue the chain (legacy parity) but leave a breadcrumb.
|
||||
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
|
||||
}
|
||||
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Value {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} value_;
|
||||
ble_device_base::ESPBTUUID service_uuid_;
|
||||
ble_device_base::ESPBTUUID char_uuid_;
|
||||
std::tuple<Ts...> var_{};
|
||||
uint16_t char_handle_{};
|
||||
bool write_response_{false};
|
||||
bool resolved_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_CLIENT_GATT_NODES
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
/// Reconnect backoff after repeated connect/discovery failures, shared by
|
||||
/// both engines. 256 ms ticks in a uint16_t keep it 4 bytes; the ~4.7 h tick
|
||||
/// wrap can at worst reinstate one stale hold-off of a minute.
|
||||
class ConnectBackoff {
|
||||
public:
|
||||
bool holding_off() const {
|
||||
return this->failures_ != 0 && static_cast<uint16_t>(now() - this->start_) < this->failures_ * STEP_TICKS;
|
||||
}
|
||||
void register_failure(const char *address_str) {
|
||||
if (this->failures_ < MAX_STEPS)
|
||||
this->failures_++;
|
||||
this->start_ = now();
|
||||
esph_log_w("ble_client", "[%s] Holding off reconnect for %u s", address_str, this->failures_ * 10u);
|
||||
}
|
||||
void reset() { this->failures_ = 0; }
|
||||
|
||||
private:
|
||||
// ~10 s per consecutive failure, capped so a flapping peer retries within
|
||||
// a minute at worst.
|
||||
static constexpr uint16_t STEP_TICKS = 40; // x 256 ms
|
||||
static constexpr uint8_t MAX_STEPS = 6;
|
||||
static uint16_t now() { return static_cast<uint16_t>(millis() >> 8); }
|
||||
|
||||
uint16_t start_{0};
|
||||
uint8_t failures_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "ble_gatt_client.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_device_base");
|
||||
|
||||
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid) {
|
||||
// 32-bit range math: a corrupt first/count pair cannot wrap past the check.
|
||||
uint32_t end = uint32_t(service.first_characteristic) + service.characteristic_count;
|
||||
if (end > table.characteristic_count) {
|
||||
ESP_LOGW(TAG, "characteristic range out of bounds");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint32_t i = service.first_characteristic; i < end; i++) {
|
||||
if (table.characteristics[i].uuid == uuid)
|
||||
return &table.characteristics[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
|
||||
const ESPBTUUID &uuid) {
|
||||
uint32_t end = uint32_t(characteristic.first_descriptor) + characteristic.descriptor_count;
|
||||
if (end > table.descriptor_count) {
|
||||
// Corrupt range, not a missing descriptor.
|
||||
ESP_LOGW(TAG, "descriptor range out of bounds");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint32_t i = characteristic.first_descriptor; i < end; i++) {
|
||||
if (table.descriptors[i].uuid == uuid)
|
||||
return &table.descriptors[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
|
||||
const GattDescriptor *desc = find_descriptor(table, characteristic, ESPBTUUID::from_uint16(CCCD_UUID));
|
||||
return desc != nullptr ? desc->handle : 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -11,13 +11,16 @@
|
||||
// interface. All listener calls are delivered on the ESPHome main loop;
|
||||
// borrowed data pointers are valid only for the duration of the call.
|
||||
//
|
||||
// Error domain (plain int, forwarded to the API without translation):
|
||||
// Error domain (plain int, forwarded to the API without translation, so the
|
||||
// values are wire-frozen - API clients interpret them):
|
||||
// 0 success
|
||||
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
|
||||
// 1..0x11 ATT error codes (Bluetooth spec) - reserved; a backend whose
|
||||
// native error codes land in this window must remap them out
|
||||
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
|
||||
// ESP_FAIL from the stack shares this value; both read as a
|
||||
// failed, unusable connection on the client side)
|
||||
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
|
||||
// -1..-15 reserved for future contract sentinels
|
||||
// anything else: platform stack error/status code, surfaced opaquely.
|
||||
// Connection events carry HCI status/disconnect reason codes (same code
|
||||
// space on every controller).
|
||||
@@ -99,9 +102,23 @@ class GattClientListener {
|
||||
// The BLEGattConnection op surface, asserted where the alias binds
|
||||
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
|
||||
// through the listener) or a synchronous error (busy, not connected, stack
|
||||
// rejection); one operation may be outstanding at a time. Semantics beyond
|
||||
// the signatures:
|
||||
// rejection); one operation may be outstanding at a time. A connection-state
|
||||
// or discovery completion always arrives from the event loop, never
|
||||
// synchronously from inside the op call - a synchronous terminal
|
||||
// on_connection_state from within gatt_disconnect() would re-enter the
|
||||
// consumer mid-teardown. A local-only completion may instead report inline,
|
||||
// before the op returns: notify_characteristic (a subscription-list edit on
|
||||
// the rp2 backend) and a write without response whose payload the stack took
|
||||
// immediately. Consumers must therefore arm whatever state their
|
||||
// on_write_result/on_notify_state reads before issuing the op, never after
|
||||
// the call returns. Semantics beyond the signatures:
|
||||
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
|
||||
// Returning 0 means the request is accepted, not that the radio acted: the
|
||||
// backend owns integration with its platform's scan/connect arbitration
|
||||
// (Bluedroid parks the request for the tracker's promote loop, which owns
|
||||
// scan-stop/coex/one-connect-at-a-time; the rp2 backend opens immediately
|
||||
// and relies on sighting-gated consumers). Consumers must not assume
|
||||
// connect timing.
|
||||
// - gatt_disconnect: also cancels a connect in progress (named to coexist
|
||||
// with a platform stack's own void disconnect() on one backend class).
|
||||
// Nonzero means nothing to tear down and no completion will follow; an
|
||||
@@ -143,6 +160,41 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
|
||||
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
|
||||
};
|
||||
|
||||
// ---- service table lookup helpers ----
|
||||
//
|
||||
// Neutral, bounds-checked walks over a materialized GattServiceTable for
|
||||
// direct consumers that resolve a known device's handles by UUID (streaming
|
||||
// consumers forward the raw database and never need these). Linear search:
|
||||
// the table exists only between discovery and release_services(), for one
|
||||
// small known device.
|
||||
|
||||
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
|
||||
static constexpr uint16_t CCCD_UUID = 0x2902;
|
||||
|
||||
// Characteristic property bits (the Bluetooth-spec declaration byte carried
|
||||
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
|
||||
// exist on the other platforms).
|
||||
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
|
||||
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
|
||||
|
||||
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
|
||||
for (uint16_t i = 0; i < table.service_count; i++) {
|
||||
if (table.services[i].uuid == uuid)
|
||||
return &table.services[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid);
|
||||
|
||||
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
|
||||
const ESPBTUUID &uuid);
|
||||
|
||||
/// Handle of the characteristic's Client Characteristic Configuration
|
||||
/// descriptor (0x2902), or 0 when it has none.
|
||||
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic);
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Per-platform GATT connection backends and the helpers to embed one.
|
||||
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the
|
||||
Bluetooth proxy's codegen declares and registers the backend instances
|
||||
through gatt_client_schema()/hub_connection_schema() + new_gatt_backend().
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
|
||||
consumer's codegen declares and registers the backend instances — the
|
||||
Bluetooth proxy through its per-slot connection wrappers (a streaming
|
||||
consumer), and the neutral ble_client through gatt_client_schema() +
|
||||
new_gatt_backend().
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import rp2040_ble
|
||||
@@ -23,7 +25,8 @@ from esphome.types import ConfigType
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
"""ble_device_base plus the platform BLE stack the build's backend
|
||||
registers with (the Bluedroid header includes the tracker's), so
|
||||
consumers need not know. The platform-less arm serves manifest tooling."""
|
||||
consumers stay platform-blind. The platform-less arm serves tooling that
|
||||
resolves the manifest without a target."""
|
||||
if CORE.is_esp32:
|
||||
return ["ble_device_base", "esp32_ble_tracker"]
|
||||
if CORE.is_rp2:
|
||||
@@ -63,6 +66,8 @@ DOMAIN = "bluetooth_connection"
|
||||
@dataclass
|
||||
class _ConnectionData:
|
||||
rp2_backend_count: int = 0
|
||||
# GATT connection slots claimed this run, for the platform cap check.
|
||||
slot_consumers: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> _ConnectionData:
|
||||
@@ -119,6 +124,10 @@ class _PlatformBackend:
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
|
||||
# Selects the backend's alias-ladder arm (order-independent arms).
|
||||
define: str
|
||||
# The backend's on-demand materializer gate, when it has one.
|
||||
materializer_define: str | None = None
|
||||
|
||||
|
||||
# The single registry of platforms with a GATT client backend; a platform
|
||||
@@ -126,11 +135,20 @@ class _PlatformBackend:
|
||||
# platform's arm.
|
||||
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
|
||||
PLATFORM_ESP32: _PlatformBackend(
|
||||
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
|
||||
BluedroidGattClient,
|
||||
_esp32_schema_fragment,
|
||||
_esp32_register,
|
||||
"USE_BLE_GATT_BACKEND_BLUEDROID",
|
||||
materializer_define="USE_BLUEDROID_GATT_SERVICE_TABLE",
|
||||
),
|
||||
PLATFORM_RP2: _PlatformBackend(
|
||||
RP2GattClient, _rp2_schema_fragment, _rp2_register, "USE_BLE_GATT_BACKEND_RP2"
|
||||
),
|
||||
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
|
||||
}
|
||||
|
||||
# Gates dedicated-backend consumers (cv.only_on).
|
||||
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
|
||||
|
||||
|
||||
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
|
||||
key = platform if platform is not None else CORE.target_platform
|
||||
@@ -165,21 +183,97 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
|
||||
)
|
||||
|
||||
|
||||
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
def _charge_esp32_budget(count: int, consumer: str, config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble
|
||||
|
||||
esp32_ble.consume_connection_slots(count, consumer)(config)
|
||||
|
||||
|
||||
def _charge_rp2_budget(count: int, consumer: str, config: ConfigType) -> None:
|
||||
rp2040_ble.consume_connection_slots(count, consumer)(config)
|
||||
|
||||
|
||||
# Platforms whose BLE stack owns its own connection budget: the claim is
|
||||
# charged there and that stack's final validation is the one place an
|
||||
# overcommit is reported (never two messages for one misconfiguration). One
|
||||
# mapping drives both, so a platform can never be skipped without a charge.
|
||||
_STACK_BUDGETS: dict[str, Callable[[int, str, ConfigType], None]] = {
|
||||
PLATFORM_ESP32: _charge_esp32_budget,
|
||||
PLATFORM_RP2: _charge_rp2_budget,
|
||||
}
|
||||
|
||||
|
||||
def consume_gatt_slot(
|
||||
consumer: str, count: int = 1
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Validator claiming GATT connection slots - the one spelling for every
|
||||
claimant. The neutral ledger covers backend platforms without a stack
|
||||
budget (the cap check in FINAL_VALIDATE_SCHEMA)."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
_get_data().slot_consumers.extend([consumer] * count)
|
||||
if (charge := _STACK_BUDGETS.get(CORE.target_platform)) is not None:
|
||||
charge(count, consumer, config)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _validate_slot_totals(config: ConfigType) -> ConfigType:
|
||||
# Skipped in testing mode so grouped component builds can co-exist
|
||||
# (mirrors esp32_ble.validate_connection_slots).
|
||||
if CORE.testing_mode:
|
||||
return config
|
||||
if CORE.target_platform in _STACK_BUDGETS:
|
||||
return config
|
||||
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
|
||||
# Any backend platform without a stack budget must carry a cap here
|
||||
# or fail loudly, never fail open.
|
||||
if CORE.target_platform in _PLATFORM_BACKENDS:
|
||||
raise cv.Invalid(
|
||||
f"{CORE.target_platform} has a GATT backend but no slot cap "
|
||||
"in HUB_MAX_CONNECTIONS"
|
||||
)
|
||||
return config
|
||||
claimed = _get_data().slot_consumers
|
||||
if len(claimed) > cap:
|
||||
raise cv.Invalid(
|
||||
f"{CORE.target_platform} supports at most {cap} GATT client "
|
||||
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
|
||||
|
||||
|
||||
async def new_gatt_backend(
|
||||
config: ConfigType, *, service_table: bool = True
|
||||
) -> cg.MockObj:
|
||||
"""Instantiate the backend declared by gatt_client_schema() and register
|
||||
it with its platform stack. The connection slot is claimed at validation
|
||||
(the proxy's slot validators), not here.
|
||||
(the consume_gatt_slot validators), not here.
|
||||
|
||||
service_table is honored by the Bluedroid backend only: forward
|
||||
scaffolding for the first esp32 direct consumer, load-bearing on no
|
||||
current build (rp2 ignores the define and always materializes - its
|
||||
proxy hub streams through get_service_table(), so it must keep the
|
||||
materializer regardless of the flag).
|
||||
"""
|
||||
from esphome.components import ble_device_base
|
||||
|
||||
entry = _backend_entry()
|
||||
ble_device_base.request_gatt_client()
|
||||
cg.add_define(entry.define)
|
||||
if service_table and entry.materializer_define is not None:
|
||||
cg.add_define(entry.materializer_define)
|
||||
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
|
||||
# The backend is the slot's real Component: component keys from the
|
||||
# connection entry (setup_priority, ...) apply to it. Consumers whose own
|
||||
# schema carries keys that register_component would misapply to the
|
||||
# backend (e.g. a polling interval) must not put them in this config.
|
||||
await cg.register_component(backend, config)
|
||||
await _backend_entry().register(backend, config)
|
||||
await entry.register(backend, config)
|
||||
return backend
|
||||
|
||||
|
||||
@@ -187,6 +281,7 @@ async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
# list (this module cannot import bluetooth_proxy to derive it).
|
||||
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
|
||||
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
|
||||
"gatt_service_table_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
|
||||
# Every hub platform the proxy admits (the file compiles empty where
|
||||
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
|
||||
# cannot hit a missing-symbol trap here.
|
||||
|
||||
@@ -46,7 +46,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
|
||||
@@ -65,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
|
||||
@@ -49,15 +49,16 @@ static constexpr conn_err_t CONN_OK = 0;
|
||||
// GATT contract so backend and wrapper cannot drift.
|
||||
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
|
||||
// What the platform's connection backend supports beyond GATT operations;
|
||||
// the proxy derives its feature flags and legacy version from these.
|
||||
#if defined(USE_ESP32)
|
||||
// What the build's connection backend supports beyond GATT operations; the
|
||||
// proxy derives its feature flags and legacy version from these. Keyed on
|
||||
// the backend define, never the platform, so a second backend on one
|
||||
// platform carries its own facts.
|
||||
#if defined(USE_BLE_GATT_BACKEND_BLUEDROID)
|
||||
static constexpr bool SUPPORTS_PAIRING = true;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
|
||||
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
#elif defined(USE_BLE_GATT_BACKEND_RP2)
|
||||
// The rp2 BTstack backend pairs (just works + bonding); it has no service
|
||||
// cache to clear. Keyed on the backend, not the generic client define, so a
|
||||
// future backend without pairing keeps the stub arm below.
|
||||
// cache to clear.
|
||||
static constexpr bool SUPPORTS_PAIRING = true;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#else
|
||||
@@ -65,13 +66,14 @@ static constexpr bool SUPPORTS_PAIRING = false;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#endif
|
||||
|
||||
// Address-scoped (not connection-scoped) maintenance requests.
|
||||
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
// Address-scoped (not connection-scoped) maintenance requests; keyed on the
|
||||
// stack (the calls need no backend instance).
|
||||
#if (defined(USE_ESP32_BLE) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t unpair_device(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
#endif
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t clear_gatt_cache(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
// The in-place streamer serves the proxy's service-discovery API; backend-only
|
||||
// builds compile without the proxy headers or the streamer.
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
#include "bluetooth_connection.h"
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -345,6 +346,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
|
||||
|
||||
void BluedroidGattClient::release_services() {
|
||||
this->service_total_ = 0;
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
this->table_.free();
|
||||
#endif
|
||||
// Always set: terminates any in-flight stream on every cache config.
|
||||
this->services_released_ = true;
|
||||
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
|
||||
@@ -357,6 +361,24 @@ void BluedroidGattClient::release_services() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
|
||||
// Lifetime: every teardown path (CLOSE_EVT, the safety timeout, stack-down,
|
||||
// passive DISCONNECT) routes through release_services(), so a materialized
|
||||
// table cannot outlive its link.
|
||||
if (this->table_.empty() &&
|
||||
(this->services_released_ || this->service_total_ == 0 ||
|
||||
!this->table_.build(this->gattc_if_, this->conn_id_, this->service_total_, this->connection_index_))) {
|
||||
// Released / no services / failed build all collapse to empty; the
|
||||
// build failures warned above, log the quiet two.
|
||||
ESP_LOGD(TAG, "[%d] No service table (released=%d, services=%u)", this->connection_index_, this->services_released_,
|
||||
this->service_total_);
|
||||
return {};
|
||||
}
|
||||
return this->table_.view();
|
||||
}
|
||||
#endif // USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
|
||||
@@ -403,6 +425,11 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
|
||||
// ---- service streaming ----
|
||||
|
||||
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
// Re-discovery moves the counts the table view derives offsets from; free
|
||||
// the stale table.
|
||||
this->table_.free();
|
||||
#endif
|
||||
// Step down from the fast discovery params.
|
||||
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
|
||||
if (status != ESP_GATT_OK) {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
#include "gatt_service_table_bluedroid.h"
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -76,11 +79,16 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
int notify_characteristic(uint16_t handle, bool enable);
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
// Contract stub: the proxy streams in place; the on-demand materializer
|
||||
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
|
||||
// this stub gets an empty table indistinguishable from a service-less
|
||||
// peer - do not ship one against this backend before the materializer.
|
||||
// On-demand table for direct consumers; the proxy streams instead, so the
|
||||
// materializer compiles only under USE_BLUEDROID_GATT_SERVICE_TABLE (emitted by
|
||||
// direct-consumer codegen, never by the proxy).
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
#else
|
||||
// A direct consumer reaching this stub misconfigured its codegen
|
||||
// (service_table=False): the empty table reads as a service-less peer.
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
#endif
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
@@ -110,6 +118,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
|
||||
// Group 1: pointers / composed objects
|
||||
ble_device_base::GattClientListener *listener_{nullptr};
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
BluedroidServiceTable table_;
|
||||
#endif
|
||||
// Group 2: 4-byte types
|
||||
uint32_t disconnecting_started_{0};
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
|
||||
#if defined(USE_RP2040_BLE)
|
||||
// Arms are keyed on codegen-emitted per-backend defines (_PLATFORM_BACKENDS
|
||||
// in __init__.py), so they are order-independent.
|
||||
#if defined(USE_BLE_GATT_BACKEND_RP2)
|
||||
#include "bluetooth_connection_rp2.h"
|
||||
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
|
||||
#elif defined(USE_ESP32_BLE)
|
||||
#elif defined(USE_BLE_GATT_BACKEND_BLUEDROID)
|
||||
#include "bluetooth_connection_bluedroid.h"
|
||||
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
|
||||
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "gatt_service_table_bluedroid.h"
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "bluetooth_connection");
|
||||
|
||||
// A stack that never reports end-of-range would otherwise walk forever.
|
||||
static constexpr uint16_t MAX_DESCRIPTORS_PER_CHARACTERISTIC = 64;
|
||||
|
||||
// Shared enumeration for both build passes: an identical walk order is what
|
||||
// lets the counting pass size the block the filling pass fills.
|
||||
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool BluedroidServiceTable::walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
|
||||
for (uint16_t s = 0; s < this->service_total_; s++) {
|
||||
esp_gattc_service_elem_t svc;
|
||||
uint16_t svc_count = 1;
|
||||
auto svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s);
|
||||
if (svc_status != ESP_GATT_OK || svc_count == 0) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_service", svc_status);
|
||||
return false;
|
||||
}
|
||||
if (!on_service(s, svc)) {
|
||||
return false;
|
||||
}
|
||||
uint16_t svc_chars = 0;
|
||||
auto count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
|
||||
svc.start_handle, svc.end_handle, 0, &svc_chars);
|
||||
if (count_status != ESP_GATT_OK) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_attr_count", count_status);
|
||||
return false;
|
||||
}
|
||||
for (uint16_t c = 0; c < svc_chars; c++) {
|
||||
esp_gattc_char_elem_t chr;
|
||||
uint16_t char_count = 1;
|
||||
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
|
||||
&char_count, c);
|
||||
if (status != ESP_GATT_OK || char_count == 0) {
|
||||
// An early terminator contradicts svc_chars from the same cache;
|
||||
// never build a silently truncated table.
|
||||
this->log_walk_warning_("esp_ble_gattc_get_all_char", status);
|
||||
return false;
|
||||
}
|
||||
if (!on_char(svc, chr)) {
|
||||
return false;
|
||||
}
|
||||
for (uint16_t d = 0;; d++) {
|
||||
if (d == MAX_DESCRIPTORS_PER_CHARACTERISTIC) {
|
||||
// A stack that never reports end-of-range; fail like every other
|
||||
// inconsistency instead of truncating the table silently.
|
||||
ESP_LOGW(TAG, "[%d] Descriptor walk exceeded %u entries", this->log_index_,
|
||||
MAX_DESCRIPTORS_PER_CHARACTERISTIC);
|
||||
return false;
|
||||
}
|
||||
esp_gattc_descr_elem_t desc;
|
||||
uint16_t desc_count = 1;
|
||||
auto desc_status =
|
||||
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
|
||||
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
|
||||
break;
|
||||
}
|
||||
if (desc_status != ESP_GATT_OK || desc_count == 0) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_all_descr", desc_status);
|
||||
return false;
|
||||
}
|
||||
if (!on_desc(chr, desc)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidServiceTable::count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total) {
|
||||
uint16_t primary = 0;
|
||||
uint16_t secondary = 0;
|
||||
if (esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001, 0xFFFF, 0, &primary) !=
|
||||
ESP_GATT_OK ||
|
||||
esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary) !=
|
||||
ESP_GATT_OK) {
|
||||
// A failed count must not read as an authoritative empty database.
|
||||
return false;
|
||||
}
|
||||
*total = primary + secondary;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidServiceTable::build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index) {
|
||||
this->free();
|
||||
this->gattc_if_ = gattc_if;
|
||||
this->conn_id_ = conn_id;
|
||||
this->service_total_ = service_total;
|
||||
this->log_index_ = log_index;
|
||||
|
||||
// Pass 1: count, so one exact-size block holds the whole table.
|
||||
uint16_t char_total = 0;
|
||||
uint16_t desc_total = 0;
|
||||
bool counted = this->walk_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
|
||||
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
|
||||
char_total++;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
|
||||
desc_total++;
|
||||
return true;
|
||||
});
|
||||
if (!counted) {
|
||||
ESP_LOGW(TAG, "[%d] Service table walk failed during count", this->log_index_);
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
|
||||
// The arrays share one block; carving stays aligned because each struct's
|
||||
// strictest member is the UUID and array sizes are multiples of it.
|
||||
static_assert(alignof(ble_device_base::GattService) >= alignof(ble_device_base::GattCharacteristic) &&
|
||||
alignof(ble_device_base::GattCharacteristic) >= alignof(ble_device_base::GattDescriptor));
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
|
||||
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
this->storage_ = allocator.allocate(total_bytes);
|
||||
if (this->storage_ == nullptr) {
|
||||
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->log_index_,
|
||||
static_cast<unsigned>(total_bytes));
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->storage_);
|
||||
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes);
|
||||
auto *descriptors = reinterpret_cast<ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes);
|
||||
|
||||
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
|
||||
// means the cached database changed between the passes; fail the build
|
||||
// rather than serve an inconsistent table (the consumer retries).
|
||||
uint16_t char_index = 0;
|
||||
uint16_t desc_index = 0;
|
||||
ble_device_base::GattService *cur_service = nullptr;
|
||||
ble_device_base::GattCharacteristic *cur_char = nullptr;
|
||||
bool filled = this->walk_(
|
||||
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
|
||||
cur_service = &services[s];
|
||||
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
|
||||
cur_service->start_handle = svc.start_handle;
|
||||
cur_service->end_handle = svc.end_handle;
|
||||
cur_service->first_characteristic = char_index;
|
||||
cur_service->characteristic_count = 0;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
|
||||
if (char_index >= char_total) {
|
||||
return false;
|
||||
}
|
||||
cur_char = &characteristics[char_index++];
|
||||
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
|
||||
cur_char->value_handle = chr.char_handle;
|
||||
// Bluedroid addresses descriptors by characteristic handle, so the
|
||||
// table's end_handle only needs the service-bounded upper bound.
|
||||
cur_char->end_handle = svc.end_handle;
|
||||
cur_char->properties = chr.properties;
|
||||
cur_char->first_descriptor = desc_index;
|
||||
cur_char->descriptor_count = 0;
|
||||
cur_service->characteristic_count++;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
|
||||
if (desc_index >= desc_total) {
|
||||
return false;
|
||||
}
|
||||
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
|
||||
descriptors[desc_index].handle = desc.handle;
|
||||
desc_index++;
|
||||
cur_char->descriptor_count++;
|
||||
return true;
|
||||
});
|
||||
if (!filled || char_index != char_total || desc_index != desc_total) {
|
||||
// Walk error or the database changed between passes; better an empty
|
||||
// table than a corrupt one.
|
||||
ESP_LOGW(TAG, "[%d] Service table walk mismatch, discarding", this->log_index_);
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
this->char_total_ = char_total;
|
||||
this->desc_total_ = desc_total;
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluedroidServiceTable::log_walk_warning_(const char *operation, int code) {
|
||||
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->log_index_, operation, code);
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
@@ -0,0 +1,82 @@
|
||||
// Owning two-pass materializer of one Bluedroid GATT database snapshot into
|
||||
// the neutral GattServiceTable layout, shared by the BluedroidGattClient
|
||||
// backend and ble_client's esp32 engine.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
class BluedroidServiceTable {
|
||||
public:
|
||||
~BluedroidServiceTable() { this->free(); }
|
||||
// Owns storage_; a copy would double-free. The default constructor is user
|
||||
// provided, not "= default", so value-initializing the enclosing backend
|
||||
// cannot zero-fill .bss that is already zero.
|
||||
BluedroidServiceTable() {}
|
||||
BluedroidServiceTable(const BluedroidServiceTable &) = delete;
|
||||
BluedroidServiceTable &operator=(const BluedroidServiceTable &) = delete;
|
||||
|
||||
/// The service count build() requires: the stack's PRIMARY+SECONDARY
|
||||
/// attribute totals, never the SEARCH_RES event count.
|
||||
static bool count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total);
|
||||
|
||||
/// Two-pass build from the stack's cached database (service_total from
|
||||
/// count_services()). log_index labels warnings. Frees any previous table
|
||||
/// first; on failure the table is left empty.
|
||||
bool build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index);
|
||||
|
||||
// The view is carved from the storage block and the counts on each call
|
||||
// (a cold path) rather than cached, saving a per-instance table member.
|
||||
ble_device_base::GattServiceTable view() const {
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = this->char_total_ * sizeof(ble_device_base::GattCharacteristic);
|
||||
return {reinterpret_cast<const ble_device_base::GattService *>(this->storage_),
|
||||
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes),
|
||||
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes),
|
||||
this->service_total_,
|
||||
this->char_total_,
|
||||
this->desc_total_};
|
||||
}
|
||||
|
||||
// Always resets the counts: a failed build must never leave a non-zero
|
||||
// service_total_ behind a null table.
|
||||
void free() {
|
||||
if (this->storage_ != nullptr) {
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
allocator.deallocate(this->storage_, 0);
|
||||
this->storage_ = nullptr;
|
||||
}
|
||||
this->service_total_ = 0;
|
||||
this->char_total_ = 0;
|
||||
this->desc_total_ = 0;
|
||||
}
|
||||
|
||||
bool empty() const { return this->storage_ == nullptr; }
|
||||
|
||||
private:
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
|
||||
void log_walk_warning_(const char *operation, int code);
|
||||
|
||||
uint8_t *storage_{nullptr};
|
||||
uint16_t service_total_{0};
|
||||
uint16_t char_total_{0};
|
||||
uint16_t desc_total_{0};
|
||||
// Walk context, set by build().
|
||||
uint16_t conn_id_{0};
|
||||
esp_gatt_if_t gattc_if_{}; // uint8_t width
|
||||
uint8_t log_index_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
@@ -94,10 +94,20 @@ def _esp32_config_schema() -> cv.All:
|
||||
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
|
||||
|
||||
def validate_connections(config: ConfigType) -> ConfigType:
|
||||
if CONF_CONNECTIONS in config or not config[CONF_ACTIVE]:
|
||||
if CONF_CONNECTIONS in config:
|
||||
# Explicit entries claim slots like the generated ones; dev
|
||||
# historically skipped this, letting an explicit-connections
|
||||
# config evade the controller budget.
|
||||
bluetooth_connection.consume_gatt_slot(
|
||||
"bluetooth_proxy", len(config[CONF_CONNECTIONS])
|
||||
)(config)
|
||||
return config
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
|
||||
@@ -149,14 +159,14 @@ def _rp2_config_schema() -> cv.All:
|
||||
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
|
||||
|
||||
def populate_connections(config: ConfigType) -> ConfigType:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
# One wrapper + backend pair per slot, declared during validation so
|
||||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||||
@@ -206,7 +216,9 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||||
# sends those requests and their handlers and encoders are dead.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
for connection_conf in connections:
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
backend = await bluetooth_connection.new_gatt_backend(
|
||||
connection_conf, service_table=False
|
||||
)
|
||||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
cg.add(connection.set_backend(backend))
|
||||
cg.add(var.register_connection(connection))
|
||||
|
||||
@@ -2,18 +2,24 @@ import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SOURCE_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import Number
|
||||
from .. import Number, number_ns
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
|
||||
}
|
||||
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(NumberSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
# Number has the same float state callback as Sensor
|
||||
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
async def to_code(config):
|
||||
source = await cg.get_variable(config[CONF_SOURCE_ID])
|
||||
var = await sensor.new_sensor(config, source)
|
||||
await cg.register_component(var, config)
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "number_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::number {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "number.sensor");
|
||||
|
||||
void NumberSensor::setup() {
|
||||
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
|
||||
if (this->source_->has_state())
|
||||
this->publish_state(this->source_->state);
|
||||
}
|
||||
|
||||
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
|
||||
|
||||
} // namespace esphome::number
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "../number.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::number {
|
||||
|
||||
class NumberSensor final : public sensor::Sensor, public Component {
|
||||
public:
|
||||
explicit NumberSensor(Number *source) : source_(source) {}
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
Number *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::number
|
||||
@@ -8,8 +8,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <new>
|
||||
|
||||
#ifdef USE_ESP_IDF
|
||||
#include "esp_task_wdt.h"
|
||||
#endif
|
||||
@@ -96,38 +94,7 @@ static int draw_callback(JPEGDRAW *jpeg) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
|
||||
RAMAllocator<JPEGDEC> allocator;
|
||||
JPEGDEC *jpeg = allocator.allocate(1);
|
||||
if (jpeg == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
|
||||
return;
|
||||
}
|
||||
this->jpeg_ = new (jpeg) JPEGDEC();
|
||||
}
|
||||
|
||||
JpegDecoder::~JpegDecoder() {
|
||||
if (this->jpeg_ != nullptr) {
|
||||
this->jpeg_->~JPEGDEC();
|
||||
RAMAllocator<JPEGDEC> allocator;
|
||||
allocator.deallocate(this->jpeg_, 1);
|
||||
}
|
||||
}
|
||||
|
||||
int JpegDecoder::prepare(size_t expected_size) {
|
||||
// Check before the base prepare() so a failure never leaves an active session
|
||||
if (this->jpeg_ == nullptr) {
|
||||
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
return ImageDecoder::prepare(expected_size);
|
||||
}
|
||||
|
||||
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
if (this->jpeg_ == nullptr) {
|
||||
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
// JPEG decoder requires complete data
|
||||
// If we know the expected size, wait for it
|
||||
if (this->expected_size_ > 0 && size < this->expected_size_) {
|
||||
@@ -138,22 +105,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
// If size unknown, try to decode and see if it's valid
|
||||
// The JPEGDEC library will fail gracefully if data is incomplete
|
||||
|
||||
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
|
||||
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
|
||||
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
|
||||
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
}
|
||||
const int err = this->start_();
|
||||
if (err != 0)
|
||||
return err;
|
||||
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
|
||||
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
this->decoded_bytes_ = size;
|
||||
this->jpeg_->close();
|
||||
this->jpeg_.close();
|
||||
return size;
|
||||
}
|
||||
|
||||
int JpegDecoder::start_() {
|
||||
auto jpeg_type = this->jpeg_->getJPEGType();
|
||||
auto jpeg_type = this->jpeg_.getJPEGType();
|
||||
if (jpeg_type == JPEG_MODE_INVALID) {
|
||||
ESP_LOGE(TAG, "Unsupported JPEG image");
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
@@ -161,12 +128,11 @@ int JpegDecoder::start_() {
|
||||
ESP_LOGE(TAG, "Progressive JPEG images not supported");
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
|
||||
this->jpeg_->getBpp());
|
||||
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
|
||||
|
||||
this->jpeg_->setUserPointer(this);
|
||||
const int width = this->jpeg_->getWidth();
|
||||
const int height = this->jpeg_->getHeight();
|
||||
this->jpeg_.setUserPointer(this);
|
||||
const int width = this->jpeg_.getWidth();
|
||||
const int height = this->jpeg_.getHeight();
|
||||
// The tables for drawing are only built once the decoded size is known, below
|
||||
if (!this->resize_image_(width, height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
@@ -189,17 +155,17 @@ int JpegDecoder::start_() {
|
||||
if (this->direct_rgb565_) {
|
||||
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
|
||||
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
|
||||
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
|
||||
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
|
||||
} else {
|
||||
this->jpeg_->setPixelType(RGB8888);
|
||||
this->jpeg_.setPixelType(RGB8888);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int JpegDecoder::fail_() {
|
||||
const bool read_failed = this->source_ != nullptr && this->source_failed_;
|
||||
const int error = this->jpeg_->getLastError();
|
||||
this->jpeg_->close();
|
||||
const int error = this->jpeg_.getLastError();
|
||||
this->jpeg_.close();
|
||||
this->source_ = nullptr;
|
||||
if (read_failed) {
|
||||
ESP_LOGE(TAG, "Reading the image failed");
|
||||
@@ -223,7 +189,7 @@ int JpegDecoder::fail_() {
|
||||
void JpegDecoder::reset() {
|
||||
// A decode from a source that was given up part way still has the image open
|
||||
if (this->source_ != nullptr) {
|
||||
this->jpeg_->close();
|
||||
this->jpeg_.close();
|
||||
this->source_ = nullptr;
|
||||
}
|
||||
ImageDecoder::reset();
|
||||
@@ -252,10 +218,6 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
|
||||
}
|
||||
|
||||
int JpegDecoder::begin_source(DecodeSource *source) {
|
||||
if (this->jpeg_ == nullptr) {
|
||||
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
|
||||
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
@@ -266,19 +228,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
||||
}
|
||||
this->source_ = source;
|
||||
this->source_failed_ = false;
|
||||
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
|
||||
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
|
||||
// The caller may recover, for example by reading the whole image first, so it reports the failure
|
||||
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
|
||||
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
|
||||
this->source_ = nullptr;
|
||||
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
|
||||
}
|
||||
const int err = this->start_();
|
||||
if (err != 0) {
|
||||
this->jpeg_->close();
|
||||
this->jpeg_.close();
|
||||
this->source_ = nullptr;
|
||||
return err;
|
||||
}
|
||||
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
|
||||
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
return 0;
|
||||
}
|
||||
@@ -286,7 +248,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
||||
int HOT JpegDecoder::decode_step() {
|
||||
if (this->source_ == nullptr)
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
const int result = this->jpeg_->decodeRows(1);
|
||||
const int result = this->jpeg_.decodeRows(1);
|
||||
// JPEGDEC may decode on with the missing data, so check the read before its result
|
||||
if (this->source_failed_)
|
||||
return this->fail_();
|
||||
@@ -297,7 +259,7 @@ int HOT JpegDecoder::decode_step() {
|
||||
if (result == 0)
|
||||
return this->fail_();
|
||||
this->decoded_bytes_ = this->expected_size_;
|
||||
this->jpeg_->close();
|
||||
this->jpeg_.close();
|
||||
this->source_ = nullptr;
|
||||
return DECODE_STEP_DONE;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder {
|
||||
*
|
||||
* @param image The RuntimeImage to decode the stream into.
|
||||
*/
|
||||
JpegDecoder(RuntimeImage *image);
|
||||
~JpegDecoder() override;
|
||||
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
|
||||
~JpegDecoder() override {}
|
||||
|
||||
int prepare(size_t expected_size) override;
|
||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||
void reset() override;
|
||||
|
||||
@@ -47,8 +46,7 @@ class JpegDecoder : public ImageDecoder {
|
||||
static int32_t source_seek(JPEGFILE *file, int32_t position);
|
||||
static void source_close(void *handle) {}
|
||||
|
||||
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
|
||||
JPEGDEC *jpeg_{nullptr};
|
||||
JPEGDEC jpeg_{};
|
||||
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
||||
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
||||
bool source_failed_{false};
|
||||
|
||||
@@ -2,22 +2,29 @@ import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SOURCE_ID
|
||||
from esphome.core.entity_helpers import FollowEntity
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import Switch
|
||||
from .. import Switch, switch_ns
|
||||
|
||||
CODEOWNERS = ["@ssieb"]
|
||||
|
||||
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
|
||||
}
|
||||
SwitchBinarySensor = switch_ns.class_(
|
||||
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
CONFIG_SCHEMA = (
|
||||
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
await cg.register_component(var, config)
|
||||
|
||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "switch_binary_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::switch_ {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
|
||||
|
||||
void SwitchBinarySensor::setup() {
|
||||
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
|
||||
|
||||
} // namespace esphome::switch_
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "../switch.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
|
||||
namespace esphome::switch_ {
|
||||
|
||||
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
|
||||
public:
|
||||
void set_source(Switch *source) { source_ = source; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
Switch *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::switch_
|
||||
@@ -4,16 +4,23 @@ import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SOURCE_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import Text
|
||||
from .. import Text, text_ns
|
||||
|
||||
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
|
||||
}
|
||||
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
text_sensor.text_sensor_schema(TextTextSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await text_sensor.new_text_sensor(config)
|
||||
# Text has the same string state callback as TextSensor
|
||||
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
source = await cg.get_variable(config[CONF_SOURCE_ID])
|
||||
var = await text_sensor.new_text_sensor(config, source)
|
||||
await cg.register_component(var, config)
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "text_text_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::text {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
|
||||
|
||||
void TextTextSensor::setup() {
|
||||
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
|
||||
if (this->source_->has_state())
|
||||
this->publish_state(this->source_->state);
|
||||
}
|
||||
|
||||
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
|
||||
|
||||
} // namespace esphome::text
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "../text.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
namespace esphome::text {
|
||||
|
||||
class TextTextSensor final : public text_sensor::TextSensor, public Component {
|
||||
public:
|
||||
explicit TextTextSensor(Text *source) : source_(source) {}
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
Text *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::text
|
||||
@@ -492,6 +492,11 @@
|
||||
#define USE_ESP32_BLE_TRACKER
|
||||
#define USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
|
||||
#define USE_BLE_GATT_CLIENT
|
||||
#define USE_BLE_GATT_BACKEND_BLUEDROID
|
||||
#define USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
#define USE_BLE_CLIENT_GATT_NODES
|
||||
#define USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
|
||||
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
|
||||
#define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1
|
||||
#define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1
|
||||
@@ -744,7 +749,10 @@
|
||||
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
|
||||
#define USE_BLE_SCAN_RESPONSE_MERGER
|
||||
#define USE_BLE_GATT_CLIENT
|
||||
#define USE_BLE_GATT_BACKEND_RP2
|
||||
#define USE_BLE_CLIENT_GATT_NODES
|
||||
#define ESPHOME_BLE_GATT_CLIENT_COUNT 3
|
||||
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
|
||||
#define USE_RP2040_VARIANT_RP2040
|
||||
#define USE_RP2_WATCHDOG_TIMEOUT 8000
|
||||
#define USE_SPI
|
||||
|
||||
@@ -483,6 +483,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
// Not reached timeout yet, done for this call
|
||||
break;
|
||||
}
|
||||
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
||||
if (this->is_item_failed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if item is marked for removal
|
||||
// This handles two cases:
|
||||
// 1. Item was marked for removal after cleanup_() but before we got here
|
||||
@@ -507,13 +514,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
||||
if (this->is_item_failed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
continue;
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_DEBUG_SCHEDULER
|
||||
{
|
||||
SchedulerNameLog name_log;
|
||||
|
||||
@@ -19,9 +19,21 @@ from esphome.const import (
|
||||
CONF_NOTIFY,
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TYPE,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def esp32_platform(set_core_config: SetCoreConfigCallable) -> None:
|
||||
# The raw-gattc node family gates through BLE_CLIENT_SCHEMA's
|
||||
# _legacy_engine_only choke point; these schema tests exercise the esp32 arm.
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
|
||||
DESCRIPTOR_CONFIG: ConfigType = {
|
||||
CONF_NAME: "test",
|
||||
CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
@@ -84,3 +96,72 @@ def test_on_notify_implies_notify() -> None:
|
||||
def test_notify_unchanged_without_on_notify() -> None:
|
||||
config: ConfigType = {CONF_NOTIFY: False}
|
||||
assert notify_from_on_notify(config)[CONF_NOTIFY] is False
|
||||
|
||||
|
||||
def test_legacy_node_choke_point_rejects_other_platforms(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
from esphome.components import ble_client
|
||||
from esphome.core import ID
|
||||
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
with pytest.raises(cv.Invalid, match="not been migrated"):
|
||||
ble_client._legacy_engine_only(ID("x"))
|
||||
# Through the public schema too, so removing the cv.All wiring fails here.
|
||||
with pytest.raises(cv.Invalid, match="not been migrated"):
|
||||
ble_client.BLE_CLIENT_SCHEMA({})
|
||||
|
||||
|
||||
def test_neutral_arm_rejects_esp32_only_keys(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# Pins the schema split's rejection side: the legacy-only keys must not
|
||||
# leak into the neutral arm. The hub is registered so the extra key is
|
||||
# the only error - without it the missing-tracker error would satisfy
|
||||
# the raises vacuously.
|
||||
from esphome.components import ble_client, ble_device_base
|
||||
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
ble_device_base.register_hub_provider("rp2_ble_tracker")
|
||||
CORE.loaded_integrations.add("rp2_ble_tracker")
|
||||
for key in ("name", "on_passkey_request", "on_passkey_notification"):
|
||||
with pytest.raises(cv.Invalid, match="extra keys not allowed"):
|
||||
ble_client.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF", key: "x"})
|
||||
|
||||
|
||||
def test_security_actions_reject_platforms_without_the_feature(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
from esphome.components import ble_client
|
||||
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
for schema in (
|
||||
ble_client.BLE_PASSKEY_REPLY_ACTION_SCHEMA,
|
||||
ble_client.BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA,
|
||||
ble_client.BLE_REMOVE_BOND_ACTION_SCHEMA,
|
||||
):
|
||||
with pytest.raises(cv.Invalid, match="'security' feature, which rp2"):
|
||||
schema({})
|
||||
|
||||
|
||||
def test_node_schema_passes_on_every_gatt_platform(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# The neutral node schema carries no engine gate: raw_gattc components
|
||||
# stay choked, gatt_node components validate wherever ble_client does.
|
||||
from esphome.components import ble_client
|
||||
|
||||
for pf in (PlatformFramework.ESP32_IDF, PlatformFramework.RP2_ARDUINO):
|
||||
set_core_config(pf)
|
||||
assert ble_client.NODE_BLE_CLIENT_SCHEMA({})
|
||||
|
||||
|
||||
def test_feature_error_names_the_available_features(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
from esphome.components import ble_client
|
||||
from esphome.core import ID
|
||||
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
with pytest.raises(cv.Invalid, match="provides: gatt_node"):
|
||||
ble_client._legacy_engine_only(ID("x"))
|
||||
Whitespace-only changes.
@@ -0,0 +1,79 @@
|
||||
"""Tests for the cross-component GATT slot ledger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import (
|
||||
ble_client,
|
||||
ble_device_base,
|
||||
bluetooth_connection,
|
||||
bluetooth_proxy,
|
||||
rp2040_ble,
|
||||
)
|
||||
from esphome.const import CONF_MAC_ADDRESS, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def test_gatt_slot_ledger_rejects_overcommit_on_rp2(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# rp2 owns its budget: the stack's validation reports the overcommit and
|
||||
# the neutral cap check stays silent (one message per misconfiguration).
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
|
||||
bluetooth_connection.consume_gatt_slot("ble_client")({})
|
||||
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
|
||||
with pytest.raises(cv.Invalid, match="rp2 maximum is 3"):
|
||||
rp2040_ble.validate_connection_slots()
|
||||
|
||||
|
||||
def test_gatt_slot_ledger_skipped_in_testing_mode(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# Grouped component builds merge fixtures past the cap; the check defers
|
||||
# to testing mode like esp32_ble.validate_connection_slots.
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
|
||||
bluetooth_connection.consume_gatt_slot("ble_client")({})
|
||||
CORE.testing_mode = True
|
||||
try:
|
||||
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
|
||||
rp2040_ble.validate_connection_slots()
|
||||
finally:
|
||||
CORE.testing_mode = False
|
||||
|
||||
|
||||
def test_real_validators_charge_the_ledger_on_rp2(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# End to end through the component CONFIG_SCHEMAs (no hand charges):
|
||||
# removing either consumer's consume_gatt_slot call fails this test.
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
ble_device_base.register_hub_provider("rp2_ble_tracker")
|
||||
CORE.loaded_integrations.add("rp2_ble_tracker")
|
||||
bluetooth_proxy.CONFIG_SCHEMA({})
|
||||
ble_client.CONFIG_SCHEMA({CONF_MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"})
|
||||
# The proxy defaults to 3 slots on rp2; ble_client's claim overcommits
|
||||
# and rp2's own budget names every claimant.
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match="Components: bluetooth_proxy, bluetooth_proxy, bluetooth_proxy, "
|
||||
"ble_client",
|
||||
):
|
||||
rp2040_ble.validate_connection_slots()
|
||||
|
||||
|
||||
def test_neutral_cap_check_guards_future_hub_platforms(
|
||||
set_core_config: SetCoreConfigCallable, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Both current platforms defer to their stack budgets; pin the message
|
||||
# and boundary of the branch a future budget-less hub platform takes.
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
monkeypatch.setattr(bluetooth_connection, "_STACK_BUDGETS", {})
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
|
||||
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
|
||||
bluetooth_connection.consume_gatt_slot("ble_client")({})
|
||||
with pytest.raises(cv.Invalid, match="supports at most 3 GATT client connection"):
|
||||
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
|
||||
@@ -186,6 +186,18 @@ def test_rp2_rejects_esp32_only_keys_by_name(
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
|
||||
|
||||
|
||||
def test_esp32_explicit_connections_claim_gatt_slots(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# Explicit `connections:` entries must charge the slot ledger like the
|
||||
# generated ones; dev historically let them evade the budget.
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"active": True, "connections": [{}, {}]})
|
||||
# Exact match (one entry per slot): catches a missed charge and a
|
||||
# double charge alike.
|
||||
assert bluetooth_connection._get_data().slot_consumers == ["bluetooth_proxy"] * 2
|
||||
|
||||
|
||||
def test_hub_source_filter_covers_every_hub_platform() -> None:
|
||||
# bluetooth_connection cannot import this module to derive the hub.cpp
|
||||
# framework set, so pin it here: a platform admitted to the proxy but
|
||||
@@ -229,6 +241,12 @@ def test_every_registered_hub_platform_has_a_schema_arm() -> None:
|
||||
# Hub platforms must also be in the backend registry the shared codegen
|
||||
# helpers dispatch on.
|
||||
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
|
||||
# Every non-esp32 backend platform must carry a slot cap: without one the
|
||||
# ledger's FINAL_VALIDATE accepts unlimited claims silently (esp32's cap
|
||||
# is the controller budget in esp32_ble).
|
||||
assert set(bluetooth_connection._PLATFORM_BACKENDS) - {"esp32"} <= set(
|
||||
bluetooth_connection.HUB_MAX_CONNECTIONS
|
||||
)
|
||||
# The outer walkable schema's bound must stay the loosest platform cap.
|
||||
assert (
|
||||
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
ble_client:
|
||||
- mac_address: 01:02:03:04:05:06
|
||||
id: test_blec
|
||||
on_connect:
|
||||
then:
|
||||
- ble_client.ble_write:
|
||||
id: test_blec
|
||||
service_uuid: '1802'
|
||||
characteristic_uuid: '2a06'
|
||||
value: [0x04, 0x05, 0x06]
|
||||
on_disconnect:
|
||||
then:
|
||||
# Reconnect from the disconnect trigger: the connect action must
|
||||
# survive the same dispatch that started it, so the log still runs.
|
||||
- ble_client.connect: test_blec
|
||||
- logger.log: reconnected
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Connect button
|
||||
on_press:
|
||||
- ble_client.connect: test_blec
|
||||
- ble_client.ble_write:
|
||||
id: test_blec
|
||||
service_uuid: '1802'
|
||||
characteristic_uuid: '2a06'
|
||||
value: !lambda return {0x01, 0x02};
|
||||
- ble_client.disconnect: test_blec
|
||||
@@ -0,0 +1,6 @@
|
||||
# The neutral engine: the BTstack backend and rp2040_ble come in through
|
||||
# bluetooth_connection's auto-load; the tracker hub supplies the sightings.
|
||||
packages:
|
||||
common: !include common-gatt.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
@@ -10,6 +10,9 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# and the listener vector it dispatches into (codegen-sized by consumers).
|
||||
async def to_code_testing(config):
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
# The gatt contract test exercises the gated lookup helpers; compile
|
||||
# their definitions (ble_gatt_client.cpp) into the test build.
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
|
||||
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
|
||||
|
||||
|
||||
@@ -79,4 +79,54 @@ TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
|
||||
EXPECT_EQ(table.descriptor_count, 0);
|
||||
}
|
||||
|
||||
// A radon_eye_rd200-shaped table: two services, the second holding a
|
||||
// notifying characteristic with a CCCD and a bare write characteristic.
|
||||
class ServiceTableLookup : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->services_[0] = {ESPBTUUID::from_uint16(0x1800), 0x0001, 0x0005, 0, 1};
|
||||
this->services_[1] = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 1, 2};
|
||||
this->characteristics_[0] = {ESPBTUUID::from_uint16(0x2A00), 0x0003, 0x0003, 0x02, 0, 0};
|
||||
this->characteristics_[1] = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 1};
|
||||
this->characteristics_[2] = {ESPBTUUID::from_uint16(0x1524), 0x0016, 0x0016, 0x04, 1, 0};
|
||||
this->descriptors_[0] = {ESPBTUUID::from_uint16(0x2902), 0x0013};
|
||||
this->table_ = {this->services_, this->characteristics_, this->descriptors_, 2, 3, 1};
|
||||
}
|
||||
|
||||
GattService services_[2];
|
||||
GattCharacteristic characteristics_[3];
|
||||
GattDescriptor descriptors_[1];
|
||||
GattServiceTable table_;
|
||||
};
|
||||
|
||||
TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) {
|
||||
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
|
||||
ASSERT_NE(service, nullptr);
|
||||
EXPECT_EQ(service->start_handle, 0x0010);
|
||||
EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr);
|
||||
|
||||
const GattCharacteristic *characteristic =
|
||||
find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
|
||||
ASSERT_NE(characteristic, nullptr);
|
||||
EXPECT_EQ(characteristic->value_handle, 0x0012);
|
||||
// The lookup is scoped to the service: 0x2A00 lives in the other service.
|
||||
EXPECT_EQ(find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x2A00)), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(ServiceTableLookup, FindsTheCccdAndReportsItsAbsence) {
|
||||
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
|
||||
const GattCharacteristic *notify_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
|
||||
EXPECT_EQ(find_cccd(this->table_, *notify_char), 0x0013);
|
||||
const GattCharacteristic *write_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1524));
|
||||
EXPECT_EQ(find_cccd(this->table_, *write_char), 0);
|
||||
}
|
||||
|
||||
TEST_F(ServiceTableLookup, RejectsRangesThatOverrunTheTable) {
|
||||
// A corrupt index range must fail the lookup, not walk out of bounds.
|
||||
GattService bad_service = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 2, 5};
|
||||
EXPECT_EQ(find_characteristic(this->table_, bad_service, ESPBTUUID::from_uint16(0x1524)), nullptr);
|
||||
GattCharacteristic bad_char = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 9};
|
||||
EXPECT_EQ(find_cccd(this->table_, bad_char), 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -1,5 +1,6 @@
|
||||
# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is
|
||||
# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the
|
||||
# compiled (USE_BLE_GATT_CLIENT unset), which pins the
|
||||
# USE_BLUETOOTH_PROXY_CONNECTIONS gating and the
|
||||
# address-scoped maintenance path that a connections build never exercises.
|
||||
# Under batch grouping the active default build is what runs; the standalone
|
||||
# compile of this fixture is what exercises the passive gating.
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace esphome::core::testing {
|
||||
@@ -43,40 +41,4 @@ TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) {
|
||||
scheduler.trim_freelist();
|
||||
}
|
||||
|
||||
// A cancelled timeout of a component that has since failed has to be counted as removed when call()
|
||||
// reaches it, or the leftover count makes the next few cancellations trigger a full cleanup early.
|
||||
TEST(Scheduler, CancelledItemOfFailedComponentIsCountedAsRemoved) {
|
||||
Scheduler scheduler;
|
||||
SchedulerTestComponent healthy;
|
||||
SchedulerTestComponent failing;
|
||||
|
||||
// The healthy item is due first, so cleanup_() stops at it and the cancelled item is only reached in the run loop
|
||||
scheduler.set_timeout(&healthy, "first", 1, []() {});
|
||||
scheduler.set_timeout(&failing, "cancelled", 2, []() {});
|
||||
scheduler.process_to_add();
|
||||
scheduler.cancel_timeout(&failing, "cancelled");
|
||||
failing.mark_failed();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
scheduler.call(millis());
|
||||
|
||||
// Four cancelled items behind a live one stay below the full cleanup threshold of five, so their callbacks are
|
||||
// released only once they reach the top of the heap
|
||||
auto token = std::make_shared<int>(0);
|
||||
scheduler.set_timeout(&healthy, "keep", 10000, []() {});
|
||||
static constexpr const char *NAMES[] = {"c0", "c1", "c2", "c3"};
|
||||
for (const char *name : NAMES)
|
||||
scheduler.set_timeout(&healthy, name, 20000, [token]() {});
|
||||
scheduler.process_to_add();
|
||||
for (const char *name : NAMES)
|
||||
scheduler.cancel_timeout(&healthy, name);
|
||||
ASSERT_EQ(token.use_count(), 5);
|
||||
scheduler.call(millis());
|
||||
EXPECT_EQ(token.use_count(), 5);
|
||||
|
||||
scheduler.cancel_timeout(&healthy, "keep");
|
||||
scheduler.call(millis());
|
||||
EXPECT_EQ(token.use_count(), 1);
|
||||
scheduler.trim_freelist();
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -1,39 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
|
||||
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state is handed back before the base
|
||||
// destructor runs, so the teardown stays with JpegDecoder.
|
||||
class StatelessJpegDecoder : public JpegDecoder {
|
||||
public:
|
||||
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
|
||||
this->jpeg_ = nullptr;
|
||||
}
|
||||
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
|
||||
|
||||
protected:
|
||||
JPEGDEC *state_;
|
||||
};
|
||||
|
||||
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
|
||||
TEST(JpegOutOfMemory, DecoderStateIsAllocatedSeparately) { EXPECT_LT(sizeof(JpegDecoder), sizeof(JPEGDEC) / 4); }
|
||||
|
||||
TEST(JpegOutOfMemory, MissingStateFailsWithoutStartingASession) {
|
||||
RuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
|
||||
StatelessJpegDecoder decoder(&img);
|
||||
EXPECT_EQ(decoder.prepare(100), DECODE_ERROR_OUT_OF_MEMORY);
|
||||
EXPECT_FALSE(decoder.is_active());
|
||||
uint8_t data[4] = {};
|
||||
EXPECT_EQ(decoder.decode(data, sizeof(data)), DECODE_ERROR_OUT_OF_MEMORY);
|
||||
EXPECT_EQ(decoder.begin_source(nullptr), DECODE_ERROR_OUT_OF_MEMORY);
|
||||
decoder.reset();
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image::testing
|
||||
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
@@ -1,56 +0,0 @@
|
||||
esphome:
|
||||
name: test-source-entity-follow
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
id: source_switch
|
||||
name: Source Switch
|
||||
optimistic: true
|
||||
restore_mode: ALWAYS_ON
|
||||
# Publishes nothing until commanded
|
||||
- platform: template
|
||||
id: quiet_switch
|
||||
name: Quiet Switch
|
||||
optimistic: true
|
||||
restore_mode: DISABLED
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
id: source_number
|
||||
name: Source Number
|
||||
optimistic: true
|
||||
min_value: 0
|
||||
max_value: 10
|
||||
step: 0.5
|
||||
initial_value: 4
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
id: source_text
|
||||
name: Source Text
|
||||
optimistic: true
|
||||
mode: text
|
||||
initial_value: hello
|
||||
|
||||
binary_sensor:
|
||||
- platform: switch
|
||||
source_id: source_switch
|
||||
name: Switch State
|
||||
- platform: switch
|
||||
source_id: quiet_switch
|
||||
name: Quiet Switch State
|
||||
|
||||
sensor:
|
||||
- platform: number
|
||||
source_id: source_number
|
||||
name: Number Value
|
||||
|
||||
text_sensor:
|
||||
- platform: text
|
||||
source_id: source_text
|
||||
name: Text Value
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Integration test for the switch binary sensor, number sensor and text text sensor platforms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import (
|
||||
BinarySensorState,
|
||||
EntityState,
|
||||
NumberInfo,
|
||||
SensorState,
|
||||
SwitchInfo,
|
||||
TextInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
INITIAL: dict[str, tuple[type[EntityState], float | bool | str | None]] = {
|
||||
"switch_state": (BinarySensorState, True),
|
||||
# A switch that has not published leaves its binary sensor unknown
|
||||
"quiet_switch_state": (BinarySensorState, None),
|
||||
"number_value": (SensorState, 4.0),
|
||||
"text_value": (TextSensorState, "hello"),
|
||||
}
|
||||
UPDATED: dict[str, tuple[type[EntityState], float | bool | str | None]] = {
|
||||
"switch_state": (BinarySensorState, False),
|
||||
"quiet_switch_state": (BinarySensorState, True),
|
||||
"number_value": (SensorState, 6.5),
|
||||
"text_value": (TextSensorState, "bye"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_entity_follow(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Each entity holds its source's state from boot, then follows it."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
keys = {
|
||||
name: require_entity(entities, name, exact=True).key for name in INITIAL
|
||||
}
|
||||
switch = require_entity(entities, "source_switch", SwitchInfo)
|
||||
quiet = require_entity(entities, "quiet_switch", SwitchInfo)
|
||||
number = require_entity(entities, "source_number", NumberInfo)
|
||||
text = require_entity(entities, "source_text", TextInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
for name, (cls, state) in INITIAL.items():
|
||||
key = keys[name]
|
||||
expected = (
|
||||
cls(key=key, missing_state=True)
|
||||
if state is None
|
||||
else cls(key=key, state=state)
|
||||
)
|
||||
assert sub.initial_states[key] == expected, name
|
||||
|
||||
names = list(UPDATED)
|
||||
waits = [sub.waiter.next_state(keys[name]) for name in names]
|
||||
client.switch_command(switch.key, False)
|
||||
client.switch_command(quiet.key, True)
|
||||
client.number_command(number.key, 6.5)
|
||||
client.text_command(text.key, "bye")
|
||||
for name, got in zip(names, await asyncio.gather(*waits), strict=True):
|
||||
cls, state = UPDATED[name]
|
||||
assert got == cls(key=keys[name], state=state), name
|
||||
Reference in new issue
Block a user