mirror of
https://github.com/esphome/esphome.git
synced 2026-10-26 16:02:13 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37e046a52 | ||
|
|
3246311daa | ||
|
|
659efd5fcb | ||
|
|
bb3f175fa7 | ||
|
|
223022e0a6 | ||
|
|
3b0a1ba339 | ||
|
|
92655743c1 | ||
|
|
5f79e92c14 |
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"
|
||||
@@ -217,10 +217,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
|
||||
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
|
||||
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
|
||||
`LineReader<N>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; I2C has `update_register`,
|
||||
`write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`.
|
||||
`if (!this->status_comm_result(ok)) return;` sets or clears the warning. A child sensor that skips repeated
|
||||
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
|
||||
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
|
||||
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
|
||||
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
|
||||
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
|
||||
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
@@ -444,16 +443,12 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
||||
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
|
||||
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
|
||||
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
|
||||
the body only when turned on. Never use it for a new entity; an action is a button.
|
||||
```python
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which
|
||||
already applies `inverted`.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
|
||||
+1
-30
@@ -1,18 +1,15 @@
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import math
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ABOVE,
|
||||
CONF_ALL,
|
||||
CONF_ANY,
|
||||
CONF_AUTOMATION_ID,
|
||||
CONF_BELOW,
|
||||
CONF_CONDITION,
|
||||
CONF_COUNT,
|
||||
CONF_ELSE,
|
||||
@@ -366,25 +363,6 @@ class ApplyField:
|
||||
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
|
||||
|
||||
|
||||
def _in_range_low(config: ConfigType, value: float) -> str:
|
||||
return "-INFINITY" if math.isnan(value) else str(cg.safe_exp(value))
|
||||
|
||||
|
||||
def _in_range_high(config: ConfigType, value: float) -> str:
|
||||
# With no lower bound, an unset upper bound stays NaN so the check is false, as before.
|
||||
if math.isnan(value):
|
||||
return "NAN" if math.isnan(config[CONF_ABOVE]) else "INFINITY"
|
||||
return str(cg.safe_exp(value))
|
||||
|
||||
|
||||
# ``above <= state <= below`` on the parent's float ``state``; register with ``IN_RANGE_DEFAULTS``.
|
||||
IN_RANGE_CHECK = ApplyCall(
|
||||
"state >= {} && {parent}->state <= {}",
|
||||
((CONF_ABOVE, cg.float_, _in_range_low), (CONF_BELOW, cg.float_, _in_range_high)),
|
||||
)
|
||||
IN_RANGE_DEFAULTS = {CONF_ABOVE: math.nan, CONF_BELOW: math.nan}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyBytes:
|
||||
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
|
||||
@@ -713,11 +691,7 @@ async def _render_check(
|
||||
|
||||
|
||||
def register_apply_condition(
|
||||
name: str,
|
||||
schema: cv.Schema,
|
||||
check: str | ApplyCall,
|
||||
id_key: str = CONF_ID,
|
||||
defaults: ConfigType | None = None,
|
||||
name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID
|
||||
) -> None:
|
||||
"""Register a condition that is one expression on its parent, with no C++ class.
|
||||
|
||||
@@ -726,7 +700,6 @@ def register_apply_condition(
|
||||
against config values, all of which must be present. Write ``== false`` to negate.
|
||||
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
|
||||
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
|
||||
``defaults`` fills absent keys at code generation only, so the validated config is unchanged.
|
||||
Generates one static predicate and an ``ApplyCondition`` templated on it.
|
||||
"""
|
||||
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
|
||||
@@ -741,8 +714,6 @@ def register_apply_condition(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
if defaults:
|
||||
config = {**defaults, **config}
|
||||
parent = await _apply_parent(config, id_key)
|
||||
lambda_args = _apply_lambda_args(args)
|
||||
values = _apply_values(config, members)
|
||||
|
||||
+13
-30
@@ -7,7 +7,6 @@ import re
|
||||
import textwrap
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
|
||||
from esphome.components.esp32 import (
|
||||
KEY_SRC_CMAKE,
|
||||
get_esp32_variant,
|
||||
@@ -516,7 +515,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
# Where CMake puts the .gch of the src component; ccache reads the checksum
|
||||
# next to it in place of the .gch
|
||||
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
|
||||
# Where the CMake gate records its choice
|
||||
# Where the Windows gate records its choice
|
||||
_PCH_CHOICE_VAR = "ESPHOME_PCH"
|
||||
|
||||
|
||||
@@ -532,32 +531,16 @@ def _pch_cmake_block() -> str:
|
||||
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
|
||||
{headers}
|
||||
)"""
|
||||
if pch.pch_forced():
|
||||
if not pch.pch_needs_gcc_check():
|
||||
return f"\n# ESPHome precompiled header\n{block}\n"
|
||||
# Only CMake knows the compiler version before the first configure, and
|
||||
# the ccache IDF found is the one the build runs
|
||||
gcc_gate = ""
|
||||
if pch.pch_needs_gcc_check():
|
||||
gcc_gate = f"""if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
|
||||
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
|
||||
set(_esphome_pch OFF)
|
||||
endif()
|
||||
"""
|
||||
min_ccache = ".".join(map(str, CCACHE_PCH_MIN_VERSION))
|
||||
# Before the first configure only CMake knows the compiler version
|
||||
return f"""
|
||||
# ESPHome precompiled header, unless a check below rules it out
|
||||
set(_esphome_pch ON)
|
||||
{gcc_gate}if(CCACHE_ENABLE AND CCACHE_FOUND)
|
||||
execute_process(COMMAND "${{CCACHE_FOUND}}" --version OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)
|
||||
if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")
|
||||
if(CMAKE_MATCH_1 VERSION_LESS {min_ccache})
|
||||
message(STATUS "ESPHome: ccache ${{CMAKE_MATCH_1}} can reuse stale objects with a precompiled header; compiling without it (upgrade ccache to {min_ccache} or newer, or set ESPHOME_PCH_ENABLE=1 to force)")
|
||||
set(_esphome_pch OFF)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
set({_PCH_CHOICE_VAR} ${{_esphome_pch}} CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
if(_esphome_pch)
|
||||
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
|
||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
|
||||
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
|
||||
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
else()
|
||||
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
{textwrap.indent(block, " ")}
|
||||
endif()
|
||||
"""
|
||||
@@ -574,10 +557,10 @@ def write_pch_checksum() -> None:
|
||||
|
||||
if not pch.pch_enabled():
|
||||
return
|
||||
# Written whatever the gate chose last time: a build tree without that
|
||||
# choice, or a reconfigure that turns the pch on, must not see a stale sum
|
||||
if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
|
||||
pch.log_pch_in_use()
|
||||
# The gate's choice, cached by configure
|
||||
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
|
||||
return
|
||||
pch.log_pch_in_use()
|
||||
checksum = pch.pch_checksum(
|
||||
CORE.relative_src_path(),
|
||||
pch.PCH_DEFAULT_HEADERS,
|
||||
|
||||
@@ -3,68 +3,21 @@ resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.framework_helpers import (
|
||||
strip_win_long_path_prefix,
|
||||
tool_version_probe,
|
||||
tool_version_runs,
|
||||
)
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Before 4.7 ccache stores a pch consumer whose header is newer than one
|
||||
# second without that header, so a later build reuses it after the header
|
||||
# changed (#21621)
|
||||
CCACHE_PCH_MIN_VERSION = (4, 7)
|
||||
|
||||
_VERSION_RE = re.compile(r"ccache version (\d+(?:\.\d+)*)")
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _ccache_probe(ccache: str) -> str | Exception:
|
||||
"""``ccache --version``, run once per binary per process."""
|
||||
return tool_version_probe(ccache)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
ccache,
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
probe=_ccache_probe,
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _ccache_too_old_for_pch(ccache: str) -> bool:
|
||||
output = _ccache_probe(ccache)
|
||||
if not isinstance(output, str) or (found := _VERSION_RE.search(output)) is None:
|
||||
return False
|
||||
if tuple(map(int, found[1].split("."))) >= CCACHE_PCH_MIN_VERSION:
|
||||
return False
|
||||
_LOGGER.info(
|
||||
"ccache %s at %s can reuse stale objects with a precompiled header; "
|
||||
"compiling without it (upgrade ccache to %s or newer, or set "
|
||||
"ESPHOME_PCH_ENABLE=1 to force)",
|
||||
found[1],
|
||||
ccache,
|
||||
".".join(map(str, CCACHE_PCH_MIN_VERSION)),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def ccache_breaks_pch() -> bool:
|
||||
"""Whether the ccache this build uses is too old for a precompiled header.
|
||||
ESP-IDF builds check the ccache IDF finds in CMake instead (build_gen.espidf)."""
|
||||
return (ccache := resolve_ccache_path()) is not None and _ccache_too_old_for_pch(
|
||||
ccache
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from esphome.build_helpers.ccache import (
|
||||
ccache_breaks_pch,
|
||||
effective_ccache_basedir,
|
||||
parse_enable_env,
|
||||
)
|
||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
||||
from esphome.const import PLATFORM_NRF52
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
@@ -121,16 +117,9 @@ def pch_needs_gcc_check() -> bool:
|
||||
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
|
||||
|
||||
|
||||
def _ccache_rules_out_pch() -> bool:
|
||||
"""An old ccache, unless ``ESPHOME_PCH_ENABLE=1`` forces the pch on.
|
||||
ESP-IDF runs the same check in CMake (build_gen.espidf)."""
|
||||
return not pch_forced() and ccache_breaks_pch()
|
||||
|
||||
|
||||
def pch_usable(cxx: Sequence[Path | str]) -> bool:
|
||||
"""The knob plus the ccache and host rules; ``ESPHOME_PCH_ENABLE=1``
|
||||
skips the rules."""
|
||||
if not pch_enabled() or _ccache_rules_out_pch():
|
||||
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
|
||||
if not pch_enabled():
|
||||
return False
|
||||
if not pch_needs_gcc_check():
|
||||
return True
|
||||
@@ -173,11 +162,7 @@ def pch_script_enabled() -> bool:
|
||||
"""Whether this PlatformIO build takes the pch script."""
|
||||
from esphome.core import CORE
|
||||
|
||||
return (
|
||||
pch_enabled()
|
||||
and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
|
||||
and not _ccache_rules_out_pch()
|
||||
)
|
||||
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
|
||||
|
||||
|
||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||
|
||||
@@ -90,10 +90,6 @@ class AirtonClimate : public climate_ir::ClimateIR {
|
||||
bool set_display_state(bool state);
|
||||
bool set_vertical_direction_state(VerticalDirection direction);
|
||||
void send_settings();
|
||||
void control_vertical_direction(VerticalDirection direction) {
|
||||
if (this->set_vertical_direction_state(direction))
|
||||
this->send_settings();
|
||||
}
|
||||
void set_setting(AirtonSetting setting, bool state) {
|
||||
const bool changed = setting == AirtonSetting::AIRTON_SETTING_DISPLAY ? this->set_display_state(state)
|
||||
: this->set_sleep_mode_state(state);
|
||||
|
||||
@@ -9,16 +9,18 @@ from ..climate import (
|
||||
CONF_VERTICAL_DIRECTION,
|
||||
VERTICAL_DIRECTIONS,
|
||||
AirtonClimate,
|
||||
VerticalDirection,
|
||||
airton_ns,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@Procsiab"]
|
||||
|
||||
VerticalDirectionSelect = airton_ns.class_("VerticalDirectionSelect", select.Select)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate),
|
||||
cv.Optional(CONF_VERTICAL_DIRECTION): select.select_schema(
|
||||
select.ApplySelect,
|
||||
VerticalDirectionSelect,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
}
|
||||
@@ -32,6 +34,4 @@ async def to_code(config: ConfigType) -> None:
|
||||
CONF_VERTICAL_DIRECTION,
|
||||
hub.set_vertical_direction_select,
|
||||
options=list(VERTICAL_DIRECTIONS),
|
||||
# The options are in the order of the enum; the hub publishes the state
|
||||
apply=f"control_vertical_direction(static_cast<{VerticalDirection}>(index))",
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "../airton.h"
|
||||
|
||||
namespace esphome::airton {
|
||||
|
||||
class VerticalDirectionSelect : public select::Select, public Parented<AirtonClimate> {
|
||||
protected:
|
||||
// The options are in the order of the enum, so the index is the direction; the parent publishes the state
|
||||
void control(size_t index) override {
|
||||
if (this->parent_->set_vertical_direction_state(static_cast<VerticalDirection>(index)))
|
||||
this->parent_->send_settings();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::airton
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CODE,
|
||||
@@ -9,11 +10,11 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MQTT_ID,
|
||||
CONF_ON_STATE,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
)
|
||||
@@ -43,12 +44,12 @@ AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState")
|
||||
|
||||
|
||||
_ALARM_CONTROL_PANEL_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt_component_class("MQTTAlarmControlPanelComponent")
|
||||
mqtt.MQTTAlarmControlPanelComponent
|
||||
),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation({}),
|
||||
@@ -165,7 +166,11 @@ _CALLBACK_AUTOMATIONS = (
|
||||
@setup_entity("alarm_control_panel")
|
||||
async def setup_alarm_control_panel_core_(var, config):
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
await entity_helpers.setup_entity_integrations(var, config)
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
if mqtt_id := config.get(CONF_MQTT_ID):
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
|
||||
|
||||
async def register_alarm_control_panel(var, config):
|
||||
|
||||
@@ -11,52 +11,7 @@ ESPHOME_LOG_TAG(TAG, "apds9960");
|
||||
this->mark_failed(LOG_STR("Setting up APDS9960 registers failed")); \
|
||||
return; \
|
||||
}
|
||||
|
||||
static constexpr i2c::I2CRegValue APDS9960_TIMING[] PROGMEM = {
|
||||
// ATime (ADC integration time, 2.78ms increments, 0x81) -> 0xDB (103ms)
|
||||
{0x81, 0xDB},
|
||||
// WTime (Wait time, 0x83) -> 0xF6 (27ms)
|
||||
{0x83, 0xF6},
|
||||
// PPulse (0x8E) -> 0x87 (16us, 8 pulses)
|
||||
{0x8E, 0x87},
|
||||
// POffset UR (0x9D) -> 0 (no offset)
|
||||
{0x9D, 0x00},
|
||||
// POffset DL (0x9E) -> 0 (no offset)
|
||||
{0x9E, 0x00},
|
||||
// Config 1 (0x8D) -> 0x60 (no wtime factor)
|
||||
{0x8D, 0x60},
|
||||
};
|
||||
|
||||
static constexpr i2c::I2CRegValue APDS9960_INTERRUPT_GESTURE[] PROGMEM = {
|
||||
// Pers (0x8C) -> 0x11 (2 consecutive proximity or ALS for interrupt)
|
||||
{0x8C, 0x11},
|
||||
// Config 2 (0x90) -> 0x01 (no saturation interrupts or LED boost)
|
||||
{0x90, 0x01},
|
||||
// Config 3 (0x9F) -> 0x00 (enable all photodiodes, no SAI)
|
||||
{0x9F, 0x00},
|
||||
// GPenTh (0xA0, gesture enter threshold) -> 0x28 (also 0x32)
|
||||
{0xA0, 0x28},
|
||||
// GPexTh (0xA1, gesture exit threshold) -> 0x1E
|
||||
{0xA1, 0x1E},
|
||||
// GConf 1 (0xA2, gesture config 1) -> 0x40 (4 gesture events for interrupt (GFIFO 3), 1 for exit)
|
||||
{0xA2, 0x40},
|
||||
};
|
||||
|
||||
static constexpr i2c::I2CRegValue APDS9960_GESTURE_OFFSETS[] PROGMEM = {
|
||||
// GOffsetU (0xA4) -> 0x00 (no offset)
|
||||
{0xA4, 0x00},
|
||||
// GOffsetD (0xA5) -> 0x00 (no offset)
|
||||
{0xA5, 0x00},
|
||||
// GOffsetL (0xA7) -> 0x00 (no offset)
|
||||
{0xA7, 0x00},
|
||||
// GOffsetR (0xA9) -> 0x00 (no offset)
|
||||
{0xA9, 0x00},
|
||||
// GPulse (0xA6) -> 0xC9 (32 µs, 10 pulses)
|
||||
{0xA6, 0xC9},
|
||||
// GConf 3 (0xAA, gesture config 3) -> 0x00 (all photodiodes active during gesture, all gesture dimensions enabled)
|
||||
// 0x00 -> all dimensions, 0x01 -> up down, 0x02 -> left right
|
||||
{0xAA, 0x00},
|
||||
};
|
||||
#define APDS9960_WRITE_BYTE(reg, value) APDS9960_ERROR_CHECK(this->write_byte(reg, value));
|
||||
|
||||
void APDS9960::setup() {
|
||||
uint8_t id;
|
||||
@@ -70,27 +25,83 @@ void APDS9960::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
APDS9960_ERROR_CHECK(this->write_registers(APDS9960_TIMING));
|
||||
// ATime (ADC integration time, 2.78ms increments, 0x81) -> 0xDB (103ms)
|
||||
APDS9960_WRITE_BYTE(0x81, 0xDB);
|
||||
// WTime (Wait time, 0x83) -> 0xF6 (27ms)
|
||||
APDS9960_WRITE_BYTE(0x83, 0xF6);
|
||||
// PPulse (0x8E) -> 0x87 (16us, 8 pulses)
|
||||
APDS9960_WRITE_BYTE(0x8E, 0x87);
|
||||
// POffset UR (0x9D) -> 0 (no offset)
|
||||
APDS9960_WRITE_BYTE(0x9D, 0x00);
|
||||
// POffset DL (0x9E) -> 0 (no offset)
|
||||
APDS9960_WRITE_BYTE(0x9E, 0x00);
|
||||
// Config 1 (0x8D) -> 0x60 (no wtime factor)
|
||||
APDS9960_WRITE_BYTE(0x8D, 0x60);
|
||||
|
||||
// Control (0x8F): led drive (0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA),
|
||||
// proximity gain (0 -> 1x, 1 -> 2X, 2 -> 4X, 3 -> 8X), ambient light gain (0 -> 1x, 1 -> 4x, 2 -> 16x, 3 -> 64x)
|
||||
APDS9960_ERROR_CHECK(this->update_register(
|
||||
0x8F, 0b11001111,
|
||||
(this->led_drive_ & 0b11) << 6 | (this->proximity_gain_ & 0b11) << 2 | (this->ambient_gain_ & 0b11)));
|
||||
// Control (0x8F) ->
|
||||
uint8_t val = 0;
|
||||
APDS9960_ERROR_CHECK(this->read_byte(0x8F, &val));
|
||||
val &= 0b00111111;
|
||||
// led drive, 0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA
|
||||
val |= (this->led_drive_ & 0b11) << 6;
|
||||
|
||||
APDS9960_ERROR_CHECK(this->write_registers(APDS9960_INTERRUPT_GESTURE));
|
||||
val &= 0b11110011;
|
||||
// proximity gain, 0 -> 1x, 1 -> 2X, 2 -> 4X, 3 -> 8X
|
||||
val |= (this->proximity_gain_ & 0b11) << 2;
|
||||
|
||||
// GConf 2 (0xA3): gesture gain (0 -> 1x, 1 -> 2x, 2 -> 4x, 3 -> 8x),
|
||||
// gesture led drive (0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA), gesture wait time
|
||||
// (0 -> 0ms, 1 -> 2.8ms, 2 -> 5.6ms, 3 -> 8.4ms, 4 -> 14.0ms, 5 -> 22.4 ms, 6 -> 30.8ms, 7 -> 39.2 ms)
|
||||
APDS9960_ERROR_CHECK(this->update_register(
|
||||
0xA3, 0b01111111,
|
||||
(this->gesture_gain_ & 0b11) << 5 | (this->gesture_led_drive_ & 0b11) << 3 | (this->gesture_wait_time_ & 0b111)));
|
||||
val &= 0b11111100;
|
||||
// ambient light gain, 0 -> 1x, 1 -> 4x, 2 -> 16x, 3 -> 64x
|
||||
val |= (this->ambient_gain_ & 0b11) << 0;
|
||||
APDS9960_WRITE_BYTE(0x8F, val);
|
||||
|
||||
APDS9960_ERROR_CHECK(this->write_registers(APDS9960_GESTURE_OFFSETS));
|
||||
// Pers (0x8C) -> 0x11 (2 consecutive proximity or ALS for interrupt)
|
||||
APDS9960_WRITE_BYTE(0x8C, 0x11);
|
||||
// Config 2 (0x90) -> 0x01 (no saturation interrupts or LED boost)
|
||||
APDS9960_WRITE_BYTE(0x90, 0x01);
|
||||
// Config 3 (0x9F) -> 0x00 (enable all photodiodes, no SAI)
|
||||
APDS9960_WRITE_BYTE(0x9F, 0x00);
|
||||
// GPenTh (0xA0, gesture enter threshold) -> 0x28 (also 0x32)
|
||||
APDS9960_WRITE_BYTE(0xA0, 0x28);
|
||||
// GPexTh (0xA1, gesture exit threshold) -> 0x1E
|
||||
APDS9960_WRITE_BYTE(0xA1, 0x1E);
|
||||
|
||||
// GConf 1 (0xA2, gesture config 1) -> 0x40 (4 gesture events for interrupt (GFIFO 3), 1 for exit)
|
||||
APDS9960_WRITE_BYTE(0xA2, 0x40);
|
||||
|
||||
// GConf 2 (0xA3, gesture config 2) ->
|
||||
APDS9960_ERROR_CHECK(this->read_byte(0xA3, &val));
|
||||
val &= 0b10011111;
|
||||
// gesture gain, 0 -> 1x, 1 -> 2x, 2 -> 4x, 3 -> 8x
|
||||
val |= (this->gesture_gain_ & 0b11) << 5;
|
||||
|
||||
val &= 0b11100111;
|
||||
// gesture led drive, 0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA
|
||||
val |= (this->gesture_led_drive_ & 0b11) << 3;
|
||||
|
||||
val &= 0b11111000;
|
||||
// gesture wait time
|
||||
// 0 -> 0ms, 1 -> 2.8ms, 2 -> 5.6ms, 3 -> 8.4ms
|
||||
// 4 -> 14.0ms, 5 -> 22.4 ms, 6 -> 30.8ms, 7 -> 39.2 ms
|
||||
val |= (this->gesture_wait_time_ & 0b111) << 0;
|
||||
APDS9960_WRITE_BYTE(0xA3, val);
|
||||
|
||||
// GOffsetU (0xA4) -> 0x00 (no offset)
|
||||
APDS9960_WRITE_BYTE(0xA4, 0x00);
|
||||
// GOffsetD (0xA5) -> 0x00 (no offset)
|
||||
APDS9960_WRITE_BYTE(0xA5, 0x00);
|
||||
// GOffsetL (0xA7) -> 0x00 (no offset)
|
||||
APDS9960_WRITE_BYTE(0xA7, 0x00);
|
||||
// GOffsetR (0xA9) -> 0x00 (no offset)
|
||||
APDS9960_WRITE_BYTE(0xA9, 0x00);
|
||||
// GPulse (0xA6) -> 0xC9 (32 µs, 10 pulses)
|
||||
APDS9960_WRITE_BYTE(0xA6, 0xC9);
|
||||
|
||||
// GConf 3 (0xAA, gesture config 3) -> 0x00 (all photodiodes active during gesture, all gesture dimensions enabled)
|
||||
// 0x00 -> all dimensions, 0x01 -> up down, 0x02 -> left right
|
||||
APDS9960_WRITE_BYTE(0xAA, 0x00);
|
||||
|
||||
// Enable (0x80) ->
|
||||
uint8_t val = 0;
|
||||
val = 0;
|
||||
val |= (0b1) << 0; // power on
|
||||
val |= (this->is_color_enabled_() & 0b1) << 1;
|
||||
val |= (this->is_proximity_enabled_() & 0b1) << 2;
|
||||
@@ -98,7 +109,7 @@ void APDS9960::setup() {
|
||||
val |= 0b0 << 4; // color interrupt disabled
|
||||
val |= 0b0 << 5; // proximity interrupt disabled
|
||||
val |= (this->is_gesture_enabled_() & 0b1) << 6; // proximity is required for gestures
|
||||
APDS9960_ERROR_CHECK(this->write_byte(0x80, val));
|
||||
APDS9960_WRITE_BYTE(0x80, val);
|
||||
}
|
||||
bool APDS9960::is_color_enabled_() const {
|
||||
#ifdef USE_SENSOR
|
||||
|
||||
@@ -6,11 +6,19 @@ namespace esphome::as3935_i2c {
|
||||
ESPHOME_LOG_TAG(TAG, "as3935_i2c");
|
||||
|
||||
void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t start_pos) {
|
||||
const uint8_t value = bits << start_pos;
|
||||
// Bits of value outside mask were ORed in before, so they stay part of the update
|
||||
// Also called at runtime, so a transient bus error only warns, like read_register()
|
||||
if (!this->update_register(reg, mask | value, value)) {
|
||||
ESP_LOGW(TAG, "Failed to update register 0x%02X", reg);
|
||||
uint8_t write_reg;
|
||||
if (!this->read_byte(reg, &write_reg)) {
|
||||
this->mark_failed();
|
||||
ESP_LOGW(TAG, "read_byte failed - increase log level for more details!");
|
||||
return;
|
||||
}
|
||||
|
||||
write_reg &= (~mask);
|
||||
write_reg |= (bits << start_pos);
|
||||
|
||||
if (!this->write_byte(reg, write_reg)) {
|
||||
ESP_LOGW(TAG, "write_byte failed - increase log level for more details!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,11 +240,17 @@ bool AS7341Component::write_register_bit(uint8_t address, bool value, uint8_t bi
|
||||
}
|
||||
|
||||
bool AS7341Component::set_register_bit(uint8_t address, uint8_t bit_position) {
|
||||
return this->update_register(address, 1 << bit_position, 1 << bit_position);
|
||||
uint8_t data;
|
||||
this->read_byte(address, &data);
|
||||
data |= (1 << bit_position);
|
||||
return this->write_byte(address, data);
|
||||
}
|
||||
|
||||
bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position) {
|
||||
return this->update_register(address, 1 << bit_position, 0);
|
||||
uint8_t data;
|
||||
this->read_byte(address, &data);
|
||||
data &= ~(1 << bit_position);
|
||||
return this->write_byte(address, data);
|
||||
}
|
||||
|
||||
} // namespace esphome::as7341
|
||||
@@ -264,7 +264,12 @@ bool AS734XComponent::select_bank_(uint8_t reg) {
|
||||
}
|
||||
|
||||
bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) {
|
||||
return this->select_bank_(reg) && this->update_register(reg, mask, value ? mask : 0);
|
||||
uint8_t data = 0;
|
||||
if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) {
|
||||
return false;
|
||||
}
|
||||
data = value ? (data | mask) : (data & ~mask);
|
||||
return this->write_byte(reg, data);
|
||||
}
|
||||
|
||||
// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime
|
||||
|
||||
@@ -108,7 +108,8 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
hub = fv.get_declaring_config(config[CONF_AS734X_ID])
|
||||
full = fv.full_config.get()
|
||||
hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1])
|
||||
if hub[CONF_TYPE] != "AS7341":
|
||||
return config
|
||||
for key in _BAND_KEYS:
|
||||
|
||||
@@ -4,13 +4,14 @@ import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_AT581X_ID, AT581XComponent
|
||||
from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns
|
||||
|
||||
DEPENDENCIES = ["at581x"]
|
||||
|
||||
RFSwitch = at581x_ns.class_("RFSwitch", switch.Switch)
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema(
|
||||
switch.ApplySwitch,
|
||||
RFSwitch,
|
||||
device_class=DEVICE_CLASS_SWITCH,
|
||||
icon=ICON_WIFI,
|
||||
).extend(
|
||||
@@ -23,7 +24,7 @@ CONFIG_SCHEMA = switch.switch_schema(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_AT581X_ID])
|
||||
await switch.sub_switches(config, parent=hub).create(
|
||||
config, hub.set_rf_power_switch, apply="set_rf_mode(state)", publish="before"
|
||||
)
|
||||
at581x_component = await cg.get_variable(config[CONF_AT581X_ID])
|
||||
s = await switch.new_switch(config)
|
||||
await cg.register_parented(s, config[CONF_AT581X_ID])
|
||||
cg.add(at581x_component.set_rf_power_switch(s))
|
||||
@@ -0,0 +1,10 @@
|
||||
#include "rf_switch.h"
|
||||
|
||||
namespace esphome::at581x {
|
||||
|
||||
void RFSwitch::write_state(bool state) {
|
||||
this->publish_state(state);
|
||||
this->parent_->set_rf_mode(state);
|
||||
}
|
||||
|
||||
} // namespace esphome::at581x
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#include "../at581x.h"
|
||||
|
||||
namespace esphome::at581x {
|
||||
|
||||
class RFSwitch final : public switch_::Switch, public Parented<AT581XComponent> {
|
||||
protected:
|
||||
void write_state(bool state) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::at581x
|
||||
@@ -58,7 +58,7 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const ble_device_base::Ser
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto &raw = service_data.data;
|
||||
auto raw = service_data.data;
|
||||
if (raw.size() < 13) {
|
||||
ESP_LOGVV(TAG, "parse_header_(): service data too short (%zu).", raw.size());
|
||||
return {};
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
#include "bedjet_child.h"
|
||||
#include "bedjet_const.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::bedjet {
|
||||
|
||||
@@ -42,7 +40,7 @@ void BedJetHub::upgrade_firmware() {
|
||||
auto status = this->write_bedjet_packet_(pkt);
|
||||
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] MAGIC_UPDATE button failed, status=%d", this->get_name(), status);
|
||||
ESP_LOGW(TAG, "[%s] MAGIC_UPDATE button failed, status=%d", this->get_name().c_str(), status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +64,7 @@ bool BedJetHub::set_fan_index(uint8_t fan_speed_index) {
|
||||
auto status = this->write_bedjet_packet_(pkt);
|
||||
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] writing fan speed failed, status=%d", this->get_name(), status);
|
||||
ESP_LOGW(TAG, "[%s] writing fan speed failed, status=%d", this->get_name().c_str(), status);
|
||||
}
|
||||
return status == 0;
|
||||
}
|
||||
@@ -84,7 +82,7 @@ bool BedJetHub::set_target_temp(float temp_c) {
|
||||
auto status = this->write_bedjet_packet_(pkt);
|
||||
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] writing target temp failed, status=%d", this->get_name(), status);
|
||||
ESP_LOGW(TAG, "[%s] writing target temp failed, status=%d", this->get_name().c_str(), status);
|
||||
}
|
||||
return status == 0;
|
||||
}
|
||||
@@ -95,7 +93,7 @@ bool BedJetHub::set_time_remaining(uint8_t hours, uint8_t mins) {
|
||||
auto status = this->write_bedjet_packet_(pkt);
|
||||
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] writing remaining runtime failed, status=%d", this->get_name(), status);
|
||||
ESP_LOGW(TAG, "[%s] writing remaining runtime failed, status=%d", this->get_name().c_str(), status);
|
||||
}
|
||||
return status == 0;
|
||||
}
|
||||
@@ -105,10 +103,11 @@ bool BedJetHub::send_button(BedjetButton button) {
|
||||
auto status = this->write_bedjet_packet_(pkt);
|
||||
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] writing button %s failed, status=%d", this->get_name(),
|
||||
ESP_LOGW(TAG, "[%s] writing button %s failed, status=%d", this->get_name().c_str(),
|
||||
LOG_STR_ARG(bedjet_button_to_string(button)), status);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "[%s] writing button %s success", this->get_name(), LOG_STR_ARG(bedjet_button_to_string(button)));
|
||||
ESP_LOGD(TAG, "[%s] writing button %s success", this->get_name().c_str(),
|
||||
LOG_STR_ARG(bedjet_button_to_string(button)));
|
||||
}
|
||||
return status == 0;
|
||||
}
|
||||
@@ -126,9 +125,9 @@ uint16_t BedJetHub::get_time_remaining() {
|
||||
uint8_t BedJetHub::write_bedjet_packet_(BedjetPacket *pkt) {
|
||||
if (!this->is_connected()) {
|
||||
if (!this->parent_->enabled) {
|
||||
ESP_LOGI(TAG, "[%s] Cannot write packet: Not connected, enabled=false", this->get_name());
|
||||
ESP_LOGI(TAG, "[%s] Cannot write packet: Not connected, enabled=false", this->get_name().c_str());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] Cannot write packet: Not connected", this->get_name());
|
||||
ESP_LOGW(TAG, "[%s] Cannot write packet: Not connected", this->get_name().c_str());
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -145,16 +144,16 @@ uint8_t BedJetHub::set_notify_(const bool enable) {
|
||||
status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(),
|
||||
this->char_handle_status_);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name(), status);
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status);
|
||||
}
|
||||
} else {
|
||||
status = esp_ble_gattc_unregister_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(),
|
||||
this->char_handle_status_);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name(), status);
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name().c_str(), status);
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "[%s] set_notify: enable=%d; result=%d", this->get_name(), enable, status);
|
||||
ESP_LOGV(TAG, "[%s] set_notify: enable=%d; result=%d", this->get_name().c_str(), enable, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -165,7 +164,7 @@ bool BedJetHub::discover_characteristics_() {
|
||||
if (!this->char_handle_cmd_) {
|
||||
chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_COMMAND_UUID);
|
||||
if (chr == nullptr) {
|
||||
ESP_LOGW(TAG, "[%s] No control service found at device, not a BedJet..?", this->get_name());
|
||||
ESP_LOGW(TAG, "[%s] No control service found at device, not a BedJet..?", this->get_name().c_str());
|
||||
result = false;
|
||||
} else {
|
||||
this->char_handle_cmd_ = chr->handle;
|
||||
@@ -175,7 +174,7 @@ bool BedJetHub::discover_characteristics_() {
|
||||
if (!this->char_handle_status_) {
|
||||
chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_STATUS_UUID);
|
||||
if (chr == nullptr) {
|
||||
ESP_LOGW(TAG, "[%s] No status service found at device, not a BedJet..?", this->get_name());
|
||||
ESP_LOGW(TAG, "[%s] No status service found at device, not a BedJet..?", this->get_name().c_str());
|
||||
result = false;
|
||||
} else {
|
||||
this->char_handle_status_ = chr->handle;
|
||||
@@ -205,14 +204,14 @@ bool BedJetHub::discover_characteristics_() {
|
||||
if (!this->char_handle_name_) {
|
||||
chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_NAME_UUID);
|
||||
if (chr == nullptr) {
|
||||
ESP_LOGW(TAG, "[%s] No name service found at device, not a BedJet..?", this->get_name());
|
||||
ESP_LOGW(TAG, "[%s] No name service found at device, not a BedJet..?", this->get_name().c_str());
|
||||
result = false;
|
||||
} else {
|
||||
this->char_handle_name_ = chr->handle;
|
||||
auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_name_, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGI(TAG, "[%s] Unable to read name characteristic: %d", this->get_name(), status);
|
||||
ESP_LOGI(TAG, "[%s] Unable to read name characteristic: %d", this->get_name().c_str(), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +222,7 @@ bool BedJetHub::discover_characteristics_() {
|
||||
" - Status char: 0x%x\n"
|
||||
" - config descriptor: 0x%x\n"
|
||||
" - Name char: 0x%x",
|
||||
this->get_name(), this->char_handle_cmd_, this->char_handle_status_, this->config_descr_status_,
|
||||
this->get_name().c_str(), this->char_handle_cmd_, this->char_handle_status_, this->config_descr_status_,
|
||||
this->char_handle_name_);
|
||||
|
||||
return result;
|
||||
@@ -242,7 +241,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
auto result = this->discover_characteristics_();
|
||||
|
||||
if (result) {
|
||||
ESP_LOGD(TAG, "[%s] Services complete: obtained char handles.", this->get_name());
|
||||
ESP_LOGD(TAG, "[%s] Services complete: obtained char handles.", this->get_name().c_str());
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->set_notify_(true);
|
||||
|
||||
@@ -254,7 +253,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
|
||||
this->dispatch_state_(true);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] Failed discovering service characteristics.", this->get_name());
|
||||
ESP_LOGW(TAG, "[%s] Failed discovering service characteristics.", this->get_name().c_str());
|
||||
this->parent()->set_enabled(false);
|
||||
this->status_set_warning();
|
||||
this->dispatch_state_(false);
|
||||
@@ -266,15 +265,15 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
if (param->write.status == ESP_GATT_INVALID_ATTR_LEN) {
|
||||
// This probably means that our hack for notify_en (8 bit vs 16 bit) didn't work right.
|
||||
// Should we try to fall back to BLEClient's way?
|
||||
ESP_LOGW(TAG, "[%s] Invalid attr length writing descr at handle 0x%04d, status=%d", this->get_name(),
|
||||
ESP_LOGW(TAG, "[%s] Invalid attr length writing descr at handle 0x%04d, status=%d", this->get_name().c_str(),
|
||||
param->write.handle, param->write.status);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] Error writing descr at handle 0x%04d, status=%d", this->get_name(), param->write.handle,
|
||||
param->write.status);
|
||||
ESP_LOGW(TAG, "[%s] Error writing descr at handle 0x%04d, status=%d", this->get_name().c_str(),
|
||||
param->write.handle, param->write.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%s] Write to handle 0x%04x status=%d", this->get_name(), param->write.handle,
|
||||
ESP_LOGD(TAG, "[%s] Write to handle 0x%04x status=%d", this->get_name().c_str(), param->write.handle,
|
||||
param->write.status);
|
||||
break;
|
||||
}
|
||||
@@ -307,11 +306,9 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
} else if (param->read.handle == this->char_handle_name_) {
|
||||
// The data should represent the name.
|
||||
if (param->read.status == ESP_GATT_OK && param->read.value_len > 0) {
|
||||
ESP_LOGV(TAG, "[%s] Got BedJet name: '%.*s'", this->get_name(), static_cast<int>(param->read.value_len),
|
||||
reinterpret_cast<const char *>(param->read.value));
|
||||
const size_t len = std::min<size_t>(param->read.value_len, sizeof(this->name_) - 1);
|
||||
memcpy(this->name_, param->read.value, len);
|
||||
this->name_[len] = '\0';
|
||||
std::string bedjet_name(reinterpret_cast<char const *>(param->read.value), param->read.value_len);
|
||||
ESP_LOGV(TAG, "[%s] Got BedJet name: '%s'", this->get_name().c_str(), bedjet_name.c_str());
|
||||
this->set_name_(bedjet_name);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -325,8 +322,8 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
// doesn't break anything.
|
||||
|
||||
if (param->reg_for_notify.handle != this->char_handle_status_) {
|
||||
ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x", this->get_name(),
|
||||
param->reg_for_notify.handle, this->char_handle_status_);
|
||||
ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x",
|
||||
this->get_name().c_str(), param->reg_for_notify.handle, this->char_handle_status_);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -338,8 +335,8 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
|
||||
// This event is not handled by the parent BLEClient, so we need to do this either way.
|
||||
if (param->unreg_for_notify.handle != this->char_handle_status_) {
|
||||
ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x", this->get_name(),
|
||||
param->unreg_for_notify.handle, this->char_handle_status_);
|
||||
ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x",
|
||||
this->get_name().c_str(), param->unreg_for_notify.handle, this->char_handle_status_);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -353,13 +350,13 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
break;
|
||||
|
||||
if (param->notify.conn_id != this->parent_->get_conn_id()) {
|
||||
ESP_LOGW(TAG, "[%s] Received notify event for unexpected parent conn: expect %x, got %x", this->get_name(),
|
||||
this->parent_->get_conn_id(), param->notify.conn_id);
|
||||
ESP_LOGW(TAG, "[%s] Received notify event for unexpected parent conn: expect %x, got %x",
|
||||
this->get_name().c_str(), this->parent_->get_conn_id(), param->notify.conn_id);
|
||||
// FIXME: bug in BLEClient holding wrong conn_id.
|
||||
}
|
||||
|
||||
if (param->notify.handle != this->char_handle_status_) {
|
||||
ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name(),
|
||||
ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name().c_str(),
|
||||
this->char_handle_status_, param->notify.handle);
|
||||
break;
|
||||
}
|
||||
@@ -375,13 +372,13 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
if (!this->force_refresh_ && this->codec_->compare(param->notify.value, param->notify.value_len)) {
|
||||
// If the packet is meaningfully different, trigger children as well
|
||||
this->force_refresh_ = true;
|
||||
ESP_LOGV(TAG, "[%s] Incoming packet indicates a significant change.", this->get_name());
|
||||
ESP_LOGV(TAG, "[%s] Incoming packet indicates a significant change.", this->get_name().c_str());
|
||||
}
|
||||
|
||||
if (this->last_notify_ == 0 || delta > MIN_NOTIFY_THROTTLE || this->force_refresh_) {
|
||||
// Set reentrant flag to prevent processing multiple packets.
|
||||
this->processing_ = true;
|
||||
ESP_LOGVV(TAG, "[%s] Decoding packet: last=%" PRId32 ", delta=%" PRId32 ", force=%s", this->get_name(),
|
||||
ESP_LOGVV(TAG, "[%s] Decoding packet: last=%" PRId32 ", delta=%" PRId32 ", force=%s", this->get_name().c_str(),
|
||||
this->last_notify_, delta, this->force_refresh_ ? "y" : "n");
|
||||
bool needs_extra = this->codec_->decode_notify(param->notify.value, param->notify.value_len);
|
||||
|
||||
@@ -391,7 +388,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_status_, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGI(TAG, "[%s] Unable to read extended status packet", this->get_name());
|
||||
ESP_LOGI(TAG, "[%s] Unable to read extended status packet", this->get_name().c_str());
|
||||
}
|
||||
} else {
|
||||
this->status_packet_ready_();
|
||||
@@ -400,7 +397,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name(), event);
|
||||
ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name().c_str(), event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -439,7 +436,7 @@ uint8_t BedJetHub::write_notify_config_descriptor_(bool enable) {
|
||||
ESP_LOGW(TAG, "esp_ble_gattc_write_char_descr error, status=%d", status);
|
||||
return status;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%s] wrote notify=%s to status config 0x%04x, for conn %d", this->get_name(),
|
||||
ESP_LOGD(TAG, "[%s] wrote notify=%s to status config 0x%04x, for conn %d", this->get_name().c_str(),
|
||||
enable ? "true" : "false", handle, this->parent_->get_conn_id());
|
||||
return ESP_GATT_OK;
|
||||
}
|
||||
@@ -471,7 +468,7 @@ void BedJetHub::setup_time_() {
|
||||
|
||||
void BedJetHub::set_clock(uint8_t hour, uint8_t minute) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGV(TAG, "[%s] Not connected, cannot send time.", this->get_name());
|
||||
ESP_LOGV(TAG, "[%s] Not connected, cannot send time.", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -480,7 +477,7 @@ void BedJetHub::set_clock(uint8_t hour, uint8_t minute) {
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "Failed setting BedJet clock: %d", status);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "[%s] BedJet clock set to: %d:%02d", this->get_name(), hour, minute);
|
||||
ESP_LOGD(TAG, "[%s] BedJet clock set to: %d:%02d", this->get_name().c_str(), hour, minute);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +495,7 @@ void BedJetHub::dump_config() {
|
||||
"BedJet Hub '%s'\n"
|
||||
" ble_client.app_id: %d\n"
|
||||
" ble_client.conn_id: %d",
|
||||
this->get_name(), this->parent()->app_id, this->parent()->get_conn_id());
|
||||
this->get_name().c_str(), this->parent()->app_id, this->parent()->get_conn_id());
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
ESP_LOGCONFIG(TAG, " Child components (%d):", this->children_.size());
|
||||
for (auto *child : this->children_) {
|
||||
@@ -516,9 +513,10 @@ void BedJetHub::dispatch_status_() {
|
||||
auto *status = this->codec_->get_status_packet();
|
||||
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGD(TAG, "[%s] Not connected, will not send status.", this->get_name());
|
||||
ESP_LOGD(TAG, "[%s] Not connected, will not send status.", this->get_name().c_str());
|
||||
} else if (status != nullptr) {
|
||||
ESP_LOGD(TAG, "[%s] Notifying %d children of latest status @%p.", this->get_name(), this->children_.size(), status);
|
||||
ESP_LOGD(TAG, "[%s] Notifying %d children of latest status @%p.", this->get_name().c_str(), this->children_.size(),
|
||||
status);
|
||||
for (auto *child : this->children_) {
|
||||
child->on_status(status);
|
||||
}
|
||||
@@ -532,13 +530,13 @@ void BedJetHub::dispatch_status_() {
|
||||
// We can try to unregister for notifications now, and then re-register, hoping to clear it up...
|
||||
// But how do we know for sure which state we're in, and how do we actually clear out the buggy state?
|
||||
|
||||
ESP_LOGI(TAG, "[%s] Still waiting for first GATT notify event.", this->get_name());
|
||||
ESP_LOGI(TAG, "[%s] Still waiting for first GATT notify event.", this->get_name().c_str());
|
||||
} else if (diff > NOTIFY_WARN_THRESHOLD) {
|
||||
ESP_LOGW(TAG, "[%s] Last GATT notify was %" PRId32 " seconds ago.", this->get_name(), diff / 1000);
|
||||
ESP_LOGW(TAG, "[%s] Last GATT notify was %" PRId32 " seconds ago.", this->get_name().c_str(), diff / 1000);
|
||||
}
|
||||
|
||||
if (this->timeout_ > 0 && diff > this->timeout_ && this->parent()->enabled) {
|
||||
ESP_LOGW(TAG, "[%s] Timed out after %" PRId32 " sec. Retrying", this->get_name(), this->timeout_);
|
||||
ESP_LOGW(TAG, "[%s] Timed out after %" PRId32 " sec. Retrying", this->get_name().c_str(), this->timeout_);
|
||||
// set_enabled(false) will only close the connection if state != IDLE.
|
||||
this->parent()->set_state(espbt::ClientState::CONNECTING);
|
||||
this->parent()->set_enabled(false);
|
||||
|
||||
@@ -119,7 +119,13 @@ class BedJetHub final : public esphome::ble_client::BLEClientNode, public Pollin
|
||||
float get_setup_priority() const override { return setup_priority::BLUETOOTH; }
|
||||
|
||||
/** @return The BedJet's configured name, or the MAC address if not discovered yet. */
|
||||
const char *get_name() const { return this->name_[0] == '\0' ? this->parent_->address_str() : this->name_; }
|
||||
std::string get_name() {
|
||||
if (this->name_.empty()) {
|
||||
return this->parent_->address_str();
|
||||
} else {
|
||||
return this->name_;
|
||||
}
|
||||
}
|
||||
|
||||
/* BLEClient overrides */
|
||||
|
||||
@@ -145,8 +151,9 @@ class BedJetHub final : public esphome::ble_client::BLEClientNode, public Pollin
|
||||
uint8_t set_notify_(bool enable);
|
||||
/** Send the `BedjetPacket` to the device. */
|
||||
uint8_t write_bedjet_packet_(BedjetPacket *pkt);
|
||||
// Only used in log lines; longer names are truncated
|
||||
char name_[24]{};
|
||||
void set_name_(const std::string &name) { this->name_ = name; }
|
||||
|
||||
std::string name_;
|
||||
|
||||
uint32_t last_notify_ = 0;
|
||||
inline void status_packet_ready_();
|
||||
|
||||
@@ -62,13 +62,6 @@ struct SpiData {
|
||||
|
||||
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// BDK 3.0.78 makes REG_WRITE a statement expression that yields the volatile assignment,
|
||||
// a deprecated volatile read under clang
|
||||
static inline void reg_write(uint32_t addr, uint32_t value) {
|
||||
// NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address
|
||||
*reinterpret_cast<volatile uint32_t *>(addr) = value;
|
||||
}
|
||||
|
||||
static void set_spi_ctrl_register(uint32_t bit, bool val) {
|
||||
uint32_t value = REG_READ(SPI_CTRL);
|
||||
if (val == 0) {
|
||||
@@ -76,7 +69,7 @@ static void set_spi_ctrl_register(uint32_t bit, bool val) {
|
||||
} else if (val == 1) {
|
||||
value |= bit;
|
||||
}
|
||||
reg_write(SPI_CTRL, value);
|
||||
REG_WRITE(SPI_CTRL, value);
|
||||
}
|
||||
|
||||
#ifdef BEKEN_SPI_IS_N_LIKE
|
||||
@@ -87,7 +80,7 @@ static void set_spi_config_register(uint32_t bit, bool val) {
|
||||
} else if (val == 1) {
|
||||
value |= bit;
|
||||
}
|
||||
reg_write(SPI_CONFIG, value);
|
||||
REG_WRITE(SPI_CONFIG, value);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -143,7 +136,7 @@ static void spi_set_clock(uint32_t max_hz) {
|
||||
param = REG_READ(SPI_CTRL);
|
||||
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
|
||||
param |= (div << SPI_CKR_POSI);
|
||||
reg_write(SPI_CTRL, param);
|
||||
REG_WRITE(SPI_CTRL, param);
|
||||
#ifdef BEKEN_SPI_IS_N_LIKE
|
||||
const int divisor = div;
|
||||
#else
|
||||
@@ -236,7 +229,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
value = REG_READ(SPI_CTRL);
|
||||
value &= ~CTRL_NSSMD_3;
|
||||
value |= (1 << 17);
|
||||
reg_write(SPI_CTRL, value);
|
||||
REG_WRITE(SPI_CTRL, value);
|
||||
|
||||
#ifdef BEKEN_SPI_IS_N_LIKE
|
||||
value = GFUNC_MODE_SPI_DMA;
|
||||
@@ -286,7 +279,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
value = REG_READ(SPI_CONFIG);
|
||||
value &= ~(0xFFF << 8);
|
||||
value |= ((dma_buffer_size & 0xFFF) << 8);
|
||||
reg_write(SPI_CONFIG, value);
|
||||
REG_WRITE(SPI_CONFIG, value);
|
||||
#else
|
||||
// T-like variants: No transfer-length register. The SPI controller shifts out
|
||||
// everything the DMA writes into SPI_DAT.
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_BH1745_ID, BH1745Component
|
||||
from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns
|
||||
|
||||
DEPENDENCIES = ["bh1745"]
|
||||
|
||||
BH1745InterruptPinOutput = bh1745_ns.class_(
|
||||
"BH1745InterruptPinOutput", output.BinaryOutput
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(output.ApplyBinaryOutput),
|
||||
cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput),
|
||||
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await output.register_output(var, config)
|
||||
hub = await cg.get_variable(config[CONF_BH1745_ID])
|
||||
await output.sub_binary_outputs(config, parent=hub).create(
|
||||
config, None, apply="set_interrupt_state(state)"
|
||||
)
|
||||
await cg.register_parented(var, hub)
|
||||
cg.add(hub.enable_interrupt_output())
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/output/binary_output.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include "../bh1745.h"
|
||||
|
||||
namespace esphome::bh1745 {
|
||||
|
||||
/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one.
|
||||
class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented<BH1745Component> {
|
||||
protected:
|
||||
void write_state(bool state) override { this->parent_->set_interrupt_state(state); }
|
||||
};
|
||||
|
||||
} // namespace esphome::bh1745
|
||||
@@ -63,7 +63,6 @@ from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity
|
||||
from esphome.core.entity_helpers import (
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
lazy_load_setup,
|
||||
lazy_load_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
@@ -622,7 +621,10 @@ async def setup_binary_sensor_core_(var, config):
|
||||
|
||||
await entity_helpers.setup_entity_integrations(var, config)
|
||||
|
||||
await lazy_load_setup("zigbee", "setup_binary_sensor", var, config)
|
||||
if "zigbee" in CORE.loaded_integrations:
|
||||
from esphome.components import zigbee
|
||||
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
|
||||
|
||||
async def register_binary_sensor(var, config):
|
||||
|
||||
@@ -29,7 +29,6 @@ from esphome.components.libretiny.const import (
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS
|
||||
|
||||
@@ -21,10 +21,9 @@ public ble_api.h.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny
|
||||
from esphome.components import libretiny, wifi
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
@@ -33,30 +32,14 @@ from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7251,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_ON_BOOT,
|
||||
CONF_ESPHOME,
|
||||
CONF_ID,
|
||||
CONF_PLATFORMIO_OPTIONS,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
import esphome.final_validate as fv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
DOMAIN = "bk72xx_ble"
|
||||
|
||||
BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
|
||||
# Older BDKs lay the BLE stack out differently and can crash at boot or erase the
|
||||
# bootloader with BLE compiled in; the libretiny platform builds with this one
|
||||
MIN_BDK_VERSION = (3, 0, 78)
|
||||
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
|
||||
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414)
|
||||
MIN_LIBRETINY_VERSION = (1, 14, 0)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
|
||||
@@ -97,45 +80,20 @@ def _unsupported_family_message(family: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def check_bdk_version(value: str | None) -> None:
|
||||
"""Reject a beken-bdk override older than the release the BLE stack needs."""
|
||||
if value is None:
|
||||
return
|
||||
# LibreTiny appends the value to the package URL as a git ref and only builds
|
||||
# 3.0.x releases; the 2021.06.07 tag is the pre 3.0 SDK
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", str(value))
|
||||
if match is not None:
|
||||
version = tuple(int(g) for g in match.groups())
|
||||
if version[:2] == MIN_BDK_VERSION[:2] and version >= MIN_BDK_VERSION:
|
||||
return
|
||||
minimum = ".".join(str(part) for part in MIN_BDK_VERSION)
|
||||
raise cv.Invalid(
|
||||
f"bk72xx_ble needs beken-bdk {minimum} or a newer 3.0.x release, not "
|
||||
f"'{value}': with BLE compiled in, an older BDK can crash at boot or erase "
|
||||
"the bootloader"
|
||||
)
|
||||
|
||||
|
||||
def check_libretiny_version(version: cv.Version) -> None:
|
||||
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
|
||||
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
|
||||
return
|
||||
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
|
||||
raise cv.Invalid(
|
||||
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
|
||||
"arm the Beken MCU sleep with wifi power save and the device halts once the "
|
||||
"station stops while BLE runs"
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
# Warn only: a hard error here would break the validate-only CI fixtures,
|
||||
# which run on a BLE 4.2 board. The hard error is raised at codegen.
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
_LOGGER.warning("%s (this configuration cannot compile)", msg)
|
||||
options = fv.full_config.get()[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS) or {}
|
||||
check_bdk_version(options.get(BEKEN_BDK_VERSION_OPTION))
|
||||
check_libretiny_version(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
# Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU
|
||||
# sleep. With the BLE controller running, that sleep never wakes up once the
|
||||
# station is stopped (adapter restart after failed roams, wifi.disable): the
|
||||
# device is dead until a power cycle (esphome#18592). Keep power save off
|
||||
# until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414).
|
||||
wifi.force_power_save_off(
|
||||
"with BLE running, the Beken SDK's MCU sleep halts the device once the "
|
||||
"station is stopped (https://github.com/esphome/esphome/issues/18592)"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -158,6 +116,20 @@ async def to_code(config: ConfigType) -> None:
|
||||
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
|
||||
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
|
||||
|
||||
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
|
||||
# bundled 3.0.33 has an older BLE header/library layout — and with
|
||||
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
|
||||
# (the reason the libretiny platform sets =0 when BLE is unused), so a
|
||||
# mismatched BDK can crash the device before WiFi comes up regardless of
|
||||
# enable_on_boot. Pinning here makes a plain config build against the
|
||||
# validated BDK without any manual platformio_options.
|
||||
_LOGGER.warning(
|
||||
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
|
||||
"default: the default's older BLE layout can crash the device at boot when "
|
||||
"BLE is compiled in"
|
||||
)
|
||||
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
|
||||
|
||||
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
|
||||
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
|
||||
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "bl0939.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -21,7 +20,7 @@ static const uint8_t BL0939_REG_SOFT_RESET = 0x19;
|
||||
static const uint8_t BL0939_REG_USR_WRPROT = 0x1A;
|
||||
static const uint8_t BL0939_REG_TPS_CTRL = 0x1B;
|
||||
|
||||
static constexpr uint8_t BL0939_INIT[6][6] PROGMEM = {
|
||||
const uint8_t BL0939_INIT[6][6] = {
|
||||
// Reset to default
|
||||
{BL0939_WRITE_COMMAND, BL0939_REG_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x33},
|
||||
// Enable User Operation Write
|
||||
@@ -67,10 +66,8 @@ void BL0939::update() {
|
||||
}
|
||||
|
||||
void BL0939::setup() {
|
||||
uint8_t cmd[sizeof(BL0939_INIT[0])];
|
||||
for (const auto *i : BL0939_INIT) {
|
||||
progmem_memcpy(cmd, i, sizeof(cmd));
|
||||
this->write_array(cmd, sizeof(cmd));
|
||||
for (auto *i : BL0939_INIT) {
|
||||
this->write_array(i, 6);
|
||||
delay(1);
|
||||
}
|
||||
this->flush();
|
||||
|
||||
@@ -139,24 +139,49 @@ bool BME680Component::setup_registers_() {
|
||||
this->calibration_.ambient_temperature = 25; // prime ambient temperature
|
||||
|
||||
// Config register
|
||||
if (!this->update_register(BME680_REGISTER_CONFIG, 0b00011100, (this->iir_filter_ & 0b111) << 2)) {
|
||||
uint8_t config_register;
|
||||
if (!this->read_byte(BME680_REGISTER_CONFIG, &config_register)) {
|
||||
return false;
|
||||
}
|
||||
config_register &= ~0b00011100;
|
||||
config_register |= (this->iir_filter_ & 0b111) << 2;
|
||||
if (!this->write_byte(BME680_REGISTER_CONFIG, config_register)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Humidity control register
|
||||
if (!this->update_register(BME680_REGISTER_CONTROL_HUMIDITY, 0b00000111, this->humidity_oversampling_ & 0b111)) {
|
||||
uint8_t hum_control;
|
||||
if (!this->read_byte(BME680_REGISTER_CONTROL_HUMIDITY, &hum_control)) {
|
||||
return false;
|
||||
}
|
||||
hum_control &= ~0b00000111;
|
||||
hum_control |= this->humidity_oversampling_ & 0b111;
|
||||
if (!this->write_byte(BME680_REGISTER_CONTROL_HUMIDITY, hum_control)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gas 1 control register: run gas, heater profile 0
|
||||
if (!this->update_register(BME680_REGISTER_CONTROL_GAS1, 0b00011111, 1 << 4)) {
|
||||
// Gas 1 control register
|
||||
uint8_t gas1_control;
|
||||
if (!this->read_byte(BME680_REGISTER_CONTROL_GAS1, &gas1_control)) {
|
||||
return false;
|
||||
}
|
||||
gas1_control &= ~0b00011111;
|
||||
gas1_control |= 1 << 4;
|
||||
gas1_control |= 0; // profile 0
|
||||
if (!this->write_byte(BME680_REGISTER_CONTROL_GAS1, gas1_control)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool heat_off = this->heater_temperature_ == 0 || this->heater_duration_ == 0;
|
||||
|
||||
// Gas 0 control register
|
||||
if (!this->update_register(BME680_REGISTER_CONTROL_GAS0, 0b00001000, heat_off << 3)) {
|
||||
uint8_t gas0_control;
|
||||
if (!this->read_byte(BME680_REGISTER_CONTROL_GAS0, &gas0_control)) {
|
||||
return false;
|
||||
}
|
||||
gas0_control &= ~0b00001000;
|
||||
gas0_control |= heat_off << 3;
|
||||
if (!this->write_byte(BME680_REGISTER_CONTROL_GAS0, gas0_control)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "bme680_bsec.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include <string>
|
||||
|
||||
namespace esphome::bme680_bsec {
|
||||
#ifdef USE_BSEC
|
||||
@@ -9,7 +9,7 @@ ESPHOME_LOG_TAG(TAG, "bme680_bsec.sensor");
|
||||
|
||||
static constexpr uint32_t READ_TIMEOUT_ID = 0;
|
||||
|
||||
PROGMEM_STRING_TABLE(IaqAccuracyStrings, "Stabilizing", "Uncertain", "Calibrating", "Calibrated");
|
||||
static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"};
|
||||
|
||||
std::vector<BME680BSECComponent *>
|
||||
BME680BSECComponent::instances; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -386,11 +386,10 @@ void BME680BSECComponent::publish_(const bsec_output_t *outputs, uint8_t num_out
|
||||
switch (outputs[i].sensor_id) {
|
||||
case BSEC_OUTPUT_IAQ:
|
||||
case BSEC_OUTPUT_STATIC_IAQ: {
|
||||
uint8_t accuracy = std::min<uint8_t>(outputs[i].accuracy, IaqAccuracyStrings::LAST_INDEX);
|
||||
uint8_t accuracy = std::min<uint8_t>(outputs[i].accuracy, std::size(IAQ_ACCURACY_STATES) - 1);
|
||||
this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); });
|
||||
this->queue_push_([this, accuracy]() {
|
||||
this->publish_sensor_(this->iaq_accuracy_text_sensor_,
|
||||
IaqAccuracyStrings::get_progmem_str(accuracy, IaqAccuracyStrings::LAST_INDEX));
|
||||
this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[accuracy]);
|
||||
});
|
||||
this->queue_push_([this, accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, accuracy, true); });
|
||||
|
||||
@@ -436,9 +435,8 @@ void BME680BSECComponent::publish_sensor_(sensor::Sensor *sensor, float value, b
|
||||
sensor->publish_state(value);
|
||||
}
|
||||
|
||||
void BME680BSECComponent::publish_sensor_(text_sensor::TextSensor *sensor, ProgmemStr value) {
|
||||
if (!sensor ||
|
||||
(sensor->has_state() && ESPHOME_strcmp_P(sensor->state.c_str(), reinterpret_cast<ESPHOME_PGM_P>(value)) == 0)) {
|
||||
void BME680BSECComponent::publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value) {
|
||||
if (!sensor || (sensor->has_state() && sensor->state == value)) {
|
||||
return;
|
||||
}
|
||||
sensor->publish_state(value);
|
||||
|
||||
@@ -77,7 +77,7 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice {
|
||||
int64_t get_time_ns_();
|
||||
|
||||
void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false);
|
||||
void publish_sensor_(text_sensor::TextSensor *sensor, ProgmemStr value);
|
||||
void publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value);
|
||||
|
||||
void snapshot_state_(); // Fetch the current BSEC library state and save it in the bsec_state_data_ member (volatile
|
||||
// memory)
|
||||
|
||||
@@ -66,7 +66,9 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
if config[CONF_TYPE] not in _MAGNETIC_FIELDS:
|
||||
return
|
||||
bmi270_config = fv.get_declaring_config(config[CONF_BMI270_ID])
|
||||
full_config = fv.full_config.get()
|
||||
bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1]
|
||||
bmi270_config = full_config.get_config_for_path(bmi270_path)
|
||||
if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150":
|
||||
raise cv.Invalid(
|
||||
"This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'",
|
||||
|
||||
@@ -80,8 +80,7 @@ void BP1658CJ::loop() {
|
||||
this->update_ = false;
|
||||
}
|
||||
|
||||
void BP1658CJ::write_channel(uint8_t channel, float state) {
|
||||
auto value = static_cast<uint16_t>(state * 0x3FF);
|
||||
void BP1658CJ::set_channel_value_(uint8_t channel, uint16_t value) {
|
||||
if (this->pwm_amounts_[channel] != value) {
|
||||
this->update_ = true;
|
||||
this->update_channel_ = channel;
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::bp1658cj {
|
||||
|
||||
class BP1658CJ final : public Component {
|
||||
public:
|
||||
class Channel;
|
||||
|
||||
void set_data_pin(GPIOPin *data_pin) { data_pin_ = data_pin; }
|
||||
void set_clock_pin(GPIOPin *clock_pin) { clock_pin_ = clock_pin; }
|
||||
void set_max_power_color_channels(uint8_t max_power_color_channels) {
|
||||
@@ -26,9 +29,23 @@ class BP1658CJ final : public Component {
|
||||
/// Send new values if they were updated.
|
||||
void loop() override;
|
||||
|
||||
void write_channel(uint8_t channel, float state);
|
||||
class Channel final : public output::FloatOutput {
|
||||
public:
|
||||
void set_parent(BP1658CJ *parent) { parent_ = parent; }
|
||||
void set_channel(uint8_t channel) { channel_ = channel; }
|
||||
|
||||
protected:
|
||||
void write_state(float state) override {
|
||||
auto amount = static_cast<uint16_t>(state * 0x3FF);
|
||||
this->parent_->set_channel_value_(this->channel_, amount);
|
||||
}
|
||||
|
||||
BP1658CJ *parent_;
|
||||
uint8_t channel_;
|
||||
};
|
||||
|
||||
protected:
|
||||
void set_channel_value_(uint8_t channel, uint16_t value);
|
||||
void write_bit_(bool value);
|
||||
void write_byte_(uint8_t data);
|
||||
void write_buffer_(uint8_t *buffer, uint8_t size);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CHANNEL, CONF_ID
|
||||
@@ -7,15 +8,16 @@ from . import BP1658CJ
|
||||
|
||||
DEPENDENCIES = ["bp1658cj"]
|
||||
|
||||
Channel = BP1658CJ.class_("Channel", output.FloatOutput)
|
||||
|
||||
CONF_BP1658CJ_ID = "bp1658cj_id"
|
||||
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_BP1658CJ_ID): cv.use_id(BP1658CJ),
|
||||
cv.Required(CONF_ID): cv.declare_id(output.ApplyFloatOutput),
|
||||
cv.Required(CONF_ID): cv.declare_id(Channel),
|
||||
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4),
|
||||
}
|
||||
)
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
@@ -24,6 +26,8 @@ FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
await output.sub_float_outputs(config, parent=config[CONF_BP1658CJ_ID]).create(
|
||||
config, None, config[CONF_CHANNEL], apply="write_channel(index, state)"
|
||||
)
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await output.register_output(var, config)
|
||||
|
||||
await cg.register_parented(var, config[CONF_BP1658CJ_ID])
|
||||
cg.add(var.set_channel(config[CONF_CHANNEL]))
|
||||
@@ -7,7 +7,6 @@ from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
CODEOWNERS = ["@Rapsssito"]
|
||||
@@ -360,7 +359,8 @@ async def bq25186_configure_to_code(
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
# Options the action leaves out fall back to the component's configuration
|
||||
parent_config = fv.get_declaring_config(config[CONF_ID], CORE.config)
|
||||
path = CORE.config.get_path_for_id(config[CONF_ID])[:-1]
|
||||
parent_config = CORE.config.get_config_for_path(path)
|
||||
merged = {**parent_config, **config}
|
||||
try:
|
||||
_check_watchdog(merged)
|
||||
|
||||
@@ -28,10 +28,13 @@ bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) {
|
||||
}
|
||||
|
||||
bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) {
|
||||
if (this->update_register(reg, mask, value))
|
||||
return true;
|
||||
ESP_LOGW(TAG, "Failed to update register 0x%02X", reg);
|
||||
return false;
|
||||
uint8_t reg_value;
|
||||
if (!this->read_byte(reg, ®_value)) {
|
||||
ESP_LOGW(TAG, "Failed to read register 0x%02X", reg);
|
||||
return false;
|
||||
}
|
||||
reg_value = (reg_value & ~mask) | (value & mask);
|
||||
return this->write_register_(reg, reg_value);
|
||||
}
|
||||
|
||||
uint8_t BQ25186Component::expected_register_(size_t index) const {
|
||||
|
||||
@@ -71,12 +71,12 @@ class BQ25186Component : public PollingComponent, public i2c::I2CDevice {
|
||||
|
||||
protected:
|
||||
bool write_register_(uint8_t reg, uint8_t value);
|
||||
bool update_register_(uint8_t reg, uint8_t mask, uint8_t value);
|
||||
uint8_t expected_register_(size_t index) const;
|
||||
bool config_matches_(const BQ25186StatusRegisters ®s) const;
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending);
|
||||
#endif
|
||||
bool update_register_(uint8_t reg, uint8_t mask, uint8_t value);
|
||||
|
||||
#ifdef BQ25186_BINARY_SENSOR_COUNT
|
||||
StaticVector<BQ25186BinarySensor *, BQ25186_BINARY_SENSOR_COUNT> binary_sensors_;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BUTTON
|
||||
|
||||
#include "bq25186.h"
|
||||
#include "esphome/components/button/button.h"
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
/// Writes one SHIP_RST field when pressed
|
||||
class BQ25186Button final : public button::Button, public Parented<BQ25186Component> {
|
||||
public:
|
||||
BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {}
|
||||
|
||||
protected:
|
||||
void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); }
|
||||
|
||||
const uint8_t mask_;
|
||||
const uint8_t value_;
|
||||
};
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
#endif // USE_BUTTON
|
||||
@@ -14,7 +14,11 @@ void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) {
|
||||
if (value == this->last_)
|
||||
return;
|
||||
this->last_ = value;
|
||||
this->publish_state(StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX));
|
||||
const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX);
|
||||
char text[32];
|
||||
ESPHOME_strncpy_P(text, reinterpret_cast<ESPHOME_PGM_P>(name), sizeof(text));
|
||||
text[sizeof(text) - 1] = '\0';
|
||||
this->publish_state(text);
|
||||
}
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -9,10 +10,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID
|
||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
|
||||
|
||||
DEPENDENCIES = ["bq25186"]
|
||||
|
||||
BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button)
|
||||
|
||||
# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes
|
||||
SOFTWARE_RESET_MASK = 0x80
|
||||
@@ -31,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
BQ25186_CHILD_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(key): button.button_schema(
|
||||
button.ApplyButton,
|
||||
BQ25186Button,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon=icon,
|
||||
device_class=device_class,
|
||||
@@ -44,6 +46,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
buttons = button.sub_buttons(config, parent=config[CONF_BQ25186_ID])
|
||||
for key, mask, value, _, _ in _BUTTONS:
|
||||
await buttons(key, None, apply=f"write_ship_rst({mask:#04x}, {value:#04x})")
|
||||
if (conf := config.get(key)) is not None:
|
||||
var = await button.new_button(conf, mask, value)
|
||||
await cg.register_parented(var, config[CONF_BQ25186_ID])
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
@@ -9,6 +10,7 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MQTT_ID,
|
||||
CONF_ON_PRESS,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
DEVICE_CLASS_IDENTIFY,
|
||||
DEVICE_CLASS_RESTART,
|
||||
@@ -19,12 +21,11 @@ from esphome.const import (
|
||||
ICON_RESTART,
|
||||
ICON_RESTART_ALERT,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
ApplyEntity,
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
@@ -52,13 +53,11 @@ validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
|
||||
|
||||
|
||||
_BUTTON_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt_component_class("MQTTButtonComponent")
|
||||
),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent),
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
@@ -136,7 +135,12 @@ async def setup_button_core_(var: MockObj, config: ConfigType) -> None:
|
||||
|
||||
setup_device_class(config)
|
||||
|
||||
await entity_helpers.setup_entity_integrations(var, config)
|
||||
if mqtt_id := config.get(CONF_MQTT_ID):
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
|
||||
|
||||
async def register_button(var: MockObj, config: ConfigType) -> None:
|
||||
|
||||
@@ -42,8 +42,10 @@ void CAP1188Component::finish_setup_() {
|
||||
}
|
||||
|
||||
// Set sensitivity
|
||||
// The threshold replaces the upper nibble and is ORed into the lower one, as before
|
||||
this->update_register(CAP1188_SENSITVITY, 0xF0 | this->touch_threshold_, this->touch_threshold_);
|
||||
uint8_t sensitivity = 0;
|
||||
this->read_byte(CAP1188_SENSITVITY, &sensitivity);
|
||||
sensitivity = sensitivity & 0x0f;
|
||||
this->write_byte(CAP1188_SENSITVITY, sensitivity | this->touch_threshold_);
|
||||
|
||||
// Allow multiple touches
|
||||
this->write_byte(CAP1188_MULTI_TOUCH, this->allow_multiple_touches_);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "cc1101pa.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
@@ -15,9 +14,6 @@ namespace esphome::cc1101 {
|
||||
ESPHOME_LOG_TAG(TAG, "cc1101");
|
||||
static constexpr size_t TX_FIFO_SIZE = 64;
|
||||
|
||||
// MDMCFG2 MOD_FORMAT values
|
||||
PROGMEM_STRING_TABLE(ModulationStrings, "2-FSK", "GFSK", "UNUSED", "ASK/OOK", "4-FSK", "UNUSED", "UNUSED", "MSK");
|
||||
|
||||
static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) {
|
||||
int e_tmp;
|
||||
float m_tmp = std::frexp(value, &e_tmp);
|
||||
@@ -242,6 +238,8 @@ void CC1101Component::loop() {
|
||||
}
|
||||
|
||||
void CC1101Component::dump_config() {
|
||||
static const char *const MODULATION_NAMES[] = {"2-FSK", "GFSK", "UNUSED", "ASK/OOK",
|
||||
"4-FSK", "UNUSED", "UNUSED", "MSK"};
|
||||
int32_t freq = static_cast<int32_t>(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) *
|
||||
XTAL_FREQUENCY / (1 << 16);
|
||||
float symbol_rate = (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY;
|
||||
@@ -255,9 +253,8 @@ void CC1101Component::dump_config() {
|
||||
" Symbol Rate: %.0f baud\n"
|
||||
" Filter Bandwidth: %.1f Hz\n"
|
||||
" Output Power: %.1f dBm",
|
||||
this->chip_id_, freq, this->state_.CHANNR,
|
||||
LOG_STR_ARG(ModulationStrings::get_log_str(this->state_.MOD_FORMAT & 0x07, 0)), symbol_rate, bw,
|
||||
this->output_power_effective_);
|
||||
this->chip_id_, freq, this->state_.CHANNR, MODULATION_NAMES[this->state_.MOD_FORMAT & 0x07],
|
||||
symbol_rate, bw, this->output_power_effective_);
|
||||
LOG_PIN(" CS Pin: ", this->cs_);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTION_STATE_TOPIC,
|
||||
@@ -48,15 +49,15 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE_STEP,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
CONF_WEB_SERVER,
|
||||
UNIT_KELVIN,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
TemperatureUnitResolver,
|
||||
convert_visual_temperatures,
|
||||
declare_fixed_temperature_unit,
|
||||
entity_duplicate_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
setup_entity_temperature_unit,
|
||||
@@ -176,13 +177,11 @@ _visual_temperatures_in_entity_unit = partial(
|
||||
|
||||
|
||||
_CLIMATE_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt_component_class("MQTTClimateComponent")
|
||||
),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
|
||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||
@@ -354,8 +353,10 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
|
||||
cg.add(var.set_visual_max_humidity_override(max_humidity))
|
||||
setup_entity_temperature_unit(var, config)
|
||||
|
||||
mqtt_ = await entity_helpers.setup_entity_integrations(var, config)
|
||||
if mqtt_ is not None:
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
|
||||
if (action_state_topic := config.get(CONF_ACTION_STATE_TOPIC)) is not None:
|
||||
cg.add(mqtt_.set_custom_action_state_topic(action_state_topic))
|
||||
if (away_command_topic := config.get(CONF_AWAY_COMMAND_TOPIC)) is not None:
|
||||
@@ -485,6 +486,9 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
|
||||
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
|
||||
|
||||
def set_custom_fan_modes(var: MockObj, modes: list[str]) -> None:
|
||||
"""Point the climate at a shared flash table of custom fan modes."""
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
@@ -22,6 +23,7 @@ from esphome.const import (
|
||||
CONF_TILT_COMMAND_TOPIC,
|
||||
CONF_TILT_STATE_TOPIC,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_AWNING,
|
||||
DEVICE_CLASS_BLIND,
|
||||
DEVICE_CLASS_CURTAIN,
|
||||
@@ -34,10 +36,9 @@ from esphome.const import (
|
||||
DEVICE_CLASS_SHUTTER,
|
||||
DEVICE_CLASS_WINDOW,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
@@ -111,13 +112,11 @@ TRIGGERS = {
|
||||
|
||||
|
||||
_COVER_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt_component_class("MQTTCoverComponent")
|
||||
),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTCoverComponent),
|
||||
cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All(
|
||||
cv.requires_component("mqtt"), cv.boolean
|
||||
),
|
||||
@@ -203,8 +202,10 @@ async def setup_cover_core_(var: MockObj, config: ConfigType) -> None:
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
mqtt_ = await entity_helpers.setup_entity_integrations(var, config)
|
||||
if mqtt_ is not None:
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
|
||||
if (position_state_topic := config.get(CONF_POSITION_STATE_TOPIC)) is not None:
|
||||
cg.add(mqtt_.set_custom_position_state_topic(position_state_topic))
|
||||
if (
|
||||
@@ -219,6 +220,9 @@ async def setup_cover_core_(var: MockObj, config: ConfigType) -> None:
|
||||
if (tilt_command_topic := config.get(CONF_TILT_COMMAND_TOPIC)) is not None:
|
||||
cg.add(mqtt_.set_custom_tilt_command_topic(tilt_command_topic))
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
|
||||
|
||||
async def register_cover(var: MockObj, config: ConfigType) -> None:
|
||||
if not CORE.has_id(config[CONF_ID]):
|
||||
|
||||
Loaded 100 of 694 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user