mirror of
https://github.com/esphome/esphome.git
synced 2026-10-28 00:42:11 +00:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02502b7af2 | ||
|
|
205ef9407f | ||
|
|
d552396650 | ||
|
|
a101d57476 | ||
|
|
195cb3b2b4 | ||
|
|
8529b7a59c | ||
|
|
4e63342350 | ||
|
|
3784a3d56a | ||
|
|
848d1ac4e8 | ||
|
|
c3348789eb | ||
|
|
6ea4d976c2 | ||
|
|
ebd5a8bb18 | ||
|
|
6ac63ac495 | ||
|
|
7d5bfb8fa3 | ||
|
|
4cd4ddb981 | ||
|
|
4ea26ed65f | ||
|
|
747921faa8 | ||
|
|
f8056c3a30 | ||
|
|
85c241391e | ||
|
|
9cccb42c6f | ||
|
|
81d451aa63 | ||
|
|
c3506bd7b9 | ||
|
|
449c511606 | ||
|
|
0ec8e58772 | ||
|
|
1ee8762006 | ||
|
|
c865b551ee | ||
|
|
6e416952c8 | ||
|
|
96b8bae876 | ||
|
|
3b1bf1dcba | ||
|
|
72221aa0d3 | ||
|
|
1e8f084741 | ||
|
|
70448afb87 | ||
|
|
6ba8862ad8 | ||
|
|
ca69cd06e9 | ||
|
|
c965c5ee70 | ||
|
|
97bbe58c8c | ||
|
|
6595df0cab | ||
|
|
73894b2490 | ||
|
|
9bef20d8dc | ||
|
|
0e440500a2 | ||
|
|
badfa84fe0 | ||
|
|
e686ecf76e | ||
|
|
64fb4e06b5 | ||
|
|
cb82c9031a | ||
|
|
ca74ab9873 | ||
|
|
86fc740b65 | ||
|
|
450bb52f65 | ||
|
|
ed3615ec69 | ||
|
|
a5f25ebe2d | ||
|
|
7a4775de3d |
No files matched your search
@@ -177,7 +177,8 @@ async def to_code(config):
|
|||||||
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
|
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
|
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
|
`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
|
state, and `publish="on_success"` publishes only when the hub call returns true (a write the device can
|
||||||
|
refuse). `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.
|
and runs the body only when turned on. Never use it for a new entity; an action is a button.
|
||||||
```python
|
```python
|
||||||
switches = switch.sub_switches(config, parent=hub)
|
switches = switch.sub_switches(config, parent=hub)
|
||||||
|
|||||||
@@ -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.
|
automations in the config itself.
|
||||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
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`).
|
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,
|
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
|
||||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
|
||||||
no IP addresses.
|
```
|
||||||
|
> [!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
|
## 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,
|
(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
|
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
|
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`
|
## 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
|
- 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
|
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.
|
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;
|
- 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.
|
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.
|
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
|
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
|
||||||
a never-saved value.
|
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
|
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ One file per component, named after it:
|
|||||||
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
|
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
|
||||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||||
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot
|
||||||
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
|
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
|
||||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
|
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
|
||||||
- [`nrf52.md`](nrf52.md): dongle board support
|
- [`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
|
- [`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
|
- [`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
|
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||||
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||||
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
- [`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.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||||
@@ -36,5 +36,8 @@ One file per component, named after it:
|
|||||||
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
|
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
|
||||||
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
|
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
|
||||||
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
|
- [`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
|
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols per chip, loop cost per strip
|
||||||
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
|
- [`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.
|
||||||
@@ -8,3 +8,7 @@
|
|||||||
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
|
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
|
||||||
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
|
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
|
||||||
add effects; restore its own config afterwards.
|
add effects; restore its own config afterwards.
|
||||||
|
- Loop cost of a strip (#19337): give each strip an `addressable_lambda` effect with `update_interval: 0ms` that
|
||||||
|
changes every pixel, and add `runtime_stats: log_interval: 30s`; compare the main loop and light times per pass,
|
||||||
|
leaving out the first period after boot. The strips need no LEDs attached. A classic ESP32 has 512 RMT symbols in
|
||||||
|
all, so three strips need `rmt_symbols: 128` each.
|
||||||
@@ -56,3 +56,18 @@ OTA-only board: copy the device's own config, add the probe include and the step
|
|||||||
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
|
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
|
||||||
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
|
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
|
||||||
client gets it on subscribe) or on the board's serial log port if it has one.
|
client gets it on subscribe) or on the board's serial log port if it has one.
|
||||||
|
|
||||||
|
## RTL8720C crash report on the next boot (#21648)
|
||||||
|
|
||||||
|
`templates/rtl8720c-crash-buttons-dut.yaml` has one API button per case: `trap` (`__builtin_trap()`, usage fault),
|
||||||
|
`bus fault` (store to 0x70000000, imprecise, CFSR 0x400), `wild call` (call to 0xFFFFFFF1, MemManage fault with PC
|
||||||
|
0xFFFFFFF0 and LR in the caller), `isr trap` (a trap from a spare interrupt pended by software, hard fault), `hang`
|
||||||
|
(interrupts off, the watchdog resets it and nothing must be reported) and `restart`. Every boot logs
|
||||||
|
`boot reason N, crash record yes|no|n/a`; `n/a` means the handler is not built, as on a dev control build.
|
||||||
|
|
||||||
|
Flow per build: flash with `flash_rtl87xx_kit.py`, start one `serial_capture.py PORT 600 OUT --released --ms`, then
|
||||||
|
`scripts/libretiny_crash/rtl_drive.py HOST OUTDIR TREE YAML trap restart "bus fault" "wild call" "isr trap" hang restart`.
|
||||||
|
The `restart` after the first crash checks the report shows only once. Time each reset from the serial capture
|
||||||
|
(the ROM fault dump to the next `ROM Version` line), not from the API port: the board is back before an API client
|
||||||
|
notices it went away. Compare the reported PC with the `PC =` line of the ROM dump, and check the decoded lines in the
|
||||||
|
`caseN-*.txt` files. Pass the UF2 by name (`firmware.uf2`): the build directory holds a second identically sized copy.
|
||||||
@@ -4,3 +4,10 @@
|
|||||||
|
|
||||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
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.
|
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,51 @@ 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
|
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.
|
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.
|
`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.
|
||||||
|
|
||||||
|
`receiving chunk result: Unknown error from ESP` is error 0xFF sent by the device, not a dropped connection. The
|
||||||
|
device sends it for a decode or flash write error, and also when no data arrives for 105 s, so on a weak link it
|
||||||
|
usually means a stall. A failure about 105 s after the last progress, or the same error on a raw upload, points to
|
||||||
|
the link; to be sure, capture the device log (`Inflate err`, `Inflate overrun`, `No data received`).
|
||||||
|
|
||||||
|
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
|
(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
|
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.
|
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,38 @@
|
|||||||
|
# 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).
|
||||||
|
Its `failed sends` sensor counts only GAP calls rejected when queued; a frame the controller drops later shows
|
||||||
|
up as missing publishes in `count.py`, the same as radio loss.
|
||||||
|
- 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,124 @@
|
|||||||
|
"""Press one crash button after another on an RTL8720C test build and keep the decoded API logs of each next boot.
|
||||||
|
Usage: rtl_drive.py HOST OUTDIR TREE YAML BUTTON... (button names from templates/rtl8720c-crash-buttons-dut.yaml)
|
||||||
|
Prints PRESS lines with the wall clock time, so resets can be timed against a serial capture taken with --ms.
|
||||||
|
Each case waits for the board to answer on the API port again, then saves 15 s of `esphome logs` (run from TREE,
|
||||||
|
so the CLI decodes the addresses) as OUTDIR/caseN-BUTTON.txt. The run prints CASE N BUTTON and exits 1 with NO RESET
|
||||||
|
when the API port never closes after the press (the button did not reset the board), NO BOOT when the board is not
|
||||||
|
back within BOOT_DEADLINE_S, and NO LOGS when the log capture never connected."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient
|
||||||
|
|
||||||
|
host, out, tree, yaml = sys.argv[1:5]
|
||||||
|
buttons = sys.argv[5:]
|
||||||
|
BOOT_DEADLINE_S = 120
|
||||||
|
# Every case button resets the board: a fault, the watchdog (hang) or a restart. The watchdog takes about 11 s.
|
||||||
|
RESET_DEADLINE_S = 20
|
||||||
|
|
||||||
|
|
||||||
|
def port_open() -> bool:
|
||||||
|
try:
|
||||||
|
socket.create_connection((host, 6053), 1).close()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_port() -> bool:
|
||||||
|
deadline = time.monotonic() + BOOT_DEADLINE_S
|
||||||
|
while not port_open():
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
return False
|
||||||
|
time.sleep(1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_reset() -> bool:
|
||||||
|
deadline = time.monotonic() + RESET_DEADLINE_S
|
||||||
|
while port_open():
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
return False
|
||||||
|
time.sleep(0.2)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fail(i: int, name: str, why: str) -> None:
|
||||||
|
print(f"CASE {i} {name} {why}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def button_keys() -> dict[str, int]:
|
||||||
|
cli = APIClient(host, 6053, None)
|
||||||
|
await cli.connect(login=True)
|
||||||
|
try:
|
||||||
|
entities, _ = await cli.list_entities_services()
|
||||||
|
finally:
|
||||||
|
await cli.disconnect()
|
||||||
|
return {e.name: e.key for e in entities if type(e).__name__ == "ButtonInfo"}
|
||||||
|
|
||||||
|
|
||||||
|
async def press(name: str, key: int) -> None:
|
||||||
|
cli = APIClient(host, 6053, None)
|
||||||
|
await cli.connect(login=True)
|
||||||
|
now = time.time()
|
||||||
|
print(
|
||||||
|
f"PRESS {name} at {time.strftime('%H:%M:%S', time.localtime(now))}.{int(now * 1000) % 1000:03d}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
cli.button_command(key)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
# the board resets within about a second, so the disconnect reply may never come
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.wait_for(cli.disconnect(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if not wait_for_port():
|
||||||
|
sys.exit("NO BOOT before the first case")
|
||||||
|
# Check every name up front, so a typo in the last case does not waste the crashes before it
|
||||||
|
keys = asyncio.run(button_keys())
|
||||||
|
if missing := [b for b in buttons if b not in keys]:
|
||||||
|
sys.exit(f"Unknown buttons {missing}; the board has {sorted(keys)}")
|
||||||
|
|
||||||
|
for i, name in enumerate(buttons):
|
||||||
|
if not wait_for_port():
|
||||||
|
fail(i, name, "NO BOOT")
|
||||||
|
time.sleep(5)
|
||||||
|
asyncio.run(press(name, keys[name]))
|
||||||
|
if not wait_for_reset():
|
||||||
|
fail(i, name, "NO RESET")
|
||||||
|
time.sleep(20) # boot and Wi-Fi
|
||||||
|
if not wait_for_port():
|
||||||
|
fail(i, name, "NO BOOT")
|
||||||
|
case_file = Path(out) / f"case{i}-{name.replace(' ', '_')}.txt"
|
||||||
|
with case_file.open("w", encoding="utf-8") as fh:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"timeout",
|
||||||
|
"15",
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"esphome",
|
||||||
|
"logs",
|
||||||
|
yaml,
|
||||||
|
"--device",
|
||||||
|
host,
|
||||||
|
],
|
||||||
|
cwd=tree,
|
||||||
|
env={**os.environ, "PYTHONPATH": tree, "PYTHONUNBUFFERED": "1"},
|
||||||
|
stdout=fh,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if "Successful handshake" not in case_file.read_text(
|
||||||
|
encoding="utf-8", errors="replace"
|
||||||
|
):
|
||||||
|
fail(i, name, "NO LOGS")
|
||||||
|
print(f"CASE {i} {name} done", flush=True)
|
||||||
@@ -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,88 @@
|
|||||||
|
"""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]
|
||||||
|
# Assigning a missing attribute would silently do nothing, so a renamed name must stop the run
|
||||||
|
for name in ("CLIENT_FEATURE_SUPPORTS_DEFLATE", "_loss_reason"):
|
||||||
|
if not hasattr(espota, name):
|
||||||
|
sys.exit(f"espota2 in this tree has no {name}; update otatime.py")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
# The log must agree with the mode, or the row would be counted under the wrong mode
|
||||||
|
if mode.startswith("raw") and result["size"]:
|
||||||
|
result["rc"] = rc = rc or 1
|
||||||
|
print("raw mode upload was compressed anyway", file=sys.stderr)
|
||||||
|
if mode.endswith("-tcp") and result["udp"]:
|
||||||
|
result["rc"] = rc = rc or 1
|
||||||
|
print("-tcp mode upload switched to UDP", file=sys.stderr)
|
||||||
|
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,57 @@
|
|||||||
|
#!/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+=($!)
|
||||||
|
# an empty file marks the CB3S capture as requested, so an.py fails it if the capture never writes anything
|
||||||
|
[ -n "$CB" ] && { : > "$D/$b-cb.txt"; $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,256 @@
|
|||||||
|
"""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 = """
|
||||||
|
// Sends where a GAP call returned an error when it was queued. Failures the controller reports later in the GAP
|
||||||
|
// completion events are not counted; those frames show up as missing publishes in count.py.
|
||||||
|
static uint32_t af_failed = 0;
|
||||||
|
|
||||||
|
// 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"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# RTL8720C (BW15) crash report test build (#21648): one API button per fault, driven by
|
||||||
|
# scripts/libretiny_crash/rtl_drive.py. Each boot logs the reboot reason and whether a crash record was found.
|
||||||
|
substitutions:
|
||||||
|
name: rtlcrash
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
// "n/a" when the handler is not built (a dev control build), so it is not read as a lost record
|
||||||
|
const char *rec = "n/a";
|
||||||
|
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||||
|
rec = esphome::libretiny::crash_handler_has_data() ? "yes" : "no";
|
||||||
|
#endif
|
||||||
|
ESP_LOGI("probe", "boot reason %d, crash record %s", (int) lt_get_reboot_reason(), rec);
|
||||||
|
|
||||||
|
rtl87xx:
|
||||||
|
board: bw15
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
api:
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
button:
|
||||||
|
- platform: template
|
||||||
|
name: trap
|
||||||
|
on_press:
|
||||||
|
- lambda: __builtin_trap();
|
||||||
|
- platform: template
|
||||||
|
name: bus fault
|
||||||
|
on_press:
|
||||||
|
- lambda: '*(volatile uint32_t *) 0x70000000 = 1;'
|
||||||
|
- platform: template
|
||||||
|
name: wild call
|
||||||
|
on_press:
|
||||||
|
- lambda: ((void (*)()) 0xFFFFFFF1)();
|
||||||
|
- platform: template
|
||||||
|
name: isr trap
|
||||||
|
on_press:
|
||||||
|
- lambda: |-
|
||||||
|
auto *vt = (uint32_t *) *(volatile uint32_t *) 0xE000ED08;
|
||||||
|
vt[16 + 11] = (uint32_t) +[]() { __builtin_trap(); };
|
||||||
|
*(volatile uint32_t *) 0xE000E100 = 1u << 11;
|
||||||
|
*(volatile uint32_t *) 0xE000E200 = 1u << 11;
|
||||||
|
- platform: template
|
||||||
|
name: hang
|
||||||
|
on_press:
|
||||||
|
- lambda: |-
|
||||||
|
__disable_irq();
|
||||||
|
while (true) {
|
||||||
|
}
|
||||||
|
- platform: restart
|
||||||
|
name: restart
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Install apt packages (cached)
|
||||||
|
description: >-
|
||||||
|
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
|
||||||
|
only from the package list and version, so both live here and nowhere else.
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Keep a failed install out of the cache
|
||||||
|
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
|
||||||
|
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
|
||||||
|
# already on PATH: this one refreshes the lists and retries when the install fails and,
|
||||||
|
# if that fails too, removes the folder the action would save. Only a cache miss installs.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||||
|
Acquire::Retries "1";
|
||||||
|
Acquire::http::Timeout "15";
|
||||||
|
Acquire::https::Timeout "15";
|
||||||
|
EOF
|
||||||
|
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ " $* " != *" install "* ]]; then
|
||||||
|
exec apt-get "$@"
|
||||||
|
fi
|
||||||
|
touch /tmp/apt-fast-standin-ran
|
||||||
|
apt-get "$@" && exit 0
|
||||||
|
apt-get update -qq >&2
|
||||||
|
# The action pins each package to the version the stale lists named
|
||||||
|
args=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
|
||||||
|
done
|
||||||
|
apt-get "${args[@]}" && exit 0
|
||||||
|
# The action writes our output to install.log inside the folder it saves
|
||||||
|
log="$(readlink -f "/proc/$$/fd/1")"
|
||||||
|
if [ "$(basename "${log}")" = install.log ]; then
|
||||||
|
rm -rf "$(dirname "${log}")"
|
||||||
|
else
|
||||||
|
touch /tmp/apt-fast-cleanup-missed
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
sudo chmod +x /usr/local/bin/apt-fast
|
||||||
|
- name: Install apt packages (cached)
|
||||||
|
id: apt
|
||||||
|
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
|
||||||
|
# release calls apt-fast and where it writes install.log.
|
||||||
|
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||||
|
with:
|
||||||
|
packages: libsdl2-dev libmbedtls-dev ccache
|
||||||
|
# 1.3 drops the empty caches saved before the step above existed
|
||||||
|
version: 1.3
|
||||||
|
- name: Check the packages are installed
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
|
||||||
|
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -e /tmp/apt-fast-cleanup-missed ]; then
|
||||||
|
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The action restores files without registering them with dpkg
|
||||||
|
for cmd in ccache sdl2-config; do
|
||||||
|
if ! command -v "${cmd}" > /dev/null; then
|
||||||
|
echo "::error::${cmd} is missing after the cached install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ! -f /usr/include/mbedtls/version.h ]; then
|
||||||
|
echo "::error::libmbedtls-dev is missing after the cached install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+146
-32
@@ -73,16 +73,17 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
||||||
# pushes seed the one shared entry PR jobs restore. The key is derived
|
# pushes seed the one shared entry PR jobs restore. The key is derived
|
||||||
# only from the package list and version; keep both identical in every
|
# only from the package list and version, kept in the apt-packages action.
|
||||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
# In ci-status needs so a broken seed fails dev.
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt packages (cached)
|
- name: Check out code from GitHub
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
packages: libsdl2-dev libmbedtls-dev ccache
|
sparse-checkout: .github
|
||||||
version: 1.2
|
- name: Install apt packages (cached)
|
||||||
|
uses: ./.github/actions/apt-packages
|
||||||
|
|
||||||
determine-jobs:
|
determine-jobs:
|
||||||
name: Determine which jobs to run
|
name: Determine which jobs to run
|
||||||
@@ -406,20 +407,21 @@ jobs:
|
|||||||
# Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds
|
# Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds
|
||||||
# download, shared per xdist worker by tests/integration/conftest.py
|
# download, shared per xdist worker by tests/integration/conftest.py
|
||||||
INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components
|
INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components
|
||||||
|
# The host build's ccache (get_build_env() in esphome/host/toolchain.py)
|
||||||
|
INTEGRATION_CCACHE_PATH: ~/.cache/esphome/host/ccache
|
||||||
|
# Only a full matrix run saves the ccache seed: a partial run would
|
||||||
|
# trim it to the few tests it ran
|
||||||
|
INTEGRATION_CCACHE_SAVE: ${{ needs.determine-jobs.outputs.integration-run-all == 'true' && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- name: Install apt packages (cached)
|
- name: Install apt packages (cached)
|
||||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||||
# path. Packages and version must match seed-apt-cache exactly.
|
# path. libsdl2-dev is needed by the headless display tests, which
|
||||||
# libsdl2-dev is needed by the headless display tests, which capture
|
# capture screenshots.
|
||||||
# screenshots.
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: ./.github/actions/apt-packages
|
||||||
with:
|
|
||||||
packages: libsdl2-dev libmbedtls-dev ccache
|
|
||||||
version: 1.2
|
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
@@ -432,6 +434,54 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||||
key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }}
|
key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }}
|
||||||
|
# A full run saves one ccache slice per bucket and restores only its own; a
|
||||||
|
# partial run groups tests differently, so it restores all of them. One step
|
||||||
|
# per slice of INTEGRATION_TESTS_SPLIT_BUCKETS in script/determine-jobs.py.
|
||||||
|
- name: Restore integration ccache (slice 1/5)
|
||||||
|
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 0
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||||
|
key: integration-ccache-v1-0-${{ github.run_id }}
|
||||||
|
restore-keys: integration-ccache-v1-0-
|
||||||
|
- name: Restore integration ccache (slice 2/5)
|
||||||
|
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 1
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||||
|
key: integration-ccache-v1-1-${{ github.run_id }}
|
||||||
|
restore-keys: integration-ccache-v1-1-
|
||||||
|
- name: Restore integration ccache (slice 3/5)
|
||||||
|
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 2
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||||
|
key: integration-ccache-v1-2-${{ github.run_id }}
|
||||||
|
restore-keys: integration-ccache-v1-2-
|
||||||
|
- name: Restore integration ccache (slice 4/5)
|
||||||
|
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 3
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||||
|
key: integration-ccache-v1-3-${{ github.run_id }}
|
||||||
|
restore-keys: integration-ccache-v1-3-
|
||||||
|
- name: Restore integration ccache (slice 5/5)
|
||||||
|
if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 4
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||||
|
key: integration-ccache-v1-4-${{ github.run_id }}
|
||||||
|
restore-keys: integration-ccache-v1-4-
|
||||||
|
- name: Mark the restored ccache as unused
|
||||||
|
# A hit refreshes an entry's mtime, so the trim before the save keeps
|
||||||
|
# exactly what this run used; the zeroed statistics cover this run only
|
||||||
|
run: |
|
||||||
|
dir="${INTEGRATION_CCACHE_PATH/#\~/$HOME}"
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
find "$dir" -type f -exec touch -m -d 2000-01-01 {} +
|
||||||
|
fi
|
||||||
|
echo "CCACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||||
|
CCACHE_DIR="$dir" ccache -z
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
@@ -462,6 +512,7 @@ jobs:
|
|||||||
- name: Register matcher
|
- name: Register matcher
|
||||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
|
id: integration
|
||||||
env:
|
env:
|
||||||
# JSON array of test paths; parsed into a bash array below to avoid
|
# JSON array of test paths; parsed into a bash array below to avoid
|
||||||
# shell word-splitting / glob hazards.
|
# shell word-splitting / glob hazards.
|
||||||
@@ -477,6 +528,32 @@ jobs:
|
|||||||
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
|
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
|
||||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
|
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
|
||||||
--junitxml=junit-integration.xml "${test_files[@]}"
|
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||||
|
- name: Re-run the failed tests with debug symbols
|
||||||
|
# The first run builds without -g. A crash in the re-run prints a gdb
|
||||||
|
# backtrace of its core; exit 1 keeps the job red when the re-run passes.
|
||||||
|
if: failure() && steps.integration.outcome == 'failure'
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
ESPHOME_INTEGRATION_DEBUG_SYMBOLS: "1"
|
||||||
|
ESPHOME_INTEGRATION_CORE_DIR: /tmp/esphome-cores
|
||||||
|
run: |
|
||||||
|
. venv/bin/activate
|
||||||
|
# The image's package lists usually suffice; refresh them only if not
|
||||||
|
if ! command -v gdb >/dev/null &&
|
||||||
|
! sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 apt-get install -y -qq gdb; then
|
||||||
|
sudo timeout -k 15 300 apt-get update -qq &&
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 apt-get install -y -qq gdb ||
|
||||||
|
echo "::warning::gdb install failed; no backtraces"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
mkdir -p "$ESPHOME_INTEGRATION_CORE_DIR" &&
|
||||||
|
echo "$ESPHOME_INTEGRATION_CORE_DIR/core.%p" | sudo tee /proc/sys/kernel/core_pattern &&
|
||||||
|
ulimit -c unlimited
|
||||||
|
} || echo "::warning::core dumps not enabled; no backtraces"
|
||||||
|
# Under the step limit, so a hung test still reports before Actions kills the step
|
||||||
|
pytest -vv --no-cov --tb=native -n auto --dist worksteal --timeout=600 \
|
||||||
|
--lf --lfnf=none tests/integration || true
|
||||||
|
exit 1
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
# Tests that compare rendered output write the image they actually got here, so a
|
# Tests that compare rendered output write the image they actually got here, so a
|
||||||
# failure can be looked at without reproducing the whole build locally.
|
# failure can be looked at without reproducing the whole build locally.
|
||||||
@@ -499,10 +576,19 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
# A full cron period of margin for the weekly refresh
|
# A full cron period of margin for the weekly refresh
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
- name: Trim the integration ccache to this run
|
||||||
|
# Restored entries this run did not hit are stale; dropping them keeps
|
||||||
|
# the seed at one full run's working set instead of growing per save
|
||||||
|
if: success() && env.INTEGRATION_CCACHE_SAVE == 'true'
|
||||||
|
run: ccache --evict-older-than 1d
|
||||||
- name: Print ccache statistics
|
- name: Print ccache statistics
|
||||||
# esphome stores the host build's ccache under the machine-global
|
run: ccache -s
|
||||||
# cache dir (see get_build_env() in esphome/host/toolchain.py).
|
- name: Save integration ccache slice
|
||||||
run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s
|
if: success() && env.INTEGRATION_CCACHE_SAVE == 'true'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_CCACHE_PATH }}
|
||||||
|
key: integration-ccache-v1-${{ strategy.job-index }}-${{ github.run_id }}
|
||||||
- name: Save integration library cache
|
- name: Save integration library cache
|
||||||
# Bucket 0 only; the others would race the same immutable key.
|
# Bucket 0 only; the others would race the same immutable key.
|
||||||
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true'
|
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true'
|
||||||
@@ -511,6 +597,41 @@ jobs:
|
|||||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||||
key: ${{ steps.library-cache.outputs.cache-primary-key }}
|
key: ${{ steps.library-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
|
integration-ccache-prune:
|
||||||
|
name: Prune replaced integration ccache slices
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- determine-jobs
|
||||||
|
- integration-tests
|
||||||
|
# Dev only: a pull request's saves stay in its own scope and expire unused.
|
||||||
|
# A separate job so no test code runs with the cache write scope.
|
||||||
|
if: >-
|
||||||
|
!cancelled() &&
|
||||||
|
github.ref == 'refs/heads/dev' &&
|
||||||
|
needs.determine-jobs.outputs.integration-run-all == 'true'
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
|
- name: Delete the slices this run replaced
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
entries=$(gh cache list --ref refs/heads/dev --key integration-ccache-v1- --limit 100 --json id,key)
|
||||||
|
# A slice whose bucket failed did not save; its older entry stays
|
||||||
|
for key in $(jq -r --arg run "-$GITHUB_RUN_ID" '.[].key | select(endswith($run))' <<<"$entries"); do
|
||||||
|
prefix="${key%-"$GITHUB_RUN_ID"}-"
|
||||||
|
jq -r --arg p "$prefix" --argjson run "$GITHUB_RUN_ID" \
|
||||||
|
'.[] | select((.key | startswith($p)) and (.key | ltrimstr($p) | tonumber) < $run) | .id' <<<"$entries" |
|
||||||
|
while read -r id; do
|
||||||
|
if ! out=$(gh api -X DELETE "repos/$GH_REPO/actions/caches/$id" 2>&1); then
|
||||||
|
# An overlapping dev run may have deleted it already; any other error fails the job
|
||||||
|
grep -q 'HTTP 404' <<<"$out" || { echo "$out" >&2; exit 1; }
|
||||||
|
echo "::notice::cache $id already gone"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
import-time:
|
import-time:
|
||||||
name: Check import esphome.__main__ time
|
name: Check import esphome.__main__ time
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -718,13 +839,9 @@ jobs:
|
|||||||
- name: Install apt packages (cached)
|
- name: Install apt packages (cached)
|
||||||
if: matrix.pio_cache_key == 'tidyhost'
|
if: matrix.pio_cache_key == 'tidyhost'
|
||||||
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
|
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
|
||||||
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
|
# by dsmr and dlms_meter.
|
||||||
# exactly.
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: ./.github/actions/apt-packages
|
||||||
with:
|
|
||||||
packages: libsdl2-dev libmbedtls-dev ccache
|
|
||||||
version: 1.2
|
|
||||||
|
|
||||||
- name: Restore Python
|
- name: Restore Python
|
||||||
id: restore-python
|
id: restore-python
|
||||||
@@ -1111,20 +1228,16 @@ jobs:
|
|||||||
- name: List components
|
- name: List components
|
||||||
run: echo ${{ matrix.batch.components }}
|
run: echo ${{ matrix.batch.components }}
|
||||||
|
|
||||||
|
- name: Check out code from GitHub
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install apt packages (cached)
|
- name: Install apt packages (cached)
|
||||||
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
||||||
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
||||||
# the cold path. Packages and version must match seed-apt-cache
|
# the cold path. The action has no --no-install-recommends; same
|
||||||
# exactly. The action has no --no-install-recommends; same package
|
# package set this job used before #17463.
|
||||||
# set this job used before #17463.
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: ./.github/actions/apt-packages
|
||||||
with:
|
|
||||||
packages: libsdl2-dev libmbedtls-dev ccache
|
|
||||||
version: 1.2
|
|
||||||
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
- name: Restore Python
|
- name: Restore Python
|
||||||
uses: ./.github/actions/restore-python
|
uses: ./.github/actions/restore-python
|
||||||
with:
|
with:
|
||||||
@@ -1799,6 +1912,7 @@ jobs:
|
|||||||
- pytest
|
- pytest
|
||||||
- codecov-empty-upload
|
- codecov-empty-upload
|
||||||
- integration-tests
|
- integration-tests
|
||||||
|
- integration-ccache-prune
|
||||||
- import-time
|
- import-time
|
||||||
- cpp-unit-tests
|
- cpp-unit-tests
|
||||||
- clang-tidy-single
|
- clang-tidy-single
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ from esphome.cpp_helpers import ( # noqa: F401
|
|||||||
add_variable,
|
add_variable,
|
||||||
build_registry_entry,
|
build_registry_entry,
|
||||||
build_registry_list,
|
build_registry_list,
|
||||||
|
build_registry_list_merged,
|
||||||
extract_registry_entry_config,
|
extract_registry_entry_config,
|
||||||
get_slot_count,
|
get_slot_count,
|
||||||
gpio_pin_expression,
|
gpio_pin_expression,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
|
from esphome.components.const import CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
||||||
from esphome.components.ota import (
|
from esphome.components.ota import (
|
||||||
BASE_OTA_SCHEMA,
|
BASE_OTA_SCHEMA,
|
||||||
@@ -12,10 +13,7 @@ from esphome.components.ota import (
|
|||||||
)
|
)
|
||||||
from esphome.components.zephyr import zephyr_data
|
from esphome.components.zephyr import zephyr_data
|
||||||
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||||
from esphome.components.zephyr_ble_server import (
|
from esphome.components.zephyr_ble_server import DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN
|
||||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
|
||||||
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
|
|
||||||
)
|
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
||||||
from esphome.core import CoroPriority, coroutine_with_priority
|
from esphome.core import CoroPriority, coroutine_with_priority
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
|
|||||||
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
|
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
|
||||||
on this component and contain no SDK calls of their own.
|
on this component and contain no SDK calls of their own.
|
||||||
|
|
||||||
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
|
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 (BLE 5.2),
|
||||||
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
|
and any future BLE-5.x SoC. LibreTiny 1.14 or newer is required: earlier releases
|
||||||
compiled in, the Beken SDK erases the bootloader flash sector at boot because
|
have no BLE bonding partition, so the Beken SDK erases the bootloader flash sector
|
||||||
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
|
at boot on BK7238 (esphome#18646, libretiny-eu/libretiny#408). Known non-5.x
|
||||||
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
|
families are rejected in to_code. Unknown families are capability-checked at compile time via
|
||||||
to_code. Unknown families are capability-checked at compile time via
|
|
||||||
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
|
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
|
||||||
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
|
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
|
||||||
fails with a clear #error.
|
fails with a clear #error.
|
||||||
@@ -29,7 +28,6 @@ from esphome.components.libretiny.const import (
|
|||||||
FAMILY_BK7231N,
|
FAMILY_BK7231N,
|
||||||
FAMILY_BK7231Q,
|
FAMILY_BK7231Q,
|
||||||
FAMILY_BK7231T,
|
FAMILY_BK7231T,
|
||||||
FAMILY_BK7238,
|
|
||||||
FAMILY_BK7251,
|
FAMILY_BK7251,
|
||||||
)
|
)
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
@@ -54,7 +52,9 @@ BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
|
|||||||
# bootloader with BLE compiled in; the libretiny platform builds with this one
|
# bootloader with BLE compiled in; the libretiny platform builds with this one
|
||||||
MIN_BDK_VERSION = (3, 0, 78)
|
MIN_BDK_VERSION = (3, 0, 78)
|
||||||
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
|
# 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)
|
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414), and
|
||||||
|
# have no BLE bond partition, so the BK7238 bond store erases flash sector 0 and
|
||||||
|
# bricks the chip (libretiny-eu/libretiny#408)
|
||||||
MIN_LIBRETINY_VERSION = (1, 14, 0)
|
MIN_LIBRETINY_VERSION = (1, 14, 0)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -86,14 +86,6 @@ def _unsupported_family_message(family: str) -> str | None:
|
|||||||
)
|
)
|
||||||
if family == FAMILY_BK7231Q:
|
if family == FAMILY_BK7231Q:
|
||||||
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
|
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
|
||||||
if family == FAMILY_BK7238:
|
|
||||||
return (
|
|
||||||
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
|
|
||||||
"erases the bootloader flash sector at boot and the device can no longer "
|
|
||||||
"start (see https://github.com/esphome/esphome/issues/18646); support "
|
|
||||||
"returns once the LibreTiny partition table fix "
|
|
||||||
"(libretiny-eu/libretiny#408) is released"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -117,14 +109,15 @@ def check_bdk_version(value: str | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def check_libretiny_version(version: cv.Version) -> None:
|
def check_libretiny_version(version: cv.Version) -> None:
|
||||||
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
|
"""Reject a LibreTiny release older than the one whose WiFi sleep and bond storage are safe with BLE."""
|
||||||
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
|
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
|
||||||
return
|
return
|
||||||
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
|
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
|
||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
|
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 "
|
"arm the Beken MCU sleep with wifi power save and the device halts once the "
|
||||||
"station stops while BLE runs"
|
"station stops while BLE runs, and on BK7238 the bond store erases flash "
|
||||||
|
"sector 0 and bricks the chip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from esphome import automation
|
|||||||
from esphome.automation import maybe_simple_id
|
from esphome.automation import maybe_simple_id
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||||
|
from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||||
from esphome.components.esp32_ble import BTLoggers
|
from esphome.components.esp32_ble import BTLoggers
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -101,10 +102,8 @@ BLERemoveBondAction = ble_client_ns.class_(
|
|||||||
)
|
)
|
||||||
|
|
||||||
CONF_PASSKEY = "passkey"
|
CONF_PASSKEY = "passkey"
|
||||||
CONF_ACCEPT = "accept"
|
|
||||||
CONF_ON_PASSKEY_REQUEST = "on_passkey_request"
|
CONF_ON_PASSKEY_REQUEST = "on_passkey_request"
|
||||||
CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification"
|
CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification"
|
||||||
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
|
|
||||||
CONF_AUTO_CONNECT = "auto_connect"
|
CONF_AUTO_CONNECT = "auto_connect"
|
||||||
|
|
||||||
MULTI_CONF = True
|
MULTI_CONF = True
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "esphome/core/defines.h"
|
|
||||||
#ifdef USE_BQ25186_PG_GPO
|
|
||||||
|
|
||||||
#include "bq25186.h"
|
|
||||||
#include "esphome/components/switch/switch.h"
|
|
||||||
|
|
||||||
namespace esphome::bq25186 {
|
|
||||||
|
|
||||||
class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented<BQ25186Component> {
|
|
||||||
protected:
|
|
||||||
void write_state(bool state) override {
|
|
||||||
if (this->parent_->write_pg_gpo_level(state))
|
|
||||||
this->publish_state(state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::bq25186
|
|
||||||
|
|
||||||
#endif // USE_BQ25186_PG_GPO
|
|
||||||
@@ -3,18 +3,16 @@ from esphome.components import switch
|
|||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
|
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID
|
||||||
|
|
||||||
DEPENDENCIES = ["bq25186"]
|
DEPENDENCIES = ["bq25186"]
|
||||||
|
|
||||||
CONF_PG_GPO = "pg_gpo"
|
CONF_PG_GPO = "pg_gpo"
|
||||||
|
|
||||||
BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch)
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend(
|
CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_PG_GPO): switch.switch_schema(
|
cv.Required(CONF_PG_GPO): switch.switch_schema(
|
||||||
BQ25186PgGpoSwitch, default_restore_mode="DISABLED"
|
switch.ApplySwitch, default_restore_mode="DISABLED"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -24,4 +22,9 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.add_define("USE_BQ25186_PG_GPO")
|
cg.add_define("USE_BQ25186_PG_GPO")
|
||||||
hub = await cg.get_variable(config[CONF_BQ25186_ID])
|
hub = await cg.get_variable(config[CONF_BQ25186_ID])
|
||||||
switches = switch.sub_switches(config, parent=hub)
|
switches = switch.sub_switches(config, parent=hub)
|
||||||
await switches(CONF_PG_GPO, hub.set_pg_gpo_switch)
|
await switches(
|
||||||
|
CONF_PG_GPO,
|
||||||
|
hub.set_pg_gpo_switch,
|
||||||
|
apply="write_pg_gpo_level(state)",
|
||||||
|
publish="on_success",
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ BYTE_ORDER_BIG = "big_endian"
|
|||||||
|
|
||||||
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
|
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
|
||||||
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
|
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
|
||||||
|
CONF_ACCEPT = "accept"
|
||||||
CONF_ADC_AVERAGING = "adc_averaging"
|
CONF_ADC_AVERAGING = "adc_averaging"
|
||||||
CONF_ADC_TIME = "adc_time"
|
CONF_ADC_TIME = "adc_time"
|
||||||
CONF_ALLOWED_IPS = "allowed_ips"
|
CONF_ALLOWED_IPS = "allowed_ips"
|
||||||
@@ -58,6 +59,7 @@ CONF_MAX_PACKET_SIZE = "max_packet_size"
|
|||||||
CONF_MODEM = "modem"
|
CONF_MODEM = "modem"
|
||||||
CONF_MODULATION = "modulation"
|
CONF_MODULATION = "modulation"
|
||||||
CONF_NOX_INDEX = "nox_index"
|
CONF_NOX_INDEX = "nox_index"
|
||||||
|
CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request"
|
||||||
CONF_ON_PACKET = "on_packet"
|
CONF_ON_PACKET = "on_packet"
|
||||||
CONF_ON_RECEIVE = "on_receive"
|
CONF_ON_RECEIVE = "on_receive"
|
||||||
CONF_ON_SCAN_END = "on_scan_end"
|
CONF_ON_SCAN_END = "on_scan_end"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
|
from esphome.components import logger
|
||||||
from esphome.components.nrf52.framework import include_west_project
|
from esphome.components.nrf52.framework import include_west_project
|
||||||
from esphome.components.zephyr import zephyr_add_prj_conf
|
from esphome.components.zephyr import zephyr_add_prj_conf
|
||||||
from esphome.config_helpers import filter_source_files_from_platform
|
from esphome.config_helpers import filter_source_files_from_platform
|
||||||
@@ -9,6 +10,8 @@ from esphome.const import (
|
|||||||
CONF_FRAGMENTATION,
|
CONF_FRAGMENTATION,
|
||||||
CONF_FREE,
|
CONF_FREE,
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
|
CONF_LEVEL,
|
||||||
|
CONF_LOGGER,
|
||||||
CONF_LOOP_TIME,
|
CONF_LOOP_TIME,
|
||||||
PlatformFramework,
|
PlatformFramework,
|
||||||
)
|
)
|
||||||
@@ -62,6 +65,13 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096)
|
zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096)
|
||||||
zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True)
|
zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True)
|
||||||
zephyr_add_prj_conf("PRINTK", True)
|
zephyr_add_prj_conf("PRINTK", True)
|
||||||
|
# per-thread CPU usage, logged by update_platform_() at verbose level
|
||||||
|
level = CORE.config[CONF_LOGGER][CONF_LEVEL]
|
||||||
|
if logger.LOG_LEVEL_SEVERITY.index(level) >= logger.LOG_LEVEL_SEVERITY.index(
|
||||||
|
"VERBOSE"
|
||||||
|
):
|
||||||
|
zephyr_add_prj_conf("THREAD_RUNTIME_STATS", True)
|
||||||
|
zephyr_add_prj_conf("SCHED_THREAD_USAGE_ANALYSIS", True)
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
cg.add_define("USE_DEBUG")
|
cg.add_define("USE_DEBUG")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
#include <esphome/components/zephyr/reset_reason.h>
|
#include <esphome/components/zephyr/reset_reason.h>
|
||||||
#include <zephyr/drivers/hwinfo.h>
|
#include <zephyr/drivers/hwinfo.h>
|
||||||
|
#include <zephyr/kernel.h>
|
||||||
#include <hal/nrf_power.h>
|
#include <hal/nrf_power.h>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <zephyr/storage/flash_map.h>
|
#include <zephyr/storage/flash_map.h>
|
||||||
@@ -422,7 +423,34 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
|||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DebugComponent::update_platform_() {}
|
// The Python side enables the statistics only for a verbose logger
|
||||||
|
#ifdef CONFIG_SCHED_THREAD_USAGE_ANALYSIS
|
||||||
|
static void log_thread_usage(const struct k_thread *thread, void *user_data) {
|
||||||
|
const auto cpu_cycles = *static_cast<const uint64_t *>(user_data);
|
||||||
|
auto *tid = const_cast<k_tid_t>(thread); // NOLINT(cppcoreguidelines-pro-type-const-cast)
|
||||||
|
k_thread_runtime_stats_t stats;
|
||||||
|
if (k_thread_runtime_stats_get(tid, &stats) != 0)
|
||||||
|
return;
|
||||||
|
char state[32];
|
||||||
|
const char *name = k_thread_name_get(tid);
|
||||||
|
ESP_LOGV(TAG, " %-16s prio %3d %-10s %3u%% peak %" PRIu32 " avg %" PRIu32, name != nullptr ? name : "?",
|
||||||
|
thread->base.prio, k_thread_state_str(tid, state, sizeof(state)),
|
||||||
|
static_cast<unsigned>(stats.execution_cycles * 100 / cpu_cycles),
|
||||||
|
static_cast<uint32_t>(k_cyc_to_us_floor64(stats.peak_cycles)),
|
||||||
|
static_cast<uint32_t>(k_cyc_to_us_floor64(stats.average_cycles)));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void DebugComponent::update_platform_() {
|
||||||
|
#ifdef CONFIG_SCHED_THREAD_USAGE_ANALYSIS
|
||||||
|
k_thread_runtime_stats_t cpu;
|
||||||
|
if (k_thread_runtime_stats_all_get(&cpu) != 0 || cpu.execution_cycles == 0)
|
||||||
|
return;
|
||||||
|
ESP_LOGV(TAG, "Thread CPU usage since boot (peak and average us per time slice):");
|
||||||
|
// The unlocked walk lets the log output inside the callback block
|
||||||
|
k_thread_foreach_unlocked(log_thread_usage, &cpu.execution_cycles);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace esphome::debug
|
} // namespace esphome::debug
|
||||||
#endif
|
#endif
|
||||||
@@ -25,16 +25,86 @@ extern "C" {
|
|||||||
#include <freertos/FreeRTOSConfig.h>
|
#include <freertos/FreeRTOSConfig.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
#ifdef USE_ARDUINO
|
#ifdef USE_ARDUINO
|
||||||
// Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c).
|
// Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c).
|
||||||
// Without this, esp_bt_controller_init() fails with ESP_ERR_INVALID_STATE.
|
// Without this, esp_bt_controller_init() fails with ESP_ERR_INVALID_STATE.
|
||||||
extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-naming)
|
extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-naming)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if !defined(CONFIG_BT_CONTROLLER_DISABLED) && \
|
||||||
|
(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3))
|
||||||
|
// The ESP32-C3 and ESP32-S3 share one controller (lib_esp32c3_family, RivieraWaves link layer).
|
||||||
|
#define USE_ESP32_BLE_TERMINATE_PATCH
|
||||||
|
// Controller internals, reached through r_ip_funcs_p: a table btdm_controller_init() copies into RAM.
|
||||||
|
extern "C" {
|
||||||
|
extern void *r_ip_funcs_p;
|
||||||
|
const char *btdm_controller_get_compile_version();
|
||||||
|
uint8_t r_lld_con_terminate_max_evt_update(uint8_t link_id, uint8_t acked);
|
||||||
|
uint8_t *r_lld_con_cntl_pkt_info_get();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace esphome::esp32_ble {
|
namespace esphome::esp32_ble {
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "esp32_ble");
|
ESPHOME_LOG_TAG(TAG, "esp32_ble");
|
||||||
|
|
||||||
|
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
|
||||||
|
// The controller ends its terminate procedure after 6 connection events without an acknowledgement and reports
|
||||||
|
// the link closed. A peer using peripheral latency listens only every (latency + 1) events, so it can miss all
|
||||||
|
// six LL_TERMINATE_IND: it keeps the link and searches for the central at full receive current until its own
|
||||||
|
// supervision timeout. The Core spec keeps the procedure going until the acknowledgement or the supervision
|
||||||
|
// timeout; this does that, by holding the controller's no-acknowledgement count below its limit. A peer that
|
||||||
|
// never answers still ends the link at the supervision timeout.
|
||||||
|
static constexpr size_t TERMINATE_MAX_EVT_SLOT = 0x7dc / sizeof(void *);
|
||||||
|
static constexpr uint8_t TERMINATE_MAX_EVT = 5; // the controller gives up once its count exceeds this
|
||||||
|
|
||||||
|
static uint8_t IRAM_ATTR terminate_until_acked(uint8_t link_id, uint8_t acked) {
|
||||||
|
// Per link: [2] terminate in progress, [3] connection events without an acknowledgement.
|
||||||
|
uint8_t *info = r_lld_con_cntl_pkt_info_get() + link_id * 4;
|
||||||
|
if (!acked && info[2] != 0 && info[3] >= TERMINATE_MAX_EVT)
|
||||||
|
info[3] = TERMINATE_MAX_EVT - 1;
|
||||||
|
return r_lld_con_terminate_max_evt_update(link_id, acked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller builds whose r_lld_con_terminate_max_evt_update and r_lld_con_cntl_pkt_info_get the wrapper was
|
||||||
|
// checked against. The table slot alone does not prove the pkt_info layout, so an unknown build is left alone.
|
||||||
|
// btdm_controller_get_compile_version() is what ESP-IDF logs at init as "BT controller compile version [...]".
|
||||||
|
// Recheck on every ESP-IDF bump: add the new build once both functions are confirmed unchanged, or drop the patch once
|
||||||
|
// espressif/esp-idf#19189 is fixed.
|
||||||
|
static const char *const TERMINATE_PATCH_CONTROLLER_BUILDS[] = {
|
||||||
|
"51d9dfd", // ESP-IDF 5.5.5-5.5.6, Arduino 3.3.10-3.3.12
|
||||||
|
"b7de11e", // ESP-IDF 5.5.4, Arduino 3.3.8-3.3.9: both functions identical to 51d9dfd's
|
||||||
|
};
|
||||||
|
|
||||||
|
static void patch_terminate_until_acked() {
|
||||||
|
auto **table = static_cast<void **>(r_ip_funcs_p);
|
||||||
|
if (table == nullptr) {
|
||||||
|
ESP_LOGW(TAG, "Controller layout not recognised; disconnects keep the controller's 6-event limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const char *build = btdm_controller_get_compile_version();
|
||||||
|
bool known_build = false;
|
||||||
|
for (const char *known : TERMINATE_PATCH_CONTROLLER_BUILDS)
|
||||||
|
known_build = known_build || strcmp(build, known) == 0;
|
||||||
|
if (!known_build) {
|
||||||
|
// Expected after an ESP-IDF bump, and nothing a user can act on
|
||||||
|
ESP_LOGD(TAG,
|
||||||
|
"Controller build %s not checked for the terminate patch; disconnects keep the controller's 6-event limit",
|
||||||
|
build);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (table[TERMINATE_MAX_EVT_SLOT] == reinterpret_cast<void *>(&terminate_until_acked))
|
||||||
|
return; // already patched: the controller stayed enabled across a setup
|
||||||
|
if (table[TERMINATE_MAX_EVT_SLOT] != reinterpret_cast<void *>(&r_lld_con_terminate_max_evt_update)) {
|
||||||
|
ESP_LOGW(TAG, "Controller build %s has an unexpected terminate handler; terminate patch skipped", build);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table[TERMINATE_MAX_EVT_SLOT] = reinterpret_cast<void *>(&terminate_until_acked);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef CONFIG_BT_CONTROLLER_DISABLED
|
#ifdef CONFIG_BT_CONTROLLER_DISABLED
|
||||||
// Bringing up the remote BT controller issues synchronous RPCs to the
|
// Bringing up the remote BT controller issues synchronous RPCs to the
|
||||||
// co-processor with 5 second response timeouts, and the default task watchdog
|
// co-processor with 5 second response timeouts, and the default task watchdog
|
||||||
@@ -223,6 +293,9 @@ bool ESP32BLE::ble_setup_() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#ifdef USE_ESP32_BLE_TERMINATE_PATCH
|
||||||
|
patch_terminate_until_acked();
|
||||||
|
#endif
|
||||||
|
|
||||||
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
|
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
|
||||||
#else
|
#else
|
||||||
@@ -649,20 +722,30 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
|
|||||||
// never answer an update, and without this the link silently stays on the
|
// never answer an update, and without this the link silently stays on the
|
||||||
// old parameters (visible only as unexplained supervision timeouts).
|
// old parameters (visible only as unexplained supervision timeouts).
|
||||||
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: {
|
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: {
|
||||||
|
// A peer can keep asking for parameters it then rejects: warn once per peer, repeats are
|
||||||
|
// verbose until an update succeeds or it disconnects.
|
||||||
if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) {
|
if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) {
|
||||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
|
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
|
||||||
ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status);
|
if (global_ble->conn_param_failed_slot_(param->update_conn_params.bda) != nullptr) {
|
||||||
}
|
ESP_LOGV(TAG, "[%s] Conn param update failed again, status=%d", mac_s, param->update_conn_params.status);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status);
|
||||||
|
static constexpr esp_bd_addr_t FREE{};
|
||||||
|
uint8_t *slot = global_ble->conn_param_failed_slot_(FREE);
|
||||||
|
if (slot != nullptr)
|
||||||
|
memcpy(slot, param->update_conn_params.bda, sizeof(esp_bd_addr_t));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
global_ble->forget_conn_param_failure_(param->update_conn_params.bda);
|
||||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||||
else {
|
|
||||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
|
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
|
||||||
ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s,
|
ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s,
|
||||||
param->update_conn_params.conn_int, param->update_conn_params.latency,
|
param->update_conn_params.conn_int, param->update_conn_params.latency,
|
||||||
param->update_conn_params.timeout);
|
param->update_conn_params.timeout);
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +767,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
|
|||||||
#ifdef USE_ESP32_BLE_SERVER
|
#ifdef USE_ESP32_BLE_SERVER
|
||||||
void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if,
|
void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if,
|
||||||
esp_ble_gatts_cb_param_t *param) {
|
esp_ble_gatts_cb_param_t *param) {
|
||||||
|
if (event == ESP_GATTS_DISCONNECT_EVT)
|
||||||
|
global_ble->forget_conn_param_failure_(param->disconnect.remote_bda);
|
||||||
enqueue_ble_event(event, gatts_if, param);
|
enqueue_ble_event(event, gatts_if, param);
|
||||||
// Wake up main loop to process GATT event immediately
|
// Wake up main loop to process GATT event immediately
|
||||||
App.wake_loop_threadsafe();
|
App.wake_loop_threadsafe();
|
||||||
@@ -693,12 +778,28 @@ void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gat
|
|||||||
#ifdef USE_ESP32_BLE_CLIENT
|
#ifdef USE_ESP32_BLE_CLIENT
|
||||||
void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||||
esp_ble_gattc_cb_param_t *param) {
|
esp_ble_gattc_cb_param_t *param) {
|
||||||
|
if (event == ESP_GATTC_DISCONNECT_EVT)
|
||||||
|
global_ble->forget_conn_param_failure_(param->disconnect.remote_bda);
|
||||||
enqueue_ble_event(event, gattc_if, param);
|
enqueue_ble_event(event, gattc_if, param);
|
||||||
// Wake up main loop to process GATT event immediately
|
// Wake up main loop to process GATT event immediately
|
||||||
App.wake_loop_threadsafe();
|
App.wake_loop_threadsafe();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
uint8_t *ESP32BLE::conn_param_failed_slot_(const uint8_t *bda) {
|
||||||
|
for (auto &slot : this->conn_param_failed_bdas_) {
|
||||||
|
if (memcmp(slot, bda, sizeof(esp_bd_addr_t)) == 0)
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ESP32BLE::forget_conn_param_failure_(const uint8_t *bda) {
|
||||||
|
uint8_t *slot = this->conn_param_failed_slot_(bda);
|
||||||
|
if (slot != nullptr)
|
||||||
|
memset(slot, 0, sizeof(esp_bd_addr_t));
|
||||||
|
}
|
||||||
|
|
||||||
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
||||||
// The running stack owns the address (on hosted controllers it lives in
|
// The running stack owns the address (on hosted controllers it lives in
|
||||||
// the remote chip's efuse); null before init becomes all-zero.
|
// the remote chip's efuse); null before init becomes all-zero.
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ class ESP32BLE final : public Component {
|
|||||||
// Other non-ACTIVE states (e.g. OFF, DISABLED) are currently treated as no-ops.
|
// Other non-ACTIVE states (e.g. OFF, DISABLED) are currently treated as no-ops.
|
||||||
void __attribute__((noinline)) loop_handle_state_transition_not_active_();
|
void __attribute__((noinline)) loop_handle_state_transition_not_active_();
|
||||||
|
|
||||||
|
uint8_t *conn_param_failed_slot_(const uint8_t *bda);
|
||||||
|
void forget_conn_param_failure_(const uint8_t *bda);
|
||||||
|
|
||||||
bool ble_setup_();
|
bool ble_setup_();
|
||||||
bool ble_dismantle_();
|
bool ble_dismantle_();
|
||||||
void request_state_(bool enable);
|
void request_state_(bool enable);
|
||||||
@@ -244,6 +247,8 @@ class ESP32BLE final : public Component {
|
|||||||
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef)
|
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef)
|
||||||
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
||||||
bool enable_on_boot_{}; // 1 byte
|
bool enable_on_boot_{}; // 1 byte
|
||||||
|
// Peers already warned about a failed conn param update, one per connection; all-zero is free
|
||||||
|
esp_bd_addr_t conn_param_failed_bdas_[USE_ESP32_BLE_MAX_CONNECTIONS]{};
|
||||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||||
uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising
|
uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from esphome.components.network import (
|
|||||||
add_use_address,
|
add_use_address,
|
||||||
get_network_priority,
|
get_network_priority,
|
||||||
get_priority_interfaces_from_full_config,
|
get_priority_interfaces_from_full_config,
|
||||||
ip_address_literal,
|
|
||||||
require_ipv4,
|
require_ipv4,
|
||||||
)
|
)
|
||||||
from esphome.config_helpers import (
|
from esphome.config_helpers import (
|
||||||
@@ -239,7 +238,6 @@ MANUAL_IP_SCHEMA = cv.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component)
|
EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component)
|
||||||
ManualIP = ethernet_ns.struct("ManualIP")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_framework_spi_polling_mode_supported() -> bool:
|
def _is_framework_spi_polling_mode_supported() -> bool:
|
||||||
@@ -625,15 +623,10 @@ def _final_validate_spi(config: ConfigType) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def manual_ip(config: ConfigType) -> cg.StructInitializer:
|
def _manual_ip_table(config: ConfigType) -> list[int]:
|
||||||
return cg.StructInitializer(
|
"""Pack manual_ip in the ETHERNET_MANUAL_IP layout."""
|
||||||
ManualIP,
|
keys = (CONF_STATIC_IP, CONF_GATEWAY, CONF_SUBNET, CONF_DNS1, CONF_DNS2)
|
||||||
("static_ip", ip_address_literal(config[CONF_STATIC_IP])),
|
return [octet for key in keys for octet in config[key].packed]
|
||||||
("gateway", ip_address_literal(config[CONF_GATEWAY])),
|
|
||||||
("subnet", ip_address_literal(config[CONF_SUBNET])),
|
|
||||||
("dns1", ip_address_literal(config[CONF_DNS1])),
|
|
||||||
("dns2", ip_address_literal(config[CONF_DNS2])),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def phy_register(address: int, value: int, page: int) -> cg.StructInitializer:
|
def phy_register(address: int, value: int, page: int) -> cg.StructInitializer:
|
||||||
@@ -685,7 +678,11 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
|
|
||||||
if CONF_MANUAL_IP in config:
|
if CONF_MANUAL_IP in config:
|
||||||
cg.add_define("USE_ETHERNET_MANUAL_IP")
|
cg.add_define("USE_ETHERNET_MANUAL_IP")
|
||||||
cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP])))
|
cg.extern_progmem_array(
|
||||||
|
"esphome::ethernet::ETHERNET_MANUAL_IP",
|
||||||
|
cg.uint8,
|
||||||
|
_manual_ip_table(config[CONF_MANUAL_IP]),
|
||||||
|
)
|
||||||
|
|
||||||
# Add compile-time define for PHY types with specific code
|
# Add compile-time define for PHY types with specific code
|
||||||
if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]):
|
if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]):
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non-
|
|||||||
|
|
||||||
EthernetComponent::EthernetComponent() { global_eth_component = this; }
|
EthernetComponent::EthernetComponent() { global_eth_component = this; }
|
||||||
|
|
||||||
|
#ifdef USE_ETHERNET_MANUAL_IP
|
||||||
|
ManualIP load_manual_ip() {
|
||||||
|
uint8_t b[ETHERNET_MANUAL_IP_SIZE];
|
||||||
|
progmem_memcpy(b, ETHERNET_MANUAL_IP, sizeof(b));
|
||||||
|
auto ip = [&b](size_t i) { return network::IPAddress(b[i], b[i + 1], b[i + 2], b[i + 3]); };
|
||||||
|
return ManualIP{ip(0), ip(4), ip(8), ip(12), ip(16)};
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
|
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
|
||||||
void EthernetComponent::notify_ip_state_listeners_() {
|
void EthernetComponent::notify_ip_state_listeners_() {
|
||||||
auto ips = this->get_ip_addresses();
|
auto ips = this->get_ip_addresses();
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ struct ManualIP {
|
|||||||
network::IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default.
|
network::IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#ifdef USE_ETHERNET_MANUAL_IP
|
||||||
|
/// The configured static IP, gateway, subnet, dns1 and dns2 as 4 octets each, generated by codegen
|
||||||
|
/// (components/ethernet/__init__.py) and kept in flash.
|
||||||
|
static constexpr size_t ETHERNET_MANUAL_IP_SIZE = 5 * 4;
|
||||||
|
extern const uint8_t ETHERNET_MANUAL_IP[ETHERNET_MANUAL_IP_SIZE] PROGMEM;
|
||||||
|
ManualIP load_manual_ip();
|
||||||
|
#endif
|
||||||
|
|
||||||
struct PHYRegister {
|
struct PHYRegister {
|
||||||
uint32_t address;
|
uint32_t address;
|
||||||
uint32_t value;
|
uint32_t value;
|
||||||
@@ -151,9 +159,6 @@ class EthernetComponent final : public Component {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
void set_type(EthernetType type) { this->type_ = type; }
|
void set_type(EthernetType type) { this->type_ = type; }
|
||||||
#ifdef USE_ETHERNET_MANUAL_IP
|
|
||||||
void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; }
|
|
||||||
#endif
|
|
||||||
void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; }
|
void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; }
|
||||||
|
|
||||||
network::IPAddresses get_ip_addresses();
|
network::IPAddresses get_ip_addresses();
|
||||||
@@ -333,9 +338,6 @@ class EthernetComponent final : public Component {
|
|||||||
#endif // USE_RP2
|
#endif // USE_RP2
|
||||||
|
|
||||||
// Common members
|
// Common members
|
||||||
#ifdef USE_ETHERNET_MANUAL_IP
|
|
||||||
optional<ManualIP> manual_ip_{};
|
|
||||||
#endif
|
|
||||||
uint32_t connect_begin_;
|
uint32_t connect_begin_;
|
||||||
|
|
||||||
// Group all uint8_t types together (enums and bools)
|
// Group all uint8_t types together (enums and bools)
|
||||||
|
|||||||
@@ -763,9 +763,7 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
|
|||||||
event_name = "ETH connected";
|
event_name = "ETH connected";
|
||||||
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
|
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
|
||||||
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
|
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
|
||||||
if (global_eth_component->manual_ip_.has_value()) {
|
global_eth_component->notify_ip_state_listeners_();
|
||||||
global_eth_component->notify_ip_state_listeners_();
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#if USE_NETWORK_IPV6
|
#if USE_NETWORK_IPV6
|
||||||
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
|
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
|
||||||
@@ -889,19 +887,13 @@ void EthernetComponent::start_connect_() {
|
|||||||
ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err));
|
ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_netif_ip_info_t info;
|
esp_netif_ip_info_t info{};
|
||||||
#ifdef USE_ETHERNET_MANUAL_IP
|
#ifdef USE_ETHERNET_MANUAL_IP
|
||||||
if (this->manual_ip_.has_value()) {
|
const ManualIP manual_ip = load_manual_ip();
|
||||||
info.ip = this->manual_ip_->static_ip;
|
info.ip = manual_ip.static_ip;
|
||||||
info.gw = this->manual_ip_->gateway;
|
info.gw = manual_ip.gateway;
|
||||||
info.netmask = this->manual_ip_->subnet;
|
info.netmask = manual_ip.subnet;
|
||||||
} else
|
|
||||||
#endif
|
#endif
|
||||||
{
|
|
||||||
info.ip.addr = 0;
|
|
||||||
info.gw.addr = 0;
|
|
||||||
info.netmask.addr = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT;
|
esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT;
|
||||||
|
|
||||||
@@ -919,35 +911,32 @@ void EthernetComponent::start_connect_() {
|
|||||||
ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
|
ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
|
||||||
|
|
||||||
#ifdef USE_ETHERNET_MANUAL_IP
|
#ifdef USE_ETHERNET_MANUAL_IP
|
||||||
if (this->manual_ip_.has_value()) {
|
// Set DNS through esp_netif so the servers are stored in the netif's own
|
||||||
// Set DNS through esp_netif so the servers are stored in the netif's own
|
// dns[] array; raw dns_setserver() would be lost when the default-route
|
||||||
// dns[] array; raw dns_setserver() would be lost when the default-route
|
// arbitration re-applies the default netif's DNS.
|
||||||
// arbitration re-applies the default netif's DNS.
|
// Log-only on failure: the link still has a working IP/gateway, so degraded
|
||||||
// Log-only on failure: the link still has a working IP/gateway, so degraded
|
// name resolution does not justify marking the whole component failed.
|
||||||
// name resolution does not justify marking the whole component failed.
|
esp_netif_dns_info_t dns{};
|
||||||
esp_netif_dns_info_t dns{};
|
if (manual_ip.dns1.is_set()) {
|
||||||
if (this->manual_ip_->dns1.is_set()) {
|
dns.ip = manual_ip.dns1;
|
||||||
dns.ip = this->manual_ip_->dns1;
|
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns);
|
||||||
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns);
|
if (err != ESP_OK) {
|
||||||
if (err != ESP_OK) {
|
ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err));
|
||||||
ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this->manual_ip_->dns2.is_set()) {
|
|
||||||
dns.ip = this->manual_ip_->dns2;
|
|
||||||
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns);
|
|
||||||
if (err != ESP_OK) {
|
|
||||||
ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
#endif
|
|
||||||
{
|
|
||||||
err = esp_netif_dhcpc_start(this->eth_netif_);
|
|
||||||
if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) {
|
|
||||||
ESPHL_ERROR_CHECK(err, "DHCPC start error");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (manual_ip.dns2.is_set()) {
|
||||||
|
dns.ip = manual_ip.dns2;
|
||||||
|
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
err = esp_netif_dhcpc_start(this->eth_netif_);
|
||||||
|
if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) {
|
||||||
|
ESPHL_ERROR_CHECK(err, "DHCPC start error");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if USE_NETWORK_IPV6
|
#if USE_NETWORK_IPV6
|
||||||
// Attempt to create IPv6 link-local address
|
// Attempt to create IPv6 link-local address
|
||||||
// We MUST attempt this here, not just in finish_connect_(), because with
|
// We MUST attempt this here, not just in finish_connect_(), because with
|
||||||
|
|||||||
@@ -56,15 +56,12 @@ void EthernetComponent::setup() {
|
|||||||
// Set hostname before begin() so the LWIP netif gets it
|
// Set hostname before begin() so the LWIP netif gets it
|
||||||
this->eth_->hostname(App.get_name().c_str());
|
this->eth_->hostname(App.get_name().c_str());
|
||||||
|
|
||||||
// Configure static IP if set (must be done before begin())
|
// Configure static IP (must be done before begin())
|
||||||
#ifdef USE_ETHERNET_MANUAL_IP
|
#ifdef USE_ETHERNET_MANUAL_IP
|
||||||
if (this->manual_ip_.has_value()) {
|
{
|
||||||
IPAddress ip(this->manual_ip_->static_ip);
|
const ManualIP manual_ip = load_manual_ip();
|
||||||
IPAddress gateway(this->manual_ip_->gateway);
|
this->eth_->config(IPAddress(manual_ip.static_ip), IPAddress(manual_ip.gateway), IPAddress(manual_ip.subnet),
|
||||||
IPAddress subnet(this->manual_ip_->subnet);
|
IPAddress(manual_ip.dns1), IPAddress(manual_ip.dns2));
|
||||||
IPAddress dns1(this->manual_ip_->dns1);
|
|
||||||
IPAddress dns2(this->manual_ip_->dns2);
|
|
||||||
this->eth_->config(ip, gateway, subnet, dns1, dns2);
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -290,20 +287,17 @@ void EthernetComponent::start_connect_() {
|
|||||||
// Hostname is already set in setup() via LwipIntf::setHostname()
|
// Hostname is already set in setup() via LwipIntf::setHostname()
|
||||||
|
|
||||||
#ifdef USE_ETHERNET_MANUAL_IP
|
#ifdef USE_ETHERNET_MANUAL_IP
|
||||||
if (this->manual_ip_.has_value()) {
|
// Static IP was already configured before begin() in setup()
|
||||||
// Static IP was already configured before begin() in setup()
|
// Set DNS servers
|
||||||
// Set DNS servers
|
const ManualIP manual_ip = load_manual_ip();
|
||||||
LwIPLock lock;
|
LwIPLock lock;
|
||||||
if (this->manual_ip_->dns1.is_set()) {
|
if (manual_ip.dns1.is_set()) {
|
||||||
ip_addr_t d;
|
ip_addr_t d = manual_ip.dns1;
|
||||||
d = this->manual_ip_->dns1;
|
dns_setserver(0, &d);
|
||||||
dns_setserver(0, &d);
|
}
|
||||||
}
|
if (manual_ip.dns2.is_set()) {
|
||||||
if (this->manual_ip_->dns2.is_set()) {
|
ip_addr_t d = manual_ip.dns2;
|
||||||
ip_addr_t d;
|
dns_setserver(1, &d);
|
||||||
d = this->manual_ip_->dns2;
|
|
||||||
dns_setserver(1, &d);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import climate_ir
|
from esphome.components import climate_ir, remote_base
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
|
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
@@ -13,7 +13,16 @@ GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR)
|
|||||||
|
|
||||||
Model = gree_ns.enum("Model")
|
Model = gree_ns.enum("Model")
|
||||||
MODELS = Model.options(
|
MODELS = Model.options(
|
||||||
"generic", "yan", "yaa", "yac", "yac1fb9", "yx1ff", "yag", "yaw1f", prefix="GREE_"
|
"generic",
|
||||||
|
"yan",
|
||||||
|
"yaa",
|
||||||
|
"yac",
|
||||||
|
"yac1fb9",
|
||||||
|
"yb1fa",
|
||||||
|
"yx1ff",
|
||||||
|
"yag",
|
||||||
|
"yaw1f",
|
||||||
|
prefix="GREE_",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
|
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
|
||||||
@@ -32,3 +41,5 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.add(var.set_use_fahrenheit(True))
|
cg.add(var.set_use_fahrenheit(True))
|
||||||
if config[CONF_MODEL] == "yaw1f":
|
if config[CONF_MODEL] == "yaw1f":
|
||||||
cg.add_define("USE_GREE_YAW1F")
|
cg.add_define("USE_GREE_YAW1F")
|
||||||
|
if remote_base.CONF_RECEIVER_ID in config:
|
||||||
|
cg.add_define("USE_GREE_RECEIVER")
|
||||||
+537
-190
@@ -4,8 +4,6 @@
|
|||||||
#include "esphome/components/remote_base/remote_base.h"
|
#include "esphome/components/remote_base/remote_base.h"
|
||||||
#include "esphome/core/helpers.h"
|
#include "esphome/core/helpers.h"
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
namespace esphome::gree {
|
namespace esphome::gree {
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "gree.climate");
|
ESPHOME_LOG_TAG(TAG, "gree.climate");
|
||||||
@@ -13,27 +11,25 @@ ESPHOME_LOG_TAG(TAG, "gree.climate");
|
|||||||
static constexpr remote_base::BitTimings GREE_BIT_TIMINGS{
|
static constexpr remote_base::BitTimings GREE_BIT_TIMINGS{
|
||||||
.bit_mark = GREE_BIT_MARK, .one_space = GREE_ONE_SPACE, .zero_space = GREE_ZERO_SPACE};
|
.bit_mark = GREE_BIT_MARK, .one_space = GREE_ONE_SPACE, .zero_space = GREE_ZERO_SPACE};
|
||||||
|
|
||||||
void GreeClimate::set_model(Model model) {
|
static constexpr uint8_t GREE_SWING_AUTO_MASK = 0x40;
|
||||||
if (model == GREE_YAN || model == GREE_YX1FF) {
|
#ifdef USE_GREE_YAW1F
|
||||||
// These models only expose vertical swing; horizontal swing is not encoded on the wire.
|
// The YAW1F second message is 00 00 00 A0 | 00 00 00 A0.
|
||||||
this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
|
static constexpr uint8_t GREE_YAW1F_FOLLOWUP_BYTE = 0xA0;
|
||||||
this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
|
#endif
|
||||||
}
|
|
||||||
if (model == GREE_YX1FF) {
|
|
||||||
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed
|
|
||||||
}
|
|
||||||
// Models with a sleep preset
|
|
||||||
if (model == GREE_YX1FF || model == GREE_YAC1FB9) {
|
|
||||||
this->presets_.insert(climate::CLIMATE_PRESET_NONE);
|
|
||||||
this->presets_.insert(climate::CLIMATE_PRESET_SLEEP);
|
|
||||||
}
|
|
||||||
|
|
||||||
this->model_ = model;
|
// The mode, fan and feature mappings use if-else: a switch becomes a lookup table in RAM on ESP8266.
|
||||||
|
static constexpr uint8_t supported_feature_mask(Model model) {
|
||||||
|
if (model == GREE_YB1FA)
|
||||||
|
return GREE_MODE_BIT_TURBO | GREE_MODE_BIT_LIGHT | GREE_MODE_BIT_XFAN;
|
||||||
|
if (model == GREE_YX1FF)
|
||||||
|
return GREE_MODE_BIT_LIGHT;
|
||||||
|
if (model == GREE_GENERIC || model == GREE_YAG || model == GREE_YAW1F)
|
||||||
|
return 0;
|
||||||
|
return GREE_MODE_BIT_TURBO | GREE_MODE_BIT_LIGHT | GREE_MODE_BIT_HEALTH | GREE_MODE_BIT_XFAN;
|
||||||
}
|
}
|
||||||
|
|
||||||
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
|
static constexpr uint8_t default_feature_bits(Model model) {
|
||||||
assign_bits(this->mode_bits_, bit_mask, enabled);
|
return supported_feature_mask(model) & GREE_MODE_BIT_LIGHT;
|
||||||
this->transmit_state();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_GREE_FAHRENHEIT
|
#ifdef USE_GREE_FAHRENHEIT
|
||||||
@@ -51,190 +47,292 @@ uint8_t fahrenheit_half_steps(float target_celsius) {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
void GreeClimate::transmit_state() {
|
struct GreeFrameSpaces {
|
||||||
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
|
uint32_t header;
|
||||||
const Model model = this->model_;
|
uint32_t message;
|
||||||
// YAW1F uses the YAG byte layout with different framing (see below)
|
};
|
||||||
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
|
|
||||||
|
|
||||||
remote_state[0] = this->fan_speed_() | this->operation_mode_();
|
static GreeFrameSpaces frame_spaces(Model model) {
|
||||||
#ifdef USE_GREE_FAHRENHEIT
|
if (model == GREE_YAC1FB9 || model == GREE_YAW1F)
|
||||||
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
|
return {GREE_YAC1FB9_HEADER_SPACE, GREE_YAC1FB9_MESSAGE_SPACE};
|
||||||
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
|
return {GREE_HEADER_SPACE, GREE_MESSAGE_SPACE};
|
||||||
#else
|
}
|
||||||
remote_state[1] = this->temperature_();
|
|
||||||
|
static void write_frame(remote_base::RemoteTransmitData *data, const GreeState &frame, const GreeFrameSpaces &spaces) {
|
||||||
|
data->item(GREE_HEADER_MARK, spaces.header);
|
||||||
|
data->item_bytes<GREE_BIT_TIMINGS>(frame.data(), 4);
|
||||||
|
// Fixed 010 between the two halves, then the message gap
|
||||||
|
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
|
||||||
|
data->item(GREE_BIT_MARK, GREE_ONE_SPACE);
|
||||||
|
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
|
||||||
|
data->item(GREE_BIT_MARK, spaces.message);
|
||||||
|
data->item_bytes<GREE_BIT_TIMINGS>(frame.data() + 4, 4);
|
||||||
|
data->mark(GREE_BIT_MARK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
static constexpr uint8_t GREE_MODE_MASK = 0x07;
|
||||||
|
static constexpr uint8_t GREE_POWER_MASK = 0x08;
|
||||||
|
static constexpr uint8_t GREE_FAN_MASK = 0x30;
|
||||||
|
static constexpr uint8_t GREE_SLEEP_MASK = 0x80;
|
||||||
|
static constexpr uint8_t GREE_TEMP_MASK = 0x0F;
|
||||||
|
static constexpr uint8_t GREE_TIMER_HALF_HOUR_MASK = 0x10;
|
||||||
|
static constexpr uint8_t GREE_TIMER_TENS_HOUR_MASK = 0x60;
|
||||||
|
static constexpr uint8_t GREE_TIMER_HOURS_MASK = 0x0F;
|
||||||
|
static constexpr uint8_t GREE_BYTE5_FIXED_MASK = 0xB8;
|
||||||
|
static constexpr uint8_t GREE_BYTE5_FIXED_VALUE = 0x20;
|
||||||
|
|
||||||
|
static bool read_frame(remote_base::RemoteReceiveData &data, GreeState &frame, const GreeFrameSpaces &spaces) {
|
||||||
|
return data.expect_item(GREE_HEADER_MARK, spaces.header) && data.expect_bytes<GREE_BIT_TIMINGS>(frame.data(), 4) &&
|
||||||
|
data.expect_item(GREE_BIT_MARK, GREE_ZERO_SPACE) && data.expect_item(GREE_BIT_MARK, GREE_ONE_SPACE) &&
|
||||||
|
data.expect_item(GREE_BIT_MARK, GREE_ZERO_SPACE) && data.expect_item(GREE_BIT_MARK, spaces.message) &&
|
||||||
|
data.expect_bytes<GREE_BIT_TIMINGS>(frame.data() + 4, 4) && data.expect_mark(GREE_BIT_MARK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef USE_GREE_YAW1F
|
||||||
|
static bool is_yaw1f_followup(const GreeState &state) {
|
||||||
|
return state[3] == GREE_YAW1F_FOLLOWUP_BYTE && state[7] == GREE_YAW1F_FOLLOWUP_BYTE &&
|
||||||
|
(state[0] | state[1] | state[2] | state[4] | state[5] | state[6]) == 0;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (model == GREE_YAN) {
|
// YAC sends a 0x70 follow-up frame and YAW1F a fixed second message; neither carries climate state.
|
||||||
remote_state[3] = 0x50; // bits 4..7 always 0101
|
static bool is_auxiliary_frame(Model model, const GreeState &state) {
|
||||||
remote_state[4] = this->vertical_swing_();
|
#ifdef USE_GREE_YAW1F
|
||||||
|
if (model == GREE_YAW1F && is_yaw1f_followup(state))
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
return model == GREE_YAC && (state[3] & 0xF0) == 0x70;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool is_valid_timer(const GreeState &state) {
|
||||||
|
const uint8_t timer_hours = state[2] & GREE_TIMER_HOURS_MASK;
|
||||||
|
const uint8_t timer_tens_hours = (state[1] & GREE_TIMER_TENS_HOUR_MASK) >> 5;
|
||||||
|
const bool timer_half_hour = state[1] & GREE_TIMER_HALF_HOUR_MASK;
|
||||||
|
|
||||||
|
if (timer_hours > 9 || timer_tens_hours > 2)
|
||||||
|
return false;
|
||||||
|
if (timer_tens_hours < 2)
|
||||||
|
return true;
|
||||||
|
return timer_hours < 4 || (timer_hours == 4 && !timer_half_hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool is_valid_vertical_swing(bool automatic, uint8_t position) {
|
||||||
|
if (automatic) {
|
||||||
|
return position == GREE_VDIR_SWING || position == GREE_VDIR_SWING_DOWN || position == GREE_VDIR_SWING_MIDDLE ||
|
||||||
|
position == GREE_VDIR_SWING_UP;
|
||||||
}
|
}
|
||||||
|
return position == GREE_VDIR_MANUAL || (position >= GREE_VDIR_UP && position <= GREE_VDIR_DOWN);
|
||||||
|
}
|
||||||
|
|
||||||
if (model == GREE_YX1FF || yag_layout) {
|
static bool is_valid_swing(Model model, const GreeState &state) {
|
||||||
remote_state[2] = 0x60;
|
const bool automatic = state[0] & GREE_SWING_AUTO_MASK;
|
||||||
remote_state[3] = 0x50;
|
const uint8_t vertical = state[4] & 0x0F;
|
||||||
remote_state[4] = this->vertical_swing_();
|
const uint8_t horizontal = state[4] >> 4;
|
||||||
|
|
||||||
if (model == GREE_YX1FF) {
|
if (!is_valid_vertical_swing(automatic, vertical))
|
||||||
// ModelA is set only when powered on.
|
return false;
|
||||||
if (this->mode == climate::CLIMATE_MODE_OFF)
|
if (model == GREE_YX1FF)
|
||||||
remote_state[2] &= ~0x40;
|
return horizontal == (automatic ? GREE_HDIR_SWING : GREE_HDIR_MANUAL);
|
||||||
// YX1FF needs the swing flag as well as its own vane-position value
|
return horizontal == GREE_HDIR_MANUAL;
|
||||||
if (this->vertical_swing_() == GREE_VDIR_SWING) {
|
}
|
||||||
remote_state[0] |= (1 << 6);
|
|
||||||
remote_state[4] = GREE_YX1FF_VANE_SWING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (yag_layout) {
|
static climate::ClimateMode decode_operation_mode(uint8_t mode) {
|
||||||
remote_state[5] = model == GREE_YAW1F ? 0x80 : 0x40;
|
if (mode == GREE_MODE_COOL)
|
||||||
|
return climate::CLIMATE_MODE_COOL;
|
||||||
if (this->vertical_swing_() == GREE_VDIR_SWING || this->horizontal_swing_() == GREE_HDIR_SWING) {
|
if (mode == GREE_MODE_DRY)
|
||||||
remote_state[0] |= (1 << 6);
|
return climate::CLIMATE_MODE_DRY;
|
||||||
}
|
if (mode == GREE_MODE_FAN)
|
||||||
}
|
return climate::CLIMATE_MODE_FAN_ONLY;
|
||||||
|
if (mode == GREE_MODE_HEAT)
|
||||||
if (model == GREE_YAC)
|
return climate::CLIMATE_MODE_HEAT;
|
||||||
remote_state[4] = this->vertical_swing_();
|
return climate::CLIMATE_MODE_HEAT_COOL;
|
||||||
|
}
|
||||||
if (model == GREE_YAC || yag_layout) {
|
|
||||||
remote_state[4] |= (this->horizontal_swing_() << 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
|
|
||||||
remote_state[3] = 0x50; // bits 4..7 always 0101
|
|
||||||
remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010
|
|
||||||
|
|
||||||
if (this->vertical_swing_() == GREE_VDIR_SWING) {
|
|
||||||
remote_state[0] |= (1 << 6); // Enable swing by setting bit 6
|
|
||||||
} else if (this->vertical_swing_() != GREE_VDIR_AUTO) {
|
|
||||||
remote_state[5] = this->vertical_swing_();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model == GREE_YAN || model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
|
|
||||||
// Merge the mode bits into remote_state[2]
|
|
||||||
// Clear the mode bits (bits 4-7) and OR in the current mode_bits_
|
|
||||||
remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Use if-else rather than lookup tables to avoid RAM usage on ESP8266.
|
||||||
|
static climate::ClimateFanMode decode_fan_mode(Model model, uint8_t fan) {
|
||||||
|
if (fan == GREE_FAN_AUTO)
|
||||||
|
return climate::CLIMATE_FAN_AUTO;
|
||||||
if (model == GREE_YX1FF) {
|
if (model == GREE_YX1FF) {
|
||||||
if (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO) == climate::CLIMATE_FAN_HIGH) {
|
if (fan == GREE_FAN_1)
|
||||||
remote_state[2] |= GREE_FAN_TURBO_BIT;
|
return climate::CLIMATE_FAN_QUIET;
|
||||||
}
|
if (fan == GREE_FAN_2)
|
||||||
|
return climate::CLIMATE_FAN_LOW;
|
||||||
|
return climate::CLIMATE_FAN_MEDIUM;
|
||||||
}
|
}
|
||||||
|
if (fan == GREE_FAN_1)
|
||||||
|
return climate::CLIMATE_FAN_LOW;
|
||||||
|
if (fan == GREE_FAN_2)
|
||||||
|
return climate::CLIMATE_FAN_MEDIUM;
|
||||||
|
return climate::CLIMATE_FAN_HIGH;
|
||||||
|
}
|
||||||
|
#endif // USE_GREE_RECEIVER
|
||||||
|
|
||||||
if (this->preset_() == GREE_PRESET_SLEEP) {
|
void GreeProtocol::encode(remote_base::RemoteTransmitData *data, const GreeState &state) const {
|
||||||
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
|
const GreeFrameSpaces spaces = frame_spaces(this->model_);
|
||||||
}
|
data->reserve(this->model_ == GREE_YAW1F ? 280 : 140);
|
||||||
|
|
||||||
#ifdef USE_GREE_FAHRENHEIT
|
|
||||||
if (this->use_fahrenheit_) {
|
|
||||||
remote_state[3] |= GREE_FAHRENHEIT_BIT;
|
|
||||||
if (half_steps & 1)
|
|
||||||
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
remote_state[7] = frame_checksum(remote_state);
|
|
||||||
|
|
||||||
auto transmit = this->transmitter_->transmit();
|
|
||||||
auto *data = transmit.get_data();
|
|
||||||
data->set_carrier_frequency(GREE_IR_FREQUENCY);
|
data->set_carrier_frequency(GREE_IR_FREQUENCY);
|
||||||
|
|
||||||
const bool long_gaps = model == GREE_YAC1FB9 || model == GREE_YAW1F;
|
write_frame(data, state, spaces);
|
||||||
const uint32_t header_space = long_gaps ? GREE_YAC1FB9_HEADER_SPACE : GREE_HEADER_SPACE;
|
|
||||||
const uint32_t message_space = long_gaps ? GREE_YAC1FB9_MESSAGE_SPACE : GREE_MESSAGE_SPACE;
|
|
||||||
auto encode = [&](const uint8_t *state) {
|
|
||||||
data->item(GREE_HEADER_MARK, header_space);
|
|
||||||
data->item_bytes<GREE_BIT_TIMINGS>(state, 4);
|
|
||||||
// Fixed 010 between the two halves, then the message gap
|
|
||||||
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
|
|
||||||
data->item(GREE_BIT_MARK, GREE_ONE_SPACE);
|
|
||||||
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
|
|
||||||
data->item(GREE_BIT_MARK, message_space);
|
|
||||||
data->item_bytes<GREE_BIT_TIMINGS>(state + 4, 4);
|
|
||||||
data->mark(GREE_BIT_MARK);
|
|
||||||
};
|
|
||||||
|
|
||||||
encode(remote_state);
|
|
||||||
#ifdef USE_GREE_YAW1F
|
#ifdef USE_GREE_YAW1F
|
||||||
if (model == GREE_YAW1F) {
|
if (this->model_ == GREE_YAW1F) {
|
||||||
// The YAW1F remote follows every command with a second message, 00 00 00 A0 | 00 00 00 A0.
|
// The YAW1F remote follows every command with a fixed second message.
|
||||||
// The command buffer is reused so the payload takes no RAM on ESP8266.
|
GreeState followup{};
|
||||||
std::fill_n(remote_state, sizeof(remote_state), 0);
|
followup[3] = followup[7] = GREE_YAW1F_FOLLOWUP_BYTE;
|
||||||
remote_state[3] = remote_state[7] = 0xA0;
|
|
||||||
data->space(GREE_YAW1F_FOLLOWUP_SPACE);
|
data->space(GREE_YAW1F_FOLLOWUP_SPACE);
|
||||||
encode(remote_state);
|
write_frame(data, followup, spaces);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
data->space(0);
|
data->space(0);
|
||||||
|
|
||||||
transmit.perform();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t GreeClimate::operation_mode_() {
|
#ifdef USE_GREE_RECEIVER
|
||||||
uint8_t operating_mode = GREE_MODE_ON;
|
optional<GreeState> GreeProtocol::decode(remote_base::RemoteReceiveData data) const {
|
||||||
|
const GreeFrameSpaces spaces = frame_spaces(this->model_);
|
||||||
|
GreeState state{};
|
||||||
|
if (!read_frame(data, state, spaces) || !GreeProtocol::valid_checksum(state))
|
||||||
|
return {};
|
||||||
|
|
||||||
switch (this->mode) {
|
#ifdef USE_GREE_YAW1F
|
||||||
case climate::CLIMATE_MODE_COOL:
|
// Depending on receiver idle settings, YAW1F's ~40 ms second message may arrive in
|
||||||
operating_mode |= GREE_MODE_COOL;
|
// the same capture. Validate it completely; never treat it as climate state.
|
||||||
break;
|
if (this->model_ == GREE_YAW1F && data.is_valid(1)) {
|
||||||
case climate::CLIMATE_MODE_DRY:
|
GreeState followup{};
|
||||||
operating_mode |= GREE_MODE_DRY;
|
if (!data.expect_space(GREE_YAW1F_FOLLOWUP_SPACE) || !read_frame(data, followup, spaces) ||
|
||||||
break;
|
!is_yaw1f_followup(followup)) {
|
||||||
case climate::CLIMATE_MODE_HEAT:
|
ESP_LOGV(TAG, "YAW1F frame dropped: second message invalid");
|
||||||
operating_mode |= GREE_MODE_HEAT;
|
return {};
|
||||||
break;
|
}
|
||||||
case climate::CLIMATE_MODE_HEAT_COOL:
|
}
|
||||||
operating_mode |= GREE_MODE_AUTO;
|
#endif
|
||||||
break;
|
|
||||||
case climate::CLIMATE_MODE_FAN_ONLY:
|
// A receiver normally appends one final idle space. Reject any extra pulses.
|
||||||
operating_mode |= GREE_MODE_FAN;
|
if (data.is_valid(1) || (data.is_valid() && data.peek() > 0)) {
|
||||||
break;
|
ESP_LOGV(TAG, "Frame dropped: extra pulses follow it (receiver idle may be too long)");
|
||||||
case climate::CLIMATE_MODE_OFF:
|
return {};
|
||||||
default:
|
|
||||||
operating_mode = GREE_MODE_OFF;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return operating_mode;
|
return state;
|
||||||
|
}
|
||||||
|
#endif // USE_GREE_RECEIVER
|
||||||
|
|
||||||
|
uint8_t GreeProtocol::calculate_checksum(const GreeState &state) {
|
||||||
|
uint8_t sum = 0x0A;
|
||||||
|
for (uint8_t pos = 0; pos < 4; pos++)
|
||||||
|
sum += state[pos] & 0x0F;
|
||||||
|
for (uint8_t pos = 4; pos < GREE_STATE_FRAME_SIZE - 1; pos++)
|
||||||
|
sum += state[pos] >> 4;
|
||||||
|
return (sum & 0x0F) << 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t GreeClimate::fan_speed_() {
|
#ifdef USE_GREE_RECEIVER
|
||||||
// YX1FF has 4 fan speeds -- we treat low as quiet and turbo as high
|
bool GreeProtocol::valid_checksum(const GreeState &state) {
|
||||||
if (this->model_ == GREE_YX1FF) {
|
return (state[GREE_STATE_FRAME_SIZE - 1] & 0xF0) == GreeProtocol::calculate_checksum(state);
|
||||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
|
}
|
||||||
case climate::CLIMATE_FAN_QUIET:
|
#endif
|
||||||
return GREE_FAN_1;
|
|
||||||
case climate::CLIMATE_FAN_LOW:
|
GreeState GreeClimateCodec::encode(Model model, const GreeClimateData &data) {
|
||||||
return GREE_FAN_2;
|
GreeState state{0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
|
||||||
case climate::CLIMATE_FAN_MEDIUM:
|
const uint8_t vertical_swing = GreeClimateCodec::encode_vertical_swing(data.swing_mode);
|
||||||
case climate::CLIMATE_FAN_HIGH:
|
const uint8_t horizontal_swing = GreeClimateCodec::encode_horizontal_swing(data.swing_mode);
|
||||||
// HIGH additionally sets the separate Turbo bit in byte 2.
|
|
||||||
return GREE_FAN_3;
|
state[0] =
|
||||||
case climate::CLIMATE_FAN_AUTO:
|
GreeClimateCodec::encode_fan_mode(model, data.fan_mode) | GreeClimateCodec::encode_operation_mode(data.mode);
|
||||||
default:
|
const uint8_t target_temperature = clamp<uint8_t>(data.target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX);
|
||||||
return GREE_FAN_AUTO;
|
state[1] = target_temperature - GREE_TEMP_MIN;
|
||||||
|
state[2] = data.feature_bits & supported_feature_mask(model);
|
||||||
|
if (model != GREE_GENERIC)
|
||||||
|
state[3] = 0x50;
|
||||||
|
|
||||||
|
if (model == GREE_YAN)
|
||||||
|
state[4] = vertical_swing;
|
||||||
|
|
||||||
|
if (model == GREE_YB1FA || model == GREE_YX1FF) {
|
||||||
|
if (data.mode != climate::CLIMATE_MODE_OFF)
|
||||||
|
state[2] |= GREE_MODEL_A_BIT;
|
||||||
|
if (model == GREE_YX1FF && data.fan_mode == climate::CLIMATE_FAN_HIGH)
|
||||||
|
state[2] |= GREE_FAN_TURBO_BIT;
|
||||||
|
if (vertical_swing == GREE_VDIR_SWING) {
|
||||||
|
state[0] |= GREE_SWING_AUTO_MASK;
|
||||||
|
// YX1FF repeats the automatic swing value in both nibbles, while YB1FA only uses the vertical field.
|
||||||
|
state[4] = model == GREE_YX1FF ? GREE_YX1FF_VANE_SWING : GREE_VDIR_SWING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
|
if (model == GREE_YAG || model == GREE_YAW1F) {
|
||||||
case climate::CLIMATE_FAN_LOW:
|
state[2] |= 0x60;
|
||||||
return GREE_FAN_1;
|
state[5] = model == GREE_YAW1F ? 0x80 : 0x40;
|
||||||
case climate::CLIMATE_FAN_MEDIUM:
|
if (vertical_swing == GREE_VDIR_SWING || horizontal_swing == GREE_HDIR_SWING)
|
||||||
return GREE_FAN_2;
|
state[0] |= GREE_SWING_AUTO_MASK;
|
||||||
case climate::CLIMATE_FAN_HIGH:
|
|
||||||
return GREE_FAN_3;
|
|
||||||
case climate::CLIMATE_FAN_AUTO:
|
|
||||||
default:
|
|
||||||
return GREE_FAN_AUTO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAC, like YAG, keeps vertical swing in the low nibble of byte 4; the byte 0 swing flag alone is not enough.
|
||||||
|
if (model == GREE_YAC || model == GREE_YAG || model == GREE_YAW1F)
|
||||||
|
state[4] = vertical_swing | (horizontal_swing << 4);
|
||||||
|
|
||||||
|
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
|
||||||
|
state[6] = 0x20;
|
||||||
|
if (vertical_swing == GREE_VDIR_SWING)
|
||||||
|
state[0] |= GREE_SWING_AUTO_MASK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((model == GREE_YX1FF || model == GREE_YAC1FB9) && data.preset == climate::CLIMATE_PRESET_SLEEP)
|
||||||
|
state[0] |= GREE_PRESET_SLEEP_BIT;
|
||||||
|
|
||||||
|
if (data.fahrenheit) {
|
||||||
|
state[3] |= GREE_FAHRENHEIT_BIT;
|
||||||
|
if (data.half_degree)
|
||||||
|
state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
state[GREE_STATE_FRAME_SIZE - 1] = GreeProtocol::calculate_checksum(state);
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t GreeClimate::horizontal_swing_() {
|
#ifdef USE_GREE_RECEIVER
|
||||||
switch (this->swing_mode) {
|
optional<GreeClimateData> GreeClimateCodec::decode(Model model, const GreeState &state) {
|
||||||
|
if (model == GREE_YB1FA || model == GREE_YX1FF)
|
||||||
|
return GreeClimateCodec::decode_model_a(model, state);
|
||||||
|
return GreeClimateCodec::decode_legacy(model, state);
|
||||||
|
}
|
||||||
|
#endif // USE_GREE_RECEIVER
|
||||||
|
|
||||||
|
uint8_t GreeClimateCodec::encode_operation_mode(climate::ClimateMode mode) {
|
||||||
|
if (mode == climate::CLIMATE_MODE_COOL)
|
||||||
|
return GREE_MODE_ON | GREE_MODE_COOL;
|
||||||
|
if (mode == climate::CLIMATE_MODE_DRY)
|
||||||
|
return GREE_MODE_ON | GREE_MODE_DRY;
|
||||||
|
if (mode == climate::CLIMATE_MODE_HEAT)
|
||||||
|
return GREE_MODE_ON | GREE_MODE_HEAT;
|
||||||
|
if (mode == climate::CLIMATE_MODE_HEAT_COOL)
|
||||||
|
return GREE_MODE_ON | GREE_MODE_AUTO;
|
||||||
|
if (mode == climate::CLIMATE_MODE_FAN_ONLY)
|
||||||
|
return GREE_MODE_ON | GREE_MODE_FAN;
|
||||||
|
return GREE_MODE_OFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t GreeClimateCodec::encode_fan_mode(Model model, climate::ClimateFanMode fan_mode) {
|
||||||
|
if (model == GREE_YX1FF) {
|
||||||
|
if (fan_mode == climate::CLIMATE_FAN_QUIET)
|
||||||
|
return GREE_FAN_1;
|
||||||
|
if (fan_mode == climate::CLIMATE_FAN_LOW)
|
||||||
|
return GREE_FAN_2;
|
||||||
|
if (fan_mode == climate::CLIMATE_FAN_MEDIUM || fan_mode == climate::CLIMATE_FAN_HIGH)
|
||||||
|
return GREE_FAN_3;
|
||||||
|
return GREE_FAN_AUTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fan_mode == climate::CLIMATE_FAN_LOW)
|
||||||
|
return GREE_FAN_1;
|
||||||
|
if (fan_mode == climate::CLIMATE_FAN_MEDIUM)
|
||||||
|
return GREE_FAN_2;
|
||||||
|
if (fan_mode == climate::CLIMATE_FAN_HIGH)
|
||||||
|
return GREE_FAN_3;
|
||||||
|
return GREE_FAN_AUTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t GreeClimateCodec::encode_horizontal_swing(climate::ClimateSwingMode swing_mode) {
|
||||||
|
switch (swing_mode) {
|
||||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||||
case climate::CLIMATE_SWING_BOTH:
|
case climate::CLIMATE_SWING_BOTH:
|
||||||
return GREE_HDIR_SWING;
|
return GREE_HDIR_SWING;
|
||||||
@@ -243,8 +341,8 @@ uint8_t GreeClimate::horizontal_swing_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t GreeClimate::vertical_swing_() {
|
uint8_t GreeClimateCodec::encode_vertical_swing(climate::ClimateSwingMode swing_mode) {
|
||||||
switch (this->swing_mode) {
|
switch (swing_mode) {
|
||||||
case climate::CLIMATE_SWING_VERTICAL:
|
case climate::CLIMATE_SWING_VERTICAL:
|
||||||
case climate::CLIMATE_SWING_BOTH:
|
case climate::CLIMATE_SWING_BOTH:
|
||||||
return GREE_VDIR_SWING;
|
return GREE_VDIR_SWING;
|
||||||
@@ -253,24 +351,273 @@ uint8_t GreeClimate::vertical_swing_() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t GreeClimate::temperature_() {
|
#ifdef USE_GREE_RECEIVER
|
||||||
return (uint8_t) roundf(clamp<float>(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)) - GREE_TEMP_MIN;
|
optional<GreeClimateData> GreeClimateCodec::decode_legacy(Model model, const GreeState &state) {
|
||||||
}
|
if (is_auxiliary_frame(model, state))
|
||||||
|
return {};
|
||||||
|
|
||||||
uint8_t GreeClimate::preset_() {
|
const uint8_t mode = state[0] & GREE_MODE_MASK;
|
||||||
// YX1FF and YAC1FB9 have sleep preset
|
const bool power = state[0] & GREE_POWER_MASK;
|
||||||
if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAC1FB9) {
|
const uint8_t fan = state[0] & GREE_FAN_MASK;
|
||||||
switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) {
|
const uint8_t temperature = state[1] & GREE_TEMP_MASK;
|
||||||
case climate::CLIMATE_PRESET_NONE:
|
|
||||||
return GREE_PRESET_NONE;
|
// Legacy GREE-branded remotes have several model and OEM variants. Decode the fields that are stable across the
|
||||||
case climate::CLIMATE_PRESET_SLEEP:
|
// protocol, but do not require the transmitter-side fixed bytes: physical Tadiran/Tosot/GREE remotes may populate
|
||||||
return GREE_PRESET_SLEEP;
|
// timer, display-temperature, I-Feel, WiFi, vane-position, or otherwise unused fields differently.
|
||||||
default:
|
if (mode > GREE_MODE_HEAT || temperature > GREE_TEMP_MAX - GREE_TEMP_MIN)
|
||||||
return GREE_PRESET_NONE;
|
return {};
|
||||||
}
|
|
||||||
|
const uint8_t vertical = state[4] & 0x0F;
|
||||||
|
const uint8_t horizontal = (state[4] >> 4) & 0x07;
|
||||||
|
bool swing_vertical = false;
|
||||||
|
bool swing_horizontal = false;
|
||||||
|
|
||||||
|
switch (model) {
|
||||||
|
case GREE_YAG:
|
||||||
|
case GREE_YAW1F:
|
||||||
|
case GREE_YAC:
|
||||||
|
// On YAC1FB bit 6 does not reliably identify vertical swing; use vane fields for both axes.
|
||||||
|
swing_vertical = is_valid_vertical_swing(true, vertical);
|
||||||
|
swing_horizontal = horizontal == GREE_HDIR_SWING;
|
||||||
|
break;
|
||||||
|
case GREE_GENERIC:
|
||||||
|
case GREE_YAN:
|
||||||
|
case GREE_YAA:
|
||||||
|
case GREE_YAC1FB9:
|
||||||
|
default:
|
||||||
|
// ESPHome's YAN transmitter stores automatic vertical swing in byte 4, while YAA/YAC1FB9 use bit 6.
|
||||||
|
// Accept either representation so branded remotes using the standard GREE layout are handled as well.
|
||||||
|
swing_vertical = (state[0] & GREE_SWING_AUTO_MASK) || is_valid_vertical_swing(true, vertical);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return GREE_PRESET_NONE;
|
GreeClimateData data{
|
||||||
|
.mode = climate::CLIMATE_MODE_OFF,
|
||||||
|
.target_temperature = static_cast<uint8_t>(GREE_TEMP_MIN + temperature),
|
||||||
|
.fan_mode = climate::CLIMATE_FAN_AUTO,
|
||||||
|
.swing_mode = climate::CLIMATE_SWING_OFF,
|
||||||
|
.preset = model == GREE_YAC1FB9 && (state[0] & GREE_SLEEP_MASK) ? climate::CLIMATE_PRESET_SLEEP
|
||||||
|
: climate::CLIMATE_PRESET_NONE,
|
||||||
|
.feature_bits = static_cast<uint8_t>(state[2] & supported_feature_mask(model)),
|
||||||
|
.fahrenheit = static_cast<bool>(state[3] & GREE_FAHRENHEIT_BIT),
|
||||||
|
.half_degree = static_cast<bool>(state[3] & GREE_TEMP_HALF_DEGREE_BIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (swing_vertical && swing_horizontal) {
|
||||||
|
data.swing_mode = climate::CLIMATE_SWING_BOTH;
|
||||||
|
} else if (swing_vertical) {
|
||||||
|
data.swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||||
|
} else if (swing_horizontal) {
|
||||||
|
data.swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (power)
|
||||||
|
data.mode = decode_operation_mode(mode);
|
||||||
|
|
||||||
|
data.fan_mode = decode_fan_mode(model, fan);
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
optional<GreeClimateData> GreeClimateCodec::decode_model_a(Model model, const GreeState &state) {
|
||||||
|
const uint8_t mode = state[0] & GREE_MODE_MASK;
|
||||||
|
const bool power = state[0] & GREE_POWER_MASK;
|
||||||
|
const uint8_t fan = state[0] & GREE_FAN_MASK;
|
||||||
|
const bool swing = state[0] & GREE_SWING_AUTO_MASK;
|
||||||
|
const bool turbo = state[2] & GREE_FAN_TURBO_BIT;
|
||||||
|
const uint8_t temperature = state[1] & GREE_TEMP_MASK;
|
||||||
|
|
||||||
|
// Validate fields that are not represented by Climate where possible. Supported independent feature state is
|
||||||
|
// returned separately so the parent can synchronize its feature switches.
|
||||||
|
if (mode > GREE_MODE_HEAT || temperature > GREE_TEMP_MAX - GREE_TEMP_MIN || !is_valid_timer(state) ||
|
||||||
|
static_cast<bool>(state[2] & GREE_MODEL_A_BIT) != power ||
|
||||||
|
(state[3] & ~(GREE_FAHRENHEIT_BIT | GREE_TEMP_HALF_DEGREE_BIT)) != 0x50 || !is_valid_swing(model, state) ||
|
||||||
|
(state[5] & GREE_BYTE5_FIXED_MASK) != GREE_BYTE5_FIXED_VALUE || state[6] != 0x00 || (state[7] & 0x0F) != 0x00 ||
|
||||||
|
(model == GREE_YX1FF && turbo && fan != GREE_FAN_3)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
GreeClimateData data{
|
||||||
|
.mode = climate::CLIMATE_MODE_OFF,
|
||||||
|
.target_temperature = static_cast<uint8_t>(GREE_TEMP_MIN + temperature),
|
||||||
|
.fan_mode = climate::CLIMATE_FAN_AUTO,
|
||||||
|
.swing_mode = swing ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF,
|
||||||
|
.preset = model == GREE_YX1FF && state[0] & GREE_SLEEP_MASK ? climate::CLIMATE_PRESET_SLEEP
|
||||||
|
: climate::CLIMATE_PRESET_NONE,
|
||||||
|
.feature_bits = static_cast<uint8_t>(state[2] & supported_feature_mask(model)),
|
||||||
|
.fahrenheit = static_cast<bool>(state[3] & GREE_FAHRENHEIT_BIT),
|
||||||
|
.half_degree = static_cast<bool>(state[3] & GREE_TEMP_HALF_DEGREE_BIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (power)
|
||||||
|
data.mode = decode_operation_mode(mode);
|
||||||
|
|
||||||
|
data.fan_mode = turbo ? climate::CLIMATE_FAN_HIGH : decode_fan_mode(model, fan);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
#endif // USE_GREE_RECEIVER
|
||||||
|
|
||||||
|
void GreeClimate::set_model(Model model) {
|
||||||
|
if (model == GREE_YAN || model == GREE_YB1FA || model == GREE_YX1FF) {
|
||||||
|
// These remotes only expose a vertical swing control.
|
||||||
|
this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
|
||||||
|
this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
|
||||||
|
}
|
||||||
|
if (model == GREE_YX1FF)
|
||||||
|
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET);
|
||||||
|
if (model == GREE_YX1FF || model == GREE_YAC1FB9) {
|
||||||
|
this->presets_.insert(climate::CLIMATE_PRESET_NONE);
|
||||||
|
this->presets_.insert(climate::CLIMATE_PRESET_SLEEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
this->model_ = model;
|
||||||
|
this->feature_bits_ = default_feature_bits(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GreeClimate::set_feature_state(GreeFeature feature, bool enabled) {
|
||||||
|
const uint8_t bit = gree_feature_bit(feature);
|
||||||
|
if (!(supported_feature_mask(this->model_) & bit)) {
|
||||||
|
ESP_LOGW(TAG, "GREE model %u does not support feature %u", static_cast<unsigned>(this->model_),
|
||||||
|
static_cast<unsigned>(feature));
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
this->publish_feature_state_(feature);
|
||||||
|
#endif
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YB1FA reports Turbo as effective HIGH fan. Keep the Climate and feature entities consistent when Turbo is enabled.
|
||||||
|
const bool fan_changed = this->model_ == GREE_YB1FA && feature == GREE_FEATURE_TURBO && enabled &&
|
||||||
|
this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO) != climate::CLIMATE_FAN_HIGH;
|
||||||
|
if (fan_changed)
|
||||||
|
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||||
|
|
||||||
|
assign_bits(this->feature_bits_, bit, enabled);
|
||||||
|
this->transmit_state();
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
this->publish_feature_state_(feature);
|
||||||
|
#endif
|
||||||
|
if (fan_changed)
|
||||||
|
this->publish_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GreeClimate::get_feature_state(GreeFeature feature) const {
|
||||||
|
return this->feature_bits_ & gree_feature_bit(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
void GreeClimate::setup() {
|
||||||
|
climate_ir::ClimateIR::setup();
|
||||||
|
// Apply every restored switch, then send one frame. Models that receive feature state have restoring disabled,
|
||||||
|
// so their switches show the current feature bits instead.
|
||||||
|
bool restored = false;
|
||||||
|
for (size_t feature = 0; feature < this->feature_switches_.size(); feature++) {
|
||||||
|
auto *feature_switch = this->feature_switches_[feature];
|
||||||
|
if (feature_switch == nullptr)
|
||||||
|
continue;
|
||||||
|
if (auto initial = feature_switch->get_initial_state_with_restore_mode()) {
|
||||||
|
// What control() would hand the switch's apply body
|
||||||
|
assign_bits(this->feature_bits_, gree_feature_bit(static_cast<GreeFeature>(feature)),
|
||||||
|
*initial != feature_switch->is_inverted());
|
||||||
|
restored = true;
|
||||||
|
}
|
||||||
|
this->publish_feature_state_(static_cast<GreeFeature>(feature));
|
||||||
|
}
|
||||||
|
if (restored)
|
||||||
|
this->transmit_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GreeClimate::register_feature_switch(GreeFeature feature, switch_::Switch *feature_switch) {
|
||||||
|
this->feature_switches_[static_cast<uint8_t>(feature)] = feature_switch;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GreeClimate::publish_feature_state_(GreeFeature feature) {
|
||||||
|
auto *feature_switch = this->feature_switches_[static_cast<uint8_t>(feature)];
|
||||||
|
if (feature_switch != nullptr)
|
||||||
|
feature_switch->publish_state(this->get_feature_state(feature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
void GreeClimate::publish_feature_states_() {
|
||||||
|
for (size_t feature = 0; feature < this->feature_switches_.size(); feature++)
|
||||||
|
this->publish_feature_state_(static_cast<GreeFeature>(feature));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif // USE_GREE_FEATURE_SWITCH
|
||||||
|
|
||||||
|
void GreeClimate::control(const climate::ClimateCall &call) {
|
||||||
|
// An explicit fan selection exits YB1FA Turbo. Disabling Turbo itself leaves the effective HIGH fan selected.
|
||||||
|
const bool clear_turbo =
|
||||||
|
this->model_ == GREE_YB1FA && call.get_fan_mode().has_value() && this->get_feature_state(GREE_FEATURE_TURBO);
|
||||||
|
if (clear_turbo) {
|
||||||
|
this->feature_bits_ &= ~GREE_MODE_BIT_TURBO;
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
this->publish_feature_state_(GREE_FEATURE_TURBO);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
climate_ir::ClimateIR::control(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GreeClimate::transmit_state() {
|
||||||
|
GreeClimateData climate_data{
|
||||||
|
.mode = this->mode,
|
||||||
|
.target_temperature =
|
||||||
|
static_cast<uint8_t>(roundf(clamp<float>(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX))),
|
||||||
|
.fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO),
|
||||||
|
.swing_mode = this->swing_mode,
|
||||||
|
.preset = this->preset.value_or(climate::CLIMATE_PRESET_NONE),
|
||||||
|
.feature_bits = this->feature_bits_,
|
||||||
|
};
|
||||||
|
#ifdef USE_GREE_FAHRENHEIT
|
||||||
|
if (this->use_fahrenheit_) {
|
||||||
|
const uint8_t half_steps = fahrenheit_half_steps(this->target_temperature);
|
||||||
|
climate_data.target_temperature = GREE_TEMP_MIN + (half_steps >> 1);
|
||||||
|
climate_data.fahrenheit = true;
|
||||||
|
climate_data.half_degree = half_steps & 1;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
const GreeState state = GreeClimateCodec::encode(this->model_, climate_data);
|
||||||
|
|
||||||
|
auto transmit = this->transmitter_->transmit();
|
||||||
|
GreeProtocol(this->model_).encode(transmit.get_data(), state);
|
||||||
|
transmit.perform();
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
bool GreeClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||||
|
auto state = GreeProtocol(this->model_).decode(data);
|
||||||
|
if (!state.has_value())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
auto decoded = GreeClimateCodec::decode(this->model_, *state);
|
||||||
|
if (!decoded.has_value()) {
|
||||||
|
ESP_LOGD(TAG, "GREE frame rejected for model %u: %02X %02X %02X %02X %02X %02X %02X %02X",
|
||||||
|
static_cast<unsigned>(this->model_), (*state)[0], (*state)[1], (*state)[2], (*state)[3], (*state)[4],
|
||||||
|
(*state)[5], (*state)[6], (*state)[7]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this->mode = decoded->mode;
|
||||||
|
const int half_steps = 2 * (decoded->target_temperature - GREE_TEMP_MIN) + decoded->half_degree;
|
||||||
|
float target = GREE_TEMP_MIN + 0.5f * half_steps;
|
||||||
|
// The panel shows (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10 F; store that degree so it is sent back unchanged
|
||||||
|
if (decoded->fahrenheit) {
|
||||||
|
// The remote truncates the displayed Fahrenheit degree; retain that exact integer
|
||||||
|
// through subsequent feature-only and fan-only transmissions.
|
||||||
|
const int fahrenheit_degree = (10 * 32 + 18 * GREE_TEMP_MIN + 9 * half_steps) / 10;
|
||||||
|
target = fahrenheit_to_celsius(static_cast<float>(fahrenheit_degree));
|
||||||
|
}
|
||||||
|
this->target_temperature = target;
|
||||||
|
this->fan_mode = decoded->fan_mode;
|
||||||
|
this->swing_mode = decoded->swing_mode;
|
||||||
|
this->preset = decoded->preset;
|
||||||
|
this->feature_bits_ = decoded->feature_bits;
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
this->publish_feature_states_();
|
||||||
|
#endif
|
||||||
|
this->publish_state();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#endif // USE_GREE_RECEIVER
|
||||||
|
|
||||||
} // namespace esphome::gree
|
} // namespace esphome::gree
|
||||||
+113
-35
@@ -1,7 +1,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
#include "esphome/components/climate_ir/climate_ir.h"
|
#include "esphome/components/climate_ir/climate_ir.h"
|
||||||
#include "esphome/core/defines.h"
|
#include "esphome/core/defines.h"
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
#include "esphome/components/switch/switch.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace esphome::gree {
|
namespace esphome::gree {
|
||||||
|
|
||||||
@@ -40,18 +45,15 @@ static constexpr uint32_t GREE_YAC_HEADER_MARK = 6000;
|
|||||||
static constexpr uint32_t GREE_YAC_HEADER_SPACE = 3000;
|
static constexpr uint32_t GREE_YAC_HEADER_SPACE = 3000;
|
||||||
static constexpr uint32_t GREE_YAC_BIT_MARK = 650;
|
static constexpr uint32_t GREE_YAC_BIT_MARK = 650;
|
||||||
|
|
||||||
// Timing specific to YAC1FB9
|
// YAW1F shares the longer YAC1FB9 header and inter-block gap.
|
||||||
// Also used by YAW1F
|
|
||||||
static constexpr uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500;
|
static constexpr uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500;
|
||||||
static constexpr uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980;
|
static constexpr uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980;
|
||||||
|
// Gap between the YAW1F primary command and its fixed second message.
|
||||||
// YAW1F: every command is followed by a second 8-byte message after this gap
|
|
||||||
static constexpr uint32_t GREE_YAW1F_FOLLOWUP_SPACE = 40000;
|
static constexpr uint32_t GREE_YAW1F_FOLLOWUP_SPACE = 40000;
|
||||||
|
|
||||||
// State Frame size
|
// State Frame size
|
||||||
static constexpr uint8_t GREE_STATE_FRAME_SIZE = 8;
|
static constexpr uint8_t GREE_STATE_FRAME_SIZE = 8;
|
||||||
|
|
||||||
// Only available on YAN
|
|
||||||
// Vertical air directions. Note that these cannot be set on all heat pumps
|
// Vertical air directions. Note that these cannot be set on all heat pumps
|
||||||
static constexpr uint8_t GREE_VDIR_AUTO = 0x00;
|
static constexpr uint8_t GREE_VDIR_AUTO = 0x00;
|
||||||
static constexpr uint8_t GREE_VDIR_MANUAL = 0x00;
|
static constexpr uint8_t GREE_VDIR_MANUAL = 0x00;
|
||||||
@@ -63,6 +65,9 @@ static constexpr uint8_t GREE_VDIR_MDOWN = 0x05;
|
|||||||
static constexpr uint8_t GREE_VDIR_DOWN = 0x06;
|
static constexpr uint8_t GREE_VDIR_DOWN = 0x06;
|
||||||
// YX1FF vane-position byte while swinging
|
// YX1FF vane-position byte while swinging
|
||||||
static constexpr uint8_t GREE_YX1FF_VANE_SWING = 0x11;
|
static constexpr uint8_t GREE_YX1FF_VANE_SWING = 0x11;
|
||||||
|
static constexpr uint8_t GREE_VDIR_SWING_DOWN = 0x07;
|
||||||
|
static constexpr uint8_t GREE_VDIR_SWING_MIDDLE = 0x09;
|
||||||
|
static constexpr uint8_t GREE_VDIR_SWING_UP = 0x0B;
|
||||||
|
|
||||||
// Only available on YAC/YAG
|
// Only available on YAC/YAG
|
||||||
// Horizontal air directions. Note that these cannot be set on all heat pumps
|
// Horizontal air directions. Note that these cannot be set on all heat pumps
|
||||||
@@ -75,17 +80,7 @@ static constexpr uint8_t GREE_HDIR_MIDDLE = 0x04;
|
|||||||
static constexpr uint8_t GREE_HDIR_MRIGHT = 0x05;
|
static constexpr uint8_t GREE_HDIR_MRIGHT = 0x05;
|
||||||
static constexpr uint8_t GREE_HDIR_RIGHT = 0x06;
|
static constexpr uint8_t GREE_HDIR_RIGHT = 0x06;
|
||||||
|
|
||||||
// Only available on YX1FF
|
// Feature bits in byte 2
|
||||||
// Turbo (high) fan mode
|
|
||||||
static constexpr uint8_t GREE_FAN_TURBO_BIT = 0x10;
|
|
||||||
|
|
||||||
// Available on YX1FF and YAC1FB9
|
|
||||||
// Sleep preset mode
|
|
||||||
static constexpr uint8_t GREE_PRESET_NONE = 0x00;
|
|
||||||
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
|
|
||||||
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
|
|
||||||
|
|
||||||
// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9.
|
|
||||||
static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10;
|
static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10;
|
||||||
static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
|
static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
|
||||||
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
|
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
|
||||||
@@ -95,24 +90,94 @@ static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
|
|||||||
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
|
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
|
||||||
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
|
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
|
||||||
|
|
||||||
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
|
|
||||||
/// nibble.
|
|
||||||
inline uint8_t frame_checksum(const uint8_t *remote_state) {
|
|
||||||
return (
|
|
||||||
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
|
|
||||||
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
|
|
||||||
0x0F)
|
|
||||||
<< 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#ifdef USE_GREE_FAHRENHEIT
|
#ifdef USE_GREE_FAHRENHEIT
|
||||||
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
|
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
|
||||||
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
|
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
|
||||||
uint8_t fahrenheit_half_steps(float target_celsius);
|
uint8_t fahrenheit_half_steps(float target_celsius);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Model-specific meanings for the same bit positions on YB1FA/YX1FF.
|
||||||
|
static constexpr uint8_t GREE_FAN_TURBO_BIT = 0x10;
|
||||||
|
static constexpr uint8_t GREE_MODEL_A_BIT = 0x40;
|
||||||
|
|
||||||
|
// Only available on YX1FF
|
||||||
|
// Sleep preset mode
|
||||||
|
static constexpr uint8_t GREE_PRESET_NONE = 0x00;
|
||||||
|
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
|
||||||
|
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
|
||||||
|
|
||||||
// Model codes
|
// Model codes
|
||||||
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
|
enum Model : uint8_t {
|
||||||
|
GREE_GENERIC,
|
||||||
|
GREE_YAN,
|
||||||
|
GREE_YAA,
|
||||||
|
GREE_YAC,
|
||||||
|
GREE_YAC1FB9,
|
||||||
|
GREE_YB1FA,
|
||||||
|
GREE_YX1FF,
|
||||||
|
GREE_YAG,
|
||||||
|
GREE_YAW1F
|
||||||
|
};
|
||||||
|
|
||||||
|
enum GreeFeature : uint8_t {
|
||||||
|
GREE_FEATURE_TURBO = 0,
|
||||||
|
GREE_FEATURE_LIGHT = 1,
|
||||||
|
GREE_FEATURE_HEALTH = 2,
|
||||||
|
GREE_FEATURE_XFAN = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr uint8_t gree_feature_bit(GreeFeature feature) { return GREE_MODE_BIT_TURBO << feature; }
|
||||||
|
|
||||||
|
using GreeState = std::array<uint8_t, GREE_STATE_FRAME_SIZE>;
|
||||||
|
|
||||||
|
struct GreeClimateData {
|
||||||
|
climate::ClimateMode mode;
|
||||||
|
uint8_t target_temperature;
|
||||||
|
climate::ClimateFanMode fan_mode;
|
||||||
|
climate::ClimateSwingMode swing_mode;
|
||||||
|
climate::ClimatePreset preset;
|
||||||
|
uint8_t feature_bits{0};
|
||||||
|
// The unit's panel shows Fahrenheit; half_degree adds half a degree Celsius to target_temperature
|
||||||
|
bool fahrenheit{false};
|
||||||
|
bool half_degree{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
class GreeProtocol {
|
||||||
|
public:
|
||||||
|
explicit GreeProtocol(Model model) : model_(model) {}
|
||||||
|
|
||||||
|
void encode(remote_base::RemoteTransmitData *data, const GreeState &state) const;
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
optional<GreeState> decode(remote_base::RemoteReceiveData data) const;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static uint8_t calculate_checksum(const GreeState &state);
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
static bool valid_checksum(const GreeState &state);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Model model_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class GreeClimateCodec {
|
||||||
|
public:
|
||||||
|
static GreeState encode(Model model, const GreeClimateData &data);
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
/// Decode a frame whose checksum the protocol layer already checked.
|
||||||
|
static optional<GreeClimateData> decode(Model model, const GreeState &state);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
protected:
|
||||||
|
static uint8_t encode_operation_mode(climate::ClimateMode mode);
|
||||||
|
static uint8_t encode_fan_mode(Model model, climate::ClimateFanMode fan_mode);
|
||||||
|
static uint8_t encode_horizontal_swing(climate::ClimateSwingMode swing_mode);
|
||||||
|
static uint8_t encode_vertical_swing(climate::ClimateSwingMode swing_mode);
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
static optional<GreeClimateData> decode_legacy(Model model, const GreeState &state);
|
||||||
|
static optional<GreeClimateData> decode_model_a(Model model, const GreeState &state);
|
||||||
|
#endif
|
||||||
|
};
|
||||||
|
|
||||||
class GreeClimate final : public climate_ir::ClimateIR {
|
class GreeClimate final : public climate_ir::ClimateIR {
|
||||||
public:
|
public:
|
||||||
@@ -123,25 +188,38 @@ class GreeClimate final : public climate_ir::ClimateIR {
|
|||||||
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
|
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
|
||||||
|
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
void setup() override;
|
||||||
|
#endif
|
||||||
void set_model(Model model);
|
void set_model(Model model);
|
||||||
#ifdef USE_GREE_FAHRENHEIT
|
#ifdef USE_GREE_FAHRENHEIT
|
||||||
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
|
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
|
||||||
#endif
|
#endif
|
||||||
void set_mode_bit(uint8_t bit_mask, bool enabled);
|
void set_feature_state(GreeFeature feature, bool enabled);
|
||||||
|
bool get_feature_state(GreeFeature feature) const;
|
||||||
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
|
void register_feature_switch(GreeFeature feature, switch_::Switch *feature_switch);
|
||||||
|
#endif
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
void control(const climate::ClimateCall &call) override;
|
||||||
// Transmit via IR the state of this climate controller.
|
// Transmit via IR the state of this climate controller.
|
||||||
void transmit_state() override;
|
void transmit_state() override;
|
||||||
|
#ifdef USE_GREE_RECEIVER
|
||||||
|
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||||
|
#endif
|
||||||
|
|
||||||
uint8_t operation_mode_();
|
#ifdef USE_GREE_FEATURE_SWITCH
|
||||||
uint8_t fan_speed_();
|
void publish_feature_state_(GreeFeature feature);
|
||||||
uint8_t horizontal_swing_();
|
#ifdef USE_GREE_RECEIVER
|
||||||
uint8_t vertical_swing_();
|
void publish_feature_states_();
|
||||||
uint8_t temperature_();
|
#endif
|
||||||
uint8_t preset_();
|
|
||||||
|
std::array<switch_::Switch *, 4> feature_switches_{};
|
||||||
|
#endif
|
||||||
|
|
||||||
Model model_{};
|
Model model_{};
|
||||||
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
|
uint8_t feature_bits_{0};
|
||||||
#ifdef USE_GREE_FAHRENHEIT
|
#ifdef USE_GREE_FAHRENHEIT
|
||||||
bool use_fahrenheit_{false};
|
bool use_fahrenheit_{false};
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import switch
|
from esphome.components import switch
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_LIGHT, DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
|
from esphome.const import (
|
||||||
|
CONF_INITIAL_STATE,
|
||||||
|
CONF_LIGHT,
|
||||||
|
CONF_RESTORE_MODE,
|
||||||
|
CONF_RESTORE_STATE,
|
||||||
|
DEVICE_CLASS_SWITCH,
|
||||||
|
ENTITY_CATEGORY_CONFIG,
|
||||||
|
)
|
||||||
|
from esphome.core import CORE
|
||||||
import esphome.final_validate as fv
|
import esphome.final_validate as fv
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
@@ -10,77 +20,174 @@ from ..climate import CONF_MODEL, GreeClimate
|
|||||||
|
|
||||||
CODEOWNERS = ["@nagyrobi"]
|
CODEOWNERS = ["@nagyrobi"]
|
||||||
|
|
||||||
GreeModeBitSwitch = gree_ns.class_("GreeModeBitSwitch", switch.Switch, cg.Component)
|
GreeFeature = gree_ns.enum("GreeFeature")
|
||||||
|
|
||||||
CONF_TURBO = "turbo"
|
CONF_TURBO = "turbo"
|
||||||
CONF_HEALTH = "health"
|
CONF_HEALTH = "health"
|
||||||
CONF_XFAN = "xfan"
|
CONF_XFAN = "xfan"
|
||||||
CONF_GREE_ID = "gree_id"
|
CONF_GREE_ID = "gree_id"
|
||||||
|
|
||||||
# Switch configurations: (config_key, display_name, mode_bit, icon)
|
# Switch configurations: (config_key, feature, icon, default_restore_mode)
|
||||||
SWITCH_CONFIGS = (
|
SWITCH_CONFIGS = (
|
||||||
(
|
(
|
||||||
CONF_TURBO,
|
CONF_TURBO,
|
||||||
"Gree Turbo Switch",
|
GreeFeature.GREE_FEATURE_TURBO,
|
||||||
gree_ns.GREE_MODE_BIT_TURBO,
|
|
||||||
"mdi:car-turbocharger",
|
"mdi:car-turbocharger",
|
||||||
|
"RESTORE_DEFAULT_OFF",
|
||||||
|
),
|
||||||
|
# The display light was on before the switches existed, so it stays on by default
|
||||||
|
(
|
||||||
|
CONF_LIGHT,
|
||||||
|
GreeFeature.GREE_FEATURE_LIGHT,
|
||||||
|
"mdi:led-outline",
|
||||||
|
"RESTORE_DEFAULT_ON",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CONF_HEALTH,
|
||||||
|
GreeFeature.GREE_FEATURE_HEALTH,
|
||||||
|
"mdi:pine-tree",
|
||||||
|
"RESTORE_DEFAULT_OFF",
|
||||||
),
|
),
|
||||||
(CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"),
|
|
||||||
(CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"),
|
|
||||||
(
|
(
|
||||||
CONF_XFAN,
|
CONF_XFAN,
|
||||||
"Gree X-FAN Switch",
|
GreeFeature.GREE_FEATURE_XFAN,
|
||||||
gree_ns.GREE_MODE_BIT_XFAN,
|
|
||||||
"mdi:wall-sconce-flat",
|
"mdi:wall-sconce-flat",
|
||||||
|
"RESTORE_DEFAULT_OFF",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
SUPPORTED_MODELS = {
|
ALL_FEATURES = frozenset(key for key, *_ in SWITCH_CONFIGS)
|
||||||
"yan",
|
MODEL_FEATURES = {
|
||||||
"yaa",
|
"yan": ALL_FEATURES,
|
||||||
"yac",
|
"yaa": ALL_FEATURES,
|
||||||
"yac1fb9",
|
"yac": ALL_FEATURES,
|
||||||
|
"yac1fb9": ALL_FEATURES,
|
||||||
|
"yb1fa": ALL_FEATURES - {CONF_HEALTH},
|
||||||
|
"yx1ff": frozenset({CONF_LIGHT}),
|
||||||
}
|
}
|
||||||
|
# These models receive feature state over IR, so the switches follow it instead of restoring
|
||||||
|
RX_FEATURE_MODELS = frozenset({"yb1fa", "yx1ff"})
|
||||||
|
|
||||||
|
_RESTORE_KEYS = (CONF_RESTORE_MODE, CONF_RESTORE_STATE, CONF_INITIAL_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_switch_schema(
|
||||||
|
icon: str, default_restore_mode: str
|
||||||
|
) -> Callable[[ConfigType], ConfigType]:
|
||||||
|
schema = switch.switch_schema(
|
||||||
|
switch.ApplySwitch,
|
||||||
|
icon=icon,
|
||||||
|
default_restore_mode=default_restore_mode,
|
||||||
|
device_class=DEVICE_CLASS_SWITCH,
|
||||||
|
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validator(value: ConfigType) -> ConfigType:
|
||||||
|
explicit = isinstance(value, dict) and any(
|
||||||
|
key in value for key in _RESTORE_KEYS
|
||||||
|
)
|
||||||
|
config = schema(value)
|
||||||
|
# Leave an omitted restore mode unset, so final validation can tell it from an explicit one
|
||||||
|
# and to_code can fill in the default for the model.
|
||||||
|
if not explicit:
|
||||||
|
config.pop(CONF_RESTORE_MODE, None)
|
||||||
|
return config
|
||||||
|
|
||||||
|
return validator
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.Schema(
|
CONFIG_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_GREE_ID): cv.use_id(GreeClimate),
|
cv.Required(CONF_GREE_ID): cv.use_id(GreeClimate),
|
||||||
**{
|
**{
|
||||||
cv.Optional(key): switch.switch_schema(
|
cv.Optional(key): _feature_switch_schema(icon, default_restore_mode)
|
||||||
GreeModeBitSwitch,
|
for key, _, icon, default_restore_mode in SWITCH_CONFIGS
|
||||||
icon=icon,
|
|
||||||
# The display light was on before the switches existed, so it stays on by default
|
|
||||||
default_restore_mode="RESTORE_DEFAULT_ON"
|
|
||||||
if key == CONF_LIGHT
|
|
||||||
else "RESTORE_DEFAULT_OFF",
|
|
||||||
device_class=DEVICE_CLASS_SWITCH,
|
|
||||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
|
||||||
)
|
|
||||||
for key, _, _, icon in SWITCH_CONFIGS
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_model(config: ConfigType) -> None:
|
def _validate_features_for_model(model: str, config: ConfigType) -> None:
|
||||||
climate_conf = fv.get_declaring_config(config[CONF_GREE_ID])
|
supported_features = MODEL_FEATURES.get(model)
|
||||||
if climate_conf[CONF_MODEL] not in SUPPORTED_MODELS:
|
if supported_features is None:
|
||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
"Gree switches are only supported for the "
|
"Gree switches are only supported for the "
|
||||||
+ ", ".join(SUPPORTED_MODELS)
|
+ ", ".join(sorted(MODEL_FEATURES))
|
||||||
+ " models"
|
+ " models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
configured_features = {key for key, *_ in SWITCH_CONFIGS if key in config}
|
||||||
|
if unsupported := configured_features - supported_features:
|
||||||
|
suffix = "switch" if len(unsupported) == 1 else "switches"
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Gree model {model} does not support the "
|
||||||
|
+ ", ".join(sorted(unsupported))
|
||||||
|
+ f" {suffix}"
|
||||||
|
)
|
||||||
|
|
||||||
FINAL_VALIDATE_SCHEMA = _validate_model
|
|
||||||
|
def _validate_model(config: ConfigType) -> None:
|
||||||
|
model = fv.get_declaring_config(config[CONF_GREE_ID])[CONF_MODEL]
|
||||||
|
_validate_features_for_model(model, config)
|
||||||
|
if model not in RX_FEATURE_MODELS:
|
||||||
|
return
|
||||||
|
for feature, *_ in SWITCH_CONFIGS:
|
||||||
|
if (feature_conf := config.get(feature)) is None:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
CONF_RESTORE_STATE in feature_conf
|
||||||
|
or str(feature_conf.get(CONF_RESTORE_MODE, "DISABLED")) != "DISABLED"
|
||||||
|
):
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Gree model {model} receives {feature} state from IR; "
|
||||||
|
"restore_mode must be DISABLED or omitted",
|
||||||
|
path=[feature],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The climate keeps one switch per feature, so a second entry would take over the first
|
||||||
|
_validate_unique = fv.unique_platform_key(
|
||||||
|
domain=switch.DOMAIN,
|
||||||
|
platform="gree",
|
||||||
|
keys=(CONF_GREE_ID,),
|
||||||
|
message="Only one gree switch entry is allowed per climate ('{gree_id}')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _final_validate(config: ConfigType) -> None:
|
||||||
|
_validate_unique(config)
|
||||||
|
_validate_model(config)
|
||||||
|
|
||||||
|
|
||||||
|
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||||
|
|
||||||
|
|
||||||
|
def _with_default_restore_mode(
|
||||||
|
feature_conf: ConfigType, default_restore_mode: str, receives_state: bool
|
||||||
|
) -> ConfigType:
|
||||||
|
"""Fill in the model's restore mode when none was configured."""
|
||||||
|
if CONF_RESTORE_MODE in feature_conf or CONF_RESTORE_STATE in feature_conf:
|
||||||
|
return feature_conf
|
||||||
|
mode = "DISABLED" if receives_state else default_restore_mode
|
||||||
|
return {**feature_conf, CONF_RESTORE_MODE: switch.RESTORE_MODES[mode]}
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
parent = await cg.get_variable(config[CONF_GREE_ID])
|
hub = await cg.get_variable(config[CONF_GREE_ID])
|
||||||
|
receives_state = (
|
||||||
|
fv.get_declaring_config(config[CONF_GREE_ID], CORE.config)[CONF_MODEL]
|
||||||
|
in RX_FEATURE_MODELS
|
||||||
|
)
|
||||||
|
cg.add_define("USE_GREE_FEATURE_SWITCH")
|
||||||
|
|
||||||
for conf_key, name, bit_mask, _ in SWITCH_CONFIGS:
|
switches = switch.sub_switches(config, parent=hub)
|
||||||
if switch_conf := config.get(conf_key):
|
for conf_key, feature, _, default_restore_mode in SWITCH_CONFIGS:
|
||||||
sw = cg.new_Pvariable(switch_conf[cv.CONF_ID], name, bit_mask)
|
if (feature_conf := config.get(conf_key)) is None:
|
||||||
await switch.register_switch(sw, switch_conf)
|
continue
|
||||||
await cg.register_component(sw, switch_conf)
|
await switches.create(
|
||||||
await cg.register_parented(sw, parent)
|
_with_default_restore_mode(
|
||||||
|
feature_conf, default_restore_mode, receives_state
|
||||||
|
),
|
||||||
|
lambda sw, feature=feature: hub.register_feature_switch(feature, sw),
|
||||||
|
apply=f"set_feature_state({feature}, state)",
|
||||||
|
)
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#include "gree_switch.h"
|
|
||||||
#include "esphome/core/log.h"
|
|
||||||
|
|
||||||
namespace esphome::gree {
|
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "gree.switch");
|
|
||||||
|
|
||||||
void GreeModeBitSwitch::setup() { this->restore(); }
|
|
||||||
|
|
||||||
void GreeModeBitSwitch::dump_config() { log_switch(TAG, " ", this->name_, this); }
|
|
||||||
|
|
||||||
void GreeModeBitSwitch::write_state(bool state) {
|
|
||||||
this->parent_->set_mode_bit(this->bit_mask_, state);
|
|
||||||
this->publish_state(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace esphome::gree
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "esphome/core/component.h"
|
|
||||||
#include "esphome/components/switch/switch.h"
|
|
||||||
#include "esphome/components/gree/gree.h"
|
|
||||||
|
|
||||||
namespace esphome::gree {
|
|
||||||
|
|
||||||
class GreeModeBitSwitch final : public switch_::Switch, public Component, public Parented<GreeClimate> {
|
|
||||||
public:
|
|
||||||
GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {}
|
|
||||||
|
|
||||||
void setup() override;
|
|
||||||
void dump_config() override;
|
|
||||||
void write_state(bool state) override;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
const char *name_;
|
|
||||||
uint8_t bit_mask_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::gree
|
|
||||||
@@ -102,8 +102,6 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = OUT_PIN_SENSOR + 3 + HEADER_FOO
|
|||||||
// Data Header & Footer
|
// Data Header & Footer
|
||||||
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
|
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
|
||||||
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
|
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
|
||||||
// MAC address the module uses when Bluetooth is disabled
|
|
||||||
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
|
|
||||||
|
|
||||||
void LD2410Component::dump_config() {
|
void LD2410Component::dump_config() {
|
||||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
@@ -425,10 +423,7 @@ void LD2410Component::handle_ack_data_() {
|
|||||||
break; // too short to hold a MAC address
|
break; // too short to hold a MAC address
|
||||||
}
|
}
|
||||||
|
|
||||||
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
|
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
|
||||||
if (this->bluetooth_on_) {
|
|
||||||
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
|
|
||||||
}
|
|
||||||
|
|
||||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#include "ld2410s.h"
|
#include "ld2410s.h"
|
||||||
#include "ld2410s_number.h"
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include "esphome/core/application.h"
|
#include "esphome/core/application.h"
|
||||||
#include "esphome/core/hal.h"
|
#include "esphome/core/hal.h"
|
||||||
@@ -40,6 +39,11 @@ static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_
|
|||||||
#ifdef USE_LD2410S_PARAMETERS
|
#ifdef USE_LD2410S_PARAMETERS
|
||||||
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
|
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
|
||||||
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
|
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
|
||||||
|
#ifdef USE_NUMBER
|
||||||
|
// Module units to number units: 0.7 m gates, seconds, tenths of a hertz (number.py uses the same)
|
||||||
|
static constexpr float PARAMETER_SCALES[] PROGMEM = {0.7f, 0.7f, 1.0f, 0.1f, 0.1f, 1.0f};
|
||||||
|
static_assert(sizeof(PARAMETER_SCALES) / sizeof(PARAMETER_SCALES[0]) == PARAMETER_COUNT, "one scale per parameter");
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
|
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
|
||||||
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
|
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
|
||||||
@@ -136,6 +140,27 @@ bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
|
|||||||
this->parameters_dirty_ = true; // written by loop() once no sequence is running
|
this->parameters_dirty_ = true; // written by loop() once no sequence is running
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef USE_NUMBER
|
||||||
|
void LD2410SComponent::control_number(number::Number *number, float value) {
|
||||||
|
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||||
|
if (this->numbers_[i] != number)
|
||||||
|
continue;
|
||||||
|
// Show the step the module got, not a value between two steps
|
||||||
|
const uint8_t raw = lroundf(value / PARAMETER_SCALES[i]); // the schema keeps every parameter within a byte
|
||||||
|
if (this->set_parameter(static_cast<Parameter>(i), raw))
|
||||||
|
number->publish_state(raw * PARAMETER_SCALES[i]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef USE_SELECT
|
||||||
|
void LD2410SComponent::control_response_speed(select::Select *select, size_t index) {
|
||||||
|
if (this->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
|
||||||
|
select->publish_state(index);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
void LD2410SComponent::restart_init_(uint32_t at) {
|
void LD2410SComponent::restart_init_(uint32_t at) {
|
||||||
@@ -502,7 +527,7 @@ void LD2410SComponent::publish_parameters_() {
|
|||||||
#ifdef USE_NUMBER
|
#ifdef USE_NUMBER
|
||||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||||
if (this->numbers_[i] != nullptr) {
|
if (this->numbers_[i] != nullptr) {
|
||||||
this->numbers_[i]->publish_raw(this->parameters_[i]);
|
this->numbers_[i]->publish_state(this->parameters_[i] * PARAMETER_SCALES[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
#ifdef USE_LD2410S_VERSION
|
#ifdef USE_LD2410S_VERSION
|
||||||
#include "esphome/components/text_sensor/text_sensor.h"
|
#include "esphome/components/text_sensor/text_sensor.h"
|
||||||
#endif
|
#endif
|
||||||
|
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||||
|
#include "esphome/components/number/number.h"
|
||||||
|
#endif
|
||||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||||
#include "esphome/components/select/select.h"
|
#include "esphome/components/select/select.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -39,10 +42,6 @@ static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAM
|
|||||||
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
|
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
|
||||||
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
|
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
|
||||||
|
|
||||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
|
||||||
class LD2410SNumber;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
class LD2410SComponent final : public Component, public uart::UARTDevice {
|
class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||||
#ifdef USE_BINARY_SENSOR
|
#ifdef USE_BINARY_SENSOR
|
||||||
SUB_BINARY_SENSOR(target)
|
SUB_BINARY_SENSOR(target)
|
||||||
@@ -67,9 +66,14 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
|
|||||||
#ifdef USE_LD2410S_PARAMETERS
|
#ifdef USE_LD2410S_PARAMETERS
|
||||||
bool set_parameter(Parameter parameter, uint8_t value);
|
bool set_parameter(Parameter parameter, uint8_t value);
|
||||||
#ifdef USE_NUMBER
|
#ifdef USE_NUMBER
|
||||||
void set_number(Parameter parameter, LD2410SNumber *number) {
|
void set_number(Parameter parameter, number::Number *number) {
|
||||||
this->numbers_[static_cast<uint8_t>(parameter)] = number;
|
this->numbers_[static_cast<uint8_t>(parameter)] = number;
|
||||||
}
|
}
|
||||||
|
/// Writes the number's parameter and publishes the step the module got.
|
||||||
|
void control_number(number::Number *number, float value);
|
||||||
|
#endif
|
||||||
|
#ifdef USE_SELECT
|
||||||
|
void control_response_speed(select::Select *select, size_t index);
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -109,7 +113,7 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||||
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
|
number::Number *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
|
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "esphome/core/defines.h"
|
|
||||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
|
||||||
|
|
||||||
#include "ld2410s.h"
|
|
||||||
#include "esphome/components/number/number.h"
|
|
||||||
|
|
||||||
namespace esphome::ld2410s {
|
|
||||||
|
|
||||||
/// Edits one common parameter; scale converts the module's units to the number's.
|
|
||||||
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
|
|
||||||
public:
|
|
||||||
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
|
|
||||||
|
|
||||||
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void control(float value) override {
|
|
||||||
// Show the step the module got, not a value between two steps
|
|
||||||
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
|
|
||||||
if (this->parent_->set_parameter(this->parameter_, raw))
|
|
||||||
this->publish_raw(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Parameter parameter_;
|
|
||||||
const float scale_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::ld2410s
|
|
||||||
|
|
||||||
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "esphome/core/defines.h"
|
|
||||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
|
||||||
|
|
||||||
#include "ld2410s.h"
|
|
||||||
#include "esphome/components/select/select.h"
|
|
||||||
|
|
||||||
namespace esphome::ld2410s {
|
|
||||||
|
|
||||||
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
|
|
||||||
protected:
|
|
||||||
void control(size_t index) override {
|
|
||||||
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
|
|
||||||
this->publish_state(index);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::ld2410s
|
|
||||||
|
|
||||||
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
|
|
||||||
@@ -16,7 +16,7 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
|
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter
|
||||||
|
|
||||||
DEPENDENCIES = ["ld2410s"]
|
DEPENDENCIES = ["ld2410s"]
|
||||||
|
|
||||||
@@ -26,9 +26,8 @@ CONF_MIN_DISTANCE = "min_distance"
|
|||||||
CONF_NO_DELAY = "no_delay"
|
CONF_NO_DELAY = "no_delay"
|
||||||
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
|
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
|
||||||
|
|
||||||
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
|
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz;
|
||||||
|
# PARAMETER_SCALES in ld2410s.cpp converts with the same factors
|
||||||
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
|
|
||||||
GATE_WIDTH_M = 0.7
|
GATE_WIDTH_M = 0.7
|
||||||
FREQUENCY_SCALE = 0.1
|
FREQUENCY_SCALE = 0.1
|
||||||
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
|
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
|
||||||
@@ -37,7 +36,7 @@ _FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
|
|||||||
|
|
||||||
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
|
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
|
||||||
return number.number_schema(
|
return number.number_schema(
|
||||||
LD2410SNumber,
|
number.ApplyNumber,
|
||||||
device_class=device_class,
|
device_class=device_class,
|
||||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||||
unit_of_measurement=unit,
|
unit_of_measurement=unit,
|
||||||
@@ -72,22 +71,19 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
await numbers(
|
await numbers(
|
||||||
CONF_MAX_DISTANCE,
|
CONF_MAX_DISTANCE,
|
||||||
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
|
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
|
||||||
Parameter.PARAMETER_MAX_DISTANCE,
|
apply="control_number(entity, value)",
|
||||||
GATE_WIDTH_M,
|
|
||||||
**_DISTANCE_RANGE,
|
**_DISTANCE_RANGE,
|
||||||
)
|
)
|
||||||
await numbers(
|
await numbers(
|
||||||
CONF_MIN_DISTANCE,
|
CONF_MIN_DISTANCE,
|
||||||
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
|
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
|
||||||
Parameter.PARAMETER_MIN_DISTANCE,
|
apply="control_number(entity, value)",
|
||||||
GATE_WIDTH_M,
|
|
||||||
**_DISTANCE_RANGE,
|
**_DISTANCE_RANGE,
|
||||||
)
|
)
|
||||||
await numbers(
|
await numbers(
|
||||||
CONF_NO_DELAY,
|
CONF_NO_DELAY,
|
||||||
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
|
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
|
||||||
Parameter.PARAMETER_NO_DELAY,
|
apply="control_number(entity, value)",
|
||||||
1,
|
|
||||||
min_value=1,
|
min_value=1,
|
||||||
max_value=120,
|
max_value=120,
|
||||||
step=1,
|
step=1,
|
||||||
@@ -95,14 +91,12 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
await numbers(
|
await numbers(
|
||||||
CONF_STATUS_REPORTING_FREQUENCY,
|
CONF_STATUS_REPORTING_FREQUENCY,
|
||||||
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
|
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
|
||||||
Parameter.PARAMETER_STATUS_FREQUENCY,
|
apply="control_number(entity, value)",
|
||||||
FREQUENCY_SCALE,
|
|
||||||
**_FREQUENCY_RANGE,
|
**_FREQUENCY_RANGE,
|
||||||
)
|
)
|
||||||
await numbers(
|
await numbers(
|
||||||
CONF_DISTANCE_REPORTING_FREQUENCY,
|
CONF_DISTANCE_REPORTING_FREQUENCY,
|
||||||
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
|
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
|
||||||
Parameter.PARAMETER_DISTANCE_FREQUENCY,
|
apply="control_number(entity, value)",
|
||||||
FREQUENCY_SCALE,
|
|
||||||
**_FREQUENCY_RANGE,
|
**_FREQUENCY_RANGE,
|
||||||
)
|
)
|
||||||
@@ -4,18 +4,16 @@ import esphome.config_validation as cv
|
|||||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
|
from . import CONF_LD2410S_ID, LD2410SComponent
|
||||||
|
|
||||||
DEPENDENCIES = ["ld2410s"]
|
DEPENDENCIES = ["ld2410s"]
|
||||||
|
|
||||||
CONF_RESPONSE_SPEED = "response_speed"
|
CONF_RESPONSE_SPEED = "response_speed"
|
||||||
|
|
||||||
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = {
|
CONFIG_SCHEMA = {
|
||||||
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
||||||
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
|
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
|
||||||
LD2410SSelect,
|
select.ApplySelect,
|
||||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||||
icon="mdi:speedometer",
|
icon="mdi:speedometer",
|
||||||
),
|
),
|
||||||
@@ -27,5 +25,8 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
||||||
selects = select.sub_selects(config, parent=hub)
|
selects = select.sub_selects(config, parent=hub)
|
||||||
await selects(
|
await selects(
|
||||||
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
|
CONF_RESPONSE_SPEED,
|
||||||
|
hub.set_response_speed_select,
|
||||||
|
options=["Normal", "Fast"],
|
||||||
|
apply="control_response_speed(entity, index)",
|
||||||
)
|
)
|
||||||
@@ -120,8 +120,6 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = LIGHT_SENSOR + 3 + HEADER_FOOTE
|
|||||||
// Data Header & Footer
|
// Data Header & Footer
|
||||||
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
|
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
|
||||||
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
|
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
|
||||||
// MAC address the module uses when Bluetooth is disabled
|
|
||||||
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
|
|
||||||
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
|
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
|
||||||
|
|
||||||
void LD2412Component::dump_config() {
|
void LD2412Component::dump_config() {
|
||||||
@@ -477,10 +475,7 @@ void LD2412Component::handle_ack_data_() {
|
|||||||
break; // too short to hold a MAC address
|
break; // too short to hold a MAC address
|
||||||
}
|
}
|
||||||
|
|
||||||
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
|
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
|
||||||
if (this->bluetooth_on_) {
|
|
||||||
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
|
|
||||||
}
|
|
||||||
|
|
||||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC};
|
|||||||
// 3 zones * 8 + footer (4).
|
// 3 zones * 8 + footer (4).
|
||||||
static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40;
|
static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40;
|
||||||
static constexpr uint8_t PERIODIC_FRAME_SIZE = 30; // header, three targets of 8 bytes, footer
|
static constexpr uint8_t PERIODIC_FRAME_SIZE = 30; // header, three targets of 8 bytes, footer
|
||||||
// MAC address the module uses when Bluetooth is disabled
|
|
||||||
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
|
|
||||||
|
|
||||||
static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; };
|
static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; };
|
||||||
|
|
||||||
@@ -192,6 +190,11 @@ void LD2450Component::dump_config() {
|
|||||||
for (text_sensor::TextSensor *s : this->direction_text_sensors_) {
|
for (text_sensor::TextSensor *s : this->direction_text_sensors_) {
|
||||||
LOG_TEXT_SENSOR(" ", "Direction", s);
|
LOG_TEXT_SENSOR(" ", "Direction", s);
|
||||||
}
|
}
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
for (text_sensor::TextSensor *s : this->target_xy_text_sensors_) {
|
||||||
|
LOG_TEXT_SENSOR(" ", "TargetXY", s);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_NUMBER
|
#ifdef USE_NUMBER
|
||||||
ESP_LOGCONFIG(TAG, "Numbers:");
|
ESP_LOGCONFIG(TAG, "Numbers:");
|
||||||
@@ -295,9 +298,6 @@ bool LD2450Component::get_timeout_status_(uint32_t check_millis) {
|
|||||||
if (check_millis == 0) {
|
if (check_millis == 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (this->timeout_ == 0) {
|
|
||||||
this->timeout_ = ld2450::convert_seconds_to_ms(DEFAULT_PRESENCE_TIMEOUT);
|
|
||||||
}
|
|
||||||
return App.get_loop_component_start_time() - check_millis >= this->timeout_;
|
return App.get_loop_component_start_time() - check_millis >= this->timeout_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,9 +468,16 @@ void LD2450Component::handle_periodic_data_() {
|
|||||||
|
|
||||||
// Store target info for zone target count. Zero out untracked targets (td==0)
|
// Store target info for zone target count. Zero out untracked targets (td==0)
|
||||||
// so stale coordinates don't produce ghost counts in count_targets_in_zone_().
|
// so stale coordinates don't produce ghost counts in count_targets_in_zone_().
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
const Target previous = this->target_info_[index];
|
||||||
|
#endif
|
||||||
this->target_info_[index].x = (td > 0) ? tx : 0;
|
this->target_info_[index].x = (td > 0) ? tx : 0;
|
||||||
this->target_info_[index].y = (td > 0) ? ty : 0;
|
this->target_info_[index].y = (td > 0) ? ty : 0;
|
||||||
this->target_info_[index].is_moving = (td > 0) && is_moving;
|
this->target_info_[index].is_moving = (td > 0) && is_moving;
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
this->update_target_xy_(index,
|
||||||
|
previous.x != this->target_info_[index].x || previous.y != this->target_info_[index].y);
|
||||||
|
#endif
|
||||||
|
|
||||||
} // End loop thru targets
|
} // End loop thru targets
|
||||||
|
|
||||||
@@ -621,10 +628,7 @@ void LD2450Component::handle_ack_data_() {
|
|||||||
break; // too short to hold a MAC address
|
break; // too short to hold a MAC address
|
||||||
}
|
}
|
||||||
|
|
||||||
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
|
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
|
||||||
if (this->bluetooth_on_) {
|
|
||||||
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
|
|
||||||
}
|
|
||||||
|
|
||||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||||
@@ -862,6 +866,27 @@ void LD2450Component::set_direction_text_sensor(uint8_t target, text_sensor::Tex
|
|||||||
this->direction_text_sensors_[target] = s;
|
this->direction_text_sensors_[target] = s;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
void LD2450Component::set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s) {
|
||||||
|
this->target_xy_text_sensors_[target] = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish the position of a target in one radar frame; the sensor's filters throttle it
|
||||||
|
void LD2450Component::update_target_xy_(uint8_t target, bool changed) {
|
||||||
|
text_sensor::TextSensor *s = this->target_xy_text_sensors_[target];
|
||||||
|
if (s == nullptr || (!changed && s->has_state()))
|
||||||
|
return;
|
||||||
|
const Target &pos = this->target_info_[target];
|
||||||
|
if (pos.x == 0 && pos.y == 0) {
|
||||||
|
s->publish_state(DirectionStrings::get_progmem_str(DIRECTION_NA, DirectionStrings::LAST_INDEX));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Longest is "-32767,-32767"
|
||||||
|
char text[16];
|
||||||
|
const size_t len = buf_append_printf(text, sizeof(text), 0, "%d,%d", pos.x, pos.y);
|
||||||
|
s->publish_state(text, len);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Send Zone coordinates data to LD2450
|
// Send Zone coordinates data to LD2450
|
||||||
#ifdef USE_NUMBER
|
#ifdef USE_NUMBER
|
||||||
@@ -900,17 +925,14 @@ void LD2450Component::set_zone_numbers(uint8_t zone, number::Number *x1, number:
|
|||||||
// Set Presence Timeout load and save from flash
|
// Set Presence Timeout load and save from flash
|
||||||
#ifdef USE_NUMBER
|
#ifdef USE_NUMBER
|
||||||
void LD2450Component::set_presence_timeout() {
|
void LD2450Component::set_presence_timeout() {
|
||||||
if (this->presence_timeout_number_ != nullptr) {
|
if (this->presence_timeout_number_ == nullptr)
|
||||||
if (this->presence_timeout_number_->state == 0) {
|
return;
|
||||||
float timeout = this->restore_from_flash_();
|
// Restore only while the number has no state yet: 0 is a valid presence_timeout (no hold)
|
||||||
this->presence_timeout_number_->publish_state(timeout);
|
if (!this->presence_timeout_number_->has_state())
|
||||||
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
|
this->presence_timeout_number_->publish_state(this->restore_from_flash_());
|
||||||
}
|
const float timeout = this->presence_timeout_number_->state;
|
||||||
if (this->presence_timeout_number_->has_state()) {
|
this->save_to_flash_(timeout);
|
||||||
this->save_to_flash_(this->presence_timeout_number_->state);
|
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
|
||||||
this->timeout_ = ld2450::convert_seconds_to_ms(this->presence_timeout_number_->state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Presence Timeout to flash
|
// Save Presence Timeout to flash
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ class LD2450Component : public Component, public uart::UARTDevice {
|
|||||||
#ifdef USE_TEXT_SENSOR
|
#ifdef USE_TEXT_SENSOR
|
||||||
void set_direction_text_sensor(uint8_t target, text_sensor::TextSensor *s);
|
void set_direction_text_sensor(uint8_t target, text_sensor::TextSensor *s);
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
void set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s);
|
||||||
|
#endif
|
||||||
#ifdef USE_NUMBER
|
#ifdef USE_NUMBER
|
||||||
void set_zone_coordinate(number::Number *n);
|
void set_zone_coordinate(number::Number *n);
|
||||||
void set_zone_numbers(uint8_t zone, number::Number *x1, number::Number *y1, number::Number *x2, number::Number *y2);
|
void set_zone_numbers(uint8_t zone, number::Number *x1, number::Number *y1, number::Number *x2, number::Number *y2);
|
||||||
@@ -189,6 +192,9 @@ class LD2450Component : public Component, public uart::UARTDevice {
|
|||||||
void save_to_flash_(float value);
|
void save_to_flash_(float value);
|
||||||
float restore_from_flash_();
|
float restore_from_flash_();
|
||||||
bool get_timeout_status_(uint32_t check_millis);
|
bool get_timeout_status_(uint32_t check_millis);
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
void update_target_xy_(uint8_t target, bool changed);
|
||||||
|
#endif
|
||||||
void count_targets_in_zone_(const Zone &zone, uint8_t &still, uint8_t &moving);
|
void count_targets_in_zone_(const Zone &zone, uint8_t &still, uint8_t &moving);
|
||||||
|
|
||||||
uint32_t presence_millis_ = 0;
|
uint32_t presence_millis_ = 0;
|
||||||
@@ -226,6 +232,10 @@ class LD2450Component : public Component, public uart::UARTDevice {
|
|||||||
#ifdef USE_TEXT_SENSOR
|
#ifdef USE_TEXT_SENSOR
|
||||||
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
|
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef USE_LD2450_TARGET_XY
|
||||||
|
// Publishes a target's position as "x,y" text, so both values always come from the same radar frame
|
||||||
|
std::array<text_sensor::TextSensor *, MAX_TARGETS> target_xy_text_sensors_{};
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef LD2450_POLYGON_ZONE_COUNT
|
#ifdef LD2450_POLYGON_ZONE_COUNT
|
||||||
StaticVector<PolygonZone *, LD2450_POLYGON_ZONE_COUNT> polygon_zones_;
|
StaticVector<PolygonZone *, LD2450_POLYGON_ZONE_COUNT> polygon_zones_;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ void PolygonZone::control(const std::string &value) {
|
|||||||
ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", LOG_STR_ARG(this->get_log_name()));
|
ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", LOG_STR_ARG(this->get_log_name()));
|
||||||
} else {
|
} else {
|
||||||
this->polygon_ = polygon;
|
this->polygon_ = polygon;
|
||||||
|
// A user-made edit is written to flash now, as wall-powered sensors lose power without warning
|
||||||
|
global_preferences->sync();
|
||||||
// A target seen inside the previous polygon must not keep the new one on
|
// A target seen inside the previous polygon must not keep the new one on
|
||||||
this->last_inside_ms_ = 0;
|
this->last_inside_ms_ = 0;
|
||||||
if (this->polygon_.empty())
|
if (this->polygon_.empty())
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401
|
|||||||
|
|
||||||
DEPENDENCIES = ["ld2450"]
|
DEPENDENCIES = ["ld2450"]
|
||||||
|
|
||||||
|
CONF_XY = "xy"
|
||||||
|
|
||||||
|
ICON_CROSSHAIRS_GPS = "mdi:crosshairs-gps"
|
||||||
|
|
||||||
MAX_TARGETS = 3
|
MAX_TARGETS = 3
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.Schema(
|
CONFIG_SCHEMA = cv.Schema(
|
||||||
@@ -43,6 +47,11 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
|
|||||||
entity_category=ENTITY_CATEGORY_NONE,
|
entity_category=ENTITY_CATEGORY_NONE,
|
||||||
icon=ICON_SIGN_DIRECTION,
|
icon=ICON_SIGN_DIRECTION,
|
||||||
),
|
),
|
||||||
|
cv.Optional(CONF_XY): text_sensor.text_sensor_schema(
|
||||||
|
entity_category=ENTITY_CATEGORY_NONE,
|
||||||
|
icon=ICON_CROSSHAIRS_GPS,
|
||||||
|
filters=text_sensor.timeout_throttle_filters("NA"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for n in range(MAX_TARGETS)
|
for n in range(MAX_TARGETS)
|
||||||
@@ -56,8 +65,12 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
|
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
|
||||||
await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor)
|
await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor)
|
||||||
for n in range(MAX_TARGETS):
|
for n in range(MAX_TARGETS):
|
||||||
if (direction_conf := config.get(f"target_{n + 1}")) and (
|
if not (target_conf := config.get(f"target_{n + 1}")):
|
||||||
direction_config := direction_conf.get(CONF_DIRECTION)
|
continue
|
||||||
):
|
if direction_config := target_conf.get(CONF_DIRECTION):
|
||||||
sens = await text_sensor.new_text_sensor(direction_config)
|
sens = await text_sensor.new_text_sensor(direction_config)
|
||||||
cg.add(hub.set_direction_text_sensor(n, sens))
|
cg.add(hub.set_direction_text_sensor(n, sens))
|
||||||
|
if xy_config := target_conf.get(CONF_XY):
|
||||||
|
cg.add_define("USE_LD2450_TARGET_XY")
|
||||||
|
sens = await text_sensor.new_text_sensor(xy_config)
|
||||||
|
cg.add(hub.set_target_xy_text_sensor(n, sens))
|
||||||
@@ -41,7 +41,15 @@ template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T
|
|||||||
|
|
||||||
// Formats the MAC address into the buffer, or "unknown" when it is not valid
|
// Formats the MAC address into the buffer, or "unknown" when it is not valid
|
||||||
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
|
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
|
||||||
if (mac_address_is_valid(mac_address)) {
|
// mac_address_is_valid() rejects the multicast bit, which radar Bluetooth MACs may have set; only all-00 and all-FF
|
||||||
|
// mean no address
|
||||||
|
uint8_t any_set = 0x00;
|
||||||
|
uint8_t all_set = 0xFF;
|
||||||
|
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
|
||||||
|
any_set |= mac_address[i];
|
||||||
|
all_set &= mac_address[i];
|
||||||
|
}
|
||||||
|
if (any_set != 0x00 && all_set != 0xFF) {
|
||||||
format_mac_addr_upper(mac_address, buffer.data());
|
format_mac_addr_upper(mac_address, buffer.data());
|
||||||
} else {
|
} else {
|
||||||
ESPHOME_strncpy_P(buffer.data(), ESPHOME_PSTR("unknown"), buffer.size());
|
ESPHOME_strncpy_P(buffer.data(), ESPHOME_PSTR("unknown"), buffer.size());
|
||||||
@@ -49,6 +57,21 @@ inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MA
|
|||||||
return buffer.data();
|
return buffer.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MAC address the module uses when Bluetooth is disabled
|
||||||
|
static constexpr uint8_t NO_MAC[MAC_ADDRESS_SIZE] PROGMEM = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
|
||||||
|
|
||||||
|
// Stores the radar's reported MAC, or clears it when the radar reports Bluetooth off; returns whether Bluetooth is on
|
||||||
|
inline bool store_mac(const uint8_t *reported, uint8_t *mac_address) {
|
||||||
|
uint8_t no_mac[MAC_ADDRESS_SIZE];
|
||||||
|
progmem_memcpy(no_mac, NO_MAC, MAC_ADDRESS_SIZE);
|
||||||
|
if (std::memcmp(reported, no_mac, MAC_ADDRESS_SIZE) == 0) {
|
||||||
|
std::memset(mac_address, 0, MAC_ADDRESS_SIZE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::memcpy(mac_address, reported, MAC_ADDRESS_SIZE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to format firmware version with stack allocation
|
// Helper function to format firmware version with stack allocation
|
||||||
// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety)
|
// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety)
|
||||||
inline void format_version_str(const uint8_t *version, std::span<char, 20> buffer) {
|
inline void format_version_str(const uint8_t *version, std::span<char, 20> buffer) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from .const import (
|
|||||||
FAMILY_COMPONENT,
|
FAMILY_COMPONENT,
|
||||||
FAMILY_FRIENDLY,
|
FAMILY_FRIENDLY,
|
||||||
FAMILY_RTL8710B,
|
FAMILY_RTL8710B,
|
||||||
|
FAMILY_RTL8720C,
|
||||||
KEY_BOARD,
|
KEY_BOARD,
|
||||||
KEY_COMPONENT,
|
KEY_COMPONENT,
|
||||||
KEY_COMPONENT_DATA,
|
KEY_COMPONENT_DATA,
|
||||||
@@ -59,6 +60,8 @@ from .const import (
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
# Fault handlers the crash handler wraps (BK7238, BK7231N)
|
# Fault handlers the crash handler wraps (BK7238, BK7231N)
|
||||||
BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt")
|
BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt")
|
||||||
|
# Families with a crash handler; the RTL8720C installs it at run time (no wraps).
|
||||||
|
CRASH_HANDLER_FAMILIES = (FAMILY_BK7231N, FAMILY_BK7238, FAMILY_RTL8720C)
|
||||||
|
|
||||||
CODEOWNERS = ["@kuba2k2"]
|
CODEOWNERS = ["@kuba2k2"]
|
||||||
AUTO_LOAD = ["preferences"]
|
AUTO_LOAD = ["preferences"]
|
||||||
@@ -554,10 +557,11 @@ async def component_to_code(config):
|
|||||||
# RAM-executable output section and prints a post-link placement summary.
|
# RAM-executable output section and prints a post-link placement summary.
|
||||||
if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX:
|
if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX:
|
||||||
cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"])
|
cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"])
|
||||||
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
|
if config[CONF_FAMILY] in CRASH_HANDLER_FAMILIES:
|
||||||
# Crash handler (crash_handler.cpp, patch_noinit.py.script)
|
# Crash handler (crash_handler.cpp)
|
||||||
cg.add_define("USE_LIBRETINY_CRASH_HANDLER")
|
cg.add_define("USE_LIBRETINY_CRASH_HANDLER")
|
||||||
cg.add_define("USE_CRASH_HANDLER")
|
cg.add_define("USE_CRASH_HANDLER")
|
||||||
|
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
|
||||||
for trap in BK_TRAP_HANDLERS:
|
for trap in BK_TRAP_HANDLERS:
|
||||||
cg.add_build_flag(f"-Wl,--wrap={trap}")
|
cg.add_build_flag(f"-Wl,--wrap={trap}")
|
||||||
cg.add_platformio_option("extra_scripts", ["pre:patch_noinit.py"])
|
cg.add_platformio_option("extra_scripts", ["pre:patch_noinit.py"])
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
#ifdef USE_BK72XX
|
#ifdef USE_LIBRETINY
|
||||||
|
|
||||||
#include "esphome/core/defines.h"
|
#include "esphome/core/defines.h"
|
||||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||||
|
|
||||||
|
#if !defined(USE_BK72XX) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)
|
||||||
|
#error "The LibreTiny crash handler has no fault capture for this chip family"
|
||||||
|
#endif
|
||||||
|
|
||||||
#include "crash_handler.h"
|
#include "crash_handler.h"
|
||||||
#include "core.h"
|
#include "core.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
@@ -13,6 +17,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
|
#ifdef USE_BK72XX
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include <arm_mcu_pub.h> // MCU_REG_BACKUP_SP_*
|
#include <arm_mcu_pub.h> // MCU_REG_BACKUP_SP_*
|
||||||
#include <intc.h> // struct arm_registers
|
#include <intc.h> // struct arm_registers
|
||||||
@@ -39,28 +44,138 @@ extern char _itcmcode_ram_begin[];
|
|||||||
extern char _itcmcode_ram_end[];
|
extern char _itcmcode_ram_end[];
|
||||||
}
|
}
|
||||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||||
|
#endif // USE_BK72XX
|
||||||
|
|
||||||
|
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||||
|
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||||
|
extern "C" {
|
||||||
|
// Bounds of the code in flash (XIP) and in RAM (linker symbols, rtl8710c_ram.ld).
|
||||||
|
extern char __xip_code_text_start__[];
|
||||||
|
extern char __xip_code_text_end__[];
|
||||||
|
extern char __ram_entry_text_start__[];
|
||||||
|
extern char __ram_code_text_end__[];
|
||||||
|
}
|
||||||
|
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||||
|
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||||
|
|
||||||
namespace esphome::libretiny {
|
namespace esphome::libretiny {
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "libretiny.crash");
|
ESPHOME_LOG_TAG(TAG, "libretiny.crash");
|
||||||
|
|
||||||
// 0xDEAD sentinel, then the record layout version.
|
// 0xDEAD sentinel, then the record layout version.
|
||||||
static constexpr uint32_t CRASH_MAGIC_V1 = 0xDEAD0001;
|
static constexpr uint32_t CRASH_MAGIC_V2 = 0xDEAD0002;
|
||||||
|
|
||||||
|
// BK72xx: the ARM9 exception taken.
|
||||||
static constexpr uint32_t CRASH_REASON_UNDEFINED = 1;
|
static constexpr uint32_t CRASH_REASON_UNDEFINED = 1;
|
||||||
static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2;
|
static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2;
|
||||||
static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3;
|
static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3;
|
||||||
PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort");
|
// RTL8720C: the Cortex-M exception number (3 to 7) plus this offset.
|
||||||
|
static constexpr uint32_t CRASH_REASON_CORTEX_M_OFFSET = 1;
|
||||||
|
PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort",
|
||||||
|
"Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault");
|
||||||
|
|
||||||
|
static constexpr size_t MAX_BACKTRACE = 8;
|
||||||
|
// Words scanned above sp for return addresses.
|
||||||
|
static constexpr size_t STACK_SCAN_WORDS = 64;
|
||||||
|
|
||||||
|
struct CrashRecord {
|
||||||
|
uint32_t magic;
|
||||||
|
uint32_t reason;
|
||||||
|
uint32_t pc;
|
||||||
|
uint32_t lr;
|
||||||
|
uint32_t sp;
|
||||||
|
uint32_t fault_status; // Cortex-M CFSR, 0 on BK72xx
|
||||||
|
uint32_t fault_address; // Cortex-M BFAR or MMFAR when valid, else 0
|
||||||
|
uint32_t backtrace[MAX_BACKTRACE];
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef USE_BK72XX
|
||||||
|
// Kept in ITCM, see patch_noinit.py.script.
|
||||||
|
#define CRASH_RECORD_SECTION ".noinit"
|
||||||
|
#else
|
||||||
|
// rtl8710c_ram.ld keeps .ram.bss.noinit* out of the startup zeroing.
|
||||||
|
#define CRASH_RECORD_SECTION ".ram.bss.noinit.esphome_crash"
|
||||||
|
#endif
|
||||||
|
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
__attribute__((section(CRASH_RECORD_SECTION))) static CrashRecord s_record;
|
||||||
|
|
||||||
|
static bool s_record_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
static bool s_record_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|
||||||
|
bool crash_handler_has_data() {
|
||||||
|
if (!s_record_read) {
|
||||||
|
s_record_read = true;
|
||||||
|
s_record_valid = s_record.magic == CRASH_MAGIC_V2;
|
||||||
|
// Clear the magic so the record is reported once.
|
||||||
|
s_record.magic = 0;
|
||||||
|
}
|
||||||
|
return s_record_valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One ESP_LOGE per line: the CLI decodes per line and partial output still shows.
|
||||||
|
void crash_handler_log() {
|
||||||
|
if (!crash_handler_has_data())
|
||||||
|
return;
|
||||||
|
|
||||||
|
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
||||||
|
ESP_LOGE(TAG, " Reason: %s", LOG_STR_ARG(CrashReasonStrings::get_log_str(s_record.reason, 0)));
|
||||||
|
if (s_record.fault_status != 0) {
|
||||||
|
ESP_LOGE(TAG, " CFSR: 0x%08" PRIX32 " Address: 0x%08" PRIX32, s_record.fault_status, s_record.fault_address);
|
||||||
|
}
|
||||||
|
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc);
|
||||||
|
ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr);
|
||||||
|
ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp);
|
||||||
|
uint8_t count = 0;
|
||||||
|
for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) {
|
||||||
|
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", count, s_record.backtrace[count]);
|
||||||
|
}
|
||||||
|
// addr2line hint with every address
|
||||||
|
char hint[160];
|
||||||
|
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32,
|
||||||
|
s_record.pc, s_record.lr);
|
||||||
|
for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) {
|
||||||
|
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]);
|
||||||
|
}
|
||||||
|
ESP_LOGE(TAG, "%s", hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Crash time: exception mode, a few dozen bytes of stack: no printing, no large locals.
|
||||||
|
|
||||||
|
static inline bool is_code_addr(uint32_t val) {
|
||||||
|
uint32_t addr = val & ~1u; // clear the Thumb bit of a return address
|
||||||
|
#ifdef USE_BK72XX
|
||||||
|
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
|
||||||
|
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
|
||||||
|
#else
|
||||||
|
return (addr >= (uint32_t) __xip_code_text_start__ && addr < (uint32_t) __xip_code_text_end__) ||
|
||||||
|
(addr >= (uint32_t) __ram_entry_text_start__ && addr < (uint32_t) __ram_code_text_end__);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return addresses above sp give the backtrace; then the magic marks the record complete.
|
||||||
|
static void finish_record(uint32_t sp, uint32_t sram_start, uint32_t sram_end) {
|
||||||
|
uint32_t count = 0;
|
||||||
|
if (sp >= sram_start && sp < sram_end && (sp & 3) == 0) {
|
||||||
|
auto *scan = (const uint32_t *) sp; // NOLINT(performance-no-int-to-ptr)
|
||||||
|
auto *end = (const uint32_t *) sram_end; // NOLINT(performance-no-int-to-ptr)
|
||||||
|
for (size_t i = 0; i < STACK_SCAN_WORDS && scan < end && count < MAX_BACKTRACE; i++, scan++) {
|
||||||
|
uint32_t val = *scan;
|
||||||
|
if (is_code_addr(val) && val != s_record.lr && (val & ~1u) != s_record.pc)
|
||||||
|
s_record.backtrace[count++] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count < MAX_BACKTRACE)
|
||||||
|
s_record.backtrace[count] = 0; // terminator, the reader stops at the first 0
|
||||||
|
s_record.magic = CRASH_MAGIC_V2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef USE_BK72XX
|
||||||
|
|
||||||
// With Wi-Fi up the boot-time watchdog no longer fires: any interrupts-off spin hangs until power is
|
// With Wi-Fi up the boot-time watchdog no longer fires: any interrupts-off spin hangs until power is
|
||||||
// cycled (BK7238 and BK7231N; cause not in the open SDK). Re-armed here, long enough for the SDK's
|
// cycled (BK7238 and BK7231N; cause not in the open SDK). Re-armed here, long enough for the SDK's
|
||||||
// register dump when sdk_silent is off.
|
// register dump when sdk_silent is off.
|
||||||
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 500;
|
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 500;
|
||||||
|
|
||||||
static constexpr size_t MAX_BACKTRACE = 8;
|
|
||||||
// Words scanned above sp for return addresses.
|
|
||||||
static constexpr size_t STACK_SCAN_WORDS = 64;
|
|
||||||
|
|
||||||
// SRAM: 192 KB on the BK7231N, 256 KB on the BK7238.
|
// SRAM: 192 KB on the BK7231N, 256 KB on the BK7238.
|
||||||
static constexpr uint32_t SRAM_START = 0x00400000;
|
static constexpr uint32_t SRAM_START = 0x00400000;
|
||||||
#ifdef USE_LIBRETINY_VARIANT_BK7231N
|
#ifdef USE_LIBRETINY_VARIANT_BK7231N
|
||||||
@@ -79,65 +194,6 @@ static constexpr uint32_t CPSR_MODE_UND = 0x1B;
|
|||||||
static constexpr uint32_t CPSR_MODE_SYS = 0x1F;
|
static constexpr uint32_t CPSR_MODE_SYS = 0x1F;
|
||||||
static constexpr uint32_t CPSR_THUMB = 1u << 5;
|
static constexpr uint32_t CPSR_THUMB = 1u << 5;
|
||||||
|
|
||||||
struct CrashRecord {
|
|
||||||
uint32_t magic;
|
|
||||||
uint32_t reason;
|
|
||||||
uint32_t pc;
|
|
||||||
uint32_t lr;
|
|
||||||
uint32_t sp;
|
|
||||||
uint32_t backtrace[MAX_BACKTRACE];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Kept in ITCM, see patch_noinit.py.script.
|
|
||||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
|
||||||
__attribute__((section(".noinit"))) static CrashRecord s_record;
|
|
||||||
|
|
||||||
static bool s_record_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
|
||||||
static bool s_record_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
|
||||||
|
|
||||||
bool crash_handler_has_data() {
|
|
||||||
if (!s_record_read) {
|
|
||||||
s_record_read = true;
|
|
||||||
s_record_valid = s_record.magic == CRASH_MAGIC_V1;
|
|
||||||
// Clear the magic so the record is reported once.
|
|
||||||
s_record.magic = 0;
|
|
||||||
}
|
|
||||||
return s_record_valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One ESP_LOGE per line: the CLI decodes per line and partial output still shows.
|
|
||||||
void crash_handler_log() {
|
|
||||||
if (!crash_handler_has_data())
|
|
||||||
return;
|
|
||||||
|
|
||||||
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
|
||||||
ESP_LOGE(TAG, " Reason: %s", LOG_STR_ARG(CrashReasonStrings::get_log_str(s_record.reason, 0)));
|
|
||||||
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc);
|
|
||||||
ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr);
|
|
||||||
ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp);
|
|
||||||
uint8_t count = 0;
|
|
||||||
for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) {
|
|
||||||
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", count, s_record.backtrace[count]);
|
|
||||||
}
|
|
||||||
// addr2line hint with every address
|
|
||||||
char hint[160];
|
|
||||||
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32,
|
|
||||||
s_record.pc, s_record.lr);
|
|
||||||
for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) {
|
|
||||||
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]);
|
|
||||||
}
|
|
||||||
ESP_LOGE(TAG, "%s", hint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Crash time: exception mode, interrupts masked, a few dozen bytes of stack: no printing, no
|
|
||||||
// large locals.
|
|
||||||
|
|
||||||
static inline bool is_code_addr(uint32_t val) {
|
|
||||||
uint32_t addr = val & ~1u; // clear the Thumb bit of a return address
|
|
||||||
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
|
|
||||||
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUSH_ALL_ARM_REG (boot_handlers.S) saves every mode's registers at the arm_mcu_pub.h addresses
|
// PUSH_ALL_ARM_REG (boot_handlers.S) saves every mode's registers at the arm_mcu_pub.h addresses
|
||||||
// before bk_trap_*; lr (r14) follows sp (r13).
|
// before bk_trap_*; lr (r14) follows sp (r13).
|
||||||
struct SavedBank {
|
struct SavedBank {
|
||||||
@@ -174,21 +230,9 @@ static void record_crash(arm_registers *regs, uint32_t reason, uint32_t pc_offse
|
|||||||
s_record.pc = regs->pc - pc_offset;
|
s_record.pc = regs->pc - pc_offset;
|
||||||
s_record.lr = lr;
|
s_record.lr = lr;
|
||||||
s_record.sp = sp;
|
s_record.sp = sp;
|
||||||
|
s_record.fault_status = 0;
|
||||||
// Return addresses above sp give the backtrace.
|
s_record.fault_address = 0;
|
||||||
uint32_t count = 0;
|
finish_record(sp, SRAM_START, SRAM_END);
|
||||||
if (sp >= SRAM_START && sp < SRAM_END && (sp & 3) == 0) {
|
|
||||||
auto *scan = (const uint32_t *) sp; // NOLINT(performance-no-int-to-ptr)
|
|
||||||
auto *end = (const uint32_t *) SRAM_END; // NOLINT(performance-no-int-to-ptr)
|
|
||||||
for (size_t i = 0; i < STACK_SCAN_WORDS && scan < end && count < MAX_BACKTRACE; i++, scan++) {
|
|
||||||
uint32_t val = *scan;
|
|
||||||
if (is_code_addr(val) && val != lr && (val & ~1u) != s_record.pc)
|
|
||||||
s_record.backtrace[count++] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count < MAX_BACKTRACE)
|
|
||||||
s_record.backtrace[count] = 0; // terminator, the reader stops at the first 0
|
|
||||||
s_record.magic = CRASH_MAGIC_V1;
|
|
||||||
|
|
||||||
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
|
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
|
||||||
}
|
}
|
||||||
@@ -211,7 +255,106 @@ extern "C" void __wrap_bk_trap_dabt(arm_registers *regs) {
|
|||||||
}
|
}
|
||||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||||
|
|
||||||
|
#endif // USE_BK72XX
|
||||||
|
|
||||||
|
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||||
|
|
||||||
|
// The SDK points the fault vectors (3 HardFault to 7 SecureFault) of its RAM vector table (VTOR) at a
|
||||||
|
// ROM handler that prints a register dump and then spins. crash_handler_install() puts
|
||||||
|
// esphome_fault_entry() in front of it, as the SDK's own CONFIG_MIIO patch (app_start.c) does.
|
||||||
|
static_assert(LT_HW_WATCHDOG, "the crash reboot relies on the hardware watchdog");
|
||||||
|
// The ROM dump takes about 0.4 s at 115200 baud; without the re-arm the reset waits for the 10 s
|
||||||
|
// watchdog from arch_init().
|
||||||
|
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 1000;
|
||||||
|
static constexpr uint32_t FIRST_FAULT_VECTOR = 3;
|
||||||
|
static constexpr uint32_t FAULT_VECTOR_COUNT = 5;
|
||||||
|
|
||||||
|
// Architectural System Control Block registers (ARMv8-M).
|
||||||
|
static constexpr uintptr_t SCB_VTOR = 0xE000ED08;
|
||||||
|
static constexpr uintptr_t SCB_CFSR = 0xE000ED28;
|
||||||
|
static constexpr uintptr_t SCB_MMFAR = 0xE000ED34;
|
||||||
|
static constexpr uintptr_t SCB_BFAR = 0xE000ED38;
|
||||||
|
static constexpr uint32_t CFSR_MMARVALID = 1u << 7;
|
||||||
|
static constexpr uint32_t CFSR_BFARVALID = 1u << 15;
|
||||||
|
|
||||||
|
// DTCM, where the app's stacks and heap live (rtl8710c_ram.ld).
|
||||||
|
static constexpr uint32_t SRAM_START = 0x10000000;
|
||||||
|
static constexpr uint32_t SRAM_END = 0x1003FA00;
|
||||||
|
|
||||||
|
// Exception frame: r0-r3, r12, lr, pc, xPSR (the RTL8720C has no FPU, so never the extended frame).
|
||||||
|
static constexpr uint32_t FRAME_LR = 5;
|
||||||
|
static constexpr uint32_t FRAME_PC = 6;
|
||||||
|
static constexpr uint32_t FRAME_XPSR = 7;
|
||||||
|
static constexpr uint32_t FRAME_WORDS = 8;
|
||||||
|
static constexpr uint32_t XPSR_STACK_ALIGNED = 1u << 9;
|
||||||
|
|
||||||
|
// The ROM handlers fault_entry() continues into, by vector.
|
||||||
|
static uint32_t s_rom_fault_vectors[FAULT_VECTOR_COUNT]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|
||||||
|
static inline uint32_t read_reg(uintptr_t addr) {
|
||||||
|
return *(const volatile uint32_t *) addr; // NOLINT(performance-no-int-to-ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the ROM handler to continue into. pc, lr and sp stay 0 when the frame is outside DTCM.
|
||||||
|
extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *frame, uint32_t vector) {
|
||||||
|
uint32_t cfsr = read_reg(SCB_CFSR);
|
||||||
|
uint32_t address = 0;
|
||||||
|
if (cfsr & CFSR_BFARVALID) {
|
||||||
|
address = read_reg(SCB_BFAR);
|
||||||
|
} else if (cfsr & CFSR_MMARVALID) {
|
||||||
|
address = read_reg(SCB_MMFAR);
|
||||||
|
}
|
||||||
|
s_record.reason = vector + CRASH_REASON_CORTEX_M_OFFSET;
|
||||||
|
s_record.fault_status = cfsr;
|
||||||
|
s_record.fault_address = address;
|
||||||
|
|
||||||
|
auto frame_addr = (uint32_t) frame;
|
||||||
|
uint32_t sp = 0;
|
||||||
|
if (frame_addr >= SRAM_START && frame_addr <= SRAM_END - FRAME_WORDS * 4 && (frame_addr & 3) == 0) {
|
||||||
|
s_record.pc = frame[FRAME_PC];
|
||||||
|
s_record.lr = frame[FRAME_LR];
|
||||||
|
sp = frame_addr + FRAME_WORDS * 4 + ((frame[FRAME_XPSR] & XPSR_STACK_ALIGNED) ? 4 : 0);
|
||||||
|
} else {
|
||||||
|
s_record.pc = 0;
|
||||||
|
s_record.lr = 0;
|
||||||
|
}
|
||||||
|
s_record.sp = sp;
|
||||||
|
finish_record(sp, SRAM_START, SRAM_END);
|
||||||
|
|
||||||
|
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
|
||||||
|
return s_rom_fault_vectors[vector - FIRST_FAULT_VECTOR];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fault vector: calls esphome_record_fault(frame, exception number), then jumps to the ROM handler it returns
|
||||||
|
// with r0-r3 and lr restored. Asm because C would push onto MSP before the frame is found, and the ROM handler
|
||||||
|
// reads EXC_RETURN from lr.
|
||||||
|
extern "C" __attribute__((naked, used)) void esphome_fault_entry() {
|
||||||
|
__asm volatile("push {r0-r3, r4, lr} \n" // r4 only keeps the stack 8-byte aligned
|
||||||
|
"tst lr, #4 \n"
|
||||||
|
"ite eq \n"
|
||||||
|
"mrseq r0, msp \n"
|
||||||
|
"mrsne r0, psp \n"
|
||||||
|
"it eq \n"
|
||||||
|
"addeq r0, r0, #24 \n" // MSP: skip the six words pushed above
|
||||||
|
"mrs r1, ipsr \n"
|
||||||
|
"ubfx r1, r1, #0, #9 \n" // exception number, 3 to 7
|
||||||
|
"bl esphome_record_fault \n"
|
||||||
|
"mov r12, r0 \n" // ROM handler address
|
||||||
|
"pop {r0-r3, r4, lr} \n"
|
||||||
|
"bx r12 \n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void crash_handler_install() {
|
||||||
|
auto *vectors = (uint32_t *) read_reg(SCB_VTOR); // NOLINT(performance-no-int-to-ptr)
|
||||||
|
for (uint32_t i = 0; i < FAULT_VECTOR_COUNT; i++) {
|
||||||
|
s_rom_fault_vectors[i] = vectors[FIRST_FAULT_VECTOR + i];
|
||||||
|
vectors[FIRST_FAULT_VECTOR + i] = (uint32_t) &esphome_fault_entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||||
|
|
||||||
} // namespace esphome::libretiny
|
} // namespace esphome::libretiny
|
||||||
|
|
||||||
#endif // USE_LIBRETINY_CRASH_HANDLER
|
#endif // USE_LIBRETINY_CRASH_HANDLER
|
||||||
#endif // USE_BK72XX
|
#endif // USE_LIBRETINY
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#ifdef USE_BK72XX
|
#ifdef USE_LIBRETINY
|
||||||
|
|
||||||
#include "esphome/core/defines.h"
|
#include "esphome/core/defines.h"
|
||||||
|
|
||||||
@@ -14,7 +14,12 @@ void crash_handler_log();
|
|||||||
/// Returns true if crash data was found this boot, reading it first if needed.
|
/// Returns true if crash data was found this boot, reading it first if needed.
|
||||||
bool crash_handler_has_data();
|
bool crash_handler_has_data();
|
||||||
|
|
||||||
|
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||||
|
/// Put the crash recorder in front of the SDK's fault handlers.
|
||||||
|
void crash_handler_install();
|
||||||
|
#endif
|
||||||
|
|
||||||
} // namespace esphome::libretiny
|
} // namespace esphome::libretiny
|
||||||
|
|
||||||
#endif // USE_LIBRETINY_CRASH_HANDLER
|
#endif // USE_LIBRETINY_CRASH_HANDLER
|
||||||
#endif // USE_BK72XX
|
#endif // USE_LIBRETINY
|
||||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user