mirror of
https://github.com/esphome/esphome.git
synced 2026-10-26 16:02:13 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37e046a52 | ||
|
|
3246311daa | ||
|
|
659efd5fcb | ||
|
|
bb3f175fa7 | ||
|
|
223022e0a6 | ||
|
|
2d37ad6836 | ||
|
|
d2f6b389d4 | ||
|
|
875b2076c0 | ||
|
|
3b0a1ba339 | ||
|
|
c21660251c | ||
|
|
995acb94b4 | ||
|
|
24b7eefaad | ||
|
|
92655743c1 | ||
|
|
2349e0be00 | ||
|
|
63349801b7 | ||
|
|
afdfa7b298 | ||
|
|
5f79e92c14 | ||
|
|
400407b135 | ||
|
|
22b32b194a | ||
|
|
6dcd989a7f | ||
|
|
2016884210 | ||
|
|
d3f4f44c7b | ||
|
|
6df15be714 | ||
|
|
a03fe3c38e | ||
|
|
afc8c51f93 | ||
|
|
ef7ed69f9d | ||
|
|
ec32802662 | ||
|
|
433d8046b1 | ||
|
|
8ecb981656 | ||
|
|
8c990573d7 | ||
|
|
bdec0c1485 | ||
|
|
70e85d939d | ||
|
|
25b3c2820a | ||
|
|
ef53e675ff |
No files matched your search
@@ -67,9 +67,17 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
automations in the config itself.
|
||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
||||
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
|
||||
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
|
||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
||||
no IP addresses.
|
||||
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
|
||||
|
||||
```
|
||||
> [!NOTE]
|
||||
> Automated testing results
|
||||
```
|
||||
|
||||
Then one short sentence saying what was tested (builds and SHAs, board, what was driven), a results table
|
||||
(build SHA, board and framework, what was driven, counts, result, plus the `dev` control row), short bullets
|
||||
for the findings, and the setup and exact flow in a collapsed `<details>` block. Answer any question asked on
|
||||
the PR directly in the comment. No credentials, SSIDs, IP addresses, port names or other bench details.
|
||||
|
||||
## Flashing by chip family
|
||||
|
||||
@@ -115,7 +123,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame).
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
|
||||
@@ -125,6 +125,11 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
|
||||
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
|
||||
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
|
||||
- Moving a BK7231N device (CB3S) that sits on its fallback AP to another network: join the host's Wi-Fi to
|
||||
`<name>-fallback`, OTA to 192.168.4.1, then rejoin the host's own network. Moving it with `/wifisave` instead can
|
||||
leave it in the captive portal's BLIND_RETRY state (no scan, `Beacon Timeout` every 46 s) until it reboots, and a
|
||||
log-only serial adapter cannot reset it. Rejoining may change the host's address; update a syslog target that
|
||||
points at the old one.
|
||||
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
||||
radio enable 160 to 278 ms, disable about 200 ms.
|
||||
|
||||
@@ -172,6 +177,12 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
|
||||
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
|
||||
a never-saved value.
|
||||
- Thread: nRF52 `openthread:` accepts only `tlv:`; the individual keys (network_key, channel, ...) are rejected at
|
||||
validation. To list nearby networks with channel and PAN ID, call
|
||||
`otThreadDiscover(InstanceLock::acquire().get_instance(), 0, 0xFFFF, false, false, cb, nullptr)` from an
|
||||
`interval:` lambda in an included header; results arrive on the OpenThread thread and log fine.
|
||||
- A wrong network key on a real network's channel and PAN ID makes the device form its own partition as leader on
|
||||
that PAN within about 30 s; take it off Thread at once. Never try guessed datasets against a network in use.
|
||||
|
||||
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ One file per component, named after it:
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
|
||||
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
|
||||
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
|
||||
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark
|
||||
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
|
||||
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
@@ -38,3 +38,6 @@ One file per component, named after it:
|
||||
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
|
||||
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
|
||||
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
|
||||
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
|
||||
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
|
||||
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
|
||||
@@ -0,0 +1,17 @@
|
||||
# BLE terminate with peripheral latency (#20232)
|
||||
|
||||
`scripts/ble_terminate`: an ESP32-S3 `bluetooth_proxy` (active) over Ethernet as the central (`btproxy.yaml`) and a
|
||||
classic devkit `esp32_ble_server` peer (`btpeer.yaml`). The peer asks for 50 ms / latency 10 / 6 s with
|
||||
`esp_ble_gap_update_conn_params` 4 s after connect; earlier requests are overwritten by the proxy's own "medium
|
||||
conn params" after service discovery. Read the proxy's `Conn params updated` line (VERBOSE, API logs) to confirm;
|
||||
the peer side never logs the event. The proxy only connects to a device its scanner has seen, so `btdrive.py`
|
||||
subscribes to raw advertisements first.
|
||||
|
||||
Measure the peer's own disconnect (`serial_capture.py --ms`) after the client's disconnect: about 0.2 to 0.8 s when
|
||||
the terminate arrived, about 6 s (the supervision timeout) when it was lost. The client side disconnect time is the
|
||||
API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
|
||||
|
||||
`run.sh LABEL ROUNDS` writes `central_bda.hpp` from `PROXY_BT_MAC`, builds and flashes both boards, runs the rounds
|
||||
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
|
||||
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
|
||||
latency and counts as inconclusive).
|
||||
@@ -0,0 +1,12 @@
|
||||
# Fake I2C slave (#21586)
|
||||
|
||||
`scripts/i2c_slave`: a D1 mini running an Arduino `Wire` slave at 0x40 (`i2cslave.yaml` with `i2cslave.hpp`): a fake
|
||||
HDC1080 plus a 256 byte register file, and reads of 0x7E and 0x7F stretch the clock for 0.5 and 5 ms. It needs
|
||||
`libraries: [Wire]` and 160 MHz. With jumper wires only and the internal pull ups it works to about 50 kHz; it cannot
|
||||
keep up with a true 100 kHz hardware master.
|
||||
|
||||
The master (`i2cbk.yaml`, a BK7238 on P26/P24, needs the software I2C bus from that PR) writes a counter, reads it
|
||||
back with a repeated start, probes an absent address and reads both stretch registers every 2 s, logging `RW OK`
|
||||
(the read back matches, the absent address NACKs and both stretched reads succeed) or `RW BAD` with running totals.
|
||||
Control: the same blocks on a classic ESP32 devkit (SDA GPIO21, SCL GPIO22) with hardware I2C at 25 and 50 kHz got
|
||||
every repeated start read right, so failures on a bit banged master at a similar real clock point at the master.
|
||||
@@ -4,3 +4,10 @@
|
||||
|
||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
||||
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
||||
|
||||
## ItsyBitsy over UF2 when serial DFU fails (#20481)
|
||||
|
||||
Serial DFU to the ItsyBitsy nRF52840 fails about half the time ("Target is not in DFU mode"). Reliable path: a
|
||||
1200 baud touch on the app port, wait for the `ITSY840BOOT` volume, convert `zephyr/zephyr.hex` with the SDK's
|
||||
`zephyr/scripts/build/uf2conv.py -f 0xADA52840 -c` and copy the .uf2 to the volume (the copy ends in an I/O error
|
||||
when the board reboots, which is normal). macOS BLE scans give UUIDs, not MAC addresses.
|
||||
@@ -6,3 +6,46 @@ All overlap orders (a web upload during a native OTA and the reverse) must be re
|
||||
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
|
||||
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
|
||||
`pkill -f "a\|b"` does not alternate; use two pkills.
|
||||
|
||||
## OTA upload timing A/B (#21639, #21641, #21642)
|
||||
|
||||
`scripts/ota_timing/run.sh N PHASE...` times native OTA uploads per build (phase) and per board. It builds every
|
||||
board from `TREE_<PHASE>`, does one warm-up upload per board (it installs the phase's build and is left out of the
|
||||
results), checks over the API that each board reports the compile time from that build's `build_info.json`
|
||||
(`apiinfo.py`), then runs N rounds of every mode on every board. Boards are interleaved per round so they share the
|
||||
same air; put a nearby ESP32 on the same Wi-Fi in `BOARDS` as a network control. `summ.py` prints medians: never
|
||||
judge from single runs, slow Wi-Fi gave 33 to 235 s for the same upload in one night.
|
||||
|
||||
- `otatime.py` drives `espota2.run_ota()` from the tree on `PYTHONPATH` and prints one `RESULT` line with the
|
||||
`Update took` split, the compressed size, whether UDP was used and the time at each tenth of the upload.
|
||||
An upload that leaves no `RESULT` line (board gone, script crash, timeout) is recorded as `rc: -1`, and `summ.py`
|
||||
fails when a board and mode has fewer than N rows or an upload time cannot be read.
|
||||
- Boards with an `api: encryption: key` also encrypt OTA: set `API_KEY` for `otatime.py` and `apiinfo.py`. LibreTiny
|
||||
LN882H listens for OTA on 8820, not 8892; BK72xx and RTL87xx use 8892.
|
||||
- Raw mode leaves `CLIENT_FEATURE_SUPPORTS_DEFLATE` out of the client's offer. Never switch deflate off after the
|
||||
offer (`if deflate and 0:`): once the device accepted deflate it expects a deflate stream.
|
||||
- `-tcp` modes make `espota2._loss_reason` return None, so the client asks to stay on TCP. Clearing
|
||||
`CLIENT_FEATURE_SUPPORTS_UDP` instead desyncs the handshake (`receiving UDP token response: timed out`).
|
||||
- LibreTiny trimmed UF2 (#21642): the CLI sends what `esphome.components.libretiny.uf2.device_ota_uf2()` returns for
|
||||
`firmware.uf2`; that module only exists with #21642, so on older trees the full UF2 goes out. Compare the sizes
|
||||
in the `Compressed to` lines, and rely on the API compile time check to show that the trimmed image booted.
|
||||
|
||||
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409628227 (dev and LibreTiny 1.14.0 on a
|
||||
CB3S), https://github.com/esphome/esphome/pull/21642#issuecomment-6412972562 (trimmed UF2 on CB3S, BK7238 and BW15),
|
||||
https://github.com/esphome/esphome/pull/21641#issuecomment-6412976859 (new decoder on the same three).
|
||||
|
||||
## On-device inflate benchmark (#21639, #21641)
|
||||
|
||||
`scripts/ota_timing/inflate_bench`: `gen_streams.py NAME=IMAGE...` cuts a 128 KB slice from each image and writes
|
||||
`bench_streams.hpp` with raw deflate streams (level 9, 4 KB and 32 KB windows). `bench.hpp` inflates every stream with
|
||||
each ring size at least the encoder window, three times, and logs the best time plus input and output rates, then
|
||||
`BENCHDONE`. `bench.sh CONFIG DEVICE` builds and uploads `bench-esp32s3.yaml` or `bench-bk72xx.yaml`, collects one pass
|
||||
over syslog and fails when a case did not reach the end of its stream.
|
||||
|
||||
- LibreTiny defines `micros` as a macro, so the header calls `(esphome::micros)()`.
|
||||
- Keep the streams near 130 KB on a BK7231N: an image with about 330 KB of extra incompressible data failed its
|
||||
OTA with "Writing OTA data to flash memory failed". Use one image and `--windows 12` there.
|
||||
- `bench.hpp` targets the decoder interface with `dest_limit`. #21641 drops it (the output runs into the window up
|
||||
to `dest`); after that lands, remove the `dest_limit` line.
|
||||
|
||||
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409786224
|
||||
@@ -37,3 +37,44 @@ AP start (the fix in that PR), the first AP start failed on every boot with `esp
|
||||
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
|
||||
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
|
||||
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
|
||||
|
||||
## Reboot against a WPA2/WPA3 PMF access point (#21590)
|
||||
|
||||
- AP: `scripts/wpa3_ap/wdap.yaml` on a classic devkit. ESPHome `wifi: ap:` only, then an on_boot lambda switches it
|
||||
to `WIFI_AUTH_WPA2_WPA3_PSK`, PMF capable, and registers a WIFI_EVENT handler that logs `STA JOIN` and
|
||||
`STA LEAVE ... reason N` (the include header pulls in `esp_wifi.h`). It disables Wi-Fi after 420 s.
|
||||
- AP leave reason 3 means the station sent a deauth; reason 2 means the AP found a stale association when the
|
||||
station came back; several reason 2 before one join are rejected reconnects.
|
||||
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the house
|
||||
network at 0, and reboot 30 s after joining the AP and 150 s after joining the house network. So they move to the
|
||||
AP while it is up and fall back to the house network (where they can be flashed over the air) once its window
|
||||
ends. `run.sh BUILD` flashes the AP, does OTA for the CB3S and a serial flash for the BK7238, then captures one AP
|
||||
window; `an.py` prints each station's join and leave sequence and fails on a rejected reconnect, an AP that did
|
||||
not switch to WPA2/WPA3 (`AP CONFIG FAILED`), a DUT missing from the AP events (`DUT_MACS`) or a BK7238 that never
|
||||
joined the AP.
|
||||
- Gotchas: the LibreTiny build directory has two `.uf2` files, so flash `firmware.uf2`. The BK7238 serial flash
|
||||
sometimes fails to link while the chip is in a reboot loop; retry it 3 or 4 times. OTA fails if the DUT reboots
|
||||
mid-upload; retry.
|
||||
- Finding: BDK 3.0.78 rejects about 2 reconnects per reboot when the station left without a deauth; BDK 3.0.33
|
||||
does not.
|
||||
|
||||
## WISP and ALWAYS_ON AP rig (#20077)
|
||||
|
||||
`scripts/wisp_ap`: the upstream is an ESP32-S3 with Ethernet running a WISP bridge (`wisp-upstream.yaml`) with its AP on
|
||||
10.40.0.1/24 and web buttons `ap off` and `ap on`. Its subnet must not be 192.168.4.0/24, the DUT AP's default, or
|
||||
nothing routes. The DUT is a classic devkit (`dut-wisp.yaml` or `dut-always-on.yaml`); the client is a D1 mini on
|
||||
the DUT's AP (`wispcli.yaml`) logging its DNS server, a name lookup, a GET by name, the AP itself and a LAN file by
|
||||
IP. `run.sh DUT START SECONDS T:STATE...` switches the upstream AP at the given times and `sum.py` groups the client
|
||||
results by phase. Capture the D1 mini without `--reset` (a CH340 floods garbage after an RTS reset). Clients keep
|
||||
the captive portal's DNS answer for its TTL.
|
||||
|
||||
## AP restart with WPA3 mixed and LIGHT power save (#21531)
|
||||
|
||||
`scripts/wifi_restart`: the WISP upstream (`wisp-upstream-wpa3.yaml`) switched to WPA2/WPA3 mixed by a script that
|
||||
runs again after every `ap on`, DUTs (`rebk.yaml`, `recb.yaml` on `re-common.yaml`) with a static IP on the bridge's
|
||||
subnet, a fallback AP, `power_save_mode: LIGHT` and syslog to the host through the NAT. `run.sh BUILD` switches the
|
||||
AP off and on three times; `an.py` fails on a reboot, a crash or a DUT that is not back on syslog within 120 s of an
|
||||
AP start. After 19 minutes the DUTs run `wifi.configure` to the house network; that keeps the static IP, so the next
|
||||
OTA needs the host to have an address on the bridge's subnet as well (an interface alias). The CB3S log is on UART1
|
||||
(TX1), so its configs need `logger: hardware_uart: UART1`. Runners must `pkill` only their own ports; a global pkill
|
||||
stops the other runs' captures.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Xiaomi BLE
|
||||
|
||||
## Xiaomi, ATC and PVVX parsing without the sensors (#21483)
|
||||
|
||||
No Xiaomi devices are needed, and one ESP32 cannot hear its own advertising, so the frames are injected into the
|
||||
tracker as scan results: everything after the radio runs as normal.
|
||||
|
||||
- `scripts/xiaomi_ble/gen.py` writes `frames.hpp` and `expected.json`: plain MiBeacon (0x50), encrypted v5 23 byte
|
||||
with MAC and 19 byte without, real AES-CCM (`cryptography` AESCCM, tag 4, AAD 0x11, nonce = MAC reversed + pid +
|
||||
cnt + 3 byte ext counter), ATC (13 byte, big endian) and PVVX (15 byte, little endian) 0x181A frames, a corrupted
|
||||
tag every other cycle and a counter 0 boot frame. It models the old shared duplicate counter (`base`) and the per
|
||||
device one (`pr`) and prints the expected publish counts.
|
||||
- `xbinject.yaml` (ESP-IDF devkit) includes `frames.hpp`; an `interval:` builds a `BLEScanResult` and calls
|
||||
`global_esp32_ble_tracker->gap_scan_event_handler(r)`. `count.py` counts the `Got Xiaomi` blocks (sensor
|
||||
publishes are not logged at DEBUG) and compares them with `expected.json`.
|
||||
- On ESP32 the tracker's own triggers sit in `listeners_`, which runs before `neutral_listeners_` (the
|
||||
ble_device_base devices), so an `on_ble_service_data_advertise` never sees what a Xiaomi listener did to the
|
||||
shared data. Check that directly: `xb_rewrite_probe(id(xa))` builds an `ESPBTDevice` with `parse_scan_rst()`,
|
||||
calls the component's `parse_device()` and logs the service data before and after (`[probe` lines).
|
||||
- `run.sh inject pr|base` builds, flashes, captures and prints PASS when the counts match that build's model and,
|
||||
on the PR build, the probe's service data is the same before and after `parse_device()`.
|
||||
Base dropped a second device's frame with the same counter, the frame after a bad tag and a first frame with
|
||||
counter 0, and rewrote the shared data in place; the PR fixed all of them.
|
||||
|
||||
## Over the air with a second ESP32
|
||||
|
||||
- Advertiser: an ESP32-S3 with Ethernet (OTA over Ethernet), `xbadv.yaml` with `adv_frames.hpp` from
|
||||
`gen.py --ota`. Per frame: stop advertising, `esp_ble_gap_set_rand_addr`, `config_adv_data_raw`, start
|
||||
non-connectable advertising with `own_addr_type` random, 20 ms interval, 250 ms dwell. Random static addresses
|
||||
need the top two bits set, so these use C4:C1:38 instead of A4. `esp32_ble:` alone brings up the stack. The
|
||||
web_server button starts a run: `curl -X POST -d "" http://<advertiser>/button/start/press` (no body gives 411).
|
||||
- Receiver: `xbrecv.yaml` on the devkit, passive scan with window = interval = 100 ms. `run.sh ota pr|base` (with
|
||||
`ADV` set) flashes it, resets it with a capture and presses start 18 s later, so the counter 0 boot frame is the
|
||||
first one it hears.
|
||||
- Each frame arrives about 4 times; the counts matched the injection run exactly apart from one radio loss, so the
|
||||
over the air comparison allows two missing publishes per tag.
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Match each proxy disconnect with the peer's own disconnect line and count the links the peer kept.
|
||||
Usage: an.py DIR LABEL [ROUNDS] reads LABEL-drive.txt, LABEL-peer.txt (serial_capture --ms) and LABEL-proxy.txt
|
||||
(esphome logs, VERBOSE). A round counts only when the last `Conn params updated` the proxy logged since the previous
|
||||
disconnect shows latency=10; otherwise it is inconclusive. Exits 1 when the peer kept a link past 3 s (the terminate
|
||||
was lost), a round was inconclusive, or fewer than ROUNDS rounds completed."""
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, label = Path(sys.argv[1]), sys.argv[2]
|
||||
ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
|
||||
|
||||
def read(name: str) -> list[str]:
|
||||
return (
|
||||
(D / f"{label}-{name}.txt")
|
||||
.read_text(encoding="utf-8", errors="replace")
|
||||
.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def ptime(line: str) -> float | None:
|
||||
"""Seconds since midnight from a leading [HH:MM:SS.mmm] stamp."""
|
||||
m = re.match(r"\[(\d+):(\d+):(\d+)\.(\d+)\]", line)
|
||||
return (
|
||||
int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
|
||||
)
|
||||
|
||||
|
||||
pdisc = [
|
||||
t
|
||||
for line in read("peer")
|
||||
if "PEER DISCONNECT" in line and (t := ptime(line)) is not None
|
||||
]
|
||||
params = [
|
||||
(t, int(m[1]))
|
||||
for line in read("proxy")
|
||||
if "Conn params updated" in line
|
||||
and (m := re.search(r"latency=(\d+)", line))
|
||||
and (t := ptime(line)) is not None
|
||||
]
|
||||
res = []
|
||||
prev = -1.0
|
||||
for line in read("drive"):
|
||||
m = re.search(
|
||||
r"R(\d+) .*DISC_CALL ([\d.]+) DISC_DONE ([\d.]+) \(\+([\d.]+)s\)", line
|
||||
)
|
||||
if not m:
|
||||
if line.startswith("R"):
|
||||
print(" ", line)
|
||||
continue
|
||||
# Both captures stamp local wall clock time; compare as seconds since midnight.
|
||||
t = dt.datetime.fromtimestamp(float(m[2])).astimezone()
|
||||
tc = t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
|
||||
after = [p - tc for p in pdisc if -1 < p - tc < 12]
|
||||
window = [lat for pt, lat in params if prev < pt <= tc]
|
||||
res.append(
|
||||
(
|
||||
int(m[1]),
|
||||
float(m[4]),
|
||||
after[0] if after else None,
|
||||
window[-1] if window else None,
|
||||
)
|
||||
)
|
||||
prev = tc
|
||||
for r, done, pd, lat in res:
|
||||
peer = (
|
||||
f"peer disconnect +{pd:.2f}s" if pd is not None else "peer disconnect not seen"
|
||||
)
|
||||
print(f" R{r:2} central done +{done:.3f}s {peer} latency at disconnect {lat}")
|
||||
late = sum(1 for _, _, pd, lat in res if lat == 10 and (pd is None or pd > 3))
|
||||
inconclusive = sum(1 for *_, lat in res if lat != 10)
|
||||
print(
|
||||
f"{label}: rounds {len(res)}, peer kept the link (>3 s or not seen) {late}, inconclusive {inconclusive}"
|
||||
)
|
||||
failed = []
|
||||
if not res:
|
||||
failed.append("no round completed")
|
||||
if ROUNDS is not None and len(res) < ROUNDS:
|
||||
failed.append(f"only {len(res)} of {ROUNDS} rounds completed")
|
||||
if late:
|
||||
failed.append(f"{late} links kept")
|
||||
if inconclusive:
|
||||
failed.append(
|
||||
f"INCONCLUSIVE: {inconclusive} rounds had no latency=10 update before the disconnect"
|
||||
)
|
||||
print(f"PASS: {label}" if not failed else f"FAIL: {label}: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Connect to the peer through the proxy, read its services, idle, disconnect; print host timestamps per round.
|
||||
Usage: btdrive.py PROXY KEY PEER_MAC ROUNDS IDLE_S"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
|
||||
async def main(host: str, key: str, peer: str, rounds: int, idle: float) -> int:
|
||||
addr = int(peer.replace(":", ""), 16)
|
||||
cli = APIClient(host, 6053, None, noise_psk=key)
|
||||
await cli.connect(login=True)
|
||||
info = await cli.device_info()
|
||||
print(f"proxy {info.name} bt_mac {info.bluetooth_mac_address}", flush=True)
|
||||
seen = 0
|
||||
|
||||
def adv(msg) -> None:
|
||||
nonlocal seen
|
||||
seen += sum(a.address == addr for a in msg.advertisements)
|
||||
|
||||
# The proxy only connects to a device its scanner has seen.
|
||||
cli.subscribe_bluetooth_le_raw_advertisements(adv)
|
||||
await asyncio.sleep(5)
|
||||
print(f"peer advertisements seen in 5 s: {seen}", flush=True)
|
||||
if not seen:
|
||||
print("FAIL: the proxy does not see the peer", flush=True)
|
||||
await cli.disconnect()
|
||||
return 1
|
||||
done = 0
|
||||
for r in range(1, rounds + 1):
|
||||
state: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def cb(
|
||||
connected: bool, mtu: int, err: int, state: asyncio.Queue = state
|
||||
) -> None:
|
||||
state.put_nowait((connected, err, time.time()))
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
cancel = await cli.bluetooth_device_connect(
|
||||
addr, cb, timeout=20, address_type=0, feature_flags=0xFF
|
||||
)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
print(f"R{r} CONNECT ERROR {type(e).__name__}", flush=True)
|
||||
await asyncio.sleep(9)
|
||||
continue
|
||||
try:
|
||||
c, err, t = await asyncio.wait_for(state.get(), 25)
|
||||
except TimeoutError:
|
||||
print(f"R{r} CONNECT TIMEOUT", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
if not c:
|
||||
print(f"R{r} CONNECT FAIL err {err} after {t - t0:.2f}s", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
try:
|
||||
n = len((await cli.bluetooth_gatt_get_services(addr)).services)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
n = f"err {e!r}"
|
||||
await asyncio.sleep(idle)
|
||||
td = time.time()
|
||||
await cli.bluetooth_device_disconnect(addr)
|
||||
try:
|
||||
c2, err2, t2 = await asyncio.wait_for(state.get(), 15)
|
||||
if c2 or not isinstance(n, int):
|
||||
# an unexpected connect state or a failed service read: the round did not run as intended
|
||||
print(
|
||||
f"R{r} BAD ROUND services {n} connected {c2} err {err2}", flush=True
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} DISC_DONE {t2:.3f} (+{t2 - td:.3f}s) err {err2}",
|
||||
flush=True,
|
||||
)
|
||||
done += 1
|
||||
except TimeoutError:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} no disconnect state", flush=True
|
||||
)
|
||||
cancel()
|
||||
await asyncio.sleep(
|
||||
9
|
||||
) # a peer that missed the terminate hits its 6 s supervision timeout in this gap
|
||||
await cli.disconnect()
|
||||
print(f"rounds completed {done} of {rounds}", flush=True)
|
||||
return 0 if done == rounds else 1
|
||||
|
||||
|
||||
sys.exit(
|
||||
asyncio.run(
|
||||
main(
|
||||
sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <cstring>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include "esphome/core/log.h"
|
||||
#include "central_bda.hpp" // written by run.sh from PROXY_BT_MAC
|
||||
|
||||
// 50 ms interval, peripheral latency 10, 6 s supervision timeout
|
||||
inline void btpeer_request_latency() {
|
||||
esp_ble_conn_update_params_t p{};
|
||||
memcpy(p.bda, CENTRAL_BDA, 6);
|
||||
p.min_int = 40;
|
||||
p.max_int = 40;
|
||||
p.latency = 10;
|
||||
p.timeout = 600;
|
||||
esp_err_t err = esp_ble_gap_update_conn_params(&p);
|
||||
ESP_LOGI("peer", "requested interval 50 ms latency 10 timeout 6 s: %d", err);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
esphome:
|
||||
name: btpeer
|
||||
includes:
|
||||
- btpeer.hpp
|
||||
- central_bda.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble: VERBOSE
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 1
|
||||
|
||||
esp32_ble_server:
|
||||
manufacturer: test
|
||||
on_connect:
|
||||
- lambda: ESP_LOGI("peer", "PEER CONNECT conn %u", (unsigned) id);
|
||||
- delay: 4s
|
||||
- lambda: btpeer_request_latency();
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("peer", "PEER DISCONNECT conn %u", (unsigned) id);
|
||||
services:
|
||||
- uuid: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
|
||||
advertise: true
|
||||
characteristics:
|
||||
- uuid: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
|
||||
read: true
|
||||
value: "peer"
|
||||
@@ -0,0 +1,44 @@
|
||||
substitutions:
|
||||
name: btproxy
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: VERBOSE
|
||||
logs:
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
esp32_ble_tracker: DEBUG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh LABEL ROUNDS build both boards, then ROUNDS connect/disconnect rounds through the proxy
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (api_key, base64 Noise key) and run it there.
|
||||
# env: TREE (esphome checkout to build the proxy from), PORT (peer devkit serial port), PROXY (proxy address, flashed
|
||||
# over the air; the first flash of a fresh board goes over USB), PROXY_BT_MAC (the proxy's Bluetooth MAC, printed by
|
||||
# btdrive.py as bt_mac), PEER_MAC (the peer's Bluetooth MAC, base MAC + 2 on a classic ESP32), SC (path to
|
||||
# serial_capture.py), PY (checkout python), PEER_TREE (checkout for the peer, defaults to TREE)
|
||||
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=${2:-12}; T=${TREE:?set TREE}; PT=${PEER_TREE:-$T}
|
||||
P=${PORT:?set PORT}; S3=${PROXY:?set PROXY}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
PEER=${PEER_MAC:?set PEER_MAC}; CM=${PROXY_BT_MAC:?set PROXY_BT_MAC}
|
||||
KEY=$(sed -n 's/^api_key: *"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$D/secrets.yaml")
|
||||
[ -n "$KEY" ] || { echo "FAIL: no api_key in secrets.yaml"; exit 1; }
|
||||
cd "$D" || exit 1
|
||||
printf '#pragma once\n#include <cstdint>\nstatic const uint8_t CENTRAL_BDA[6] = {0x%s};\n' "${CM//:/, 0x}" > central_bda.hpp
|
||||
(cd "$PT" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btpeer.yaml" --device "$P" > "$D/$L-peer-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: peer build or upload failed"; exit 1; }
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: proxy build or upload failed"; exit 1; }
|
||||
SECS=$((N * 26 + 40))
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
$PY "$SC" "$P" $SECS "$D/$L-peer.txt" --ms > /dev/null 2>&1 & CAP=$!
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 exec timeout $((SECS - 10)) $PY -m esphome logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy.txt" 2>&1) & LOGS=$!
|
||||
trap 'kill $CAP $LOGS 2>/dev/null' EXIT
|
||||
sleep 6
|
||||
$PY "$D/btdrive.py" "$S3" "$KEY" "$PEER" "$N" 10 > "$D/$L-drive.txt" 2>&1; RC=$?
|
||||
sleep 10
|
||||
kill $CAP $LOGS 2>/dev/null; wait 2>/dev/null; trap - EXIT
|
||||
[ -s "$D/$L-peer.txt" ] || { tail -3 "$D/$L-drive.txt"; echo "FAIL: peer capture got nothing"; exit 1; }
|
||||
$PY "$D/an.py" "$D" "$L" "$N"; ARC=$?
|
||||
[ "$RC" = 0 ] || { tail -1 "$D/$L-drive.txt"; echo "FAIL: driver rc $RC (not every round completed)"; exit 1; }
|
||||
exit $ARC
|
||||
@@ -0,0 +1,79 @@
|
||||
# BK7238 bit banged master; needs the BK72xx software I2C bus from #21586 (dev rejects the pins).
|
||||
# The ESP32 control is the same i2c, sensor and interval blocks on a devkit (sda GPIO21, scl GPIO22, 25 or 50 kHz).
|
||||
substitutions:
|
||||
frequency: 100kHz
|
||||
|
||||
esphome:
|
||||
name: i2cbk
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
i2c:
|
||||
id: bus
|
||||
sda: P26
|
||||
scl: P24
|
||||
scan: true
|
||||
frequency: ${frequency}
|
||||
timeout: 20ms # clock stretch limit; the bus default (1 ms) is shorter than the slave's 5 ms stretch
|
||||
|
||||
sensor:
|
||||
- platform: hdc1080
|
||||
address: 0x40
|
||||
update_interval: 5s
|
||||
temperature:
|
||||
name: fake temperature
|
||||
humidity:
|
||||
name: fake humidity
|
||||
|
||||
globals:
|
||||
- id: n
|
||||
type: uint8_t
|
||||
initial_value: "0"
|
||||
- id: n_ok
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: n_bad
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 2s
|
||||
then:
|
||||
- lambda: |-
|
||||
uint8_t k = ++id(n);
|
||||
uint8_t w[4] = {0x20, k, (uint8_t) ~k, 0xA5};
|
||||
uint32_t t0 = micros();
|
||||
auto e1 = id(bus)->write(0x40, w, 4);
|
||||
uint8_t reg = 0x20, r[3] = {0, 0, 0};
|
||||
auto e2 = id(bus)->write_readv(0x40, ®, 1, r, 3);
|
||||
uint32_t t1 = micros();
|
||||
uint8_t x;
|
||||
auto e3 = id(bus)->read(0x41, &x, 1);
|
||||
uint8_t s1 = 0x7E, s2 = 0x7F, rr[2];
|
||||
uint32_t t2 = micros();
|
||||
auto e4 = id(bus)->write_readv(0x40, &s1, 1, rr, 2);
|
||||
uint32_t t3 = micros();
|
||||
auto e5 = id(bus)->write_readv(0x40, &s2, 1, rr, 2);
|
||||
uint32_t t4 = micros();
|
||||
// the absent address must NACK and both clock stretched reads must succeed
|
||||
bool ok = e1 == 0 && e2 == 0 && r[0] == k && r[1] == (uint8_t) ~k && r[2] == 0xA5 && e3 != 0 && e4 == 0 &&
|
||||
e5 == 0;
|
||||
if (ok) id(n_ok)++; else id(n_bad)++;
|
||||
ESP_LOGI("t", "RW %s e1 %d e2 %d got %02X %02X %02X want %02X %02X A5 | %u us | absent e %d | stretch500 e %d %u us | stretch5ms e %d %u us | ok %u bad %u",
|
||||
ok ? "OK" : "BAD", e1, e2, r[0], r[1], r[2], k, (uint8_t) ~k, (unsigned) (t1 - t0), e3, e4,
|
||||
(unsigned) (t3 - t2), e5, (unsigned) (t4 - t3), (unsigned) id(n_ok), (unsigned) id(n_bad));
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <Wire.h>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Fake HDC1080 at 0x40: 0x00 temperature (26.0 C), 0x01 humidity (50 %), other registers are a
|
||||
// 256 byte file; reading 0x7E holds SCL for 500 us, reading 0x7F for 5 ms.
|
||||
static volatile uint8_t SL_REGS[256];
|
||||
static volatile uint8_t sl_ptr = 0;
|
||||
static volatile uint32_t sl_rx = 0, sl_tx = 0, sl_stretch = 0;
|
||||
|
||||
static void sl_on_receive(int n) {
|
||||
if (n < 1)
|
||||
return;
|
||||
sl_ptr = Wire.read();
|
||||
uint8_t p = sl_ptr;
|
||||
while (--n > 0 && Wire.available())
|
||||
SL_REGS[p++] = Wire.read();
|
||||
sl_rx++;
|
||||
}
|
||||
|
||||
static void sl_on_request() {
|
||||
uint8_t b[8];
|
||||
uint8_t p = sl_ptr;
|
||||
sl_tx++;
|
||||
if (p == 0x7E || p == 0x7F) {
|
||||
sl_stretch++;
|
||||
delayMicroseconds(p == 0x7E ? 500 : 5000);
|
||||
}
|
||||
for (int i = 0; i < 8; i++)
|
||||
b[i] = SL_REGS[(uint8_t) (p + i)];
|
||||
if (p == 0x00) {
|
||||
b[0] = 0x66;
|
||||
b[1] = 0x66;
|
||||
} else if (p == 0x01) {
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x00;
|
||||
}
|
||||
Wire.write(b, 8);
|
||||
}
|
||||
|
||||
inline void i2cslave_begin() {
|
||||
Wire.begin(4, 5, 0x40);
|
||||
Wire.onReceive(sl_on_receive);
|
||||
Wire.onRequest(sl_on_request);
|
||||
ESP_LOGI("slave", "I2C slave at 0x40 on SDA GPIO4 SCL GPIO5");
|
||||
}
|
||||
|
||||
inline void i2cslave_report() {
|
||||
ESP_LOGI("slave", "rx %u tx %u stretch %u", (unsigned) sl_rx, (unsigned) sl_tx, (unsigned) sl_stretch);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: i2cslave
|
||||
libraries:
|
||||
- Wire
|
||||
includes:
|
||||
- i2cslave.hpp
|
||||
platformio_options:
|
||||
board_build.f_cpu: 160000000L
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: i2cslave_begin();
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: i2cslave_report();
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Check over the API that each board runs the expected build.
|
||||
Usage: apiinfo.py HOST[=BUILD_INFO_JSON]...
|
||||
Prints name and compilation time per host. With a build_info.json (from the build directory), the board's
|
||||
compilation time must equal that build's build_time_str. Exits 1 when a host fails or runs another build.
|
||||
env: API_KEY (the boards' api encryption key, when they have one)"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
|
||||
async def info(host: str) -> tuple[str, str]:
|
||||
cli = APIClient(host, 6053, None, noise_psk=os.environ.get("API_KEY"))
|
||||
await cli.connect(login=True)
|
||||
try:
|
||||
i = await cli.device_info()
|
||||
finally:
|
||||
await cli.disconnect()
|
||||
return i.name, i.compilation_time
|
||||
|
||||
|
||||
bad = 0
|
||||
for arg in sys.argv[1:]:
|
||||
host, _, build_info = arg.partition("=")
|
||||
try:
|
||||
name, built = asyncio.run(asyncio.wait_for(info(host), 30))
|
||||
except (APIConnectionError, TimeoutError, OSError) as err:
|
||||
print("API", host, "ERROR", repr(err), flush=True)
|
||||
bad += 1
|
||||
continue
|
||||
want = (
|
||||
json.loads(Path(build_info).read_text(encoding="utf-8"))["build_time_str"]
|
||||
if build_info
|
||||
else None
|
||||
)
|
||||
ok = want is None or built == want
|
||||
bad += not ok
|
||||
print("API", host, name, built, "" if ok else f"MISMATCH want {want}", flush=True)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,42 @@
|
||||
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
|
||||
substitutions:
|
||||
name: inflbench-bk
|
||||
board: cb3s
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- bench_streams.hpp
|
||||
- bench.hpp
|
||||
|
||||
bk72xx:
|
||||
board: ${board}
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 90s
|
||||
then:
|
||||
- delay: 20s
|
||||
- lambda: run_inflate_bench();
|
||||
@@ -0,0 +1,44 @@
|
||||
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
|
||||
substitutions:
|
||||
name: inflbench-s3
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- bench_streams.hpp
|
||||
- bench.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
|
||||
time:
|
||||
- platform: sntp
|
||||
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 90s
|
||||
then:
|
||||
- delay: 20s
|
||||
- lambda: run_inflate_bench();
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
// On-device inflate speed for the ESPHome OTA decoder. Targets the decoder interface with dest_limit (dev as of
|
||||
// this writing); once the interface without dest_limit lands (#21641), drop the dest_limit line.
|
||||
#include "esphome/components/esphome/ota/ota_esphome_inflate.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "bench_streams.hpp"
|
||||
|
||||
static uint8_t bench_ring[32768];
|
||||
static OtaInflateState bench_state;
|
||||
|
||||
// LibreTiny defines micros as a macro; the parentheses call the ESPHome function.
|
||||
inline uint32_t bench_now() { return (esphome::micros) (); }
|
||||
|
||||
inline void run_inflate_bench() {
|
||||
unsigned expected = 0;
|
||||
for (const auto &c : BENCH_CASES) {
|
||||
for (unsigned ring : {4096u, 8192u, 16384u, 32768u})
|
||||
expected += ring >= c.enc_window;
|
||||
}
|
||||
ESP_LOGI("bench", "BENCHSTART cases=%u", expected);
|
||||
for (const auto &c : BENCH_CASES) {
|
||||
for (unsigned ring : {4096u, 8192u, 16384u, 32768u}) {
|
||||
if (ring < c.enc_window)
|
||||
continue;
|
||||
uint32_t best = UINT32_MAX;
|
||||
size_t out = 0;
|
||||
int res = 0;
|
||||
for (int rep = 0; rep < 3; rep++) {
|
||||
ota_inflate_init(&bench_state, bench_ring, ring);
|
||||
bench_state.source = c.data;
|
||||
bench_state.source_limit = c.data + c.len;
|
||||
bench_state.source_read_cb = [](OtaInflateState *) -> int { return -1; };
|
||||
out = 0;
|
||||
uint32_t t0 = bench_now();
|
||||
do {
|
||||
bench_state.dest = bench_ring;
|
||||
bench_state.dest_limit = bench_ring + ring;
|
||||
res = ota_inflate(&bench_state);
|
||||
out += bench_state.dest - bench_ring;
|
||||
esphome::App.feed_wdt();
|
||||
} while (res == OTA_INFLATE_OK);
|
||||
uint32_t dt = bench_now() - t0;
|
||||
if (dt < best)
|
||||
best = dt;
|
||||
}
|
||||
ESP_LOGI("bench", "%s enc=%u ring=%u in=%u out=%u/%u res=%d best_us=%u out_kBps=%u in_kBps=%u", c.name,
|
||||
c.enc_window, ring, (unsigned) c.len, (unsigned) out, (unsigned) c.raw, res, (unsigned) best,
|
||||
(unsigned) ((uint64_t) out * 1000 / best), (unsigned) ((uint64_t) c.len * 1000 / best));
|
||||
}
|
||||
}
|
||||
ESP_LOGI("bench", "BENCHDONE");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# usage: bench.sh CONFIG DEVICE build and upload CONFIG (bench-bk72xx or bench-esp32s3) to DEVICE (OTA address or
|
||||
# serial port), then collect one benchmark pass over syslog. Run gen_streams.py in this directory first.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, syslog_ip = this
|
||||
# host's address) and run it there. env: TREE (esphome checkout under test), PY (checkout python), WAIT (seconds to
|
||||
# wait for a pass, default 300)
|
||||
D=$(cd "$(dirname "$0")" && pwd); C=${1:?config name}; DEV=${2:?device}; T=${TREE:?set TREE}
|
||||
PY=${PY:-$T/venv/bin/python}; WAIT=${WAIT:-300}; OUT=$D/$C-syslog.txt
|
||||
[ -f "$D/bench_streams.hpp" ] || { echo "FAIL: run gen_streams.py first"; exit 1; }
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$C.yaml" --device "$DEV" > "$D/$C-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: build or upload failed, see $C-upload.log"; exit 1; }
|
||||
$PY - "$OUT" "$WAIT" << 'PYEOF'
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("0.0.0.0", 5514))
|
||||
s.settimeout(1)
|
||||
end = time.time() + int(sys.argv[2])
|
||||
started = False
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as out:
|
||||
while time.time() < end:
|
||||
try:
|
||||
d, _ = s.recvfrom(2048)
|
||||
except TimeoutError:
|
||||
continue
|
||||
line = d.decode(errors="replace").strip()
|
||||
# keep one whole pass: lines from a pass already under way are dropped
|
||||
started = started or "BENCHSTART" in line
|
||||
if started and "bench" in line:
|
||||
print(line, file=out, flush=True)
|
||||
if "BENCHDONE" in line:
|
||||
break
|
||||
PYEOF
|
||||
grep -q BENCHDONE "$OUT" || { echo "FAIL: no complete pass within $WAIT s"; exit 1; }
|
||||
grep -o 'bench:[0-9]*\]: .*' "$OUT"
|
||||
BAD=$(grep 'best_us=' "$OUT" | grep -v 'res=1 ' | wc -l | tr -d ' ')
|
||||
SHORT=$(grep -o 'out=[0-9]*/[0-9]*' "$OUT" | awk -F'[=/]' '$2 != $3' | wc -l | tr -d ' ')
|
||||
WANT=$(grep -o 'BENCHSTART cases=[0-9]*' "$OUT" | cut -d= -f2); GOT=$(grep -c 'best_us=' "$OUT")
|
||||
[ "$BAD" = 0 ] && [ "$SHORT" = 0 ] && [ "$GOT" = "$WANT" ] && echo "PASS" \
|
||||
|| { echo "FAIL: $BAD cases did not finish, $SHORT short outputs, $GOT of $WANT results (lost syslog lines)"; exit 1; }
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Write bench_streams.hpp: one 128 KB slice per image, raw deflate at level 9 with 4 KB and 32 KB windows.
|
||||
Usage: gen_streams.py NAME=IMAGE[@OFFSET]... [--windows 12,15]
|
||||
Each image gives one case per window. Keep the total near 130 KB on a BK7231N: an image with about 330 KB of extra
|
||||
incompressible data failed its OTA ("Writing OTA data to flash memory failed"); use one image and --windows 12 there."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import zlib
|
||||
|
||||
SLICE = 128 * 1024
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("images", nargs="+")
|
||||
ap.add_argument("--windows", default="12,15")
|
||||
args = ap.parse_args()
|
||||
arrays, cases, total = [], [], 0
|
||||
for spec in args.images:
|
||||
name, _, rest = spec.partition("=")
|
||||
path, _, offset = rest.partition("@")
|
||||
raw = Path(path).read_bytes()[int(offset or 0) :][:SLICE]
|
||||
for wbits in (int(w) for w in args.windows.split(",")):
|
||||
data = zlib.compress(raw, 9, -wbits)
|
||||
total += len(data)
|
||||
var = f"{name}_w{wbits}"
|
||||
arrays.append(
|
||||
f"static const uint8_t {var}[{len(data)}] = {{{','.join(map(str, data))}}};"
|
||||
)
|
||||
cases.append(f'{{"{name}", {var}, sizeof({var}), {len(raw)}u, {1 << wbits}u}}')
|
||||
print(f"{var}: {len(raw)} -> {len(data)} bytes")
|
||||
head = "#pragma once\n#include <cstddef>\n#include <cstdint>\n"
|
||||
case_struct = (
|
||||
"struct BenchCase {\n const char *name;\n const uint8_t *data;\n size_t len;\n size_t raw;\n"
|
||||
" unsigned enc_window;\n};\n"
|
||||
)
|
||||
body = (
|
||||
"\n".join(arrays)
|
||||
+ "\n"
|
||||
+ case_struct
|
||||
+ "static const BenchCase BENCH_CASES[] = {"
|
||||
+ ", ".join(cases)
|
||||
+ "};\n"
|
||||
)
|
||||
Path("bench_streams.hpp").write_text(head + body, encoding="utf-8")
|
||||
print(f"total {total} bytes of streams")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Time one ESPHome OTA upload and print one RESULT json line.
|
||||
Usage: otatime.py HOST IMAGE MODE (MODE: deflate, raw, deflate-tcp or raw-tcp)
|
||||
env: OTA_PORT (default 3232), OTA_PASSWORD, API_KEY (a board with an api encryption key also encrypts OTA),
|
||||
TAG (label stored in the result)
|
||||
Run it with the checkout under test first on PYTHONPATH, so its espota2 is the client being measured.
|
||||
raw: the client leaves deflate out of its feature offer. Never patch the deflate branch off after the offer: once
|
||||
the device accepted deflate it expects a deflate stream. -tcp: the link is reported clean, so the client asks to
|
||||
stay on TCP. Clearing the UDP feature bit instead desyncs the handshake ("receiving UDP token response: timed out")."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import esphome.espota2 as espota
|
||||
|
||||
host, image, mode = sys.argv[1], Path(sys.argv[2]), sys.argv[3]
|
||||
if mode.startswith("raw"):
|
||||
espota.CLIENT_FEATURE_SUPPORTS_DEFLATE = 0
|
||||
if mode.endswith("-tcp"):
|
||||
espota._loss_reason = lambda *a: None # noqa: SLF001
|
||||
marks: list[tuple[float, float]] = []
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Stands in for the progress bar and records when each fraction went out."""
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
pass
|
||||
|
||||
def update(self, progress: float) -> None:
|
||||
marks.append((time.perf_counter(), progress))
|
||||
|
||||
def done(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
espota.ProgressBar = Recorder
|
||||
lines: list[str] = []
|
||||
|
||||
|
||||
class Keep(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
lines.append(record.getMessage())
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger().addHandler(Keep())
|
||||
t0 = time.perf_counter()
|
||||
rc, _ = espota.run_ota(
|
||||
host,
|
||||
int(os.environ.get("OTA_PORT", "3232")),
|
||||
os.environ.get("OTA_PASSWORD"),
|
||||
image,
|
||||
noise_psk=os.environ.get("API_KEY"),
|
||||
)
|
||||
total = time.perf_counter() - t0
|
||||
deciles = []
|
||||
if marks:
|
||||
start = marks[0][0]
|
||||
for q in range(1, 11):
|
||||
hit = next((t for t, f in marks if f >= q / 10 - 1e-9), None)
|
||||
deciles.append(round(hit - start, 2) if hit is not None else None)
|
||||
result = {
|
||||
"board": os.environ.get("TAG", ""),
|
||||
"mode": mode,
|
||||
"rc": rc,
|
||||
"total": round(total, 2),
|
||||
"took": next((x for x in lines if x.startswith("Update took")), ""),
|
||||
"size": next((x for x in lines if "Compressed to" in x), ""),
|
||||
"udp": any("Using UDP" in x for x in lines),
|
||||
"deciles": deciles,
|
||||
}
|
||||
print("RESULT", json.dumps(result), flush=True)
|
||||
sys.exit(1 if rc else 0)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh N PHASE... OTA upload timing A/B: per phase build every board, one warm-up upload per board
|
||||
# (installs the phase's build, not timed), an API check that each board booted that build, then N rounds of every
|
||||
# MODE on every board, interleaved so the boards share the same air. summ.py prints medians at the end.
|
||||
# Layout: $D/PHASE/NAME.yaml per phase and board (with its secrets.yaml); outputs go to $D/res.txt.
|
||||
# env: BOARDS ("NAME,HOST,OTA_PORT ..."; add an ESP32 on the same Wi-Fi as a network control), TREE_<PHASE>
|
||||
# (esphome checkout for each phase, e.g. TREE_base, TREE_pr), PY (python with esphome's requirements),
|
||||
# MODES (default "deflate-tcp raw-tcp"), SETTLE (seconds after a board answers again, default 15), API_KEY (when the
|
||||
# boards have an api encryption key; it also encrypts their OTA, so all boards in one run need the same key)
|
||||
D=$(cd "$(dirname "$0")" && pwd); N=${1:?rounds}; shift; [ $# -gt 0 ] || { echo "usage: run.sh N PHASE..."; exit 2; }
|
||||
B=${BOARDS:?set BOARDS}; PY=${PY:?set PY}; MODES=${MODES:-deflate-tcp raw-tcp}; SETTLE=${SETTLE:-15}
|
||||
wait_up() { # wait_up HOST PORT: up to 5 minutes for the OTA port to answer
|
||||
local i
|
||||
for i in $(seq 60); do
|
||||
"$PY" -c "import socket,sys; socket.create_connection((sys.argv[1], int(sys.argv[2])), 2).close()" "$1" "$2" 2>/dev/null \
|
||||
&& { sleep "$SETTLE"; return 0; }
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
up() { # up PHASE NAME HOST PORT MODE TAG OUTFILE; returns 1 unless the upload succeeded
|
||||
local tree=TREE_$1 out=""
|
||||
if wait_up "$3" "$4"; then
|
||||
out=$(TAG=$1-$2-$6 OTA_PORT=$4 PYTHONPATH=${!tree} timeout 900 "$PY" "$D/otatime.py" "$3" \
|
||||
"$(cat "$D/img-$1-$2.txt")" "$5" 2>> "$D/stderr-$1.log" | grep RESULT)
|
||||
else
|
||||
echo "FAIL: $3 not answering on $4"
|
||||
fi
|
||||
# no RESULT line (board gone, otatime.py crashed or timed out): record a failed upload so summ.py counts it
|
||||
[ -n "$out" ] || out="RESULT {\"board\": \"$1-$2-$6\", \"mode\": \"$5\", \"rc\": -1, \"total\": 0, \"took\": \"\", \"size\": \"\", \"udp\": false, \"deciles\": []}"
|
||||
echo "$out" | tee -a "$7"
|
||||
[[ $out == *'"rc": 0,'* ]]
|
||||
}
|
||||
# results from an earlier run would fill in for missing rows: move them aside
|
||||
for f in res.txt warm.txt api.txt; do [ -f "$D/$f" ] && mv "$D/$f" "$D/$f.$(date +%Y%m%d-%H%M%S)"; done
|
||||
for ph in "$@"; do
|
||||
tree=TREE_$ph; [ -n "${!tree}" ] || { echo "FAIL: set $tree"; exit 1; }
|
||||
checks=()
|
||||
for b in $B; do
|
||||
IFS=, read -r n h p <<< "$b"
|
||||
(cd "${!tree}" && PYTHONUNBUFFERED=1 timeout 1500 "$PY" -m esphome compile "$D/$ph/$n.yaml") > "$D/build-$ph-$n.log" 2>&1 \
|
||||
|| { echo "FAIL: build $ph/$n"; exit 1; }
|
||||
# the image the CLI uploads (CORE.firmware_bin): ESP-IDF build, LibreTiny UF2, else the PlatformIO bin
|
||||
bd=$D/$ph/.esphome/build/$n; : > "$D/img-$ph-$n.txt"
|
||||
for f in "$bd/build/$n.bin" "$bd/.pioenvs/$n/firmware.uf2" "$bd/.pioenvs/$n/firmware.bin"; do
|
||||
[ -f "$f" ] && { echo "$f" > "$D/img-$ph-$n.txt"; break; }
|
||||
done
|
||||
[ -s "$D/img-$ph-$n.txt" ] || { echo "FAIL: no firmware image under $bd"; exit 1; }
|
||||
case $(cat "$D/img-$ph-$n.txt") in
|
||||
*.uf2) PYTHONPATH=${!tree} "$PY" "$D/trim_uf2.py" "$(cat "$D/img-$ph-$n.txt")" > "$D/img-$ph-$n.tmp" \
|
||||
&& mv "$D/img-$ph-$n.tmp" "$D/img-$ph-$n.txt" || { echo "FAIL: trim_uf2.py $ph/$n"; exit 1; } ;;
|
||||
esac
|
||||
checks+=("$h=$D/$ph/.esphome/build/$n/build_info.json")
|
||||
done
|
||||
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" deflate-tcp warm "$D/warm.txt" || exit 1; done
|
||||
for b in $B; do IFS=, read -r n h p <<< "$b"; wait_up "$h" "$p"; done
|
||||
"$PY" "$D/apiinfo.py" "${checks[@]}" | tee -a "$D/api.txt"; [ "${PIPESTATUS[0]}" = 0 ] || { echo "FAIL: $ph did not boot everywhere"; exit 1; }
|
||||
for i in $(seq "$N"); do
|
||||
for m in $MODES; do
|
||||
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" "$m" "r$i" "$D/res.txt"; done
|
||||
done
|
||||
done
|
||||
echo "PHASE DONE $ph"
|
||||
done
|
||||
"$PY" "$D/summ.py" "$D/res.txt" "$N"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Median upload time per board and mode from otatime.py RESULT lines.
|
||||
Usage: summ.py RESULTS_FILE [ROUNDS]
|
||||
Exits 1 when any upload failed, an upload time could not be read, or (with ROUNDS) a board and mode has fewer rows."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
rows = [
|
||||
json.loads(line.split("RESULT ", 1)[1])
|
||||
for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
|
||||
if "RESULT " in line
|
||||
]
|
||||
by: dict[tuple[str, str], list[dict]] = {}
|
||||
for r in rows:
|
||||
m = re.search(r"upload ([\d.]+)", r["took"])
|
||||
r["up"] = float(m[1]) if m else None
|
||||
# TAG is PHASE-BOARD-ROUND; group per phase and board
|
||||
by.setdefault((r["board"].rsplit("-", 1)[0], r["mode"]), []).append(r)
|
||||
rounds = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
failed = unparsed = short = 0
|
||||
for (board, mode), v in sorted(by.items()):
|
||||
ok = sorted(r["up"] for r in v if r["rc"] == 0 and r["up"] is not None)
|
||||
fails = sum(r["rc"] != 0 for r in v)
|
||||
failed += fails
|
||||
unparsed += sum(r["rc"] == 0 and r["up"] is None for r in v)
|
||||
short += len(v) < rounds
|
||||
udp = sum(r["udp"] for r in v if r["rc"] == 0)
|
||||
med = statistics.median(ok) if ok else None
|
||||
print(
|
||||
f"{board:24} {mode:12} n={len(v)} fail={fails} udp={udp} median={med} upload s: {ok}"
|
||||
)
|
||||
if not rows:
|
||||
print("FAIL: no results")
|
||||
elif failed or unparsed or short:
|
||||
print(
|
||||
f"FAIL: {failed} failed uploads, {unparsed} without an upload time, {short} groups under {rounds} rows"
|
||||
)
|
||||
else:
|
||||
print("PASS")
|
||||
sys.exit(0 if rows and not (failed or unparsed or short) else 1)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Write the trimmed LibreTiny UF2 the CLI would send (esphome#21642) next to firmware.uf2 and print its path.
|
||||
Usage: trim_uf2.py FIRMWARE_UF2 (run with the phase's checkout on PYTHONPATH)
|
||||
Prints the input path unchanged when the tree has no device_ota_uf2() or there is nothing to drop."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
src = Path(sys.argv[1])
|
||||
try:
|
||||
from esphome.components.libretiny.uf2 import device_ota_uf2
|
||||
except ModuleNotFoundError as err:
|
||||
if err.name != "esphome.components.libretiny.uf2":
|
||||
raise
|
||||
print("no device_ota_uf2() in this tree, sending the full UF2", file=sys.stderr)
|
||||
print(src)
|
||||
sys.exit(0)
|
||||
data = src.read_bytes()
|
||||
if (trimmed := device_ota_uf2(data)) is None:
|
||||
print(src)
|
||||
sys.exit(0)
|
||||
out = src.with_name("firmware.ota.uf2")
|
||||
out.write_bytes(trimmed)
|
||||
print(f"trimmed {len(data)} -> {len(trimmed)} bytes", file=sys.stderr)
|
||||
print(out)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Summarise the AP restart run: alive lines over syslog per DUT, reboots, gaps, recovery after each AP start.
|
||||
Usage: an.py DIR BUILD [RECOVER_S] reads BUILD-marks.txt, BUILD-syslog.txt, BUILD-bk.txt, BUILD-cb.txt (optional)
|
||||
and BUILD-up.txt (the bridge's API logs)
|
||||
Exits 1 when a DUT rebooted, crashed, sent nothing, or had no syslog line within RECOVER_S (default 120) of an
|
||||
AP start, when the BK7238 capture (or a CB3S capture that was started) is empty, or when the bridge did not switch
|
||||
its AP to WPA2/WPA3 mixed after each AP start."""
|
||||
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, b = Path(sys.argv[1]), sys.argv[2]
|
||||
RECOVER = int(sys.argv[3]) if len(sys.argv) > 3 else 120
|
||||
|
||||
|
||||
def read(suffix: str) -> str | None:
|
||||
f = D / f"{b}-{suffix}.txt"
|
||||
return f.read_text(encoding="utf-8", errors="replace") if f.exists() else None
|
||||
|
||||
|
||||
def secs(t: str) -> int:
|
||||
h, m, s = t.split(":")
|
||||
return int(h) * 3600 + int(m) * 60 + int(float(s))
|
||||
|
||||
|
||||
marks = [
|
||||
(secs(line.split()[0]), line.split()[3])
|
||||
for line in read("marks").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
sl = (read("syslog") or "").splitlines()
|
||||
bad = []
|
||||
for dev in ("rebk", "recb"):
|
||||
lines = [line for line in sl if re.search(rf"\b{dev}\b", line)]
|
||||
ups = [
|
||||
(secs(line.split()[0]), int(m[1]))
|
||||
for line in lines
|
||||
if (m := re.search(r"up (\d+) s", line))
|
||||
]
|
||||
if not ups:
|
||||
print(f"== {dev}: no alive lines over syslog")
|
||||
bad.append(f"{dev} silent")
|
||||
continue
|
||||
reboots = sum(1 for (_, u0), (_, u1) in pairwise(ups) if u1 < u0)
|
||||
gaps = [t1 - t0 for (t0, _), (t1, _) in pairwise(ups) if t1 - t0 > 25]
|
||||
print(
|
||||
f"== {dev}: alive lines {len(ups)}, reboots {reboots}, syslog gaps over 25 s: {gaps}"
|
||||
)
|
||||
if reboots:
|
||||
bad.append(f"{dev} rebooted")
|
||||
for t, st in marks:
|
||||
if st != "on":
|
||||
continue
|
||||
after = [x for x in ups if x[0] > t]
|
||||
first = after[0][0] - t if after else None
|
||||
print(
|
||||
f" ap back at t={t % 86400 // 3600:02d}:{t % 3600 // 60:02d}:{t % 60:02d}, first syslog line {first} s later"
|
||||
)
|
||||
if first is None or first > RECOVER:
|
||||
bad.append(f"{dev} not back {RECOVER} s after an AP start")
|
||||
for dev, suffix in (("rebk", "bk"), ("recb", "cb")):
|
||||
if (s := read(suffix)) is None and dev == "recb":
|
||||
continue # the CB3S capture is optional
|
||||
if not s:
|
||||
bad.append(f"{dev} serial capture missing or empty")
|
||||
continue
|
||||
crashes = len(re.findall(r"(?i)hard fault|exception|abort|watchdog", s))
|
||||
connected = len(re.findall(r"\[wifi:\d+\]: Connected", s))
|
||||
print(
|
||||
f"-- {dev} serial: lines {s.count(chr(10))}, overflow {s.count('queue overflow')}, fallback AP starts "
|
||||
f"{s.count('Starting fallback AP')}, connected {connected}, boots {s.count('ESPHome version')}, crashes {crashes}"
|
||||
)
|
||||
if crashes:
|
||||
bad.append(f"{dev} crashed")
|
||||
up = read("up") or ""
|
||||
switches = [
|
||||
secs(m[1]) for m in re.finditer(r"\[(\d+:\d+:\d+)[.\d]*\].*PMF capable: 0", up)
|
||||
]
|
||||
# each AP start needs its own switch between that mark (less a few seconds of clock skew) and the next mark
|
||||
ends = [t for t, _ in marks[1:]] + [float("inf")]
|
||||
missed = [
|
||||
t
|
||||
for (t, st), end in zip(marks, ends, strict=True)
|
||||
if st == "on" and not any(t - 5 <= s < end for s in switches)
|
||||
]
|
||||
starts = sum(1 for _, st in marks if st == "on")
|
||||
print(
|
||||
f"-- bridge: WPA2/WPA3 switches {len(switches)}, AP starts {starts} ({len(missed)} without a switch), "
|
||||
f"failures {up.count('AP CONFIG FAILED')}"
|
||||
)
|
||||
if "AP CONFIG FAILED" in up or missed:
|
||||
bad.append("the bridge did not switch its AP to WPA2/WPA3 mixed after every start")
|
||||
print("PASS" if not bad else "FAIL: " + ", ".join(bad))
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,58 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
manual_ip:
|
||||
static_ip: ${ip}
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
dns1: 10.40.0.1
|
||||
power_save_mode: LIGHT
|
||||
ap:
|
||||
ssid: ${name}-fallback
|
||||
password: !secret fallback_ap_password
|
||||
|
||||
captive_portal:
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- !secret syslog_ip
|
||||
time:
|
||||
- platform: sntp
|
||||
syslog:
|
||||
port: 5514
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: ESP_LOGI("alive", "up %u s", (unsigned) (millis() / 1000));
|
||||
- interval: 30s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: |-
|
||||
static bool done = false;
|
||||
if (done || millis() < 19 * 60 * 1000) return false;
|
||||
done = true;
|
||||
return true;
|
||||
then:
|
||||
- lambda: ESP_LOGI("alive", "GOING HOME");
|
||||
- wifi.configure:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
save: false
|
||||
@@ -0,0 +1,13 @@
|
||||
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
|
||||
substitutions:
|
||||
name: rebk
|
||||
ip: 10.40.0.21
|
||||
|
||||
packages:
|
||||
common: !include re-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,13 @@
|
||||
# CB3S (BK7231N) DUT, flashed over the air by run.sh; its log port is UART1 (TX1)
|
||||
substitutions:
|
||||
name: recb
|
||||
ip: 10.40.0.22
|
||||
|
||||
packages:
|
||||
common: !include re-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: cb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh BUILD OTA the CB3S, serial flash the BK7238, then switch the upstream AP off and on three times
|
||||
# while both DUTs send alive lines over syslog. BUILD is a label (pr, base) for the output files.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wisp_up_ssid,
|
||||
# wisp_up_password, fallback_ap_password, syslog_ip, api_key) and run it there. wisp-upstream-wpa3.yaml runs on the
|
||||
# ethernet ESP32-S3 upstream; syslog_ip is this host's address as the DUTs reach it through the bridge's NAT.
|
||||
# env: TREE (esphome checkout for the DUTs), BK_PORT (BK7238 serial), CB_PORT (CB3S log only serial, optional),
|
||||
# CB_HOST (CB3S address to OTA), UPSTREAM (bridge address), SC (path to serial_capture.py), FLASH_BK (path to
|
||||
# flash_bk72xx.py), LT (LibreTiny penv python), PY (checkout python), UP_TREE (checkout for the bridge's API logs)
|
||||
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
BK=${BK_PORT:?set BK_PORT}; CBH=${CB_HOST:?set CB_HOST}; UP=${UPSTREAM:?set UPSTREAM}; SC=${SC:?set SC}
|
||||
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}; CB=${CB_PORT:-}
|
||||
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
|
||||
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
|
||||
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
|
||||
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
|
||||
# only this run's ports: a global pkill stops other runs' captures
|
||||
pkill -f "[s]erial_capture.py $BK"; [ -n "$CB" ] && pkill -f "[s]erial_capture.py $CB"
|
||||
[ "$(ap on)" = 200 ] || { echo "FAIL: upstream bridge did not take ap on"; exit 1; }
|
||||
cb_upload() { build run --no-logs "$D/recb.yaml" --device "$CBH" > "$D/cb-$b-upload.log" 2>&1; }
|
||||
retry 3 15 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
|
||||
build compile "$D/rebk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
|
||||
UF2=$D/.esphome/build/rebk/.pioenvs/rebk/firmware.uf2
|
||||
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
|
||||
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
|
||||
PIDS=()
|
||||
$PY - > "$D/$b-syslog.txt" 2>&1 << 'PYEOF' &
|
||||
import socket
|
||||
import time
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("0.0.0.0", 5514))
|
||||
s.settimeout(1)
|
||||
end = time.time() + 1250
|
||||
while time.time() < end:
|
||||
try:
|
||||
d, a = s.recvfrom(2048)
|
||||
print(time.strftime("%H:%M:%S"), a[0], d.decode(errors="replace").strip(), flush=True)
|
||||
except TimeoutError:
|
||||
pass
|
||||
PYEOF
|
||||
PIDS+=($!)
|
||||
$PY "$SC" "$BK" 1240 "$D/$b-bk.txt" --released > /dev/null 2>&1 & PIDS+=($!)
|
||||
[ -n "$CB" ] && { $PY "$SC" "$CB" 1240 "$D/$b-cb.txt" --released > /dev/null 2>&1 & PIDS+=($!); }
|
||||
(cd "${UP_TREE:-$T}" && PYTHONUNBUFFERED=1 exec timeout 1240 $PY -m esphome logs "$D/wisp-upstream-wpa3.yaml" --device "$UP" > "$D/$b-up.txt" 2>&1) & PIDS+=($!)
|
||||
trap 'kill "${PIDS[@]}" 2>/dev/null' EXIT
|
||||
t0=$(date +%s); : > "$D/$b-marks.txt"
|
||||
for step in 180:off 330:on 480:off 630:on 780:off 930:on; do
|
||||
t=${step%%:*}; st=${step#*:}
|
||||
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
|
||||
code=$(ap "$st")
|
||||
echo "$(date +%H:%M:%S) t=$t ap $st $code" | tee -a "$D/$b-marks.txt"
|
||||
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
|
||||
done
|
||||
wait "${PIDS[@]}"; trap - EXIT
|
||||
$PY "$D/an.py" "$D" "$b"
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_event.h>
|
||||
@@ -0,0 +1,95 @@
|
||||
esphome:
|
||||
name: wisp-upstream
|
||||
includes:
|
||||
- wdap_inc.h
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- script.execute: wpa3_ap
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap_mode: WISP
|
||||
manual_ip:
|
||||
static_ip: 10.40.0.1
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: ap off
|
||||
on_press:
|
||||
- wifi.disable:
|
||||
- platform: template
|
||||
name: ap on
|
||||
on_press:
|
||||
- wifi.enable:
|
||||
- delay: 3s
|
||||
- script.execute: wpa3_ap
|
||||
|
||||
script:
|
||||
- id: wpa3_ap
|
||||
then:
|
||||
- lambda: |-
|
||||
static bool registered = false;
|
||||
wifi_config_t c{};
|
||||
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
|
||||
if (err == ESP_OK) {
|
||||
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
|
||||
c.ap.pmf_cfg.required = false;
|
||||
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
err = esp_wifi_set_config(WIFI_IF_AP, &c);
|
||||
}
|
||||
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
|
||||
wifi_config_t check{};
|
||||
if (err == ESP_OK)
|
||||
err = esp_wifi_get_config(WIFI_IF_AP, &check);
|
||||
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
|
||||
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
|
||||
} else {
|
||||
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
|
||||
}
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
auto *e = (wifi_event_ap_staconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5]);
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
auto *e = (wifi_event_ap_stadisconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X reason %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->reason);
|
||||
}
|
||||
}, nullptr);
|
||||
@@ -0,0 +1,16 @@
|
||||
substitutions:
|
||||
name: wispdut-aoi
|
||||
framework: esp-idf
|
||||
packages:
|
||||
common: !include dut-common.yaml
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
post_connect_roaming: false
|
||||
ap:
|
||||
ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
ap_mode: ALWAYS_ON
|
||||
web_server:
|
||||
port: 80
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: ${framework}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,14 @@
|
||||
substitutions:
|
||||
name: wispdut-wisp
|
||||
framework: esp-idf
|
||||
packages:
|
||||
common: !include dut-common.yaml
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap:
|
||||
ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
ap_mode: WISP
|
||||
captive_portal:
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh DUT START SECONDS T:STATE... e.g. run.sh dut-wisp on 600 120:off 300:on
|
||||
# Builds and flashes DUT.yaml (dut-wisp or dut-always-on) over serial, sets the upstream AP to START, captures the
|
||||
# DUT and the client, and switches the upstream AP at each T seconds; sum.py summarises the client per phase.
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wisp_up_ssid, wisp_up_password, dut_ap_ssid,
|
||||
# dut_ap_password, lan_probe_url, api_key) and run it there. wisp-upstream.yaml runs on an ethernet ESP32-S3 and
|
||||
# wispcli.yaml on a D1 mini; flash both once (CLI_FLASH=1 builds and flashes the client too).
|
||||
# env: TREE (esphome checkout for the DUT), PORT (DUT serial), CLI_PORT (client serial), UPSTREAM (upstream bridge
|
||||
# address), SC (path to serial_capture.py), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); n=${1:?dut yaml name}; start=${2:?on or off}; dur=${3:?seconds}; shift 3
|
||||
T=${TREE:?set TREE}; P=${PORT:?set PORT}; CP=${CLI_PORT:?set CLI_PORT}; UP=${UPSTREAM:?set UPSTREAM}
|
||||
SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
|
||||
pkill -f "[s]erial_capture.py ($P|$CP)"
|
||||
[ "$(ap "$start")" = 200 ] || { echo "FAIL: upstream bridge did not take ap $start"; exit 1; }
|
||||
if [ "${CLI_FLASH:-0}" = 1 ]; then
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/wispcli.yaml" --device "$CP" > "$D/wispcli-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: client build or upload"; exit 1; }
|
||||
fi
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$n.yaml" --device "$P" > "$D/$n-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: DUT build or upload"; exit 1; }
|
||||
echo "$(date +%H:%M:%S) t=0 upstream $start start" > "$D/$n-marks.txt" # phase 1 is the start state
|
||||
$PY "$SC" "$P" "$dur" "$D/$n-dut.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
# no --reset on the client: a CH340 D1 mini floods garbage after an RTS reset
|
||||
$PY "$SC" "$CP" "$dur" "$D/$n-cli.txt" > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null' EXIT
|
||||
t0=$(date +%s)
|
||||
for step in "$@"; do
|
||||
t=${step%%:*}; st=${step#*:}
|
||||
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
|
||||
code=$(ap "$st")
|
||||
echo "$(date +%H:%M:%S) t=$t upstream $st $code" | tee -a "$D/$n-marks.txt"
|
||||
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
|
||||
done
|
||||
wait $C1 $C2; trap - EXIT
|
||||
$PY "$D/sum.py" "$D" "$n"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Summarise the client's results per upstream phase and the DUT's connects, AP starts, crashes and boots.
|
||||
Usage: sum.py DIR NAME reads NAME-marks.txt, NAME-cli.txt and NAME-dut.txt
|
||||
Phase N runs from mark N (the first mark is the start state). Exits 1 when the DUT crashed, the DUT capture does not
|
||||
hold exactly one boot (empty or rebooted), the client had no successful result at all, or a phase with the upstream
|
||||
on had no successful name lookup (an answer equal to the gateway is the captive portal and does not count)."""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, n = Path(sys.argv[1]), sys.argv[2]
|
||||
|
||||
|
||||
def read(suffix: str) -> str:
|
||||
return (D / f"{n}-{suffix}.txt").read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
mark_lines = [line.split() for line in read("marks").splitlines() if line.strip()]
|
||||
marks = [m[0] for m in mark_lines]
|
||||
upstream = {
|
||||
i + 1: m[3] for i, m in enumerate(mark_lines)
|
||||
} # "HH:MM:SS t=N upstream on|off CODE"
|
||||
cli = [line for line in read("cli").splitlines() if "[cli" in line]
|
||||
|
||||
|
||||
def phase(t: str) -> int:
|
||||
return sum(1 for m in marks if t >= m)
|
||||
|
||||
|
||||
stat: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
good: dict[int, int] = defaultdict(int) # successful name lookups per phase
|
||||
any_ok = 0
|
||||
for line in cli:
|
||||
m = re.search(r"\]: (NOT CONNECTED|NET .*|NAME \S+|GW \S+|MAC \S+)", line)
|
||||
if not m:
|
||||
continue
|
||||
s = m.group(1)
|
||||
if s.startswith("NET"):
|
||||
dns = re.search(r"dns (\S+)", s).group(1)
|
||||
gw = re.search(r"gw (\S+)", s).group(1)
|
||||
res = s.split("-> ")[1].strip()
|
||||
# the captive portal answers every name with the AP's own address; that is not a working upstream
|
||||
s = f"dns={dns} resolve={'CAPTIVE' if res == gw else res}"
|
||||
if res not in ("FAIL", gw):
|
||||
good[phase(line[1:9])] += 1
|
||||
any_ok += 1
|
||||
elif re.match(r"(NAME|GW|MAC) 200\b", s):
|
||||
any_ok += 1
|
||||
stat[phase(line[1:9])][s] += 1
|
||||
for p in sorted(stat):
|
||||
print(f"phase {p} (upstream {upstream.get(p, '?')}):", dict(stat[p]))
|
||||
dead = [p for p, st in upstream.items() if st == "on" and not good[p]]
|
||||
dut = read("dut")
|
||||
crash = len(re.findall(r"Guru|panic|abort\(", dut))
|
||||
boots = dut.count("ESPHome version")
|
||||
print(
|
||||
"DUT connected:",
|
||||
len(re.findall(r"\[wifi:\d+\]: Connected", dut)),
|
||||
"AP starts:",
|
||||
dut.count("Starting AP"),
|
||||
"crash:",
|
||||
crash,
|
||||
"boots:",
|
||||
boots,
|
||||
)
|
||||
failed = []
|
||||
if not cli:
|
||||
failed.append("empty client log")
|
||||
elif not any_ok:
|
||||
failed.append("the client never got a successful result")
|
||||
if dead:
|
||||
failed.append(f"no successful lookup in upstream on phases {dead}")
|
||||
# run.sh resets the DUT when the capture starts, so a good capture holds exactly one boot
|
||||
if boots != 1:
|
||||
failed.append(f"{boots} DUT boots captured, expected 1")
|
||||
if crash:
|
||||
failed.append(f"{crash} DUT crash lines")
|
||||
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,52 @@
|
||||
esphome:
|
||||
name: wisp-upstream
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: !secret wisp_up_ssid
|
||||
password: !secret wisp_up_password
|
||||
ap_mode: WISP
|
||||
manual_ip:
|
||||
static_ip: 10.40.0.1
|
||||
gateway: 10.40.0.1
|
||||
subnet: 255.255.255.0
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: ap off
|
||||
on_press:
|
||||
- wifi.disable:
|
||||
- platform: template
|
||||
name: ap on
|
||||
on_press:
|
||||
- wifi.enable:
|
||||
@@ -0,0 +1,77 @@
|
||||
esphome:
|
||||
name: wispcli
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: !secret dut_ap_ssid
|
||||
password: !secret dut_ap_password
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
http_request:
|
||||
timeout: 5s
|
||||
verify_ssl: false
|
||||
|
||||
globals:
|
||||
- id: tick
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 3s
|
||||
then:
|
||||
- lambda: |-
|
||||
id(tick)++;
|
||||
if (id(tick) % 4 != 1) return;
|
||||
if (!WiFi.isConnected()) { ESP_LOGI("cli", "NOT CONNECTED"); return; }
|
||||
IPAddress r;
|
||||
bool ok = WiFi.hostByName("neverssl.com", r, 3000);
|
||||
ESP_LOGI("cli", "NET ip %s gw %s dns %s | neverssl.com -> %s", WiFi.localIP().toString().c_str(),
|
||||
WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP(0).toString().c_str(),
|
||||
ok ? r.toString().c_str() : "FAIL");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 2 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: http://neverssl.com/
|
||||
capture_response: true
|
||||
max_response_buffer_size: 160
|
||||
collect_headers:
|
||||
- location
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "NAME %d loc=%s body=%.60s", response->status_code, response->get_response_header("location").c_str(), body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "NAME error");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 3 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !lambda 'return std::string("http://") + WiFi.gatewayIP().toString().c_str() + "/";'
|
||||
capture_response: true
|
||||
max_response_buffer_size: 160
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "GW %d body=%.60s", response->status_code, body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "GW error");
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(tick) % 4 == 0 && WiFi.isConnected();
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !secret lan_probe_url
|
||||
capture_response: true
|
||||
max_response_buffer_size: 64
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("cli", "MAC %d body=%.20s", response->status_code, body.c_str());
|
||||
on_error:
|
||||
- lambda: ESP_LOGI("cli", "MAC error");
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Summarise the PMF AP's join and leave events per station and the BK7238's joins.
|
||||
Usage: an.py DIR BUILD [AP_SSID] reads DIR/ap-BUILD.txt and DIR/bk-BUILD.txt (AP_SSID defaults to wd-pmf-ap)
|
||||
J = join, L<n> = leave with reason n. Reason 3: the station sent a deauth. Reason 2: the AP found a stale
|
||||
association when the station came back; a run of reason 2 before one join is reconnects the AP rejected.
|
||||
env DUT_MACS: comma separated station MACs that must appear in the AP events (default: at least two stations).
|
||||
Exits 1 on a rejected reconnect, an AP that did not switch to WPA2/WPA3, a missing DUT, or a BK7238 that never
|
||||
joined the AP."""
|
||||
|
||||
from itertools import pairwise
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, b = Path(sys.argv[1]), sys.argv[2]
|
||||
AP_SSID = sys.argv[3] if len(sys.argv) > 3 else "wd-pmf-ap"
|
||||
failed = []
|
||||
ap = (D / f"ap-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if not any("PMF capable: 0" in line for line in ap) or any(
|
||||
"AP CONFIG FAILED" in line for line in ap
|
||||
):
|
||||
failed.append("the AP did not switch to WPA2/WPA3 mixed")
|
||||
ev = [line for line in ap if "STA JOIN" in line or "STA LEAVE" in line]
|
||||
mac_re = re.compile(r"([0-9A-F]{2}(?::[0-9A-F]{2}){5})")
|
||||
macs = sorted({m.group(1) for line in ev if (m := mac_re.search(line))})
|
||||
rejected = 0
|
||||
print(f"== {b}")
|
||||
for m in macs:
|
||||
seq = [
|
||||
"J" if "JOIN" in line else "L" + re.search(r"reason (\d+)", line).group(1)
|
||||
for line in ev
|
||||
if m in line
|
||||
]
|
||||
# every L2 after the first in a run without a join in between is a rejected reconnect
|
||||
rej = sum(1 for prev, cur in pairwise(seq) if prev == cur == "L2")
|
||||
rejected += rej
|
||||
print(" ", m, " ".join(seq), f"(rejected {rej})")
|
||||
if rejected:
|
||||
failed.append(f"{rejected} rejected reconnects")
|
||||
if want := [
|
||||
x.strip().upper() for x in os.environ.get("DUT_MACS", "").split(",") if x.strip()
|
||||
]:
|
||||
if missing := [x for x in want if x not in macs]:
|
||||
failed.append(f"no AP events for {', '.join(missing)}")
|
||||
elif len(macs) < 2:
|
||||
failed.append(f"AP events for {len(macs)} stations, expected both DUTs")
|
||||
bk = (D / f"bk-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
joins = [
|
||||
m.groups() for line in bk if (m := re.search(r"JOINED (.+?) after (\d+) ms", line))
|
||||
]
|
||||
print(" bk7238 joins:", " ".join(f"{s}@{int(t) / 1000:.1f}s" for s, t in joins))
|
||||
print(" bk7238 reboots:", sum("REBOOT on" in line for line in bk))
|
||||
if not any(s == AP_SSID for s, _ in joins):
|
||||
failed.append(f"the BK7238 never joined {AP_SSID}")
|
||||
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh BUILD flash the PMF AP, OTA the CB3S, serial flash the BK7238, then capture one AP window
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wd_ap_password) and
|
||||
# run it there. BUILD is a label (pr, base) for the output files.
|
||||
# env: TREE (esphome checkout for the DUTs), AP_PORT (classic devkit running wdap.yaml), BK_PORT (BK7238 serial),
|
||||
# CB_HOST (CB3S address on the house network), SC (path to serial_capture.py), FLASH_BK (path to flash_bk72xx.py),
|
||||
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), PY (checkout python), DUT_MACS
|
||||
# (optional, comma separated station MACs of both DUTs for an.py)
|
||||
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
AP=${AP_PORT:?set AP_PORT}; BK=${BK_PORT:?set BK_PORT}; CB=${CB_HOST:?set CB_HOST}; SC=${SC:?set SC}
|
||||
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
|
||||
pkill -f "[s]erial_capture.py ($AP|$BK)"
|
||||
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
|
||||
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
|
||||
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
|
||||
build run --no-logs "$D/wdap.yaml" --device "$AP" > "$D/ap-$b-upload.log" 2>&1 || { echo "FAIL: AP build or upload"; exit 1; }
|
||||
# The DUT reboots 150 s after joining the house network; retry when it reboots mid-upload.
|
||||
cb_upload() { build run --no-logs "$D/wdcb.yaml" --device "$CB" > "$D/cb-$b-upload.log" 2>&1; }
|
||||
retry 3 20 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
|
||||
build compile "$D/wdbk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
|
||||
# The build directory has two .uf2 files; firmware.uf2 is the one to flash.
|
||||
UF2=$D/.esphome/build/wdbk/.pioenvs/wdbk/firmware.uf2
|
||||
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
|
||||
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
|
||||
sleep 20
|
||||
# The reset restarts the AP's 420 s window.
|
||||
$PY "$SC" "$AP" 450 "$D/ap-$b.txt" --reset > /dev/null 2>&1 & C1=$!
|
||||
sleep 8
|
||||
$PY "$SC" "$BK" 440 "$D/bk-$b.txt" --released > /dev/null 2>&1 & C2=$!
|
||||
trap 'kill $C1 $C2 2>/dev/null' EXIT
|
||||
wait $C1 $C2; trap - EXIT
|
||||
$PY "$D/an.py" "$D" "$b"
|
||||
@@ -0,0 +1,44 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: connect_ms
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: ${ap_ssid}
|
||||
password: !secret wd_ap_password
|
||||
priority: 10
|
||||
- ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
priority: 0
|
||||
on_connect:
|
||||
- lambda: |-
|
||||
id(connect_ms) = millis();
|
||||
char buf[wifi::SSID_BUFFER_SIZE];
|
||||
ESP_LOGI("wd", "JOINED %s after %u ms since boot", wifi::global_wifi_component->wifi_ssid_to(buf), (unsigned) millis());
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: |-
|
||||
if (!wifi::global_wifi_component->is_connected() || id(connect_ms) == 0)
|
||||
return;
|
||||
char buf[wifi::SSID_BUFFER_SIZE];
|
||||
bool on_ap = strcmp(wifi::global_wifi_component->wifi_ssid_to(buf), "${ap_ssid}") == 0;
|
||||
uint32_t up = millis() - id(connect_ms);
|
||||
if (up > (on_ap ? 30000u : 150000u)) {
|
||||
ESP_LOGI("wd", "REBOOT on %s", on_ap ? "ap" : "home");
|
||||
App.safe_reboot();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
substitutions:
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
esphome:
|
||||
name: wdap
|
||||
includes:
|
||||
- wdap_inc.h
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- delay: 3s
|
||||
- lambda: |-
|
||||
wifi_config_t c{};
|
||||
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
|
||||
if (err == ESP_OK) {
|
||||
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
|
||||
c.ap.pmf_cfg.required = false;
|
||||
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
c.ap.max_connection = 4;
|
||||
err = esp_wifi_set_config(WIFI_IF_AP, &c);
|
||||
}
|
||||
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
|
||||
wifi_config_t check{};
|
||||
if (err == ESP_OK)
|
||||
err = esp_wifi_get_config(WIFI_IF_AP, &check);
|
||||
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
|
||||
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
|
||||
} else {
|
||||
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
|
||||
}
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
auto *e = (wifi_event_ap_staconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X aid %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, (unsigned) millis());
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
auto *e = (wifi_event_ap_stadisconnected_t *) data;
|
||||
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X aid %u reason %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, e->reason, (unsigned) millis());
|
||||
}
|
||||
}, nullptr);
|
||||
- delay: 420s
|
||||
- lambda: ESP_LOGI("wdap", "AP WINDOW OVER");
|
||||
- wifi.disable:
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: ${ap_ssid}
|
||||
password: !secret wd_ap_password
|
||||
ap_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_event.h>
|
||||
@@ -0,0 +1,13 @@
|
||||
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
|
||||
substitutions:
|
||||
name: wdbk
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
packages:
|
||||
common: !include wd-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,13 @@
|
||||
# CB3S (BK7231N) DUT, flashed over the air by run.sh
|
||||
substitutions:
|
||||
name: wdcb
|
||||
ap_ssid: wd-pmf-ap
|
||||
|
||||
packages:
|
||||
common: !include wd-common.yaml
|
||||
|
||||
bk72xx:
|
||||
board: cb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Count the Xiaomi, ATC and PVVX publishes in a capture and compare them with expected.json from gen.py.
|
||||
Usage: count.py CAPTURE [pr|base] [--allow-loss N] [--probe]
|
||||
--probe (injection run): the PR build must leave the probe frame's service data unchanged.
|
||||
Exits 0 when every count matches the model (or falls short by at most N over the air), 1 otherwise."""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("capture", type=Path)
|
||||
ap.add_argument("build", nargs="?", choices=["pr", "base"])
|
||||
ap.add_argument("--allow-loss", type=int, default=0)
|
||||
ap.add_argument("--probe", action="store_true")
|
||||
a = ap.parse_args()
|
||||
cap, build, loss = a.capture, a.build, a.allow_loss
|
||||
LETTER = {"0A": "A", "0B": "B", "0C": "C", "0D": "D", "0E": "E", "0F": "F", "10": "G"}
|
||||
GOT = re.compile(
|
||||
r"Got (?:Xiaomi \S+|ATC MiThermometer|PVVX MiThermometer) \([AC]4:C1:38:00:00:(\w\w)\)"
|
||||
)
|
||||
|
||||
counts = collections.Counter()
|
||||
cur = None
|
||||
humidity = set()
|
||||
probe = {}
|
||||
for line in cap.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if m := GOT.search(line):
|
||||
cur = LETTER[m.group(1)]
|
||||
continue
|
||||
if m := re.search(r"\[probe.*\b(before|after)\s+([0-9A-F]+)", line):
|
||||
probe[m.group(1)] = m.group(2)
|
||||
if "Humidity:" in line:
|
||||
humidity.add(line.split("Humidity:")[1].strip())
|
||||
if (m := re.search(r"Temperature: (-?[\d.]+)", line)) and cur:
|
||||
t = float(m.group(1))
|
||||
tag = cur
|
||||
if (
|
||||
cur == "A" and t >= 50
|
||||
): # after-bad frames are 60.1 to 64.0, normal ones 22.1 to 26.0
|
||||
tag = "A_after_bad"
|
||||
if cur == "A" and abs(t - 33.3) < 0.01:
|
||||
tag = "A_probe" # xb_rewrite_probe's own parse, not part of the model
|
||||
if cur == "D" and abs(t - 11.1) < 0.01:
|
||||
tag = "D_boot"
|
||||
counts[tag] += 1
|
||||
cur = None
|
||||
print(cap.name, dict(sorted(counts.items())))
|
||||
print(" humidity values", len(humidity))
|
||||
if not counts:
|
||||
print("FAIL: no Xiaomi publishes in the capture")
|
||||
sys.exit(1)
|
||||
if build is None:
|
||||
sys.exit(0)
|
||||
expected = json.loads(Path("expected.json").read_text(encoding="utf-8"))[build]
|
||||
bad = 0
|
||||
for tag in sorted((set(expected) | set(counts)) - {"A_probe"}):
|
||||
want, got = expected.get(tag, 0), counts.get(tag, 0)
|
||||
ok = want - loss <= got <= want
|
||||
bad += not ok
|
||||
print(f" {tag:12} want {want:3} got {got:3} {'ok' if ok else 'DIFF'}")
|
||||
if a.probe:
|
||||
before, after = probe.get("before"), probe.get("after")
|
||||
print(f" probe before {before}\n probe after {after}")
|
||||
if before is None or after is None:
|
||||
print(" probe lines missing")
|
||||
bad += 1
|
||||
elif build == "pr" and before != after:
|
||||
print(" the listener rewrote the shared service data")
|
||||
bad += 1
|
||||
print(
|
||||
f"{'PASS' if not bad else 'FAIL'}: {build} model {'matches' if not bad else f'has {bad} failed checks'}"
|
||||
)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Generate MiBeacon, ATC and PVVX advertisements, a C++ header that sends them and the expected publish counts.
|
||||
Usage: gen.py [--ota]
|
||||
Without --ota: frames.hpp for xbinject.yaml (scan results injected into the tracker, A4:C1:38 addresses).
|
||||
With --ota: adv_frames.hpp for xbadv.yaml (a second ESP32 advertises them, C4:C1:38 random static addresses).
|
||||
Both write expected.json ({"base": {...}, "pr": {...}}) for count.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
|
||||
|
||||
OTA = "--ota" in sys.argv[1:]
|
||||
CYCLES = 40
|
||||
K1 = bytes.fromhex("6c0d1a3e5b7f29c4a18e90d2f3b46a57")
|
||||
K2 = bytes.fromhex("0f1e2d3c4b5a69788796a5b4c3d2e1f0")
|
||||
# Random static addresses need the top two bits set, so the over the air run uses C4 instead of A4.
|
||||
PREFIX = "C4C138" if OTA else "A4C138"
|
||||
MACS = {
|
||||
k: f"{PREFIX}0000{i + 10:02X}" for i, k in enumerate("ABCDEFG")
|
||||
} # ...0A to ...10
|
||||
MAC = {k: bytes.fromhex(v) for k, v in MACS.items()}
|
||||
MIDX = {k: i for i, k in enumerate(MACS)}
|
||||
|
||||
|
||||
def mibeacon_plain(dev, pid, cnt, obj):
|
||||
return (
|
||||
bytes([0x50, 0x20])
|
||||
+ struct.pack("<H", pid)
|
||||
+ bytes([cnt])
|
||||
+ MAC[dev][::-1]
|
||||
+ obj
|
||||
)
|
||||
|
||||
|
||||
def mibeacon_enc(dev, pid, cnt, obj, key, short, corrupt=False):
|
||||
ext = bytes([cnt, 0x00, 0x00])
|
||||
if short:
|
||||
hdr = bytes([0x48, 0x59]) + struct.pack("<H", pid) + bytes([cnt])
|
||||
else:
|
||||
hdr = (
|
||||
bytes([0x58, 0x58]) + struct.pack("<H", pid) + bytes([cnt]) + MAC[dev][::-1]
|
||||
)
|
||||
nonce = MAC[dev][::-1] + hdr[2:5] + ext
|
||||
ct = AESCCM(key, tag_length=4).encrypt(nonce, obj, b"\x11")
|
||||
body, tag = ct[:-4], ct[-4:]
|
||||
if corrupt:
|
||||
tag = bytes([tag[0] ^ 0xFF]) + tag[1:]
|
||||
out = hdr + body + ext + tag
|
||||
assert len(out) == (19 if short else 18 + len(obj)), len(out)
|
||||
return out
|
||||
|
||||
|
||||
def t16(v): # 0x1004 temperature object
|
||||
return bytes([0x04, 0x10, 0x02]) + struct.pack("<h", round(v * 10))
|
||||
|
||||
|
||||
def th(t, h): # 0x100D temperature + humidity
|
||||
return bytes([0x0D, 0x10, 0x04]) + struct.pack("<hh", round(t * 10), round(h * 10))
|
||||
|
||||
|
||||
frames = [] # (dev, kind 0 = FE95 / 1 = 181A, data, tag for the expectation)
|
||||
frames.append(("D", 0, mibeacon_plain("D", 0x0347, 0, t16(11.1)), "D_boot"))
|
||||
steps = [len(frames)]
|
||||
for k in range(1, CYCLES + 1):
|
||||
frames.append(("B", 0, mibeacon_plain("B", 0x01AA, k, th(20 + k / 10, 40)), "B"))
|
||||
frames.append(("C", 0, mibeacon_plain("C", 0x0098, k, t16(21 + k / 10)), "C"))
|
||||
frames.append(
|
||||
("A", 0, mibeacon_enc("A", 0x055B, k + 100, t16(22 + k / 10), K1, False), "A")
|
||||
)
|
||||
if k % 2 == 0:
|
||||
bad = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False, True)
|
||||
frames.append(("A", 0, bad, "A_bad"))
|
||||
good = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False)
|
||||
frames.append(("A", 0, good, "A_after_bad"))
|
||||
frames.append(
|
||||
("E", 0, mibeacon_enc("E", 0x1203, k + 50, th(23 + k / 10, 50), K2, True), "E")
|
||||
)
|
||||
frames.append(("D", 0, mibeacon_plain("D", 0x0347, k + 200, t16(24 + k / 10)), "D"))
|
||||
atc = (
|
||||
MAC["F"]
|
||||
+ struct.pack(">h", round((26 + k / 10) * 10))
|
||||
+ bytes([45, 90])
|
||||
+ struct.pack(">H", 2950)
|
||||
)
|
||||
frames.append(("F", 1, atc + bytes([k]), "F"))
|
||||
pv = (
|
||||
MAC["G"][::-1]
|
||||
+ struct.pack("<hHH", round((27 + k / 10) * 100), 4600, 3010)
|
||||
+ bytes([88, k, 0])
|
||||
)
|
||||
frames.append(("G", 1, pv, "G"))
|
||||
steps.append(len(frames))
|
||||
|
||||
|
||||
def simulate(pr):
|
||||
"""Base keeps one counter shared by every device and checks it before the tag; the PR keeps one per device."""
|
||||
last_shared = 0
|
||||
last = {}
|
||||
pub = {}
|
||||
for dev, kind, data, tag in frames:
|
||||
if kind == 1:
|
||||
cnt = data[12 if dev == "F" else 13]
|
||||
if last.get(("x", dev)) == cnt:
|
||||
continue
|
||||
last["x", dev] = cnt
|
||||
elif pr:
|
||||
if last.get(dev) == data[4] or tag == "A_bad":
|
||||
continue
|
||||
last[dev] = data[4]
|
||||
else:
|
||||
if last_shared == data[4]:
|
||||
continue
|
||||
last_shared = data[4]
|
||||
if tag == "A_bad":
|
||||
continue
|
||||
pub[tag] = pub.get(tag, 0) + 1
|
||||
return pub
|
||||
|
||||
|
||||
expected = {"base": simulate(False), "pr": simulate(True)}
|
||||
print("base", expected["base"])
|
||||
print("pr ", expected["pr"])
|
||||
Path("expected.json").write_text(json.dumps(expected), encoding="utf-8")
|
||||
|
||||
|
||||
def c_rows(name, rows):
|
||||
out = [f"static const uint8_t {name}[{len(rows)}][{len(rows[0][0])}] = {{"]
|
||||
out += [
|
||||
" {" + ",".join(f"0x{b:02X}" for b in row) + "}," + (f" // {c}" if c else "")
|
||||
for row, c in rows
|
||||
]
|
||||
return "\n".join([*out, "};", ""])
|
||||
|
||||
|
||||
mac_rows = [(MAC[k], "") for k in MACS]
|
||||
frame_rows = [
|
||||
((bytes([MIDX[d], kind, len(data)]) + data).ljust(28, b"\0"), tag)
|
||||
for d, kind, data, tag in frames
|
||||
]
|
||||
|
||||
INJECT = """
|
||||
inline void xb_fill(esphome::esp32_ble::BLEScanResult &r, const uint8_t *mac, bool fe95, const uint8_t *data,
|
||||
uint8_t len) {
|
||||
memcpy(r.bda, mac, 6);
|
||||
r.ble_addr_type = 0;
|
||||
r.rssi = -50;
|
||||
uint8_t n = 0;
|
||||
r.ble_adv[n++] = 2;
|
||||
r.ble_adv[n++] = 0x01;
|
||||
r.ble_adv[n++] = 0x06;
|
||||
r.ble_adv[n++] = len + 3;
|
||||
r.ble_adv[n++] = 0x16;
|
||||
r.ble_adv[n++] = fe95 ? 0x95 : 0x1A;
|
||||
r.ble_adv[n++] = fe95 ? 0xFE : 0x18;
|
||||
memcpy(r.ble_adv + n, data, len);
|
||||
r.adv_data_len = n + len;
|
||||
r.scan_rsp_len = 0;
|
||||
r.search_evt = ESP_GAP_SEARCH_INQ_RES_EVT;
|
||||
}
|
||||
|
||||
inline void xb_inject(size_t first, size_t last) {
|
||||
for (size_t i = first; i < last; i++) {
|
||||
const uint8_t *row = XB_FRAMES[i];
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
xb_fill(r, XB_MACS[row[0]], row[1] == 0, row + 3, row[2]);
|
||||
esphome::esp32_ble_tracker::global_esp32_ble_tracker->gap_scan_event_handler(r);
|
||||
}
|
||||
}
|
||||
|
||||
inline void xb_hex(const char *label, const std::vector<uint8_t> &v) {
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < v.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", v[i]);
|
||||
ESP_LOGI("probe", "%s %s", label, buf);
|
||||
}
|
||||
|
||||
// Parse one encrypted frame with the listener directly and show whether it rewrote the shared service data.
|
||||
inline void xb_rewrite_probe(esphome::ble_device_base::ESPBTDeviceListener *listener) {
|
||||
esphome::esp32_ble::BLEScanResult r{};
|
||||
xb_fill(r, XB_MACS[0], true, XB_PROBE, sizeof(XB_PROBE));
|
||||
esphome::ble_device_base::ESPBTDevice device;
|
||||
device.parse_scan_rst(r);
|
||||
xb_hex("before", device.get_service_datas()[0].data);
|
||||
bool ok = listener->parse_device(device);
|
||||
ESP_LOGI("probe", "parse_device returned %d", ok);
|
||||
xb_hex("after ", device.get_service_datas()[0].data);
|
||||
}
|
||||
"""
|
||||
|
||||
ADVERTISE = """
|
||||
static uint32_t af_failed = 0; // sends where a GAP call returned an error
|
||||
|
||||
// Returns false (and counts it) when the address, the data or the start was rejected.
|
||||
inline bool af_send(int i) {
|
||||
const uint8_t *row = AF_FRAMES[i];
|
||||
uint8_t adv[31];
|
||||
uint8_t n = 0;
|
||||
adv[n++] = 2;
|
||||
adv[n++] = 0x01;
|
||||
adv[n++] = 0x06;
|
||||
adv[n++] = row[2] + 3;
|
||||
adv[n++] = 0x16;
|
||||
adv[n++] = row[1] == 0 ? 0x95 : 0x1A;
|
||||
adv[n++] = row[1] == 0 ? 0xFE : 0x18;
|
||||
memcpy(adv + n, row + 3, row[2]);
|
||||
n += row[2];
|
||||
esp_ble_gap_stop_advertising(); // fails harmlessly when nothing is advertising yet
|
||||
uint8_t addr[6];
|
||||
memcpy(addr, AF_MACS[row[0]], 6);
|
||||
esp_err_t e1 = esp_ble_gap_set_rand_addr(addr);
|
||||
esp_err_t e2 = esp_ble_gap_config_adv_data_raw(adv, n);
|
||||
esp_ble_adv_params_t p{};
|
||||
p.adv_int_min = 0x20; // 20 ms
|
||||
p.adv_int_max = 0x20;
|
||||
p.adv_type = ADV_TYPE_NONCONN_IND;
|
||||
p.own_addr_type = BLE_ADDR_TYPE_RANDOM;
|
||||
p.channel_map = ADV_CHNL_ALL;
|
||||
p.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY;
|
||||
esp_err_t e3 = esp_ble_gap_start_advertising(&p);
|
||||
if (e1 == ESP_OK && e2 == ESP_OK && e3 == ESP_OK)
|
||||
return true;
|
||||
af_failed++;
|
||||
ESP_LOGW("af", "frame %d send failed: rand_addr %d adv_data %d start %d", i, e1, e2, e3);
|
||||
return false;
|
||||
}
|
||||
|
||||
inline void af_stop() { esp_ble_gap_stop_advertising(); }
|
||||
"""
|
||||
|
||||
if OTA:
|
||||
head = '#pragma once\n#include <esp_gap_ble_api.h>\n#include <cstring>\n#include "esphome/core/log.h"\n'
|
||||
body = c_rows("AF_MACS", mac_rows) + c_rows("AF_FRAMES", frame_rows)
|
||||
body += f"static constexpr int AF_COUNT = {len(frames)};\n" + ADVERTISE
|
||||
Path("adv_frames.hpp").write_text(head + body, encoding="utf-8")
|
||||
else:
|
||||
head = '#pragma once\n#include <cstring>\n#include "esphome/components/esp32_ble/ble_scan_result.h"\n'
|
||||
probe = mibeacon_enc("A", 0x055B, 0xF5, t16(33.3), K1, False)
|
||||
body = c_rows("XB_MACS", mac_rows) + c_rows("XB_FRAMES", frame_rows)
|
||||
body += (
|
||||
f"static const uint16_t XB_STEPS[{len(steps)}] = {{"
|
||||
+ ",".join(map(str, steps))
|
||||
+ "};\n"
|
||||
)
|
||||
body += f"static constexpr size_t XB_NUM_STEPS = {len(steps)};\n"
|
||||
body += (
|
||||
"static const uint8_t XB_PROBE[] = {"
|
||||
+ ",".join(f"0x{b:02X}" for b in probe)
|
||||
+ "};\n"
|
||||
)
|
||||
Path("frames.hpp").write_text(head + body + INJECT, encoding="utf-8")
|
||||
print("frames", len(frames), "longest", max(len(d) for _, _, d, _ in frames))
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh inject|ota pr|base build and flash the receiver, capture its serial log, compare the counts
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, api_key) and run it there.
|
||||
# inject: xbinject.yaml injects the frames into its own tracker. ota: xbrecv.yaml listens to xbadv.yaml, which must
|
||||
# already run on a second ESP32 (python gen.py --ota, then flash xbadv.yaml once).
|
||||
# env: TREE (esphome checkout to build from), PORT (receiver serial port), SC (path to serial_capture.py),
|
||||
# PY (checkout python), ADV (advertiser address, ota only)
|
||||
D=$(cd "$(dirname "$0")" && pwd); MODE=${1:?inject or ota}; B=${2:?pr or base}; T=${TREE:?set TREE}
|
||||
P=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
cd "$D" || exit 1
|
||||
if [ "$MODE" = ota ]; then
|
||||
A=${ADV:?set ADV}; Y=xbrecv; SECS=130; LOSS=2
|
||||
$PY gen.py --ota > gen.txt || { echo "gen.py failed"; exit 1; }
|
||||
else
|
||||
Y=xbinject; SECS=80; LOSS=0; PROBE=--probe
|
||||
$PY gen.py > gen.txt || { echo "gen.py failed"; exit 1; }
|
||||
fi
|
||||
OUT=$D/$MODE-$B; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$Y.yaml" --device "$P" > "$OUT/upload.log" 2>&1) \
|
||||
|| { echo "FAIL: build or upload failed, see $OUT/upload.log"; exit 1; }
|
||||
$PY "$SC" "$P" $SECS "$OUT/cap.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP 2>/dev/null' EXIT
|
||||
if [ "$MODE" = ota ]; then
|
||||
# 18 s lets the receiver boot and start scanning, so the counter 0 boot frame is the first one it hears
|
||||
sleep 18
|
||||
CODE=$(curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$A/button/start/press")
|
||||
[ "$CODE" = 200 ] || { echo "FAIL: advertiser start button returned $CODE"; exit 1; }
|
||||
fi
|
||||
wait $CAP; trap - EXIT
|
||||
[ -s "$OUT/cap.txt" ] || { echo "FAIL: serial capture got nothing"; exit 1; }
|
||||
CRASH=$(grep -acE 'Guru|abort\(\)|Backtrace:' "$OUT/cap.txt")
|
||||
$PY count.py "$OUT/cap.txt" "$B" --allow-loss $LOSS $PROBE; RC=$?
|
||||
if [ "$MODE" = ota ]; then
|
||||
# the advertiser counts frames whose GAP calls failed; those are not radio loss
|
||||
FAILED=$(curl -s -m 5 "http://$A/sensor/failed%20sends" | $PY -c "import json,sys; print(int(json.load(sys.stdin)['value']))")
|
||||
echo "advertiser failed sends: ${FAILED:-unknown}"
|
||||
[ "$FAILED" = 0 ] || RC=1
|
||||
fi
|
||||
echo "crash lines: $CRASH"
|
||||
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
|
||||
@@ -0,0 +1,76 @@
|
||||
esphome:
|
||||
name: xbadv
|
||||
includes:
|
||||
- adv_frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
esp32_ble:
|
||||
|
||||
globals:
|
||||
- id: af_idx
|
||||
type: int
|
||||
initial_value: "-1"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: start
|
||||
on_press:
|
||||
- lambda: |-
|
||||
id(af_idx) = 0;
|
||||
af_failed = 0;
|
||||
// an unfinished sequence reads as unknown, not as the 0 from an earlier run
|
||||
id(failed_sends).publish_state(NAN);
|
||||
ESP_LOGI("af", "sequence start");
|
||||
|
||||
interval:
|
||||
- interval: 250ms
|
||||
then:
|
||||
- lambda: |-
|
||||
int i = id(af_idx);
|
||||
if (i < 0)
|
||||
return;
|
||||
if (i >= AF_COUNT) {
|
||||
af_stop();
|
||||
id(af_idx) = -1;
|
||||
ESP_LOGI("af", "sequence done, %u failed sends", (unsigned) af_failed);
|
||||
id(failed_sends).publish_state(af_failed);
|
||||
return;
|
||||
}
|
||||
af_send(i);
|
||||
id(af_idx) = i + 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: failed_sends
|
||||
name: failed sends
|
||||
accuracy_decimals: 0
|
||||
update_interval: never
|
||||
@@ -0,0 +1,107 @@
|
||||
substitutions:
|
||||
name: xbinject
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
includes:
|
||||
- frames.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
globals:
|
||||
- id: xb_step
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
esp32_ble_tracker:
|
||||
on_ble_service_data_advertise:
|
||||
- mac_address: A4:C1:38:00:00:0A
|
||||
service_uuid: FE95
|
||||
then:
|
||||
- lambda: |-
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
|
||||
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
|
||||
|
||||
interval:
|
||||
- interval: 1s
|
||||
startup_delay: 15s
|
||||
then:
|
||||
- lambda: |-
|
||||
if (id(xb_step) + 1 >= (int) XB_NUM_STEPS) {
|
||||
if (id(xb_step) + 1 == (int) XB_NUM_STEPS) {
|
||||
ESP_LOGI("xb", "INJECT DONE");
|
||||
xb_rewrite_probe(id(xa));
|
||||
id(xb_step)++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
int s = id(xb_step);
|
||||
ESP_LOGI("xb", "inject step %d frames %u..%u", s, XB_STEPS[s], XB_STEPS[s + 1]);
|
||||
if (s == 0)
|
||||
xb_inject(0, XB_STEPS[0]);
|
||||
xb_inject(XB_STEPS[s], XB_STEPS[s + 1]);
|
||||
id(xb_step)++;
|
||||
|
||||
sensor:
|
||||
- platform: xiaomi_lywsd03mmc
|
||||
id: xa
|
||||
mac_address: A4:C1:38:00:00:0A
|
||||
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
|
||||
temperature:
|
||||
name: "xA lywsd03mmc temp"
|
||||
- platform: xiaomi_lywsdcgq
|
||||
mac_address: A4:C1:38:00:00:0B
|
||||
temperature:
|
||||
name: "xB lywsdcgq temp"
|
||||
humidity:
|
||||
name: "xB lywsdcgq hum"
|
||||
- platform: xiaomi_hhccjcy01
|
||||
mac_address: A4:C1:38:00:00:0C
|
||||
temperature:
|
||||
name: "xC hhccjcy01 temp"
|
||||
- platform: xiaomi_cgg1
|
||||
mac_address: A4:C1:38:00:00:0D
|
||||
temperature:
|
||||
name: "xD cgg1 temp"
|
||||
- platform: xiaomi_xmwsdj04mmc
|
||||
mac_address: A4:C1:38:00:00:0E
|
||||
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
|
||||
temperature:
|
||||
name: "xE xmwsdj04mmc temp"
|
||||
humidity:
|
||||
name: "xE xmwsdj04mmc hum"
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:00:00:0F
|
||||
temperature:
|
||||
name: "xF atc temp"
|
||||
humidity:
|
||||
name: "xF atc hum"
|
||||
battery_voltage:
|
||||
name: "xF atc volt"
|
||||
- platform: pvvx_mithermometer
|
||||
mac_address: A4:C1:38:00:00:10
|
||||
temperature:
|
||||
name: "xG pvvx temp"
|
||||
humidity:
|
||||
name: "xG pvvx hum"
|
||||
battery_voltage:
|
||||
name: "xG pvvx volt"
|
||||
@@ -0,0 +1,83 @@
|
||||
substitutions:
|
||||
name: xbrecv
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 100ms
|
||||
active: false
|
||||
on_ble_service_data_advertise:
|
||||
- mac_address: C4:C1:38:00:00:0A
|
||||
service_uuid: FE95
|
||||
then:
|
||||
- lambda: |-
|
||||
char buf[80];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
|
||||
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
|
||||
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
|
||||
|
||||
sensor:
|
||||
- platform: xiaomi_lywsd03mmc
|
||||
mac_address: C4:C1:38:00:00:0A
|
||||
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
|
||||
temperature:
|
||||
name: "xA lywsd03mmc temp"
|
||||
- platform: xiaomi_lywsdcgq
|
||||
mac_address: C4:C1:38:00:00:0B
|
||||
temperature:
|
||||
name: "xB lywsdcgq temp"
|
||||
humidity:
|
||||
name: "xB lywsdcgq hum"
|
||||
- platform: xiaomi_hhccjcy01
|
||||
mac_address: C4:C1:38:00:00:0C
|
||||
temperature:
|
||||
name: "xC hhccjcy01 temp"
|
||||
- platform: xiaomi_cgg1
|
||||
mac_address: C4:C1:38:00:00:0D
|
||||
temperature:
|
||||
name: "xD cgg1 temp"
|
||||
- platform: xiaomi_xmwsdj04mmc
|
||||
mac_address: C4:C1:38:00:00:0E
|
||||
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
|
||||
temperature:
|
||||
name: "xE xmwsdj04mmc temp"
|
||||
humidity:
|
||||
name: "xE xmwsdj04mmc hum"
|
||||
- platform: atc_mithermometer
|
||||
mac_address: C4:C1:38:00:00:0F
|
||||
temperature:
|
||||
name: "xF atc temp"
|
||||
humidity:
|
||||
name: "xF atc hum"
|
||||
battery_voltage:
|
||||
name: "xF atc volt"
|
||||
- platform: pvvx_mithermometer
|
||||
mac_address: C4:C1:38:00:00:10
|
||||
temperature:
|
||||
name: "xG pvvx temp"
|
||||
humidity:
|
||||
name: "xG pvvx hum"
|
||||
battery_voltage:
|
||||
name: "xG pvvx volt"
|
||||
@@ -196,7 +196,6 @@ esphome/components/ens160_base/* @latonita @vincentscode
|
||||
esphome/components/ens160_i2c/* @latonita
|
||||
esphome/components/ens160_spi/* @latonita
|
||||
esphome/components/ens210/* @itn3rd77
|
||||
esphome/components/entity_transport/* @clydebarrow
|
||||
esphome/components/epaper_spi/* @esphome/core
|
||||
esphome/components/es7210/* @kahrendt
|
||||
esphome/components/es7243/* @Chorty
|
||||
@@ -642,9 +641,7 @@ esphome/components/sun_gtil2/* @Mat931
|
||||
esphome/components/switch/* @esphome/core
|
||||
esphome/components/switch/binary_sensor/* @ssieb
|
||||
esphome/components/sx126x/* @swoboda1337
|
||||
esphome/components/sx126x/packet_interface/* @clydebarrow
|
||||
esphome/components/sx127x/* @swoboda1337
|
||||
esphome/components/sx127x/packet_interface/* @clydebarrow
|
||||
esphome/components/sy6970/* @linkedupbits
|
||||
esphome/components/syslog/* @clydebarrow
|
||||
esphome/components/systa_bus/* @Mat931
|
||||
|
||||
@@ -1555,8 +1555,17 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ
|
||||
// Event is a special case - unlike other entities with simple state fields,
|
||||
// events store their state in a member accessed via obj->get_last_event_type()
|
||||
void APIConnection::send_event(event::Event *event) {
|
||||
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE,
|
||||
event->get_last_event_type_index());
|
||||
const uint8_t type_index = event->get_last_event_type_index();
|
||||
const size_t size_before = this->deferred_batch_.size();
|
||||
if (this->deferred_batch_.contains(event, EventResponse::MESSAGE_TYPE)) {
|
||||
// Sending now would overtake the older event of this entity that still waits in the batch
|
||||
this->schedule_message_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
|
||||
} else {
|
||||
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
|
||||
}
|
||||
// Events are rare, so a queued one sends the batch now rather than after batch_delay
|
||||
if (this->deferred_batch_.size() > size_before)
|
||||
this->make_batch_due_now_();
|
||||
}
|
||||
uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_deferred_batch.h"
|
||||
#include "api_frame_helper.h"
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
@@ -20,7 +21,6 @@
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome {
|
||||
@@ -656,73 +656,6 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Function pointer type for message encoding
|
||||
using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size);
|
||||
|
||||
// Generic batching mechanism for both state updates and entity info
|
||||
struct DeferredBatch {
|
||||
// Sentinel value for unused aux_data_index
|
||||
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
|
||||
|
||||
struct BatchItem {
|
||||
EntityBase *entity; // 4 bytes - Entity pointer
|
||||
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
|
||||
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
|
||||
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
|
||||
};
|
||||
|
||||
std::vector<BatchItem> items;
|
||||
uint32_t batch_start_time{0};
|
||||
|
||||
// No pre-allocation - log connections never use batching, and for
|
||||
// connections that do, buffers are released after initial sync anyway
|
||||
|
||||
// Add item to the batch (with deduplication)
|
||||
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index = AUX_DATA_UNUSED) {
|
||||
// Dedup: O(n) scan but optimized for RAM over performance
|
||||
// Skip deduplication for events - they are edge-triggered, every occurrence matters
|
||||
#ifdef USE_EVENT
|
||||
if (message_type != EventResponse::MESSAGE_TYPE)
|
||||
#endif
|
||||
{
|
||||
for (const auto &item : this->items) {
|
||||
if (item.entity == entity && item.message_type == message_type)
|
||||
return; // Already queued
|
||||
}
|
||||
}
|
||||
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
|
||||
}
|
||||
// Add item to the front of the batch (for high priority messages like ping)
|
||||
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||
// Swap to front avoids expensive vector::insert which shifts all elements
|
||||
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
|
||||
if (this->items.size() > 1) {
|
||||
std::swap(this->items.front(), this->items.back());
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all items
|
||||
void clear() {
|
||||
items.clear();
|
||||
batch_start_time = 0;
|
||||
}
|
||||
|
||||
// Remove processed items from the front — noinline to keep memmove out of warm callers
|
||||
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
|
||||
|
||||
bool empty() const { return items.empty(); }
|
||||
size_t size() const { return items.size(); }
|
||||
const BatchItem &operator[](size_t index) const { return items[index]; }
|
||||
|
||||
// Release excess capacity - only releases if items already empty
|
||||
void release_buffer() {
|
||||
// Safe to call: batch is processed before release_buffer is called,
|
||||
// and if any items remain (partial processing), we must not clear them.
|
||||
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
|
||||
if (items.empty()) {
|
||||
std::vector<BatchItem>().swap(items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// DeferredBatch here (16 bytes, 4-byte aligned)
|
||||
DeferredBatch deferred_batch_;
|
||||
|
||||
@@ -795,6 +728,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU
|
||||
|
||||
bool schedule_batch_();
|
||||
// Sends the batch on the next loop, so a queued event does not wait for batch_delay; queued states go with it
|
||||
void make_batch_due_now_() {
|
||||
this->deferred_batch_.batch_start_time = App.get_loop_component_start_time() - this->get_batch_delay_ms_();
|
||||
}
|
||||
void process_batch_();
|
||||
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
|
||||
__attribute__((noinline));
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_pb2.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
// Generic batching mechanism for both state updates and entity info
|
||||
struct DeferredBatch {
|
||||
// Sentinel value for unused aux_data_index
|
||||
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
|
||||
|
||||
struct BatchItem {
|
||||
EntityBase *entity; // 4 bytes - Entity pointer
|
||||
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
|
||||
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
|
||||
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
|
||||
};
|
||||
|
||||
std::vector<BatchItem> items;
|
||||
uint32_t batch_start_time{0};
|
||||
|
||||
// No pre-allocation - log connections never use batching, and for
|
||||
// connections that do, buffers are released after initial sync anyway
|
||||
|
||||
// O(n) scan but optimized for RAM over performance
|
||||
bool contains(EntityBase *entity, uint16_t message_type) const {
|
||||
for (const auto &item : this->items) {
|
||||
if (item.entity == entity && item.message_type == message_type)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add item to the batch (with deduplication)
|
||||
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index = AUX_DATA_UNUSED) {
|
||||
// Skip deduplication for events - they are edge-triggered, every occurrence matters
|
||||
#ifdef USE_EVENT
|
||||
if (message_type != EventResponse::MESSAGE_TYPE)
|
||||
#endif
|
||||
{
|
||||
if (this->contains(entity, message_type))
|
||||
return; // Already queued
|
||||
}
|
||||
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
|
||||
}
|
||||
// Add item to the front of the batch (for high priority messages like ping)
|
||||
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||
// Shift rather than swap, so the queued items keep their order (two events of one entity must not swap)
|
||||
this->items.push_back({});
|
||||
std::move_backward(this->items.begin(), this->items.end() - 1, this->items.end());
|
||||
this->items.front() = {entity, message_type, estimated_size, AUX_DATA_UNUSED};
|
||||
}
|
||||
|
||||
// Clear all items
|
||||
void clear() {
|
||||
items.clear();
|
||||
batch_start_time = 0;
|
||||
}
|
||||
|
||||
// Remove processed items from the front — noinline to keep memmove out of warm callers
|
||||
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
|
||||
|
||||
bool empty() const { return items.empty(); }
|
||||
size_t size() const { return items.size(); }
|
||||
const BatchItem &operator[](size_t index) const { return items[index]; }
|
||||
|
||||
// Release excess capacity - only releases if items already empty
|
||||
void release_buffer() {
|
||||
// Safe to call: batch is processed before release_buffer is called,
|
||||
// and if any items remain (partial processing), we must not clear them.
|
||||
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
|
||||
if (items.empty()) {
|
||||
std::vector<BatchItem>().swap(items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API
|
||||
@@ -13,19 +13,10 @@ from esphome.const import (
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import copy_ns
|
||||
|
||||
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(CopySensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
||||
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "copy_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "copy.sensor");
|
||||
|
||||
void CopySensor::setup() {
|
||||
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
|
||||
if (source_->has_state())
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
class CopySensor final : public sensor::Sensor, public Component {
|
||||
public:
|
||||
void set_source(sensor::Sensor *source) { source_ = source; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
sensor::Sensor *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import copy_ns
|
||||
|
||||
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
text_sensor.text_sensor_schema(CopyTextSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await text_sensor.new_text_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
||||
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "copy_text_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
|
||||
|
||||
void CopyTextSensor::setup() {
|
||||
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
|
||||
if (source_->has_state())
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
|
||||
public:
|
||||
void set_source(text_sensor::TextSensor *source) { source_ = source; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
text_sensor::TextSensor *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core
|
||||
from esphome import automation, core, pins
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import (
|
||||
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_FROM,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
|
||||
}
|
||||
|
||||
|
||||
class DisplayModel(Protocol):
|
||||
"""The parts of a display model used by enable_pin_schema()."""
|
||||
|
||||
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
|
||||
|
||||
|
||||
def enable_pin_schema(
|
||||
model: DisplayModel, config: ConfigType
|
||||
) -> dict[cv.Optional, Any]:
|
||||
"""
|
||||
Get the schema entry for a display's enable pins.
|
||||
Pins given in the user config are used as they are. Otherwise the model's default enable
|
||||
pins are used, except any that is also used elsewhere in the config, so a user can take
|
||||
control of that pin, e.g. with a switch.
|
||||
:param config: The display config as given by the user
|
||||
"""
|
||||
schema = cv.ensure_list(pins.gpio_output_pin_schema)
|
||||
default = model.get_default(CONF_ENABLE_PIN, None)
|
||||
if default is None or CONF_ENABLE_PIN in config:
|
||||
return {cv.Optional(CONF_ENABLE_PIN): schema}
|
||||
return {
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=default
|
||||
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
|
||||
}
|
||||
|
||||
|
||||
def validate_rotation(value):
|
||||
value = cv.string(value)
|
||||
value = value.removesuffix("°")
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Send sensor and binary sensor states to other devices over a packet interface."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import packet_interface
|
||||
from esphome.components.binary_sensor import BinarySensor
|
||||
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
|
||||
from esphome.components.packet_interface import (
|
||||
CONF_PACKET_INTERFACE,
|
||||
final_validate_interface,
|
||||
interface_layers,
|
||||
)
|
||||
from esphome.components.packet_interface.layers import CONF_SESSION
|
||||
from esphome.components.sensor import Sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_ID,
|
||||
CONF_SENSORS,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["packet_interface"]
|
||||
DOMAIN = "entity_transport"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_BROADCAST_ID = "broadcast_id"
|
||||
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
|
||||
|
||||
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
|
||||
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
|
||||
|
||||
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
|
||||
_request_remote_binary_sensor_slot = cg.slot_counter(
|
||||
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
|
||||
)
|
||||
|
||||
|
||||
def _advertised_id(conf: ConfigType) -> str:
|
||||
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
|
||||
|
||||
|
||||
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
|
||||
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
|
||||
seen: set[str] = set()
|
||||
for index, conf in enumerate(entities):
|
||||
if (advertised := _advertised_id(conf)) in seen:
|
||||
raise cv.Invalid(
|
||||
f"The id '{advertised}' is sent for two entities", path=[index]
|
||||
)
|
||||
seen.add(advertised)
|
||||
return entities
|
||||
|
||||
|
||||
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
|
||||
return cv.maybe_simple_value(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(cls),
|
||||
cv.Optional(CONF_BROADCAST_ID): cv.All(
|
||||
cv.validate_id_name, cv.Length(max=255)
|
||||
),
|
||||
}
|
||||
),
|
||||
key=CONF_ID,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(EntityTransport),
|
||||
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
|
||||
# Defaults to a periodic resend unless the interface has session and reliability layers
|
||||
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
|
||||
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
|
||||
),
|
||||
# Remote entities lose their state when none arrives for this long
|
||||
cv.Optional(CONF_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=1)),
|
||||
),
|
||||
# Entities are numbered with one byte
|
||||
cv.Optional(CONF_SENSORS): cv.All(
|
||||
cv.ensure_list(_local_entity(Sensor)),
|
||||
cv.Length(max=255),
|
||||
_unique_advertised_ids,
|
||||
),
|
||||
cv.Optional(CONF_BINARY_SENSORS): cv.All(
|
||||
cv.ensure_list(_local_entity(BinarySensor)),
|
||||
cv.Length(max=255),
|
||||
_unique_advertised_ids,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
|
||||
layers = interface_layers(config[CONF_PACKET_INTERFACE])
|
||||
if CONF_TIMEOUT in config and layers is not None and CONF_SESSION in layers:
|
||||
raise cv.Invalid(
|
||||
f"Not allowed with a '{CONF_SESSION}' layer, which already clears the states when the other device "
|
||||
"cannot be reached; unchanged states may never be sent again, so they would time out on a working link",
|
||||
path=[CONF_TIMEOUT],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
final_validate_interface(CONF_PACKET_INTERFACE), _final_validate
|
||||
)
|
||||
|
||||
|
||||
def _validate_remote_id(config: ConfigType) -> ConfigType:
|
||||
if CONF_REMOTE_ID not in config and not config[CONF_ID].is_manual:
|
||||
raise cv.Invalid(f"'{CONF_REMOTE_ID}' is required unless '{CONF_ID}' is given")
|
||||
return config
|
||||
|
||||
|
||||
def remote_entity_schema(base_schema: cv.Schema) -> cv.Schema:
|
||||
"""Schema for an entity whose state comes from another device."""
|
||||
return cv.All(
|
||||
base_schema.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_ENTITY_TRANSPORT_ID): cv.use_id(EntityTransport),
|
||||
cv.Required(CONF_PROVIDER): cv.valid_name,
|
||||
cv.Optional(CONF_REMOTE_ID): cv.All(
|
||||
cv.string_strict, cv.Length(min=1, max=255)
|
||||
),
|
||||
}
|
||||
),
|
||||
_validate_remote_id,
|
||||
)
|
||||
|
||||
|
||||
def _remote_id(config: ConfigType) -> str:
|
||||
return config.get(CONF_REMOTE_ID) or config[CONF_ID].id
|
||||
|
||||
|
||||
async def register_remote_sensor(config: ConfigType, var: MockObj) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
|
||||
_request_remote_sensor_slot(str(hub))
|
||||
cg.add(hub.add_remote_sensor(config[CONF_PROVIDER], _remote_id(config), var))
|
||||
|
||||
|
||||
async def register_remote_binary_sensor(config: ConfigType, var: MockObj) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
|
||||
_request_remote_binary_sensor_slot(str(hub))
|
||||
cg.add(hub.add_remote_binary_sensor(config[CONF_PROVIDER], _remote_id(config), var))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
|
||||
var = cg.new_Pvariable(config[CONF_ID], interface)
|
||||
await cg.register_component(var, config)
|
||||
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
|
||||
cg.add(var.set_update_interval(interval))
|
||||
if (timeout := config.get(CONF_TIMEOUT)) is not None:
|
||||
cg.add(var.set_remote_timeout(timeout))
|
||||
sensors = config.get(CONF_SENSORS, [])
|
||||
binary_sensors = config.get(CONF_BINARY_SENSORS, [])
|
||||
if count := len(sensors) + len(binary_sensors):
|
||||
cg.add(var.set_entity_count(count))
|
||||
for conf in sensors:
|
||||
sensor = await cg.get_variable(conf[CONF_ID])
|
||||
cg.add(var.add_sensor(_advertised_id(conf), sensor))
|
||||
for conf in binary_sensors:
|
||||
sensor = await cg.get_variable(conf[CONF_ID])
|
||||
cg.add(var.add_binary_sensor(_advertised_id(conf), sensor))
|
||||
@@ -1,11 +0,0 @@
|
||||
from esphome.components import binary_sensor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import register_remote_binary_sensor, remote_entity_schema
|
||||
|
||||
CONFIG_SCHEMA = remote_entity_schema(binary_sensor.binary_sensor_schema())
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
await register_remote_binary_sensor(config, var)
|
||||
@@ -1,683 +0,0 @@
|
||||
#include "entity_transport.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::entity_transport {
|
||||
|
||||
using packet_interface::PacketBuffer;
|
||||
using packet_interface::PacketMetaData;
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "entity_transport");
|
||||
|
||||
// The packet type byte holds the format version in its high nibble
|
||||
static constexpr uint8_t FORMAT_VERSION = 1;
|
||||
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
|
||||
static constexpr uint8_t PACKET_DATA = 0x12;
|
||||
static constexpr uint8_t PACKET_REQUEST = 0x13;
|
||||
// Entity types, in dictionary sections and data records; 0 is not used
|
||||
static constexpr uint8_t TYPE_SENSOR = 1;
|
||||
static constexpr uint8_t TYPE_BINARY_SENSOR = 2;
|
||||
static constexpr uint8_t MAX_TYPE = 31;
|
||||
// Packet type and dictionary id
|
||||
static constexpr size_t HEADER_SIZE = 5;
|
||||
// A dictionary packet's part byte follows the header: the part's index in bits 0-6, and on the last part, bit 7
|
||||
static constexpr uint8_t PART_INDEX_MASK = 0x7F;
|
||||
static constexpr uint8_t PART_LAST = 0x80;
|
||||
static constexpr uint8_t PART_NONE = 0xFF; // no dictionary is being received in order
|
||||
// A data record starts with a 16-bit tag: the entity's number in bits 0-7, its type in bits 8-12, the payload form in
|
||||
// bits 13-14, and with PAYLOAD_NONE, the state in bit 15. Any record can be skipped without knowing its type.
|
||||
static constexpr size_t TAG_SIZE = 2;
|
||||
static constexpr uint8_t TAG_TYPE_SHIFT = 8;
|
||||
static constexpr uint8_t TAG_PAYLOAD_SHIFT = 13;
|
||||
static constexpr uint16_t TAG_VALUE_BIT = 0x8000;
|
||||
static constexpr uint8_t PAYLOAD_NONE = 0; // the state is in the tag's value bit
|
||||
static constexpr uint8_t PAYLOAD_WORD = 1; // 4 bytes follow
|
||||
static constexpr uint8_t PAYLOAD_LENGTH = 2; // a length byte follows, then that many bytes
|
||||
static constexpr uint8_t PAYLOAD_NO_STATE = 3; // the entity has no state
|
||||
static constexpr size_t WORD_SIZE = 4;
|
||||
static constexpr size_t MAX_RECORD_SIZE = TAG_SIZE + WORD_SIZE;
|
||||
// A dictionary section starts with the entity type, first number and count
|
||||
static constexpr size_t SECTION_HEADER_SIZE = 3;
|
||||
static constexpr size_t MAX_STRING_LENGTH = 255;
|
||||
static constexpr uint32_t DEFAULT_UPDATE_INTERVAL_MS = 15000;
|
||||
static constexpr uint32_t REQUEST_INTERVAL_MS = 5000;
|
||||
static constexpr uint32_t RETRY_DELAY_MS = 1000;
|
||||
static constexpr uint32_t RETRY_TIMER_ID = 0;
|
||||
static constexpr uint32_t TIMEOUT_CHECK_INTERVAL_MS = 1000;
|
||||
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
|
||||
// How long the interface may refuse every packet before it is reported
|
||||
static constexpr uint32_t REFUSED_WARNING_MS = 60000;
|
||||
|
||||
static void put_uint32(uint8_t *buf, uint32_t value) {
|
||||
for (size_t i = 0; i != 4; i++)
|
||||
buf[i] = value >> (i * 8);
|
||||
}
|
||||
|
||||
// True if the NUL terminated str is the same as the len bytes at data
|
||||
static bool matches(const char *str, const char *data, size_t len) {
|
||||
return strlen(str) == len && memcmp(str, data, len) == 0;
|
||||
}
|
||||
|
||||
struct Record {
|
||||
uint8_t number;
|
||||
uint8_t type;
|
||||
uint8_t payload;
|
||||
bool value;
|
||||
const uint8_t *data; // what follows the tag
|
||||
};
|
||||
|
||||
static Record parse_record(std::span<const uint8_t> packet, size_t pos) {
|
||||
uint16_t tag = encode_uint16(packet[pos + 1], packet[pos]);
|
||||
return {static_cast<uint8_t>(tag), static_cast<uint8_t>((tag >> TAG_TYPE_SHIFT) & MAX_TYPE),
|
||||
static_cast<uint8_t>((tag >> TAG_PAYLOAD_SHIFT) & 3), (tag & TAG_VALUE_BIT) != 0,
|
||||
packet.data() + pos + TAG_SIZE};
|
||||
}
|
||||
|
||||
// The size of the data record at pos, or 0 if it does not fit in the packet
|
||||
static size_t record_size(std::span<const uint8_t> packet, size_t pos) {
|
||||
if (pos + TAG_SIZE > packet.size())
|
||||
return 0;
|
||||
size_t size = TAG_SIZE;
|
||||
uint8_t payload = parse_record(packet, pos).payload;
|
||||
if (payload == PAYLOAD_WORD) {
|
||||
size += WORD_SIZE;
|
||||
} else if (payload == PAYLOAD_LENGTH) {
|
||||
if (pos + TAG_SIZE == packet.size())
|
||||
return 0;
|
||||
size += 1 + packet[pos + TAG_SIZE];
|
||||
}
|
||||
return pos + size <= packet.size() ? size : 0;
|
||||
}
|
||||
|
||||
// Checks every record of a data packet, so that a damaged or forged packet changes no state at all
|
||||
static bool data_well_formed(std::span<const uint8_t> packet) {
|
||||
for (size_t pos = HEADER_SIZE; pos != packet.size();) {
|
||||
size_t size = record_size(packet, pos);
|
||||
if (size == 0)
|
||||
return false;
|
||||
Record record = parse_record(packet, pos);
|
||||
if (record.type == 0 || (record.value && record.payload != PAYLOAD_NONE))
|
||||
return false;
|
||||
// Types this device knows must use their own payload form; others are skipped
|
||||
if (record.type == TYPE_SENSOR && record.payload != PAYLOAD_WORD && record.payload != PAYLOAD_NO_STATE)
|
||||
return false;
|
||||
if (record.type == TYPE_BINARY_SENSOR && record.payload != PAYLOAD_NONE && record.payload != PAYLOAD_NO_STATE)
|
||||
return false;
|
||||
pos += size;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks the device name and every section of a dictionary packet before anything is learnt from it
|
||||
static bool dictionary_well_formed(std::span<const uint8_t> packet) {
|
||||
size_t pos = HEADER_SIZE + 1;
|
||||
if (pos >= packet.size())
|
||||
return false;
|
||||
pos += 1 + packet[pos];
|
||||
if (pos > packet.size())
|
||||
return false;
|
||||
while (pos != packet.size()) {
|
||||
if (pos + SECTION_HEADER_SIZE > packet.size())
|
||||
return false;
|
||||
uint8_t type = packet[pos];
|
||||
size_t first = packet[pos + 1];
|
||||
size_t count = packet[pos + 2];
|
||||
pos += SECTION_HEADER_SIZE;
|
||||
// Numbers are one byte, and a data record can name types up to MAX_TYPE only
|
||||
if (type == 0 || type > MAX_TYPE || count == 0 || first + count > 256)
|
||||
return false;
|
||||
for (size_t i = 0; i != count; i++) {
|
||||
if (pos == packet.size())
|
||||
return false;
|
||||
pos += 1 + packet[pos];
|
||||
if (pos > packet.size())
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EntityTransport::setup() {
|
||||
this->interface_ = this->interface_->top();
|
||||
this->max_packet_size_ = this->interface_->get_max_packet_size();
|
||||
const auto &name = App.get_name();
|
||||
size_t longest_id = 0;
|
||||
for (const auto &entity : this->entities_)
|
||||
longest_id = std::max(longest_id, strlen(entity.id));
|
||||
// A dictionary packet must hold the device name and any one id, which also leaves room for a data record
|
||||
if (name.size() > MAX_STRING_LENGTH || longest_id > MAX_STRING_LENGTH ||
|
||||
HEADER_SIZE + 2 + name.size() + SECTION_HEADER_SIZE + 1 + longest_id > this->max_packet_size_) {
|
||||
this->mark_failed(LOG_STR("max_packet_size too small for the device name and entity ids"));
|
||||
return;
|
||||
}
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
this->buffer_ = allocator.allocate(this->max_packet_size_);
|
||||
if (this->buffer_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Not enough memory for the packet buffer"));
|
||||
return;
|
||||
}
|
||||
// The dictionary id changes when the device name or any entity changes
|
||||
uint32_t hash = fnv1a_hash(name.c_str());
|
||||
for (const auto &entity : this->entities_) {
|
||||
hash = fnv1a_hash_extend(hash, entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR);
|
||||
hash = fnv1a_hash_extend(hash, entity.id);
|
||||
}
|
||||
this->dictionary_id_ = hash;
|
||||
|
||||
// [&entity] is safe: FixedVector never reallocates, and the parent pointer keeps the capture to one pointer
|
||||
for (auto &entity : this->entities_) {
|
||||
#ifdef USE_SENSOR
|
||||
if (entity.is_sensor) {
|
||||
static_cast<sensor::Sensor *>(entity.entity)->add_on_state_callback([&entity](float) {
|
||||
entity.parent->mark_updated_(entity);
|
||||
});
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (!entity.is_sensor) {
|
||||
// The full state callback also fires for the first state
|
||||
static_cast<binary_sensor::BinarySensor *>(entity.entity)
|
||||
->add_full_state_callback(
|
||||
[&entity](optional<bool>, optional<bool>) { entity.parent->mark_updated_(entity); });
|
||||
}
|
||||
#endif
|
||||
}
|
||||
this->interface_->add_packet_interface_listener(
|
||||
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
|
||||
|
||||
bool session = this->interface_->has_capability(packet_interface::CAPABILITY_SESSION);
|
||||
if (session)
|
||||
this->interface_->add_on_session_state_callback([this](bool connected) { this->on_session_(connected); });
|
||||
// Without a session and reliable delivery, a lost dictionary or state is only replaced by a periodic resend
|
||||
if (this->update_interval_ms_ == 0) {
|
||||
this->update_interval_ms_ = session && this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)
|
||||
? SCHEDULER_DONT_RUN
|
||||
: DEFAULT_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
if (this->update_interval_ms_ != SCHEDULER_DONT_RUN)
|
||||
this->set_interval(this->update_interval_ms_, [this] { this->send_all_(); });
|
||||
if (this->remote_timeout_ms_ != 0)
|
||||
this->set_interval(TIMEOUT_CHECK_INTERVAL_MS, [this] { this->clear_remote_states_(true); });
|
||||
this->send_all_();
|
||||
}
|
||||
|
||||
void EntityTransport::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Entity Transport:\n"
|
||||
" Packet Interface: %s\n"
|
||||
" Max Packet Size: %zu\n"
|
||||
" Encryption: %s\n"
|
||||
" Reliability: %s\n"
|
||||
" Session: %s\n"
|
||||
" Dictionary ID: %08" PRIX32,
|
||||
this->interface_->get_name(), this->max_packet_size_,
|
||||
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_ENCRYPTION)),
|
||||
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)),
|
||||
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_SESSION)), this->dictionary_id_);
|
||||
if (this->update_interval_ms_ == SCHEDULER_DONT_RUN) {
|
||||
ESP_LOGCONFIG(TAG, " Update Interval: never");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Update Interval: %" PRIu32 "ms", this->update_interval_ms_);
|
||||
}
|
||||
if (this->remote_timeout_ms_ != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Remote Timeout: %" PRIu32 "ms", this->remote_timeout_ms_);
|
||||
}
|
||||
for (const auto &entity : this->entities_) {
|
||||
ESP_LOGCONFIG(TAG, " %s %u: %s", entity.is_sensor ? LOG_STR_LITERAL("Sensor") : LOG_STR_LITERAL("Binary Sensor"),
|
||||
entity.number, entity.id);
|
||||
}
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Remote Sensor: %s from %s", remote.remote_id, remote.provider);
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_binary_sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Remote Binary Sensor: %s from %s", remote.remote_id, remote.provider);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void EntityTransport::send_all_() {
|
||||
this->dictionary_pending_ = true;
|
||||
this->dictionary_next_ = 0;
|
||||
this->dictionary_part_ = 0;
|
||||
for (auto &entity : this->entities_)
|
||||
entity.updated = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void EntityTransport::mark_updated_(LocalEntity &entity) {
|
||||
entity.updated = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void EntityTransport::loop() {
|
||||
this->disable_loop();
|
||||
// States are only sent once the dictionary that names them has been accepted
|
||||
if ((this->dictionary_pending_ && !this->send_dictionary_()) || !this->send_updated_()) {
|
||||
this->sending_refused_();
|
||||
this->set_timeout(RETRY_TIMER_ID, RETRY_DELAY_MS, [this] { this->enable_loop(); });
|
||||
return;
|
||||
}
|
||||
this->refusing_ = false;
|
||||
if (this->status_has_warning()) {
|
||||
ESP_LOGI(TAG, "Packets are accepted again");
|
||||
this->status_clear_warning();
|
||||
}
|
||||
}
|
||||
|
||||
// A link that is down or full refuses packets for a while; one that refuses them for long sends nothing at all
|
||||
void EntityTransport::sending_refused_() {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (!this->refusing_) {
|
||||
this->refusing_ = true;
|
||||
this->refused_since_ms_ = now;
|
||||
return;
|
||||
}
|
||||
if (!this->status_has_warning() && now - this->refused_since_ms_ >= REFUSED_WARNING_MS) {
|
||||
ESP_LOGW(TAG, "The packet interface has accepted no packets for %" PRIu32 "s, so no states are sent",
|
||||
(now - this->refused_since_ms_) / 1000);
|
||||
this->status_set_warning();
|
||||
}
|
||||
}
|
||||
|
||||
size_t EntityTransport::write_header_(uint8_t type) const {
|
||||
this->buffer_[0] = type;
|
||||
put_uint32(this->buffer_ + 1, this->dictionary_id_);
|
||||
return HEADER_SIZE;
|
||||
}
|
||||
|
||||
bool EntityTransport::send_dictionary_() {
|
||||
uint8_t *buf = this->buffer_;
|
||||
const auto &name = App.get_name();
|
||||
size_t header = this->write_header_(PACKET_DICTIONARY);
|
||||
size_t part_offset = header++;
|
||||
buf[header++] = name.size();
|
||||
memcpy(buf + header, name.c_str(), name.size());
|
||||
header += name.size();
|
||||
size_t length = header;
|
||||
size_t count_offset = 0;
|
||||
uint8_t section_type = 0; // none
|
||||
// A dictionary in several packets resumes after the last packet accepted, so a refused one does not resend the rest
|
||||
for (size_t i = this->dictionary_next_; i != this->entities_.size(); i++) {
|
||||
const auto &entity = this->entities_[i];
|
||||
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
|
||||
size_t id_length = strlen(entity.id);
|
||||
bool new_section = type != section_type;
|
||||
if (length + (new_section ? SECTION_HEADER_SIZE : 0) + 1 + id_length > this->max_packet_size_) {
|
||||
buf[part_offset] = this->dictionary_part_ & PART_INDEX_MASK;
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
|
||||
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
|
||||
return false;
|
||||
}
|
||||
this->dictionary_next_ = i;
|
||||
this->dictionary_part_++;
|
||||
length = header;
|
||||
new_section = true;
|
||||
}
|
||||
if (new_section) {
|
||||
buf[length++] = type;
|
||||
buf[length++] = entity.number;
|
||||
count_offset = length;
|
||||
buf[length++] = 0;
|
||||
section_type = type;
|
||||
}
|
||||
buf[count_offset]++;
|
||||
buf[length++] = id_length;
|
||||
memcpy(buf + length, entity.id, id_length);
|
||||
length += id_length;
|
||||
}
|
||||
if (length != header) {
|
||||
buf[part_offset] = (this->dictionary_part_ & PART_INDEX_MASK) | PART_LAST;
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
|
||||
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->dictionary_pending_ = false;
|
||||
this->dictionary_next_ = 0;
|
||||
this->dictionary_part_ = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Writes the entity's data record, and returns its size
|
||||
size_t EntityTransport::write_record_(const LocalEntity &entity, uint8_t *record) const {
|
||||
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
|
||||
uint8_t payload = PAYLOAD_NO_STATE;
|
||||
bool value = false;
|
||||
size_t size = TAG_SIZE;
|
||||
#ifdef USE_SENSOR
|
||||
if (entity.is_sensor) {
|
||||
float state = static_cast<sensor::Sensor *>(entity.entity)->get_state();
|
||||
if (!std::isnan(state)) {
|
||||
payload = PAYLOAD_WORD;
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &state, sizeof(bits));
|
||||
put_uint32(record + TAG_SIZE, bits);
|
||||
size += WORD_SIZE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (!entity.is_sensor) {
|
||||
auto *sensor = static_cast<binary_sensor::BinarySensor *>(entity.entity);
|
||||
if (sensor->has_state()) {
|
||||
payload = PAYLOAD_NONE;
|
||||
value = sensor->state;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
uint16_t tag =
|
||||
entity.number | (type << TAG_TYPE_SHIFT) | (payload << TAG_PAYLOAD_SHIFT) | (value ? TAG_VALUE_BIT : 0);
|
||||
record[0] = tag;
|
||||
record[1] = tag >> 8;
|
||||
return size;
|
||||
}
|
||||
|
||||
// Sends every updated entity, in as few packets as fit; returns false if a packet was refused
|
||||
bool EntityTransport::send_updated_() {
|
||||
size_t length = this->write_header_(PACKET_DATA);
|
||||
size_t first = 0;
|
||||
for (size_t i = 0; i != this->entities_.size(); i++) {
|
||||
auto &entity = this->entities_[i];
|
||||
if (!entity.updated)
|
||||
continue;
|
||||
uint8_t record[MAX_RECORD_SIZE];
|
||||
size_t size = this->write_record_(entity, record);
|
||||
if (length + size > this->max_packet_size_) {
|
||||
if (!this->flush_(length, first, i))
|
||||
return false;
|
||||
length = HEADER_SIZE;
|
||||
first = i;
|
||||
}
|
||||
memcpy(this->buffer_ + length, record, size);
|
||||
length += size;
|
||||
}
|
||||
return this->flush_(length, first, this->entities_.size());
|
||||
}
|
||||
|
||||
// Sends the packet holding the updated entities from first up to last, and clears their flags if it was accepted
|
||||
bool EntityTransport::flush_(size_t length, size_t first, size_t last) {
|
||||
if (length == HEADER_SIZE)
|
||||
return true;
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(this->buffer_, length), {})) {
|
||||
ESP_LOGV(TAG, "Packet of %zu bytes not accepted, will retry", length);
|
||||
return false;
|
||||
}
|
||||
for (size_t i = first; i != last; i++)
|
||||
this->entities_[i].updated = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EntityTransport::receive_(const PacketBuffer &data) {
|
||||
// Packet interface layers deliver packets in one piece
|
||||
if (!data.is_contiguous()) {
|
||||
ESP_LOGW(TAG, "Packet split across buffers is not supported");
|
||||
return;
|
||||
}
|
||||
auto packet = data.as_span();
|
||||
if (packet.size() < HEADER_SIZE) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Packet of %zu bytes is too short", packet.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packet[0] >> 4 != FORMAT_VERSION) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Packet format version %u not supported; the sender may run another ESPHome version",
|
||||
packet[0] >> 4);
|
||||
}
|
||||
return;
|
||||
}
|
||||
uint32_t dictionary_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
|
||||
if (dictionary_id == this->dictionary_id_) {
|
||||
// Either a request for our dictionary, or our own packet heard on a broadcast channel
|
||||
if (packet[0] == PACKET_REQUEST && packet.size() == HEADER_SIZE) {
|
||||
ESP_LOGD(TAG, "Dictionary requested");
|
||||
this->send_all_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packet[0] == PACKET_DICTIONARY) {
|
||||
this->receive_dictionary_(packet, dictionary_id);
|
||||
} else if (packet[0] == PACKET_DATA) {
|
||||
this->receive_data_(packet, dictionary_id);
|
||||
} else if (packet[0] != PACKET_REQUEST) {
|
||||
ESP_LOGV(TAG, "Unknown packet type 0x%02X", packet[0]);
|
||||
}
|
||||
}
|
||||
|
||||
void EntityTransport::receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
|
||||
if (!dictionary_well_formed(packet)) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Dictionary packet of %zu bytes is malformed", packet.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
size_t pos = HEADER_SIZE;
|
||||
uint8_t part = packet[pos++];
|
||||
uint8_t index = part & PART_INDEX_MASK;
|
||||
// The dictionary is complete, and need not be asked for again, once its last part follows all the others in order
|
||||
bool in_order = index == 0 || (this->rx_dictionary_id_ == dictionary_id && index == this->rx_next_part_);
|
||||
this->rx_dictionary_id_ = dictionary_id;
|
||||
this->rx_next_part_ = in_order ? (index + 1) & PART_INDEX_MASK : PART_NONE;
|
||||
bool completed = in_order && (part & PART_LAST) != 0;
|
||||
bool first_time = completed && !this->dictionary_seen_(dictionary_id);
|
||||
if (first_time) {
|
||||
this->seen_dictionaries_[this->seen_next_] = dictionary_id;
|
||||
this->seen_next_ = (this->seen_next_ + 1) % this->seen_dictionaries_.size();
|
||||
}
|
||||
size_t provider_length = packet[pos++];
|
||||
const auto *provider = reinterpret_cast<const char *>(packet.data() + pos);
|
||||
pos += provider_length;
|
||||
while (pos != packet.size()) {
|
||||
uint8_t type = packet[pos];
|
||||
uint8_t number = packet[pos + 1];
|
||||
uint8_t count = packet[pos + 2];
|
||||
pos += SECTION_HEADER_SIZE;
|
||||
for (uint8_t i = 0; i != count; i++) {
|
||||
size_t id_length = packet[pos++];
|
||||
this->learn_(provider, provider_length, type, number + i, reinterpret_cast<const char *>(packet.data() + pos),
|
||||
id_length, dictionary_id);
|
||||
pos += id_length;
|
||||
}
|
||||
}
|
||||
// A provider may go back to a dictionary already seen, so every complete one is checked
|
||||
if (completed)
|
||||
this->forget_unnamed_(provider, provider_length, dictionary_id, first_time);
|
||||
}
|
||||
|
||||
void EntityTransport::forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id,
|
||||
bool first_time) {
|
||||
// A remote entity that its provider's whole dictionary does not name would otherwise keep no or a stale state
|
||||
// silently
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
|
||||
continue;
|
||||
if (remote.known || first_time) {
|
||||
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
|
||||
}
|
||||
remote.known = false;
|
||||
if (remote.has_state) {
|
||||
remote.has_state = false;
|
||||
remote.entity->publish_state(NAN);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
|
||||
continue;
|
||||
if (remote.known || first_time) {
|
||||
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
|
||||
}
|
||||
remote.known = false;
|
||||
if (remote.has_state) {
|
||||
remote.has_state = false;
|
||||
remote.entity->invalidate_state();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void EntityTransport::learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
|
||||
size_t id_length, uint32_t dictionary_id) {
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
if (type == TYPE_SENSOR) {
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
|
||||
remote.dictionary_id = dictionary_id;
|
||||
remote.number = number;
|
||||
remote.known = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
if (type == TYPE_BINARY_SENSOR) {
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
|
||||
remote.dictionary_id = dictionary_id;
|
||||
remote.number = number;
|
||||
remote.known = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EntityTransport::dictionary_seen_(uint32_t dictionary_id) const {
|
||||
return std::find(this->seen_dictionaries_.begin(), this->seen_dictionaries_.end(), dictionary_id) !=
|
||||
this->seen_dictionaries_.end();
|
||||
}
|
||||
|
||||
void EntityTransport::receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
|
||||
bool known = false;
|
||||
bool has_remotes = false;
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_sensors_) {
|
||||
known |= remote.known && remote.dictionary_id == dictionary_id;
|
||||
has_remotes = true;
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (const auto &remote : this->remote_binary_sensors_) {
|
||||
known |= remote.known && remote.dictionary_id == dictionary_id;
|
||||
has_remotes = true;
|
||||
}
|
||||
#endif
|
||||
if (!has_remotes)
|
||||
return;
|
||||
// Checked first, so a damaged packet does not make the provider resend everything
|
||||
if (!data_well_formed(packet)) {
|
||||
if (this->warning_due_()) {
|
||||
ESP_LOGW(TAG, "Data packet of %zu bytes is malformed", packet.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A dictionary not yet received in full may name our entities, or be a provider's new one; one received in full
|
||||
// named all of them it could, so asking again would only make that provider resend everything
|
||||
if (!this->dictionary_seen_(dictionary_id))
|
||||
this->request_dictionary_(dictionary_id);
|
||||
if (!known)
|
||||
return;
|
||||
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
|
||||
for (size_t pos = HEADER_SIZE; pos != packet.size(); pos += record_size(packet, pos)) {
|
||||
[[maybe_unused]] Record record = parse_record(packet, pos);
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
if (record.type == TYPE_SENSOR) {
|
||||
float value = NAN;
|
||||
if (record.payload == PAYLOAD_WORD) {
|
||||
uint32_t bits = encode_uint32(record.data[3], record.data[2], record.data[1], record.data[0]);
|
||||
memcpy(&value, &bits, sizeof(value));
|
||||
}
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (remote.known && remote.dictionary_id == dictionary_id && remote.number == record.number) {
|
||||
remote.last_state_ms = now;
|
||||
remote.has_state = true;
|
||||
remote.entity->publish_state(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
if (record.type == TYPE_BINARY_SENSOR) {
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (!remote.known || remote.dictionary_id != dictionary_id || remote.number != record.number)
|
||||
continue;
|
||||
remote.last_state_ms = now;
|
||||
remote.has_state = true;
|
||||
if (record.payload == PAYLOAD_NO_STATE) {
|
||||
remote.entity->invalidate_state();
|
||||
} else {
|
||||
remote.entity->publish_state(record.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
void EntityTransport::request_dictionary_(uint32_t dictionary_id) {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (this->request_sent_ && now - this->last_request_ms_ < REQUEST_INTERVAL_MS)
|
||||
return;
|
||||
uint8_t packet[HEADER_SIZE] = {PACKET_REQUEST};
|
||||
put_uint32(packet + 1, dictionary_id);
|
||||
// A refused request is tried again with the next data packet under that dictionary id
|
||||
if (!this->interface_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {})) {
|
||||
ESP_LOGV(TAG, "Request for dictionary %08" PRIX32 " not accepted", dictionary_id);
|
||||
return;
|
||||
}
|
||||
this->request_sent_ = true;
|
||||
this->last_request_ms_ = now;
|
||||
ESP_LOGD(TAG, "Requested dictionary %08" PRIX32, dictionary_id);
|
||||
}
|
||||
|
||||
// Dropped packets point to a device with other firmware or a fault, and may arrive steadily, so warn only now and then
|
||||
bool EntityTransport::warning_due_() {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (this->warned_ && now - this->last_warning_ms_ < WARNING_INTERVAL_MS)
|
||||
return false;
|
||||
this->warned_ = true;
|
||||
this->last_warning_ms_ = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
// A newly connected or restarted peer needs the dictionary and every state; while it is lost, its entities have no
|
||||
// state
|
||||
void EntityTransport::on_session_(bool connected) {
|
||||
if (connected) {
|
||||
this->send_all_();
|
||||
return;
|
||||
}
|
||||
this->clear_remote_states_(false);
|
||||
}
|
||||
|
||||
// Clears the state of every remote entity, or with timed_out_only, of those whose state is older than the timeout
|
||||
void EntityTransport::clear_remote_states_(bool timed_out_only) {
|
||||
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_sensors_) {
|
||||
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
|
||||
continue;
|
||||
remote.has_state = false;
|
||||
remote.entity->publish_state(NAN);
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
for (auto &remote : this->remote_binary_sensors_) {
|
||||
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
|
||||
continue;
|
||||
remote.has_state = false;
|
||||
remote.entity->invalidate_state();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::entity_transport
|
||||
@@ -1,171 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <span>
|
||||
|
||||
#include "esphome/components/packet_interface/packet_interface.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::entity_transport {
|
||||
|
||||
/**
|
||||
* Sends the states of local sensors and binary sensors over a packet interface, and publishes the states received
|
||||
* from other devices to remote entities.
|
||||
*
|
||||
* Each device numbers its sensors and its binary sensors from 0, and sends a dictionary that gives the id for each
|
||||
* number. Data packets then refer to an entity by its number. Every packet carries the sender's dictionary id, a hash
|
||||
* of its device name and dictionary, so that a receiver knows which device sent it and which dictionary applies.
|
||||
* All values are little endian.
|
||||
*
|
||||
* Dictionary packet:
|
||||
* - type: 1 byte, PACKET_DICTIONARY;
|
||||
* - dictionary id: 4 bytes;
|
||||
* - part: 1 byte, the packet's index in the dictionary in bits 0-6, counting from 0 and wrapping, and bit 7 set on
|
||||
* the last packet;
|
||||
* - sender's device name: length (1 byte), then the name;
|
||||
* - one or more sections, each: entity type (1 byte), first number (1 byte), count (1 byte), then for each entity
|
||||
* its id: length (1 byte), then the id.
|
||||
*
|
||||
* Data packet:
|
||||
* - type: 1 byte, PACKET_DATA;
|
||||
* - dictionary id: 4 bytes;
|
||||
* - records, to the end, each a 16-bit tag, then its payload. The tag holds the entity's number in bits 0-7, its type
|
||||
* in bits 8-12, and the payload form in bits 13-14: none, with the state in bit 15 (binary sensors); 4 bytes (a
|
||||
* sensor's float); a length byte, then that many bytes; or no state, with no payload. The form gives the record's
|
||||
* size, so a receiver skips records of types it does not know.
|
||||
*
|
||||
* Every packet is checked in full before anything in it is used, so a damaged or forged packet changes no state.
|
||||
*
|
||||
* Dictionary request packet, sent by a device that receives data with a dictionary id it has not received in full,
|
||||
* with every part in order:
|
||||
* - type: 1 byte, PACKET_REQUEST;
|
||||
* - the dictionary id wanted: 4 bytes.
|
||||
*
|
||||
* Changed states are sent from loop(). The dictionary and all states are sent at boot, when a session layer reports
|
||||
* the peer connected, when the dictionary is requested, and every update interval. Unless the interface has both a
|
||||
* session and a reliability layer, the update interval defaults to DEFAULT_UPDATE_INTERVAL_MS. When the interface
|
||||
* refuses a packet, sending is tried again after a delay, and a warning is set once it has refused every packet for a
|
||||
* minute. With a remote timeout, a remote entity loses its state when
|
||||
* none arrives for it within the timeout.
|
||||
*/
|
||||
class EntityTransport : public Component {
|
||||
public:
|
||||
explicit EntityTransport(packet_interface::PacketInterface *interface) : interface_(interface) {}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// 0 chooses the default for the interface's layers; SCHEDULER_DONT_RUN never resends.
|
||||
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
|
||||
/// Remote entities lose their state when none arrives for this long; 0 keeps the last state.
|
||||
void set_remote_timeout(uint32_t timeout_ms) { this->remote_timeout_ms_ = timeout_ms; }
|
||||
void set_entity_count(size_t count) { this->entities_.init(count); }
|
||||
// Sensors must be added before binary sensors, so that data packets hold sensor records first
|
||||
#ifdef USE_SENSOR
|
||||
void add_sensor(const char *id, sensor::Sensor *sensor) {
|
||||
this->entities_.push_back({sensor, id, this, this->sensor_count_++, true, true});
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void add_binary_sensor(const char *id, binary_sensor::BinarySensor *sensor) {
|
||||
this->entities_.push_back({sensor, id, this, this->binary_sensor_count_++, false, true});
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
void add_remote_sensor(const char *provider, const char *remote_id, sensor::Sensor *sensor) {
|
||||
this->remote_sensors_.push_back({provider, remote_id, sensor});
|
||||
}
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
void add_remote_binary_sensor(const char *provider, const char *remote_id, binary_sensor::BinarySensor *sensor) {
|
||||
this->remote_binary_sensors_.push_back({provider, remote_id, sensor});
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
struct LocalEntity {
|
||||
EntityBase *entity; // a sensor::Sensor or a binary_sensor::BinarySensor, as is_sensor says
|
||||
const char *id;
|
||||
EntityTransport *parent;
|
||||
uint8_t number;
|
||||
bool is_sensor;
|
||||
bool updated;
|
||||
};
|
||||
template<typename T> struct RemoteEntity {
|
||||
const char *provider;
|
||||
const char *remote_id;
|
||||
T *entity;
|
||||
uint32_t dictionary_id{0}; // learnt from the provider's dictionary
|
||||
uint32_t last_state_ms{0};
|
||||
uint8_t number{0};
|
||||
bool known{false};
|
||||
bool has_state{false}; // a state arrived, and has not yet timed out
|
||||
};
|
||||
|
||||
void send_all_();
|
||||
void mark_updated_(LocalEntity &entity);
|
||||
bool send_dictionary_();
|
||||
bool send_updated_();
|
||||
size_t write_record_(const LocalEntity &entity, uint8_t *record) const;
|
||||
bool flush_(size_t length, size_t first, size_t last);
|
||||
size_t write_header_(uint8_t type) const;
|
||||
void receive_(const packet_interface::PacketBuffer &data);
|
||||
void receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id);
|
||||
void receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id);
|
||||
void learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
|
||||
size_t id_length, uint32_t dictionary_id);
|
||||
void request_dictionary_(uint32_t dictionary_id);
|
||||
bool dictionary_seen_(uint32_t dictionary_id) const;
|
||||
/// Clears each remote entity of \p provider that its complete dictionary does not name, warning if the entity was
|
||||
/// known or the dictionary is \p first_time seen, so a resent dictionary does not repeat the warning.
|
||||
void forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id, bool first_time);
|
||||
bool warning_due_();
|
||||
void sending_refused_();
|
||||
void on_session_(bool connected);
|
||||
void clear_remote_states_(bool timed_out_only);
|
||||
|
||||
packet_interface::PacketInterface *interface_;
|
||||
FixedVector<LocalEntity> entities_;
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
|
||||
StaticVector<RemoteEntity<sensor::Sensor>, ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT> remote_sensors_;
|
||||
#endif
|
||||
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
|
||||
StaticVector<RemoteEntity<binary_sensor::BinarySensor>, ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT>
|
||||
remote_binary_sensors_;
|
||||
#endif
|
||||
// Packets are built here, allocated in setup()
|
||||
uint8_t *buffer_{nullptr};
|
||||
size_t max_packet_size_{0};
|
||||
uint32_t update_interval_ms_{0};
|
||||
uint32_t remote_timeout_ms_{0};
|
||||
uint32_t dictionary_id_{0};
|
||||
uint32_t last_request_ms_{0};
|
||||
uint32_t last_warning_ms_{0};
|
||||
uint32_t refused_since_ms_{0}; // when the interface began refusing every packet
|
||||
uint32_t rx_dictionary_id_{0}; // the dictionary whose parts are arriving
|
||||
// The last dictionary ids received in full, so a provider whose dictionary names none of our entities is not asked
|
||||
// again
|
||||
std::array<uint32_t, 8> seen_dictionaries_{};
|
||||
uint8_t seen_next_{0};
|
||||
uint8_t rx_next_part_{0}; // the index of its next part, or PART_NONE if one was missed
|
||||
uint8_t dictionary_part_{0}; // the index of the next dictionary packet to send
|
||||
uint8_t sensor_count_{0};
|
||||
uint8_t binary_sensor_count_{0};
|
||||
size_t dictionary_next_{0}; // the first entity of the next dictionary packet to send
|
||||
bool dictionary_pending_{true};
|
||||
bool request_sent_{false};
|
||||
bool warned_{false};
|
||||
bool refusing_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::entity_transport
|
||||
@@ -1,11 +0,0 @@
|
||||
from esphome.components import sensor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import register_remote_sensor, remote_entity_schema
|
||||
|
||||
CONFIG_SCHEMA = remote_entity_schema(sensor.sensor_schema())
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await register_remote_sensor(config, var)
|
||||
@@ -146,7 +146,7 @@ def model_schema(config):
|
||||
cv.GenerateID(): cv.declare_id(class_name),
|
||||
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
|
||||
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
|
||||
map_sequence
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Any, Self
|
||||
|
||||
from esphome.components.mipi import check_model_requirements
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -128,24 +128,5 @@ class EpaperModel:
|
||||
return model
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
"""
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
"""Check that the components this model needs are configured, see check_model_requirements()."""
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
@@ -31,11 +31,25 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) {
|
||||
}
|
||||
|
||||
#if CONFIG_IDF_TARGET_ARCH_RISCV
|
||||
// Bounds of this image's flash-mapped code, from the ESP-IDF linker script.
|
||||
extern "C" char _instruction_reserved_start;
|
||||
extern "C" char _instruction_reserved_end;
|
||||
|
||||
// Check that [start, end) lies in this image's flash-mapped code or in IRAM.
|
||||
// The IROM range is larger than what the MMU maps, and reading an unmapped
|
||||
// address faults.
|
||||
static inline bool is_readable_code(uint32_t start, uint32_t end) {
|
||||
auto irom_start = reinterpret_cast<uint32_t>(&_instruction_reserved_start);
|
||||
auto irom_end = reinterpret_cast<uint32_t>(&_instruction_reserved_end);
|
||||
return (start >= irom_start && end <= irom_end) || (start >= SOC_IRAM_LOW && end <= SOC_IRAM_HIGH);
|
||||
}
|
||||
|
||||
// Check if a code address is a real return address by verifying the preceding
|
||||
// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during
|
||||
// panic) so flash cache is available and both IRAM and IROM are safely readable.
|
||||
// panic) so flash cache is available. The address may come from an earlier
|
||||
// build, so check that the bytes before it are mapped before reading them.
|
||||
static inline bool is_return_addr(uint32_t addr) {
|
||||
if (!is_code_addr(addr) || addr < 4)
|
||||
if (!is_code_addr(addr) || !is_readable_code(addr - 4, addr))
|
||||
return false;
|
||||
// A return address on the stack points to the instruction after a call.
|
||||
// Check for 4-byte JAL/JALR call instruction before this address.
|
||||
|
||||
@@ -271,7 +271,9 @@ void ESP32Camera::loop() {
|
||||
fb->timestamp = timestamp;
|
||||
}
|
||||
#endif
|
||||
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
|
||||
// A request that arrives while this image is handed out is kept for the next frame
|
||||
const uint8_t single_requesters = this->single_requesters_.exchange(0);
|
||||
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, single_requesters | this->stream_requesters_);
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
|
||||
@@ -291,7 +293,6 @@ void ESP32Camera::loop() {
|
||||
listener->on_camera_image(this->current_image_);
|
||||
}
|
||||
this->last_update_ = now;
|
||||
this->single_requesters_ = 0;
|
||||
}
|
||||
|
||||
/* ---------------- constructors ---------------- */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MODEL
|
||||
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import gree_ns
|
||||
@@ -19,6 +19,7 @@ MODELS = Model.options(
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
|
||||
{
|
||||
cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True),
|
||||
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,5 +27,8 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await climate_ir.new_climate_ir(config)
|
||||
cg.add(var.set_model(config[CONF_MODEL]))
|
||||
if config[CONF_USE_FAHRENHEIT]:
|
||||
cg.add_define("USE_GREE_FAHRENHEIT")
|
||||
cg.add(var.set_use_fahrenheit(True))
|
||||
if config[CONF_MODEL] == "yaw1f":
|
||||
cg.add_define("USE_GREE_YAW1F")
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "gree.h"
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
@@ -34,6 +36,21 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
|
||||
this->transmit_state();
|
||||
}
|
||||
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
uint8_t fahrenheit_half_steps(float target_celsius) {
|
||||
if (!std::isfinite(target_celsius)) {
|
||||
return 0;
|
||||
}
|
||||
// Whole degrees shown at k = 0 and at the top of the range
|
||||
static constexpr int MIN_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MIN));
|
||||
static constexpr int MAX_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MAX));
|
||||
const int desired_f = static_cast<int>(roundf(clamp<float>(celsius_to_fahrenheit(target_celsius), MIN_F, MAX_F)));
|
||||
// The display at k is (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10; pick the smallest k that reaches desired_f
|
||||
const int tenths_above_min = 10 * desired_f - (10 * 32 + 18 * GREE_TEMP_MIN);
|
||||
return static_cast<uint8_t>((tenths_above_min + 8) / 9);
|
||||
}
|
||||
#endif
|
||||
|
||||
void GreeClimate::transmit_state() {
|
||||
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
|
||||
const Model model = this->model_;
|
||||
@@ -41,7 +58,12 @@ void GreeClimate::transmit_state() {
|
||||
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
|
||||
|
||||
remote_state[0] = this->fan_speed_() | this->operation_mode_();
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
|
||||
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
|
||||
#else
|
||||
remote_state[1] = this->temperature_();
|
||||
#endif
|
||||
|
||||
if (model == GREE_YAN) {
|
||||
remote_state[3] = 0x50; // bits 4..7 always 0101
|
||||
@@ -107,16 +129,15 @@ void GreeClimate::transmit_state() {
|
||||
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
|
||||
}
|
||||
|
||||
// Calculate the checksum
|
||||
if (model == GREE_YAN) {
|
||||
remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0);
|
||||
} else {
|
||||
remote_state[7] =
|
||||
((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
|
||||
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
|
||||
0x0F)
|
||||
<< 4);
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
if (this->use_fahrenheit_) {
|
||||
remote_state[3] |= GREE_FAHRENHEIT_BIT;
|
||||
if (half_steps & 1)
|
||||
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
|
||||
}
|
||||
#endif
|
||||
|
||||
remote_state[7] = frame_checksum(remote_state);
|
||||
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
auto *data = transmit.get_data();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
namespace esphome::gree {
|
||||
|
||||
@@ -90,6 +91,26 @@ static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
|
||||
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
|
||||
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
|
||||
|
||||
// Byte 3 Fahrenheit bits
|
||||
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
|
||||
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
|
||||
|
||||
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
|
||||
/// nibble.
|
||||
inline uint8_t frame_checksum(const uint8_t *remote_state) {
|
||||
return (
|
||||
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
|
||||
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
|
||||
0x0F)
|
||||
<< 4);
|
||||
}
|
||||
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
|
||||
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
|
||||
uint8_t fahrenheit_half_steps(float target_celsius);
|
||||
#endif
|
||||
|
||||
// Model codes
|
||||
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
|
||||
|
||||
@@ -103,6 +124,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
|
||||
|
||||
void set_model(Model model);
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
|
||||
#endif
|
||||
void set_mode_bit(uint8_t bit_mask, bool enabled);
|
||||
|
||||
protected:
|
||||
@@ -118,6 +142,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
|
||||
|
||||
Model model_{};
|
||||
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
bool use_fahrenheit_{false};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::gree
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "image.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -165,6 +166,21 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
|
||||
}
|
||||
#ifdef USE_LVGL
|
||||
lv_image_dsc_t *Image::get_lv_image_dsc() {
|
||||
// No pixels yet. A widget keeps this descriptor and reads it on every draw, and LVGL warns on every draw of an image
|
||||
// without data, so give it a transparent pixel instead
|
||||
static constexpr uint8_t TRANSPARENT_PIXEL = 0;
|
||||
if (this->data_start_ == nullptr) {
|
||||
if (this->dsc_.data != &TRANSPARENT_PIXEL) {
|
||||
memset(&this->dsc_, 0, sizeof(this->dsc_));
|
||||
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
|
||||
this->dsc_.header.w = 1;
|
||||
this->dsc_.header.h = 1;
|
||||
this->dsc_.header.stride = 1;
|
||||
this->dsc_.data_size = 1;
|
||||
this->dsc_.data = &TRANSPARENT_PIXEL;
|
||||
}
|
||||
return &this->dsc_;
|
||||
}
|
||||
// lazily construct lvgl image_dsc.
|
||||
if (this->dsc_.data != this->data_start_) {
|
||||
this->dsc_.data = this->data_start_;
|
||||
|
||||
@@ -43,7 +43,12 @@ class Image : public display::BaseImage {
|
||||
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
|
||||
|
||||
#ifdef USE_LVGL
|
||||
lv_image_dsc_t *get_lv_image_dsc();
|
||||
virtual lv_image_dsc_t *get_lv_image_dsc();
|
||||
#endif
|
||||
#ifdef USE_LVGL_IMAGE
|
||||
/// Called when an LVGL image widget is given this image. An image whose size can change keeps the widget, so it can
|
||||
/// give the widget its source again when the size changes: the widget keeps the size it read from the descriptor.
|
||||
virtual void add_lvgl_widget(lv_obj_t *obj) {}
|
||||
#endif
|
||||
protected:
|
||||
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
|
||||
|
||||
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv.Optional(CONF_UPDATE_MODE): update_mode,
|
||||
# One or more GPIOs driven high during setup to power on the panel
|
||||
# (e.g. board power-enable rails), before reset and init.
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
|
||||
): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
**display.enable_pin_schema(model, config),
|
||||
cv.Optional(CONF_RESET_DURATION): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=core.TimePeriod(milliseconds=500)),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
@@ -15,6 +17,8 @@ from esphome.core import CORE
|
||||
from esphome.cpp_helpers import gpio_pin_expression
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@max246"]
|
||||
DOMAIN = "lightwaverf"
|
||||
|
||||
@@ -35,10 +39,20 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("1s"))
|
||||
|
||||
|
||||
def _drop_unused_name(config: ConfigType) -> ConfigType:
|
||||
# Remove before 2027.5.0
|
||||
if config.pop(CONF_NAME, None) is not None:
|
||||
_LOGGER.warning(
|
||||
"[lightwaverf] 'name' on lightwaverf.send_raw has no effect; remove it. "
|
||||
"Removed in 2027.5.0"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
|
||||
cv.Required(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Required(CONF_CODE): cv.All(
|
||||
[cv.Any(cv.hex_uint8_t)],
|
||||
cv.Length(min=10),
|
||||
@@ -47,7 +61,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
|
||||
}
|
||||
)
|
||||
).add_extra(_drop_unused_name)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
|
||||
@@ -82,7 +82,10 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
|
||||
#ifdef USE_IMAGE
|
||||
#ifdef USE_LVGL_IMAGE
|
||||
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
|
||||
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
|
||||
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) {
|
||||
::lv_image_set_src(obj, image->get_lv_image_dsc());
|
||||
image->add_lvgl_widget(obj);
|
||||
}
|
||||
|
||||
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
|
||||
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
|
||||
|
||||
@@ -28,6 +28,7 @@ from esphome.const import (
|
||||
CONF_X,
|
||||
CONF_Y,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import FixedVector
|
||||
|
||||
from ..automation import action_to_code
|
||||
@@ -41,6 +42,7 @@ from ..defines import (
|
||||
CONF_RADIUS,
|
||||
CONF_SRC,
|
||||
CONF_START_ANGLE,
|
||||
LValidator,
|
||||
addr,
|
||||
literal,
|
||||
)
|
||||
@@ -57,7 +59,7 @@ from ..lv_validation import (
|
||||
)
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
|
||||
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
|
||||
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
|
||||
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .img import CONF_IMAGE
|
||||
|
||||
@@ -221,7 +223,6 @@ RECT_PROPS = {
|
||||
"radius",
|
||||
"bg_opa",
|
||||
"bg_color",
|
||||
"bg_grad",
|
||||
"border_color",
|
||||
"border_width",
|
||||
"border_opa",
|
||||
@@ -239,6 +240,30 @@ RECT_PROPS = {
|
||||
"shadow_opa",
|
||||
)
|
||||
}
|
||||
RECT_PROPS.update(
|
||||
bg_grad=LValidator(
|
||||
cv.use_id(lv_gradient_t), lv_gradient_t, retmapper=lambda v: MockObj(f"*{v}")
|
||||
)
|
||||
)
|
||||
RECT_SCHEMA = {
|
||||
**{cv.Optional(prop): validator for prop, validator in RECT_PROPS.items()},
|
||||
cv.Optional(CONF_OPA): cv.invalid(
|
||||
"Rectangles have no single opacity; use bg_opa, border_opa, outline_opa or shadow_opa"
|
||||
),
|
||||
}
|
||||
POLYGON_PROPS = {
|
||||
p: RECT_PROPS[p] for p in ("border_color", "border_width", "border_opa")
|
||||
}
|
||||
POLYGON_SCHEMA = {
|
||||
**{cv.Optional(prop): validator for prop, validator in POLYGON_PROPS.items()},
|
||||
**{
|
||||
cv.Optional(prop): cv.invalid(
|
||||
"Polygons are drawn as an outline; use border_color, border_width and border_opa"
|
||||
)
|
||||
for prop in (*RECT_PROPS, CONF_OPA)
|
||||
if prop not in POLYGON_PROPS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _draw_line(layer, dsc, points):
|
||||
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
|
||||
ObjUpdateAction,
|
||||
cv.Schema(
|
||||
{
|
||||
**DRAW_OPA_SCHEMA,
|
||||
**DRAW_SCHEMA,
|
||||
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
**RECT_SCHEMA,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
|
||||
LINE_PROPS = {
|
||||
"width": STYLE_PROPS["line_width"],
|
||||
"color": STYLE_PROPS["line_color"],
|
||||
"dash-width": STYLE_PROPS["line_dash_width"],
|
||||
"dash-gap": STYLE_PROPS["line_dash_gap"],
|
||||
"dash_width": STYLE_PROPS["line_dash_width"],
|
||||
"dash_gap": STYLE_PROPS["line_dash_gap"],
|
||||
"round_start": lv_bool,
|
||||
"round_end": lv_bool,
|
||||
}
|
||||
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
|
||||
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
**POLYGON_SCHEMA,
|
||||
},
|
||||
).add_extra(_validate_points),
|
||||
synchronous=True,
|
||||
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
|
||||
_draw_line(layer, line_dsc, points)
|
||||
|
||||
return await draw_to_code(
|
||||
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from esphome.const import (
|
||||
CONF_WIDTH,
|
||||
CONF_X,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import nullptr
|
||||
|
||||
@@ -64,7 +65,7 @@ from ..lv_validation import (
|
||||
)
|
||||
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
|
||||
from ..schemas import STATE_SCHEMA
|
||||
from ..styles import LVStyle
|
||||
from ..styles import LVStyle, create_style
|
||||
from ..types import (
|
||||
LV_EVENT,
|
||||
LvCompound,
|
||||
@@ -100,6 +101,12 @@ CONF_SCALES = "scales"
|
||||
CONF_STRIDE = "stride"
|
||||
CONF_TICK_STYLE = "tick_style"
|
||||
|
||||
|
||||
def _arc_style_name(indicator_id: str) -> str:
|
||||
"""Name of the style variable generated for an arc indicator."""
|
||||
return f"meter_arc_{indicator_id}_style"
|
||||
|
||||
|
||||
# LVGL 9.4 Migration: Use scale widget instead of removed meter widget
|
||||
#
|
||||
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
|
||||
@@ -423,11 +430,9 @@ class MeterType(WidgetType):
|
||||
get_warnings().add(
|
||||
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
|
||||
)
|
||||
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
|
||||
style_var = await create_style(_arc_style_name(iid.id), props)
|
||||
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
|
||||
lv.scale_section_set_style(
|
||||
tvar, LV_PART.MAIN, await arc_style.get_var()
|
||||
)
|
||||
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
|
||||
lw = Widget.create(iid, tvar, arc_indicator_type)
|
||||
lw.parent = scale_var
|
||||
await set_indicator_values(scale_var, lw, v)
|
||||
@@ -610,6 +615,15 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
|
||||
|
||||
async def set_value(w: Widget):
|
||||
await set_indicator_values(w.parent, w, config)
|
||||
if (opa := config.get(CONF_OPA)) is None:
|
||||
return
|
||||
opa = await opacity.process(opa)
|
||||
if w.type is arc_indicator_type:
|
||||
style = await cg.get_variable(ID(_arc_style_name(str(w.var))))
|
||||
lv.style_set_arc_opa(style, opa)
|
||||
lv_obj.invalidate(w.parent)
|
||||
else:
|
||||
lv_obj.set_style_opa(w.obj, opa, LV_PART.MAIN)
|
||||
|
||||
return await action_to_code(widget, set_value, action_id, template_arg, args)
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
import importlib.util
|
||||
from typing import Any, Self
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components.const import CONF_COLOR_DEPTH
|
||||
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
|
||||
import esphome.config_validation as cv
|
||||
@@ -23,6 +25,7 @@ from esphome.const import (
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_NUMBER,
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_PAGES,
|
||||
@@ -306,6 +309,57 @@ def model_schema_extractor(
|
||||
return decorate
|
||||
|
||||
|
||||
def _is_pin_expander(key: str) -> bool:
|
||||
"""Check if a pin config key names a component that provides pins, e.g. an I/O expander."""
|
||||
from esphome import loader
|
||||
|
||||
if key not in pins.PIN_SCHEMA_REGISTRY:
|
||||
if importlib.util.find_spec(f"esphome.components.{key}") is None:
|
||||
return False
|
||||
# Loading the component registers its pin schema. If it fails to load (the
|
||||
# loader logs why), still require it rather than quietly dropping it.
|
||||
if loader.get_component(key) is None:
|
||||
return True
|
||||
return key in pins.PIN_SCHEMA_REGISTRY
|
||||
|
||||
|
||||
def _pin_expanders(value: Any) -> set[str]:
|
||||
"""Find the pin expander components used by any pin configs in a model's defaults."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return set().union(*(_pin_expanders(v) for v in value))
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
found = set().union(*(_pin_expanders(v) for v in value.values()))
|
||||
if CONF_NUMBER in value:
|
||||
found.update(k for k in value if k != CONF_NUMBER and _is_pin_expander(k))
|
||||
return found
|
||||
|
||||
|
||||
def check_model_requirements(name: str, defaults: dict[str, Any]) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component a display model requires is not configured.
|
||||
These are the components in its ``requires`` default plus any pin expander used by its
|
||||
default pins, which is needed even if the user config overrides those pins.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
"""
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
requirements = set(defaults.get("requires", ())) | _pin_expanders(defaults)
|
||||
if missing := {x for x in requirements if x not in global_config}:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
|
||||
|
||||
class DriverChip:
|
||||
"""
|
||||
A class representing a MIPI DBI driver chip model.
|
||||
@@ -671,32 +725,14 @@ class DriverChip:
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
|
||||
Check that the components this model needs are configured, see check_model_requirements().
|
||||
Also logs a warning if the model is deprecated.
|
||||
"""
|
||||
if deprecation_reason := self.get_default("deprecation_reason"):
|
||||
LOGGER.warning(
|
||||
"Display model %s is deprecated: %s", self.name, deprecation_reason
|
||||
)
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
|
||||
|
||||
def requires_buffer(config) -> bool:
|
||||
|
||||
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
|
||||
}
|
||||
|
||||
|
||||
def model_schema(config: ConfigType) -> cv.All:
|
||||
def model_schema(config: ConfigType) -> cv.Schema:
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
|
||||
# Dimensions are optional if the model has a default width
|
||||
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
|
||||
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
|
||||
schema = display.FULL_DISPLAY_SCHEMA.extend(
|
||||
return display.FULL_DISPLAY_SCHEMA.extend(
|
||||
{
|
||||
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||
cv.GenerateID(): cv.declare_id(MipiDsi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
|
||||
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
|
||||
}
|
||||
)
|
||||
return cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32P4]),
|
||||
)
|
||||
|
||||
|
||||
@model_schema_extractor(MODELS, model_schema)
|
||||
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
config = model_schema(config)(config)
|
||||
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
|
||||
config
|
||||
)
|
||||
# Check before the schema, which would fail on default pins of a missing expander
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
config = model_schema(config)(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ DsiDriverChip(
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="730Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
||||
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="960Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
@@ -122,7 +122,7 @@ DsiDriverChip(
|
||||
pclk_frequency="70MHz",
|
||||
lane_bit_rate="965Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
|
||||
@@ -17,7 +17,7 @@ DsiDriverChip(
|
||||
color_order="RGB",
|
||||
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
||||
reset_pin={"xl9535": None, "number": 2},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xE0, 0x00),
|
||||
(0xE1, 0x93),
|
||||
|
||||
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
schema = model_schema(config)
|
||||
config = cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
|
||||
)(config)
|
||||
# Check before the schema, which would fail on default pins of a missing expander
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
config = model_schema(config)(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ ST7701S(
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 17},
|
||||
reset_pin={"xl9535": None, "number": 5},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
pclk_pin=41,
|
||||
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
|
||||
invert_colors=False,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 3},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
de_pin=45,
|
||||
hsync_pin=47,
|
||||
vsync_pin=41,
|
||||
|
||||
@@ -115,7 +115,7 @@ st7701s.extend(
|
||||
vsync_pulse_width=8,
|
||||
vsync_back_porch=20,
|
||||
cs_pin={"pca9554": None, "number": 4},
|
||||
requires={"psram", "pca9554"},
|
||||
requires={"psram"},
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
vsync_pin=17,
|
||||
|
||||
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
|
||||
pclk_frequency="16MHz",
|
||||
reset_pin={"ch422g": None, "number": 3},
|
||||
enable_pin={"ch422g": None, "number": 2},
|
||||
requires={"psram", "ch422g"},
|
||||
requires={"psram"},
|
||||
de_pin=5,
|
||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||
|
||||
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MipiSpi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
|
||||
COLOR_ORDERS, upper=True
|
||||
),
|
||||
|
||||
@@ -22,7 +22,6 @@ DriverChip(
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
use_axis_flips=True,
|
||||
reset_delay=20,
|
||||
requires={"pca9554"},
|
||||
initsequence=(
|
||||
delay(100),
|
||||
(0xFF, 0x20, 0x10, 0x10),
|
||||
|
||||
@@ -69,8 +69,9 @@ PackedBits = modbus.modbus_ns.class_("PackedBits")
|
||||
# The exception code passed to on_error handlers.
|
||||
ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
|
||||
|
||||
# Lambda argument types for the reply handlers: the device address the send targeted, and the
|
||||
# request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
|
||||
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
|
||||
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
|
||||
_ADDRESS_ARG = (cg.uint8, "address")
|
||||
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
|
||||
@@ -135,11 +136,11 @@ _ACTION_BASE_SCHEMA = cv.Schema(
|
||||
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient),
|
||||
cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t),
|
||||
# Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive
|
||||
# later (fire-and-continue), so all run with the request/reply available - not the outer
|
||||
# automation's variables.
|
||||
# later (fire-and-continue), so all run with the address and request/reply available - not the
|
||||
# outer automation's variables.
|
||||
cv.Optional(CONF_ON_SENT): _handler_schema(),
|
||||
cv.Optional(CONF_ON_ERROR): _handler_schema(),
|
||||
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `request`,
|
||||
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `address`/`request`,
|
||||
# returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation
|
||||
# form may also carry an optional `retry:` returning lambda to run actions AND decide the retry.
|
||||
cv.Optional(CONF_ON_NO_RESPONSE): cv.All(
|
||||
@@ -199,7 +200,7 @@ async def register_client_action(
|
||||
)
|
||||
if sent_conf := config.get(CONF_ON_SENT):
|
||||
await automation.build_automation(
|
||||
var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf
|
||||
var.get_sent_trigger(), [_ADDRESS_ARG, (_PDU_SPAN, "request")], sent_conf
|
||||
)
|
||||
if response_conf := config.get(CONF_ON_RESPONSE):
|
||||
await automation.build_automation(
|
||||
@@ -217,7 +218,7 @@ async def register_client_action(
|
||||
if error_conf := config.get(CONF_ON_ERROR):
|
||||
await automation.build_automation(
|
||||
var.get_error_trigger(),
|
||||
[(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
|
||||
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
|
||||
error_conf,
|
||||
)
|
||||
if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None:
|
||||
@@ -228,18 +229,20 @@ async def register_client_action(
|
||||
else:
|
||||
await automation.build_automation(
|
||||
var.get_no_response_trigger(),
|
||||
[(_PDU_SPAN, "request")],
|
||||
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
|
||||
no_response_conf,
|
||||
)
|
||||
retry_conf = no_response_conf.get(CONF_RETRY)
|
||||
if retry_conf is not None:
|
||||
retry_lambda = await cg.process_lambda(
|
||||
retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_
|
||||
retry_conf, [_ADDRESS_ARG, (_PDU_SPAN, "request")], return_type=cg.bool_
|
||||
)
|
||||
cg.add(var.set_retry(retry_lambda))
|
||||
if not_sent_conf := config.get(CONF_ON_NOT_SENT):
|
||||
await automation.build_automation(
|
||||
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
|
||||
var.get_not_sent_trigger(),
|
||||
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
|
||||
not_sent_conf,
|
||||
)
|
||||
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
|
||||
# matching direction so a write action never generates a read option's setter.
|
||||
@@ -265,7 +268,7 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
|
||||
var,
|
||||
config,
|
||||
args,
|
||||
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
|
||||
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,45 +13,51 @@ namespace esphome::modbus_client {
|
||||
/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no
|
||||
/// central client object and no request matching. The device address is templatable; it is stamped on the
|
||||
/// device at play() time; the hub routes each reply by device pointer, so a changed address never
|
||||
/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping
|
||||
/// sends it could misreport, and the handler can recompute the expression it configured.)
|
||||
/// mis-routes an earlier reply. The handlers wired here and send's on_response get `address` first: the
|
||||
/// address of their own request, which the hub takes from its frame, so overlapping sends each report their own.
|
||||
template<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address
|
||||
|
||||
Trigger<std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
|
||||
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; }
|
||||
Trigger<std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
|
||||
Trigger<std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
|
||||
Trigger<uint8_t, std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
|
||||
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() {
|
||||
return &this->error_trigger_;
|
||||
}
|
||||
Trigger<uint8_t, std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
|
||||
Trigger<uint8_t, std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
|
||||
|
||||
/// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue
|
||||
/// The retry decision for on_no_response: given the request (address, PDU), return true to have the hub re-queue
|
||||
/// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with
|
||||
/// the no_response trigger (actions run, then this decides the retry).
|
||||
using retry_func_t = bool (*)(std::span<const uint8_t>);
|
||||
using retry_func_t = bool (*)(uint8_t, std::span<const uint8_t>);
|
||||
void set_retry(retry_func_t f) { this->retry_func_ = f; }
|
||||
|
||||
/// The frame was written to the wire: fires once per transmission, before any reply, and never for a
|
||||
/// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data).
|
||||
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_trigger_.trigger(request_pdu); }
|
||||
void on_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
|
||||
this->sent_trigger_.trigger(address, request_pdu);
|
||||
}
|
||||
/// Never reached the wire, from either of two sources. The hub calls this for a request it accepted
|
||||
/// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus
|
||||
/// device going offline, say. Everything the hub refuses at the door instead returns false from
|
||||
/// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same
|
||||
/// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder.
|
||||
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); }
|
||||
void on_not_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
|
||||
this->not_sent_trigger_.trigger(address, request_pdu);
|
||||
}
|
||||
/// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing:
|
||||
/// register_client_action() wires on_error for all of them, so a derived class must not have to
|
||||
/// remember the override.
|
||||
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
|
||||
this->error_trigger_.trigger(request_pdu, exception_code);
|
||||
void on_error(uint8_t address, std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
|
||||
this->error_trigger_.trigger(address, request_pdu, exception_code);
|
||||
}
|
||||
/// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form),
|
||||
/// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The
|
||||
/// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry.
|
||||
bool on_no_response(std::span<const uint8_t> request_pdu) override {
|
||||
this->no_response_trigger_.trigger(request_pdu);
|
||||
bool on_no_response(uint8_t address, std::span<const uint8_t> request_pdu) override {
|
||||
this->no_response_trigger_.trigger(address, request_pdu);
|
||||
if (this->retry_func_ != nullptr)
|
||||
return this->retry_func_(request_pdu);
|
||||
return this->retry_func_(address, request_pdu);
|
||||
return false;
|
||||
}
|
||||
/// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub
|
||||
@@ -70,13 +76,13 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
|
||||
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
if (!this->queue_pdu(pdu, options))
|
||||
this->on_not_sent(pdu);
|
||||
this->on_not_sent(this->address_, pdu);
|
||||
}
|
||||
|
||||
Trigger<std::span<const uint8_t>> sent_trigger_;
|
||||
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
|
||||
Trigger<std::span<const uint8_t>> no_response_trigger_;
|
||||
Trigger<std::span<const uint8_t>> not_sent_trigger_;
|
||||
Trigger<uint8_t, std::span<const uint8_t>> sent_trigger_;
|
||||
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
|
||||
Trigger<uint8_t, std::span<const uint8_t>> no_response_trigger_;
|
||||
Trigger<uint8_t, std::span<const uint8_t>> not_sent_trigger_;
|
||||
retry_func_t retry_func_{nullptr};
|
||||
};
|
||||
|
||||
@@ -119,7 +125,7 @@ template<typename... Ts> class WriteCommandOptions {
|
||||
};
|
||||
|
||||
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
|
||||
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
|
||||
/// delivered raw - on_response(address, request, response) - deliberately bypassing the typed dispatch, so
|
||||
/// non-standard/custom transactions pass through untouched.
|
||||
/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the
|
||||
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
|
||||
@@ -133,7 +139,7 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
|
||||
public:
|
||||
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
|
||||
|
||||
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
|
||||
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
|
||||
return &this->response_trigger_;
|
||||
}
|
||||
|
||||
@@ -143,12 +149,13 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
|
||||
this->send_or_resolve_(this->pdu_.value(x...), options);
|
||||
}
|
||||
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
|
||||
this->response_trigger_.trigger(request_pdu, response_pdu);
|
||||
void on_response(uint8_t address, std::span<const uint8_t> request_pdu,
|
||||
std::span<const uint8_t> response_pdu) override {
|
||||
this->response_trigger_.trigger(address, request_pdu, response_pdu);
|
||||
}
|
||||
|
||||
protected:
|
||||
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
|
||||
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
|
||||
};
|
||||
|
||||
/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs
|
||||
|
||||
@@ -2,24 +2,18 @@ import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SOURCE_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import Number, number_ns
|
||||
from .. import Number
|
||||
|
||||
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(NumberSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
source = await cg.get_variable(config[CONF_SOURCE_ID])
|
||||
var = await sensor.new_sensor(config, source)
|
||||
await cg.register_component(var, config)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
# Number has the same float state callback as Sensor
|
||||
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "number_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::number {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "number.sensor");
|
||||
|
||||
void NumberSensor::setup() {
|
||||
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
|
||||
if (this->source_->has_state())
|
||||
this->publish_state(this->source_->state);
|
||||
}
|
||||
|
||||
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
|
||||
|
||||
} // namespace esphome::number
|
||||
@@ -1,19 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "../number.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::number {
|
||||
|
||||
class NumberSensor final : public sensor::Sensor, public Component {
|
||||
public:
|
||||
explicit NumberSensor(Number *source) : source_(source) {}
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
Number *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::number
|
||||
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user