diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md new file mode 100644 index 00000000000..8ad7cd5d847 --- /dev/null +++ b/.agents/skills/hardware-test/SKILL.md @@ -0,0 +1,123 @@ +--- +name: hardware-test +description: Test an ESPHome change on real boards over USB. Use this whenever a task asks to test, verify, reproduce, bench, A/B or soak a pull request, a branch or a crash on hardware, or mentions flashing, serial logs, esphome upload, a captive portal or fallback AP test, or results to post on a PR. Covers finding the serial ports, building from the right checkout, flashing each chip family (including the LibreTiny kits that stock tools cannot flash), bounded log capture, proven test flows and how to report results. +allowed-tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Hardware testing + +The goal of a hardware test is evidence a reviewer can trust: which build ran on which board, what was +driven, what the logs show, and the same flow on a `dev` build when a quirk could be pre-existing. +`$SKILL` below is this directory (`.agents/skills/hardware-test`). + +## Ground rules + +1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that + port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py "`, + naming the port so a capture on another board survives; the bracket keeps the pattern from matching + the shell that runs it. Never run `esphome logs` in a + `while true` loop: the loop respawns it and holds the port. +2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with + `!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs. +3. **Build from the tree under test.** `python -m esphome` puts the current directory first on + `sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by + its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the + checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep + the build's `src/` for the change before trusting any number. +4. **PR build and baseline build get different `name:` values.** Builds share + `.esphome/build/`, so the same name means `esphome upload` can flash the wrong binary. Saved + Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh + name every run. +5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD` + checked out in a throwaway worktree), with the baseline commit named in the table. When a delta + looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it. +6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in + the same session and say so in the results. +7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is + piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or + `perl -e 'alarm shift; exec @ARGV' ` on macOS without it. + +## Workflow + +1. **Find the boards:** `python $SKILL/scripts/ports.py` (any Python with pyserial, for example the + ESPHome venv). Port names follow the USB hub port and change on every replug, so never reuse a name + from an earlier session. CH340 adapters (D1 mini, BW15 kit, BK7238, CB3S) all look the same; tell + them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at + 74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version). + `ports.py --wait 2e8a` blocks until a Pico re-enumerates. +2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need. + One yaml per board per variant, file name equal to `name:`. +3. **Compile** in the right tree: + `(cd && PYTHONUNBUFFERED=1 timeout 1500 python -m esphome compile > -compile.log 2>&1)`. + Keep the RAM and flash lines at the end. Two ESP-IDF or two LibreTiny builds at the same time race + over tool caches; run them one at a time. +4. **Flash** per chip family (table below). Stop the capture on that port first. +5. **Capture** with `serial_capture.py PORT SECONDS OUT` in the background, then poll the file with + `grep` in an `until ... sleep 2` loop with a cap. USB CDC boards (Pico, nRF52, native USB ESP32) + drop the port on reboot; the script reopens it until its deadline. +6. **Drive** the test: a second board running a client config, `curl`, an aioesphomeapi script that + presses a template button and subscribes to logs, MQTT publishes, or `interval:` and `on_boot:` + automations in the config itself. +7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines + that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`). +8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result, + plus the `dev` control row and the exact flow in a `
` block. No credentials, no port names, + no IP addresses. + +## Flashing by chip family + +| Board | Flash | Log | +|---|---|---| +| ESP8266 D1 mini (CH340) | `python -m esphome upload --device ` | `serial_capture.py` at 115200; `esphome logs` on some CH340s emits garbage after the RTS reset. A failed esptool write can wedge the adapter until the board is replugged. `esptool erase_flash` before a test that needs an unprovisioned device: the preference sector survives a reflash | +| ESP32 devkit (CP2102 or CH340) | `esphome upload --device `, ESP-IDF or Arduino | `serial_capture.py`. Default Wi-Fi light sleep delays API driven actions by seconds; judge timing from device logs. `power_save_mode: none` for OTA overlap tests | +| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device .local` | +| Pico W / Pico 2 W | `esphome upload --device ` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture | +| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d /.pioenvs//*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` | +| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test | +| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash | +| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second | +| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port | +| Any reachable device | `esphome upload --device `; `--file ` for a prebuilt image | `esphome logs --device ` bounded with `timeout` | + +ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA port is 8892. + +## Scripts in `$SKILL/scripts` + +- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`, + `--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and + `--serial-of PORT` (the serial number behind a path). +- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped + capture, ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the + deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. +- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot + strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. +- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. +- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: + the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the + templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), + `RESULT FAIL` (exit 3: a crash, a reboot after the AP came up, or the device never joined the network), + `RESULT INVALID` + (exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run) + or exits 1 when a build, flash or port step failed. + +## Templates in `$SKILL/templates` + +- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and + serves the captive portal. `example-dut.yaml` is the per-run file with the platform block. +- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real + credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a + plain provisioning check. + +## When something looks wrong + +- A capture stopped early or a line is missing: check the timestamps before trusting it. +- The client never joins the AP: another board may still run an AP with the same SSID, or the device + booted with saved credentials because the name was reused. One AP device at a time on the bench. +- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). +- Multicast from the host never arrives on some access points: test it device to device. Broadcast + is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate. +- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for + soak tests. + +`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven +flows. Add new findings there. diff --git a/.agents/skills/hardware-test/reference/boards.md b/.agents/skills/hardware-test/reference/boards.md new file mode 100644 index 00000000000..fd2cd448785 --- /dev/null +++ b/.agents/skills/hardware-test/reference/boards.md @@ -0,0 +1,138 @@ +# Board facts + +Ports are not listed on purpose: they follow the USB hub port and change on every replug. Run +`scripts/ports.py`. + +## ESP8266 (D1 mini, NodeMCU, CH340 or CP2102) + +- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset` + pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`). +- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset; + a plain pyserial capture at 115200 is reliable. +- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug. +- The preference sector survives a reflash: a provisioned API key or saved Wi-Fi credentials stay + active. `esptool erase_flash` gives a clean device. +- `Panic core_esp8266_main.cpp:191 __yield` with `ctx: sys` means a yield from lwIP context (a web + handler, an lwIP callback). Decode stacks with + `xtensa-lx106-elf-addr2line -e /.pioenvs//firmware.elf -f -C -i ` from the + toolchain under `~/.platformio/packages/toolchain-xtensa/bin/` (PlatformIO builds) or the ESPHome + cache directory (native toolchain builds). +- Constant data lives in RAM unless marked `PROGMEM`; with 80 KB of RAM a few hundred bytes are worth a + row in the PR. +- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and + stall the link until the broker drops TCP. + +## ESP32 classic devkit + +- Takes ESP-IDF and Arduino. Default Wi-Fi light sleep delays API driven actions by up to 6 s; judge + timings from device logs, not from the host. `power_save_mode: none` for OTA overlap tests (an IDF web + upload of 739 KB took 107 s with power save and 45 s without). +- Receives only a few percent of IPv6 multicast on some access points with power save on; a raw lwIP + socket loses the same, so that is the AP or driver, not a PR. +- Decode with `xtensa-esp32-elf-addr2line` from the IDF tools directory against + `/.pioenvs//firmware.elf` (PlatformIO) or `/build/.elf` (IDF native). +- Clean reboot between runs with the capture stopped: `esptool --after hard_reset chip_id`. +- Trigger a forced crash only after the app is marked valid (45 s after boot is plenty), never on an + image still pending OTA rollback. +- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys` + under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile + tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier + refuses unsigned images afterwards, so restore with a signed plain build. + +## ESP32-S3, C3, C6, H2 with native USB + +- `/dev/cu.usbmodem*`, esptool works, `logger: hardware_uart: USB_SERIAL_JTAG`. +- The USB console can die at the moment the app takes over the console during a reboot (log stops after + `Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a + USB replug fixes it. Fallback: `esphome logs --device .local` (API log stream, which drops lines + while Wi-Fi is off). +- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry). + +## RP2040 / RP2350 (Pico W, Pico 2 W) + +- The CDC port re-enumerates after every reboot and after `esphome upload` (1200 baud touch). Re-detect + with `ports.py --wait 2e8a` before capturing; `serial_capture.py` reopens on its own if the port + vanishes mid-capture. +- First flash ever: BOOTSEL, then `esphome upload --device /Volumes/RPI-RP2` (or `RP2350`). +- arduino-pico creates the lwIP netif in `WiFi.begin()`, so any IGMP join during component setup fails; + joins must wait for `network::is_connected()`. +- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time. +- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch. + +## BW15 kit (RTL8720CF, LibreTiny rtl87xx) + +- The kit's CH340 wires RTS to CEN and DTR to PA00 (boot strap). A plain pyserial open asserts both and + holds the chip in reset, which is why `esphome upload`, plain `ltchiptool` and `esphome logs` all fail + ("Timeout while linking", or silence). `scripts/flash_rtl87xx_kit.py` sets DTR high and RTS released + on every open, pulses RTS once so the ROM answers `ping`, and after the command drops DTR and pulses + RTS to boot the app. Run it with the PlatformIO LibreTiny penv Python. +- `flash write -d ` takes no family argument; `flash read -d -s 0 -l + realtek-ambz2 ` does. +- Capture with `serial_capture.py --released`. The ESPHome log is on the flashing UART already. +- The macOS CH340 driver only pushes DTR/RTS to the wire once a session has driven them, so the first + plain open after a replug streams logs and the second is silent; always open with the lines released. +- Driver quirks seen on `dev`: `WiFi.begin` can refuse the real network on the first try after bogus + credentials (it connects after two cooldowns); right after connecting while the AP is still up the + driver can bus-fault in `rtw_is_adapter_up` (`R0 = 0xdeadbeef`) and reboot; the AP restarts on every + STA retry, so stations cannot stay joined; `WiFi.begin` fails synchronously for an absent network and + lands in a cooldown at once; connecting blocks the loop 2.5 to 4.1 s. +- Decode faults with `arm-none-eabi-addr2line -e /.pioenvs//raw_firmware.elf -f -C -i ` + from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`). + Fault dumps print PC, LR and R0 to R3. +- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`. + +## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN) + +- Stock ltchiptool closes and reopens the port after its reset, and pyserial raises both lines on open, + holding CEN low through the BootROM window. `scripts/flash_bk72xx.py` opens with both released and + pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset + reaches the downloader; no software reboot does. +- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. +- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one + capture open for the whole test and never reopen mid-phase. +- 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. + +## BW16 kit (RTL8720DN, AmebaD) + +- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while + RTS alone is asserted and straps BURN while DTR alone is; both asserted does nothing. ROM entry is + esptool's classic sequence (`dtr:false,rts:true` 100 ms, `dtr:true,rts:false` 400 ms, both false); the + first pulse after a replug can be lost, retry up to three times. +- Any reopen resets the chip, 460800 is flaky, 115200 is solid: one open per session, in-place baud + changes only. The ROM and the loader NAK continuously while idle, so ACK scans must drain first. +- The AmebaD core has no mDNS responder (`mdns: disabled: true`). + +## LN882H + +- ltchiptool forces 1 Mbaud for writes; adapters that cannot hold it fail with "Timeout while linking" + or die mid-write. Patch `LN882H_YM_BAUDRATE` in the penv's `ltchiptool/soc/ln882h/util/ln882htool.py` + to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short + BOOT to GND while the tool connects. OTA afterwards. +- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there. + +## nRF52840 + +- Nordic PCA10059 dongle: the factory Open DFU bootloader enumerates as `1915:521F` on its own port + name; the app as `2FE3:0100` with two CDC ports, ESPHome log on the second. Only Nordic + `nrfutil nrf5sdk-tools` works (`pkg generate --hw-version 52 --sd-req 0x00 --application-version 1 + --application zephyr.hex app.zip`, then `dfu usb-serial -pkg app.zip -p `); ESPHome downloads + it on the first build. GPREGRET does not enter the bootloader; a pin reset through P0.19 does, which + `dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button + re-enters it. +- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume, + adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24), + button P0.18. + +## ESP Thread Border Router board v1.2 (S3 host + H2 RCP) + +- S3 host on USB JTAG serial, H2 RCP on its own USB. Spinel UART1 from the host: `rx_pin: GPIO17`, + `tx_pin: GPIO18`, RCP reset `GPIO7` active low, 460800. The config needs `network: enable_ipv6: true`. +- The shipped H2 firmware fails `CheckRadioCapabilities` with IDF 5.5; build `examples/openthread/ot_rcp` + for esp32h2 with ESPHome's own IDF (`IDF_PATH` at the cached framework, its penv and tools, + `IDF_PYTHON_CHECK_CONSTRAINTS=no`) and flash with esptool at 0x0, 0x8000 and 0x10000. +- A crash-looping S3 pulses the H2 reset every boot, so the H2 cannot be flashed and the S3 will not + enter its bootloader over USB. Hold S3 BOOT, tap S3 RST, flash the H2, release, then replug both. +- `dns-sd -L _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead. + Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host + logs `Unexpected RCP reset: RESET_OTHER` and recovers). diff --git a/.agents/skills/hardware-test/reference/recipes.md b/.agents/skills/hardware-test/reference/recipes.md new file mode 100644 index 00000000000..32944ce75c4 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes.md @@ -0,0 +1,117 @@ +# Proven test flows + +Each flow names the pull requests it was used on, so their result tables show what to post. + +## Captive portal `/wifisave` hammer (#20802, #20784) + +Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. +Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, +`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the +device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown +is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on +ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). + +Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash +markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. +A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters +the adapter restart), no panic, and the real credentials connect the device within about 10 s. + +Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in +`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag +(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive +portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection +refused proves the portal's server is gone. + +## `reboot_timeout` with a fallback AP (#9958) + +Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, +`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and +ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: +`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles +are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot +with no station joined, no reboot for minutes while one is, then a reboot within a second of the last +disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. + +## MQTT (#20433, #17935, #20762, #20767, #20768) + +A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives +each subscription's QoS, the device address (`New client connected from as -`) and exact +subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. +Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right +after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a +leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. +`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a +paho script publishes and polls the serial capture file. + +## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) + +A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device +multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock +listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate +(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi +reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` +(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action +rejects `%.*s`; use a lambda. + +## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) + +All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release +after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A +refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. +`pkill -f "a\|b"` does not alternate; use two pkills. + +## DHCP lease cache (#17767) + +Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the +`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep +sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use +`upload` then `logs`. + +## HDMI-CEC two-board wire (#9189) + +Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` +filter on the reply handler, template buttons for each target plus lambdas calling +`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, +round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep +lags the press). + +## LD2450 polygon zones (#20154) + +To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and +switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. +Writing those numbers blocks the ld2450 loop about 50 ms. + +## Scheduler and loop wake A/B (#20356) + +An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; +the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout +logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus +`button_command`). Build the same yaml twice with different names, one per tree. + +## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) + +Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over +two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with +`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. + +## ESP-NOW on the ESP8266 (#14407) + +The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is +up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only +mode the device transmits from the soft-AP MAC. + +## Wi-Fi Easy Connect (DPP) enrollee (#17934) + +Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's +QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. +`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the +supplicant state machine. + +## Thread border router (#18104) + +See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. + +## nRF52840 dongle boards (#19009, #20814) + +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. diff --git a/.agents/skills/hardware-test/scripts/flash_bk72xx.py b/.agents/skills/hardware-test/scripts/flash_bk72xx.py new file mode 100644 index 00000000000..1f63179325a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/flash_bk72xx.py @@ -0,0 +1,51 @@ +"""ltchiptool for boards whose RTS drives CEN: reset and link on one open port. + +Stock ltchiptool closes and reopens the port after its DTR/RTS reset, and +pyserial raises both lines on open, holding CEN low through the BootROM's +listen window. Open with both lines released and pulse the reset right +before the LinkChecks instead. +""" + +import sys + +import bk7231tools.serial.main as bk_main +from ltchiptool.__main__ import cli +from ltchiptool.soc.bk72xx.flash import BK72XXFlash +from serial import Serial + + +class ReleasedSerial(Serial): + def __init__(self, port=None, **kwargs): + super().__init__(None, **kwargs) + self.dtr = False + self.rts = False + self.port = port + self.open() + + +bk_main.Serial = ReleasedSerial + +_connect = BK72XXFlash.flash_connect + + +def flash_hw_reset(self) -> None: + self._reset_pending = True + + +def flash_connect(self) -> None: + pending = getattr(self, "_reset_pending", False) + if self.bk and self.conn.linked and not pending: + return + self.flash_build_protocol() + if pending: + self._reset_pending = False + self.bk.hw_reset() + self.conn.linked = False # the chip rebooted, so link again below + _connect(self) + + +BK72XXFlash.flash_hw_reset = flash_hw_reset +BK72XXFlash.flash_connect = flash_connect + +sys.argv = ["ltchiptool", *sys.argv[1:]] +cli() diff --git a/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py b/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py new file mode 100644 index 00000000000..55752ec0bb0 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py @@ -0,0 +1,87 @@ +"""ltchiptool for the BW15 kit whose CH340 drives CEN (RTS) and PA00 (DTR). + +A plain open asserts both lines and holds the chip in reset. Here every open keeps DTR high and RTS +released; the first open pulses RTS so the ROM comes up; after the command DTR low plus an RTS pulse +boots the app. The exit status is ltchiptool's; 2 means the flash succeeded but the app was not +booted (boot pulse failed, or no -d PORT to send it to), so the board sits in the ROM until reset. + +Usage: python flash_rtl87xx_kit.py flash write -d firmware.uf2 +""" + +import sys +import time + +import serial + +_first = [True] +_orig_open = serial.Serial.open + + +def _open(self): + self.dtr = True # PA00 high: ROM download mode on the next reset + self.rts = False # CEN released + _orig_open(self) + if _first[0]: + _first[0] = False + self.rts = True + time.sleep(0.1) + self.rts = False + time.sleep(0.3) # reset into the ROM + self.reset_input_buffer() + + +serial.Serial.open = _open + +from ltchiptool.__main__ import cli_entrypoint # noqa: E402 + + +def port_argument(argv: list[str]) -> str | None: + for i, a in enumerate(argv): + if a in ("-d", "--device") and i + 1 < len(argv): + return argv[i + 1] + if a.startswith(("-d=", "--device=")): + return a.split("=", 1)[1] + if a.startswith("-d") and len(a) > 2: + return a[2:] + return None + + +def boot_app(port: str) -> None: + s = serial.Serial(port=None) + s.port = port + s.dtr = False + s.rts = False + _orig_open(s) + s.rts = True + time.sleep(0.1) + s.rts = False + s.close() + + +def main() -> int: + rc = 1 + try: + result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False) + rc = int(result) if isinstance(result, int) else 0 + finally: + port = port_argument(sys.argv) + if not port: + print( + "[flash_rtl87xx_kit] no -d PORT given, the application was not booted", + file=sys.stderr, + ) + if rc == 0: + rc = 2 + else: + try: + boot_app(port) + print("[flash_rtl87xx_kit] application booted") + except (serial.SerialException, OSError) as err: + print(f"[flash_rtl87xx_kit] boot pulse failed: {err}", file=sys.stderr) + if rc == 0: + rc = 2 # flashed, but the board is still in the ROM: press reset + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/ports.py b/.agents/skills/hardware-test/scripts/ports.py new file mode 100644 index 00000000000..b7aa730a364 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/ports.py @@ -0,0 +1,123 @@ +"""List the boards on USB, or wait for one to enumerate. + + ports.py table: port, vid:pid, product, serial number, guess + ports.py --wait 2e8a print the first port with VID 0x2e8a once it appears (default 60 s) + ports.py --wait 1a86:7523 --timeout 30 + ports.py --product "USB Serial" first port whose USB product string matches (case-insensitive) + ports.py --wait 2e8a --serial SN the port of one specific board, whatever name it comes back under + ports.py --serial-of PORT print the USB serial number behind a port name + +Run with a Python that has pyserial (any ESPHome venv). Port names follow the hub port on macOS, so +re-run this after every replug instead of trusting a name from an earlier session. +""" + +import argparse +import sys +import time + +from serial.tools import list_ports + +GUESS = [ + ( + (0x1A86, 0x7523), + "CH340: D1 mini, BW15 kit, BK7238/CB3S or LN882H adapter; identify by the boot banner", + ), + ((0x1A86, 0x55D3), "CH343: ESP32-P4 devkit console"), + ((0x1A86, 0x55D4), "CH343: ESP32 devkit"), + ((0x1A86, None), "WCH CH34x USB serial bridge"), + ((0x10C4, 0xEA60), "CP2102: classic ESP32 devkit (esp32dev)"), + ( + (0x303A, 0x1001), + "Espressif USB JTAG serial: S3/C3/C6/H2 native USB (logger hardware_uart USB_SERIAL_JTAG)", + ), + ( + (0x2E8A, None), + "Raspberry Pi Pico W / Pico 2 W CDC; the port vanishes and comes back on every reboot", + ), + ((0x1915, 0x521F), "nRF52840 Dongle (PCA10059) in the Open DFU bootloader"), + ( + (0x2FE3, 0x0100), + "nRF52840 Dongle running a Zephyr app; two CDC ports, ESPHome log on the second", + ), + ((0x1915, 0xCAFE), "Makerdiary nRF52840 MDK dongle running a Zephyr app"), + ((0x239A, None), "Adafruit style nRF52 board (UF2 bootloader family)"), +] + + +def guess(vid: int, pid: int) -> str: + for (gv, gp), text in GUESS: + if vid == gv and (gp is None or pid == gp): + return text + return "" + + +def parse_id(text: str) -> tuple[int, int | None]: + vid, _, pid = text.partition(":") + return int(vid, 16), (int(pid, 16) if pid else None) + + +def matches( + port, vid: int | None, pid: int | None, product: str | None, serial: str | None +) -> bool: + if vid is not None and (port.vid != vid or (pid is not None and port.pid != pid)): + return False + if product and (port.product or "").lower() != product.lower(): + return False + return not serial or (port.serial_number or "") == serial + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--wait", + metavar="VID[:PID]", + help="block until a matching port appears, print it", + ) + ap.add_argument("--product", help="USB product string to match") + ap.add_argument("--serial", help="USB serial number to match") + ap.add_argument( + "--serial-of", metavar="PORT", help="print the USB serial number of PORT" + ) + ap.add_argument("--timeout", type=float, default=60) + args = ap.parse_args() + + if args.serial_of: + for p in list_ports.comports(): + if p.device == args.serial_of and p.serial_number: + print(p.serial_number) + return 0 + print(f"no USB serial number for {args.serial_of}", file=sys.stderr) + return 1 + + vid = pid = None + if args.wait: + vid, pid = parse_id(args.wait) + + if args.wait or args.product or args.serial: + deadline = time.time() + args.timeout + while True: + for p in sorted(list_ports.comports(), key=lambda p: p.device): + if p.vid and matches(p, vid, pid, args.product, args.serial): + print(p.device) + return 0 + if time.time() >= deadline: + print("no matching port", file=sys.stderr) + return 1 + time.sleep(1) + + rows = [p for p in list_ports.comports() if p.vid] + if not rows: + print("no USB serial ports") + return 1 + for p in sorted(rows, key=lambda p: p.device): + print( + f"{p.device:32} {p.vid:04x}:{p.pid:04x} {p.product or '?':34} " + f"{p.serial_number or '':18} {guess(p.vid, p.pid)}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/serial_capture.py b/.agents/skills/hardware-test/scripts/serial_capture.py new file mode 100644 index 00000000000..d93b3ca720f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/serial_capture.py @@ -0,0 +1,157 @@ +"""Bounded, timestamped serial capture that always exits and closes the port. + + serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] + + default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico. + --released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin + (DTR) and reset (RTS), so a plain open holds the chip in reset. + --reset pulse reset after opening (DTR low, RTS high 100 ms, RTS low) to capture a fresh boot. + --baud 115200 by default; 74880 shows the ESP8266 ROM boot messages. + --append keep what OUTFILE already holds; by default it is truncated so counts cover this run only. + +Exit status 1 when nothing was received (wrong port name, or a port another capture still holds). +Text ESPHome marks as secret (LOG_SECRET, the Wi-Fi password) is written as . +Lines are written as [HH:MM:SS]text with ANSI colour stripped and flushed one by one, so a shell +loop can grep the file while the capture runs. If the port is missing or disappears (USB CDC boards +re-enumerate on reboot) the script retries until the deadline, and follows the board by its USB serial +number when it comes back under another device path. + +Never run an open-ended serial session: every capture has a deadline, and a flash on the same port +needs the capture stopped first (pkill -f "[s]erial_capture.py" does not match the killing shell). +""" + +import argparse +import contextlib +import pathlib +import re +import sys +import time + +import serial +from serial.tools import list_ports + +ANSI = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]") +# ESPHome's LOG_SECRET span (a Wi-Fi password); a span cut off before its end marker is secret to the end +SECRET = re.compile(rb"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL) +PORT_ERRORS = (serial.SerialException, OSError) + + +def identify(port: str) -> tuple[str | None, int]: + """USB serial number of the port and its position among that device's ports (a dongle has two).""" + ports = sorted(list_ports.comports(), key=lambda p: p.device) + serial_number = next((p.serial_number for p in ports if p.device == port), None) + siblings = [ + p.device for p in ports if serial_number and p.serial_number == serial_number + ] + return serial_number, siblings.index(port) if port in siblings else 0 + + +def current_path(port: str, serial_number: str | None, index: int) -> str: + """The port's current device path: a USB CDC board can come back under a new name after a reboot.""" + if serial_number: + ports = sorted(list_ports.comports(), key=lambda p: p.device) + siblings = [p.device for p in ports if p.serial_number == serial_number] + if index < len(siblings): + return siblings[index] + return port + + +def open_port(args: argparse.Namespace, path: str) -> serial.Serial: + s = serial.Serial(port=None, baudrate=args.baud, timeout=0.2) + s.port = path + if args.released: + s.dtr = False + s.rts = False + s.open() + if args.reset: + try: + s.dtr = False + s.rts = True + time.sleep(0.1) + s.rts = False + s.reset_input_buffer() + except PORT_ERRORS: + s.close() + raise + return s + + +def write_line(out, tag: bytes, data: bytes) -> None: + stamp = time.strftime("[%H:%M:%S]").encode() + out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"", data)) + b"\n") + out.flush() + + +def note(out, text: str) -> None: + write_line(out, b"[capture] ", text.encode()) + + +def capture(args: argparse.Namespace, out) -> int: + """Returns the number of bytes received.""" + deadline = time.time() + args.seconds + buf = b"" + s = None + received = 0 + serial_number, index = None, 0 + while time.time() < deadline: + if s is None: + path = current_path(args.port, serial_number, index) + try: + s = open_port(args, path) + args.reset = False # only reset on the first open + if serial_number is None: + serial_number, index = identify(path) + if path != args.port: + note(out, f"reopened as {path}") + except PORT_ERRORS as err: + note(out, f"open failed: {err}") + time.sleep(1) + continue + try: + data = s.read(4096) + except PORT_ERRORS as err: + if buf: # keep the cut-off line apart from the next boot's first line + write_line(out, b"[partial]", buf) + buf = b"" + note(out, f"port lost: {err}") + with contextlib.suppress(*PORT_ERRORS): + s.close() + s = None + time.sleep(1) + continue + if not data: + continue + received += len(data) + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + write_line(out, b"", line.rstrip(b"\r")) + if buf: + write_line(out, b"[partial]", buf) + if s is not None: + s.close() + return received + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("port") + ap.add_argument("seconds", type=float) + ap.add_argument("outfile") + ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("--released", action="store_true") + ap.add_argument("--reset", action="store_true") + ap.add_argument("--append", action="store_true") + args = ap.parse_args() + with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out: + received = capture(args, out) + if received == 0: + print(f"[capture] nothing received from {args.port}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/wifisave_hammer.sh b/.agents/skills/hardware-test/scripts/wifisave_hammer.sh new file mode 100755 index 00000000000..4ef0d67e2f8 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/wifisave_hammer.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# wifisave_hammer.sh: the device under test (DUT) falls back to its AP; a client board joins it and posts +# /wifisave repeatedly, then the real credentials. Compiles, flashes, captures both serial logs and +# prints counts. Configs come from templates/example-dut.yaml and templates/example-client.yaml. +# +# Usage: wifisave_hammer.sh DUT_KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE_S] +# DUT_KIND esp (esphome upload), pico (upload, then re-find the CDC port by USB serial number), +# bw15 (flash_rtl87xx_kit.py) or bk (flash_bk72xx.py) +# *_YAML absolute path; the file name without .yaml must equal the config's `name` +# *_TREE checkout or worktree whose esphome code must build that yaml (cwd for python -m esphome) +# SETTLE_S seconds to keep capturing after the client reports the real save (default 40; BW15 wants 90) +# Env: PY (python with esphome; default DUT_TREE/venv/bin/python, else python3 on PATH; an explicit PY must exist), LTPY +# (LibreTiny penv python with ltchiptool; default ~/.platformio/penv/.libretiny/bin/python) +# Output next to each yaml: NAME-compile.log, NAME-upload.log, NAME-serial.log, NAME-capture.err, and DUT NAME-events.txt +# (addresses redacted so the extract can go on a PR; the serial logs themselves stay local). +# Exit status: 0 RESULT PASS (no crash marker or reboot after the AP came up, the device joined the network, +# and real stimulus: saves answered and a cooldown exit); 3 RESULT FAIL (the counts show +# a failure); 2 RESULT INVALID (a precondition or the stimulus was missed, or a capture did not last the run); +# 1 when a build, flash or port step fails (nothing is reported). +set -u +SKILL=$(cd "$(dirname "$0")/.." && pwd) +KIND=$1; DPORT=$2; DYAML=$3; DTREE=$4; CPORT=$5; CYAML=$6; CTREE=$7; SETTLE=${8:-40} +if [ -n "${PY:-}" ]; then [ -x "$PY" ] || { echo "[hammer] FAILED: PY=$PY is not executable"; exit 1; } +else PY=$DTREE/venv/bin/python; [ -x "$PY" ] || PY=$(command -v python3) || { echo "[hammer] FAILED: no python found, set PY"; exit 1; }; fi +LTPY=${LTPY:-$HOME/.platformio/penv/.libretiny/bin/python} +DDIR=$(dirname "$DYAML"); DNAME=$(basename "$DYAML" .yaml) +CDIR=$(dirname "$CYAML"); CNAME=$(basename "$CYAML" .yaml) +DLOG=$DDIR/$DNAME-serial.log; CLOG=$CDIR/$CNAME-serial.log +INVALID="" +AP_WAIT=120; UPLOAD_WAIT=300; SAVE_WAIT=240 +# Crash signatures (extended regex), not bare words: "aborting connection attempt" is a normal Wi-Fi retry line +CRASH_RE='Panic |abort\(\)|Fatal exception|Exception \(|Guru Meditation|HardFault|Bus Fault|Backtrace:|Soft WDT reset|wdt reset|Brownout|rst cause:[24]' +# Boot banners and port losses are counted apart: a Pico reboot shows as a port loss (and maybe a banner too), +# while on a UART board a port loss comes from the adapter or the hub, not the chip +BANNER_RE='Log initialized'; LOSS_RE='\[capture\] port lost' +DUT_CAPTURE=$((AP_WAIT + UPLOAD_WAIT + SAVE_WAIT + SETTLE + 60)); CLIENT_CAPTURE=$((SAVE_WAIT + SETTLE + 60)) + +bound() { local t=$1; shift; if command -v timeout >/dev/null; then timeout "$t" "$@"; else perl -e 'alarm shift; exec @ARGV' "$t" "$@"; fi; } +count() { LC_ALL=C grep -a -c "$1" "$2"; } +wait_for() { # wait_for FILE PATTERN SECONDS -> prints elapsed, status 1 on timeout + local n=0; until LC_ALL=C grep -a -q "$2" "$1"; do sleep 2; n=$((n+2)); [ $n -ge "$3" ] && { echo "$n"; return 1; }; done; echo "$n"; } +# Only this run's captures: another capture on the host (a soak on a third board) must survive +stop_tails() { pkill -f "[s]erial_capture.py $DPORT " 2>/dev/null; pkill -f "[s]erial_capture.py $CPORT " 2>/dev/null; sleep 1; } +fail() { echo "[hammer] FAILED: $1"; stop_tails; exit 1; } +port_never_opened() { # an open failure and no line that is not a capture note: the port, not the firmware + LC_ALL=C grep -a -q '\[capture\] open failed' "$1" && ! LC_ALL=C grep -a -q -v '^\[[0-9:]*\]\[capture\]' "$1"; } +compile() { # compile YAML TREE + (cd "$2" && PYTHONUNBUFFERED=1 bound 1500 "$PY" -m esphome compile "$1" > "${1%.yaml}-compile.log" 2>&1); local rc=$? + echo "[hammer] compile $(basename "$1"): rc=$rc"; return $rc; } +esp_upload() { # esp_upload YAML PORT TREE + (cd "$3" && bound $UPLOAD_WAIT "$PY" -m esphome upload "$1" --device "$2" > "${1%.yaml}-upload.log" 2>&1) + local ok; ok=$(count 'Successfully uploaded' "${1%.yaml}-upload.log"); echo "[hammer] upload $(basename "$1"): ok=$ok"; [ "$ok" -ge 1 ]; } + +stop_tails +compile "$DYAML" "$DTREE" || fail "DUT build failed, see $DDIR/$DNAME-compile.log" +compile "$CYAML" "$CTREE" || fail "client build failed, see $CDIR/$CNAME-compile.log" + +TAILOPT="" +case $KIND in + esp) esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" ;; + pico) + SN=$("$PY" "$SKILL/scripts/ports.py" --serial-of "$DPORT") || fail "no USB serial number for $DPORT" + esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" + # The Pico drops its CDC port on reboot and may already be back: take whatever path this board has now; + # the capture follows the serial number if the path changes again + DPORT=$("$PY" "$SKILL/scripts/ports.py" --wait 2e8a --serial "$SN" --timeout 60) || fail "pico did not re-enumerate" + echo "[hammer] pico port after upload: $DPORT"; sleep 2 ;; + bw15|bk) + UF2=$(ls "$DDIR/.esphome/build/$DNAME/.pioenvs/$DNAME/"*.uf2 2>/dev/null | head -1) + [ -n "$UF2" ] || fail "no uf2 under $DDIR/.esphome/build/$DNAME" + echo "[hammer] uf2: ${UF2##*/}" + W=$SKILL/scripts/flash_rtl87xx_kit.py; [ "$KIND" = bk ] && W=$SKILL/scripts/flash_bk72xx.py + (cd "$DDIR" && bound 400 "$LTPY" "$W" flash write -d "$DPORT" "$UF2" > "$DDIR/$DNAME-upload.log" 2>&1); rc=$? + echo "[hammer] $KIND flash: rc=$rc"; [ $rc -eq 0 ] || fail "DUT flash failed, see $DDIR/$DNAME-upload.log" + TAILOPT="--released" ;; + *) echo "unknown DUT_KIND $KIND"; exit 1 ;; +esac + +: > "$DLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$DPORT" $DUT_CAPTURE "$DLOG" $TAILOPT 2> "${DLOG%-serial.log}-capture.err" &) +if ! n=$(wait_for "$DLOG" "Captive portal\|Starting fallback AP" $AP_WAIT); then + [ -s "$DLOG" ] || fail "the DUT capture produced nothing (the capture itself failed), see ${DLOG%-serial.log}-capture.err" + port_never_opened "$DLOG" && fail "could not open $DPORT (held by another process, or the wrong port), see $DLOG" + fail "DUT never raised its AP within ${n}s, see $DLOG" +fi +echo "[hammer] DUT fallback AP after ${n}s"; sleep 2 +# reboots are counted from here; the first banner may predate the capture +BANNER_BASE=$(count "$BANNER_RE" "$DLOG"); LOSS_BASE=$(count "$LOSS_RE" "$DLOG") + +esp_upload "$CYAML" "$CPORT" "$CTREE" || fail "client upload failed" +: > "$CLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$CPORT" $CLIENT_CAPTURE "$CLOG" 2> "${CLOG%-serial.log}-capture.err" &) +if n=$(wait_for "$CLOG" "real save status\|posting the real" $SAVE_WAIT); then + echo "[hammer] client posted the real credentials after ${n}s; settling ${SETTLE}s"; sleep "$SETTLE" +else + [ -s "$CLOG" ] || fail "the client capture produced nothing (the capture itself failed), see ${CLOG%-serial.log}-capture.err" + port_never_opened "$CLOG" && fail "could not open $CPORT (held by another process, or the wrong port), see $CLOG" + INVALID="client never reached the real save within ${n}s"; echo "[hammer] WARNING: $INVALID" +fi + +# A verdict needs complete evidence: both captures must have lasted the run and logged no error of their own +for spec in "$DPORT:${DLOG%-serial.log}-capture.err" "$CPORT:${CLOG%-serial.log}-capture.err"; do + port=${spec%%:*}; err=${spec#*:} + pgrep -f "[s]erial_capture.py $port " >/dev/null || INVALID="${INVALID:+$INVALID; }the capture on $port ended before the run did, see $err" + [ -s "$err" ] && INVALID="${INVALID:+$INVALID; }the capture on $port reported an error, see $err" +done +CRASHES=$(LC_ALL=C grep -a -c -E "$CRASH_RE" "$DLOG") +BANNERS=$(( $(count "$BANNER_RE" "$DLOG") - BANNER_BASE )); LOSSES=$(( $(count "$LOSS_RE" "$DLOG") - LOSS_BASE )) +if [ "$KIND" = pico ]; then + REBOOTS=$BANNERS; [ "$LOSSES" -gt "$REBOOTS" ] && REBOOTS=$LOSSES # one reboot can show as both, so take the larger +else + REBOOTS=$BANNERS; [ "$LOSSES" -gt 0 ] && INVALID="${INVALID:+$INVALID; }the DUT port dropped $LOSSES time(s) mid-run (adapter or hub, not the chip)" +fi +STA=$(LC_ALL=C grep -a 'IP Address:' "$DLOG" | grep -v -c '192\.168\.4\.'); REAL=$(LC_ALL=C grep -a -o 'real save status [-0-9]*' "$CLOG" | tail -1 | awk '{print $4}') +SAVES=$(count 'Requested WiFi Settings Change' "$DLOG"); EXITS=$(count 'cooldown early' "$DLOG"); ANSWERED=$(count 'bogus save status 200' "$CLOG") +echo "[hammer] $DNAME: saves requested $SAVES, cooldown exits $EXITS, crash markers $CRASHES, after the AP came up: boot banners $BANNERS, port losses $LOSSES, reboots $REBOOTS; STA addresses $STA" +FAILED=$(count 'bogus save status -1' "$CLOG") +echo "[hammer] $CNAME: bogus saves answered $ANSWERED, failed $FAILED, real save status ${REAL:-none}" +# Only whole messages, matched where the message starts, so a credential line or an odd SSID never gets in +LC_ALL=C grep -a -E "^\[[0-9:]+\](\[[A-Z]\]\[[a-z_]+:[0-9]+\]: *(Exiting cooldown early|Starting fallback AP|Captive portal|Requested WiFi Settings Change|Retry phase|Connected\$|IP Address: )|\[capture\] port lost|($CRASH_RE))" "$DLOG" | LC_ALL=C tr -cd '\11\12\15\40-\176' | sed -E 's/(IP Address: )[^ ]+/\1/' | cut -c1-110 | head -80 > "$DDIR/$DNAME-events.txt" +echo "[hammer] events: $DDIR/$DNAME-events.txt" +stop_tails +[ -z "$INVALID" ] || { echo "[hammer] RESULT INVALID: $INVALID"; exit 2; } +# Provisioning is proven by the device joining the network; the client's answer to the real save can time +# out because the device leaves the AP before replying, so its status is information only +if [ "$CRASHES" -gt 0 ] || [ "$REBOOTS" -gt 0 ] || [ "$STA" -lt 1 ]; then + echo "[hammer] RESULT FAIL: crash markers $CRASHES, reboots $REBOOTS, STA addresses $STA, real save status ${REAL:-none}"; exit 3 +fi +# A pass needs the stimulus the flow is about: at least half the configured bogus saves answered and one cooldown exit +CONFIGURED=$(LC_ALL=C grep -a -E '^[[:space:]]*bogus_saves:' "$CYAML" | head -1 | tr -dc '0-9'); CONFIGURED=${CONFIGURED:-15} +if [ "$EXITS" -lt 1 ] || [ $((ANSWERED * 2)) -lt "$CONFIGURED" ]; then + echo "[hammer] RESULT INVALID: insufficient stimulus (bogus saves answered $ANSWERED of $CONFIGURED, cooldown exits $EXITS)"; exit 2 +fi +[ "$FAILED" -eq 0 ] || echo "[hammer] WARNING: $FAILED of $CONFIGURED bogus saves got no answer" +echo "[hammer] RESULT PASS"; exit 0 diff --git a/.agents/skills/hardware-test/templates/client-wifisave.yaml b/.agents/skills/hardware-test/templates/client-wifisave.yaml new file mode 100644 index 00000000000..c65581ba65f --- /dev/null +++ b/.agents/skills/hardware-test/templates/client-wifisave.yaml @@ -0,0 +1,77 @@ +# Package for the client board: it joins the device under test's AP (priority 10), posts +# `bogus_saves` bogus /wifisave requests `save_interval` apart, then posts the real credentials once. +# Include from a per-run yaml that holds the platform block and the substitutions (example-client.yaml); +# real_ssid and real_psk come from the scratch directory's secrets.yaml and must be URL safe. +# Set bogus_saves to 1 for a plain provisioning check. Log lines the driver script counts: +# "bogus save N", "bogus save status 200", "posting the real credentials", "real save status 200" +# The hammer is a single-mode script, so a reconnect to the AP (the device under test rebooted) does not +# start a second series on top of the first. +substitutions: + ap_password: hwtestpass + bogus_saves: "15" + save_interval: 3s + bogus_ssid: hwtest-no-such-network + bogus_psk: nosuchpassword + +esphome: + name: ${name} + +logger: + level: DEBUG + +http_request: + id: http + verify_ssl: false + timeout: 10s + +wifi: + id: wifi_id + networks: + - ssid: ${ap_ssid} + password: ${ap_password} + priority: 10 + - ssid: ${real_ssid} + password: ${real_psk} + on_connect: + - lambda: |- + char b[40]; + ESP_LOGI("test", "connected, ip %s", id(wifi_id).get_ip_addresses()[0].str_to(b)); + - if: + condition: + lambda: |- + char b[40]; + return strncmp(id(wifi_id).get_ip_addresses()[0].str_to(b), "192.168.4.", 10) == 0; + then: + - script.execute: hammer + +script: + - id: hammer + mode: single + then: + - delay: 3s + - logger.log: "hammering /wifisave with bogus credentials" + - repeat: + count: ${bogus_saves} + then: + - lambda: ESP_LOGI("test", "bogus save %u", (unsigned) iteration + 1); + - http_request.get: + url: "http://192.168.4.1/wifisave?ssid=${bogus_ssid}&psk=${bogus_psk}" + capture_response: true + on_response: + - lambda: ESP_LOGI("test", "bogus save status %d", response->status_code); + on_error: + - lambda: ESP_LOGW("test", "bogus save status -1"); + - delay: ${save_interval} + - logger.log: "posting the real credentials" + - http_request.get: + url: "http://192.168.4.1/wifisave?ssid=${real_ssid}&psk=${real_psk}" + capture_response: true + on_response: + - lambda: ESP_LOGI("test", "real save status %d", response->status_code); + on_error: + - lambda: ESP_LOGW("test", "real save status -1"); + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml b/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml new file mode 100644 index 00000000000..4c6e4d10d4a --- /dev/null +++ b/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml @@ -0,0 +1,31 @@ +# Package for a device under test that never finds its network, falls back to its AP and serves the +# captive portal. Include it from a per-run yaml that holds the platform block and the substitutions +# (see example-dut.yaml). Bump `name` on every run: saved credentials are keyed by it, and a reused +# name boots straight onto the real network and never raises the AP. +substitutions: + ap_password: hwtestpass + ap_timeout: 15s + bogus_ssid: hwtest-no-such-network + bogus_psk: nosuchpassword + +esphome: + name: ${name} + +logger: + level: DEBUG + +wifi: + networks: + - ssid: ${bogus_ssid} + password: ${bogus_psk} + ap: + ssid: ${ap_ssid} + password: ${ap_password} + ap_timeout: ${ap_timeout} + +captive_portal: + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/example-client.yaml b/.agents/skills/hardware-test/templates/example-client.yaml new file mode 100644 index 00000000000..7b31309ba32 --- /dev/null +++ b/.agents/skills/hardware-test/templates/example-client.yaml @@ -0,0 +1,18 @@ +# Client board for the /wifisave hammer. Copy into a scratch directory next to a secrets.yaml; +# ap_ssid must match the device under test, real_ssid and real_psk must be URL safe. +substitutions: + name: hwtest-client-1 + ap_ssid: hwtest-ap + real_ssid: !secret wifi_ssid + real_psk: !secret wifi_password + bogus_saves: "15" + +esp32: + board: esp32dev + framework: + type: esp-idf + +# esp8266: {board: d1_mini} + +packages: + hwtest: !include client-wifisave.yaml diff --git a/.agents/skills/hardware-test/templates/example-dut.yaml b/.agents/skills/hardware-test/templates/example-dut.yaml new file mode 100644 index 00000000000..b15236ad019 --- /dev/null +++ b/.agents/skills/hardware-test/templates/example-dut.yaml @@ -0,0 +1,16 @@ +# Device under test for the /wifisave hammer. Copy into a scratch directory, keep the file name equal +# to `name`, pick one platform block, bump `name` every run. +substitutions: + name: hwtest-dut-1 + ap_ssid: hwtest-ap + +esp8266: + board: d1_mini + +# esp32: {board: esp32dev, framework: {type: esp-idf}} +# rtl87xx: {board: bw15} +# bk72xx: {board: generic-bk7238} # plus logger: {hardware_uart: UART1}, BK72xx logs on UART2 by default +# rp2040: {board: rpipico2w} + +packages: + hwtest: !include dut-fallback-ap.yaml diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index affe8fc2d6b..4b8e10053ea 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index b49c66b976d..82cf9f28288 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -28,7 +28,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index d0bd1de9ef1..9ca9d68d525 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5bcdb22cb86..8546daa555f 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name @@ -210,7 +210,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285d6869e12..d97352f65f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -81,8 +81,8 @@ jobs: - name: Install apt packages (cached) uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 determine-jobs: name: Determine which jobs to run @@ -415,8 +415,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -438,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -477,7 +477,7 @@ jobs: # 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. if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -488,7 +488,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -528,7 +528,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 + uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 with: run: | . venv/bin/activate @@ -680,7 +680,7 @@ jobs: include: - id: clang-tidy name: Run script/clang-tidy for ESP8266 - options: --environment esp8266-arduino-tidy --grep USE_ESP8266 + options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA pio_cache_key: tidyesp8266 - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino @@ -693,12 +693,16 @@ jobs: ignore_errors: false - id: clang-tidy name: Run script/clang-tidy for RP2 - options: --environment rp2-tidy --grep USE_RP2 + options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for host + options: --environment host-tidy --grep USE_HOST + pio_cache_key: tidyhost - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy - options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS pio_cache_key: tidylibretiny steps: @@ -707,6 +711,16 @@ jobs: with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 + - name: Install apt packages (cached) + if: matrix.pio_cache_key == 'tidyhost' + # 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 + # exactly. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Restore Python id: restore-python @@ -989,14 +1003,14 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, # so their code paths differ -- lint them under the P4 build too. # yamllint disable-line rule:line-length - options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE tidy_environment: esp32p4-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 @@ -1102,8 +1116,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1448,7 +1462,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1647,7 +1661,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1710,7 +1724,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json @@ -1740,13 +1754,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 38a4b8ff0ed..728406e82ff 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -35,7 +35,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da02a5dd09f..caf8c6d8c8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index bfe96f3944a..d3c4c5bf029 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efccad9ad96..64b025a7427 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests @@ -195,7 +195,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: digests-* path: /tmp/digests @@ -245,7 +245,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml index 73a3f7dbaa9..76e40a0eba5 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a0725374f92..33af93b481f 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-integration-durations.yml b/.github/workflows/sync-integration-durations.yml index d09a1cf2428..6bfb492fc9e 100644 --- a/.github/workflows/sync-integration-durations.yml +++ b/.github/workflows/sync-integration-durations.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 727e8e2f9aa..48bb3a69da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.10 + rev: v0.17.0 hooks: # Run the linter. - id: ruff @@ -36,7 +36,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py312-plus] diff --git a/CODEOWNERS b/CODEOWNERS index a770810490b..f2f24f1106f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,7 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apc1/* @ademuri +esphome/components/apc_proteous/* @clydebarrow esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core @@ -48,6 +49,7 @@ esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr +esphome/components/as734x/* @latonita @mrgnr esphome/components/async_tcp/* @esphome/core esphome/components/at581x/* @X-Ryl669 esphome/components/atc_mithermometer/* @ahpohl @@ -155,6 +157,7 @@ esphome/components/delonghi/* @grob6000 esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber +esphome/components/dfrobot_sen0609/* @remcom esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz @@ -183,6 +186,7 @@ esphome/components/es7243e/* @kbx81 esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT +esphome/components/es9038q2m_katana/* @GioMez esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito esphome/components/esp32_ble_client/* @bdraco @jesserockz @@ -219,6 +223,7 @@ esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio +esphome/components/fusb302b/* @remcom esphome/components/gcja5/* @gcormier esphome/components/gdk101/* @Szewcson esphome/components/gl_r01_i2c/* @pkejval @@ -240,6 +245,7 @@ esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn esphome/components/haier/button/* @paveldn +esphome/components/haier/select/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn @@ -254,6 +260,7 @@ esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/hdc2080/* @G-Pereira @jesserockz esphome/components/hdc302x/* @joshuasing +esphome/components/hdmi_cec/* @JosVanEijndhoven esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal @@ -385,6 +392,7 @@ esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov +esphome/components/midea_portasplit/* @Fexiven esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow @@ -439,6 +447,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 +esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman @@ -502,6 +511,7 @@ esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom +esphome/components/rx8111/* @techdregs esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/samsung/* @jorofi diff --git a/esphome/components/apc_proteous/__init__.py b/esphome/components/apc_proteous/__init__.py new file mode 100644 index 00000000000..0f837d3b9d9 --- /dev/null +++ b/esphome/components/apc_proteous/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["uart"] +DOMAIN = "apc_proteous" diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp new file mode 100644 index 00000000000..f6a8432faa0 --- /dev/null +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -0,0 +1,242 @@ +#include "apc_proteous_cover.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome::apc_proteous { + +ESPHOME_LOG_TAG(TAG, "apc_proteous.cover"); +static constexpr const char *START_CMD = "*1\r"; // start/stop toggle +static constexpr const char *OPEN_CMD = "*6\r"; +static constexpr const char *CLOSE_CMD = "*7\r"; +static constexpr const char *QUERY_S = "?s\r"; +static constexpr const char *QUERY_X = "?x\r"; + +// Depending on calibration the reported position may stop a little short of the physical limits +// (e.g. 99% at the fully open reed switch), so readings this close to an end count as that end. +static constexpr uint8_t ENDPOINT_MARGIN = 2; + +using namespace esphome::cover; + +void APCProteousCover::setup() { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(this); + } else { + this->position = 0.5f; + } +} + +void APCProteousCover::loop() { + uint8_t data; + while (this->available() > 0) { + if (!this->read_byte(&data)) { + continue; + } + if (data == '\r') { + if (this->rx_len_ != 0) { + this->rx_buffer_[this->rx_len_] = '\0'; + ESP_LOGVV(TAG, "rx: '%s'", this->rx_buffer_); + this->parse_response_(); + this->rx_len_ = 0; + } + } else if (data != '\n') { + if (this->rx_len_ >= MAX_RESPONSE_LEN) { + ESP_LOGVV(TAG, "rx overflow, discarding"); + this->rx_len_ = 0; + } + this->rx_buffer_[this->rx_len_++] = static_cast(data); + } + } +} + +void APCProteousCover::parse_response_() { + // Expected format: "?s=XX" or "?x=XX" where XX is a hex value + if (this->rx_len_ < 4 || this->rx_buffer_[0] != '?' || this->rx_buffer_[2] != '=') { + ESP_LOGV(TAG, "Invalid response: %s", this->rx_buffer_); + return; + } + + const char *hex_str = this->rx_buffer_ + 3; + char *end_ptr; + uint8_t value = static_cast(strtol(hex_str, &end_ptr, 16)); + if (end_ptr == hex_str) { + ESP_LOGW(TAG, "Failed to parse hex value: %s", this->rx_buffer_); + return; + } + + bool state_changed = false; + char type = this->rx_buffer_[1]; + if (type == 's') { + // bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing) + CoverOperation new_operation = COVER_OPERATION_IDLE; + if (value & 0x01) { + new_operation = (value & 0x02) ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; + } + if (this->current_operation != new_operation) { + this->current_operation = new_operation; + state_changed = true; + } + // Motion in the commanded direction acknowledges the pending command + if ((this->pending_command_ == OPEN_CMD && new_operation == COVER_OPERATION_OPENING) || + (this->pending_command_ == CLOSE_CMD && new_operation == COVER_OPERATION_CLOSING)) { + this->clear_pending_(); + } + // A partial move is over once the acknowledged motion has stopped, wherever it stopped, so an + // old target cannot halt a movement started from the wall button or the remote later on + if (new_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) { + this->target_position_.reset(); + } + ESP_LOGV(TAG, "s-status: 0x%02X (operation=%d)", value, new_operation); + } else if (type == 'x') { + // Position percentage, snapped to the ends so is_open/is_closed can latch despite calibration offsets + float new_position; + if (value + ENDPOINT_MARGIN >= 100) { + new_position = COVER_OPEN; + } else if (value <= ENDPOINT_MARGIN) { + new_position = COVER_CLOSED; + } else { + new_position = value / 100.0f; + } + if (this->position != new_position) { + this->position = new_position; + state_changed = true; + // Until the controller has confirmed motion in the commanded direction, current_operation may + // still show the previous one, so a reversing partial move must not be judged against it + if (this->target_position_.has_value() && this->pending_command_ == nullptr && + ((this->current_operation == COVER_OPERATION_OPENING && this->position >= *this->target_position_) || + (this->current_operation == COVER_OPERATION_CLOSING && this->position <= *this->target_position_))) { + ESP_LOGD(TAG, "Target position %.2f reached", *this->target_position_); + this->stop_cmd_(App.get_loop_component_start_time()); + } + } + ESP_LOGV(TAG, "x-status: %d%% (position=%.2f)", value, this->position); + } + + if (state_changed) { + this->publish_state(); + } +} + +void APCProteousCover::clear_pending_() { + this->pending_command_ = nullptr; + this->command_retries_ = 0; +} + +void APCProteousCover::retry_pending_command_(uint32_t now) { + if (this->pending_command_ == nullptr || (now - this->pending_command_time_) < COMMAND_ACK_MS) { + return; + } + bool is_open_cmd = this->pending_command_ == OPEN_CMD; + // Nothing to resend once the gate is at the commanded end + if ((is_open_cmd && this->position >= COVER_OPEN) || (!is_open_cmd && this->position <= COVER_CLOSED)) { + this->clear_pending_(); + return; + } + const LogString *label = is_open_cmd ? LOG_STR("open") : LOG_STR("close"); + if (this->command_retries_ >= MAX_COMMAND_RETRIES) { + ESP_LOGW(TAG, "Gate did not acknowledge %s command after %u retries", LOG_STR_ARG(label), this->command_retries_); + this->clear_pending_(); + this->target_position_.reset(); + return; + } + this->command_retries_++; + ESP_LOGD(TAG, "Gate did not respond, resending %s command (retry %u)", LOG_STR_ARG(label), this->command_retries_); + this->pending_command_time_ = now; + this->write_command_(this->pending_command_, now); +} + +void APCProteousCover::update() { + const uint32_t now = App.get_loop_component_start_time(); + this->retry_pending_command_(now); + + // Let the command and its echo clear the line before the next query + if ((now - this->last_command_tx_) < COMMAND_QUIET_MS) { + return; + } + // The line is idle, so anything left in the buffer is an unterminated echo + if (this->rx_len_ != 0) { + this->rx_buffer_[this->rx_len_] = '\0'; + ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->rx_buffer_); + this->rx_len_ = 0; + } + + const char *query = this->query_s_next_ ? QUERY_S : QUERY_X; + ESP_LOGVV(TAG, "tx: %s", query); + this->write_str(query); + this->query_s_next_ = !this->query_s_next_; +} + +void APCProteousCover::dump_config() { + LOG_COVER("", "APC Proteous Cover", this); + LOG_UPDATE_INTERVAL(this); +} + +CoverTraits APCProteousCover::get_traits() { + auto traits = CoverTraits(); + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void APCProteousCover::write_command_(const char *cmd, uint32_t now) { + ESP_LOGVV(TAG, "tx: %s", cmd); + this->last_command_tx_ = now; + this->write_str(cmd); +} + +void APCProteousCover::send_command_(const char *cmd) { + // The published state comes only from status reads; this just records the command so a stop + // can cancel it before the controller reports motion, and so it can be resent if dropped. + // The commands are idempotent, so only skip one when the gate is already moving that way. + CoverOperation operation = cmd == OPEN_CMD ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING; + if (this->current_operation == operation) { + return; + } + ESP_LOGD(TAG, "Sending %s command", + operation == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("open") : LOG_STR_LITERAL("close")); + const uint32_t now = App.get_loop_component_start_time(); + this->pending_command_ = cmd; + this->pending_command_time_ = now; + this->command_retries_ = 0; + this->write_command_(cmd, now); +} + +void APCProteousCover::stop_cmd_(uint32_t now) { + // START_CMD is a start/stop toggle, so it is only sent while the gate is moving, or while a + // movement command is in flight that the controller has not reported yet; otherwise it would start the gate. + if (this->current_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) { + ESP_LOGD(TAG, "Cover already idle, ignoring stop command"); + return; + } + ESP_LOGD(TAG, "Sending stop command"); + this->write_command_(START_CMD, now); + this->clear_pending_(); + this->target_position_.reset(); +} + +void APCProteousCover::control(const CoverCall &call) { + if (call.get_stop()) { + this->stop_cmd_(App.get_loop_component_start_time()); + } else if (call.get_position().has_value()) { + float target = *call.get_position(); + ESP_LOGD(TAG, "Target position: %.2f, current: %.2f", target, this->position); + // There is no position command: a partial target is reached by moving towards it and stopping + // once the reported position gets there + this->target_position_.reset(); + if (target == COVER_OPEN) { + this->send_command_(OPEN_CMD); + } else if (target == COVER_CLOSED) { + this->send_command_(CLOSE_CMD); + } else if (target != this->position) { + this->target_position_ = target; + this->send_command_(target > this->position ? OPEN_CMD : CLOSE_CMD); + } + } else if (call.get_toggle()) { + this->clear_pending_(); + this->target_position_.reset(); + this->write_command_(START_CMD, App.get_loop_component_start_time()); + } +} + +} // namespace esphome::apc_proteous diff --git a/esphome/components/apc_proteous/apc_proteous_cover.h b/esphome/components/apc_proteous/apc_proteous_cover.h new file mode 100644 index 00000000000..03c8eb5c4d1 --- /dev/null +++ b/esphome/components/apc_proteous/apc_proteous_cover.h @@ -0,0 +1,52 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/cover/cover.h" + +namespace esphome::apc_proteous { + +class APCProteousCover : public cover::Cover, public PollingComponent, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + + cover::CoverTraits get_traits() override; + + protected: + void control(const cover::CoverCall &call) override; + void parse_response_(); + void send_command_(const char *cmd); + void write_command_(const char *cmd, uint32_t now); + void stop_cmd_(uint32_t now); + void retry_pending_command_(uint32_t now); + void clear_pending_(); + + // The longest valid frame is "?s=XX"; anything longer is line noise + static constexpr size_t MAX_RESPONSE_LEN = 16; + + // A movement command is acknowledged once the controller reports motion in the commanded + // direction; resend it this often, up to MAX_COMMAND_RETRIES times, since the serial link + // occasionally drops one. The stop toggle is never resent because a spurious toggle could start the gate. + static constexpr uint32_t COMMAND_ACK_MS = 2000; + static constexpr uint8_t MAX_COMMAND_RETRIES = 3; + + // The controller echoes every command, sometimes without a terminator. A query sent too soon + // after a command collides with that echo, so polling pauses for this long after each command. + static constexpr uint32_t COMMAND_QUIET_MS = 250; + + // Set only for a partial target; the gate is stopped when the reported position reaches it + optional target_position_{}; + // Last movement command sent and not yet acknowledged; lets a stop cancel it and drives the retries + const char *pending_command_{nullptr}; + uint32_t pending_command_time_{0}; + uint32_t last_command_tx_{0}; + char rx_buffer_[MAX_RESPONSE_LEN + 1]; + uint8_t rx_len_{0}; + uint8_t command_retries_{0}; + bool query_s_next_{true}; +}; + +} // namespace esphome::apc_proteous diff --git a/esphome/components/apc_proteous/cover.py b/esphome/components/apc_proteous/cover.py new file mode 100644 index 00000000000..4c38589580c --- /dev/null +++ b/esphome/components/apc_proteous/cover.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import cover, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +apc_proteous_ns = cg.esphome_ns.namespace("apc_proteous") +APCProteousCover = apc_proteous_ns.class_( + "APCProteousCover", cover.Cover, cg.PollingComponent, uart.UARTDevice +) + +CONFIG_SCHEMA = ( + cover.cover_schema(APCProteousCover) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.polling_component_schema("500ms")) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await cover.new_cover(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9611228ba1d..44d8b443676 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -251,9 +251,6 @@ def _validate_supports_response(value: Any) -> str: return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) -# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small -ESP8266_ACTION_STRINGS_MAX_TOTAL = 384 - VARIABLE_SCHEMA = cv.Schema( { cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), @@ -484,29 +481,6 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: return strings -def _action_strings_size(strings: list[str | None]) -> int: - """Bytes needed to copy every string out of flash, each with its terminator.""" - return sum( - len(string.encode("utf-8")) + 1 for string in strings if string is not None - ) - - -def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: - if not CORE.is_esp8266: - return config - actions = config.get(CONF_ACTIONS, []) - has_metadata = _has_action_metadata(actions) - for conf in actions: - size = _action_strings_size(_action_strings(conf, has_metadata)) - if size > ESP8266_ACTION_STRINGS_MAX_TOTAL: - raise cv.Invalid( - f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, " - f"description and example text; ESP8266 allows at most " - f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action" - ) - return config - - def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: """An IPv6 host can never be parsed, so never dialed, without IPv6.""" if ( @@ -531,7 +505,6 @@ def _validate_wizard(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = cv.All( - _validate_esp8266_action_strings, _validate_outgoing_host_ipv6, _validate_wizard, ) @@ -587,7 +560,6 @@ async def to_code(config: ConfigType) -> None: if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") - scratch_size = 0 if actions: # Metadata is compiled in for every action once any action declares it, because the # string table layout is fixed by the define rather than per action @@ -639,8 +611,6 @@ async def to_code(config: ConfigType) -> None: func_args.append((native, name)) strings = _action_strings(conf, has_metadata) table = _add_action_strings(index, strings) - if CORE.is_esp8266: - scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types templ = cg.TemplateArguments(supports_response, *service_template_args) # Key is hashed here because the name is not readable at runtime on ESP8266 @@ -667,9 +637,6 @@ async def to_code(config: ConfigType) -> None: cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) - if CORE.is_esp8266 and has_user_actions: - # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action - cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: await wizard.to_code(wizard_config) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f8f31d81df0..679aff06b5e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -403,6 +403,9 @@ message WizardCapabilities { message SendspinCapabilities { // Bitmask of the Sendspin features this device supports uint32 feature_flags = 1; + // The Sendspin client_id: base64url of the client's static public key, 43 characters. + // Empty until the Sendspin client has started once since boot. + string client_id = 2 [(max_data_length) = 43]; } message DeviceCapabilitiesResponse { @@ -1026,8 +1029,8 @@ message SubscribeHomeassistantServicesRequest { } message HomeassistantServiceMap { - string key = 1; - string value = 2; + string key = 1 [(progmem) = true]; + string value = 2 [(progmem) = true]; } message HomeassistantActionRequest { @@ -1036,7 +1039,7 @@ message HomeassistantActionRequest { option (no_delay) = true; option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; - string service = 1; + string service = 1 [(progmem) = true]; repeated HomeassistantServiceMap data = 2 [(fixed_vector) = true]; repeated HomeassistantServiceMap data_template = 3 [(fixed_vector) = true]; repeated HomeassistantServiceMap variables = 4 [(fixed_vector) = true]; @@ -1157,21 +1160,21 @@ enum SupportsResponseType { } message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; - string name = 1; + string name = 1 [(progmem) = true]; ServiceArgType type = 2; - string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; - string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; + string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; } message ListEntitiesServicesResponse { option (id) = 41; option (source) = SOURCE_SERVER; option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; - string name = 1; + string name = 1 [(progmem) = true]; fixed32 key = 2 [(force) = true]; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; SupportsResponseType supports_response = 4; - string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; @@ -3043,12 +3046,15 @@ enum SerialProxyIdentityFlag { // IDs below are empty } -// The descriptor fields of a USB device as seen by the host stack +// The descriptor fields of a USB device as seen by the host stack, and the port's index +// among those a vendor driver exposes on the interface message UsbDeviceDescriptor { uint32 vendor_id = 1; uint32 product_id = 2; uint32 bcd_device = 3; - uint32 interface_number = 4; // bInterfaceNumber the host driver binds to + uint32 interface_number = 4; // bInterfaceNumber the port is bound through + uint32 interface_class = 5; // bInterfaceClass of that interface + uint32 port_number = 6; } // The identity of the device behind a port. Identifies one physical device among others of diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fd51aae1be8..bcb6486a84d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,4 +1,7 @@ #include "api_connection.h" +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#include "esphome/components/logger/logger.h" +#endif #ifdef USE_API #include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines #ifdef USE_API_NOISE @@ -451,6 +454,15 @@ bool APIConnection::send_disconnect_response_() { DisconnectResponse resp; return this->send_message(resp); } +void APIConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { + this->flags_.log_subscription = msg.level; + if (msg.dump_config) + App.schedule_dump_config(); +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) + logger::log_crash_to_client(); +#endif +} + void APIConnection::on_disconnect_response() { // Don't close socket here, let APIServer::loop() do it // so getpeername() still works for the disconnect trigger @@ -2119,6 +2131,7 @@ bool APIConnection::send_device_capabilities_response_() { #endif #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); + resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9f784185b83..863e378a5e5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -15,15 +15,6 @@ #include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" -#ifdef USE_ESP32_CRASH_HANDLER -#include "esphome/components/esp32/crash_handler.h" -#endif -#ifdef USE_RP2_CRASH_HANDLER -#include "esphome/components/rp2/crash_handler.h" -#endif -#ifdef USE_ESP8266_CRASH_HANDLER -#include "esphome/components/esp8266/crash_handler.h" -#endif #include "esphome/core/entity_base.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" @@ -298,21 +289,7 @@ class APIConnection final : public APIServerConnectionBase { this->begin_iterator_(ActiveIterator::INITIAL_STATE); } } - void on_subscribe_logs_request(const SubscribeLogsRequest &msg) { - this->flags_.log_subscription = msg.level; - if (msg.dump_config) - App.schedule_dump_config(); -#ifdef USE_ESP32_CRASH_HANDLER - esp32::crash_handler_log(); - esp32::crash_handler_clear(); -#endif -#ifdef USE_RP2_CRASH_HANDLER - rp2::crash_handler_log(); -#endif -#ifdef USE_ESP8266_CRASH_HANDLER - esp8266::crash_handler_log(); -#endif - } + void on_subscribe_logs_request(const SubscribeLogsRequest &msg); #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; } #endif diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 8e8fa99833d..052b0c36bc7 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -123,7 +123,7 @@ extend google.protobuf.FieldOptions { // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). optional bool track_presence = 50020 [default=false]; - // progmem: The pointer_to_buffer bytes field points into flash (PROGMEM). + // progmem: The string field, or pointer_to_buffer bytes field, points into flash (PROGMEM). // The generated encode and dump copy it with progmem_memcpy, as ESP8266 // cannot read flash with ordinary loads. optional bool progmem = 50021 [default=false]; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8847249b173..8f7b622bc96 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id); return pos; } uint32_t SendspinCapabilities::calc_size_msg(const void *self) { const auto &msg = *static_cast(self); uint32_t size = 0; size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0; return size; } #endif @@ -1140,8 +1142,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) { #ifdef USE_API_HOMEASSISTANT_SERVICES uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); return pos; } uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { @@ -1153,7 +1155,7 @@ uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { } uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); for (auto &it : msg.data) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } @@ -1324,13 +1326,13 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); #endif return pos; } @@ -1350,14 +1352,14 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); for (auto &it : msg.args) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); #endif return pos; } @@ -4175,6 +4177,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4184,6 +4188,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.interface_number); + size += ProtoSize::calc_uint32(1, msg.interface_class); + size += ProtoSize::calc_uint32(1, msg.port_number); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14204e97aaa..fd12882dc9a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; + StringRef client_id{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -1388,8 +1389,8 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_SERVICES class HomeassistantServiceMap final : public ProtoMessage { public: - StringRef key{nullptr, 0}; // null until set, encode only - StringRef value{nullptr, 0}; // null until set, encode only + ProgmemStringRef key{}; + ProgmemStringRef value{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -1409,7 +1410,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } #endif - StringRef service{nullptr, 0}; // null until set, encode only + ProgmemStringRef service{}; FixedVector data{}; FixedVector data_template{}; FixedVector variables{}; @@ -1576,13 +1577,13 @@ class GetTimeResponse final : public ProtoDecodableMessage { #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: - StringRef name{nullptr, 0}; // null until set, encode only + ProgmemStringRef name{}; enums::ServiceArgType type{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{nullptr, 0}; // null until set, encode only + ProgmemStringRef description{}; #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef example{nullptr, 0}; // null until set, encode only + ProgmemStringRef example{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1603,12 +1604,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif - StringRef name{nullptr, 0}; // null until set, encode only + ProgmemStringRef name{}; uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{nullptr, 0}; // null until set, encode only + ProgmemStringRef description{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -4083,6 +4084,8 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; + uint32_t interface_class{0}; + uint32_t port_number{0}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..688ec48e6e6 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -12,8 +12,9 @@ namespace esphome::api { #ifdef USE_ESP8266 // Out-of-line to avoid inlining strlen_P/memcpy_P at every call site -void DumpBuffer::append_p_esp8266(const char *str) { - size_t len = strlen_P(str); +void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); } + +void DumpBuffer::append_p_esp8266(const char *str, size_t len) { size_t space = CAPACITY - 1 - pos_; if (len > space) len = space; @@ -134,6 +135,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co progmem_memcpy(data_buf, data, len); dump_bytes_field(out, field_name, data_buf, len, indent); } + +// Helper for string fields in flash +static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, + int indent = 2) { + append_field_prefix(out, field_name, indent); + out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\n"); +} #pragma GCC diagnostic pop template<> const char *proto_enum_to_string(enums::DisconnectReason value) { @@ -1119,6 +1127,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("client_id"), this->client_id); return out.c_str(); } #endif @@ -1511,13 +1520,13 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_HOMEASSISTANT_SERVICES const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantServiceMap")); - dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("value"), this->value); + dump_progmem_string_field(out, ESPHOME_PSTR("key"), this->key); + dump_progmem_string_field(out, ESPHOME_PSTR("value"), this->value); return out.c_str(); } const char *HomeassistantActionRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantActionRequest")); - dump_field(out, ESPHOME_PSTR("service"), this->service); + dump_progmem_string_field(out, ESPHOME_PSTR("service"), this->service); for (const auto &it : this->data) { out.append(4, ' ').append_p(ESPHOME_PSTR("data")).append(": "); it.dump_to(out); @@ -1612,19 +1621,19 @@ const char *GetTimeResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_USER_DEFINED_ACTIONS const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument")); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("description"), this->description); + dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("example"), this->example); + dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example); #endif return out.c_str(); } const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesResponse")); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("key"), this->key); for (const auto &it : this->args) { out.append(4, ' ').append_p(ESPHOME_PSTR("args")).append(": "); @@ -1633,7 +1642,7 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { } dump_field(out, ESPHOME_PSTR("supports_response"), static_cast(this->supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - dump_field(out, ESPHOME_PSTR("description"), this->description); + dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description); #endif return out.c_str(); } @@ -2942,6 +2951,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); + dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index f63039a9714..a8d14256dcb 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -547,7 +547,7 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call // arrive before the subscription and are lost - warn instead of failing silently. ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s", call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), - static_cast(call.service.size()), call.service.empty() ? "" : call.service.c_str(), + static_cast(call.service.size()), call.service.empty() ? "" : call.service.progmem_ptr(), this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)") : LOG_STR_LITERAL("no client connected")); } diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index c2b0f14bcc5..208c956eb17 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -247,7 +247,7 @@ class CustomAPIDevice { */ void call_homeassistant_service(const std::string &service_name) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); global_api_server->send_homeassistant_action(resp); } @@ -267,12 +267,12 @@ class CustomAPIDevice { */ void call_homeassistant_service(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); resp.data.init(data.size()); for (auto &it : data) { auto &kv = resp.data.emplace_back(); - kv.key = StringRef(it.first); - kv.value = StringRef(it.second); // data map lives until send completes + kv.key = ProgmemStringRef(StringRef(it.first)); + kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes } global_api_server->send_homeassistant_action(resp); } @@ -289,7 +289,7 @@ class CustomAPIDevice { */ void fire_homeassistant_event(const std::string &event_name) { HomeassistantActionRequest resp; - resp.service = StringRef(event_name); + resp.service = ProgmemStringRef(StringRef(event_name)); resp.is_event = true; global_api_server->send_homeassistant_action(resp); } @@ -309,13 +309,13 @@ class CustomAPIDevice { */ void fire_homeassistant_event(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; - resp.service = StringRef(service_name); + resp.service = ProgmemStringRef(StringRef(service_name)); resp.is_event = true; resp.data.init(data.size()); for (auto &it : data) { auto &kv = resp.data.emplace_back(); - kv.key = StringRef(it.first); - kv.value = StringRef(it.second); // data map lives until send completes + kv.key = ProgmemStringRef(StringRef(it.first)); + kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes } global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index e7798a5d267..8964e8ca352 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -116,42 +116,18 @@ template class HomeAssistantServiceCallAction final : public Act const Field *fields = this->fields_; const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_; - // Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send + // Lambda results must live until the send; the constant strings are encoded from flash where they are size_t lambda_count = 0; -#ifdef USE_ESP8266 - size_t flash_len = 0; -#endif - for (size_t i = 0; i < total; i++) { + for (size_t i = 0; i < total; i++) lambda_count += fields[i].fn != nullptr; -#ifdef USE_ESP8266 - if (fields[i].fn == nullptr) - flash_len += ESPHOME_strlen_P(fields[i].value); - if (fields[i].key != nullptr) - flash_len += ESPHOME_strlen_P(fields[i].key); -#endif - } FixedVector results; results.init(lambda_count); -#ifdef USE_ESP8266 - SmallBufferWithHeapFallback<128, char> flash_copy(flash_len); - char *cursor = flash_copy.get(); -#endif - auto string_ref = [&](const char *str) { -#ifdef USE_ESP8266 - size_t len = ESPHOME_strlen_P(str); - memcpy_P(cursor, str, len); - StringRef ref(cursor, len); - cursor += len; - return ref; -#else - return StringRef(str); -#endif - }; + auto string_ref = [](const char *str) { return ProgmemStringRef(str, ESPHOME_strlen_P(str)); }; auto value_ref = [&](const Field &field) { if (field.fn == nullptr) return string_ref(field.value); results.push_back(field.fn(x...)); - return StringRef(results.back()); + return ProgmemStringRef(StringRef(results.back())); }; auto fill = [&](FixedVector &dest, uint8_t count) { dest.init(count); diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 507b098fb48..57ff616ca7c 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -99,8 +99,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3; bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { - UserActionScratch scratch; - auto resp = service->encode_list_service_response(scratch); + auto resp = service->encode_list_service_response(); if (!this->client_->send_message(resp)) return false; // at_ is this service's index diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0fdda5067ea..e01deccbc21 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -418,6 +418,18 @@ class ProtoEncode { std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); } + /// encode_short_string_force for a string that may be in flash (PROGMEM). + [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) { +#ifdef ESPHOME_DEBUG_API + assert(ref.size() < 128 && "encode_short_progmem_string_force: string exceeds max_data_length < 128"); +#endif + PROTO_ENCODE_CHECK_BOUNDS(pos, 2 + ref.size()); + pos[0] = tag; + pos[1] = static_cast(ref.size()); + progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size()); + return pos + 2 + ref.size(); + } /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) { @@ -481,6 +493,17 @@ class ProtoEncode { progmem_memcpy(pos, data, len); return pos + len; } + /// encode_progmem_bytes for a string field that may be in flash. + [[nodiscard]] static inline uint8_t *encode_progmem_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const ProgmemStringRef &ref) { + return encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, field_id, + reinterpret_cast(ref.progmem_ptr()), ref.size()); + } + [[nodiscard]] static inline uint8_t *encode_progmem_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const ProgmemStringRef &ref) { + return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, + reinterpret_cast(ref.progmem_ptr()), ref.size()); + } [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); @@ -680,9 +703,20 @@ class DumpBuffer { return *this; } + /// Append len bytes of a PROGMEM string + DumpBuffer &append_p(const char *str, size_t len) { +#ifdef USE_ESP8266 + append_p_esp8266(str, len); +#else + append_impl_(str, len); +#endif + return *this; + } + #ifdef USE_ESP8266 /// Out-of-line ESP8266 PROGMEM append to avoid inlining strlen_P/memcpy_P at every call site void append_p_esp8266(const char *str); + void append_p_esp8266(const char *str, size_t len); #endif const char *c_str() const { return buf_; } diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index fad3cde29b9..a9836170640 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -1,47 +1,36 @@ #include "user_services.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::api { -StringRef UserServiceStatic::str_(size_t idx, std::span &scratch) const { +ProgmemStringRef UserServiceStatic::str_(size_t idx) const { const char *s = progmem_read_ptr(&this->strings_[idx]); if (s == nullptr) return {}; -#ifdef USE_ESP8266 - // Codegen sizes the scratch buffer for the largest service; the bound only guards other callers - if (scratch.empty()) - return {}; - size_t len = strnlen_P(s, scratch.size() - 1); - progmem_memcpy(scratch.data(), s, len); - scratch[len] = '\0'; - StringRef ref(scratch.data(), len); - scratch = scratch.subspan(len + 1); - return ref; -#else - return StringRef(s); -#endif + return ProgmemStringRef(s, ESPHOME_strlen_P(s)); } ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_( - std::span arg_types, std::span scratch) const { + std::span arg_types) const { ListEntitiesServicesResponse msg; - msg.name = this->str_(0, scratch); + msg.name = this->str_(0); msg.key = this->key_; msg.supports_response = this->supports_response_; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - msg.description = this->str_(1, scratch); + msg.description = this->str_(1); #endif msg.args.init(arg_types.size()); for (size_t i = 0; i < arg_types.size(); i++) { size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS; auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; - arg.name = this->str_(base, scratch); + arg.name = this->str_(base); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - arg.description = this->str_(base + 1, scratch); - arg.example = this->str_(base + 2, scratch); + arg.description = this->str_(base + 1); + arg.example = this->str_(base + 2); #endif } return msg; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 3b17bdb7bcd..057e04dd7a7 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -20,9 +20,8 @@ class APIServer; class UserServiceDescriptor { public: - /// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into - /// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it. - virtual ListEntitiesServicesResponse encode_list_service_response(std::span scratch) = 0; + /// Build the list-entities message; its strings may point into flash (PROGMEM). + virtual ListEntitiesServicesResponse encode_list_service_response() = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -37,13 +36,6 @@ template T get_execute_arg_value(const ExecuteServiceArgument &arg); template enums::ServiceArgType to_service_arg_type(); -// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it -#ifdef USE_ESP8266 -using UserActionScratch = std::array; -#else -using UserActionScratch = std::array; -#endif - // Non-template base for YAML-defined services so the list-entities encoder is compiled once. // All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in // __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then @@ -63,12 +55,9 @@ class UserServiceStatic : public UserServiceDescriptor { : strings_(strings), key_(key), supports_response_(supports_response) {} protected: - ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types, - std::span scratch) const; - /// Reference table entry `idx`; nullptr gives an empty StringRef. - /// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span - /// is advanced past the copy. - StringRef str_(size_t idx, std::span &scratch) const; + ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types) const; + /// Table entry `idx` as it sits in flash; nullptr gives an empty string. + ProgmemStringRef str_(size_t idx) const; const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr() uint32_t key_; @@ -79,9 +68,9 @@ template class UserServiceBase : public UserServiceStatic { public: using UserServiceStatic::UserServiceStatic; - ListEntitiesServicesResponse encode_list_service_response(std::span scratch) override { + ListEntitiesServicesResponse encode_list_service_response() override { std::array arg_types = {to_service_arg_type()...}; - return this->encode_list_service_response_(arg_types, scratch); + return this->encode_list_service_response_(arg_types); } bool execute_service(const ExecuteServiceRequest &req) override { @@ -125,9 +114,9 @@ template class UserServiceDynamic : public UserServiceDescriptor this->key_ = fnv1_hash(this->name_.c_str()); } - ListEntitiesServicesResponse encode_list_service_response(std::span /*scratch*/) override { + ListEntitiesServicesResponse encode_list_service_response() override { ListEntitiesServicesResponse msg; - msg.name = StringRef(this->name_); + msg.name = ProgmemStringRef(StringRef(this->name_)); msg.key = this->key_; msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet std::array arg_types = {to_service_arg_type()...}; @@ -135,7 +124,7 @@ template class UserServiceDynamic : public UserServiceDescriptor for (size_t i = 0; i < sizeof...(Ts); i++) { auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; - arg.name = StringRef(this->arg_names_[i]); + arg.name = ProgmemStringRef(StringRef(this->arg_names_[i])); } return msg; } diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py new file mode 100644 index 00000000000..bd43f78f01e --- /dev/null +++ b/esphome/components/as734x/__init__.py @@ -0,0 +1,188 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_TYPE, + CONF_UPDATE_INTERVAL, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita", "@mrgnr"] +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensor"] +MULTI_CONF = True +DOMAIN = "as734x" + +CONF_AS734X_ID = "as734x_id" +CONF_ATIME = "atime" +CONF_ASTEP = "astep" +CONF_CHANNEL_CORRECTION = "channel_correction" +CONF_DARK_CURRENT = "dark_current" +CONF_NORMALIZE_BASIC_COUNTS = "normalize_basic_counts" + +# Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot. +BANDS_43 = ( + "f1", + "f2", + "fz", + "f3", + "f4", + "fy", + "f5", + "fxl", + "f6", + "f7", + "f8", + "nir", + "clear", +) +BANDS_41 = tuple(b for b in BANDS_43 if b not in ("fz", "fy", "fxl")) + +as734x_ns = cg.esphome_ns.namespace("as734x") +AS734XComponent = as734x_ns.class_( + "AS734XComponent", cg.PollingComponent, i2c.I2CDevice +) + +Gain = as734x_ns.enum("Gain") +GAINS_41 = { + f"X{name}": getattr(Gain, f"GAIN_{name.replace('.', '_')}X") + for name in ("0.5", "1", "2", "4", "8", "16", "32", "64", "128", "256", "512") +} +GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} + +Normalization = as734x_ns.enum("Normalization", True) +NORMALIZATIONS = { + "NONE": Normalization.NORMALIZATION_NONE, + "ALL": Normalization.NORMALIZATION_ALL, + "BANDS": Normalization.NORMALIZATION_BANDS, + "CLEAR": Normalization.NORMALIZATION_CLEAR, +} + +# Per-channel correction for the golden device, measured by the manufacturer. +DEFAULT_CHANNEL_CORRECTION_43 = [ + 1.055464349, + 1.043509797, + 1.029576268, + 1.0175052, + 1.00441899, + 0.987356499, + 0.957597044, + 0.995863485, + 1.014628964, + 0.996500814, + 0.933072749, + 1.052236338, + 0.999570232, +] + +# One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us +CYCLE_STEP_SECONDS = 2.78e-6 + + +def _float_list(length: int) -> cv.All: + """One float per band, so the list length has to match the chip.""" + return cv.All(cv.ensure_list(cv.float_), cv.Length(min=length, max=length)) + + +def _calibration_schema(bands: tuple[str, ...]) -> cv.Schema: + correction = ( + DEFAULT_CHANNEL_CORRECTION_43 if bands == BANDS_43 else [1.0] * len(bands) + ) + return cv.Schema( + { + cv.Optional(CONF_CHANNEL_CORRECTION, default=correction): _float_list( + len(bands) + ), + cv.Optional(CONF_DARK_CURRENT, default=[0.0] * len(bands)): _float_list( + len(bands) + ), + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.positive_float, + } + ) + + +def _validate_exposure(cycles: int): + """Reject an exposure that is empty or longer than the update interval.""" + + def validator(config: ConfigType) -> ConfigType: + atime, astep = config[CONF_ATIME], config[CONF_ASTEP] + if atime == 0 and astep == 0: + raise cv.Invalid(f"{CONF_ATIME} and {CONF_ASTEP} must not both be 0") + measurement = (atime + 1) * (astep + 1) * CYCLE_STEP_SECONDS * cycles + if measurement > config[CONF_UPDATE_INTERVAL].total_seconds: + raise cv.Invalid( + f"A measurement takes {measurement:.1f}s with these {CONF_ATIME} and " + f"{CONF_ASTEP} values, longer than the {CONF_UPDATE_INTERVAL}" + ) + return config + + return validator + + +def _schema( + gains: dict, address: int, cycles: int, bands: tuple[str, ...] +) -> cv.Schema: + return cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AS734XComponent), + cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), + cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), + cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands), + cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum( + NORMALIZATIONS, upper=True + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(address)), + _validate_exposure(cycles), + ) + + +CONFIG_SCHEMA = cv.typed_schema( + { + # two SMUX steps per update + "AS7341": _schema(GAINS_41, 0x39, cycles=2, bands=BANDS_41), + "AS7343": _schema(GAINS_43, 0x39, cycles=3, bands=BANDS_43), + "TCS3448": _schema(GAINS_43, 0x59, cycles=3, bands=BANDS_43), + }, + upper=True, +) + + +def _per_band( + values: list[float], bands: tuple[str, ...], absent: float +) -> list[float]: + """Spread per-band calibration over the 13 slots the C++ side indexes by band.""" + spread = [absent] * len(BANDS_43) + for value, band in zip(values, bands, strict=True): + spread[BANDS_43.index(band)] = value + return spread + + +async def to_code(config: ConfigType) -> None: + chip = config[CONF_TYPE] + cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") + var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&as734x::{chip}_CHIP")) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_gain(config[CONF_GAIN])) + cg.add(var.set_atime(config[CONF_ATIME])) + cg.add(var.set_astep(config[CONF_ASTEP])) + cg.add(var.set_basic_counts_normalization(config[CONF_NORMALIZE_BASIC_COUNTS])) + + bands = BANDS_41 if chip == "AS7341" else BANDS_43 + calibration = config[CONF_CALIBRATION] + cg.add( + var.set_channel_correction( + _per_band(calibration[CONF_CHANNEL_CORRECTION], bands, 1.0) + ) + ) + cg.add(var.set_dark_current(_per_band(calibration[CONF_DARK_CURRENT], bands, 0.0))) + cg.add(var.set_glass_attenuation_factor(calibration[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp new file mode 100644 index 00000000000..5337b3918eb --- /dev/null +++ b/esphome/components/as734x/as734x.cpp @@ -0,0 +1,674 @@ +#include "as734x.h" +#include "color_helpers.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::as734x { + +ESPHOME_LOG_TAG(TAG, "as734x"); + +// Registers and bits shared by both chips +static constexpr uint8_t REG_ENABLE = 0x80; +static constexpr uint8_t REG_ATIME = 0x81; +static constexpr uint8_t REG_ASTATUS = 0x94; +static constexpr uint8_t ENABLE_PON = 0x01; +static constexpr uint8_t ENABLE_SP_EN = 0x02; +static constexpr uint8_t ENABLE_SMUX_EN = 0x10; +static constexpr uint8_t CFG0_REG_BANK = 0x10; +static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set +static constexpr uint8_t STATUS2_AVALID = 0x40; +static constexpr uint8_t ASTATUS_ASAT = 0x80; +static constexpr uint8_t LED_ACT = 0x80; // LED driver on, in the chip's LED register +static constexpr uint8_t CONFIG_LED_SEL = 0x08; + +static constexpr uint8_t AS7341_CFG6 = 0xAF; +static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain +static constexpr uint8_t AS7341_SMUX_RAM = 0x00; +static constexpr uint8_t AS7343_CHAIN_CMD = 0xE4; +static constexpr uint8_t AS7343_CHAIN_SMUX = 0xE7; +static constexpr uint8_t AS7343_CHAINS = 3; + +// Read one field of a ChipInfo kept in flash +template static T pgm(const T &field) { + T value; + progmem_memcpy(&value, &field, sizeof(T)); + return value; +} + +static constexpr uint8_t NO_SLOT = 0xFF; +static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1; +static constexpr uint8_t MAX_SMUX_LEN = 20; +static constexpr uint8_t MAX_ADC_SLOTS = 18; +static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000; +static constexpr uint32_t MAX_ADC_COUNT = 65535; + +PROGMEM_STRING_TABLE(BandNames, "F1", "F2", "FZ", "F3", "F4", "FY", "F5", "FXL", "F6", "F7", "F8", "NIR", "Clear"); + +#ifdef USE_AS7341 +static const char AS7341_NAME[] PROGMEM = "AS7341"; +static constexpr uint8_t AS7341_ADC_SLOTS = 6; +static constexpr uint8_t AS7341_SMUX_LEN = 20; +static constexpr uint8_t AS7341_SMUX_STEPS = 2; +static const RegValue AS7341_INIT[] PROGMEM = {{0x70, 0x00}}; // CONFIG: SPM mode, INT_SEL and LED_SEL cleared +static const uint8_t AS7341_SMUX[] PROGMEM = { + 0x30, 0x01, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x50, 0x00, // step 0: F1 to F4, NIR, Clear + 0x00, 0x00, 0x20, 0x04, 0x00, 0x30, 0x01, 0x50, 0x00, 0x06, // step 0, continued + 0x00, 0x00, 0x00, 0x40, 0x02, 0x00, 0x10, 0x03, 0x50, 0x10, // step 1: F5 to F8, NIR, Clear + 0x03, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x50, 0x00, 0x06, // step 1, continued +}; +static_assert(sizeof(AS7341_SMUX) == AS7341_SMUX_STEPS * AS7341_SMUX_LEN && AS7341_SMUX_LEN <= MAX_SMUX_LEN); +static constexpr uint8_t N = NO_SLOT; +static const uint8_t AS7341_READOUT[] PROGMEM = { + 0, 1, N, 2, 3, N, N, N, N, N, N, N, N, // step 0 + N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5 +}; +static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS); +// This chip has no FZ, FY or FXL band, so those slots are marked absent. +// clang-format off +static const uint16_t AS7341_WAVELENGTHS[] PROGMEM = { + 415, 445, ABSENT_NM, 480, 515, ABSENT_NM, 555, ABSENT_NM, 590, 630, 680, 910, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, the same for every band. +// The last two entries stay at zero because this chip has no 1024x or 2048x gain. +static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f, + 1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f}; +static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float)); +// clang-format off +// The three bands this chip does not have contribute nothing to the weighted sums. +static const ChannelContribution AS7341_CONTRIBUTIONS[] PROGMEM = { + {0.005261294f, 5.936230324f, 0.020518273f}, // F1 + {0.242615252f, 3.572528495f, 0.013525490f}, // F2 + {0.0f, 0.0f, 0.0f}, // FZ, absent + {0.174482552f, 2.914113437f, 0.011585145f}, // F3 + {1.479499075f, 2.452851227f, 0.010604986f}, // F4 + {0.0f, 0.0f, 0.0f}, // FY, absent + {1.906818555f, 1.940656433f, 0.008890531f}, // F5 + {0.0f, 0.0f, 0.0f}, // FXL, absent + {1.162058142f, 1.533976606f, 0.007441544f}, // F6 + {0.462374845f, 1.819838250f, 0.009393590f}, // F7 + {-0.017794572f, 1.232942274f, 0.006890812f}, // F8 + {-0.260730901f, -1.102368000f, -0.004647911f}, // NIR + {-0.022604075f, -0.033304813f, -0.000129106f}, // Clear +}; +static const ChannelTristimulus AS7341_TRISTIMULUS[] PROGMEM = { + {0.39814f, 0.01396f, 1.95010f}, // F1 + {1.29540f, 0.16748f, 6.45490f}, // F2 + {0.0f, 0.0f, 0.0f}, // FZ, absent + {0.36956f, 0.23538f, 2.78010f}, // F3 + {0.10902f, 1.42750f, 0.18501f}, // F4 + {0.0f, 0.0f, 0.0f}, // FY, absent + {0.71942f, 1.88670f, 0.15325f}, // F5 + {0.0f, 0.0f, 0.0f}, // FXL, absent + {1.78180f, 1.14200f, 0.09539f}, // F6 + {1.10110f, 0.46497f, 0.10563f}, // F7 + {-0.03991f, -0.02702f, 0.08866f}, // F8 + {-0.27597f, -0.24468f, -0.61140f}, // NIR + {-0.02347f, -0.01993f, -0.00938f}, // Clear +}; +// clang-format on +static_assert(sizeof(AS7341_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution)); +static_assert(sizeof(AS7341_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus)); +const ChipInfo AS7341_CHIP PROGMEM = { + .name = AS7341_NAME, + .init = AS7341_INIT, + .smux = AS7341_SMUX, + .readout = AS7341_READOUT, + .wavelengths = AS7341_WAVELENGTHS, + .gain_correction = AS7341_GAIN_CORRECTION, + .contributions = AS7341_CONTRIBUTIONS, + .tristimulus = AS7341_TRISTIMULUS, + .init_count = 1, + .astep_reg = 0xCA, + .cfg0_reg = 0xA9, + .cfg1_reg = 0xAA, + .status2_reg = 0xA3, + .id_reg = 0x92, + .id_mask = 0xFC, + .id_value = 0x09 << 2, + .led_reg = 0x74, + .led_sel_reg = 0x70, // CONFIG, whose LED_SEL bit connects the pin to the driver + .adc_slots = AS7341_ADC_SLOTS, + .smux_steps = AS7341_SMUX_STEPS, + .integration_cycles = 1, + .smux_len = AS7341_SMUX_LEN, + .auto_smux = false, + .gain_correction_per_band = false, + .extra_clear_slot = NO_SLOT, +}; +#endif + +#ifdef USE_AS7343 +static const char AS7343_NAME[] PROGMEM = "AS7343"; +static constexpr uint8_t AS7343_ADC_SLOTS = 18; +static constexpr uint8_t AS7343_SMUX_LEN = 10; +static const char TCS3448_NAME[] PROGMEM = "TCS3448"; +// CFG6, FD_CFG0, CFG10, CFG8, CFG20 (auto_smux, 18 channels in 3 cycles), AGC_GAIN_MAX, FD_TIME_1, FD_TIME_2 +static const RegValue AS7343_INIT[] PROGMEM = {{0xF5, 0x00}, {0xDF, 0xA1}, {0x65, 0xF2}, {0xC9, 0xC8}, + {0xD6, 0x62}, {0xD7, 0x99}, {0xE0, 0x64}, {0xE2, 0x21}}; +static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05, 0x00, 0x01, 0x00, 0x30, + 0x00, 0x00, 0x60, 0x20, 0x04, 0x50, 0x03, 0x00, 0x01, 0x05, + 0x05, 0x00, 0x60, 0x00, 0x30, 0x00, 0x40, 0x10, 0x20, 0x00}; +// Clear is the average of the cycle 1 (slot 4) and cycle 2 (slot 10) readings; cycle 3 always reads 0. +static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10}; +static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN); +static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS); +// clang-format off +static const uint16_t AS7343_WAVELENGTHS[] PROGMEM = { + 405, 425, 450, 475, 515, 555, 550, 600, 640, 690, 745, 855, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7343_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, per band. +// clang-format off +static const float AS7343_GAIN_CORRECTION[] PROGMEM = { + 1.149f, 1.100f, 1.060f, 1.070f, 1.063f, 1.051f, 1.062f, 1.056f, 1.049f, 1.040f, 1.080f, 1.038f, 1.065f, // 0.5x + 1.090f, 1.128f, 1.064f, 1.071f, 1.063f, 1.050f, 1.068f, 1.055f, 1.047f, 1.039f, 1.075f, 1.038f, 1.085f, // 1x + 1.083f, 1.086f, 1.062f, 1.070f, 1.062f, 1.049f, 1.057f, 1.053f, 1.045f, 1.038f, 1.063f, 1.037f, 1.069f, // 2x + 1.059f, 1.068f, 1.056f, 1.066f, 1.058f, 1.046f, 1.051f, 1.051f, 1.044f, 1.036f, 1.059f, 1.035f, 1.053f, // 4x + 1.100f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.076f, 1.088f, // 8x + 1.099f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.075f, 1.087f, // 16x + 1.088f, 1.096f, 1.085f, 1.097f, 1.087f, 1.078f, 1.079f, 1.080f, 1.071f, 1.067f, 1.087f, 1.064f, 1.076f, // 32x + 1.083f, 1.091f, 1.078f, 1.090f, 1.079f, 1.072f, 1.072f, 1.073f, 1.064f, 1.062f, 1.080f, 1.057f, 1.069f, // 64x + 1.076f, 1.084f, 1.072f, 1.085f, 1.074f, 1.066f, 1.062f, 1.067f, 1.055f, 1.056f, 1.074f, 1.051f, 1.061f, // 128x + 1.067f, 1.074f, 1.063f, 1.075f, 1.064f, 1.059f, 1.055f, 1.058f, 1.049f, 1.051f, 1.064f, 1.044f, 1.053f, // 256x + 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, // 512x + 1.033303857f, 1.034763813f, 1.029855013f, 1.036911249f, 0.988150537f, 1.029003501f, 1.021363258f, + 0.98762089f, 1.021713376f, 1.040108204f, 0.987417698f, 1.019481421f, 0.987270057f, // 1024x + 0.986859143f, 0.980877221f, 0.9469769f, 0.985535324f, 0.926243961f, 0.959814787f, 1.007963896f, + 0.942685187f, 1.007630587f, 1.025460005f, 0.928696275f, 1.001296639f, 1.041275263f, // 2048x +}; +// clang-format on +static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float)); +// clang-format off +static const ChannelContribution AS7343_CONTRIBUTIONS[] PROGMEM = { + {-0.028559089f, 0.450293995f, 0.001266925f}, // F1 + {-0.042150948f, 3.566379984f, 0.012515925f}, // F2 + {0.000661721f, 2.877424672f, 0.010243005f}, // FZ + {-0.108791278f, 2.273837581f, 0.009376157f}, // F3 + {-0.167172180f, 1.519298503f, 0.006017556f}, // F4 + {5.726660294f, 4.199345859f, 0.019357905f}, // FY + {0.0f, 0.0f, 0.0f}, // F5 + {-0.241492014f, 1.185259160f, 0.006516325f}, // FXL + {-0.047665296f, 1.759469826f, 0.009780714f}, // F6 + {-0.012516990f, 1.702006611f, 0.009950228f}, // F7 + {-0.026424974f, -1.059196137f, -0.005425656f}, // F8 + {-0.007565221f, -0.064827881f, -0.000232267f}, // NIR + {0.0f, 0.0f, 0.0f}, // Clear +}; +static const ChannelTristimulus AS7343_TRISTIMULUS[] PROGMEM = { + {-0.07879f, -0.03269f, -0.31295f}, // F1 + {-0.12235f, -0.01297f, -0.57885f}, // F2 + {1.99879f, -0.04011f, 10.00197f}, // FZ + {-0.33364f, -0.06889f, -0.31281f}, // F3 + {-0.06795f, -0.17453f, -0.19657f}, // F4 + {-0.45419f, 5.69083f, -0.11077f}, // FY + {0.0f, 0.0f, 0.0f}, // F5 + {5.27242f, -0.22956f, -0.06132f}, // FXL + {-0.05072f, -0.04762f, -0.03536f}, // F6 + {-0.04666f, -0.01295f, -0.06025f}, // F7 + {-0.03931f, -0.02797f, -0.12174f}, // F8 + {0.0f, 0.0f, 0.0f}, // NIR + {0.0f, 0.0f, 0.0f}, // Clear +}; +// clang-format on +static_assert(sizeof(AS7343_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution)); +static_assert(sizeof(AS7343_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus)); +static constexpr ChipInfo as7343_chip(const char *name) { + return { + .name = name, + .init = AS7343_INIT, + .smux = AS7343_SMUX, + .readout = AS7343_READOUT, + .wavelengths = AS7343_WAVELENGTHS, + .gain_correction = AS7343_GAIN_CORRECTION, + .contributions = AS7343_CONTRIBUTIONS, + .tristimulus = AS7343_TRISTIMULUS, + .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), + .astep_reg = 0xD4, + .cfg0_reg = 0xBF, + .cfg1_reg = 0xC6, + .status2_reg = 0x90, + .id_reg = 0x5A, + .id_mask = 0xFF, + .id_value = 0x81, + .led_reg = 0xCD, + .led_sel_reg = 0, // the LED register drives the pin on its own + .adc_slots = AS7343_ADC_SLOTS, + .smux_steps = 1, + .integration_cycles = 3, + .smux_len = AS7343_SMUX_LEN, + .auto_smux = true, + .gain_correction_per_band = true, + .extra_clear_slot = 4, + }; +} +const ChipInfo AS7343_CHIP PROGMEM = as7343_chip(AS7343_NAME); +const ChipInfo TCS3448_CHIP PROGMEM = as7343_chip(TCS3448_NAME); // AS7343 register map, address 0x59 +#endif + +// Registers below 0x80 are only reachable with the REG_BANK bit of CFG0 set; CFG0 itself always answers. +bool AS734XComponent::select_bank_(uint8_t reg) { + const bool low = reg < BANK_SPLIT; + if (low == this->bank_low_) { + return true; + } + this->bank_low_ = low; + return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0); +} + +bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { + uint8_t data = 0; + if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) { + return false; + } + data = value ? (data | mask) : (data & ~mask); + return this->write_byte(reg, data); +} + +// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime +// takes effect on the next reading rather than only after a restart. +bool AS734XComponent::write_exposure_() { + const ChipInfo *chip = this->chip_; + if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) || + !this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) || + !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { + return false; + } + this->reading_gain_ = this->gain_; + this->reading_atime_ = this->atime_; + this->reading_astep_ = this->astep_; + // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. + const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; + this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); + this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); + return true; +} + +void AS734XComponent::setup() { + const ChipInfo *chip = this->chip_; + const uint8_t id_reg = pgm(chip->id_reg); + uint8_t id = 0; + // The sensor keeps its bank across an MCU reset, so the first access always writes CFG0 + bool ok = this->select_bank_(REG_ENABLE) && this->write_byte(REG_ENABLE, ENABLE_PON); + delayMicroseconds(200); // oscillator start-up + ok = ok && this->select_bank_(id_reg) && this->read_byte(id_reg, &id); + if (ok && (id & pgm(chip->id_mask)) != pgm(chip->id_value)) { + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + const RegValue *init = pgm(chip->init); + for (uint8_t i = 0, n = pgm(chip->init_count); ok && i < n; i++) { + RegValue rv; + progmem_memcpy(&rv, &init[i], sizeof(rv)); + ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value); + } + // Everything from here on, including the measurement registers, is in the high bank. + ok = ok && this->write_exposure_(); + if (pgm(chip->auto_smux)) { + const uint8_t *smux = pgm(chip->smux); + const uint8_t len = pgm(chip->smux_len); + for (uint8_t i = 0; ok && i < AS7343_CHAINS * len; i++) { + ok = this->write_byte(AS7343_CHAIN_SMUX, progmem_read_byte(&smux[i])) && + this->write_byte(AS7343_CHAIN_CMD, 0x46 + (i / len) * 0x10); + } + } + if (!ok) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->disable_loop(); +} + +void AS734XComponent::dump_config() { + const float gain = this->gain_ == GAIN_0_5X ? 0.5f : static_cast(1 << (this->gain_ - 1)); + ESP_LOGCONFIG(TAG, + "AS734x:\n" + " Chip: %s\n" + " Gain: %gx\n" + " ATIME: %u\n" + " ASTEP: %u\n" + " Glass attenuation factor: %.3f", + LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_, this->glass_attenuation_factor_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + char label[24]; + const char *name = LOG_STR_ARG(BandNames::get_log_str(band, BandNames::LAST_INDEX)); + snprintf(label, sizeof(label), "Counts %s", name); + sensor::log_sensor(TAG, " ", label, this->sensors_[band]); + snprintf(label, sizeof(label), "Basic counts %s", name); + sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]); + } + LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Irradiance photopic", this->irradiance_photopic_sensor_); + LOG_SENSOR(" ", "Irradiance PAR", this->irradiance_par_sensor_); + LOG_SENSOR(" ", "PPFD", this->ppfd_sensor_); + LOG_SENSOR(" ", "Color temperature", this->color_temperature_sensor_); +#ifdef USE_AS734X_RGB + LOG_TEXT_SENSOR(" ", "RGB hex", this->rgb_hex_sensor_); +#endif + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void AS734XComponent::enable_led(bool enable) { + const uint8_t led_reg = pgm(this->chip_->led_reg); + if (led_reg == 0) { + return; + } + // The LED register only drives the LED while LED_SEL is set, so the pin is handed over before + // switching on and released only after switching off. The other order would leave the LED lit + // with nothing able to turn it off. + const uint8_t led_sel_reg = pgm(this->chip_->led_sel_reg); + bool ok = true; + if (enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, true); + } + ok = this->update_bits_(led_reg, LED_ACT, enable) && ok; + if (!enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, false) && ok; + } + // The measurement path assumes the high bank, so leave the chip there for a reading in flight + ok = this->select_bank_(REG_ENABLE) && ok; + if (!ok) { + ESP_LOGW(TAG, "Failed to switch the LED"); + } +} + +void AS734XComponent::update() { + if (this->state_ != State::STATE_IDLE) { + ESP_LOGW(TAG, "Skipping update, previous measurement still running"); + return; + } + if (!this->write_exposure_()) { + this->abort_measurement_(LOG_STR("Failed to apply measurement settings")); + return; + } + this->step_ = 0; + this->peak_raw_count_ = 0; + this->raw_counts_.fill(0); + this->enable_loop(); + if (!this->start_step_()) { + this->abort_measurement_(LOG_STR("Failed to start measurement")); + } +} + +bool AS734XComponent::start_step_() { + this->start_ms_ = App.get_loop_component_start_time(); + if (pgm(this->chip_->auto_smux)) { + this->state_ = State::STATE_READ_DATA; + return this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN); + } + // Load this step's image into SMUX RAM; SMUX_EN copies it to the chain and clears itself when done. + std::array image{}; + const uint8_t len = pgm(this->chip_->smux_len); + progmem_memcpy(image.data(), pgm(this->chip_->smux) + this->step_ * len, len); + this->state_ = State::STATE_WAIT_SMUX; + return this->write_byte(AS7341_CFG6, AS7341_SMUX_CMD_WRITE) && + this->write_bytes(AS7341_SMUX_RAM, image.data(), len) && + this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SMUX_EN); +} + +void AS734XComponent::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t value = 0; + switch (this->state_) { + case State::STATE_WAIT_SMUX: + // A failed read counts as busy, so the timeout below catches a chip that stops answering. + if (this->read_byte(REG_ENABLE, &value) && !(value & ENABLE_SMUX_EN)) { + this->start_ms_ = now; + this->state_ = State::STATE_READ_DATA; + if (!this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN)) { + this->abort_measurement_(LOG_STR("Failed to start integration")); + } + return; + } + break; + case State::STATE_READ_DATA: + if (now - this->start_ms_ < this->integration_ms_) { + return; + } + if (this->read_byte(pgm(this->chip_->status2_reg), &value) && (value & STATUS2_AVALID)) { + this->read_step_(); + return; + } + break; + case State::STATE_IDLE: + this->disable_loop(); + return; + } + if (now - this->start_ms_ > this->timeout_ms_) { + this->abort_measurement_(LOG_STR("Measurement timeout")); + } +} + +void AS734XComponent::read_step_() { + // Reading ASTATUS latches the data, which is only consistent within one burst starting there. + std::array frame{}; + if (!this->read_bytes(REG_ASTATUS, frame.data(), 1 + 2 * pgm(this->chip_->adc_slots)) || + !this->write_byte(REG_ENABLE, ENABLE_PON)) { + this->abort_measurement_(LOG_STR("Failed to read channel data")); + return; + } + if (frame[0] & ASTATUS_ASAT) { + ESP_LOGV(TAG, "Step %u saturated", this->step_); + } + auto slot_value = [&frame](uint8_t slot) { return encode_uint16(frame[2 + 2 * slot], frame[1 + 2 * slot]); }; + const uint8_t *readout = pgm(this->chip_->readout) + this->step_ * NUM_BANDS; + const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint8_t slot = progmem_read_byte(&readout[band]); + if (slot == NO_SLOT) { + continue; + } + const uint16_t raw = slot_value(slot); + this->peak_raw_count_ = std::max(this->peak_raw_count_, raw); + uint16_t counts = raw; + if (band == CLEAR_BAND && extra_clear != NO_SLOT) { + const uint16_t extra = slot_value(extra_clear); + this->peak_raw_count_ = std::max(this->peak_raw_count_, extra); + counts = (static_cast(raw) + extra) / 2; + } + this->raw_counts_[band] = counts; + } + if (++this->step_ < pgm(this->chip_->smux_steps)) { + if (!this->start_step_()) { + this->abort_measurement_(LOG_STR("Failed to configure SMUX")); + } + return; + } + this->publish_measurement_(); + this->status_clear_warning(); + this->state_ = State::STATE_IDLE; + this->disable_loop(); +} + +// A basic count is the raw count scaled to one unit of gain and one millisecond of integration +// time, which makes readings taken with different settings comparable. The manufacturer's +// conversion factors are calibrated against that definition. +void AS734XComponent::calculate_basic_counts_() { + this->basic_counts_.fill(0.0f); + this->max_basic_count_ = 0.0f; + this->max_band_basic_count_ = 0.0f; + this->clear_basic_count_ = 0.0f; + + // The gain enum counts in powers of two starting at 0.5x, so the multiplier is 2^gain / 2. + const float gain_x = static_cast(1 << this->reading_gain_) / 2.0f; + const float integration_ms = (1.0f + this->reading_atime_) * (1.0f + this->reading_astep_) * 2.78e-3f; + if (gain_x <= 0.0f || integration_ms <= 0.0f) { + return; + } + const float inv_exposure = 1.0f / (gain_x * integration_ms); + const ChipInfo *chip = this->chip_; + const uint16_t *wavelengths = pgm(chip->wavelengths); + const float *gain_correction = pgm(chip->gain_correction); + const bool per_band = pgm(chip->gain_correction_per_band); + const uint8_t correction_base = per_band ? this->reading_gain_ * NUM_BANDS : this->reading_gain_; + + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint16_t wavelength = pgm(wavelengths[band]); + if (wavelength == ABSENT_NM) { + continue; + } + float basic_count = this->raw_counts_[band] * inv_exposure; + basic_count -= this->dark_current_[band]; + basic_count = std::max(basic_count, 0.0f); + basic_count *= pgm(gain_correction[per_band ? correction_base + band : correction_base]); + basic_count *= this->channel_correction_[band]; + basic_count *= this->glass_attenuation_factor_; + + this->basic_counts_[band] = basic_count; + this->max_basic_count_ = std::max(this->max_basic_count_, basic_count); + if (wavelength == WIDEBAND_NM) { + this->clear_basic_count_ = basic_count; + } else if (wavelength >= VISIBLE_MIN_NM && wavelength <= VISIBLE_MAX_NM) { + this->max_band_basic_count_ = std::max(this->max_band_basic_count_, basic_count); + } + } +} + +float AS734XComponent::normalization_divisor_() const { + float divisor = 1.0f; + switch (this->normalization_) { + case Normalization::NORMALIZATION_ALL: + divisor = this->max_basic_count_; + break; + case Normalization::NORMALIZATION_BANDS: + divisor = this->max_band_basic_count_; + break; + case Normalization::NORMALIZATION_CLEAR: + divisor = this->clear_basic_count_; + break; + case Normalization::NORMALIZATION_NONE: + return 1.0f; + } + // In the dark the reference can be zero, and dividing by that would publish infinities. + return divisor > 0.0f ? divisor : 1.0f; +} + +void AS734XComponent::publish_basic_counts_() { + // Normalization describes the shape of the spectrum rather than its absolute level. Channels + // outside the chosen reference can exceed 1.0 and are left that way rather than clipped: with + // BANDS the near infrared channel legitimately sits well above the visible bands. + const float scale = 1.0f / this->normalization_divisor_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->basic_counts_sensors_[band] != nullptr) { + this->basic_counts_sensors_[band]->publish_state(this->basic_counts_[band] * scale); + } + } +} + +// Each band contributes a fixed amount per basic count to every integrated quantity, so the +// totals are a weighted sum over the bands. +// The weighted sums can go negative under light the tables were not fitted for, such as pure blue or +// far red, so the published values are floored at zero. +void AS734XComponent::publish_light_metrics_() { + float irradiance_photopic = 0.0f; + float irradiance_par = 0.0f; + float ppfd = 0.0f; + + const ChannelContribution *contributions = pgm(this->chip_->contributions); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const float basic_count = this->basic_counts_[band]; + const ChannelContribution contribution = pgm(contributions[band]); + + irradiance_photopic += contribution.irradiance_photopic * basic_count; + irradiance_par += contribution.irradiance_par * basic_count; + ppfd += contribution.ppfd * basic_count; + } + irradiance_photopic = std::max(irradiance_photopic, 0.0f); + irradiance_par = std::max(irradiance_par, 0.0f); + ppfd = std::max(ppfd, 0.0f) * 1e3f; + const float illuminance = irradiance_photopic * LUMENS_PER_WATT; + + ESP_LOGV(TAG, "Photopic irradiance %.4f, PAR %.4f, PPFD %.4f, illuminance %.2f lx", irradiance_photopic, + irradiance_par, ppfd, illuminance); + if (this->illuminance_sensor_ != nullptr) { + this->illuminance_sensor_->publish_state(illuminance); + } + if (this->irradiance_photopic_sensor_ != nullptr) { + this->irradiance_photopic_sensor_->publish_state(irradiance_photopic); + } + if (this->irradiance_par_sensor_ != nullptr) { + this->irradiance_par_sensor_->publish_state(irradiance_par); + } + if (this->ppfd_sensor_ != nullptr) { + this->ppfd_sensor_->publish_state(ppfd); + } +} + +void AS734XComponent::publish_color_() { + float tri_x = 0.0f; + float tri_y = 0.0f; + float tri_z = 0.0f; + + const ChannelTristimulus *table = pgm(this->chip_->tristimulus); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const float basic_count = this->basic_counts_[band]; + const ChannelTristimulus tristimulus = pgm(table[band]); + + tri_x += tristimulus.x * basic_count; + tri_y += tristimulus.y * basic_count; + tri_z += tristimulus.z * basic_count; + } + + const float color_temperature = tristimulus_to_cct(tri_x, tri_y, tri_z); + ESP_LOGV(TAG, "XYZ %.4f, %.4f, %.4f -> CCT %.0f K", tri_x, tri_y, tri_z, color_temperature); + if (this->color_temperature_sensor_ != nullptr) { + this->color_temperature_sensor_->publish_state(color_temperature); + } + +#ifdef USE_AS734X_RGB + if (this->rgb_hex_sensor_ == nullptr) { + return; + } + // Convert to chromaticity first, so the colour describes the hue rather than the brightness. + // Absolute tristimulus values clamp to white under anything but dim light. + char rgb_hex[7] = "000000"; // no usable chromaticity reads as black rather than an empty string + const float sum = tri_x + tri_y + tri_z; + if (sum > MIN_TRISTIMULUS_SUM) { + uint8_t rgb[3]; + tristimulus_to_rgb(tri_x / sum, tri_y / sum, tri_z / sum, rgb[0], rgb[1], rgb[2]); + format_hex_to(rgb_hex, rgb, sizeof(rgb)); + } + this->rgb_hex_sensor_->publish_state(rgb_hex); +#endif +} + +void AS734XComponent::publish_measurement_() { + this->calculate_basic_counts_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->sensors_[band] != nullptr) { + this->sensors_[band]->publish_state(this->raw_counts_[band]); + } + } + this->publish_basic_counts_(); + this->publish_light_metrics_(); + this->publish_color_(); + if (this->saturation_level_sensor_ != nullptr) { + const uint32_t max_adc = + std::min((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT); + const float level = max_adc == 0 ? 0.0f : 100.0f * this->peak_raw_count_ / max_adc; + ESP_LOGV(TAG, "Highest ADC count %u of %u (%.1f%%)", this->peak_raw_count_, static_cast(max_adc), level); + this->saturation_level_sensor_->publish_state(level); + } +} + +void AS734XComponent::abort_measurement_(const LogString *reason) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason)); + this->write_byte(REG_ENABLE, ENABLE_PON); + this->status_set_warning(reason); + this->state_ = State::STATE_IDLE; + this->disable_loop(); +} + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h new file mode 100644 index 00000000000..95c694da9ab --- /dev/null +++ b/esphome/components/as734x/as734x.h @@ -0,0 +1,199 @@ +#pragma once + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#ifdef USE_AS734X_RGB +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome::as734x { + +enum Gain : uint8_t { + GAIN_0_5X, + GAIN_1X, + GAIN_2X, + GAIN_4X, + GAIN_8X, + GAIN_16X, + GAIN_32X, + GAIN_64X, + GAIN_128X, + GAIN_256X, + GAIN_512X, + GAIN_1024X, // AS7343 only + GAIN_2048X, // AS7343 only +}; + +static constexpr uint8_t GAIN_COUNT = 13; // entries in Gain, for sizing the correction tables +static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py + +// What the published basic counts are divided by. The clear channel is wideband, so it is roughly +// the size of all the bands together, and the near infrared channel outgrows them under daylight. +// Dividing by the largest of every channel therefore leaves the visible bands squeezed into the +// bottom of the range. +enum class Normalization : uint8_t { + NORMALIZATION_NONE, // publish absolute basic counts + NORMALIZATION_ALL, // largest of every channel + NORMALIZATION_BANDS, // largest of the visible bands only + NORMALIZATION_CLEAR, // the wideband clear channel +}; + +// Nominal centre wavelengths in nanometres bound the visible bands. The clear channel is wideband +// and has no single centre wavelength, so it is marked with zero instead. +static constexpr uint16_t VISIBLE_MIN_NM = 380; +static constexpr uint16_t VISIBLE_MAX_NM = 700; +static constexpr uint16_t WIDEBAND_NM = 0; +static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have + +// Luminous efficacy at 555 nm, for turning photopic irradiance into lux. +static constexpr float LUMENS_PER_WATT = 683.002f; + +// How much one basic count of a band contributes to each integrated quantity. The factors come +// from the manufacturer's golden-device calibration, convolved for a reconstructed spectrum. +struct ChannelContribution { + float irradiance_photopic; // weighted by the CIE 1931 V(lambda) curve + float irradiance_par; // 400-700 nm only + float ppfd; // 400-700 nm only, scaled by 1e3 before publishing +}; + +// CIE 1931 tristimulus contribution of one basic count of a band. +struct ChannelTristimulus { + float x; + float y; + float z; +}; + +struct RegValue { + uint8_t reg; + uint8_t value; +}; + +/// Per-chip constants, stored in flash. The pointed-to tables are in flash too. +struct ChipInfo { + const char *name; + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band + const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band + const ChannelContribution *contributions; // per band + const ChannelTristimulus *tristimulus; // per band + uint8_t init_count; + uint8_t astep_reg; + uint8_t cfg0_reg; + uint8_t cfg1_reg; + uint8_t status2_reg; + uint8_t id_reg; + uint8_t id_mask; + uint8_t id_value; + uint8_t led_reg; // LED driver register (0: this chip has no LED control) + uint8_t led_sel_reg; // register whose LED_SEL bit hands the pin to the driver (0: not needed) + uint8_t adc_slots; // 16-bit data registers read after ASTATUS + uint8_t smux_steps; // measurements per update + uint8_t integration_cycles; + uint8_t smux_len; // bytes per SMUX image + bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup + bool gain_correction_per_band; // the correction table is indexed by gain and band, not gain alone + uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) +}; + +#ifdef USE_AS7341 +extern const ChipInfo AS7341_CHIP; +#endif +#ifdef USE_AS7343 +extern const ChipInfo AS7343_CHIP; +extern const ChipInfo TCS3448_CHIP; +#endif + +class AS734XComponent : public PollingComponent, public i2c::I2CDevice { + public: + // Channel correction is multiplicative, so it starts at unity rather than zero in case no + // calibration is configured. + explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) { this->channel_correction_.fill(1.0f); } + + void setup() override; + void dump_config() override; + void update() override; + void loop() override; + + void set_gain(Gain gain) { this->gain_ = gain; } + void set_atime(uint8_t atime) { this->atime_ = atime; } + void set_astep(uint16_t astep) { this->astep_ = astep; } + + /// Turns the sensor's LED driver on or off. Meant for lambdas. + void enable_led(bool enable); + + // Calibration applied while turning raw counts into basic counts. + void set_dark_current(const std::array &values) { this->dark_current_ = values; } + void set_channel_correction(const std::array &values) { this->channel_correction_ = values; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + /// Chooses what the published basic counts are divided by. + void set_basic_counts_normalization(Normalization normalization) { this->normalization_ = normalization; } + + void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; } + void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; } + + SUB_SENSOR(saturation_level) + SUB_SENSOR(illuminance) + SUB_SENSOR(irradiance_photopic) + SUB_SENSOR(irradiance_par) + SUB_SENSOR(ppfd) + SUB_SENSOR(color_temperature) + +#ifdef USE_AS734X_RGB + void set_rgb_hex_sensor(text_sensor::TextSensor *sensor) { this->rgb_hex_sensor_ = sensor; } +#endif + + protected: + bool select_bank_(uint8_t reg); + bool update_bits_(uint8_t reg, uint8_t mask, bool value); + bool write_exposure_(); + bool start_step_(); + void read_step_(); + void abort_measurement_(const LogString *reason); + void calculate_basic_counts_(); + void publish_light_metrics_(); + void publish_color_(); + void publish_measurement_(); + void publish_basic_counts_(); + float normalization_divisor_() const; + + std::array sensors_{}; + std::array basic_counts_sensors_{}; + std::array dark_current_{}; + std::array channel_correction_; + std::array basic_counts_{}; + std::array raw_counts_{}; + const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy + uint32_t start_ms_{0}; + uint32_t integration_ms_{0}; + uint32_t timeout_ms_{0}; + float glass_attenuation_factor_{1.0f}; + float max_basic_count_{0.0f}; // largest of every channel + float max_band_basic_count_{0.0f}; // largest of the visible bands only + float clear_basic_count_{0.0f}; // the wideband clear channel + uint16_t astep_{0}; + uint16_t peak_raw_count_{0}; // highest ADC value of this measurement, before the clear channels are averaged + // What the running measurement was started with, so the maths matches the data even if the + // configuration is changed while a measurement is in flight. + uint16_t reading_astep_{0}; + Gain reading_gain_{GAIN_1X}; + uint8_t reading_atime_{0}; + enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE}; + Normalization normalization_{Normalization::NORMALIZATION_NONE}; + Gain gain_{GAIN_1X}; + uint8_t atime_{0}; + uint8_t step_{0}; + bool bank_low_{true}; // unknown until setup() writes CFG0 +#ifdef USE_AS734X_RGB + text_sensor::TextSensor *rgb_hex_sensor_{nullptr}; +#endif +}; + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/color_helpers.cpp b/esphome/components/as734x/color_helpers.cpp new file mode 100644 index 00000000000..b456038eda7 --- /dev/null +++ b/esphome/components/as734x/color_helpers.cpp @@ -0,0 +1,63 @@ +#include "color_helpers.h" + +#include +#include + +namespace esphome::as734x { + +namespace { + +// Range McCamy's approximation is meaningful over. +constexpr float CCT_MIN_K = 1000.0f; +constexpr float CCT_MAX_K = 25000.0f; + +#ifdef USE_AS734X_RGB +// sRGB transfer function. +float gamma_correct(float channel) { + if (channel <= 0.0031308f) { + return 12.92f * channel; + } + return 1.055f * std::pow(channel, 1.0f / 2.4f) - 0.055f; +} + +uint8_t to_byte(float channel) { return static_cast(std::clamp(channel, 0.0f, 1.0f) * 255.0f); } +#endif + +} // namespace + +float tristimulus_to_cct(float x, float y, float z) { + const float sum = x + y + z; + // The conversion matrices carry negative coefficients, so a spectrum unlike the one they were + // calibrated against can drive the sum to zero or below. Chromaticity is undefined there, and + // McCamy's formula would still return a plausible looking number from it. + if (sum <= MIN_TRISTIMULUS_SUM) { + return NAN; + } + const float chroma_x = x / sum; + const float chroma_y = y / sum; + + // McCamy's approximation: cct = 437 n^3 + 3601 n^2 + 6861 n + 5517. The pole at + // chroma_y = 0.1858 sends the cubic off to either infinity, and it stays meaningless well + // either side of that, so the answer is only reported where the approximation holds. + const float denominator = 0.1858f - chroma_y; + if (std::abs(denominator) < 1e-4f) { + return NAN; + } + const float n = (chroma_x - 0.3320f) / denominator; + const float cct = ((437.0f * n + 3601.0f) * n + 6861.0f) * n + 5517.0f; + return (cct >= CCT_MIN_K && cct <= CCT_MAX_K) ? cct : NAN; +} + +#ifdef USE_AS734X_RGB +void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b) { + const float r_linear = 3.2406f * x - 1.5372f * y - 0.4986f * z; + const float g_linear = -0.9689f * x + 1.8758f * y + 0.0415f * z; + const float b_linear = 0.0557f * x - 0.2040f * y + 1.0570f * z; + + r = to_byte(gamma_correct(r_linear)); + g = to_byte(gamma_correct(g_linear)); + b = to_byte(gamma_correct(b_linear)); +} +#endif + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/color_helpers.h b/esphome/components/as734x/color_helpers.h new file mode 100644 index 00000000000..1bf382c4bc6 --- /dev/null +++ b/esphome/components/as734x/color_helpers.h @@ -0,0 +1,22 @@ +#pragma once +#include + +#include "esphome/core/defines.h" + +namespace esphome::as734x { + +// Smallest tristimulus sum that still carries a usable hue. +constexpr float MIN_TRISTIMULUS_SUM = 0.001f; + +// Correlated colour temperature in kelvin from CIE 1931 tristimulus values, using McCamy's +// approximation. Returns NAN when the input carries no usable hue, or when the result falls +// outside the range the approximation holds over. +float tristimulus_to_cct(float x, float y, float z); + +#ifdef USE_AS734X_RGB +// sRGB components for CIE 1931 tristimulus values. Pass chromaticity (x + y + z == 1) rather than +// absolute values, otherwise anything but dim light clamps to white. +void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b); +#endif + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py new file mode 100644 index 00000000000..85fa6635320 --- /dev/null +++ b/esphome/components/as734x/sensor.py @@ -0,0 +1,144 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import UNIT_COUNTS +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_COUNTS, + CONF_ILLUMINANCE, + CONF_TYPE, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_IRRADIANCE, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent + +DEPENDENCIES = [DOMAIN] + +CONF_BASIC_COUNTS = "basic_counts" +CONF_IRRADIANCE_PAR = "irradiance_par" +CONF_IRRADIANCE_PHOTOPIC = "irradiance_photopic" +CONF_PPFD = "ppfd" +CONF_SATURATION_LEVEL = "saturation_level" + +UNIT_IRRADIANCE = "W/m\u00b2" +UNIT_PPFD = "\u00b5mol/m\u00b2/s" + +ICON_COUNTS = "mdi:counter" +ICON_SATURATION = "mdi:weather-sunny-alert" +ICON_ILLUMINANCE = "mdi:weather-sunny" +ICON_IRRADIANCE_PHOTOPIC = "mdi:sun-wireless-outline" +ICON_PAR = "mdi:sprout" +ICON_PPFD = "mdi:sprout-outline" +ICON_COLOR_TEMPERATURE = "mdi:sun-thermometer-outline" + +_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Basic counts are fractional, so they need decimals where raw counts do not. +_BASIC_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=6, + state_class=STATE_CLASS_MEASUREMENT, +) + +_BAND_KEYS = (CONF_COUNTS, CONF_BASIC_COUNTS) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), + cv.Optional(CONF_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43} + ), + cv.Optional(CONF_BASIC_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _BASIC_COUNTS_SENSOR for band in BANDS_43} + ), + cv.Optional(CONF_SATURATION_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_SATURATION, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_ILLUMINANCE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IRRADIANCE_PHOTOPIC): sensor.sensor_schema( + unit_of_measurement=UNIT_IRRADIANCE, + icon=ICON_IRRADIANCE_PHOTOPIC, + accuracy_decimals=3, + device_class=DEVICE_CLASS_IRRADIANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IRRADIANCE_PAR): sensor.sensor_schema( + unit_of_measurement=UNIT_IRRADIANCE, + icon=ICON_PAR, + accuracy_decimals=3, + device_class=DEVICE_CLASS_IRRADIANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PPFD): sensor.sensor_schema( + unit_of_measurement=UNIT_PPFD, + icon=ICON_PPFD, + accuracy_decimals=3, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_COLOR_TEMPERATURE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + full = fv.full_config.get() + hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1]) + if hub[CONF_TYPE] != "AS7341": + return config + for key in _BAND_KEYS: + if extra := [band for band in config.get(key, {}) if band not in BANDS_41]: + raise cv.Invalid( + f"The AS7341 has no {', '.join(extra)} band", path=[key, extra[0]] + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AS734X_ID]) + counts = config[CONF_COUNTS] + basic_counts = config[CONF_BASIC_COUNTS] + for i, band in enumerate(BANDS_43): + if (conf := counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_counts_sensor(sens, i)) + if (conf := basic_counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_basic_counts_sensor(sens, i)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_IRRADIANCE_PHOTOPIC, hub.set_irradiance_photopic_sensor) + await sensors(CONF_IRRADIANCE_PAR, hub.set_irradiance_par_sensor) + await sensors(CONF_PPFD, hub.set_ppfd_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/esphome/components/as734x/text_sensor.py b/esphome/components/as734x/text_sensor.py new file mode 100644 index 00000000000..d010f2d6a42 --- /dev/null +++ b/esphome/components/as734x/text_sensor.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_AS734X_ID, DOMAIN, AS734XComponent + +DEPENDENCIES = [DOMAIN] + +CONF_RGB_HEX = "rgb_hex" + +ICON_PALETTE = "mdi:palette" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), + cv.Optional(CONF_RGB_HEX): text_sensor.text_sensor_schema(icon=ICON_PALETTE), + } +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_RGB_HEX)) is None: + return + hub = await cg.get_variable(config[CONF_AS734X_ID]) + cg.add_define("USE_AS734X_RGB") + sens = await text_sensor.new_text_sensor(conf) + cg.add(hub.set_rgb_hex_sensor(sens)) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 332638e75bb..36a9161eb45 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -338,7 +338,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") + add_idf_component(name="esphome/esp-audio-libs", ref="4.1.0") data = _get_data() diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 13dea3f9bc4..a069bbda427 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -188,7 +188,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it // processed, so the source does not need to accumulate or stitch chunks across fill() calls. - this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); const size_t available_before_decode = this->input_buffer_->available(); diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index bef62ce190b..8f35bac9012 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -129,9 +129,8 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource - // (there is no intermediate transfer buffer to compact). - this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); + // Expose a chunk of the ring buffer's internal storage. + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); if (this->audio_source_->available() == 0) { // No samples available to process diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index 01fd4bb68a6..2082a270820 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -20,16 +20,6 @@ std::unique_ptr AudioSinkTransferBuffer::create(size_t return sink_buffer; } -std::unique_ptr AudioSourceTransferBuffer::create(size_t buffer_size) { - std::unique_ptr source_buffer = make_unique(); - - if (!source_buffer->allocate_buffer_(buffer_size)) { - return nullptr; - } - - return source_buffer; -} - size_t AudioTransferBuffer::free() const { if (this->buffer_size_ == 0) { return 0; @@ -132,27 +122,6 @@ void AudioTransferBuffer::deallocate_buffer_() { this->buffer_length_ = 0; } -size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) { - if (pre_shift) { - // Shift data in buffer to start - if (this->buffer_length_ > 0) { - std::memmove(this->buffer_, this->data_start_, this->buffer_length_); - } - this->data_start_ = this->buffer_; - } - - size_t bytes_to_read = AudioTransferBuffer::free(); - size_t bytes_read = 0; - if (bytes_to_read > 0) { - if (this->ring_buffer_ != nullptr) { - bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); - } - - this->increase_buffer_length(bytes_read); - } - return bytes_read; -} - size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bool post_shift) { size_t bytes_written = 0; if (this->available()) { @@ -192,9 +161,44 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->available() > 0); } +// AudioSourceTransferBuffer is deprecated. Remove before 2027.5.0. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +std::unique_ptr AudioSourceTransferBuffer::create(size_t buffer_size) { + std::unique_ptr source_buffer = make_unique(); + + if (!source_buffer->allocate_buffer_(buffer_size)) { + return nullptr; + } + + return source_buffer; +} + +size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) { + if (pre_shift) { + // Shift data in buffer to start + if (this->buffer_length_ > 0) { + std::memmove(this->buffer_, this->data_start_, this->buffer_length_); + } + this->data_start_ = this->buffer_; + } + + size_t bytes_to_read = AudioTransferBuffer::free(); + size_t bytes_read = 0; + if (bytes_to_read > 0) { + if (this->ring_buffer_ != nullptr) { + bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); + } + + this->increase_buffer_length(bytes_read); + } + return bytes_read; +} + size_t AudioSourceTransferBuffer::free() const { return AudioTransferBuffer::free(); } bool AudioSourceTransferBuffer::has_buffered_data() const { return AudioTransferBuffer::has_buffered_data(); } +#pragma GCC diagnostic pop void ConstAudioSourceBuffer::set_data(const uint8_t *data, size_t length) { this->data_start_ = data; @@ -268,6 +272,23 @@ void RingBufferAudioSource::clear_buffered_data() { this->ring_buffer_->reset(); } +size_t RingBufferAudioSource::expose_padded_partial(size_t frame_bytes) { + if (this->current_available_ > 0 || this->queued_length_ > 0) { + return 0; + } + // Moves any trailing bytes still inside a held item into the splice buffer + this->release_item_(); + const size_t real_bytes = this->splice_length_; + if (real_bytes == 0 || frame_bytes == 0 || real_bytes % frame_bytes != 0) { + return 0; + } + std::memset(this->splice_buffer_ + real_bytes, 0, this->alignment_bytes_ - real_bytes); + this->splice_length_ = 0; + this->current_data_ = this->splice_buffer_; + this->current_available_ = this->alignment_bytes_; + return real_bytes; +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. @@ -276,7 +297,7 @@ bool RingBufferAudioSource::has_buffered_data() const { return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0); } -size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) { +size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait) { if (this->current_available_ > 0) { // Caller has not finished consuming the current exposure return 0; diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index 074684f0686..e7a103fdc8b 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" @@ -152,13 +153,20 @@ class AudioReadableBuffer { /// @brief Refills the buffer from its source. No-op by default for read-only buffers. /// @param ticks_to_wait FreeRTOS ticks to block while waiting for data - /// @param pre_shift If true, shifts existing data to the start of the buffer before reading /// @return Number of bytes read - virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return 0; } - size_t fill(TickType_t ticks_to_wait) { return this->fill(ticks_to_wait, true); } + virtual size_t fill(TickType_t ticks_to_wait) { return 0; } + + // Remove before 2027.5.0 + ESPDEPRECATED("Use fill(ticks_to_wait) instead. Removed in 2027.5.0.", "2026.11.0") + virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return this->fill(ticks_to_wait); } }; -class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadableBuffer { +// Remove before 2027.5.0 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class ESPDEPRECATED("Use RingBufferAudioSource instead. Removed in 2027.5.0.", "2026.11.0") AudioSourceTransferBuffer + : public AudioTransferBuffer, + public AudioReadableBuffer { /* * @brief A class that implements a transfer buffer for audio sources. * Supports reading audio data from a ring buffer into the transfer buffer for processing. @@ -189,10 +197,12 @@ class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadab size_t free() const override; void consume(size_t bytes) override { this->decrease_buffer_length(bytes); } bool has_buffered_data() const override; + size_t fill(TickType_t ticks_to_wait) override { return this->transfer_data_from_source(ticks_to_wait); } size_t fill(TickType_t ticks_to_wait, bool pre_shift) override { return this->transfer_data_from_source(ticks_to_wait, pre_shift); } }; +#pragma GCC diagnostic pop /// @brief A lightweight read-only audio buffer for const data sources (e.g., flash memory). /// Does not allocate memory or transfer data from external sources. @@ -246,14 +256,21 @@ class RingBufferAudioSource : public AudioReadableBuffer { size_t available() const override { return this->current_available_; } void consume(size_t bytes) override; bool has_buffered_data() const override; - /// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed - /// exposure stays in place and fill() returns 0 until it is fully consumed. - size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// An unconsumed exposure stays in place and fill() returns 0 until it is fully consumed. + size_t fill(TickType_t ticks_to_wait) override; + // Keeps the deprecated fill(ticks_to_wait, pre_shift) overload visible. Remove before 2027.5.0. + using AudioReadableBuffer::fill; /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. void clear_buffered_data(); + /// @brief Zero-pads held whole frames short of an alignment unit and exposes them, so a stream's tail is not + /// stranded. A held partial frame is left to wait for its remaining bytes. + /// @param frame_bytes Bytes per audio frame + /// @return Real (unpadded) bytes exposed, or 0 if nothing is exposable + size_t expose_padded_partial(size_t frame_bytes); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index eb777a6a834..aa84cdddde9 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -1,10 +1,11 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::bedjet { -static const char *const TAG = "bedjet"; +ESPHOME_LOG_TAG(TAG, "bedjet"); /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index cd2802f6173..b0fac5b1218 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -4,7 +4,7 @@ namespace esphome::ble_client { -const char *const Automation::TAG = "ble_client.automation"; +const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266 } // namespace esphome::ble_client diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 7b66c671835..5fe1460c661 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() { esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index df1cf96d698..de6b51ab1f5 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -100,7 +100,8 @@ void BM8563::get_date_(ESPTime &time) { this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); - time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07); + // The chip counts weekdays 0-6 from Sunday (not BCD), ESPTime counts 1-7 + time.day_of_week = (buf[1] & 0x07) + 1; time.month = this->bcd2_to_byte_(buf[2] & 0x1f); uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); @@ -115,7 +116,7 @@ void BM8563::get_date_(ESPTime &time) { void BM8563::set_date_(const ESPTime &time) { uint8_t buf[4] = { this->byte_to_bcd2_(time.day_of_month), - this->byte_to_bcd2_(time.day_of_week), + static_cast(time.day_of_week - 1), this->byte_to_bcd2_(time.month), this->byte_to_bcd2_(time.year % 100), }; diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 501556d3c40..45c3e5b260f 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bme280_i2c { -static const char *const TAG = "bme280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280_i2c.sensor"); class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 37f718fb7eb..6f14598a5f4 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -2,6 +2,7 @@ #include "bmi270_config.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::bmi270 { @@ -11,6 +12,9 @@ using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device defi ESPHOME_LOG_TAG(TAG, "bmi270"); +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g"); +PROGMEM_STRING_TABLE(GyroRangeStrings, "±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"); + #if defined(USE_ARDUINO) && !defined(USE_ESP32) static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else @@ -273,11 +277,10 @@ void BMI270Component::dump_config() { return; } - static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; - static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; - - ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); - ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + ESP_LOGCONFIG(TAG, " Accel range : %s", + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX))); + ESP_LOGCONFIG(TAG, " Gyro range : %s", + LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_, GyroRangeStrings::LAST_INDEX))); #ifdef USE_BMI270_BMM150 if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", @@ -315,7 +318,8 @@ bool BMI270Component::update_data(motion::MotionData &data) { } // Scale factor: LSB/g depends on range // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb - static constexpr float ACCEL_SCALE[] = { + // In PROGMEM; each read is a 32-bit load, which is safe from flash on ESP8266 + alignas(4) static constexpr float ACCEL_SCALE[] PROGMEM = { 2.0f / 32768.0f, 4.0f / 32768.0f, 8.0f / 32768.0f, @@ -329,7 +333,7 @@ bool BMI270Component::update_data(motion::MotionData &data) { // Gyroscope: registers 0x12–0x17 (6 bytes) // Scale: full-scale range / 2^15 - static constexpr float GYRO_SCALE[] = { + alignas(4) static constexpr float GYRO_SCALE[] PROGMEM = { 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, }; static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index a19203ff0a0..f2ec93176ef 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp280_i2c { -static const char *const TAG = "bmp280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280_i2c.sensor"); /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index 126ffd6a606..24929c7d8b0 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp581_base/bmp581_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp581_i2c { -static const char *const TAG = "bmp581_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp581_i2c.sensor"); /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 0aa48bc3ea0..16ebd407625 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -80,6 +80,9 @@ void CaptivePortal::setup() { this->disable_loop(); } void CaptivePortal::start() { + // Each start takes a web server reference that end() returns once + if (this->active_) + return; this->base_->init(); if (!this->initialized_) { this->base_->add_handler_without_auth(this); diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 84e539e5f25..9278546ee94 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::chsc6x { -static const char *const TAG = "chsc6x.touchscreen"; +ESPHOME_LOG_TAG(TAG, "chsc6x.touchscreen"); static const uint8_t CHSC6X_REG_STATUS = 0x00; static const uint8_t CHSC6X_REG_STATUS_TOUCH = 0x00; diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49f55ab47f0..3a531978aaa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BLUE_CHANNEL = "blue_channel" +CONF_BORDER_ROUTER = "border_router" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" @@ -51,6 +52,7 @@ CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" +CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index bbe76855fb8..81f831672bd 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -8,6 +8,9 @@ namespace esphome::cse7766 { ESPHOME_LOG_TAG(TAG, "cse7766"); +// The chip only sends the time since the last pulse once a cycle is longer than 1 s (in us) +static constexpr uint32_t MIN_HELD_POWER_CYCLE = 1000000; + void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_transmission_ >= 500) { @@ -154,18 +157,33 @@ void CSE7766Component::parse_data_() { if (power_cycle_exceeds_range) { // Datasheet: power cycle exceeding range means active power is 0 have_power = true; + this->last_power_cycle_ = 0; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(0.0f); } } else if (have_power) { power = power_coeff / float(power_cycle); + this->last_power_cycle_ = power_cycle; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(power); } - } else if (this->power_sensor_ != nullptr) { - // No valid power measurement from chip - publish 0W to avoid stale readings - // This typically happens when current is below the measurable threshold (~50mA) - this->power_sensor_->publish_state(0.0f); + } else if (this->last_power_cycle_ > MIN_HELD_POWER_CYCLE && power_cycle <= 2 * this->last_power_cycle_) { + // No new measurement in this frame. With cycles over 1 s the register holds the last + // cycle or the time elapsed since the last pulse. While that time is within twice the + // last cycle, the next pulse is not overdue, so the last measurement still applies. + // Shorter cycles never get here in normal operation and keep the previous behaviour. + have_power = true; + power = power_coeff / float(this->last_power_cycle_); + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + } else { + // The next pulse is overdue: the load has dropped, report 0 W. A load that drops to less + // than half also reads 0 W here until its first new pulse arrives. + this->last_power_cycle_ = 0; + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(0.0f); + } } float current = 0.0f; diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 8a57816a590..1206ad4e71f 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -36,6 +36,7 @@ class CSE7766Component final : public Component, public uart::UARTDevice { uint8_t raw_data_[CSE7766_RAW_DATA_SIZE]; uint8_t raw_data_index_{0}; + uint16_t cf_pulses_last_{0}; uint32_t last_transmission_{0}; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; @@ -45,7 +46,8 @@ class CSE7766Component final : public Component, public uart::UARTDevice { sensor::Sensor *reactive_power_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; uint32_t cf_pulses_total_{0}; - uint16_t cf_pulses_last_{0}; + // Power cycle of the last frame that carried a new power measurement, 0 if none + uint32_t last_power_cycle_{0}; }; } // namespace esphome::cse7766 diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 84b561c7345..72dda4e9854 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst816 { -static const char *const TAG = "cst816.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst816.touchscreen"); static const uint8_t REG_STATUS = 0x00; static const uint8_t REG_TOUCH_NUM = 0x02; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h index 17050e2429a..5a8e043d654 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst9220 { -static const char *const TAG = "cst9220.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen"); // The CST92xx family uses 16-bit (big-endian) register addresses. static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py new file mode 100644 index 00000000000..c1d2f806377 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -0,0 +1,84 @@ +from collections.abc import Callable + +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODE, CONF_MODEL +from esphome.core import ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["uart"] +DOMAIN = "dfrobot_sen0609" +MULTI_CONF = True + +dfrobot_sen0609_ns = cg.esphome_ns.namespace("dfrobot_sen0609") +DFRobotSEN0609Component = dfrobot_sen0609_ns.class_( + "DFRobotSEN0609Component", cg.Component, uart.UARTDevice +) +RunMode = dfrobot_sen0609_ns.enum("RunMode", is_class=True) + +CONF_DFROBOT_SEN0609_ID = "dfrobot_sen0609_id" + +MODE_PRESENCE = "presence" +MODE_SPEED_AND_DISTANCE = "speed_and_distance" +RUN_MODES = { + MODE_PRESENCE: RunMode.RUN_MODE_PRESENCE, + MODE_SPEED_AND_DISTANCE: RunMode.RUN_MODE_SPEED_AND_DISTANCE, +} + +MODEL_SEN0609 = "SEN0609" +MODEL_SEN0610 = "SEN0610" +# Longest detection range in metres +MAX_RANGES = {MODEL_SEN0609: 25.0, MODEL_SEN0610: 12.0} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DFRobotSEN0609Component), + cv.Optional(CONF_MODEL, default=MODEL_SEN0609): cv.one_of( + *MAX_RANGES, upper=True + ), + cv.Optional(CONF_MODE, default=MODE_PRESENCE): cv.one_of( + *RUN_MODES, lower=True + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + + +def get_hub_config(root: ConfigType, hub_id: ID) -> ConfigType: + """Return the configuration of the hub with the given id.""" + return next(conf for conf in root[DOMAIN] if conf[CONF_ID] == hub_id) + + +def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: + """Reject entity keys that need a different hub mode; `keys` maps each key to the mode it needs.""" + + def validator(config: ConfigType) -> None: + hub = get_hub_config(fv.full_config.get(), config[CONF_DFROBOT_SEN0609_ID]) + for key, mode in keys.items(): + if key in config and hub[CONF_MODE] != mode: + raise cv.Invalid( + f"'{key}' needs the {DOMAIN} 'mode' to be '{mode}'", path=[key] + ) + + return validator + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/dfrobot_sen0609/binary_sensor.py b/esphome/components/dfrobot_sen0609/binary_sensor.py new file mode 100644 index 00000000000..8d4cb7ed846 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/binary_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + icon=ICON_ACCOUNT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp new file mode 100644 index 00000000000..26538998a2f --- /dev/null +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -0,0 +1,883 @@ +#include "dfrobot_sen0609.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include +#include +#include + +namespace esphome::dfrobot_sen0609 { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0609"); + +static constexpr uint8_t MAX_ATTEMPTS = 3; +// The module ignores a command that follows its last answer too closely +static constexpr uint32_t COMMAND_GAP_MS = 100; +// Gathers entity changes made close together into one flash write +static constexpr uint32_t COALESCE_MS = 1000; +static constexpr uint32_t DEFAULT_TIMEOUT_MS = 1000; +static constexpr uint32_t LONG_TIMEOUT_MS = 2000; +static constexpr uint32_t GIVE_UP_BACKOFF_MS = 30000; +// Presence frames come every second (setUartOutput period); this long without one means the module lost them +static constexpr uint32_t FRAME_TIMEOUT_MS = 10000; +static constexpr uint8_t FRAME_FIELDS = 5; + +static constexpr uint8_t LINE_END[] = {'\r', '\n'}; + +// Indexed by Param; empty for parameters that cannot be read +PROGMEM_STRING_TABLE(ReadCommands, "", "", "getRange", "getTrigRange", "getSensitivity", "getLatency", "getInhibit", + "getThrFactor", "getMicroMotion", "getGpioMode 1", "", "", "getSWV", "getHWV"); + +static_assert(PARAM_COUNT <= 16, "pending_writes_ and pending_reads_ hold one bit per parameter"); +static constexpr uint16_t param_bit(Param param) { return static_cast(1U << param); } + +// Parameters kept in the module's flash only after saveConfig +static constexpr uint16_t PERSISTENT_PARAMS = + param_bit(PARAM_RANGE) | param_bit(PARAM_TRIG_RANGE) | param_bit(PARAM_SENSITIVITY) | param_bit(PARAM_LATENCY) | + param_bit(PARAM_INHIBIT) | param_bit(PARAM_THR_FACTOR) | param_bit(PARAM_MICRO_MOTION) | param_bit(PARAM_GPIO_MODE); +// The frames depend on these, so their writes are kept until they succeed +static constexpr uint16_t FRAME_PARAMS = param_bit(PARAM_RUN_APP) | param_bit(PARAM_UART_OUTPUT); + +#ifdef USE_SWITCH +static constexpr bool is_led(Param param) { return param == PARAM_RUN_LED || param == PARAM_OUT_LED; } +static constexpr SwitchSetting led_switch(Param param) { + return param == PARAM_RUN_LED ? SWITCH_RUN_LED : SWITCH_OUT_LED; +} +#endif + +#ifdef USE_NUMBER +static_assert(NUMBER_COUNT <= 16, "changed_numbers_ holds one bit per number"); +static constexpr uint8_t NUMBER_PARAMS[NUMBER_COUNT] PROGMEM = { + PARAM_RANGE, PARAM_RANGE, PARAM_TRIG_RANGE, PARAM_SENSITIVITY, PARAM_SENSITIVITY, + PARAM_LATENCY, PARAM_LATENCY, PARAM_INHIBIT, PARAM_THR_FACTOR, +}; +#endif + +static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { + return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; +} + +static bool contains(const char *str, ESPHOME_PGM_P needle) { + const size_t len = ESPHOME_strlen_P(needle); + for (; *str != '\0'; str++) { + if (ESPHOME_strncmp_P(str, needle, len) == 0) + return true; + } + return false; +} + +// Splits "a,b,c*" in place; returns the number of fields stored in `fields` +static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { + uint8_t count = 0; + char *start = str; + for (;; str++) { + if (*str != ',' && *str != '*' && *str != '\0') + continue; + const bool last = *str != ','; + *str = '\0'; + if (count < max_fields) + fields[count++] = start; + if (last) + return count; + start = str + 1; + } +} + +// Answers that refuse a command whose effect is already in place +static bool answer_means_done(Operation operation, const char *line) { + switch (operation) { + case Operation::OPERATION_STOP: + return contains(line, ESPHOME_PSTR("stopped already")); + case Operation::OPERATION_START: + return contains(line, ESPHOME_PSTR("started already")); + case Operation::OPERATION_SAVE: + return starts_with(line, ESPHOME_PSTR("no parameter")); + default: + return false; + } +} + +static bool parse_float(const char *str, float &value) { + char *end; + value = strtof(str, &end); + return end != str && *end == '\0'; +} + +static const LogString *operation_to_str(Operation operation) { + switch (operation) { + case Operation::OPERATION_STOP: + return LOG_STR("stop"); + case Operation::OPERATION_START: + return LOG_STR("start"); + case Operation::OPERATION_WRITE: + return LOG_STR("write"); + case Operation::OPERATION_SAVE: + return LOG_STR("save"); + case Operation::OPERATION_READ: + return LOG_STR("read"); + default: + return LOG_STR("none"); + } +} + +DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) { +#ifdef USE_NUMBER + this->number_values_.fill(NAN); +#endif +} + +void DFRobotSEN0609Component::dump_config() { + ESP_LOGCONFIG( + TAG, + "DFRobot SEN0609:\n" + " Mode: %s", + this->mode_ == RunMode::RUN_MODE_PRESENCE ? LOG_STR_LITERAL("presence") : LOG_STR_LITERAL("speed and distance")); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "HasTarget", this->has_target_binary_sensor_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "TargetDistance", this->target_distance_sensor_); + LOG_SENSOR(" ", "TargetSpeed", this->target_speed_sensor_); + LOG_SENSOR(" ", "TargetEnergy", this->target_energy_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); + LOG_TEXT_SENSOR(" ", "HardwareVersion", this->hardware_version_text_sensor_); +#endif +#ifdef USE_NUMBER + for (number::Number *n : this->numbers_) { + LOG_NUMBER(" ", "Number", n); + } +#endif +#ifdef USE_SWITCH + for (switch_::Switch *s : this->switches_) { + LOG_SWITCH(" ", "Switch", s); + } +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "OutPinLevel", this->out_pin_level_select_); +#endif +} + +void DFRobotSEN0609Component::setup() { +#ifdef USE_SWITCH + // The module forgets the LED states at power off + for (uint8_t i = SWITCH_RUN_LED; i <= SWITCH_OUT_LED; i++) { + switch_::Switch *sw = this->switches_[i]; + if (sw == nullptr) + continue; + const bool on = sw->get_initial_state_with_restore_mode().value_or(true); + this->publish_switch_(static_cast(i), on != sw->is_inverted()); + } +#endif + this->queue_configuration_(); +} + +void DFRobotSEN0609Component::loop() { + uint8_t buf[MAX_LINE_LENGTH]; + size_t avail = this->available(); + while (avail > 0) { + const size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + if (!this->read_failed_) { + ESP_LOGW(TAG, "UART read failed"); + } + this->read_failed_ = true; + break; + } + this->read_failed_ = false; + avail -= to_read; + for (size_t i = 0; i < to_read; i++) + this->readline_(buf[i]); + } + this->run_sequencer_(); +} + +void DFRobotSEN0609Component::readline_(uint8_t c) { + if (c == '\r') + return; + if (c == '\n') { + if (!this->line_overflow_) { + this->line_[this->line_pos_] = '\0'; + this->handle_line_(this->line_); + } + this->line_overflow_ = false; + this->line_pos_ = 0; + return; + } + if (this->line_overflow_) + return; + if (this->line_pos_ >= MAX_LINE_LENGTH - 1) { + ESP_LOGW(TAG, "Line too long, dropped"); + this->line_overflow_ = true; + return; + } + this->line_[this->line_pos_++] = static_cast(c); + // The prompt has no line end, and the echo of the next command follows it on the same line + if (c == '>') { + this->line_[this->line_pos_] = '\0'; + if (ESPHOME_strcmp_P(this->line_, ESPHOME_PSTR("DFRobot:/>")) == 0) + this->line_pos_ = 0; + } +} + +void DFRobotSEN0609Component::handle_line_(char *line) { + if (*line == '\0') + return; + ESP_LOGV(TAG, "RX: %s", line); + if (line[0] == '$') { + this->handle_frame_(line); + return; + } + // Version lines name their value, so they are used even when they arrive late + const bool software = starts_with(line, ESPHOME_PSTR("SoftwareVersion:")); + if (software || starts_with(line, ESPHOME_PSTR("HardwareVersion:"))) { + if (this->operation_ == Operation::OPERATION_READ && + this->param_ == (software ? PARAM_SW_VERSION : PARAM_HW_VERSION)) + this->version_received_ = true; +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *sens = software ? this->version_text_sensor_ : this->hardware_version_text_sensor_; + if (sens != nullptr) + sens->publish_state(line + 16); +#endif + return; + } + if (this->operation_ == Operation::OPERATION_NONE) { + ESP_LOGD(TAG, "Ignored line, no command pending: %s", line); + return; + } + if (strcmp(line, this->command_) == 0) { + this->echo_seen_ = true; + return; + } + // The module echoes every command first; lines before the echo belong to an earlier command + if (!this->echo_seen_) { + ESP_LOGD(TAG, "Ignored line from an earlier command: %s", line); + return; + } + + if (starts_with(line, ESPHOME_PSTR("Done")) || answer_means_done(this->operation_, line)) { + this->finish_operation_(); + } else if (starts_with(line, ESPHOME_PSTR("Response "))) { + this->parse_response_(line + 9); + } else if (contains(line, ESPHOME_PSTR("not stopped"))) { + // Stop the sensor before the next attempt + this->stopped_ = false; + this->fail_operation_(); + } else if (starts_with(line, ESPHOME_PSTR("Error")) || contains(line, ESPHOME_PSTR("not recognized"))) { + ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); + this->fail_operation_(); + } else { + ESP_LOGD(TAG, "Unexpected answer to '%s': %s", this->command_, line); + } +} + +void DFRobotSEN0609Component::handle_frame_(char *line) { + // $DFHPD,, , , * + if (starts_with(line, ESPHOME_PSTR("$DFHPD,"))) { + const char presence = line[7]; + if (presence != '0' && presence != '1') { + this->log_bad_frame_(LOG_STR("Malformed presence frame"), line); + return; + } + this->last_frame_ms_ = App.get_loop_component_start_time(); +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(presence == '1'); +#endif + return; + } + // $DFDMD,,,,,,...* + if (!starts_with(line, ESPHOME_PSTR("$DFDMD,"))) { + this->log_bad_frame_(LOG_STR("Unknown frame"), line); + return; + } + char *fields[FRAME_FIELDS]; + float distance; + float speed; + float energy; + if (split_fields(line + 7, fields, FRAME_FIELDS) < FRAME_FIELDS) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } + const optional targets = parse_number(fields[0]); + if (!targets.has_value()) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } + if (*targets == 0) { +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(false); +#endif +#ifdef USE_SENSOR + this->publish_target_(NAN, NAN, NAN); +#endif + return; + } + if (!parse_float(fields[2], distance) || !parse_float(fields[3], speed) || !parse_float(fields[4], energy)) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(true); +#endif +#ifdef USE_SENSOR + this->publish_target_(distance, speed, energy); +#endif +} + +void DFRobotSEN0609Component::log_bad_frame_(const LogString *problem, const char *line) { + // The module sends frames several times a second, so only the first bad one is logged + if (this->bad_frame_logged_) + return; + this->bad_frame_logged_ = true; + ESP_LOGW(TAG, "%s: %s", LOG_STR_ARG(problem), line); +} + +#ifdef USE_SENSOR +static void publish_deduplicated(sensor::Sensor *sens, Deduplicator &dedup, float value) { + if (sens == nullptr) + return; + if (std::isnan(value) ? dedup.next_unknown() : dedup.next(value)) + sens->publish_state(value); +} + +void DFRobotSEN0609Component::publish_target_(float distance, float speed, float energy) { + publish_deduplicated(this->target_distance_sensor_, this->distance_dedup_, distance); + publish_deduplicated(this->target_speed_sensor_, this->speed_dedup_, speed); + publish_deduplicated(this->target_energy_sensor_, this->energy_dedup_, energy); +} +#endif + +void DFRobotSEN0609Component::parse_response_(const char *args) { + this->response_count_ = 0; + while (this->response_count_ < MAX_RESPONSE_VALUES) { + char *end; + const float value = strtof(args, &end); + // strtof also takes "nan" and "inf", which no setting can hold + if (end == args || !std::isfinite(value)) + break; + this->response_[this->response_count_++] = value; + args = end; + } +} + +void DFRobotSEN0609Component::queue_configuration_() { + // The mode, the UART output and the LEDs are applied at every boot instead of being saved + uint16_t writes = param_bit(PARAM_RUN_APP); + if (this->mode_ == RunMode::RUN_MODE_PRESENCE) + writes |= param_bit(PARAM_UART_OUTPUT); +#ifdef USE_SWITCH + if (this->switches_[SWITCH_RUN_LED] != nullptr) + writes |= param_bit(PARAM_RUN_LED); + if (this->switches_[SWITCH_OUT_LED] != nullptr) + writes |= param_bit(PARAM_OUT_LED); +#endif + this->pending_writes_ |= writes; + this->pending_reads_ |= this->wanted_reads_(); +} + +uint16_t DFRobotSEN0609Component::wanted_reads_() const { + uint16_t reads = 0; +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + reads |= param_bit(PARAM_SW_VERSION); + if (this->hardware_version_text_sensor_ != nullptr) + reads |= param_bit(PARAM_HW_VERSION); +#endif +#ifdef USE_NUMBER + for (uint8_t i = 0; i < NUMBER_COUNT; i++) { + if (this->numbers_[i] != nullptr) + reads |= param_bit(static_cast(progmem_read_byte(&NUMBER_PARAMS[i]))); + } +#endif +#ifdef USE_SWITCH + if (this->switches_[SWITCH_MICRO_MOTION] != nullptr) + reads |= param_bit(PARAM_MICRO_MOTION); +#endif +#ifdef USE_SELECT + if (this->out_pin_level_select_ != nullptr) + reads |= param_bit(PARAM_GPIO_MODE); +#endif + return reads; +} + +void DFRobotSEN0609Component::check_frames_(uint32_t now) { + // Speed mode frames are not watched: whether the module sends them without a target is not known + if (this->mode_ != RunMode::RUN_MODE_PRESENCE || now - this->last_frame_ms_ < FRAME_TIMEOUT_MS) + return; + // The module most likely restarted on its own and lost the configuration applied at boot + ESP_LOGW(TAG, "No presence frames, applying the configuration again"); + this->status_set_warning(); +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->invalidate_state(); +#endif + this->last_frame_ms_ = now; + this->queue_configuration_(); +} + +void DFRobotSEN0609Component::request_write_(Param param) { + const uint32_t now = App.get_loop_component_start_time(); + this->pending_writes_ |= param_bit(param); + this->last_change_ms_ = now; + if (this->operation_ != Operation::OPERATION_NONE || this->stopped_) + return; + // Start a new sequence a little later, so changes made together end in one flash write. + // A longer hold, such as the back-off from a module that does not answer, is not cut short. + const uint32_t elapsed = now - this->hold_start_ms_; + if (elapsed >= this->hold_ms_ || this->hold_ms_ - elapsed < COALESCE_MS) + this->hold_(COALESCE_MS); +} + +#ifdef USE_NUMBER +void DFRobotSEN0609Component::control_number(NumberSetting setting, float value) { + // The module needs min_range <= trigger_range <= max_range; a value not read yet is NAN and passes + const auto &values = this->number_values_; + const float min_range = setting == NUMBER_MIN_RANGE ? value : values[NUMBER_MIN_RANGE]; + const float max_range = setting == NUMBER_MAX_RANGE ? value : values[NUMBER_MAX_RANGE]; + const float trigger_range = setting == NUMBER_TRIGGER_RANGE ? value : values[NUMBER_TRIGGER_RANGE]; + if (setting <= NUMBER_TRIGGER_RANGE && + (min_range > max_range || trigger_range < min_range || trigger_range > max_range)) { + ESP_LOGW(TAG, "Ranges must keep minimum <= trigger <= maximum, not written"); + if (!std::isnan(this->number_values_[setting])) + this->publish_number_(setting, this->number_values_[setting]); + return; + } + this->changed_numbers_ |= 1U << setting; + this->publish_number_(setting, value); + this->request_write_(static_cast(progmem_read_byte(&NUMBER_PARAMS[setting]))); +} + +void DFRobotSEN0609Component::forget_number_changes_(Param param, bool unknown) { + for (uint8_t i = 0; i < NUMBER_COUNT; i++) { + if (progmem_read_byte(&NUMBER_PARAMS[i]) != param) + continue; + this->changed_numbers_ &= ~(1U << i); + if (unknown) + this->publish_number_(static_cast(i), NAN); + } +} + +void DFRobotSEN0609Component::publish_number_(NumberSetting setting, float value) { + this->number_values_[setting] = value; + if (this->numbers_[setting] != nullptr) + this->numbers_[setting]->publish_state(value); +} +#endif + +#ifdef USE_SWITCH +void DFRobotSEN0609Component::control_switch(SwitchSetting setting, bool state) { + this->publish_switch_(setting, state); + switch (setting) { + case SWITCH_MICRO_MOTION: + this->request_write_(PARAM_MICRO_MOTION); + break; + case SWITCH_RUN_LED: + this->request_write_(PARAM_RUN_LED); + break; + default: + this->request_write_(PARAM_OUT_LED); + break; + } +} + +void DFRobotSEN0609Component::publish_switch_(SwitchSetting setting, bool state) { + this->switch_states_[setting] = state; + if (this->switches_[setting] != nullptr) + this->switches_[setting]->publish_state(state); +} +#endif + +#ifdef USE_SELECT +void DFRobotSEN0609Component::control_out_pin_level(size_t index) { + this->out_pin_level_ = static_cast(index); + this->out_pin_level_select_->publish_state(index); + this->request_write_(PARAM_GPIO_MODE); +} +#endif + +void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { + this->hold_start_ms_ = App.get_loop_component_start_time(); + this->hold_ms_ = duration_ms; +} + +void DFRobotSEN0609Component::run_sequencer_() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->operation_ != Operation::OPERATION_NONE) { + const bool slow = this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_SAVE; + if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) + return; + ESP_LOGD(TAG, "No answer to '%s'", this->command_); + this->fail_operation_(); + return; + } + if (now - this->hold_start_ms_ < this->hold_ms_) + return; + + const bool work_pending = this->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; + Operation operation; + Param param = PARAM_COUNT; + if (!work_pending) { + if (!this->stopped_) { + this->check_frames_(now); + // Work given up on is tried again in a later sequence, so the sensor runs in between + if ((this->deferred_writes_ != 0 || this->deferred_save_) && now - this->deferred_ms_ >= GIVE_UP_BACKOFF_MS) { + this->pending_writes_ |= this->deferred_writes_; + this->save_pending_ = this->save_pending_ || this->deferred_save_; + this->deferred_writes_ = 0; + this->deferred_save_ = false; + } + return; + } + operation = Operation::OPERATION_START; + } else if (!this->stopped_) { + operation = Operation::OPERATION_STOP; + } else if (this->pending_writes_ != 0) { + operation = Operation::OPERATION_WRITE; + param = static_cast(__builtin_ctz(this->pending_writes_)); + } else if (this->save_pending_) { + // Changes made within a second of each other share one flash write, also while a sequence runs + if (now - this->last_change_ms_ < COALESCE_MS) + return; + operation = Operation::OPERATION_SAVE; + } else { + operation = Operation::OPERATION_READ; + param = static_cast(__builtin_ctz(this->pending_reads_)); + } + + const uint16_t bit = param_bit(param); + bool built = this->build_command_(operation, param); + if (!built && operation == Operation::OPERATION_WRITE) { + // The value this write is paired with is unknown: read it first, once, and keep the write pending + if ((this->paired_reads_ & bit) == 0) + this->pending_reads_ |= this->wanted_reads_() & bit; + if ((this->pending_reads_ & bit) != 0) { + this->paired_reads_ |= bit; + operation = Operation::OPERATION_READ; + built = this->build_command_(operation, param); + } + } + if (!built) { + if (operation == Operation::OPERATION_WRITE) { + ESP_LOGW(TAG, "Setting not written: the value it is paired with could not be read"); + this->pending_writes_ &= ~bit; + this->paired_reads_ &= ~bit; +#ifdef USE_NUMBER + // Both values come from the read that failed, so what the module holds is unknown + this->forget_number_changes_(param, true); +#endif + } else { + ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), + static_cast(param)); + this->pending_reads_ &= ~bit; + } + this->sequence_failed_ = true; + this->status_set_warning(); + return; + } + // Cleared now, so a change made while the write is on its way is written again + if (operation == Operation::OPERATION_WRITE) { + this->pending_writes_ &= ~bit; + this->paired_reads_ &= ~bit; +#ifdef USE_NUMBER + this->forget_number_changes_(param, false); +#endif + } + this->operation_ = operation; + this->param_ = param; + this->response_count_ = 0; + this->echo_seen_ = false; + this->version_received_ = false; + this->sent_ms_ = now; + ESP_LOGV(TAG, "TX: %s", this->command_); + this->write_str(this->command_); + this->write_array(LINE_END, sizeof(LINE_END)); +} + +bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { + char *buf = this->command_; + constexpr size_t len = MAX_COMMAND_LENGTH; + switch (operation) { + case Operation::OPERATION_STOP: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStop"), len); + return true; + case Operation::OPERATION_START: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStart"), len); + return true; + case Operation::OPERATION_SAVE: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("saveConfig"), len); + return true; + case Operation::OPERATION_READ: + ESPHOME_strncpy_P(buf, reinterpret_cast(ReadCommands::get_progmem_str(param, 0)), len); + return buf[0] != '\0'; + default: + break; + } + + switch (param) { + case PARAM_RUN_APP: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setRunApp %u"), static_cast(this->mode_)); + return true; + case PARAM_UART_OUTPUT: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("setUartOutput 1 1 1 1.0"), len); + return true; +#ifdef USE_SWITCH + case PARAM_MICRO_MOTION: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setMicroMotion %u"), + static_cast(this->switch_states_[SWITCH_MICRO_MOTION])); + return true; + case PARAM_RUN_LED: + case PARAM_OUT_LED: { + // 0 turns the LED on + const bool on = this->switch_states_[led_switch(param)]; + this->sent_led_on_ = on; + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setLedMode %u %u"), param == PARAM_RUN_LED ? 1U : 2U, on ? 0U : 1U); + return true; + } +#endif +#ifdef USE_SELECT + case PARAM_GPIO_MODE: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setGpioMode 1 %u"), static_cast(this->out_pin_level_)); + return true; +#endif + default: + break; + } + +#ifdef USE_NUMBER + const auto &values = this->number_values_; + float first = NAN; + float second = 0.0f; + ESPHOME_PGM_P format = nullptr; + switch (param) { + case PARAM_RANGE: + first = values[NUMBER_MIN_RANGE]; + second = values[NUMBER_MAX_RANGE]; + format = ESPHOME_PSTR("setRange %.2f %.2f"); + break; + case PARAM_TRIG_RANGE: + first = values[NUMBER_TRIGGER_RANGE]; + format = ESPHOME_PSTR("setTrigRange %.2f"); + break; + case PARAM_SENSITIVITY: + first = values[NUMBER_HOLD_SENSITIVITY]; + second = values[NUMBER_TRIGGER_SENSITIVITY]; + format = ESPHOME_PSTR("setSensitivity %.0f %.0f"); + break; + case PARAM_LATENCY: + first = values[NUMBER_ON_LATENCY]; + second = values[NUMBER_OFF_LATENCY]; + format = ESPHOME_PSTR("setLatency %.2f %.1f"); + break; + case PARAM_INHIBIT: + first = values[NUMBER_INHIBIT_TIME]; + format = ESPHOME_PSTR("setInhibit %.2f"); + break; + case PARAM_THR_FACTOR: + first = values[NUMBER_THRESHOLD_FACTOR]; + format = ESPHOME_PSTR("setThrFactor %.0f"); + break; + default: + return false; + } + // Commands that take two values need both read from the module first + if (std::isnan(first) || std::isnan(second)) + return false; + ESPHOME_snprintf_P(buf, len, format, first, second); + return true; +#else + return false; +#endif +} + +bool DFRobotSEN0609Component::apply_read_(Param param) { + const float *r = this->response_; + const uint8_t count = this->response_count_; + // A change still waiting to be written is newer than what the module holds + const bool write_pending = (this->pending_writes_ & param_bit(param)) != 0; +#ifdef USE_NUMBER + // Checked for each number: publishing runs automations, which may change the next one + auto read_number = [this](NumberSetting setting, float value) { + if ((this->changed_numbers_ & (1U << setting)) == 0) + this->publish_number_(setting, value); + }; +#endif + switch (param) { +#ifdef USE_NUMBER + case PARAM_RANGE: + if (count < 2) + return false; + read_number(NUMBER_MIN_RANGE, r[0]); + read_number(NUMBER_MAX_RANGE, r[1]); + return true; + case PARAM_TRIG_RANGE: + if (count < 1) + return false; + read_number(NUMBER_TRIGGER_RANGE, r[0]); + return true; + case PARAM_SENSITIVITY: + if (count < 2) + return false; + read_number(NUMBER_HOLD_SENSITIVITY, r[0]); + read_number(NUMBER_TRIGGER_SENSITIVITY, r[1]); + return true; + case PARAM_LATENCY: + if (count < 2) + return false; + read_number(NUMBER_ON_LATENCY, r[0]); + read_number(NUMBER_OFF_LATENCY, r[1]); + return true; + case PARAM_INHIBIT: + if (count < 1) + return false; + read_number(NUMBER_INHIBIT_TIME, r[0]); + return true; + case PARAM_THR_FACTOR: + if (count < 1) + return false; + read_number(NUMBER_THRESHOLD_FACTOR, r[0]); + return true; +#endif +#ifdef USE_SWITCH + case PARAM_MICRO_MOTION: + if (count < 1) + return false; + if (!write_pending) + this->publish_switch_(SWITCH_MICRO_MOTION, r[0] != 0.0f); + return true; +#endif +#ifdef USE_SELECT + case PARAM_GPIO_MODE: + // Response + if (count < 2 || (r[1] != 0.0f && r[1] != 1.0f)) + return false; + if (!write_pending) { + this->out_pin_level_ = static_cast(r[1]); + this->out_pin_level_select_->publish_state(this->out_pin_level_); + } + return true; +#endif + case PARAM_SW_VERSION: + case PARAM_HW_VERSION: + // The versions are published as their lines arrive + return this->version_received_; + default: + return true; + } +} + +void DFRobotSEN0609Component::finish_operation_() { + switch (this->operation_) { + case Operation::OPERATION_STOP: + this->stopped_ = true; + break; + case Operation::OPERATION_START: + this->stopped_ = false; + this->last_frame_ms_ = App.get_loop_component_start_time(); + if (!this->sequence_failed_) + this->status_clear_warning(); + this->sequence_failed_ = false; + break; + case Operation::OPERATION_WRITE: + this->deferred_writes_ &= ~param_bit(this->param_); + if (PERSISTENT_PARAMS & param_bit(this->param_)) + this->save_pending_ = true; +#ifdef USE_SWITCH + if (is_led(this->param_)) { + const uint8_t bit = 1U << led_switch(this->param_); + this->led_known_ |= bit; + this->led_on_ = this->sent_led_on_ ? (this->led_on_ | bit) : (this->led_on_ & ~bit); + } +#endif + break; + case Operation::OPERATION_SAVE: + this->save_pending_ = false; + this->deferred_save_ = false; + break; + case Operation::OPERATION_READ: + if (!this->apply_read_(this->param_)) { + ESP_LOGW(TAG, "Malformed answer to '%s'", this->command_); + this->fail_operation_(); + return; + } + this->pending_reads_ &= ~param_bit(this->param_); + this->paired_reads_ &= ~param_bit(this->param_); + break; + default: + return; + } + if (this->is_aux_operation_()) { + this->aux_attempts_ = 0; + } else { + this->attempts_ = 0; + } + this->operation_ = Operation::OPERATION_NONE; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::fail_operation_() { + const bool aux = this->is_aux_operation_(); + uint8_t &attempts = aux ? this->aux_attempts_ : this->attempts_; + const Operation operation = this->operation_; + this->operation_ = Operation::OPERATION_NONE; + this->hold_(COMMAND_GAP_MS); + const bool keep_write = operation == Operation::OPERATION_WRITE && (FRAME_PARAMS & param_bit(this->param_)) != 0; + const bool retry = ++attempts < MAX_ATTEMPTS; + if (operation == Operation::OPERATION_WRITE && (retry || keep_write)) + this->pending_writes_ |= param_bit(this->param_); + if (retry) + return; + + attempts = 0; + this->status_set_warning(); + if (aux || keep_write) { + // Without stop, start or the configuration the sensor cannot run as set up; try again later + ESP_LOGW(TAG, "Command '%s' failed, trying again later", this->command_); + this->hold_ms_ = GIVE_UP_BACKOFF_MS; + return; + } + ESP_LOGW(TAG, "Giving up on %s '%s'", LOG_STR_ARG(operation_to_str(operation)), this->command_); + this->sequence_failed_ = true; + switch (operation) { + case Operation::OPERATION_WRITE: +#ifdef USE_SWITCH + if (is_led(this->param_)) { + // A newer change is written anyway + if ((this->pending_writes_ & param_bit(this->param_)) != 0) + break; + // The LEDs cannot be read: show the state the module last accepted, or write this one again later + const uint8_t bit = 1U << led_switch(this->param_); + if ((this->led_known_ & bit) != 0) { + this->publish_switch_(led_switch(this->param_), (this->led_on_ & bit) != 0); + } else { + this->deferred_writes_ |= param_bit(this->param_); + this->deferred_ms_ = App.get_loop_component_start_time(); + } + break; + } +#endif + // The entity shows a value the module never got; show the module's value again + this->pending_reads_ |= this->wanted_reads_() & param_bit(this->param_); + break; + case Operation::OPERATION_SAVE: + // The entities show the changes as kept, but unsaved they are lost at power off: save in a later sequence + this->save_pending_ = false; + this->deferred_save_ = true; + this->deferred_ms_ = App.get_loop_component_start_time(); + break; + case Operation::OPERATION_READ: + this->pending_reads_ &= ~param_bit(this->param_); + break; + default: + break; + } +} + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h new file mode 100644 index 00000000000..719aa6f5fe0 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -0,0 +1,213 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::dfrobot_sen0609 { + +static constexpr uint8_t MAX_LINE_LENGTH = 80; +static constexpr uint8_t MAX_COMMAND_LENGTH = 40; +static constexpr uint8_t MAX_RESPONSE_VALUES = 3; + +enum class RunMode : uint8_t { + RUN_MODE_PRESENCE = 0, + RUN_MODE_SPEED_AND_DISTANCE = 1, +}; + +/// Settings shown as number entities. +enum NumberSetting : uint8_t { + NUMBER_MIN_RANGE, + NUMBER_MAX_RANGE, + NUMBER_TRIGGER_RANGE, + NUMBER_HOLD_SENSITIVITY, + NUMBER_TRIGGER_SENSITIVITY, + NUMBER_ON_LATENCY, + NUMBER_OFF_LATENCY, + NUMBER_INHIBIT_TIME, + NUMBER_THRESHOLD_FACTOR, + NUMBER_COUNT, +}; + +/// Settings shown as switch entities. +enum SwitchSetting : uint8_t { + SWITCH_MICRO_MOTION, + SWITCH_RUN_LED, + SWITCH_OUT_LED, + SWITCH_COUNT, +}; + +/// Module parameters; a write or read of each is one command. Pending work runs in this order. +enum Param : uint8_t { + PARAM_RUN_APP, + PARAM_UART_OUTPUT, + PARAM_RANGE, + PARAM_TRIG_RANGE, + PARAM_SENSITIVITY, + PARAM_LATENCY, + PARAM_INHIBIT, + PARAM_THR_FACTOR, + PARAM_MICRO_MOTION, + PARAM_GPIO_MODE, + PARAM_RUN_LED, + PARAM_OUT_LED, + PARAM_SW_VERSION, + PARAM_HW_VERSION, + PARAM_COUNT, +}; + +enum class Operation : uint8_t { + OPERATION_NONE, + OPERATION_STOP, + OPERATION_START, + OPERATION_WRITE, + OPERATION_SAVE, + OPERATION_READ, +}; + +class DFRobotSEN0609Component final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(has_target) +#endif +#ifdef USE_SENSOR + SUB_SENSOR(target_distance) + SUB_SENSOR(target_speed) + SUB_SENSOR(target_energy) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(version) + SUB_TEXT_SENSOR(hardware_version) +#endif +#ifdef USE_SELECT + SUB_SELECT(out_pin_level) +#endif + + public: + explicit DFRobotSEN0609Component(RunMode mode); + + void setup() override; + void loop() override; + void dump_config() override; + +#ifdef USE_NUMBER + void set_number(NumberSetting setting, number::Number *number) { this->numbers_[setting] = number; } + void control_number(NumberSetting setting, float value); +#endif +#ifdef USE_SWITCH + void set_switch(SwitchSetting setting, switch_::Switch *sw) { this->switches_[setting] = sw; } + void control_switch(SwitchSetting setting, bool state); +#endif +#ifdef USE_SELECT + void control_out_pin_level(size_t index); +#endif + + protected: + void readline_(uint8_t c); + void handle_line_(char *line); + void handle_frame_(char *line); + void log_bad_frame_(const LogString *problem, const char *line); + void parse_response_(const char *args); + + void queue_configuration_(); + uint16_t wanted_reads_() const; + void request_write_(Param param); + void run_sequencer_(); + void check_frames_(uint32_t now); + bool build_command_(Operation operation, Param param); + bool apply_read_(Param param); + void finish_operation_(); + void fail_operation_(); + void hold_(uint32_t duration_ms); + bool is_aux_operation_() const { + return this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_START; + } + +#ifdef USE_NUMBER + void publish_number_(NumberSetting setting, float value); + void forget_number_changes_(Param param, bool unknown); +#endif +#ifdef USE_SWITCH + void publish_switch_(SwitchSetting setting, bool state); +#endif +#ifdef USE_SENSOR + void publish_target_(float distance, float speed, float energy); + Deduplicator distance_dedup_; + Deduplicator speed_dedup_; + Deduplicator energy_dedup_; +#endif + +#ifdef USE_NUMBER + std::array numbers_{}; + std::array number_values_; +#endif +#ifdef USE_SWITCH + std::array switches_{}; + std::array switch_states_{}; +#endif + + char line_[MAX_LINE_LENGTH]; + char command_[MAX_COMMAND_LENGTH]{}; + float response_[MAX_RESPONSE_VALUES]{}; + uint32_t sent_ms_{0}; + uint32_t hold_start_ms_{0}; + uint32_t hold_ms_{0}; + uint32_t last_frame_ms_{0}; + uint32_t last_change_ms_{0}; + uint32_t deferred_ms_{0}; + uint16_t pending_writes_{0}; + uint16_t pending_reads_{0}; + // Reads made for a write whose paired value is unknown; the write is dropped when the read fails + uint16_t paired_reads_{0}; + // Writes given up on that are tried again in a later sequence + uint16_t deferred_writes_{0}; +#ifdef USE_NUMBER + // Numbers changed since their last write was sent, one bit per NumberSetting + uint16_t changed_numbers_{0}; +#endif + RunMode mode_; + Operation operation_{Operation::OPERATION_NONE}; + Param param_{PARAM_COUNT}; + uint8_t line_pos_{0}; + uint8_t response_count_{0}; + uint8_t out_pin_level_{0}; + uint8_t attempts_{0}; + uint8_t aux_attempts_{0}; +#ifdef USE_SWITCH + // The module cannot be asked for its LED states: the ones it last accepted, one bit per SwitchSetting + uint8_t led_known_{0}; + uint8_t led_on_{0}; + bool sent_led_on_{false}; +#endif + bool line_overflow_{false}; + bool read_failed_{false}; + bool bad_frame_logged_{false}; + bool echo_seen_{false}; + bool version_received_{false}; + bool stopped_{false}; + bool save_pending_{false}; + bool deferred_save_{false}; + bool sequence_failed_{false}; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/number/__init__.py b/esphome/components/dfrobot_sen0609/number/__init__.py new file mode 100644 index 00000000000..668ad929c50 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/number/__init__.py @@ -0,0 +1,194 @@ +from typing import NamedTuple + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MAX_RANGE, + CONF_MIN_RANGE, + CONF_MODEL, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + ICON_MOTION_SENSOR, + ICON_RULER, + ICON_TIMELAPSE, + UNIT_METER, + UNIT_SECOND, +) +from esphome.core import CORE +from esphome.types import ConfigType + +from .. import ( + CONF_DFROBOT_SEN0609_ID, + MAX_RANGES, + MODE_PRESENCE, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + dfrobot_sen0609_ns, + final_validate_mode, + get_hub_config, +) + +DFRobotSEN0609Number = dfrobot_sen0609_ns.class_("DFRobotSEN0609Number", number.Number) +NumberSetting = dfrobot_sen0609_ns.enum("NumberSetting") + +CONF_HOLD_SENSITIVITY = "hold_sensitivity" +CONF_INHIBIT_TIME = "inhibit_time" +CONF_OFF_LATENCY = "off_latency" +CONF_ON_LATENCY = "on_latency" +CONF_THRESHOLD_FACTOR = "threshold_factor" +CONF_TRIGGER_RANGE = "trigger_range" +CONF_TRIGGER_SENSITIVITY = "trigger_sensitivity" + + +class Setting(NamedTuple): + key: str + setting: cg.MockObj + min_value: float + max_value: float | None # None: the model's longest range + step: float + mode: str | None # None: both modes + schema: cv.Schema + + +def _distance(icon: str = ICON_RULER) -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + unit_of_measurement=UNIT_METER, + ) + + +def _duration() -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_TIMELAPSE, + unit_of_measurement=UNIT_SECOND, + ) + + +def _plain() -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ) + + +SETTINGS = ( + Setting( + CONF_MIN_RANGE, + NumberSetting.NUMBER_MIN_RANGE, + 0.6, + None, + 0.1, + None, + _distance(), + ), + Setting( + CONF_MAX_RANGE, + NumberSetting.NUMBER_MAX_RANGE, + 0.6, + None, + 0.1, + None, + _distance(), + ), + Setting( + CONF_TRIGGER_RANGE, + NumberSetting.NUMBER_TRIGGER_RANGE, + 0.6, + None, + 0.1, + MODE_PRESENCE, + _distance(ICON_MOTION_SENSOR), + ), + Setting( + CONF_HOLD_SENSITIVITY, + NumberSetting.NUMBER_HOLD_SENSITIVITY, + 0, + 9, + 1, + MODE_PRESENCE, + _plain(), + ), + Setting( + CONF_TRIGGER_SENSITIVITY, + NumberSetting.NUMBER_TRIGGER_SENSITIVITY, + 0, + 9, + 1, + MODE_PRESENCE, + _plain(), + ), + Setting( + CONF_ON_LATENCY, + NumberSetting.NUMBER_ON_LATENCY, + 0, + 2, + 0.01, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_OFF_LATENCY, + NumberSetting.NUMBER_OFF_LATENCY, + 2, + 1500, + 0.5, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_INHIBIT_TIME, + NumberSetting.NUMBER_INHIBIT_TIME, + 0.1, + 60, + 0.1, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_THRESHOLD_FACTOR, + NumberSetting.NUMBER_THRESHOLD_FACTOR, + 0, + 65535, + 1, + MODE_SPEED_AND_DISTANCE, + _plain(), + ), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + **{cv.Optional(s.key): s.schema for s in SETTINGS}, + } +) + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + {s.key: s.mode for s in SETTINGS if s.mode is not None} +) + + +async def to_code(config: ConfigType) -> None: + hub_id = config[CONF_DFROBOT_SEN0609_ID] + hub = await cg.get_variable(hub_id) + max_range = MAX_RANGES[get_hub_config(CORE.config, hub_id)[CONF_MODEL]] + numbers = number.sub_numbers(config, parent=hub) + for s in SETTINGS: + await numbers( + s.key, + lambda n, setting=s.setting: hub.set_number(setting, n), + s.setting, + min_value=s.min_value, + max_value=max_range if s.max_value is None else s.max_value, + step=s.step, + ) diff --git a/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h b/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h new file mode 100644 index 00000000000..d241ec75e10 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Number final : public number::Number, public Parented { + public: + explicit DFRobotSEN0609Number(NumberSetting setting) : setting_(setting) {} + + protected: + void control(float value) override { this->parent_->control_number(this->setting_, value); } + + NumberSetting setting_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/select/__init__.py b/esphome/components/dfrobot_sen0609/select/__init__.py new file mode 100644 index 00000000000..a68ed8c8729 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/select/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL +import esphome.config_validation as cv +from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType + +from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns + +OutPinLevelSelect = dfrobot_sen0609_ns.class_("OutPinLevelSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_OUT_PIN_LEVEL): select.select_schema( + OutPinLevelSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_SCALE, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + selects = select.sub_selects(config, parent=hub) + # The level the OUT pin has while a target is present; the index is the module's value + await selects( + CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + ) diff --git a/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h new file mode 100644 index 00000000000..0ea2fdd8974 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class OutPinLevelSelect final : public select::Select, public Parented { + public: + // User provided, not "= default": `new(p) OutPinLevelSelect()` would zero-fill .bss that is already zero. + OutPinLevelSelect() {} + + protected: + void control(size_t index) override { this->parent_->control_out_pin_level(index); } +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/sensor.py b/esphome/components/dfrobot_sen0609/sensor.py new file mode 100644 index 00000000000..43c1a1bb05a --- /dev/null +++ b/esphome/components/dfrobot_sen0609/sensor.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_SPEED, + ICON_SIGNAL, + STATE_CLASS_MEASUREMENT, + UNIT_METER, + UNIT_METER_PER_SECOND, +) +from esphome.types import ConfigType + +from . import ( + CONF_DFROBOT_SEN0609_ID, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + final_validate_mode, +) + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONF_TARGET_DISTANCE = "target_distance" +CONF_TARGET_ENERGY = "target_energy" +CONF_TARGET_SPEED = "target_speed" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_TARGET_DISTANCE): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_METER, + ), + cv.Optional(CONF_TARGET_SPEED): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_METER_PER_SECOND, + ), + cv.Optional(CONF_TARGET_ENERGY): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_SIGNAL, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + { + CONF_TARGET_DISTANCE: MODE_SPEED_AND_DISTANCE, + CONF_TARGET_ENERGY: MODE_SPEED_AND_DISTANCE, + CONF_TARGET_SPEED: MODE_SPEED_AND_DISTANCE, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_DISTANCE, hub.set_target_distance_sensor) + await sensors(CONF_TARGET_SPEED, hub.set_target_speed_sensor) + await sensors(CONF_TARGET_ENERGY, hub.set_target_energy_sensor) diff --git a/esphome/components/dfrobot_sen0609/switch/__init__.py b/esphome/components/dfrobot_sen0609/switch/__init__.py new file mode 100644 index 00000000000..3baa9656f4c --- /dev/null +++ b/esphome/components/dfrobot_sen0609/switch/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_SWITCH, + ENTITY_CATEGORY_CONFIG, + ICON_LIGHTBULB, + ICON_MOTION_SENSOR, +) +from esphome.types import ConfigType + +from .. import ( + CONF_DFROBOT_SEN0609_ID, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + dfrobot_sen0609_ns, + final_validate_mode, +) + +DFRobotSEN0609Switch = dfrobot_sen0609_ns.class_("DFRobotSEN0609Switch", switch.Switch) +SwitchSetting = dfrobot_sen0609_ns.enum("SwitchSetting") + +CONF_MICRO_MOTION = "micro_motion" +CONF_OUT_LED = "out_led" +CONF_RUN_LED = "run_led" + + +def _led() -> cv.Schema: + # The module forgets the LED state at power off, so it is restored from here + return switch.switch_schema( + DFRobotSEN0609Switch, + default_restore_mode="RESTORE_DEFAULT_ON", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_LIGHTBULB, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_MICRO_MOTION): switch.switch_schema( + DFRobotSEN0609Switch, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ), + cv.Optional(CONF_RUN_LED): _led(), + cv.Optional(CONF_OUT_LED): _led(), +} + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + {CONF_MICRO_MOTION: MODE_SPEED_AND_DISTANCE} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + switches = switch.sub_switches(config, parent=hub) + for key, setting in ( + (CONF_MICRO_MOTION, SwitchSetting.SWITCH_MICRO_MOTION), + (CONF_RUN_LED, SwitchSetting.SWITCH_RUN_LED), + (CONF_OUT_LED, SwitchSetting.SWITCH_OUT_LED), + ): + await switches( + key, lambda s, setting=setting: hub.set_switch(setting, s), setting + ) diff --git a/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h new file mode 100644 index 00000000000..dc2def0d809 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Switch final : public switch_::Switch, public Parented { + public: + explicit DFRobotSEN0609Switch(SwitchSetting setting) : setting_(setting) {} + + protected: + void write_state(bool state) override { this->parent_->control_switch(this->setting_, state); } + + SwitchSetting setting_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/text_sensor.py b/esphome/components/dfrobot_sen0609/text_sensor.py new file mode 100644 index 00000000000..8972f33c58e --- /dev/null +++ b/esphome/components/dfrobot_sen0609/text_sensor.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONF_HARDWARE_VERSION = "hardware_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + cv.Optional(CONF_HARDWARE_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_HARDWARE_VERSION, hub.set_hardware_version_text_sensor) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 0b871af30cd..22e3d9320b0 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -250,6 +250,42 @@ def add_metadata( ) +KEY_EXTERNAL_WRITERS = "external_writers" + + +def _get_external_writer_list() -> list[tuple[ID, str]]: + """Get the raw external writer list. Each entry is (display id, owner).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_EXTERNAL_WRITERS, []) + + +def register_external_writer(display_id: ID, owner: str) -> None: + """Declare that ``owner`` draws into the display from outside its lambda or pages. + + Call from the owner's CONFIG_SCHEMA. The ID may still be unresolved; the ID pass + resolves it in place before final validation. + """ + _get_external_writer_list().append((display_id, owner)) + + +def get_external_writers(display_id: ID) -> list[str]: + """Get the owners registered for a display. Only valid once IDs are resolved.""" + return [ + owner + for id_, owner in _get_external_writer_list() + if id_.id is not None and id_.id == display_id.id + ] + + +def has_external_writer(display_id: ID) -> bool: + """Tell a driver's final validation that something else draws into the display. + + LVGL anywhere in the config also counts, as the drivers assumed before. + """ + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + return LVGL_DOMAIN in full_config.get() or bool(get_external_writers(display_id)) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 0bd8346953c..ba8f85463f4 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -5,6 +5,7 @@ #include "display_color_utils.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::display { @@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_ this->line(x1, y1, x2, y2, color); } -void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { - size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels - uint32_t color_value; +// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at +// -Os stops inlining it once there are two call sites, which costs a call and +// spills per pixel. Without PSRAM there is one call site and it changes +// nothing, and draw_pixel_at() stays a virtual call either way. +template +static void __attribute__((flatten)) +draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad) { + const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels + // BITNESS is a constant here, so to_color()'s three per pixel divisions + // become a multiply and shift and its bitness switch folds. + const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE { + display->draw_pixel_at( + x + x_start, y + y_start, + ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, big_endian), order, BITNESS)); + }; + // The source and frame buffers are row-major, so walking rows keeps the read + // and the write sequential. When the display swaps the axes before writing, + // walking columns keeps the write sequential instead; with the frame buffer + // in PSRAM a strided write costs more than a strided read. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + size_t column_idx = y_offset * line_stride + x_offset; + for (int x = 0; x != w; x++, column_idx++) { + size_t source_idx = column_idx; + for (int y = 0; y != h; y++, source_idx += line_stride) { + draw_source_pixel(x, y, source_idx); + } + } + return; + } +#endif + // Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped. for (int y = 0; y != h; y++) { size_t source_idx = (y_offset + y) * line_stride + x_offset; - size_t source_idx_mod; for (int x = 0; x != w; x++, source_idx++) { - switch (bitness) { - default: - color_value = ptr[source_idx]; - break; - case COLOR_BITNESS_565: - source_idx_mod = source_idx * 2; - if (big_endian) { - color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1]; - } else { - color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8); - } - break; - case COLOR_BITNESS_888: - source_idx_mod = source_idx * 3; - if (big_endian) { - color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2]; - } else { - color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16); - } - break; - } - this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness)); + draw_source_pixel(x, y, source_idx); } } } +void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + switch (bitness) { + case COLOR_BITNESS_565: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + case COLOR_BITNESS_888: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + default: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + } +} + void HOT Display::horizontal_line(int x, int y, int width, Color color) { // Future: Could be made more efficient by manipulating buffer directly in certain rotations. for (int i = x; i < x + width; i++) @@ -817,7 +843,6 @@ constexpr uint8_t TESTCARD_FONT[3][8] PROGMEM = {{0x41, 0x7F, 0x7F, 0x09, 0x19, void Display::test_card() { int w = get_width(), h = get_height(), image_w, image_h; this->clear(); - this->show_test_card_ = false; image_w = std::min(w - 20, 310); image_h = std::min(h - 20, 255); int shift_x = (w - image_w) / 2; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 7d1053f4fa7..f17b7d86f71 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -139,6 +139,11 @@ enum DisplayRotation { DISPLAY_ROTATION_270_DEGREES = 270, }; +/// Whether a rotation swaps the x and y axes. +constexpr bool rotation_swaps_axes(DisplayRotation rotation) { + return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES; +} + const int EDGES_TRIGON = 3; const int EDGES_TRIANGLE = 3; const int EDGES_TETRAGON = 4; @@ -352,7 +357,8 @@ class Display : public PollingComponent { * \param ptr A pointer to the start of the data to be copied * \param order The ordering of the colors * \param bitness Defines the number of bits and their format for each pixel - * \param big_endian True if 16 bit values are stored big-endian + * \param big_endian True if 16 bit values are stored big-endian. Ignored for 888, which is always b,g,r + * in memory for RGB order, as LVGL and ESPHome images store it. * \param x_offset The initial x-offset into the source buffer. * \param y_offset The initial y-offset into the source buffer. * \param x_pad How many pixels are in each line after the end of the pixels to be copied. @@ -717,6 +723,12 @@ class Display : public PollingComponent { virtual DisplayRotation get_rotation() const { return this->rotation_; } + /// True when draw_pixel_at() swaps x and y before writing, so pixels that are + /// neighbours along x land a row apart in the frame buffer. Not the same as + /// get_width()/get_height() swapping: a display that rotates in hardware + /// reports swapped dimensions but writes unswapped pixels. + virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(this->rotation_); } + /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. */ diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index be39e2a3e2f..bd658986519 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -2,6 +2,7 @@ #include #include #include "esphome/core/color.h" +#include "esphome/core/helpers.h" namespace esphome::display { enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 }; @@ -10,6 +11,26 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25 class ColorUtil { public: + /// Reads one packed pixel of the given bitness from a source buffer into the + /// value to_color() takes. BITNESS is a template argument so the byte layout + /// folds; pass it on to to_color() as a constant too, and its channel scaling + /// divides by a constant, which the compiler turns into a multiply and shift. + template + static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) { + if constexpr (BITNESS == COLOR_BITNESS_565) { + const size_t idx = index * 2; + if (big_endian) + return (ptr[idx] << 8) + ptr[idx + 1]; + return ptr[idx] + (ptr[idx + 1] << 8); + } else if constexpr (BITNESS == COLOR_BITNESS_888) { + // Always the LVGL RGB888 layout, b,g,r in memory for RGB order; byte order applies to 565 only + const size_t idx = index * 3; + return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); + } else { + return ptr[index]; + } + } + static Color to_color(uint32_t colorcode, ColorOrder color_order, ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { uint8_t first_color, second_color, third_color; diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp index c9d64892c48..d41bd311b7d 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -118,13 +118,8 @@ void Ecocomfort2Fan::on_status() { int new_speed = this->speed; uint8_t base_speed = raw_speed & SPEED_MASK; - if (raw_speed & FLAG_BOOST) { - new_speed = SPEED_COUNT; - } else if (raw_speed & FLAG_NIGHT) { - new_speed = SPEED_SLEEP; - } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) new_speed = base_speed; - } const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); const char *new_preset = preset != nullptr ? preset->name : nullptr; bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 00ce0f20804..223648e3276 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -205,6 +205,10 @@ void Ecocomfort2Hub::publish_unknown_() { sensor->publish_state(NAN); } #endif +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->invalidate_state(); +#endif } // C_OPER: mode byte, speed/flags byte @@ -216,10 +220,6 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { this->mode_ = data[0]; this->speed_ = data[1]; ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); -#ifdef USE_BINARY_SENSOR - if (this->boost_binary_sensor_ != nullptr) - this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); -#endif #ifdef ECOCOMFORT2_CHILD_COUNT for (auto *child : this->children_) { child->on_status(); @@ -227,7 +227,9 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { #endif } -// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +// C_STATE: bytes 0-1 the mode and speed the unit runs; byte 1 also has the boost flag (0x40) and the advanced +// control step (0x20). Byte 2 the air flow direction, bytes 3-4 temperature and 5-6 humidity in hundredths, +// 7-8 VOC in ppb void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { if (len < 9) { ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); @@ -236,8 +238,12 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; const float humidity = encode_uint16(data[5], data[6]) / 100.0f; const uint16_t voc = encode_uint16(data[7], data[8]); - ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, - humidity, voc, data[2]); + ESP_LOGD(TAG, "[%s] C_STATE: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", + this->parent_->address_str(), data[0], data[1], temperature, humidity, voc, data[2]); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((data[1] & FLAG_BOOST) != 0); +#endif #ifdef USE_SENSOR if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); @@ -267,7 +273,7 @@ void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { } #ifdef USE_TIME -// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +// C_CLOCK: two-digit year, month, day, weekday with Monday as 0, hour, minute, second, automatic DST flag void Ecocomfort2Hub::sync_clock_() { if (!this->is_ready() || this->char_handle_clock_ == 0) return; @@ -276,15 +282,11 @@ void Ecocomfort2Hub::sync_clock_() { return; // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 const uint8_t weekday = (now.day_of_week + 5) % 7; - uint8_t data[8]{now.hour, - now.minute, - now.second, - weekday, - now.day_of_month, - now.month, - static_cast(now.year >> 8), - static_cast(now.year)}; - ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + // The time source already applies DST and every sync rewrites the clock, so the unit must not shift it again + uint8_t data[8]{ + static_cast(now.year % 100), now.month, now.day_of_month, weekday, now.hour, now.minute, now.second, 0}; + ESP_LOGD(TAG, "[%s] Syncing clock to %04u-%02u-%02u %02u:%02u:%02u", this->parent_->address_str(), now.year, + now.month, now.day_of_month, now.hour, now.minute, now.second); auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index ed046fe0495..2f65914ddec 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -32,7 +32,8 @@ static constexpr uint8_t OPER_OUT = 0x02; static constexpr uint8_t OPER_IN_OUT = 0x03; static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; -// C_OPER byte 1: speed in the low nibble, flags in the high nibble +// Speed byte: speed in the low nibble, flags in the high nibble. +// C_OPER carries FLAG_AUTO; the unit reports FLAG_BOOST in byte 1 of C_STATE. static constexpr uint8_t SPEED_OFF = 0x00; static constexpr uint8_t SPEED_SLEEP = 0x01; static constexpr uint8_t SPEED_VEL1 = 0x02; @@ -41,7 +42,6 @@ static constexpr uint8_t SPEED_VEL3 = 0x04; static constexpr uint8_t SPEED_MASK = 0x0F; static constexpr uint8_t FLAG_AUTO = 0x10; static constexpr uint8_t FLAG_BOOST = 0x40; -static constexpr uint8_t FLAG_NIGHT = 0x80; /** Hub component for Ecocomfort 2.0 VMC via BLE. */ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index d6c46b5509e..8d2351f1fad 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) +LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" CONF_LOCK_ON_REBOOT = "lock_on_reboot" +LOCK_ON_REBOOT_AUTO = "auto" +LOCK_ON_REBOOT_MODES = { + LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO, + True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS, + False: LockOnReboot.LOCK_ON_REBOOT_NEVER, +} DOMAIN = "emontx" @@ -59,7 +66,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(EmonTx), cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), - cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, + cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any( + cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True) + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - if config[CONF_LOCK_ON_REBOOT]: - cg.add(var.set_lock_on_reboot(True)) + lock = config[CONF_LOCK_ON_REBOOT] + cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock])) + if lock == LOCK_ON_REBOOT_AUTO: + # Locking depends on the firmware version the emonTx reports + cg.add_define("USE_EMONTX_VERSION") # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 9c8436c0070..dca5ae2131d 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -7,30 +7,85 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +static constexpr uint32_t UNLOCK_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so the next one waits +static constexpr uint32_t COMMAND_GAP_MS = 250; + #ifdef USE_EMONTX_VERSION -static constexpr uint32_t VERSION_REQUEST_ID = 0; -// The emonTx joins bytes received while a command is pending into that command, so 'v' waits -static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +static constexpr uint32_t VERSION_REQUEST_ID = 1; +// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all +static constexpr uint8_t VERSION_RETRY_FRAMES = 2; +static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3; // Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +// Every data line starts with the message counter, in JSON or text format +static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":"; +static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:"; +// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command +static constexpr uint32_t LOCK_MIN_MAJOR = 1; +static constexpr uint32_t LOCK_MIN_MINOR = 2; + +// True if line starts with the PROGMEM prefix and has more after it +static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) { + return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0; +} + +// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none +static bool read_number(const char *&pos, const char *end, uint32_t &value) { + const char *start = pos; + value = 0; + while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9') + value = value * 10 + (*pos++ - '0'); + return pos != start; +} + +// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on +static bool supports_lock(const char *version, const char *end) { + if (version < end && (*version == 'v' || *version == 'V')) + version++; + uint32_t major; + if (!read_number(version, end, major) || version == end || *version != '.') + return false; + version++; + uint32_t minor; + if (!read_number(version, end, minor)) + return false; + return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR); +} #endif void EmonTx::setup() { this->buffer_pos_ = 0; - if (this->lock_on_reboot_) { - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + // The line feed ends any boot noise this->write_byte('\n'); - this->send_command("emonunlock"); + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + this->unlock_(); +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) + this->request_version_(); +#endif + }); + return; } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + // With AUTO, the reply decides whether to unlock + if (this->wants_version_()) this->request_version_(); #endif } +void EmonTx::unlock_() { + ESP_LOGI(TAG, "Unlocking emonTx"); + this->send_command("emonunlock"); +} + void EmonTx::on_shutdown() { - if (!this->lock_on_reboot_) + bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS; +#ifdef USE_EMONTX_VERSION + lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_; +#endif + if (!lock) return; if (this->is_paused()) { ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); @@ -63,7 +118,7 @@ void EmonTx::loop() { this->discarding_line_ = dropped != '\n'; #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) this->request_version_(); #endif } @@ -96,8 +151,10 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) { + this->parse_msg_number_(this->buffer_.data(), len); this->parse_version_line_(this->buffer_.data(), len); + } #endif } } else if (received == '\r' || this->discarding_line_) { @@ -124,16 +181,6 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif -#ifdef USE_EMONTX_VERSION - auto msg = root["MSG"]; - if (this->version_text_sensor_ != nullptr && msg.is()) { - // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update - uint32_t msg_number = msg; - if (msg_number <= this->last_msg_number_) - this->request_version_(); - this->last_msg_number_ = msg_number; - } -#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -145,17 +192,50 @@ void EmonTx::parse_json_(const char *data, size_t len) { } #ifdef USE_EMONTX_VERSION -// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() void EmonTx::request_version_() { + // The firmware may have changed; until the reply, an unknown version gets no lock commands + this->lock_supported_ = false; + this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS; + this->send_version_request_(); +} + +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::send_version_request_() { if (this->is_paused()) return; this->write_byte('\n'); - this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); + this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_msg_number_(const char *line, size_t len) { + const char *pos; + if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) { + pos = line + sizeof(MSG_JSON_PREFIX) - 1; + } else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) { + pos = line + sizeof(MSG_TEXT_PREFIX) - 1; + } else { + return; + } + uint32_t msg_number; + if (!read_number(pos, line + len, msg_number)) + return; + + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + if (msg_number <= this->last_msg_number_) { + this->request_version_(); + } else if (this->version_frames_left_ > 0) { + if (--this->version_frames_left_ == 0) { + ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked"); + } else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) { + this->send_version_request_(); + } + } + this->last_msg_number_ = msg_number; } void EmonTx::parse_version_line_(const char *line, size_t len) { constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; - if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + if (!starts_with(line, len, VERSION_PREFIX, prefix_len)) return; const char *version = line + prefix_len; const char *end = line + len; @@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { if (version == end) return; ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); - this->version_text_sensor_->publish_state(version, end - version); + this->version_frames_left_ = 0; +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + this->version_text_sensor_->publish_state(version, end - version); +#endif + + bool supported = supports_lock(version, end); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) { + // Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + if (this->is_paused()) + return; + this->unlock_(); + this->unlock_sent_ = true; + }); + } + this->lock_supported_ = supported; } #endif @@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); - ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); + const char *lock = LOG_STR_LITERAL("no"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + lock = LOG_STR_LITERAL("yes"); + } else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) { + lock = LOG_STR_LITERAL("auto"); + } + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -183,7 +285,7 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); #endif } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 8f6a2424c94..a8d91d36eb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,7 +13,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -22,6 +22,12 @@ namespace esphome::emontx { /// Maximum line length in bytes (plus one byte reserved for null terminator) static constexpr size_t MAX_LINE_LENGTH = 1024; +enum class LockOnReboot : uint8_t { + LOCK_ON_REBOOT_NEVER, + LOCK_ON_REBOOT_ALWAYS, + LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later +}; + /** * @class EmonTx * @brief Main class for the EmonTx component. @@ -30,7 +36,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR SUB_TEXT_SENSOR(version) #endif public: @@ -56,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice { bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken - /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. - void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } + /// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks. + void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); + void unlock_(); #ifdef USE_EMONTX_VERSION + bool wants_version_() const { +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + return true; +#endif + return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO; + } void request_version_(); + void send_version_request_(); + void parse_msg_number_(const char *line, size_t len); void parse_version_line_(const char *line, size_t len); #endif @@ -79,9 +95,12 @@ class EmonTx final : public Component, public uart::UARTDevice { uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; - bool lock_on_reboot_{false}; + LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION + bool lock_supported_{false}; // The last version reported is v1.2.0 or later + bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot + uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif std::array buffer_{}; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py index 9b3065f0f86..a3e381b9b5d 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..d04ffd33be8 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -40,7 +40,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from . import models @@ -198,16 +197,13 @@ def _final_validate(config) -> None: "epaper_spi", require_miso=False, require_mosi=True )(config) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: - # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = update_interval("1min") elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index c1c4de56287..c34e099522b 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -465,7 +465,7 @@ void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, cons ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { size_t contiguous_length; uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); - if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || + if (!this->buffer_is_mono_() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || contiguous_length != this->buffer_length_) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 92adbb7c711..969ec87f5d6 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -38,15 +38,18 @@ class EPaperBase : public Display, spi::DATA_RATE_2MHZ> { public: EPaperBase(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence = nullptr, - size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY) + size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY, + uint8_t bits_per_pixel = 1, uint8_t planes = 1) : name_(name), width_(width), + row_width_((width * bits_per_pixel + 7) / 8), height_(height), + bits_per_pixel_(bits_per_pixel), + planes_(planes), init_sequence_(init_sequence), init_sequence_length_(init_sequence_length), - display_type_(display_type) { - this->row_width_ = (this->width_ + 7) / 8; // width of a row in bytes - } + display_type_(display_type), + buffer_length_((size_t) this->row_width_ * height * planes) {} void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } @@ -99,6 +102,7 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; } void draw_pixel_at(int x, int y, Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; @@ -138,8 +142,8 @@ class EPaperBase : public Display, bool rotate_coordinates_(int &x, int &y); void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad); - // Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly - virtual bool buffer_is_mono() const { return false; } + // Whether the buffer is a single plane of 1 bit per pixel, as draw_pixel_at() writes it + bool buffer_is_mono_() const { return this->bits_per_pixel_ == 1 && this->planes_ == 1; } /** * Methods that must be implemented by concrete classes to control the display @@ -177,13 +181,15 @@ class EPaperBase : public Display, // properties initialised in the constructor const char *name_; uint16_t width_; - uint16_t row_width_; // width of a row in bytes + uint16_t row_width_; // width of a row of one plane in bytes uint16_t height_; + uint8_t bits_per_pixel_; + uint8_t planes_; // the buffer holds this many planes of row_width_ * height_ bytes, one after another const uint8_t *init_sequence_; size_t init_sequence_length_; DisplayType display_type_; - size_t buffer_length_{}; + size_t buffer_length_; size_t current_data_index_{}; // used by data transfer to track progress split_buffer::SplitBuffer buffer_{}; GPIOPin *dc_pin_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h index 1518a3e0d13..9f056e001b2 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.h +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -15,9 +15,7 @@ class EPaper4bpp : public EPaperBase { public: EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = width * height / 2; // 2 pixels per byte - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} protected: void fill_buffer(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h index 999f12a8236..c8b3edb0b66 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.h +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -9,10 +9,7 @@ class EPaperInkplate2 final : public EPaperBase { public: EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. - this->buffer_length_ = this->row_width_ * this->height_ * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 1, 2) {} void draw_pixel_at(int x, int y, Color color) override; diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.h b/esphome/components/epaper_spi/epaper_spi_jd79660.h index 511e9d3291f..a0407ad7cdd 100644 --- a/esphome/components/epaper_spi/epaper_spi_jd79660.h +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.h @@ -27,12 +27,9 @@ class EPaperJD79660 final : public EPaperBase { public: EPaperJD79660(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *fast_update, uint16_t fast_update_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR), + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 2), fast_update_(fast_update), - fast_update_length_(fast_update_length) { - this->row_width_ = (width + 3) / 4; // Fix base class calc (2bpp instead of 1bpp) - this->buffer_length_ = this->row_width_ * height; - } + fast_update_length_(fast_update_length) {} protected: void fill_buffer(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 0f570149de4..4dc54ae60ba 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -9,13 +9,10 @@ namespace esphome::epaper_spi { class EPaperMono : public EPaperBase { public: EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) { - this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up - } + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {} protected: - bool buffer_is_mono() const override { return true; } void refresh_screen(bool partial) override; void power_on() override {} void power_off() override{}; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index 293ed1b69f9..ac8a36ce4f0 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -55,9 +55,9 @@ bool HOT EPaperSSD1677::transfer_data() { // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after // boot): send the new frame to both planes. const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; - // A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper - // buffer (the four-level model) returns false and packs the plane in plane_row(). - const bool direct = send_copy || this->buffer_is_mono(); + // A 1 bit per pixel buffer already holds the frame as a RAM plane does, so it can be sent as it is; a + // deeper buffer (the four-level model, 2 bits per pixel) packs the plane in plane_row() instead. + const bool direct = send_copy || this->buffer_is_mono_(); const auto &source = send_copy ? this->sent_ : this->buffer_; const size_t row_length = this->plane_row_length_(); const size_t plane_length = row_length * this->height_; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index b8c93a1ecb8..d8481b712c7 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -23,8 +23,8 @@ namespace esphome::epaper_spi { class EPaperSSD1677 : public EPaperMono { public: EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) - : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {} + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1) + : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {} void setup() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index d8775a581cb..b475214bc3d 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -29,16 +29,12 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { public: EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) { - this->row_width_ = (width + 3) / 4; // 4 pixels per byte - this->buffer_length_ = (size_t) this->row_width_ * height; - } + : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE, 2) {} void draw_pixel_at(int x, int y, Color color) override; protected: void fill_buffer(Color color) override; - bool buffer_is_mono() const override { return false; } // A partial update if partial updates are enabled and this is not a full one. bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } void plane_row(size_t y, uint8_t *out) override; diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 593f8b1304b..8b78602ea9f 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -22,9 +22,7 @@ class EPaperT133A01 : public EPaperBase { public: EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { this->cs_pin_ = cs; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index b12ed54ef6d..fda4297802b 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -32,12 +32,9 @@ class EPaperUC8179 final : public EPaperBase { public: EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: - bool buffer_is_mono() const override { return true; } bool initialise(bool partial) override; bool transfer_data() override; void refresh_screen(bool partial) override; diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index e59ff49751e..d97275da782 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -22,9 +22,7 @@ class EPaperWaveshareBWR : public EPaperBase { public: EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} protected: void fill_buffer(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index 0b4ae253c6a..70c50ca165a 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -18,9 +18,7 @@ class EPaperWeAct3C : public EPaperBase { public: EPaperWeAct3C(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} protected: void fill_buffer(Color color) override; diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a70d3fe522e..783dc8953a5 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -87,6 +87,38 @@ jd79660.extend( ), ) +# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later) +# +# Vendor init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed +# 124 on this revision. width=124 is declared here (rather than 122) purely so the +# transferred bytes-per-row matches what the vendor init tells the IC to expect; the +# extra 2 columns fall outside the visible glass. The model width must equal the +# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back. +# +# fmt: off +jd79660.extend( + "Waveshare-2.13in-G", + width=124, + height=250, + + initsequence=( + (0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value) + (0xE9, 0x01,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5A,), + (0xA5,), + ), +) + # Waveshare 7.5-H # # Vendor init derived from vendor sample code @@ -130,7 +162,7 @@ jd79660.extend( # Note: busy pin uses LOW=busy, HIGH=idle. # # fmt: off -jd79660.extend( +gdey0213f51 = jd79660.extend( "goodisplay-gdey0213f51-2.13", width=128, height=250, @@ -154,3 +186,13 @@ jd79660.extend( (0x04,), ), ) + +# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good +# Display entry above, byte for byte, so it only adds the name. Vendor sample: +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding +# dimensions: in YAML shears the image. Not tested on V1 hardware. +gdey0213f51.extend("Waveshare-2.13in-G-V1") diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 688e7164566..67a0cf5f233 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -24,3 +24,11 @@ WaveshareB( data_rate="10MHz", minimum_update_interval="1s", ) + +WaveshareB( + "waveshare-2.66in-b", + width=152, + height=296, + data_rate="10MHz", + minimum_update_interval="15s", +) diff --git a/esphome/components/es9038q2m_katana/__init__.py b/esphome/components/es9038q2m_katana/__init__.py new file mode 100644 index 00000000000..5950268ad04 --- /dev/null +++ b/esphome/components/es9038q2m_katana/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@GioMez"] +DEPENDENCIES = ["i2c"] +DOMAIN = "es9038q2m_katana" diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py new file mode 100644 index 00000000000..f5c14d234c6 --- /dev/null +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -0,0 +1,92 @@ +import esphome.codegen as cg +from esphome.components import audio_dac, i2c +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +CONF_DEEMPHASIS = "deemphasis" +CONF_DOP = "dop" +CONF_FILTER_SHAPE = "filter_shape" + +# Fields of the Katana format register: channel layout, word length and rate code. +FORMAT_STEREO = 0x80 +BITS_PER_SAMPLE_CODES = {16: 0x10, 24: 0x20, 32: 0x30} +SAMPLE_RATE_CODES = { + 44100: 0x04, + 48000: 0x05, + 88200: 0x06, + 96000: 0x07, + 176400: 0x08, + 192000: 0x09, + 352800: 0x0A, + 384000: 0x0B, +} + +es9038q2m_katana_ns = cg.esphome_ns.namespace("es9038q2m_katana") +ES9038Q2MKatana = es9038q2m_katana_ns.class_( + "ES9038Q2MKatana", + audio_dac.AudioDac, + cg.Component, + i2c.I2CDevice, +) + +FilterShape = es9038q2m_katana_ns.enum("FilterShape") +FILTER_SHAPES = { + "linear_phase_fast": FilterShape.FILTER_SHAPE_LINEAR_PHASE_FAST, + "linear_phase_slow": FilterShape.FILTER_SHAPE_LINEAR_PHASE_SLOW, + "minimum_phase_fast": FilterShape.FILTER_SHAPE_MIN_PHASE_FAST, + "minimum_phase_slow": FilterShape.FILTER_SHAPE_MIN_PHASE_SLOW, + "apodizing": FilterShape.FILTER_SHAPE_APODIZING, + "hybrid": FilterShape.FILTER_SHAPE_HYBRID, + "brick_wall": FilterShape.FILTER_SHAPE_BRICK_WALL, +} + +DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") +DEEMPHASIS_MODES = { + "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, + "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, + "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, + "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ES9038Q2MKatana), + cv.Optional(CONF_FILTER_SHAPE, default="apodizing"): cv.enum( + FILTER_SHAPES, lower=True + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), + cv.one_of(*BITS_PER_SAMPLE_CODES, int=True), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.one_of( + *SAMPLE_RATE_CODES, int=True + ), + cv.Optional(CONF_DEEMPHASIS, default="bypass"): cv.enum( + DEEMPHASIS_MODES, lower=True + ), + # Only switches the control logic; the source must already send DoP frames. + cv.Optional(CONF_DOP, default=False): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) + cg.add( + var.set_format( + FORMAT_STEREO + | BITS_PER_SAMPLE_CODES[config[CONF_BITS_PER_SAMPLE]] + | SAMPLE_RATE_CODES[config[CONF_SAMPLE_RATE]] + ) + ) + cg.add(var.set_deemphasis_mode(config[CONF_DEEMPHASIS])) + cg.add(var.set_dop_enabled(config[CONF_DOP])) diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp new file mode 100644 index 00000000000..464f971acef --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -0,0 +1,176 @@ +#include "es9038q2m_katana.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::es9038q2m_katana { + +ESPHOME_LOG_TAG(TAG, "es9038q2m_katana"); + +namespace { + +constexpr uint8_t KATANA_CHIP_ID = 0x30; + +constexpr uint8_t KATANA_REG_CHIP_ID = 0x00; +constexpr uint8_t KATANA_REG_RESET = 0x01; +constexpr uint8_t KATANA_REG_VOLUME_1 = 0x02; +constexpr uint8_t KATANA_REG_VOLUME_2 = 0x03; +constexpr uint8_t KATANA_REG_MUTE = 0x04; +constexpr uint8_t KATANA_REG_DSP_PROGRAM = 0x05; +constexpr uint8_t KATANA_REG_DEEMPHASIS = 0x06; +constexpr uint8_t KATANA_REG_DOP = 0x07; +constexpr uint8_t KATANA_REG_FORMAT = 0x08; +constexpr uint8_t KATANA_REG_COMMAND = 0x09; +constexpr uint8_t KATANA_REG_MUTE_STREAM = 0x0A; + +constexpr float KATANA_VOLUME_MIDPOINT = 0.5f; +constexpr float KATANA_VOLUME_MIDPOINT_STEPS = 50.0f; // -25 dB at 50% volume. +// Cubic amplitude gain: -20 * log10(volume^3) / 0.5 attenuation steps. +constexpr float KATANA_VOLUME_CUBIC_SCALE = 120.0f; +constexpr uint8_t KATANA_VOLUME_FULL_ATTENUATION = 255; + +// The control logic ignores writes for this long after a reset. +constexpr uint32_t RESET_SETTLE_DELAY_MS = 10; + +const LogString *filter_shape_to_str(FilterShape shape) { + switch (shape) { + case FILTER_SHAPE_LINEAR_PHASE_FAST: + return LOG_STR("linear_phase_fast"); + case FILTER_SHAPE_LINEAR_PHASE_SLOW: + return LOG_STR("linear_phase_slow"); + case FILTER_SHAPE_MIN_PHASE_FAST: + return LOG_STR("minimum_phase_fast"); + case FILTER_SHAPE_MIN_PHASE_SLOW: + return LOG_STR("minimum_phase_slow"); + case FILTER_SHAPE_APODIZING: + return LOG_STR("apodizing"); + case FILTER_SHAPE_HYBRID: + return LOG_STR("hybrid"); + default: + return LOG_STR("brick_wall"); + } +} + +const LogString *deemphasis_to_str(DeemphasisMode mode) { + switch (mode) { + case DEEMPHASIS_32KHZ: + return LOG_STR("32khz"); + case DEEMPHASIS_44_1KHZ: + return LOG_STR("44_1khz"); + case DEEMPHASIS_48KHZ: + return LOG_STR("48khz"); + default: + return LOG_STR("bypass"); + } +} + +} // namespace + +void ES9038Q2MKatana::setup() { + uint8_t chip_id = 0; + if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (chip_id != KATANA_CHIP_ID) { + ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); + this->mark_failed(LOG_STR("Unexpected chip ID")); + return; + } + if (!this->write_byte(KATANA_REG_RESET, 0x01)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + delay(RESET_SETTLE_DELAY_MS); + + // MUTE_STREAM is the gate the Linux driver closes between streams; ESPHome has no such + // notion, so it stays open and mute is only ever driven through MUTE. + if (!this->write_byte(KATANA_REG_FORMAT, this->format_) || !this->write_byte(KATANA_REG_COMMAND, 0x00) || + !this->write_byte(KATANA_REG_DSP_PROGRAM, this->filter_shape_) || + !this->write_byte(KATANA_REG_DEEMPHASIS, this->deemphasis_mode_) || + !this->write_byte(KATANA_REG_DOP, this->dop_enabled_ ? 1 : 0) || !this->write_volume_() || + !this->write_byte(KATANA_REG_MUTE, this->is_muted_ ? 1 : 0) || !this->write_byte(KATANA_REG_MUTE_STREAM, 0x00)) { + this->mark_failed(LOG_STR("Failed to configure the control logic")); + } +} + +void ES9038Q2MKatana::dump_config() { + ESP_LOGCONFIG(TAG, + "ES9038Q2M Katana:\n" + " Filter shape: %s\n" + " De-emphasis: %s\n" + " Format register: 0x%02X\n" + " DoP: %s", + LOG_STR_ARG(filter_shape_to_str(this->filter_shape_)), + LOG_STR_ARG(deemphasis_to_str(this->deemphasis_mode_)), this->format_, ONOFF(this->dop_enabled_)); + LOG_I2C_DEVICE(this); + if (this->is_failed()) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + for (uint8_t reg = KATANA_REG_CHIP_ID; reg <= KATANA_REG_MUTE_STREAM; reg++) { + uint8_t value = 0; + if (this->read_byte(reg, &value)) { + ESP_LOGV(TAG, " Register 0x%02X: 0x%02X", reg, value); + } else { + ESP_LOGV(TAG, " Register 0x%02X: read failed", reg); + } + } +#endif +} + +// Both channel registers follow ESPHome's single master volume. +bool ES9038Q2MKatana::write_volume_() { + return this->write_byte(KATANA_REG_VOLUME_1, this->volume_reg_) && + this->write_byte(KATANA_REG_VOLUME_2, this->volume_reg_); +} + +// Records the outcome of a runtime write; the cached state only changes on success. +bool ES9038Q2MKatana::check_write_(bool ok) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } + return ok; +} + +bool ES9038Q2MKatana::set_volume(float volume) { + if (this->is_failed()) + return false; + volume = clamp(volume, 0.0f, 1.0f); + const uint8_t previous = this->volume_reg_; + float attenuation = KATANA_VOLUME_FULL_ATTENUATION; + if (volume >= KATANA_VOLUME_MIDPOINT) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS * (1.0f - volume) / (1.0f - KATANA_VOLUME_MIDPOINT); + } else if (volume > 0.0f) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS - KATANA_VOLUME_CUBIC_SCALE * log10f(volume / KATANA_VOLUME_MIDPOINT); + } + this->volume_reg_ = + static_cast(lroundf(clamp(attenuation, 0.0f, static_cast(KATANA_VOLUME_FULL_ATTENUATION)))); + if (this->check_write_(this->write_volume_())) + return true; + this->volume_reg_ = previous; + return false; +} + +float ES9038Q2MKatana::volume() { + if (this->volume_reg_ == KATANA_VOLUME_FULL_ATTENUATION) + return 0.0f; + if (this->volume_reg_ <= KATANA_VOLUME_MIDPOINT_STEPS) + return 1.0f - this->volume_reg_ * (1.0f - KATANA_VOLUME_MIDPOINT) / KATANA_VOLUME_MIDPOINT_STEPS; + return KATANA_VOLUME_MIDPOINT * + powf(10.0f, (KATANA_VOLUME_MIDPOINT_STEPS - this->volume_reg_) / KATANA_VOLUME_CUBIC_SCALE); +} + +bool ES9038Q2MKatana::set_mute_state_(bool mute_state) { + if (this->is_failed()) + return false; + if (!this->check_write_(this->write_byte(KATANA_REG_MUTE, mute_state ? 1 : 0))) + return false; + this->is_muted_ = mute_state; + return true; +} + +} // namespace esphome::es9038q2m_katana diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.h b/esphome/components/es9038q2m_katana/es9038q2m_katana.h new file mode 100644 index 00000000000..1d900f9bab4 --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::es9038q2m_katana { + +// DSP program numbers of the Katana control interface; there is no program 5. +enum FilterShape : uint8_t { + FILTER_SHAPE_LINEAR_PHASE_FAST = 0, + FILTER_SHAPE_LINEAR_PHASE_SLOW = 1, + FILTER_SHAPE_MIN_PHASE_FAST = 2, + FILTER_SHAPE_MIN_PHASE_SLOW = 3, + FILTER_SHAPE_APODIZING = 4, + FILTER_SHAPE_HYBRID = 6, + FILTER_SHAPE_BRICK_WALL = 7, +}; + +enum DeemphasisMode : uint8_t { + DEEMPHASIS_BYPASS = 0, + DEEMPHASIS_32KHZ = 1, + DEEMPHASIS_44_1KHZ = 2, + DEEMPHASIS_48KHZ = 3, +}; + +// Boards that expose an ES9038Q2M through the Allo Katana control logic at I2C address 0x30. +// Ported from the Linux driver sound/soc/bcm/allo-katana-codec.c; the DAC's own registers +// are never written, the control logic applies the configuration itself. +class ES9038Q2MKatana : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Cubic below 50%, then 0.5 dB per 1% above, meeting at -25 dB. + // Zero selects full attenuation (-127.5 dB). + bool set_volume(float volume) override; + float volume() override; + bool set_mute_off() override { return this->set_mute_state_(false); } + bool set_mute_on() override { return this->set_mute_state_(true); } + bool is_muted() override { return this->is_muted_; } + + void set_filter_shape(FilterShape shape) { this->filter_shape_ = shape; } + void set_format(uint8_t format) { this->format_ = format; } + void set_deemphasis_mode(DeemphasisMode mode) { this->deemphasis_mode_ = mode; } + void set_dop_enabled(bool enabled) { this->dop_enabled_ = enabled; } + + protected: + bool set_mute_state_(bool mute_state); + bool write_volume_(); + bool check_write_(bool ok); + + FilterShape filter_shape_{FILTER_SHAPE_APODIZING}; + DeemphasisMode deemphasis_mode_{DEEMPHASIS_BYPASS}; + uint8_t format_{0}; + // Attenuation in 0.5 dB steps: 0 is loudest, 255 quietest. Starts 20 dB down. + uint8_t volume_reg_{0x28}; + bool dop_enabled_{false}; +}; + +} // namespace esphome::es9038q2m_katana diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 422413a269f..aae1a4c41f6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -243,7 +243,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component - "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, hdmi_cec, opentherm, Arduino BLE libs "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself @@ -2920,6 +2920,7 @@ async def _finalize_arduino_aware_flags(): if not arduino_linked: cg.add_build_flag("-Wl,--wrap=esp_panic_handler") cg.add_define("USE_ESP32_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") elif not CORE.using_arduino: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 182bc5f2d6c..bc28dde8d63 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c namespace esphome::esp32 { -ESPHOME_LOG_TAG(TAG, "esp32.crash"); +ESPHOME_LOG_TAG(TAG, CRASH_LOG_TAG); // RAM copy of the build timestamp. The generated constant lives in flash, // which the panic handler must not read (cache may be disabled during @@ -175,7 +175,8 @@ static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME) // Validate the NOINIT record. Runs on every has_data() call; re-running is // harmless and the magic is left alone so the record survives an OTA -// rollback reboot, crash_handler_clear() drops it once an API client has it. +// rollback reboot, crash_handler_clear() drops it once a log client has it +// (logger::log_crash_to_client(), for the API and MQTT). static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 314be80314c..e145a4a6032 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,6 +4,9 @@ namespace esphome::esp32 { +/// The tag the crash record is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "esp32.crash"; + /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 02978ae0f7a..87cb3345b08 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -131,6 +131,7 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { void ESP32Preferences::open() { // Runs from app_main() before the logger is initialized; any logging here // must be deferred. See s_open_err and make_preference() below. + // Components that need NVS check nvs_handle instead of initializing it again. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 11290a95f41..1379584120b 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #ifndef CONFIG_BT_CONTROLLER_DISABLED #include @@ -28,7 +29,6 @@ extern "C" { #include #include #include -#include #ifdef USE_ARDUINO // Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c). @@ -77,8 +77,9 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; void ESP32BLE::setup() { global_ble = this; - if (!ble_pre_setup_()) { - ESP_LOGE(TAG, "BLE could not be prepared for configuration"); + // Preferences opens NVS in app_main(); a zero handle means it could not be opened, even after an erase + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); this->mark_failed(); return; } @@ -185,15 +186,6 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { } #endif -bool ESP32BLE::ble_pre_setup_() { - esp_err_t err = nvs_flash_init(); - if (err != ESP_OK) { - ESP_LOGE(TAG, "nvs_flash_init failed: %d", err); - return false; - } - return true; -} - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 950ae6e0bc1..ef326e1532d 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -185,7 +185,6 @@ class ESP32BLE final : public Component { } this->ble_events_.get_and_reset_dropped_count(); } - bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); #endif diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 7481d2c612c..430393f2d81 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include "esphome/core/hal.h" diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index dde47b0c5e4..6905ef82228 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) { esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 650a765b6a5..0dec0a74c5d 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #ifndef CONFIG_BT_CONTROLLER_DISABLED diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index a0592d0a809..51a6ce72cde 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -48,6 +48,7 @@ class BLEService final { bool is_created() { return this->state_ == CREATED; } bool is_running() { return this->state_ == RUNNING; } bool is_starting() { return this->state_ == STARTING; } + bool is_stopped() { return this->state_ == STOPPED; } bool is_deleted() { return this->state_ == DELETED; } protected: diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8d3148c2fc7..cd1c16e8f5d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #ifdef USE_OTA diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2713bae241a..f8f76b2d578 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -6,6 +6,8 @@ import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( + CONF_MINIMUM_CHIP_REVISION, + KEY_ESP32, VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -20,6 +22,8 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ADVANCED, + CONF_FRAMEWORK, CONF_ID, CONF_MODE, CONF_RX_PIN, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] @@ -42,6 +47,7 @@ CanMode = esp32_can_ns.enum("CanMode") CAN_MODES = { "NORMAL": CanMode.CAN_MODE_NORMAL, "LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY, + "NO_ACK": CanMode.CAN_MODE_NO_ACK, } # Currently the driver only supports a subset of the bit rates defined in canbus @@ -49,6 +55,7 @@ CAN_MODES = { # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) CAN_SPEEDS_ESP32 = { + "20KBPS": CanSpeed.CAN_20KBPS, "25KBPS": CanSpeed.CAN_25KBPS, "50KBPS": CanSpeed.CAN_50KBPS, "100KBPS": CanSpeed.CAN_100KBPS, @@ -114,6 +121,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) +def _final_validate(config: ConfigType) -> None: + if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS": + esp32_config = fv.full_config.get()[KEY_ESP32] + min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get( + CONF_MINIMUM_CHIP_REVISION + ) + if min_revision is None or min_revision < "2.0": + raise cv.Invalid( + "20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher", + path=[CONF_BIT_RATE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index b98ba853e78..ee677a08005 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config case canbus::CAN_16KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS(); return true; +#endif +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200) case canbus::CAN_20KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS(); return true; @@ -75,10 +79,13 @@ bool ESP32Can::setup_internal() { } // Select TWAI mode based on configuration - twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL; - + twai_mode_t twai_mode = TWAI_MODE_NORMAL; if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + twai_mode = TWAI_MODE_LISTEN_ONLY; ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)"); + } else if (this->mode_ == CAN_MODE_NO_ACK) { + twai_mode = TWAI_MODE_NO_ACK; + ESP_LOGI(TAG, "CAN bus configured in NO_ACK mode (transmit without requiring ACK)"); } twai_general_config_t g_config = diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f9549bea346..176a3b57f5e 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -12,6 +12,7 @@ namespace esphome::esp32_can { enum CanMode : uint8_t { CAN_MODE_NORMAL = 0, CAN_MODE_LISTEN_ONLY = 1, + CAN_MODE_NO_ACK = 2, }; class ESP32Can final : public canbus::Canbus { diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index 03b03fd983f..d226a83c1eb 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -42,6 +42,7 @@ extern "C" { } #include "esp_now_hosted_rpc.h" +#include "esphome/core/log.h" namespace { @@ -52,6 +53,8 @@ ESPHOME_LOG_TAG(TAG, "esp_now_hosted"); // single esp-hosted RPC RX thread. Serializing requests keeps the shared // response slot race-free; a sequence number stops a late/stale response from // being mistaken for ours. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables): the shim is +// C-ABI state shared between the main loop and the esp-hosted RX thread. SemaphoreHandle_t g_req_mutex = nullptr; SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands bool g_setup_done = false; // set only after setup fully succeeds @@ -85,6 +88,7 @@ constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; size_t g_peer_count = 0; portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) // Caller must hold g_peer_lock. int peer_cache_find_locked(const uint8_t *mac) { @@ -231,12 +235,14 @@ esp_err_t ensure_setup() { g_resp_sem = xSemaphoreCreateBinary(); if (g_req_mutex == nullptr || g_resp_sem == nullptr) return ESP_ERR_NO_MEM; - esp_err_t err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + esp_err_t err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr); + if (err != ESP_OK) return err; g_setup_done = true; return ESP_OK; @@ -422,10 +428,11 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; auto *s = reinterpret_cast(hdr); s->has_addr = peer_addr != nullptr ? 1 : 0; - if (peer_addr != nullptr) + if (peer_addr != nullptr) { memcpy(s->peer_addr, peer_addr, 6); - else + } else { memset(s->peer_addr, 0, 6); + } s->data_len = static_cast(len); // Fire-and-forget (wait=false): native esp_now_send returns once the frame is // queued, with the real TX result delivered later through the send callback. diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d95cbd239b5..c2a827ca35c 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,10 +53,15 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + PREFERENCES_RTC_EBOOT_WORDS, + PREFERENCES_RTC_NORMAL_WORDS, THROW_STUBS_HEADER, + check_preferences_fit, + check_timer1_users, enable_serial, enable_serial1, esp8266_ns, + preferences_flash_words, ) from .gpio import PinInitialState, add_pin_initial_states_array @@ -416,6 +421,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) cg.add_define("USE_ESP8266_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): @@ -467,6 +473,12 @@ async def to_code(config: ConfigType) -> None: # Other projects like Tasmota & ESPEasy also use this cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + cg.add_define("ESP8266_PREFERENCES_RTC_NORMAL_WORDS", PREFERENCES_RTC_NORMAL_WORDS) + cg.add_define("ESP8266_PREFERENCES_RTC_EBOOT_WORDS", PREFERENCES_RTC_EBOOT_WORDS) + cg.add_define( + "ESP8266_PREFERENCES_FLASH_WORDS", + preferences_flash_words(config[CONF_RESTORE_FROM_FLASH]), + ) if config[CONF_RESTORE_FROM_FLASH]: cg.add_define("USE_ESP8266_PREFERENCES_FLASH") @@ -528,6 +540,8 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) CORE.add_job(finalize_serial_config) + CORE.add_job(check_preferences_fit) + CORE.add_job(check_timer1_users) @coroutine_with_priority(CoroPriority.WORKAROUNDS) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index fb50b6a5ca2..673c1e91eac 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,9 +1,13 @@ +import logging + import esphome.codegen as cg # Re-exported from the shared definition; here it indexes the BOARDS # metadata dicts, whose entries in boards.py spell the literal. from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority + +_LOGGER = logging.getLogger(__name__) KEY_ESP8266 = "esp8266" KEY_BOARD = "board" @@ -13,6 +17,7 @@ CONF_EARLY_PIN_INIT = "early_pin_init" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_WAVEFORM_REQUIRED = "waveform_required" +KEY_TIMER1_USERS = "timer1_users" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator @@ -24,6 +29,87 @@ KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ KEY_DECODE_TOOLS = "decode_tools" +# Saved-settings space in 4-byte words; preferences.cpp gets these as defines. A value cannot span the two +# RTC regions, and the flash store is smaller when only explicit flash preferences use it. +PREFERENCES_RTC_NORMAL_WORDS = 78 +PREFERENCES_RTC_EBOOT_WORDS = 32 +PREFERENCES_RTC_WORDS = PREFERENCES_RTC_NORMAL_WORDS + PREFERENCES_RTC_EBOOT_WORDS +PREFERENCES_FLASH_WORDS = 128 +PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE = 64 +KEY_PREFERENCES = "preferences" + + +def preferences_flash_words(restore_from_flash: bool) -> int: + """Size of the flash store for this configuration.""" + return ( + PREFERENCES_FLASH_WORDS + if restore_from_flash + else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE + ) + + +def preference_words(value_bytes: int) -> int: + """Words a saved value takes, checksum word included (rtc_pref_bytes_to_words plus one).""" + return (value_bytes + 3) // 4 + 1 + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Record saved values a component creates; check_preferences_fit() adds them all up. + + in_flash is the value's own storage, as passed to make_preference(); None is the two-argument + form, which follows restore_from_flash. + """ + if in_flash is None: + in_flash = CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + reservations = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_PREFERENCES, []) + reservations.extend([(preference_words(value_bytes), in_flash)] * count) + + +def _rtc_values_can_split(values: list[int]) -> bool: + """Whether some order fills the two RTC regions without a value spanning them.""" + total = sum(values) + if total <= PREFERENCES_RTC_NORMAL_WORDS: + return True + # Bit i of reachable is set when some subset of the values sums to i + reachable = 1 + for words in values: + reachable |= reachable << words + lowest = max(0, total - PREFERENCES_RTC_EBOOT_WORDS) + highest = min(total, PREFERENCES_RTC_NORMAL_WORDS) + return any(reachable >> normal & 1 for normal in range(lowest, highest + 1)) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_preferences_fit() -> None: + """Fail the build when the reserved saved values cannot fit the space preferences.cpp has.""" + flash_store = preferences_flash_words( + CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + ) + reservations = CORE.data[KEY_ESP8266].get(KEY_PREFERENCES, []) + flash_words = sum(w for w, in_flash in reservations if in_flash) + rtc_values = [w for w, in_flash in reservations if not in_flash] + if flash_words > flash_store: + raise EsphomeError( + f"Saved values need {flash_words} words but the ESP8266 flash store has {flash_store}; " + "use fewer or smaller saved values" + ) + if not _rtc_values_can_split(rtc_values): + raise EsphomeError( + f"Saved values need {sum(rtc_values)} words but ESP8266 RTC memory has {PREFERENCES_RTC_WORDS} in " + f"regions of {PREFERENCES_RTC_NORMAL_WORDS} and {PREFERENCES_RTC_EBOOT_WORDS} that a value cannot " + "span; use fewer or smaller saved values, or store them in flash" + ) + if sum(rtc_values) > PREFERENCES_RTC_NORMAL_WORDS: + _LOGGER.warning( + "Saved values reserve %d of %d words of ESP8266 RTC memory; the fit depends on setup order, and " + "saved settings that do not reserve are not counted", + sum(rtc_values), + PREFERENCES_RTC_WORDS, + ) + + # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") @@ -44,6 +130,28 @@ def require_waveform() -> None: require_waveform() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_WAVEFORM_REQUIRED] = True + use_timer1("the Arduino waveform generator (esp8266_pwm, ac_dimmer)") + + +def use_timer1(owner: str) -> None: + """Record that a component drives hardware timer 1; check_timer1_users() rejects a second one. + + The ESP8266 has one general purpose timer with one interrupt. Two components that program it + take it from each other, so the configuration is refused instead. Components that go through + the Arduino waveform generator share it and register through require_waveform(). + """ + users = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_TIMER1_USERS, set()) + users.add(owner) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_timer1_users() -> None: + """Fail the build when more than one component drives hardware timer 1.""" + users = sorted(CORE.data[KEY_ESP8266].get(KEY_TIMER1_USERS, ())) + if len(users) > 1: + raise EsphomeError( + f"{' and '.join(users)} each need hardware timer 1 of the ESP8266, which only one of them can drive" + ) def enable_serial() -> None: diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 1ba96eaebb2..8513d213155 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP8266_CRASH_HANDLER #include "crash_handler.h" +#include "rtc_layout.h" #include "esphome/core/log.h" #include @@ -46,11 +47,8 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t val) { // Recover the actual code address from a windowed-ABI return address on the stack. static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; } -// RTC user memory layout for crash backtrace data. -// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes). -// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts. +// Crash data lives in the last esphome::esp8266::RTC_CRASH_WORDS of user RTC memory (rtc_layout.h). // Store 16 raw candidates, filter to real return addresses at log time. -static constexpr uint8_t RTC_CRASH_BASE = 174; static constexpr size_t MAX_BACKTRACE = 16; // Magic word packs sentinel, version, and count into one uint32_t: @@ -78,7 +76,7 @@ struct RtcCrashData { uint32_t backtrace[MAX_BACKTRACE]; uint32_t epc1; // Fault PC, used to filter duplicates }; -static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks"); +static_assert(sizeof(RtcCrashData) == esphome::esp8266::RTC_CRASH_WORDS * 4, "RtcCrashData must fill its RTC words"); namespace esphome::esp8266 { @@ -129,7 +127,7 @@ static const LogString *get_reset_reason(uint32_t reason) { // Returns the number of valid backtrace entries (0 if no data found). static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { RtcCrashData rtc_data; - if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) + if (!system_rtc_mem_read(esphome::esp8266::RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) return 0; uint32_t magic = rtc_data.magic; if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION) @@ -233,7 +231,7 @@ extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint3 data.epc1 = epc1; data.magic = CRASH_SENTINEL | CRASH_VERSION | count; - system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data)); + system_rtc_mem_write(esphome::esp8266::RTC_CRASH_BASE, &data, sizeof(data)); } #endif // USE_ESP8266_CRASH_HANDLER diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index b731187f9c3..3c1e89673ea 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -6,6 +6,7 @@ extern "C" { } #include "preferences.h" +#include "rtc_layout.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences_rtc.h" @@ -17,31 +18,26 @@ namespace esphome::esp8266 { ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; -static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = RTC_USER_MEM_WORDS; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; -// RTC memory layout: +// RTC memory layout (sizes come from const.py through codegen): // - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109) // - Normal region: RTC words 32-109 (mapped from preference offset 0-77) // - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data) -static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot -static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs +static constexpr uint32_t RTC_EBOOT_REGION_WORDS = ESP8266_PREFERENCES_RTC_EBOOT_WORDS; +static constexpr uint32_t RTC_NORMAL_REGION_WORDS = ESP8266_PREFERENCES_RTC_NORMAL_WORDS; static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110 +static_assert(PREF_TOTAL_WORDS <= RTC_CRASH_BASE - RTC_SYSTEM_USER_BASE, + "preference regions overlap the crash handler's RTC words"); // Maximum preference size in words (limited by uint8_t length_words field) static constexpr uint32_t MAX_PREFERENCE_WORDS = 255; #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -// Flash storage size depends on esp8266 -> restore_from_flash YAML option (default: false). -// When enabled (USE_ESP8266_PREFERENCES_FLASH), all preferences default to flash and need -// 128 words (512 bytes). When disabled, only explicit flash prefs use this storage so -// 64 words (256 bytes) suffices since most preferences go to RTC memory instead. -#ifdef USE_ESP8266_PREFERENCES_FLASH -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; -#else -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; -#endif +// Smaller without restore_from_flash, when only explicit flash preferences live here +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = ESP8266_PREFERENCES_FLASH_WORDS; static uint32_t s_flash_storage[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -60,7 +56,7 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; } - if (index < 32 && s_prevent_write) { + if (index < RTC_EBOOT_REGION_WORDS && s_prevent_write) { return false; } @@ -123,7 +119,7 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { // Maximum buffer for any single preference - bounded by storage sizes. // Flash prefs: bounded by ESP8266_FLASH_STORAGE_SIZE (128 or 64 words). -// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (96) - a single pref can't span both RTC regions. +// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (78) - a single pref can't span both RTC regions. static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; @@ -180,8 +176,8 @@ ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t } else { uint32_t start = this->current_offset; bool in_normal = start < RTC_NORMAL_REGION_WORDS; - // Normal: offset 0-95 maps to RTC offset 32-127 - // Eboot: offset 96-127 maps to RTC offset 0-31 + // Normal: offset 0-77 maps to RTC offset 32-109 + // Eboot: offset 78-109 maps to RTC offset 0-31 if (in_normal && start + total_words > RTC_NORMAL_REGION_WORDS) { // start is in normal but end is not -> switch to Eboot this->current_offset = start = RTC_NORMAL_REGION_WORDS; diff --git a/esphome/components/esp8266/rtc_layout.h b/esphome/components/esp8266/rtc_layout.h new file mode 100644 index 00000000000..0320d67a198 --- /dev/null +++ b/esphome/components/esp8266/rtc_layout.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include + +namespace esphome::esp8266 { + +// User RTC memory is system blocks 64-191, one 4-byte word each. preferences.cpp owns the first +// words, crash_handler.cpp the last; const.py hands preferences.cpp its region sizes. +static constexpr uint32_t RTC_SYSTEM_USER_BASE = 64; +static constexpr uint32_t RTC_USER_MEM_WORDS = 128; +static constexpr uint32_t RTC_CRASH_WORDS = 18; +static constexpr uint32_t RTC_CRASH_BASE = RTC_SYSTEM_USER_BASE + RTC_USER_MEM_WORDS - RTC_CRASH_WORDS; + +} // namespace esphome::esp8266 + +#endif // USE_ESP8266 diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e6b19c42f0c..df2aa4ee37a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -44,11 +44,14 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { } #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; -static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake +static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 60000; // milliseconds for initial handshake // Milliseconds for data transfer. Covers the lwIP retransmit run seen in // practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits // longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; +// A handshake this old gives way to a waiting connection: a client retrying over a lossy link otherwise queues +// behind its own stalled attempt until both time out +static constexpr uint32_t OTA_HANDSHAKE_PREEMPT = 10000; static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000; static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first @@ -199,6 +202,9 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10; +#ifdef ESPHOME_OTA_UDP +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_UDP = 0x20; +#endif // Noise needs the extended protocol: the prologue binds the 2-byte feature ack static constexpr uint8_t CLIENT_NOISE_FEATURES = CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; @@ -208,6 +214,10 @@ static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; // Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the // client must then send the image size frame and a deflate stream. static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08; +#ifdef ESPHOME_OTA_UDP +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_UDP = 0x10; +static constexpr uint8_t UDP_REQUEST_V1 = 0x01; // espota2.UDP_REQUEST +#endif #ifdef USE_OTA_ENCRYPTION inline bool ESPHomeOTAComponent::noise_offered_() const { @@ -266,6 +276,12 @@ void ESPHomeOTAComponent::handle_handshake_() { this->cleanup_connection_(); return; } + // Only unauthenticated handshakes reach here; the data phase blocks inside handle_data_ + if (now - this->client_connect_time_ > OTA_HANDSHAKE_PREEMPT && this->server_->ready()) { + ESP_LOGD(TAG, "Newer connection waiting"); + this->cleanup_connection_(); + return; + } switch (this->ota_state_) { case OTAState::MAGIC_READ: { @@ -329,6 +345,9 @@ void ESPHomeOTAComponent::handle_handshake_() { static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); +#ifdef ESPHOME_OTA_UDP + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_UDP; +#endif #ifdef USE_OTA_PARTITIONS this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; #endif @@ -483,6 +502,10 @@ void ESPHomeOTAComponent::handle_data_() { char *sbuf = reinterpret_cast(buf); size_t image_size; ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; +#ifdef ESPHOME_OTA_UDP + UdpLink udp_link; + this->udp_ = nullptr; // client_connect_time_ is done with +#endif // Set socket timeouts and blocking mode (see strategy table above) struct timeval tv; @@ -498,6 +521,38 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); +#ifdef ESPHOME_OTA_UDP + // The server always offers UDP on the extended protocol + if (this->extended_proto_() && (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_UDP) != 0) { + // Whether the client wants UDP (0: its link looks good), answered with our token + if (!this->data_readall_(buf, 1)) { + if (this->client_left_before_start_()) + return; + this->log_read_error_(LOG_STR("UDP request")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + // 1 is this UDP format; other values are free for later ones, which we decline (a zero reply keeps TCP) + if (buf[0] == UDP_REQUEST_V1) { + this->udp_open_(udp_link); + if (udp_link.reply[0] == 0) { + ESP_LOGW(TAG, "UDP unavailable, using TCP"); + } + } + if (!this->data_write_(udp_link.reply, sizeof(udp_link.reply))) + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + if (udp_link.reply[0] != 0) { + this->udp_ = &udp_link; + if (!this->udp_start_()) { + // Nothing started yet: just drop the session, which the client retries as a network failure + this->log_read_error_(LOG_STR("UDP start")); + this->cleanup_connection_(); + return; + } + } + } + +#endif + if (this->extended_proto_()) { // Read ota type, 1 byte if (!this->data_readall_(buf, 1)) { @@ -598,11 +653,17 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge Update end OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); - // Read ACK - if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - this->log_read_error_(LOG_STR("ack")); - // do not go to error, this is not fatal - } +#ifdef ESPHOME_OTA_UDP + // UDP never reports a close: wait only until the client has our responses, not for its final ack + if (this->udp_ != nullptr) { + this->udp_linger_(); + } else +#endif + // Read ACK + if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { + this->log_read_error_(LOG_STR("ack")); + // do not go to error, this is not fatal + } this->cleanup_connection_(); delay(10); @@ -623,6 +684,9 @@ void ESPHomeOTAComponent::handle_data_() { error: this->data_write_byte_(static_cast(error_code)); +#ifdef ESPHOME_OTA_UDP + this->udp_linger_(); +#endif // Abort backend before cleanup - cleanup_connection_() destroys the backend. // Always call abort() unconditionally: backends register external partitions before @@ -651,7 +715,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return false; } - ssize_t read = this->client_->read(buf + at, len - at); + ssize_t read = this->transport_read_(buf + at, len - at); if (read == -1) { const int err = errno; if (!this->would_block_(err)) { @@ -676,6 +740,10 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return true; } bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { +#ifdef ESPHOME_OTA_UDP + if (this->udp_ != nullptr) + return this->udp_send_(buf, len); +#endif uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { @@ -728,7 +796,20 @@ void ESPHomeOTAComponent::log_read_error_(const LogString *what) { void ESPHomeOTAComponent::log_start_(const LogString *phase) { char peername[socket::SOCKADDR_STR_LEN]; - this->client_->getpeername_to(peername); +#ifdef ESPHOME_OTA_UDP + if (this->client_ == nullptr) { + // A committed UDP session has already dropped TCP +#ifdef ESPHOME_OTA_UDP_LWIP + ipaddr_ntoa_r(&this->udp_->peer_ip, peername, sizeof(peername)); +#else + socket::format_sockaddr_to(reinterpret_cast(&this->udp_->peer), this->udp_->peer_len, + peername); +#endif + } else +#endif + { + this->client_->getpeername_to(peername); + } ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), peername); } @@ -840,7 +921,7 @@ ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) { break; } #endif - read = this->client_->read(buf, requested); + read = this->transport_read_(buf, requested); if (read > 0) break; if (read == 0) { @@ -874,6 +955,11 @@ ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) { void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) { #if USE_OTA_VERSION == 2 +#ifdef ESPHOME_OTA_UDP + // UDP acks every message itself + if (this->udp_ != nullptr) + return; +#endif while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total || (xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) { this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); @@ -883,7 +969,12 @@ void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) { } void ESPHomeOTAComponent::cleanup_connection_() { - this->client_->close(); +#ifdef ESPHOME_OTA_UDP + this->udp_ = nullptr; // the link itself lives on handle_data_'s stack +#endif + // Already gone once a UDP session committed + if (this->client_ != nullptr) + this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; this->handshake_buf_pos_ = 0; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 910a5b3d99d..fbb1ba5447b 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -15,6 +15,25 @@ #include "esphome/core/preferences.h" #include "esphome/core/hash_base.h" +// UDP data phase: raw lwIP needs a main loop lock (or a single thread); the host polls a socket instead +#ifdef USE_ESP32 +#include +#endif +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || \ + (defined(USE_ESP32) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING)) +#define ESPHOME_OTA_UDP +#define ESPHOME_OTA_UDP_LWIP +#include "lwip/ip_addr.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" +#if defined(USE_LIBRETINY) && !LWIP_TCPIP_CORE_LOCKING +#undef ESPHOME_OTA_UDP +#undef ESPHOME_OTA_UDP_LWIP +#endif +#elif defined(USE_HOST) +#define ESPHOME_OTA_UDP +#endif + namespace esphome { /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. @@ -65,6 +84,69 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); + +#ifdef ESPHOME_OTA_UDP + // UDP transport for the data phase; wire format in ota_esphome_udp.cpp + static constexpr size_t UDP_OUT_SIZE = 64; // our unacked responses: at most three encrypted ones + // Messages the device keeps unread: like TCP's 4 segment window, so no more memory than TCP holds + static constexpr uint8_t UDP_WINDOW = 4; +#ifdef ESPHOME_OTA_UDP_LWIP + using UdpSlot = struct pbuf *; +#else + using UdpSlot = struct OtaUdpDatagram *; +#endif + // Lives on handle_data_'s stack; the receive callback shares it under the lwIP lock + struct UdpLink { + ~UdpLink(); + void release(); // closes the socket and frees what is queued +#ifdef ESPHOME_OTA_UDP_LWIP + struct udp_pcb *pcb{nullptr}; + ip_addr_t peer_ip; +#else + std::unique_ptr sock; + struct sockaddr_storage peer; // the TCP peer; its port is replaced by peer_port + socklen_t peer_len{0}; +#endif + uint16_t peer_port{0}; // learned from the client's datagrams + // The session token, our answer to the client: top bit clear, so never an error code; all zero declines + uint8_t reply[4]{}; + UdpSlot queue[UDP_WINDOW]{}; // slot seq % UDP_WINDOW for seq in [consumed, consumed + UDP_WINDOW) + uint16_t received{0}; // every seq before it is here + uint16_t consumed{0}; // the message being read + uint16_t read_offset{0}; // payload bytes of it already read + uint16_t out_base{0}; // stream offset of out[0] + uint8_t out_len{0}; + bool committed{false}; // the client heard us over UDP + uint8_t out[UDP_OUT_SIZE]; + }; + // Fills link.reply on success; leaves it all zero (declined) otherwise + void udp_open_(UdpLink &link); + // Waits for the client to commit to UDP or fall back to TCP; false on failure + bool udp_start_(); + // Like a blocking socket read with SO_RCVTIMEO: up to len bytes, or -1 with EWOULDBLOCK + ssize_t udp_read_(uint8_t *buf, size_t len); + bool udp_send_(const uint8_t *data, size_t len); + // In a UDP session, keeps answering until the client has our responses (an error code), briefly + void udp_linger_(); + // A datagram from the client's IP; caller holds the lwIP lock (or runs in the receive callback) + static void udp_on_datagram(UdpLink &link, UdpSlot p, uint16_t port); + // Caller holds the lwIP lock (or runs in the receive callback) + static void udp_send_ack(UdpLink &link, uint16_t prompted_by); +#ifdef ESPHOME_OTA_UDP_LWIP + static void udp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, uint16_t port); + void udp_poll_() {} +#else + void udp_poll_(); // hands what the socket received to udp_on_datagram +#endif +#endif + // Data phase only (udp_ shares storage with the handshake's connect time): TCP or, once committed, the UDP stream + inline ssize_t transport_read_(uint8_t *buf, size_t len) { +#ifdef ESPHOME_OTA_UDP + if (this->udp_ != nullptr) + return this->udp_read_(buf, len); +#endif + return this->client_->read(buf, len); + } #ifdef USE_OTA_PASSWORD static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 bytes * 2) bool handle_auth_send_(); @@ -75,7 +157,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); - inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); } #ifdef USE_OTA_ENCRYPTION // Heap-allocated only while an encrypted OTA session is active. @@ -105,18 +186,21 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext); bool noise_readall_(uint8_t *buf, size_t len); ssize_t noise_read_data_(uint8_t *buf, size_t capacity); - bool noise_write_byte_(uint8_t byte); + static constexpr size_t NOISE_MAX_RESPONSE = 4; // the UDP token + bool noise_write_(const uint8_t *data, size_t len); #endif // USE_OTA_ENCRYPTION // Data-phase I/O dispatch: through the noise transport when a session is // active, straight to the socket otherwise. - inline bool data_write_byte_(uint8_t byte) { + // len is at most NOISE_MAX_RESPONSE + inline bool data_write_(const uint8_t *data, size_t len) { #ifdef USE_OTA_ENCRYPTION if (this->noise_ != nullptr) - return this->noise_write_byte_(byte); + return this->noise_write_(data, len); #endif - return this->write_byte_(byte); + return this->writeall_(data, len); } + inline bool data_write_byte_(uint8_t byte) { return this->data_write_(&byte, 1); } // When encrypted, buf must have room for len + noise::MAC_SIZE bytes. inline bool data_readall_(uint8_t *buf, size_t len) { #ifdef USE_OTA_ENCRYPTION @@ -200,7 +284,21 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr client_; ota::OTABackendPtr backend_; +#ifdef ESPHOME_OTA_UDP_LWIP + union { + uint32_t client_connect_time_{0}; // handshake states + UdpLink *udp_; // data phase (never both), points into handle_data_'s stack + }; + static_assert(sizeof(UdpLink *) == sizeof(uint32_t), "clearing client_connect_time_ must clear udp_"); +#else uint32_t client_connect_time_{0}; +#ifdef ESPHOME_OTA_UDP + UdpLink *udp_{nullptr}; // data phase, points into handle_data_'s stack +#endif +#endif +#if defined(ESPHOME_OTA_UDP) && defined(USE_OTA_ENCRYPTION) + static_assert(sizeof(UdpLink::reply) <= NOISE_MAX_RESPONSE, "the token goes out through noise_write_"); +#endif static constexpr size_t HANDSHAKE_BUF_SIZE = 5; // Buffer size for OTA data transfer. The upload client derives its maximum // encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index a271ef9d39d..02c19ed7b7d 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -266,13 +266,15 @@ ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) { return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext); } -/// Blocking write of one response byte as an encrypted frame. -bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) { - uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE]; - frame[noise::FRAME_HEADER_SIZE] = byte; +/// Blocking write of a short response (at most NOISE_MAX_RESPONSE bytes) as one encrypted frame. +bool ESPHomeOTAComponent::noise_write_(const uint8_t *data, size_t len) { + uint8_t frame[noise::FRAME_HEADER_SIZE + NOISE_MAX_RESPONSE + noise::MAC_SIZE]; + if (len > NOISE_MAX_RESPONSE) + return false; + memcpy(frame + noise::FRAME_HEADER_SIZE, data, len); NoiseBuffer mbuf; noise_buffer_init(mbuf); - noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE); + noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, len, len + noise::MAC_SIZE); int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf); if (err != 0) { ESP_LOGW(TAG, "Encrypt: %d", err); diff --git a/esphome/components/esphome/ota/ota_esphome_udp.cpp b/esphome/components/esphome/ota/ota_esphome_udp.cpp new file mode 100644 index 00000000000..6044284755c --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_udp.cpp @@ -0,0 +1,375 @@ +#include "ota_esphome.h" +#if defined(USE_OTA) && defined(ESPHOME_OTA_UDP) + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef ESPHOME_OTA_UDP_LWIP +#include "lwip/inet.h" +#ifdef USE_LIBRETINY +#include "lwip/tcpip.h" +#endif +#endif + +#include +#include +#include + +/* + * UDP data phase: the same byte stream as TCP, cut into numbered datagrams. + * client -> device [type | COMMITTED][token u32][seq u16][device bytes received u16][payload] + * device -> client [ACK][token u32][received u16][seq that prompted it u16][stream offset u16][window u8][our bytes] + * The lwIP callback acks each datagram on arrival, so flash writes never look like loss, and keeps messages in its + * UDP_WINDOW slots until the OTA loop reads them. "window": free slots in the high nibble, and in the low nibble bit k + * set when received + k is already here (like TCP SACK). + */ + +namespace esphome { + +ESPHOME_LOG_TAG(TAG, "esphome.ota"); + +// PING (0x02) and PROBE (0x03) only prompt an ACK +static constexpr uint8_t UDP_MSG_DATA = 0x01; +static constexpr uint8_t UDP_FLAG_COMMITTED = 0x80; +static constexpr uint8_t UDP_MSG_ACK = 0x40; +static constexpr size_t UDP_HEADER_SIZE = 9; +static constexpr size_t UDP_ACK_HEADER_SIZE = 12; +// The client's 60 s wait for the token over a lossy TCP link, plus its 15 s probe +static constexpr uint32_t UDP_COMMIT_TIMEOUT = 80000; +// Like the TCP socket's SO_RCVTIMEO in handle_data_, so readall_ treats both alike +static constexpr uint32_t UDP_READ_WAIT_MS = 2000; +// How often the commit wait looks at the UDP link between reads of the TCP fallback byte +static constexpr uint32_t UDP_COMMIT_POLL_MS = 100; +static constexpr uint32_t UDP_LINGER_MS = 3000; + +#ifdef ESPHOME_OTA_UDP_LWIP +#ifdef USE_LIBRETINY +// LibreTiny's LwIPLock is a no-op; take the core lock directly (the header drops UDP without one) +struct UdpLock { + UdpLock() { LOCK_TCPIP_CORE(); } + ~UdpLock() { UNLOCK_TCPIP_CORE(); } +}; +#else +using UdpLock = LwIPLock; +#endif +static uint16_t slot_len(struct pbuf *p) { return p->tot_len; } +static uint16_t slot_copy(struct pbuf *p, void *dst, uint16_t len, uint16_t offset) { + return pbuf_copy_partial(p, dst, len, offset); +} +static void slot_free(struct pbuf *p) { pbuf_free(p); } +#else +// The host has no lwIP thread: the OTA loop drains the socket itself. Empty bodies, not = default, so clang-tidy +// does not flag the guards as unused +struct UdpLock { + UdpLock() {} + ~UdpLock() {} +}; +struct OtaUdpDatagram { + uint16_t len; + uint8_t data[1500]; +}; +static uint16_t slot_len(OtaUdpDatagram *p) { return p->len; } +static uint16_t slot_copy(OtaUdpDatagram *p, void *dst, uint16_t len, uint16_t offset) { + const uint16_t n = std::min(len, p->len - offset); + memcpy(dst, p->data + offset, n); + return n; +} +static void slot_free(OtaUdpDatagram *p) { delete p; } +#endif + +static void put_u16(uint8_t *at, uint16_t value) { + at[0] = value >> 8; + at[1] = value & 0xFF; +} + +ESPHomeOTAComponent::UdpLink::~UdpLink() { this->release(); } + +void ESPHomeOTAComponent::UdpLink::release() { + UdpLock lock; +#ifdef ESPHOME_OTA_UDP_LWIP + if (this->pcb != nullptr) + udp_remove(this->pcb); + this->pcb = nullptr; +#else + this->sock = nullptr; +#endif + for (auto &p : this->queue) { + if (p != nullptr) + slot_free(p); + p = nullptr; + } +} + +void ESPHomeOTAComponent::udp_send_ack(UdpLink &link, uint16_t prompted_by) { + const uint16_t len = UDP_ACK_HEADER_SIZE + link.out_len; +#ifdef ESPHOME_OTA_UDP_LWIP + struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); + if (p == nullptr) + return; // Like a lost ACK: the client asks again + auto *ack = static_cast(p->payload); +#else + uint8_t ack[UDP_ACK_HEADER_SIZE + UDP_OUT_SIZE]; +#endif + ack[0] = UDP_MSG_ACK; + memcpy(ack + 1, link.reply, 4); + put_u16(ack + 5, link.received); + put_u16(ack + 7, prompted_by); + put_u16(ack + 9, link.out_base); + uint8_t window = (UDP_WINDOW - static_cast(link.received - link.consumed)) << 4; + for (uint8_t k = 1; k < UDP_WINDOW; k++) { + if (link.queue[(link.received + k) % UDP_WINDOW] != nullptr) + window |= 1 << k; + } + ack[11] = window; + memcpy(ack + UDP_ACK_HEADER_SIZE, link.out, link.out_len); + // Best effort: a lost ACK is repeated for the client's next datagram +#ifdef ESPHOME_OTA_UDP_LWIP + udp_sendto(link.pcb, p, &link.peer_ip, link.peer_port); + pbuf_free(p); +#else + struct sockaddr_storage to = link.peer; + if (to.ss_family == AF_INET6) { + reinterpret_cast(&to)->sin6_port = htons(link.peer_port); + } else { + reinterpret_cast(&to)->sin_port = htons(link.peer_port); + } + link.sock->sendto(ack, len, 0, reinterpret_cast(&to), link.peer_len); +#endif +} + +void ESPHomeOTAComponent::udp_on_datagram(UdpLink &link, UdpSlot p, uint16_t port) { + uint8_t hdr[UDP_HEADER_SIZE]; + if (slot_copy(p, hdr, UDP_HEADER_SIZE, 0) != UDP_HEADER_SIZE || memcmp(hdr + 1, link.reply, 4) != 0) { + slot_free(p); + return; + } + // The token proves the sender; answer the port it arrived from, which NAT may have rewritten + link.peer_port = port; + link.committed |= (hdr[0] & UDP_FLAG_COMMITTED) != 0; + // Drop the responses the client already has + const uint16_t acked = encode_uint16(hdr[7], hdr[8]) - link.out_base; + if (acked != 0 && acked <= link.out_len) { + link.out_len -= acked; + memmove(link.out, link.out + acked, link.out_len); + link.out_base += acked; + } + const uint16_t seq = encode_uint16(hdr[5], hdr[6]); + UdpSlot &slot = link.queue[seq % UDP_WINDOW]; + if ((hdr[0] & ~UDP_FLAG_COMMITTED) == UDP_MSG_DATA && slot_len(p) > UDP_HEADER_SIZE && + static_cast(seq - link.consumed) < UDP_WINDOW && slot == nullptr) { + slot = p; + while (static_cast(link.received - link.consumed) < UDP_WINDOW && + link.queue[link.received % UDP_WINDOW] != nullptr) + link.received++; + } else { + slot_free(p); + } + udp_send_ack(link, seq); +} + +#ifdef ESPHOME_OTA_UDP_LWIP +// lwIP context: the tcpip thread (ESP32, LibreTiny), SYS (ESP8266) or an IRQ (RP2040) +void ESPHomeOTAComponent::udp_recv_cb(void *arg, struct udp_pcb * /*pcb*/, struct pbuf *p, const ip_addr_t *addr, + uint16_t port) { + auto &link = *static_cast(arg); + if (!ip_addr_cmp(addr, &link.peer_ip)) { + pbuf_free(p); + return; + } + udp_on_datagram(link, p, port); +} +#else +static bool same_ip(const struct sockaddr_storage &a, const struct sockaddr_storage &b) { + uint32_t ipv4_a, ipv4_b; + const bool v4_a = socket::sockaddr_to_ipv4(reinterpret_cast(&a), &ipv4_a); + const bool v4_b = socket::sockaddr_to_ipv4(reinterpret_cast(&b), &ipv4_b); + if (v4_a || v4_b) + return v4_a && v4_b && ipv4_a == ipv4_b; + return a.ss_family == AF_INET6 && b.ss_family == AF_INET6 && + memcmp(&reinterpret_cast(&a)->sin6_addr, + &reinterpret_cast(&b)->sin6_addr, sizeof(in6_addr)) == 0; +} + +void ESPHomeOTAComponent::udp_poll_() { + UdpLink &link = *this->udp_; + for (;;) { + uint8_t buf[sizeof(OtaUdpDatagram::data)]; + struct sockaddr_storage from; + socklen_t from_len = sizeof(from); + const ssize_t len = link.sock->recvfrom(buf, sizeof(buf), reinterpret_cast(&from), &from_len); + if (len < 0) + return; + if (!same_ip(from, link.peer)) + continue; + auto *datagram = new OtaUdpDatagram; // NOLINT(cppcoreguidelines-owning-memory) + datagram->len = len; + memcpy(datagram->data, buf, len); + const uint16_t port = from.ss_family == AF_INET6 ? ntohs(reinterpret_cast(&from)->sin6_port) + : ntohs(reinterpret_cast(&from)->sin_port); + udp_on_datagram(link, datagram, port); + } +} +#endif + +void ESPHomeOTAComponent::udp_open_(UdpLink &link) { + struct sockaddr_storage peer; + socklen_t peer_len = sizeof(peer); + if (this->client_->getpeername(reinterpret_cast(&peer), &peer_len) != 0) + return; + uint8_t token[4]; + if (!random_bytes(token, sizeof(token))) + return; +#ifdef ESPHOME_OTA_UDP_LWIP + uint32_t ipv4; + if (socket::sockaddr_to_ipv4(reinterpret_cast(&peer), &ipv4)) { + ip_addr_set_ip4_u32(&link.peer_ip, ipv4); + } else { +#if USE_NETWORK_IPV6 && LWIP_IPV6 + inet6_addr_to_ip6addr(ip_2_ip6(&link.peer_ip), &reinterpret_cast(&peer)->sin6_addr); + IP_SET_TYPE_VAL(link.peer_ip, IPADDR_TYPE_V6); +#else + return; +#endif + } + UdpLock lock; +#if LWIP_IPV6 + link.pcb = udp_new_ip_type(IPADDR_TYPE_ANY); +#else + link.pcb = udp_new(); +#endif + // On failure the reply stays all zero and ~UdpLink removes the pcb + if (link.pcb == nullptr || udp_bind(link.pcb, IP_ANY_TYPE, this->port_) != ERR_OK) + return; + udp_recv(link.pcb, udp_recv_cb, &link); +#else + link.peer = peer; + link.peer_len = peer_len; + // Same domain as the listener, so the peer address from getpeername fits + link.sock = socket::socket_ip(SOCK_DGRAM, IPPROTO_UDP); + struct sockaddr_storage local; + const socklen_t local_len = + socket::set_sockaddr_any(reinterpret_cast(&local), sizeof(local), this->port_); + if (link.sock == nullptr || local_len == 0 || + link.sock->bind(reinterpret_cast(&local), local_len) != 0 || + link.sock->setblocking(false) != 0) + return; +#endif + // Below the error codes (0x80 and up) the client checks the first byte against, and never all zero + token[0] = (token[0] & 0x7F) | 0x01; + memcpy(link.reply, token, sizeof(token)); +} + +bool ESPHomeOTAComponent::udp_start_() { + // TCP now only carries a fallback byte; short read timeouts let the UDP link be checked in between + struct timeval tv { + 0, UDP_COMMIT_POLL_MS * 1000 + }; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + const uint32_t start = millis(); + while (millis() - start < UDP_COMMIT_TIMEOUT) { + this->udp_poll_(); + bool committed; + { + UdpLock lock; + committed = this->udp_->committed; + } + if (committed) { + // The client heard us over UDP, so TCP is done. Outside the lock: closing a socket waits on the tcpip thread + this->client_ = nullptr; + return true; + } + // A raw byte outside any Noise frame: the client heard nothing over UDP. Forging it only forces the ordinary + // TCP path, which dropping our datagrams forces anyway + uint8_t fallback; + const ssize_t read = this->client_->read(&fallback, 1); + if (read == 1) { + this->udp_->release(); + this->udp_ = nullptr; + tv = {UDP_READ_WAIT_MS / 1000, 0}; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + return true; + } + if (read == 0) { + this->remote_closed_ = true; + return false; + } + if (!this->would_block_(errno)) + return false; + App.feed_wdt(); + } + return false; +} + +ssize_t ESPHomeOTAComponent::udp_read_(uint8_t *buf, size_t len) { + UdpLink &link = *this->udp_; + const uint32_t start = millis(); + size_t got = 0; + for (;;) { + this->udp_poll_(); + UdpSlot p; + { + UdpLock lock; + p = link.consumed != link.received ? link.queue[link.consumed % UDP_WINDOW] : nullptr; + } + if (p == nullptr) { + if (got != 0) + return got; + if (millis() - start >= UDP_READ_WAIT_MS) + break; + this->yield_and_feed_watchdog_(); + continue; + } + // The callback never touches the slot being read, so the copy runs without the lock + const uint16_t offset = UDP_HEADER_SIZE + link.read_offset; + const uint16_t n = slot_copy(p, buf + got, std::min(len - got, slot_len(p) - offset), offset); + got += n; + link.read_offset += n; + if (UDP_HEADER_SIZE + link.read_offset >= slot_len(p)) { + UdpLock lock; + slot_free(p); + link.queue[link.consumed % UDP_WINDOW] = nullptr; + // A full window just opened: tell the client, which is holding back + const bool was_full = static_cast(link.received - link.consumed) == UDP_WINDOW; + link.consumed++; + link.read_offset = 0; + if (was_full) + udp_send_ack(link, link.received - 1); + } + if (got == len) + return got; + } + errno = EWOULDBLOCK; + return -1; +} + +bool ESPHomeOTAComponent::udp_send_(const uint8_t *data, size_t len) { + UdpLink &link = *this->udp_; + UdpLock lock; + if (link.out_len + len > UDP_OUT_SIZE) + return false; + memcpy(link.out + link.out_len, data, len); + link.out_len += len; + // Prompted by nothing new, so the client takes no round trip sample from it + udp_send_ack(link, link.received - 1); + return true; +} + +void ESPHomeOTAComponent::udp_linger_() { + if (this->udp_ == nullptr) + return; + const uint32_t start = millis(); + while (millis() - start < UDP_LINGER_MS) { + this->udp_poll_(); + { + UdpLock lock; + if (this->udp_->out_len == 0) + return; + } + this->yield_and_feed_watchdog_(); + } +} + +} // namespace esphome +#endif // USE_OTA && ESPHOME_OTA_UDP diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1568d37b114..d0df293fd4e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ETHERNET) && defined(USE_ESP32) +#include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -731,14 +732,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(this->eth_netif_, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index a20419eceb1..7a3e84f56ca 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,11 +3,12 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" #include namespace esphome::ezo { -static const char *const TAG = "ezo.sensor"; +ESPHOME_LOG_TAG(TAG, "ezo.sensor"); enum EzoCommandType : uint8_t { EZO_READ = 0, diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index ab769954124..20eda9a3278 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -330,7 +330,9 @@ async def write_image( if byte_order is not None: # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() + pixels = encoder.convert( + image.resize((width, height)), path + ).get_flattened_data() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py new file mode 100644 index 00000000000..c9dd24463b4 --- /dev/null +++ b/esphome/components/fusb302b/__init__.py @@ -0,0 +1,132 @@ +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import i2c, sensor, text_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_CURRENT, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_ON_CONNECT, + CONF_ON_DISCONNECT, + CONF_ON_ERROR, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor", "text_sensor"] +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] +DOMAIN = "fusb302b" + +CONF_CONTRACT = "contract" +CONF_ON_POWER_READY = "on_power_ready" +CONF_ON_REQUEST_FAILED = "on_request_failed" +CONF_REQUEST_VOLTAGE = "request_voltage" + +fusb302b_ns = cg.esphome_ns.namespace("fusb302b") +FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice) + +ConnectForwarder = fusb302b_ns.class_("ConnectForwarder") +DisconnectForwarder = fusb302b_ns.class_("DisconnectForwarder") +PowerReadyForwarder = fusb302b_ns.class_("PowerReadyForwarder") +ErrorForwarder = fusb302b_ns.class_("ErrorForwarder") + +# USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the +# requested voltage is chosen. +validate_voltage = cv.int_range(min=5, max=20) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FUSB302B), + cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_POWER_READY): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + cv.Optional(CONF_ON_REQUEST_FAILED): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x22)), + cv.only_on_esp32, +) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "add_on_state_callback", forwarder=ConnectForwarder + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, "add_on_state_callback", forwarder=DisconnectForwarder + ), + automation.CallbackAutomation( + CONF_ON_POWER_READY, "add_on_state_callback", forwarder=PowerReadyForwarder + ), + automation.CallbackAutomation( + CONF_ON_ERROR, "add_on_state_callback", forwarder=ErrorForwarder + ), + automation.CallbackAutomation( + CONF_ON_REQUEST_FAILED, "add_on_request_failed_callback" + ), +) + + +async def to_code(config: ConfigType) -> None: + interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, var.set_contract_text_sensor) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +automation.register_apply_action( + "fusb302b.request_voltage", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FUSB302B), + cv.Required(CONF_VOLTAGE): cv.templatable(validate_voltage), + }, + key=CONF_VOLTAGE, + ), + automation.ApplyField(CONF_VOLTAGE, "request_voltage", cg.int_), +) + +automation.register_apply_condition( + "fusb302b.is_connected", + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(FUSB302B)}), + "is_connected()", +) diff --git a/esphome/components/fusb302b/automation.h b/esphome/components/fusb302b/automation.h new file mode 100644 index 00000000000..4d1ce39db25 --- /dev/null +++ b/esphome/components/fusb302b/automation.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "esphome/core/automation.h" +#include "pd.h" + +namespace esphome::fusb302b { + +/// Triggers when a source is attached, including after recovering from a communication error. +struct ConnectForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (is_connected_state(state) && !is_connected_state(previous)) + this->automation->trigger(); + } +}; + +/// Triggers when the source is removed, or is no longer known to be attached because communication failed. +struct DisconnectForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (!is_connected_state(state) && is_connected_state(previous)) + this->automation->trigger(); + } +}; + +/// Triggers when negotiation is finished: a new PD contract is in place, or the source does not speak PD. +struct PowerReadyForwarder { + Automation<> *automation; + void operator()(PdState state, PdState /*previous*/) const { + if (state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT) + this->automation->trigger(); + } +}; + +/// Triggers when communication with the chip fails. +struct ErrorForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (state == PdState::PD_STATE_ERROR && previous != PdState::PD_STATE_ERROR) + this->automation->trigger(); + } +}; + +static_assert(sizeof(ConnectForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(DisconnectForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(PowerReadyForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(ErrorForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp new file mode 100644 index 00000000000..479981f8cac --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -0,0 +1,580 @@ +#include "fusb302b.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include "fusb302b_registers.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b"); + +// Task notification bits +static constexpr uint32_t NOTIFY_IRQ = 1 << 0; +static constexpr uint32_t NOTIFY_STOP = 1 << 1; +static constexpr uint32_t NOTIFY_REQUEST = 1 << 2; + +static constexpr uint32_t TASK_STACK_SIZE = 4096; +static constexpr UBaseType_t TASK_PRIORITY = 18; + +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t CC_SETTLE_MS = 5; +static constexpr uint32_t CC_POLL_INTERVAL_MS = 500; +static constexpr uint32_t PD_POLL_INTERVAL_MS = 100; +// Safety net in case an interrupt edge is missed while attached and idle +static constexpr uint32_t IDLE_POLL_INTERVAL_MS = 1000; +// Time to wait for the source to send its capabilities on its own after attach +static constexpr uint32_t SOURCE_CAP_WAIT_MS = 5000; +// Time to wait after each GET_SOURCE_CAP / soft reset before the next recovery step +static constexpr uint32_t SOURCE_CAP_RETRY_MS = 2500; +// Upper bound on back-to-back interrupt handling while INT_N stays asserted +static constexpr uint8_t MAX_IRQ_ROUNDS = 8; +// BC_LVL is read this many extra times to confirm a stable measurement +static constexpr uint8_t CC_STABLE_READS = 5; +static constexpr uint8_t CC_LEVEL_UNSTABLE = 0xFF; + +void IRAM_ATTR FUSB302B::gpio_intr(FUSB302B *arg) { + TaskHandle_t handle = arg->task_.get_handle(); + if (handle == nullptr) + return; + BaseType_t woken = pdFALSE; + xTaskNotifyFromISR(handle, NOTIFY_IRQ, eSetBits, &woken); + portYIELD_FROM_ISR(woken); +} + +void FUSB302B::task_func(void *arg) { static_cast(arg)->run_task_(); } + +void FUSB302B::setup() { + uint8_t device_id = 0; + if (!this->read_byte(FUSB_DEVICE_ID, &device_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + const uint8_t version = device_id & FUSB_DEVICE_ID_VERSION_MASK; + if (version != FUSB_DEVICE_ID_VERSION_A && version != FUSB_DEVICE_ID_VERSION_B) { + ESP_LOGE(TAG, "Unknown device ID 0x%02X", device_id); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Device ID 0x%02X (product %u, revision %u)", device_id, (device_id >> 2) & 0x3, device_id & 0x3); + if (!this->init_chip_()) { + this->mark_failed(LOG_STR("Initialization failed")); + return; + } + + this->interrupt_pin_->setup(); + // The task must exist before the interrupt is attached, since the ISR notifies it + if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create task")); + return; + } + this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); +} + +void FUSB302B::queue_event_(uint8_t event) { + const uint8_t head = this->event_queue_head_.load(std::memory_order_relaxed); + if (static_cast(head - this->event_queue_tail_.load(std::memory_order_acquire)) < EVENT_QUEUE_SIZE) { + this->event_queue_[head % EVENT_QUEUE_SIZE] = event; + this->event_queue_head_.store(head + 1, std::memory_order_release); + } else { + if (event == EVENT_REQUEST_FAILED) + this->dropped_request_failures_.fetch_add(1, std::memory_order_relaxed); + this->event_queue_overflow_ = true; + } + this->enable_loop_soon_any_context(); +} + +void FUSB302B::on_state_changed() { + const PdState state = this->state_; + if (state == this->queued_state_) { + // Only the contract changed + this->enable_loop_soon_any_context(); + return; + } + this->queued_state_ = state; + this->queue_event_(static_cast(state)); +} + +void FUSB302B::on_request_failed() { this->queue_event_(EVENT_REQUEST_FAILED); } + +void FUSB302B::loop() { + const uint32_t contract = this->contract_; + if (contract != this->published_contract_) { + this->published_contract_ = contract; + this->publish_contract_(PackedContract{contract}); + } + + uint8_t tail = this->event_queue_tail_.load(std::memory_order_relaxed); + const uint8_t head = this->event_queue_head_.load(std::memory_order_acquire); + while (tail != head) { + const uint8_t event = this->event_queue_[tail % EVENT_QUEUE_SIZE]; + this->event_queue_tail_.store(++tail, std::memory_order_release); + if (event == EVENT_REQUEST_FAILED) { + this->request_failed_callback_.call(); + } else { + this->publish_state_(static_cast(event)); + } + } + if (this->event_queue_overflow_.exchange(false)) { + ESP_LOGW(TAG, "Events arrived faster than the main loop handled them; some state triggers were skipped"); + // Drop the queued states before reading the state, so no older state is replayed after it. Request failures + // are only counted, so every one of them still fires. + uint8_t failures = this->dropped_request_failures_.exchange(0, std::memory_order_relaxed); + const uint8_t end = this->event_queue_head_.load(std::memory_order_acquire); + for (; tail != end; tail++) { + if (this->event_queue_[tail % EVENT_QUEUE_SIZE] == EVENT_REQUEST_FAILED) + failures++; + } + this->event_queue_tail_.store(end, std::memory_order_release); + this->publish_state_(this->state_); + for (; failures > 0; failures--) + this->request_failed_callback_.call(); + } + // Re-enabled by on_state_changed() or on_request_failed() from the PHY task + this->disable_loop(); +} + +void FUSB302B::publish_state_(PdState state) { + if (state == this->published_state_) + return; + const PdState previous = this->published_state_; + this->published_state_ = state; + this->state_callback_.call(state, previous); +} + +void FUSB302B::publish_contract_(PackedContract contract) { +#ifdef USE_SENSOR + if (this->voltage_sensor_ != nullptr) + this->voltage_sensor_->publish_state(contract.voltage()); + if (this->current_sensor_ != nullptr) + this->current_sensor_->publish_state(contract.current()); +#endif +#ifdef USE_TEXT_SENSOR + if (this->contract_text_sensor_ != nullptr) { + if (!contract.is_set()) { + this->contract_text_sensor_->publish_state("Detached"); + } else { + char buf[24]; + snprintf(buf, sizeof(buf), "%.2fA @ %.0fV", contract.current(), contract.voltage()); + this->contract_text_sensor_->publish_state(buf); + } + } +#endif +} + +void FUSB302B::dump_config() { + ESP_LOGCONFIG(TAG, + "FUSB302B:\n" + " Request Voltage: %u V", + this->get_request_voltage()); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Contract", this->contract_text_sensor_); +#endif +} + +void FUSB302B::on_shutdown() { + this->interrupt_pin_->detach_interrupt(); + // The task parks itself instead of being deleted, so it can never be stopped in the middle of an I2C transfer + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits); +} + +void FUSB302B::request_voltage(int voltage) { + if (voltage < MIN_REQUEST_VOLTAGE || voltage > MAX_REQUEST_VOLTAGE) { + ESP_LOGW(TAG, "Requested %d V is outside %u-%u V, clamping", voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + this->set_request_voltage(static_cast(std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE))); + if (this->is_failed()) { + ESP_LOGW(TAG, "Cannot request %u V, the component failed", this->get_request_voltage()); + // loop() no longer runs, so fire the trigger from the scheduler. Calling it here directly would recurse if the + // automation requests a voltage again. + App.scheduler.set_timeout(this, 0, [this]() { this->request_failed_callback_.call(); }); + return; + } + // Before setup() the voltage is simply used at the first attach + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_REQUEST, eSetBits); +} + +void FUSB302B::run_task_() { + // Give the source time to settle after power-up before the first CC measurement + vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS)); + + while (true) { + uint32_t wait_ms = CC_POLL_INTERVAL_MS; + if (this->attached_) { + const bool busy = this->waiting_for_source_caps_ || this->request_pending_ || this->retry_request_pending_ || + this->check_ams_(); + wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; + } + uint32_t bits = 0; + xTaskNotifyWait(0, UINT32_MAX, &bits, pdMS_TO_TICKS(wait_ms)); + + if (bits & NOTIFY_STOP) { + while (true) + vTaskSuspend(nullptr); + } + if (bits & NOTIFY_REQUEST) { + // When unattached, the new voltage is simply used at the next attach + this->request_pending_ = this->attached_; + } + + // INT_N is held low until the interrupt registers are read, so a new event that arrives while it is + // already low has no falling edge: keep servicing while the pin stays asserted. + if (bits & NOTIFY_IRQ) + this->handle_interrupt_(); + for (uint8_t i = 0; i < MAX_IRQ_ROUNDS && !this->interrupt_pin_->digital_read(); i++) + this->handle_interrupt_(); + + if (!this->attached_) { + this->try_attach_(); + continue; + } + this->check_transition_timeout_(); + this->check_request_retry_(); + // Waits for a pending Wait retry to end in a contract or a failure, so the two sequences never overlap + if (this->request_pending_ && !this->retry_request_pending_ && !this->check_ams_()) { + this->request_pending_ = false; + this->start_negotiation_(); + } + this->check_source_caps_(); + } +} + +bool FUSB302B::init_chip_() { + // Software reset restores all registers to their defaults + if (!this->write_byte(FUSB_RESET, FUSB_RESET_SW_RES)) + return false; + if (!this->reset_pd_()) + return false; + + // Unmask the interrupts used for PD messaging and VBUS changes + if (!this->write_byte(FUSB_MASK1, FUSB_MASK1_DEFAULT) || !this->write_byte(FUSB_MASKA, 0) || + !this->write_byte(FUSB_MASKB, 0)) + return false; + + // Let the chip retransmit unacknowledged messages up to 3 times + uint8_t control3 = 0; + if (!this->read_byte(FUSB_CONTROL3, &control3)) + return false; + control3 = + (control3 & ~FUSB_CONTROL3_N_RETRIES_MASK) | (3 << FUSB_CONTROL3_N_RETRIES_SHIFT) | FUSB_CONTROL3_AUTO_RETRY; + if (!this->write_byte(FUSB_CONTROL3, control3)) + return false; + + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL)) + return false; + return this->reset_pd_(); +} + +bool FUSB302B::reset_pd_() { + this->reset_protocol_(); + // Writing CONTROL0 as a whole byte also clears INT_MASK, which is what enables the INT_N pin + return this->write_byte(FUSB_CONTROL0, FUSB_CONTROL0_TX_FLUSH) && + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH) && this->write_byte(FUSB_RESET, FUSB_RESET_PD_RESET); +} + +bool FUSB302B::enable_auto_crc_() { + uint8_t switches1 = 0; + return this->read_byte(FUSB_SWITCHES1, &switches1) && + this->write_byte(FUSB_SWITCHES1, switches1 | FUSB_SWITCHES1_AUTO_CRC); +} + +void FUSB302B::handle_interrupt_() { + // STATUS0A, STATUS1A, INTERRUPTA, INTERRUPTB, STATUS0, STATUS1, INTERRUPT; reading clears the interrupts + uint8_t regs[7]; + if (this->read_register(FUSB_STATUS0A, regs, sizeof(regs)) != i2c::ERROR_OK) { + this->enter_error_(); + return; + } + const uint8_t interrupta = regs[2]; + const uint8_t status0 = regs[4]; + uint8_t status1 = regs[5]; + const uint8_t interrupt = regs[6]; + + if ((interrupt & FUSB_INTERRUPT_I_VBUSOK) && !(status0 & FUSB_STATUS0_VBUSOK)) { + if (this->attached_) + this->detach_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_HARDRST) { + if (this->attached_) + this->handle_hard_reset_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_SOFTRST) { + ESP_LOGV(TAG, "Soft reset received"); + } + if ((interrupta & FUSB_INTERRUPTA_I_RETRYFAIL) && this->attached_) + this->handle_transmit_failed_(); + + if (!this->attached_) + return; + PdMsg msg; + while (!(status1 & FUSB_STATUS1_RX_EMPTY)) { + bool is_sop = false; + if (!this->read_message_(msg, is_sop)) { + ESP_LOGW(TAG, "Reading message failed"); + // A partial read leaves the FIFO misaligned + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH); + return; + } + // Only SOP packets are addressed to a sink; SOP'/SOP'' are for cable plugs + if (is_sop) + this->handle_message_(msg); + if (!this->read_byte(FUSB_STATUS1, &status1)) + return; + } +} + +bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { + uint8_t token = 0; + uint8_t header[2]; + if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) + return false; + msg.set_header(encode_uint16(header[1], header[0])); + + // Data objects, followed by the CRC32 that the chip already checked + uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) + return false; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint8_t *obj = &buf[i * 4]; + msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); + } + is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; + return true; +} + +bool FUSB302B::send_message(const PdMsg &msg) { + // Register address, then the TX tokens; written in one transfer without the copy write_register() makes + uint8_t buf[1 + 4 + 1 + 2 + PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + size_t len = 0; + const uint16_t header = msg.get_coded_header(); + + buf[len++] = FUSB_FIFOS; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC2; + buf[len++] = FUSB_TX_TOKEN_PACKSYM | (msg.num_of_obj * 4 + 2); + buf[len++] = header & 0xFF; + buf[len++] = header >> 8; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint32_t obj = msg.data_objects[i]; + buf[len++] = obj & 0xFF; + buf[len++] = (obj >> 8) & 0xFF; + buf[len++] = (obj >> 16) & 0xFF; + buf[len++] = obj >> 24; + } + buf[len++] = FUSB_TX_TOKEN_JAM_CRC; + buf[len++] = FUSB_TX_TOKEN_EOP; + buf[len++] = FUSB_TX_TOKEN_TXOFF; + buf[len++] = FUSB_TX_TOKEN_TXON; + + i2c::ErrorCode err = this->write(buf, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Sending message type %u failed (error %d)", msg.type, static_cast(err)); + return false; + } + return true; +} + +bool FUSB302B::measure_cc_(uint8_t meas_switch, uint8_t &level) { + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch)) + return false; + vTaskDelay(pdMS_TO_TICKS(CC_SETTLE_MS)); + uint8_t status0 = 0; + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + level = status0 & FUSB_STATUS0_BC_LVL; + for (uint8_t i = 0; i < CC_STABLE_READS; i++) { + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + if ((status0 & FUSB_STATUS0_BC_LVL) != level) { + level = CC_LEVEL_UNSTABLE; + return true; + } + } + return true; +} + +void FUSB302B::try_attach_() { + if (this->state_ == PdState::PD_STATE_ERROR) { + if (!this->init_chip_()) + return; + ESP_LOGI(TAG, "Communication restored"); + this->set_state_(PdState::PD_STATE_DISCONNECTED); + } + + uint8_t status0 = 0; + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL) || !this->read_byte(FUSB_STATUS0, &status0)) { + this->enter_error_(); + return; + } + // A source may present its CC pull-up before VBUS is on; detach is only detected when VBUS drops + if (!(status0 & FUSB_STATUS0_VBUSOK)) { + this->cc_ambiguous_logged_ = false; + return; + } + + uint8_t cc1 = 0; + uint8_t cc2 = 0; + if (!this->write_byte(FUSB_SWITCHES1, FUSB_SWITCHES1_SPECREV_REV2) || + !this->write_byte(FUSB_MEASURE, FUSB_MEASURE_MDAC_CC) || !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC1, cc1) || + !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC2, cc2)) { + this->enter_error_(); + return; + } + // Exactly one CC line carries the source pull-up; the other one is open + uint8_t meas_switch; + uint8_t tx_switch; + if (cc1 != CC_LEVEL_UNSTABLE && cc1 > 0 && cc2 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC1; + tx_switch = FUSB_SWITCHES1_TXCC1; + } else if (cc2 != CC_LEVEL_UNSTABLE && cc2 > 0 && cc1 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC2; + tx_switch = FUSB_SWITCHES1_TXCC2; + } else { + if (!this->cc_ambiguous_logged_) { + ESP_LOGD(TAG, "VBUS present but no single CC line with a source pull-up (CC1 %u, CC2 %u)", cc1, cc2); + this->cc_ambiguous_logged_ = true; + } + return; + } + + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch) || + !this->write_byte(FUSB_SWITCHES1, tx_switch | FUSB_SWITCHES1_SPECREV_REV2) || !this->enable_auto_crc_() || + !this->reset_pd_()) { + this->enter_error_(); + return; + } + ESP_LOGD(TAG, "Attached on CC%u", meas_switch == FUSB_SWITCHES0_MEAS_CC1 ? 1 : 2); + this->attached_ = true; + this->cc_ambiguous_logged_ = false; + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); +} + +void FUSB302B::detach_() { + ESP_LOGD(TAG, "Detached (VBUS lost)"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->request_pending_ = false; + this->reset_pd_(); + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_DISCONNECTED); +} + +void FUSB302B::handle_hard_reset_() { + ESP_LOGD(TAG, "Hard reset received"); + // The source returns to Type-C default power and sends its capabilities again, using the current request voltage + this->request_pending_ = false; + // Also flushes the RX FIFO, so the caller has nothing left to read + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); +} + +void FUSB302B::enter_error_() { + if (this->state_ == PdState::PD_STATE_ERROR) + return; + ESP_LOGW(TAG, "Communication with the chip failed"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->request_pending_ = false; + // Also stops a pending Wait retry, which would otherwise send its request to the failed chip + this->reset_protocol_(); + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_ERROR); +} + +void FUSB302B::start_negotiation_() { + ESP_LOGD(TAG, "Requesting source capabilities for %u V", this->get_request_voltage()); + // send_message() logs a failure; the soft reset is the next step either way, so do not wait for an answer + if (!this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP))) { + this->send_soft_reset_(); + return; + } + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_GET_SENT); +} + +void FUSB302B::send_soft_reset_() { + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + if (!this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET))) { + // No answer can come, so do not wait for one + this->source_caps_failed_(); + return; + } + // The source answers a soft reset with new source capabilities + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT); +} + +void FUSB302B::source_caps_failed_() { + this->waiting_for_source_caps_ = false; + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) { + ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + } else { + // A request made at runtime got no answer. A soft reset keeps the power level, so the contract in place stays. + ESP_LOGW(TAG, "Renegotiation failed, keeping the current contract"); + } + this->on_request_failed(); +} + +void FUSB302B::start_source_cap_wait_(SourceCapStep step) { + this->waiting_for_source_caps_ = true; + this->source_cap_step_ = step; + this->source_cap_step_start_ = millis(); +} + +void FUSB302B::check_source_caps_() { + if (!this->waiting_for_source_caps_ || this->check_ams_()) + return; + const uint32_t elapsed = millis() - this->source_cap_step_start_; + switch (this->source_cap_step_) { + case SourceCapStep::SOURCE_CAP_STEP_WAIT: + if (elapsed < SOURCE_CAP_WAIT_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, requesting them"); + if (!this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP))) { + this->send_soft_reset_(); + return; + } + this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT; + break; + case SourceCapStep::SOURCE_CAP_STEP_GET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, sending soft reset"); + this->send_soft_reset_(); + return; + case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + this->source_caps_failed_(); + return; + } + this->source_cap_step_start_ = millis(); +} + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b.h b/esphome/components/fusb302b/fusb302b.h new file mode 100644 index 00000000000..e63944ebd07 --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.h @@ -0,0 +1,143 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +#include "pd.h" + +namespace esphome::fusb302b { + +/// FUSB302B USB Type-C / Power Delivery sink controller. +/// +/// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing +/// even while the main loop is busy. The main loop only publishes state changes. +class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { + public: + explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {} + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + /// Negotiate a new contract with the highest source voltage that does not exceed `voltage`. + /// Values outside 5-20 V are clamped with a warning. + void request_voltage(int voltage); + + /// Callbacks receive each new state in order, with the state before it. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + /// Called when a voltage request was rejected, got no answer, or was dropped because the component failed. + /// A contract already in place stays valid. + template void add_on_request_failed_callback(F &&callback) { + this->request_failed_callback_.add(std::forward(callback)); + } + +#ifdef USE_SENSOR + void set_voltage_sensor(sensor::Sensor *sensor) { this->voltage_sensor_ = sensor; } + void set_current_sensor(sensor::Sensor *sensor) { this->current_sensor_ = sensor; } +#endif +#ifdef USE_TEXT_SENSOR + void set_contract_text_sensor(text_sensor::TextSensor *sensor) { this->contract_text_sensor_ = sensor; } +#endif + + protected: + enum class SourceCapStep : uint8_t { + SOURCE_CAP_STEP_WAIT, + SOURCE_CAP_STEP_GET_SENT, + SOURCE_CAP_STEP_SOFT_RESET_SENT, + }; + + static void gpio_intr(FUSB302B *arg); + static void task_func(void *arg); + + bool send_message(const PdMsg &msg) override; + void on_state_changed() override; + void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); } + void on_source_caps_received() override { this->waiting_for_source_caps_ = false; } + void on_transition_timeout() override { this->send_soft_reset_(); } + void on_request_failed() override; + + // PHY task only + [[noreturn]] void run_task_(); + bool init_chip_(); + bool reset_pd_(); + bool enable_auto_crc_(); + void handle_interrupt_(); + bool read_message_(PdMsg &msg, bool &is_sop); + void try_attach_(); + bool measure_cc_(uint8_t meas_switch, uint8_t &level); + void detach_(); + void handle_hard_reset_(); + void start_source_cap_wait_(SourceCapStep step); + void start_negotiation_(); + void send_soft_reset_(); + void source_caps_failed_(); + void check_source_caps_(); + void enter_error_(); + + void publish_contract_(PackedContract contract); + void publish_state_(PdState state); + void queue_event_(uint8_t event); + + InternalGPIOPin *interrupt_pin_; + StaticTask task_; + + // PHY task only + uint32_t source_cap_step_start_{0}; + SourceCapStep source_cap_step_{SourceCapStep::SOURCE_CAP_STEP_WAIT}; + bool attached_{false}; + bool waiting_for_source_caps_{false}; + bool cc_ambiguous_logged_{false}; + bool request_pending_{false}; + // Last state put in the queue + PdState queued_state_{PdState::PD_STATE_DISCONNECTED}; + + // State changes and request failures from the PHY task, replayed in order by loop() so no event is lost while the + // main loop is blocked. An entry is a PdState value or EVENT_REQUEST_FAILED. The PHY task only writes head, the + // main loop only writes tail. + static constexpr uint8_t EVENT_QUEUE_SIZE = 8; + static constexpr uint8_t EVENT_REQUEST_FAILED = 0xFF; + std::array event_queue_{}; + std::atomic event_queue_head_{0}; + std::atomic event_queue_tail_{0}; + std::atomic event_queue_overflow_{false}; + // Request failures that did not fit in the queue + std::atomic dropped_request_failures_{0}; + + // Main loop only + PdState published_state_{PdState::PD_STATE_DISCONNECTED}; + uint32_t published_contract_{UINT32_MAX}; + + LazyCallbackManager state_callback_; + LazyCallbackManager request_failed_callback_; +#ifdef USE_SENSOR + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *contract_text_sensor_{nullptr}; +#endif +}; + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b_registers.h b/esphome/components/fusb302b/fusb302b_registers.h new file mode 100644 index 00000000000..3efaf57215f --- /dev/null +++ b/esphome/components/fusb302b/fusb302b_registers.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +namespace esphome::fusb302b { + +// Register addresses +static constexpr uint8_t FUSB_DEVICE_ID = 0x01; +static constexpr uint8_t FUSB_SWITCHES0 = 0x02; +static constexpr uint8_t FUSB_SWITCHES1 = 0x03; +static constexpr uint8_t FUSB_MEASURE = 0x04; +static constexpr uint8_t FUSB_CONTROL0 = 0x06; +static constexpr uint8_t FUSB_CONTROL1 = 0x07; +static constexpr uint8_t FUSB_CONTROL3 = 0x09; +static constexpr uint8_t FUSB_MASK1 = 0x0A; +static constexpr uint8_t FUSB_POWER = 0x0B; +static constexpr uint8_t FUSB_RESET = 0x0C; +static constexpr uint8_t FUSB_MASKA = 0x0E; +static constexpr uint8_t FUSB_MASKB = 0x0F; +static constexpr uint8_t FUSB_STATUS0A = 0x3C; +static constexpr uint8_t FUSB_STATUS0 = 0x40; +static constexpr uint8_t FUSB_STATUS1 = 0x41; +static constexpr uint8_t FUSB_FIFOS = 0x43; + +// DEVICE_ID: version in bits 7:4, product variant (I2C address 0x22-0x25) in bits 3:2, silicon revision in bits 1:0 +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_MASK = 0xF0; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_A = 0x80; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_B = 0x90; + +// SWITCHES0 bits +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC2 = 1 << 3; +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC1 = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_1 = 1 << 0; + +// SWITCHES1 bits +static constexpr uint8_t FUSB_SWITCHES1_SPECREV_REV2 = 0x01 << 5; +static constexpr uint8_t FUSB_SWITCHES1_AUTO_CRC = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES1_TXCC2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES1_TXCC1 = 1 << 0; + +// MEASURE value used for CC detection +static constexpr uint8_t FUSB_MEASURE_MDAC_CC = 49; + +// CONTROL0 bits +static constexpr uint8_t FUSB_CONTROL0_TX_FLUSH = 1 << 6; + +// CONTROL1 bits +static constexpr uint8_t FUSB_CONTROL1_RX_FLUSH = 1 << 2; + +// CONTROL3 bits +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_SHIFT = 1; +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_MASK = 0x3 << FUSB_CONTROL3_N_RETRIES_SHIFT; +static constexpr uint8_t FUSB_CONTROL3_AUTO_RETRY = 1 << 0; + +// MASK1: mask the ACTIVITY, CRC_CHK and BC_LVL interrupts +static constexpr uint8_t FUSB_MASK1_DEFAULT = 0x51; + +// POWER bits +static constexpr uint8_t FUSB_POWER_ALL = 0x0F; + +// RESET bits +static constexpr uint8_t FUSB_RESET_PD_RESET = 1 << 1; +static constexpr uint8_t FUSB_RESET_SW_RES = 1 << 0; + +// INTERRUPTA bits +static constexpr uint8_t FUSB_INTERRUPTA_I_RETRYFAIL = 1 << 4; +static constexpr uint8_t FUSB_INTERRUPTA_I_SOFTRST = 1 << 1; +static constexpr uint8_t FUSB_INTERRUPTA_I_HARDRST = 1 << 0; + +// INTERRUPT bits +static constexpr uint8_t FUSB_INTERRUPT_I_VBUSOK = 1 << 7; + +// STATUS0 bits +static constexpr uint8_t FUSB_STATUS0_VBUSOK = 1 << 7; +static constexpr uint8_t FUSB_STATUS0_BC_LVL = 0x03; + +// STATUS1 bits +static constexpr uint8_t FUSB_STATUS1_RX_EMPTY = 1 << 5; + +// RX FIFO token +static constexpr uint8_t FUSB_FIFO_RX_TOKEN_BITS = 0xE0; +static constexpr uint8_t FUSB_FIFO_RX_SOP = 0xE0; + +// TX FIFO tokens +static constexpr uint8_t FUSB_TX_TOKEN_TXON = 0xA1; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC1 = 0x12; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC2 = 0x13; +static constexpr uint8_t FUSB_TX_TOKEN_PACKSYM = 0x80; +static constexpr uint8_t FUSB_TX_TOKEN_JAM_CRC = 0xFF; +static constexpr uint8_t FUSB_TX_TOKEN_EOP = 0x14; +static constexpr uint8_t FUSB_TX_TOKEN_TXOFF = 0xFE; + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.cpp b/esphome/components/fusb302b/pd.cpp new file mode 100644 index 00000000000..28d8e35fe9b --- /dev/null +++ b/esphome/components/fusb302b/pd.cpp @@ -0,0 +1,337 @@ +#include "pd.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b.pd"); + +static constexpr uint32_t AMS_TIMEOUT_MS = 2000; +// tSinkRequest: delay before repeating a request that the source answered with Wait +static constexpr uint32_t SINK_REQUEST_MS = 100; +static constexpr uint8_t MAX_WAIT_RETRIES = 10; + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object flags; only the first (5 V) object carries them +static constexpr uint32_t SINK_PDO_HIGHER_CAPABILITY = 1u << 28; +static constexpr uint32_t SINK_PDO_USB_COMMS_CAPABLE = 1u << 26; +// Operational current advertised for every sink object: 3 A (300 x 10 mA) +static constexpr uint16_t SINK_PDO_CURRENT = 300; + +// USB PD 6.4.1.2.3 Source Fixed Supply Power Data Object; other PDO types only report their type +static PdContract parse_pdo(uint32_t pdo) { + PdContract info{}; + info.type = static_cast(pdo >> 30); + if (info.type == PD_PDO_TYPE_FIXED_SUPPLY) { + info.max_v = (pdo >> 10) & 0x3FF; + info.max_i = pdo & 0x3FF; + } + return info; +} + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object; voltage in 50 mV units, current in 10 mA units +static constexpr uint32_t sink_fixed_pdo(uint16_t voltage, uint16_t current) { + return (static_cast(PD_PDO_TYPE_FIXED_SUPPLY) << 30) | (static_cast(voltage & 0x3FF) << 10) | + (current & 0x3FF); +} + +void PdMsg::set_header(uint16_t header) { + this->type = header & 0x1F; + this->spec_rev = static_cast((header >> 6) & 0x3); + this->id = (header >> 9) & 0x7; + this->num_of_obj = (header >> 12) & 0x7; + this->extended = (header >> 15) & 0x1; +} + +uint16_t PdMsg::get_coded_header() const { + // Data role UFP (bit 5 = 0), power role sink (bit 8 = 0) + return static_cast(this->type & 0x1F) | (static_cast(this->spec_rev) << 6) | + (static_cast(this->id & 0x7) << 9) | (static_cast(this->num_of_obj & 0x7) << 12) | + (static_cast(this->extended) << 15); +} + +PdMsg PowerDelivery::make_control_msg_(PdControlMsgType type) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + return msg; +} + +PdMsg PowerDelivery::make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + msg.num_of_obj = len; + memcpy(msg.data_objects, objects, len * sizeof(uint32_t)); + return msg; +} + +void PowerDelivery::reset_protocol_() { + this->last_received_msg_id_ = 255; + this->msg_counter_ = 0; + this->active_ams_ = false; + this->ps_rdy_expected_ = false; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; +} + +void PowerDelivery::set_state_(PdState state) { + if (this->state_.exchange(state) != state) + this->on_state_changed(); +} + +void PowerDelivery::set_contract_(const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + if (this->contract_.exchange(packed) != packed) + this->on_state_changed(); +} + +void PowerDelivery::clear_contract_() { + this->accepted_contract_ = {}; + if (this->contract_.exchange(0) != 0) + this->on_state_changed(); +} + +void PowerDelivery::set_state_and_contract_(PdState state, const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + // The contract is stored first, so a reader that sees the new state also sees the new contract + const bool contract_changed = this->contract_.exchange(packed) != packed; + const bool state_changed = this->state_.exchange(state) != state; + if (contract_changed || state_changed) + this->on_state_changed(); +} + +void PowerDelivery::set_ams_(bool active) { + this->active_ams_ = active; + // A new sequence has no accepted request yet, and an ended one can no longer complete + this->ps_rdy_expected_ = false; + if (active) + this->ams_start_ = millis(); +} + +bool PowerDelivery::check_ams_() { + if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) { + this->set_ams_(false); + // Every sequence carries a request. In TRANSITION, check_transition_timeout_() handles the missing PS_RDY. + if (this->state_ != PdState::PD_STATE_TRANSITION) { + ESP_LOGW(TAG, "Source did not complete the request in time"); + this->request_failed_(); + } + } + return this->active_ams_; +} + +void PowerDelivery::check_request_retry_() { + if (!this->retry_request_pending_ || millis() - this->wait_received_ms_ < SINK_REQUEST_MS) + return; + if (this->send_request_()) { + this->retry_request_pending_ = false; + } else if (this->wait_retries_ < MAX_WAIT_RETRIES) { + // Try again after another tSinkRequest; failed attempts count against the same limit as Wait replies + this->wait_retries_++; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Giving up repeating the request"); + this->retry_request_pending_ = false; + this->request_failed_(); + } +} + +bool PowerDelivery::send_request_() { + this->set_ams_(true); + if (this->send_message(this->make_data_msg_(PD_DATA_REQUEST, &this->last_rdo_, 1))) + return true; + ESP_LOGW(TAG, "Sending the request failed"); + this->set_ams_(false); + return false; +} + +void PowerDelivery::request_failed_() { + // A contract already in place stays valid. Without one, report that negotiation is over at default power. + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + this->on_request_failed(); +} + +void PowerDelivery::check_transition_timeout_() { + // The AMS that sent the request only ends on PS_RDY, so an ended AMS while in transition means a timeout + if (this->state_ != PdState::PD_STATE_TRANSITION || this->check_ams_()) + return; + ESP_LOGW(TAG, "Source did not report the new supply ready, falling back to default power"); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_transition_timeout(); +} + +void PowerDelivery::handle_transmit_failed_() { + // USB PD 6.12.2.2: the counter also moves on after a failed transmission. The source may have received the + // message and only its GoodCRC was lost, so reusing the MessageID would make it drop the next one as a repeat. + ESP_LOGD(TAG, "Message %u not acknowledged", this->msg_counter_ % 8); + this->msg_counter_++; +} + +void PowerDelivery::handle_message_(const PdMsg &msg) { + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_GOODCRC) { + // A GoodCRC carries the MessageID it acknowledges; a duplicate or stray one must not advance the counter + if (msg.id != this->msg_counter_ % 8) { + ESP_LOGD(TAG, "Ignoring GoodCRC for MessageID %u, expected %u", msg.id, this->msg_counter_ % 8); + return; + } + // Our last message was received, so the next one gets a new MessageID + this->msg_counter_++; + return; + } + // Soft_Reset always has MessageID 0, so it must not be dropped as a retransmission of the last message + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_SOFT_RESET) { + this->handle_soft_reset_(msg); + return; + } + // Retransmissions repeat the MessageID of a message that was already handled + if (msg.id == this->last_received_msg_id_) + return; + this->last_received_msg_id_ = msg.id; + + if (msg.num_of_obj == 0) { + this->handle_control_message_(msg); + } else { + this->handle_data_message_(msg); + } +} + +void PowerDelivery::handle_data_message_(const PdMsg &msg) { + if (msg.extended || msg.type != PD_DATA_SOURCE_CAP) { + // BIST and Vendor_Defined messages must not be answered with Reject, so unsupported data messages are ignored + ESP_LOGV(TAG, "Ignoring data message type %u%s", msg.type, + msg.extended ? LOG_STR_LITERAL(" (extended)") : LOG_STR_LITERAL("")); + return; + } + this->on_source_caps_received(); + this->respond_to_source_caps_(msg); +} + +void PowerDelivery::handle_control_message_(const PdMsg &msg) { + switch (msg.type) { + case PD_CNTRL_ACCEPT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Accept outside a request"); + break; + } + this->ps_rdy_expected_ = true; + if (this->requested_contract_ != this->accepted_contract_) + this->set_state_and_contract_(PdState::PD_STATE_TRANSITION, this->requested_contract_); + break; + case PD_CNTRL_PS_RDY: + // check_ams_() first, so a PS_RDY that arrives after the sequence timed out is ignored too + if (!this->check_ams_() || !this->ps_rdy_expected_) { + ESP_LOGD(TAG, "Ignoring PS_RDY without an accepted request"); + break; + } + this->set_ams_(false); + this->set_state_(PdState::PD_STATE_EXPLICIT_CONTRACT); + break; + case PD_CNTRL_REJECT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Reject outside a request"); + break; + } + ESP_LOGW(TAG, "Source rejected the request"); + this->set_ams_(false); + this->request_failed_(); + break; + case PD_CNTRL_WAIT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Wait outside a request"); + break; + } + this->set_ams_(false); + if (this->wait_retries_ < MAX_WAIT_RETRIES) { + ESP_LOGD(TAG, "Source asked to wait, repeating the request"); + this->wait_retries_++; + this->retry_request_pending_ = true; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Source kept answering Wait, giving up"); + this->request_failed_(); + } + break; + case PD_CNTRL_PING: + case PD_CNTRL_NOT_SUPPORTED: + break; + case PD_CNTRL_GET_SINK_CAP: { + // 5 V first, then the requested voltage. Higher Capability tells the source that 5 V is not enough for + // full operation. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + uint32_t pdos[2]; + uint8_t count = 0; + pdos[count++] = sink_fixed_pdo(100, SINK_PDO_CURRENT) | SINK_PDO_USB_COMMS_CAPABLE | + (request_v > 100 ? SINK_PDO_HIGHER_CAPABILITY : 0); + if (request_v > 100) + pdos[count++] = sink_fixed_pdo(request_v, SINK_PDO_CURRENT); + this->send_message(this->make_data_msg_(PD_DATA_SINK_CAP, pdos, count)); + break; + } + default: + // Messages are sent as PD 2.0, which has no Not_Supported message + this->send_message(this->make_control_msg_(PD_CNTRL_REJECT)); + break; + } +} + +void PowerDelivery::handle_soft_reset_(const PdMsg &msg) { + this->reset_protocol_(); + this->last_received_msg_id_ = msg.id; + // If this fails the source escalates to a hard reset. The hook still runs, so the PHY starts waiting for source + // capabilities and recovers on its own if neither follows. + if (!this->send_message(this->make_control_msg_(PD_CNTRL_ACCEPT))) { + ESP_LOGW(TAG, "Accepting the soft reset failed"); + } + // A soft reset keeps the power level, but the outcome of a transition in progress is unknown + if (this->state_ == PdState::PD_STATE_TRANSITION) + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_soft_reset_received(); +} + +void PowerDelivery::respond_to_source_caps_(const PdMsg &msg) { + // Pick the highest fixed supply PDO that does not exceed the requested voltage. + // The first PDO is always the 5 V fixed supply. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + PdContract selected{}; + uint8_t position = 0; // 1-based object position, 0 = none + for (uint8_t idx = 0; idx < msg.num_of_obj; idx++) { + PdContract info = parse_pdo(msg.data_objects[idx]); + if (info.type != PD_PDO_TYPE_FIXED_SUPPLY) + continue; + if (position == 0 || (info.max_v <= request_v && info.max_v > selected.max_v)) { + selected = info; + position = idx + 1; + } + } + + // USB PD 6.4.2 Request Data Object: no USB suspend (bit 24), USB communications capable (bit 25) + uint32_t rdo = (1u << 24) | (1u << 25); + if (position == 0) { + ESP_LOGW(TAG, "No usable PDO in source capabilities, requesting 5 V"); + // Object 1 (5 V fixed supply), 300 mA maximum, 100 mA operating current + selected = PdContract{PD_PDO_TYPE_FIXED_SUPPLY, 100, 30}; + position = 1; + rdo |= (30u << 0) | (10u << 10); + } else { + // Request the full current the source offers + const uint32_t current = selected.max_i; + rdo |= (current << 0) | (current << 10); + } + rdo |= static_cast(position) << 28; + ESP_LOGD(TAG, "Requesting PDO %u: %.2f V", position, selected.max_v * 0.05f); + this->requested_contract_ = selected; + this->last_rdo_ = rdo; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; + // A repeat would come too late: the source resets if it gets no Request within tSenderResponse (24-30 ms) + if (!this->send_request_()) + this->request_failed_(); +} + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h new file mode 100644 index 00000000000..b9712f1a673 --- /dev/null +++ b/esphome/components/fusb302b/pd.h @@ -0,0 +1,183 @@ +#pragma once + +#include +#include +#include +#include + +namespace esphome::fusb302b { + +static constexpr uint8_t PD_MAX_NUM_DATA_OBJECTS = 7; +// Standard Power Range fixed supplies, which are the only ones this sink negotiates +static constexpr uint8_t MIN_REQUEST_VOLTAGE = 5; +static constexpr uint8_t MAX_REQUEST_VOLTAGE = 20; + +enum PdSpecRevision : uint8_t { + PD_SPEC_REV_1 = 0, + PD_SPEC_REV_2 = 1, + PD_SPEC_REV_3 = 2, +}; + +enum PdDataMsgType : uint8_t { + PD_DATA_SOURCE_CAP = 0x01, + PD_DATA_REQUEST = 0x02, + PD_DATA_SINK_CAP = 0x04, +}; + +enum PdControlMsgType : uint8_t { + PD_CNTRL_GOODCRC = 0x01, + PD_CNTRL_ACCEPT = 0x03, + PD_CNTRL_REJECT = 0x04, + PD_CNTRL_PING = 0x05, + PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SOURCE_CAP = 0x07, + PD_CNTRL_GET_SINK_CAP = 0x08, + PD_CNTRL_WAIT = 0x0C, + PD_CNTRL_SOFT_RESET = 0x0D, + PD_CNTRL_NOT_SUPPORTED = 0x10, +}; + +enum PdPdoType : uint8_t { + PD_PDO_TYPE_FIXED_SUPPLY = 0, + PD_PDO_TYPE_BATTERY = 1, + PD_PDO_TYPE_VARIABLE_SUPPLY = 2, + PD_PDO_TYPE_AUGMENTED = 3, +}; + +enum class PdState : uint8_t { + PD_STATE_DISCONNECTED, + /// Negotiation is over without a PD contract (no capabilities, or the request was not accepted); the source + /// supplies Type-C default power. + PD_STATE_PD_TIMEOUT, + PD_STATE_DEFAULT_CONTRACT, + PD_STATE_TRANSITION, + PD_STATE_EXPLICIT_CONTRACT, + PD_STATE_ERROR, +}; + +/// Returns true for the states in which a source is attached and supplying power. +inline bool is_connected_state(PdState state) { + return state == PdState::PD_STATE_DEFAULT_CONTRACT || state == PdState::PD_STATE_TRANSITION || + state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT; +} + +struct PdContract { + PdPdoType type{PD_PDO_TYPE_FIXED_SUPPLY}; + uint16_t max_v{0}; // 50 mV units + uint16_t max_i{0}; // 10 mA units + + bool operator==(const PdContract &other) const { + return this->max_v == other.max_v && this->max_i == other.max_i && this->type == other.type; + } + bool operator!=(const PdContract &other) const { return !(*this == other); } +}; + +// Type-C default power: 5 V (100 x 50 mV) at 500 mA (50 x 10 mA) +static constexpr PdContract DEFAULT_CONTRACT{PD_PDO_TYPE_FIXED_SUPPLY, 100, 50}; + +/// A contract packed into one word so it can be shared between tasks as an atomic. +/// 0 means there is no contract (nothing attached). +struct PackedContract { + uint32_t value{0}; + + static PackedContract from(const PdContract &contract) { + return {(static_cast(contract.max_v) << 16) | contract.max_i}; + } + bool is_set() const { return this->value != 0; } + float voltage() const { return (this->value >> 16) * 0.05f; } + float current() const { return (this->value & 0xFFFF) * 0.01f; } +}; + +struct PdMsg { + uint8_t type{0}; + PdSpecRevision spec_rev{PD_SPEC_REV_2}; + uint8_t id{0}; + uint8_t num_of_obj{0}; + bool extended{false}; + uint32_t data_objects[PD_MAX_NUM_DATA_OBJECTS]{}; + + void set_header(uint16_t header); + uint16_t get_coded_header() const; +}; + +/// USB-PD sink protocol logic, independent of the PHY chip. +/// Everything except the getters and set_request_voltage() runs in the PHY task, which must also call +/// check_transition_timeout_() and check_request_retry_() regularly; the protocol has no timers of its own. +class PowerDelivery { + public: + void set_request_voltage(uint8_t voltage) { + this->request_voltage_ = std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + uint8_t get_request_voltage() const { return this->request_voltage_; } + + PdState get_state() const { return this->state_; } + bool is_connected() const { return is_connected_state(this->state_); } + + protected: + /// Returns false when the message could not be handed to the chip; the PHY logs the failure. + virtual bool send_message(const PdMsg &msg) = 0; + /// Called after state_ or contract_ changed, to let the main loop publish it. + virtual void on_state_changed() = 0; + /// Called after the source soft reset the protocol; it sends new source capabilities next. + virtual void on_soft_reset_received() = 0; + /// Called when source capabilities arrive, before the request for them is sent. + virtual void on_source_caps_received() = 0; + /// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power; + /// the PHY sends a soft reset so the source offers its capabilities again. + virtual void on_transition_timeout() = 0; + /// Called when a request ended without a new contract: it was rejected, the source kept answering Wait or did not + /// answer in time, or it could not be sent. A contract already in place stays valid. + virtual void on_request_failed() = 0; + + void handle_message_(const PdMsg &msg); + /// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC. + void handle_transmit_failed_(); + void reset_protocol_(); + void set_state_(PdState state); + void set_contract_(const PdContract &contract); + void clear_contract_(); + /// Stores both before calling on_state_changed() once, so the main loop never sees the new state with the old + /// contract. + void set_state_and_contract_(PdState state, const PdContract &contract); + void set_ams_(bool active); + /// Returns true while an atomic message sequence is in progress. Ends it after a timeout, which fails the + /// request unless the source already accepted a new contract. + bool check_ams_(); + /// Falls back to default power when the source accepted a request but did not report the new supply ready in time. + void check_transition_timeout_(); + /// Sends the last request again once tSinkRequest has passed after the source answered Wait. + void check_request_retry_(); + + PdMsg make_control_msg_(PdControlMsgType type) const; + PdMsg make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const; + + // Shared with the main loop + std::atomic state_{PdState::PD_STATE_DISCONNECTED}; + std::atomic contract_{0}; + std::atomic request_voltage_{5}; + + // PHY task only + uint32_t ams_start_{0}; + uint32_t wait_received_ms_{0}; + bool retry_request_pending_{false}; + + private: + void handle_data_message_(const PdMsg &msg); + void handle_control_message_(const PdMsg &msg); + void handle_soft_reset_(const PdMsg &msg); + void respond_to_source_caps_(const PdMsg &msg); + bool send_request_(); + void request_failed_(); + + PdContract requested_contract_{}; + PdContract accepted_contract_{}; + uint32_t last_rdo_{0}; + bool active_ams_{false}; + // Set by Accept; PS_RDY is only valid after the source accepted the request of the current sequence + bool ps_rdy_expected_{false}; + uint8_t last_received_msg_id_{255}; + uint8_t msg_counter_{0}; + uint8_t wait_retries_{0}; +}; + +} // namespace esphome::fusb302b diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 5e672c3e65d..59484aab16d 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -2,7 +2,10 @@ #include #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "hon_climate.h" #include "hon_packet.h" @@ -11,6 +14,26 @@ using namespace esphome::uart; namespace esphome::haier { +static bool vertical_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_VERTICAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool horizontal_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_HORIZONTAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool is_auto(hon_protocol::VerticalSwingMode mode) { + return (mode == hon_protocol::VerticalSwingMode::AUTO) || (mode == hon_protocol::VerticalSwingMode::AUTO_SPECIAL); +} + +static bool is_auto(hon_protocol::HorizontalSwingMode mode) { return mode == hon_protocol::HorizontalSwingMode::AUTO; } + +// A position the user just set wins over the appliance's echo of the old one for this long +constexpr uint32_t AIRFLOW_SELECT_DEBOUNCE_MS = 10000; +static bool in_debounce(uint32_t set_time, uint32_t now) { + return set_time != 0 && now - set_time < AIRFLOW_SELECT_DEBOUNCE_MS; +} + ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; @@ -20,6 +43,27 @@ constexpr size_t ALARM_STATUS_REQUEST_INTERVAL_MS = 600000; const uint8_t ONE_BUF[] = {0x00, 0x01}; const uint8_t ZERO_BUF[] = {0x00, 0x00}; +// Alarm texts indexed by alarm code; the last entry is the fallback for unknown codes +PROGMEM_STRING_TABLE( + HonAlarmMessages, "Outdoor module failure", "Outdoor defrost sensor failure", + "Outdoor compressor exhaust sensor failure", "Outdoor EEPROM abnormality", "Indoor coil sensor failure", + "Indoor-outdoor communication failure", "Power supply overvoltage protection", + "Communication failure between panel and indoor unit", "Outdoor compressor overheat protection", + "Outdoor environmental sensor abnormality", "Full water protection", "Indoor EEPROM failure", + "Outdoor out air sensor failure", "CBD and module communication failure", "Indoor DC fan failure", + "Outdoor DC fan failure", "Door switch failure", "Dust filter needs cleaning reminder", "Water shortage protection", + "Humidity sensor failure", "Indoor temperature sensor failure", "Manipulator limit failure", + "Indoor PM2.5 sensor failure", "Outdoor PM2.5 sensor failure", "Indoor heating overload/high load alarm", + "Outdoor AC current protection", "Outdoor compressor operation abnormality", "Outdoor DC current protection", + "Outdoor no-load failure", "CT current abnormality", "Indoor cooling freeze protection", + "High and low pressure protection", "Compressor out air temperature is too high", + "Outdoor evaporator sensor failure", "Outdoor cooling overload", "Water pump drainage failure", + "Three-phase power supply failure", "Four-way valve failure", "External alarm/scraper flow switch failure", + "Temperature cutoff protection alarm", "Different mode operation failure", "Electronic expansion valve failure", + "Dual heat source sensor Tw failure", "Communication failure with the wired controller", + "Indoor unit address duplication failure", "50Hz zero crossing failure", "Outdoor unit failure", + "Formaldehyde sensor failure", "VOC sensor failure", "CO2 sensor failure", "Firewall failure", "Unknown"); + HonClimate::HonClimate() : cleaning_status_(CleaningState::NO_CLEANING), got_valid_outdoor_temp_(false), active_alarms_{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -67,21 +111,60 @@ bool HonClimate::get_quiet_mode_state() const { } esphome::optional HonClimate::get_vertical_airflow() const { + if (!this->current_vertical_swing_.has_value()) { + return this->current_vertical_swing_; + } + // While the appliance swings, or right after the user chose a position, report the chosen position + if (is_auto(this->current_vertical_swing_.value()) || + in_debounce(this->vertical_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_vertiacal_swing; + } return this->current_vertical_swing_; -}; +} void HonClimate::set_vertical_airflow(hon_protocol::VerticalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Vertical AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_vertiacal_swing != direction) { + this->settings_.last_vertiacal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_vertical_direction_ = direction; this->force_send_control_ = true; + this->vertical_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } esphome::optional HonClimate::get_horizontal_airflow() const { + if (!this->current_horizontal_swing_.has_value()) { + return this->current_horizontal_swing_; + } + if (is_auto(this->current_horizontal_swing_.value()) || + in_debounce(this->horizontal_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_horizontal_swing; + } return this->current_horizontal_swing_; } void HonClimate::set_horizontal_airflow(hon_protocol::HorizontalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Horizontal AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_horizontal_swing != direction) { + this->settings_.last_horizontal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_horizontal_direction_ = direction; this->force_send_control_ = true; + this->horizontal_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } const char *HonClimate::get_cleaning_status_text() const { @@ -511,6 +594,18 @@ void HonClimate::initialization() { } else { this->settings_ = {hon_protocol::VerticalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::CENTER, true, false}; } + bool settings_fixed = false; + if (is_auto(this->settings_.last_vertiacal_swing)) { + this->settings_.last_vertiacal_swing = hon_protocol::VerticalSwingMode::CENTER; + settings_fixed = true; + } + if (is_auto(this->settings_.last_horizontal_swing)) { + this->settings_.last_horizontal_swing = hon_protocol::HorizontalSwingMode::CENTER; + settings_fixed = true; + } + if (settings_fixed) { + this->hon_rtc_.save(&this->settings_); + } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; @@ -646,12 +741,21 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { } } } + const ClimateSwingMode target_swing_mode = this->current_hvac_settings_.swing_mode.value_or(this->swing_mode); if (this->pending_vertical_direction_.has_value()) { - out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + if (!vertical_axis_is_auto(target_swing_mode)) { + out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + } else { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } this->pending_vertical_direction_.reset(); } if (this->pending_horizontal_direction_.has_value()) { - out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + if (!horizontal_axis_is_auto(target_swing_mode)) { + out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + } else { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } this->pending_horizontal_direction_.reset(); } { @@ -687,9 +791,17 @@ void HonClimate::process_alarm_message_(const uint8_t *packet, uint8_t size, boo if ((packet[2 + i] & alarm_bit) != (this->active_alarms_[i] & alarm_bit)) { bool alarm_status = (packet[2 + i] & alarm_bit) != 0; int log_level = alarm_status ? ESPHOME_LOG_LEVEL_WARN : ESPHOME_LOG_LEVEL_INFO; - const char *alarm_message = alarm_code < esphome::haier::hon_protocol::HON_ALARM_COUNT - ? esphome::haier::hon_protocol::HON_ALARM_MESSAGES[alarm_code].c_str() - : "Unknown"; +#ifdef USE_ESP8266 + // Static so a delayed on_alarm automation can still read it, but only until the next + // alarm change, including a later one in this packet; `code` stays correct + static char alarm_message[HonAlarmMessages::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(alarm_message, + reinterpret_cast( + HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX)), + sizeof(alarm_message)); +#else + const char *alarm_message = HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX); +#endif esp_log_printf_(log_level, TAG, __LINE__, "Alarm %s (%d): %s", alarm_status ? "activated" : "deactivated", alarm_code, alarm_message); if (alarm_status) { @@ -810,6 +922,41 @@ void HonClimate::set_quiet_mode_switch(switch_::Switch *sw) { } #endif // USE_SWITCH +#ifdef USE_SELECT +template +static void publish_airflow_index(select::Select *sel, const std::array &order, Mode mode) { + if (sel == nullptr) { + return; + } + // Saved settings never hold AUTO after initialization(); any other value outside the table shows as Center + size_t index = N; + size_t center = 0; + for (size_t i = 0; i < N; i++) { + const Mode entry = airflow_order_at(order, i); + if (entry == mode) { + index = i; + } else if (entry == Mode::CENTER) { + center = i; + } + } + if (index == N) { + index = center; + } + const auto active = sel->active_index(); + if (active.has_value() && *active == index) { + return; + } + sel->publish_state(index); +} + +void HonClimate::publish_airflow_selects_() { + publish_airflow_index(this->vertical_airflow_select_, VERTICAL_SWING_MODES_ORDER, + this->settings_.last_vertiacal_swing); + publish_airflow_index(this->horizontal_airflow_select_, HORIZONTAL_SWING_MODES_ORDER, + this->settings_.last_horizontal_swing); +} +#endif // USE_SELECT + haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) { size_t expected_size = 2 + this->status_message_header_size_ + this->real_control_packet_size_ + this->real_sensors_packet_size_; @@ -1044,16 +1191,25 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // Saving last known non auto mode for vertical and horizontal swing this->current_vertical_swing_ = (hon_protocol::VerticalSwingMode) packet.control.vertical_swing_mode; this->current_horizontal_swing_ = (hon_protocol::HorizontalSwingMode) packet.control.horizontal_swing_mode; - bool save_settings = ((this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO) && - (this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO_SPECIAL) && - (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing)) || - ((this->current_horizontal_swing_.value() != hon_protocol::HorizontalSwingMode::AUTO) && - (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing)); - if (save_settings) { - this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); - this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + const uint32_t now_ms = App.get_loop_component_start_time(); + bool save_vertical = !in_debounce(this->vertical_direction_set_time_, now_ms) && + !is_auto(this->current_vertical_swing_.value()) && + (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing); + bool save_horizontal = !in_debounce(this->horizontal_direction_set_time_, now_ms) && + !is_auto(this->current_horizontal_swing_.value()) && + (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing); + if (save_vertical || save_horizontal) { + if (save_vertical) { + this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); + } + if (save_horizontal) { + this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + } this->hon_rtc_.save(&this->settings_); } +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif should_publish = should_publish || (old_swing_mode != this->swing_mode); } this->last_valid_status_timestamp_ = std::chrono::steady_clock::now(); @@ -1229,8 +1385,10 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::SET_POINT, buffer, 2); } + ClimateSwingMode target_swing_mode = this->swing_mode; // Vertical swing mode if (climate_control.swing_mode.has_value()) { + target_swing_mode = climate_control.swing_mode.value(); uint8_t vertical_swing_buf[] = {0x00, (uint8_t) hon_protocol::VerticalSwingMode::AUTO}; uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) hon_protocol::HorizontalSwingMode::AUTO}; switch (climate_control.swing_mode.value()) { @@ -1256,6 +1414,32 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, vertical_swing_buf, 2); } + // A swing change above already sent both axes from settings_.last_*, which set_*_airflow() updated + const bool swing_sent = climate_control.swing_mode.has_value(); + if (this->pending_vertical_direction_.has_value()) { + if (vertical_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t vertical_swing_buf[] = {0x00, (uint8_t) this->pending_vertical_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, + vertical_swing_buf, 2); + } + this->pending_vertical_direction_.reset(); + } + if (this->pending_horizontal_direction_.has_value()) { + if (horizontal_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) this->pending_horizontal_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::HORIZONTAL_SWING_MODE, + horizontal_swing_buf, 2); + } + this->pending_horizontal_direction_.reset(); + } // Fan mode if (climate_control.fan_mode.has_value()) { switch (climate_control.fan_mode.value()) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a34b4422c69..db4ef4590bf 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #ifdef USE_SENSOR @@ -14,12 +15,36 @@ #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #include "esphome/core/automation.h" +#include "esphome/core/hal.h" #include "haier_base.h" #include "hon_packet.h" namespace esphome::haier { +#ifdef USE_SELECT +// Positions in the order the select platform lists them; in flash on ESP8266, so read with airflow_order_at() +inline constexpr std::array VERTICAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::VerticalSwingMode::HEALTH_UP, hon_protocol::VerticalSwingMode::MAX_UP, + hon_protocol::VerticalSwingMode::UP, hon_protocol::VerticalSwingMode::CENTER, + hon_protocol::VerticalSwingMode::DOWN, hon_protocol::VerticalSwingMode::MAX_DOWN, + hon_protocol::VerticalSwingMode::HEALTH_DOWN, +}; +inline constexpr std::array HORIZONTAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::HorizontalSwingMode::MAX_LEFT, hon_protocol::HorizontalSwingMode::LEFT, + hon_protocol::HorizontalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::RIGHT, + hon_protocol::HorizontalSwingMode::MAX_RIGHT, +}; + +template Mode airflow_order_at(const std::array &order, size_t index) { + static_assert(sizeof(Mode) == 1); + return static_cast(progmem_read_byte(reinterpret_cast(order.data() + index))); +} +#endif + enum class CleaningState : uint8_t { NO_CLEANING = 0, SELF_CLEAN = 1, @@ -100,6 +125,16 @@ class HonClimate final : public HaierClimateBase { protected: switch_::Switch *beeper_switch_{nullptr}; switch_::Switch *quiet_mode_switch_{nullptr}; +#endif +#ifdef USE_SELECT + public: + void set_vertical_airflow_select(select::Select *sel) { this->vertical_airflow_select_ = sel; } + void set_horizontal_airflow_select(select::Select *sel) { this->horizontal_airflow_select_ = sel; } + + protected: + void publish_airflow_selects_(); + select::Select *vertical_airflow_select_{nullptr}; + select::Select *horizontal_airflow_select_{nullptr}; #endif public: HonClimate(); @@ -195,6 +230,9 @@ class HonClimate final : public HaierClimateBase { uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; + // millis() of the last user-set position per axis, 0 when none + uint32_t vertical_direction_set_time_{0}; + uint32_t horizontal_direction_set_time_{0}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; diff --git a/esphome/components/haier/hon_packet.h b/esphome/components/haier/hon_packet.h index 63799f04ba1..1d17acb868a 100644 --- a/esphome/components/haier/hon_packet.h +++ b/esphome/components/haier/hon_packet.h @@ -197,60 +197,4 @@ enum class SubcommandsControl : uint16_t { // content: all values like in status packet) }; -const std::string HON_ALARM_MESSAGES[] = { - "Outdoor module failure", - "Outdoor defrost sensor failure", - "Outdoor compressor exhaust sensor failure", - "Outdoor EEPROM abnormality", - "Indoor coil sensor failure", - "Indoor-outdoor communication failure", - "Power supply overvoltage protection", - "Communication failure between panel and indoor unit", - "Outdoor compressor overheat protection", - "Outdoor environmental sensor abnormality", - "Full water protection", - "Indoor EEPROM failure", - "Outdoor out air sensor failure", - "CBD and module communication failure", - "Indoor DC fan failure", - "Outdoor DC fan failure", - "Door switch failure", - "Dust filter needs cleaning reminder", - "Water shortage protection", - "Humidity sensor failure", - "Indoor temperature sensor failure", - "Manipulator limit failure", - "Indoor PM2.5 sensor failure", - "Outdoor PM2.5 sensor failure", - "Indoor heating overload/high load alarm", - "Outdoor AC current protection", - "Outdoor compressor operation abnormality", - "Outdoor DC current protection", - "Outdoor no-load failure", - "CT current abnormality", - "Indoor cooling freeze protection", - "High and low pressure protection", - "Compressor out air temperature is too high", - "Outdoor evaporator sensor failure", - "Outdoor cooling overload", - "Water pump drainage failure", - "Three-phase power supply failure", - "Four-way valve failure", - "External alarm/scraper flow switch failure", - "Temperature cutoff protection alarm", - "Different mode operation failure", - "Electronic expansion valve failure", - "Dual heat source sensor Tw failure", - "Communication failure with the wired controller", - "Indoor unit address duplication failure", - "50Hz zero crossing failure", - "Outdoor unit failure", - "Formaldehyde sensor failure", - "VOC sensor failure", - "CO2 sensor failure", - "Firewall failure", -}; - -constexpr size_t HON_ALARM_COUNT = sizeof(HON_ALARM_MESSAGES) / sizeof(HON_ALARM_MESSAGES[0]); - } // namespace esphome::haier::hon_protocol diff --git a/esphome/components/haier/select/__init__.py b/esphome/components/haier/select/__init__.py new file mode 100644 index 00000000000..4198ba82081 --- /dev/null +++ b/esphome/components/haier/select/__init__.py @@ -0,0 +1,67 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import ( + CONF_HAIER_ID, + CONF_HORIZONTAL_AIRFLOW, + CONF_VERTICAL_AIRFLOW, + HonClimate, + haier_ns, +) + +CODEOWNERS = ["@paveldn"] +VerticalAirflowSelect = haier_ns.class_("VerticalAirflowSelect", select.Select) +HorizontalAirflowSelect = haier_ns.class_("HorizontalAirflowSelect", select.Select) + +# Same order as VERTICAL_SWING_MODES_ORDER and HORIZONTAL_SWING_MODES_ORDER in hon_climate.h +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = [ + "Health Up", + "Max Up", + "Up", + "Center", + "Down", + "Max Down", + "Health Down", +] + +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = [ + "Max Left", + "Left", + "Center", + "Right", + "Max Right", +] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HAIER_ID): cv.use_id(HonClimate), + cv.Optional(CONF_VERTICAL_AIRFLOW): select.select_schema( + VerticalAirflowSelect, + icon="mdi:arrow-expand-vertical", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_HORIZONTAL_AIRFLOW): select.select_schema( + HorizontalAirflowSelect, + icon="mdi:arrow-expand-horizontal", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_AIRFLOW, + hub.set_vertical_airflow_select, + options=AIRFLOW_VERTICAL_DIRECTION_OPTIONS, + ) + await selects( + CONF_HORIZONTAL_AIRFLOW, + hub.set_horizontal_airflow_select, + options=AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS, + ) diff --git a/esphome/components/haier/select/horizontal_airflow.h b/esphome/components/haier/select/horizontal_airflow.h new file mode 100644 index 00000000000..54f46bfcb19 --- /dev/null +++ b/esphome/components/haier/select/horizontal_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class HorizontalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_horizontal_airflow(airflow_order_at(HORIZONTAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/esphome/components/haier/select/vertical_airflow.h b/esphome/components/haier/select/vertical_airflow.h new file mode 100644 index 00000000000..81ac1b39d2c --- /dev/null +++ b/esphome/components/haier/select/vertical_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class VerticalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_vertical_airflow(airflow_order_at(VERTICAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py new file mode 100644 index 00000000000..68499d933e3 --- /dev/null +++ b/esphome/components/hdmi_cec/__init__.py @@ -0,0 +1,146 @@ +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components.esp32 import include_builtin_idf_component +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DATA, + CONF_ID, + CONF_ON_MESSAGE, + CONF_PIN, + CONF_SOURCE, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RP2, + PLATFORM_RTL87XX, +) +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@JosVanEijndhoven"] +DOMAIN = "hdmi_cec" + +MAX_LOGICAL_ADDRESS = 15 +MAX_PAYLOAD_LENGTH = 15 +CONF_PROMISCUOUS_MODE = "promiscuous_mode" +CONF_MONITOR_MODE = "monitor_mode" +CONF_DESTINATION = "destination" +CONF_OPCODE = "opcode" + +hdmi_cec_ns = cg.esphome_ns.namespace("hdmi_cec") +HDMICEC = hdmi_cec_ns.class_("HDMICEC", cg.Component) +_PAYLOAD_SPAN = cg.std_span.template(cg.uint8.operator("const")) +_PAYLOAD_VECTOR = cg.std_vector.template(cg.uint8) +_MESSAGE_ARGS = [ + (cg.uint8, "source"), + (cg.uint8, "destination"), + (_PAYLOAD_SPAN, "data"), +] + +_request_message_callback_slot = cg.slot_counter("HDMI_CEC_MESSAGE_CALLBACK_COUNT") + +logical_address = cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS) +payload = cv.All(cv.ensure_list(cv.hex_uint8_t), cv.Length(max=MAX_PAYLOAD_LENGTH)) +payload_prefix = cv.All(payload, cv.Length(min=1)) + + +def _validate_message_filter(config: ConfigType) -> ConfigType: + # The opcode is the first payload byte: fold it into the data prefix + if (opcode := config.pop(CONF_OPCODE, None)) is not None: + data = config.setdefault(CONF_DATA, [opcode]) + if data[0] != opcode: + raise cv.Invalid( + f"opcode 0x{opcode:02X} differs from the first data byte 0x{data[0]:02X}" + ) + return config + + +def _message_filter(conf: ConfigType) -> str | None: + """The C++ condition an on_message automation runs under, or None for every frame.""" + checks = [] + if (source := conf.get(CONF_SOURCE)) is not None: + checks.append(f"source == {source}") + if (destination := conf.get(CONF_DESTINATION)) is not None: + checks.append(f"destination == {destination}") + if data := conf.get(CONF_DATA): + checks.append(f"data.size() >= {len(data)}") + checks.extend(f"data[{i}] == 0x{byte:02X}" for i, byte in enumerate(data)) + return " && ".join(checks) or None + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDMICEC), + cv.Required(CONF_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_ADDRESS): logical_address, + cv.Optional(CONF_PROMISCUOUS_MODE, default=False): cv.boolean, + cv.Optional(CONF_MONITOR_MODE, default=False): cv.boolean, + cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( + { + cv.Optional(CONF_SOURCE): logical_address, + cv.Optional(CONF_DESTINATION): logical_address, + cv.Optional(CONF_OPCODE): cv.hex_uint8_t, + cv.Optional(CONF_DATA): payload_prefix, + }, + _validate_message_filter, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), +) + + +async def to_code(config: ConfigType) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + # The acknowledge bit is released from hardware timer 1 + use_timer1(DOMAIN) + if CORE.is_esp32: + # The acknowledge bit is released from a general purpose timer, left out of the build by default + include_builtin_idf_component("esp_driver_gptimer") + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + var = cg.new_Pvariable(config[CONF_ID], pin, config[CONF_ADDRESS]) + await cg.register_component(var, config) + if config[CONF_PROMISCUOUS_MODE]: + cg.add(var.set_promiscuous_mode(True)) + if config[CONF_MONITOR_MODE]: + cg.add(var.set_monitor_mode(True)) + + for conf in config.get(CONF_ON_MESSAGE, []): + _request_message_callback_slot() + await automation.build_callback_automation( + var, + "add_on_message_callback", + _MESSAGE_ARGS, + conf, + when=_message_filter(conf), + ) + + +automation.register_apply_action( + "hdmi_cec.send", + cv.Schema( + { + cv.GenerateID(): cv.use_id(HDMICEC), + cv.Required(CONF_DESTINATION): cv.templatable(logical_address), + cv.Required(CONF_DATA): cv.templatable(payload), + } + ), + automation.ApplyCall( + "send({}, {})", + ((CONF_DESTINATION, cg.uint8), (CONF_DATA, _PAYLOAD_VECTOR)), + ), +) diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp new file mode 100644 index 00000000000..82e354dbae7 --- /dev/null +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -0,0 +1,450 @@ +#include "hdmi_cec.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome::hdmi_cec { + +ESPHOME_LOG_TAG(TAG, "hdmi_cec"); + +#if defined(USE_ESP8266) || defined(USE_RP2040) +// The timer interrupt of these platforms carries no argument +static HDMICEC *ack_timer_owner = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +// Bit timing from the HDMI 1.4 CEC specification, in microseconds +static constexpr uint32_t START_BIT_MIN_US = 3500; // a low period at least this long is a start bit +static constexpr uint32_t START_BIT_NOM_US = 3700; +static constexpr uint32_t START_BIT_HIGH_US = 800; +static constexpr uint32_t HIGH_BIT_MIN_US = 400; // shortest low period of a logical 1 +static constexpr uint32_t HIGH_BIT_US = 600; // nominal low period of a logical 1 +static constexpr uint32_t LOW_BIT_US = 1500; // nominal low period of a logical 0 +static constexpr uint32_t SAFE_SAMPLE_US = 1050; // the line is sampled here; a longer low period is a logical 0 +static constexpr uint32_t TOTAL_BIT_US = 2400; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_RECEIVE = TOTAL_BIT_US * 5; // also for a new initiator +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_FAIL = TOTAL_BIT_US * 3; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS = TOTAL_BIT_US * 7; +static constexpr uint32_t ABORTED_FRAME_US = TOTAL_BIT_US * 15; // no edge for this long: our frame was cut short +static constexpr uint8_t MAX_ATTEMPTS = 8; // CEC allows 5, but a busy bus counts as an attempt here too + +Frame::Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload) { + this->data_[0] = ((initiator_addr & 0xf) << 4) | (destination_addr & 0xf); + this->size_ = 1 + payload.size(); + std::copy(payload.begin(), payload.end(), this->data_.begin() + 1); +} + +void HDMICEC::setup() { + // The line is open drain with a weak pull-up: driven low to send, released to read + this->pin_->setup(); + this->isr_pin_ = this->pin_->to_isr(); + this->set_pin_input_high_(); + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + if (!this->setup_ack_timer_()) { + ESP_LOGE(TAG, "Failed to set up the acknowledge timer"); + this->mark_failed(); + return; + } + this->pin_->attach_interrupt(HDMICEC::gpio_isr_s, this, gpio::INTERRUPT_ANY_EDGE); + // loop() only has work once the interrupt queues a frame or send() queues one + this->disable_loop(); +} + +void HDMICEC::dump_config() { + ESP_LOGCONFIG(TAG, + "HDMI-CEC:\n" + " Address: 0x%X\n" + " Monitor mode: %s\n" + " Promiscuous mode: %s", + this->address_, YESNO(this->monitor_mode_), YESNO(this->promiscuous_mode_)); + LOG_PIN(" Pin: ", this->pin_); +} + +void HDMICEC::loop() { + const uint8_t dropped = atomic_get(this->dropped_frames_); + if (dropped != this->reported_dropped_frames_) { + const uint8_t count = dropped - this->reported_dropped_frames_; + ESP_LOGW(TAG, "%u received frame%s dropped (queue full or oversized)", count, + count == 1 ? LOG_STR_LITERAL("") : LOG_STR_LITERAL("s")); + this->reported_dropped_frames_ = dropped; + } + // One inbound frame per pass keeps the time spent in the automations bounded + if (const Frame *frame = this->frames_queue_.front()) { +#ifdef ESPHOME_LOG_HAS_DEBUG + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); +#endif +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); +#endif + this->frames_queue_.pop_front(); + } + // A frame being sent stays at the front of the queue until its attempts are over + if (!this->send_queue_.is_empty()) { + this->transmit_message_(); + } + if (this->frames_queue_.is_empty() && this->send_queue_.is_empty()) { + this->disable_loop(); + } +} + +bool HDMICEC::send(uint8_t source, uint8_t destination, std::span payload) { + if (this->monitor_mode_) { + ESP_LOGW(TAG, "Monitor mode, not sending 0x%X->0x%X", source, destination); + return false; + } + if (source > 0xf || destination > 0xf) { + ESP_LOGE(TAG, "Send abort: logical address 0x%X->0x%X out of range", source, destination); + return false; + } + if (payload.size() > Frame::MAX_PAYLOAD_LENGTH) { + ESP_LOGE(TAG, "Send abort 0x%X->0x%X: payload of %zu bytes exceeds %zu", source, destination, payload.size(), + Frame::MAX_PAYLOAD_LENGTH); + return false; + } + Frame *frame = this->send_queue_.back(); + if (frame == nullptr) { + ESP_LOGW(TAG, "Send queue full, frame 0x%X->0x%X dropped", source, destination); + return false; + } + *frame = Frame(source, destination, payload); + this->send_queue_.push_back(); + this->enable_loop(); + return true; +} + +void HDMICEC::transmit_message_() { + const uint32_t now = micros(); + const uint32_t since_last_edge = now - atomic_get(this->last_edge_us_); + if (this->transmitting_) { + // The interrupt sees our own frame; wait for its end of message bit, or give up when the + // bus went quiet without one + if (!atomic_get(this->eom_received_) && since_last_edge < ABORTED_FRAME_US) { + return; + } + this->transmitting_ = false; + atomic_set(this->eom_received_, false); // the end of our own frame is not a foreign one + const Frame *frame = this->send_queue_.front(); + // A broadcast is acknowledged by nobody; a directed frame on every byte + const bool bytes_ok = this->n_bytes_received_ == frame->size(); + const bool acks_ok = this->n_acks_received_ == (frame->is_broadcast() ? 0 : frame->size()); + if (bytes_ok && acks_ok) { + ESP_LOGD(TAG, "Send frame success in %u attempt%s", this->transmit_attempts_, + this->transmit_attempts_ > 1 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL("")); + } else if (!bytes_ok) { + ESP_LOGD(TAG, "Send frame incorrect on attempt %u, saw %u bytes but expected %zu", this->transmit_attempts_, + this->n_bytes_received_, frame->size()); + } else { + ESP_LOGD(TAG, "Send frame not acknowledged on attempt %u", this->transmit_attempts_); + } + this->finish_attempt_(bytes_ok && acks_ok); + } else if (atomic_get(this->eom_received_)) { + // Another device finished a frame; the standard asks a new initiator to wait after it + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + atomic_set(this->eom_received_, false); + } + + const Frame *frame = this->send_queue_.front(); + // An edge within the signal free time means the bus is in use or not yet free for us; a start + // bit refused by a held line drives no edge, so that attempt is timed on its own + if (frame == nullptr || since_last_edge < this->required_idle_period_ || + now - this->last_attempt_us_ < this->required_idle_period_) { + return; + } + + this->last_attempt_us_ = now; + this->transmit_attempts_++; +#ifdef ESPHOME_LOG_HAS_DEBUG + if (this->transmit_attempts_ == 1) { + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Sending: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); + } +#endif + // The interrupt counts the bytes and acknowledge bits of our own frame + this->n_bytes_received_ = 0; + this->n_acks_received_ = 0; + atomic_set(this->eom_received_, false); + this->transmitting_ = true; + if (!this->transmit_frame_(frame)) { + // The line was held, or another initiator sent at the same time + this->transmitting_ = false; + this->finish_attempt_(false); + } + // Otherwise the acknowledge result arrives through the interrupt +} + +void HDMICEC::finish_attempt_(bool sent_ok) { + this->required_idle_period_ = sent_ok ? SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS : SIGNAL_FREE_TIME_AFTER_XMIT_FAIL; + if (!sent_ok) { + if (this->transmit_attempts_ < MAX_ATTEMPTS) { + return; // the signal free time has to pass before the retry + } + ESP_LOGW(TAG, "Frame not sent after %u attempts, dropped", this->transmit_attempts_); + } + this->transmit_attempts_ = 0; + this->send_queue_.pop_front(); +} + +bool HDMICEC::transmit_frame_(const Frame *frame) { + if (!this->send_start_bit_()) { + return false; + } + const auto bytes = frame->bytes(); + for (size_t byte_inx = 0; byte_inx < bytes.size(); byte_inx++) { + const uint8_t current_byte = bytes[byte_inx]; + // Most significant bit first. The four initiator address bits watch for a collision with + // another initiator that started at the same moment; a lower address wins by holding the line + for (int8_t i = 7; i >= 0; i--) { + const bool bit_value = (current_byte >> i) & 0x1; + if (!this->send_bit_(bit_value) && byte_inx == 0 && i >= 4) { + ESP_LOGD(TAG, "Bus collision on sending initiator address 0x%X on attempt %u", frame->initiator_addr(), + this->transmit_attempts_); + return false; + } + } + // End of message bit, then the acknowledge bit which the receiver may pull low + this->send_bit_(byte_inx == bytes.size() - 1); + this->send_bit_(true); + } + return true; +} + +bool HDMICEC::send_start_bit_() { + if (!this->isr_pin_.digital_read()) { + ESP_LOGD(TAG, "Bus occupied before start bit on attempt %u", this->transmit_attempts_); + return false; + } + this->set_pin_output_low_(); + delay_microseconds_safe(START_BIT_NOM_US); + // Release the line and check that nobody else keeps it low + this->set_pin_input_high_(); + delay_microseconds_safe(START_BIT_HIGH_US / 2); + bool value = this->isr_pin_.digital_read(); + delay_microseconds_safe(START_BIT_HIGH_US / 2); + value &= this->isr_pin_.digital_read(); + if (!value) { + ESP_LOGD(TAG, "Bus collision on sending start bit on attempt %u", this->transmit_attempts_); + } + return value; +} + +bool HDMICEC::send_bit_(bool bit_value) { + const uint32_t start_us = micros(); + this->set_pin_output_low_(); + delay_microseconds_safe(bit_value ? HIGH_BIT_US : LOW_BIT_US); + this->set_pin_input_high_(); + bool clear = true; + if (bit_value) { + // Sample in the middle of the safe sample period: a line still low means another initiator + // is sending a logical 0 at the same time + uint32_t elapsed = micros() - start_us; + if (elapsed < SAFE_SAMPLE_US) { + delay_microseconds_safe(SAFE_SAMPLE_US - elapsed); + } + clear = this->isr_pin_.digital_read(); + } + const uint32_t elapsed = micros() - start_us; + if (elapsed < TOTAL_BIT_US) { + delay_microseconds_safe(TOTAL_BIT_US - elapsed); + } + return clear; +} + +void IRAM_ATTR HDMICEC::send_ack_() { + // The acknowledge has to start right at this falling edge; the timer releases the line + this->set_pin_output_low_(); + this->start_ack_timer_(); +} + +#if defined(USE_ESP32) +bool HDMICEC::setup_ack_timer_() { + gptimer_config_t config = { + .clk_src = GPTIMER_CLK_SRC_DEFAULT, + .direction = GPTIMER_COUNT_UP, + .resolution_hz = 1000000, // one tick per microsecond + }; + if (gptimer_new_timer(&config, &this->ack_timer_) != ESP_OK) { + return false; + } + gptimer_event_callbacks_t callbacks = {.on_alarm = HDMICEC::ack_timer_isr_s}; + // The alarm reloads the count to zero itself, so the interrupt only has to start the timer + gptimer_alarm_config_t alarm = { + .alarm_count = LOW_BIT_US, .reload_count = 0, .flags = {.auto_reload_on_alarm = true}}; + return gptimer_register_event_callbacks(this->ack_timer_, &callbacks, this) == ESP_OK && + gptimer_set_alarm_action(this->ack_timer_, &alarm) == ESP_OK && gptimer_enable(this->ack_timer_) == ESP_OK; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { gptimer_start(this->ack_timer_); } + +bool IRAM_ATTR HDMICEC::ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg) { + auto *self = static_cast(arg); + self->set_pin_input_high_(); + gptimer_stop(timer); + return false; // no task to wake +} + +#elif defined(USE_ESP8266) +static constexpr uint32_t TIMER1_TICKS_PER_US = 5; // TIM_DIV16 of the 80 MHz clock + +bool HDMICEC::setup_ack_timer_() { + ack_timer_owner = this; + timer1_isr_init(); + timer1_attachInterrupt(HDMICEC::ack_timer_isr_s); + timer1_enable(TIM_DIV16, TIM_EDGE, TIM_SINGLE); // a write arms one shot + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { timer1_write(LOW_BIT_US * TIMER1_TICKS_PER_US); } + +void IRAM_ATTR HDMICEC::ack_timer_isr_s() { ack_timer_owner->set_pin_input_high_(); } + +#elif defined(USE_RP2040) +bool HDMICEC::setup_ack_timer_() { + this->ack_alarm_ = hardware_alarm_claim_unused(false); + if (this->ack_alarm_ < 0) { + return false; // every hardware alarm is taken + } + ack_timer_owner = this; + hardware_alarm_set_callback(this->ack_alarm_, HDMICEC::ack_timer_isr_s); + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { + hardware_alarm_set_target(this->ack_alarm_, make_timeout_time_us(LOW_BIT_US)); +} + +void IRAM_ATTR HDMICEC::ack_timer_isr_s(uint alarm_num) { ack_timer_owner->set_pin_input_high_(); } + +#else +bool HDMICEC::setup_ack_timer_() { return true; } + +// Without a one-shot timer the interrupt holds the line itself +void IRAM_ATTR HDMICEC::start_ack_timer_() { + delay_microseconds_safe(LOW_BIT_US); + this->set_pin_input_high_(); +} +#endif + +void IRAM_ATTR HDMICEC::gpio_isr_s(HDMICEC *self) { self->gpio_isr_(); } + +void IRAM_ATTR HDMICEC::gpio_isr_() { + const uint32_t now = micros(); + const bool level = this->isr_pin_.digital_read(); + if (level == this->prev_pin_level_) { + return; // a mode change of the pin, not a bus edge + } + this->prev_pin_level_ = level; + atomic_set(this->last_edge_us_, now); + + if (!level) { + // Falling edge: the start of a low period + if (now - this->last_falling_edge_us_ > START_BIT_NOM_US + TOTAL_BIT_US) { + // The bus was quiet; whatever was gathered belongs to an aborted frame + this->reset_receiver_(); + } + this->last_falling_edge_us_ = now; + if (this->recv_ack_queued_) { + // Acknowledge by stretching this low period + this->send_ack_(); + this->recv_ack_queued_ = false; + } + return; + } + + // Rising edge: the low period tells the bit value + const uint32_t pulse_duration = now - this->last_falling_edge_us_; + if (pulse_duration > START_BIT_MIN_US) { + this->reset_receiver_(); + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + // Without a free slot the frame is neither acknowledged nor delivered, so its initiator retries + this->recv_frame_ = this->frames_queue_.back(); + if (this->recv_frame_ != nullptr) { + this->recv_frame_->clear(); + } + return; + } + if (pulse_duration < HIGH_BIT_MIN_US / 4) { + return; // glitch + } + const bool value = pulse_duration <= SAFE_SAMPLE_US; + // Our own frame is on the bus until its end of message; send() may give it any initiator address + const bool own_frame = this->transmitting_ && !atomic_get(this->eom_received_); + + switch (this->receiver_state_) { + case ReceiverState::RECEIVER_STATE_IDLE: + break; + + case ReceiverState::RECEIVER_STATE_RECEIVING_BYTE: + this->recv_byte_ = (this->recv_byte_ << 1) | value; + if (++this->recv_bit_counter_ == 8) { + if (this->recv_length_++ == 0) { + this->recv_header_ = this->recv_byte_; + } + if (this->recv_frame_ != nullptr && !this->recv_frame_->push_back(this->recv_byte_)) { + this->recv_frame_ = nullptr; // longer than the bus allows: corrupt, do not deliver it + } + this->recv_bit_counter_ = 0; + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM; + } + break; + + case ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM: + this->recv_eom_ = value; + // A frame for us is acknowledged on the next bit, unless there is no room for it (so its + // initiator retries) or it is our own, which includes a polling message from a device + // claiming our logical address. A polling message for us (header only) needs no room, and + // goes unacknowledged only when nobody holds the address + if (!this->monitor_mode_ && !own_frame && (this->recv_frame_ != nullptr || (value && this->recv_length_ == 1)) && + (this->recv_header_ & 0xf) == this->address_ && (this->recv_header_ >> 4) != this->address_) { + this->recv_ack_queued_ = true; + } + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK; + break; + + case ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK: + if (own_frame) { + // A low acknowledge bit means the addressed device took the byte + // (for a broadcast it means some device refused it) + this->n_bytes_received_++; + if (!value) { + this->n_acks_received_++; + } + } + if (!this->recv_eom_) { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + break; + } + atomic_set(this->eom_received_, true); // publishes the tally above to loop() + // The application only gets frames that are not our own and not a polling message (header + // only), which the acknowledge answered already, and that are addressed to us unless + // promiscuous + const uint8_t destination = this->recv_header_ & 0xf; + const bool wanted = this->recv_length_ > 1 && !own_frame && (this->recv_header_ >> 4) != this->address_ && + (this->promiscuous_mode_ || destination == 0xf || destination == this->address_); + if (!wanted) { + // nothing to deliver + } else if (this->recv_frame_ == nullptr) { + atomic_set(this->dropped_frames_, atomic_get(this->dropped_frames_) + 1); + } else { + this->frames_queue_.push_back(); + this->enable_loop_soon_any_context(); + } + this->reset_receiver_(); + break; + } +} + +void IRAM_ATTR HDMICEC::reset_receiver_() { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_IDLE; + this->recv_bit_counter_ = 0; + this->recv_length_ = 0; + this->recv_ack_queued_ = false; + this->recv_frame_ = nullptr; +} + +} // namespace esphome::hdmi_cec diff --git a/esphome/components/hdmi_cec/hdmi_cec.h b/esphome/components/hdmi_cec/hdmi_cec.h new file mode 100644 index 00000000000..d001a7ae23b --- /dev/null +++ b/esphome/components/hdmi_cec/hdmi_cec.h @@ -0,0 +1,218 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +#ifdef USE_ESP32 +#include "driver/gptimer.h" +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome::hdmi_cec { + +// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on +// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here +// because every atomic in this component has exactly one writer. +template ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic &atom, T value) { + atom.store(value, std::memory_order_release); +} +template ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic &atom) { + return atom.load(std::memory_order_acquire); +} + +/// One CEC bus message: a header byte with the initiator and destination addresses, then up to +/// 15 payload bytes of which the first is the opcode. Fixed storage, so the receive interrupt +/// never allocates. +class Frame { + public: + static constexpr size_t MAX_LENGTH = 16; // HDMI-CEC 1.4 + static constexpr size_t MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1; + + Frame() = default; + /// The payload is at most MAX_PAYLOAD_LENGTH bytes; send() checks that + Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload); + + ESPHOME_ALWAYS_INLINE uint8_t initiator_addr() const { return this->data_[0] >> 4; } + ESPHOME_ALWAYS_INLINE uint8_t destination_addr() const { return this->data_[0] & 0xf; } + ESPHOME_ALWAYS_INLINE bool is_broadcast() const { return this->destination_addr() == 0xf; } + ESPHOME_ALWAYS_INLINE size_t size() const { return this->size_; } + std::span bytes() const { return {this->data_.data(), this->size_}; } + /// The bytes after the header, starting with the opcode + std::span payload() const { return this->bytes().subspan(1); } + ESPHOME_ALWAYS_INLINE void clear() { this->size_ = 0; } + /// False when the frame is full already; the bus allows no more + ESPHOME_ALWAYS_INLINE bool push_back(uint8_t data) { + if (this->size_ == MAX_LENGTH) { + return false; + } + this->data_[this->size_++] = data; + return true; + } + + protected: + std::array data_{}; + uint8_t size_{0}; +}; + +/// A queue of frames between one producer and one consumer, for instance the receive interrupt and +/// loop(). The indices run freely and each is written by one side only, so no lock is needed. +/// back() hands out a slot to fill and push_back() commits it; front() and pop_front() do the same +/// on the reading side. +template class FrameRingBuffer { + static_assert((SIZE & (SIZE - 1)) == 0, "SIZE must be a power of two"); + + public: + ESPHOME_ALWAYS_INLINE Frame *front() { return this->is_empty() ? nullptr : this->slot_(this->front_inx_); } + ESPHOME_ALWAYS_INLINE void pop_front() { this->advance_(this->front_inx_); } + ESPHOME_ALWAYS_INLINE Frame *back() { return this->count_() == SIZE ? nullptr : this->slot_(this->back_inx_); } + ESPHOME_ALWAYS_INLINE void push_back() { this->advance_(this->back_inx_); } + ESPHOME_ALWAYS_INLINE bool is_empty() const { return this->count_() == 0; } + + protected: + ESPHOME_ALWAYS_INLINE uint8_t count_() const { + return static_cast(atomic_get(this->back_inx_) - atomic_get(this->front_inx_)); + } + ESPHOME_ALWAYS_INLINE Frame *slot_(const std::atomic &inx) { return &this->store_[atomic_get(inx) % SIZE]; } + ESPHOME_ALWAYS_INLINE void advance_(std::atomic &inx) { + atomic_set(inx, static_cast(atomic_get(inx) + 1)); + } + + std::array store_{}; + std::atomic front_inx_{0}; + std::atomic back_inx_{0}; +}; + +/// The HDMI-CEC bus on one GPIO pin. The receive interrupt decodes the edges of every frame, +/// including those this device sends, and queues complete frames for loop(); loop() runs the +/// on_message automations and transmits queued frames, one blocking bit-banged attempt per pass. +class HDMICEC : public Component { + public: + HDMICEC(InternalGPIOPin *pin, uint8_t address) : pin_(pin), address_(address) {} + void setup() override; + void dump_config() override; + void loop() override; + + void set_promiscuous_mode(bool promiscuous) { this->promiscuous_mode_ = promiscuous; } + void set_monitor_mode(bool monitor_mode) { this->monitor_mode_ = monitor_mode; } +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + /// Called with the initiator, the destination and the payload (opcode first) of every received + /// frame; the payload is only valid during the call + template void add_on_message_callback(F &&callback) { + this->message_callbacks_.add(std::forward(callback)); + } +#endif + + /// Queue a frame for transmission; false when the queue is full or in monitor mode + bool send(uint8_t source, uint8_t destination, std::span payload); + bool send(uint8_t destination, std::span payload) { + return this->send(this->address_, destination, payload); + } + /// For lambdas: id(cec).send(0x0, {0x44, 0x41}); + bool send(uint8_t destination, std::initializer_list payload) { + return this->send(destination, std::span(payload.begin(), payload.size())); + } + + protected: + enum class ReceiverState : uint8_t { + RECEIVER_STATE_IDLE, + RECEIVER_STATE_RECEIVING_BYTE, + RECEIVER_STATE_WAITING_FOR_EOM, + RECEIVER_STATE_WAITING_FOR_ACK, + }; + + // Receive side, in the interrupt + static void gpio_isr_s(HDMICEC *self); + void gpio_isr_(); + void reset_receiver_(); + /// Pull the line low for the acknowledge bit of a frame addressed to us + void send_ack_(); + // A hardware one-shot timer releases the line after the acknowledge bit (gptimer on the ESP32, + // timer 1 on the ESP8266, a hardware alarm on the RP2040), so the interrupt returns at once; + // without one the interrupt holds the line for the 1.5 ms itself + bool setup_ack_timer_(); + void start_ack_timer_(); +#ifdef USE_ESP32 + static bool ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg); +#endif +#ifdef USE_ESP8266 + static void ack_timer_isr_s(); +#endif +#ifdef USE_RP2040 + static void ack_timer_isr_s(uint alarm_num); +#endif + + // Transmit side, in loop() + void transmit_message_(); + /// Keep the frame for another attempt, or drop it once the attempts are used up + void finish_attempt_(bool sent_ok); + /// Drive the frame onto the line; false when another initiator sent at the same time + bool transmit_frame_(const Frame *frame); + bool send_start_bit_(); + /// Send one bit; false when a logical 1 found the line held low by another initiator + bool send_bit_(bool bit_value); + ESPHOME_ALWAYS_INLINE void set_pin_input_high_() { this->isr_pin_.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); } + ESPHOME_ALWAYS_INLINE void set_pin_output_low_() { + this->isr_pin_.pin_mode(gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); + this->isr_pin_.digital_write(false); + } + + static constexpr size_t QUEUE_SIZE = 4; + // Each queue is 70 bytes, so two single-byte fields follow each to fill the alignment gap + FrameRingBuffer frames_queue_; // received, interrupt to loop() + uint8_t recv_bit_counter_{0}; + uint8_t recv_byte_{0}; + FrameRingBuffer send_queue_; // to send, both ends on loop() + uint8_t transmit_attempts_{0}; + uint8_t reported_dropped_frames_{0}; +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + StaticCallbackManager)> + message_callbacks_; +#endif + InternalGPIOPin *pin_; + ISRInternalGPIOPin isr_pin_; +#ifdef USE_ESP32 + gptimer_handle_t ack_timer_{nullptr}; +#endif +#ifdef USE_RP2040 + int ack_alarm_{-1}; +#endif + + // The word-sized fields come first and the single-byte ones form one run, so nothing is padded + std::atomic last_edge_us_{0}; // the bus is busy until this is some time ago; interrupt writes + Frame *recv_frame_{nullptr}; // interrupt only; null when the queue was full or the frame too long + uint32_t last_falling_edge_us_{0}; // interrupt only + uint32_t last_attempt_us_{0}; // loop() only; a refused start bit drives no edge, so it is timed here + uint16_t required_idle_period_{0}; // loop() only + + // Shared with the interrupt. The tally of our own frame is written by the interrupt while + // transmitting_ is set and no end of message has passed yet; loop() reads it once eom_received_ + // says the frame is over + std::atomic eom_received_{false}; // set for every frame, ours or not + std::atomic dropped_frames_{0}; + uint8_t n_bytes_received_{0}; + uint8_t n_acks_received_{0}; + bool transmitting_{false}; + + // Receive state, interrupt only + uint8_t recv_header_{0}; // kept outside the frame so a dropped frame can still be judged + uint8_t recv_length_{0}; + ReceiverState receiver_state_{ReceiverState::RECEIVER_STATE_IDLE}; + bool recv_eom_{false}; + bool recv_ack_queued_{false}; + bool prev_pin_level_{true}; + + uint8_t address_; // logical address, 0xf when only listening + bool promiscuous_mode_{false}; + bool monitor_mode_{false}; +}; + +} // namespace esphome::hdmi_cec diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 2583839ca81..42ef79d31d2 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,8 +126,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await climate_ir.new_climate_ir(config) - cg.add(var.set_protocol(config[CONF_PROTOCOL])) + tag = heatpumpir_ns.ProtocolTag.template(config[CONF_PROTOCOL]) + var = await climate_ir.new_climate_ir(config, tag()) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index b4597fe4abf..a23d69e3b22 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -5,7 +5,6 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) #include -#include #include #include #include "esphome/components/remote_base/remote_base.h" @@ -45,71 +44,76 @@ class IRSenderESPHome : public IRSender { ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); -const std::map> PROTOCOL_CONSTRUCTOR_MAP = { - {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BALLU, []() { return new BalluHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_MCA, []() { return new CarrierMCAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_NQV, []() { return new CarrierNQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC417, []() { return new DaikinHeatpumpARC417IR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC480, []() { return new DaikinHeatpumpARC480A14IR(); }}, // NOLINT - {PROTOCOL_DAIKIN, []() { return new DaikinHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ELECTROLUXYAL, []() { return new ElectroluxYALHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUEGO, []() { return new FuegoHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUJITSU_AWYZ, []() { return new FujitsuHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREE, []() { return new GreeGenericHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAA, []() { return new GreeYAAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAN, []() { return new GreeYANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAC, []() { return new GreeYACHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYT, []() { return new GreeYTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAP, []() { return new GreeYAPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HISENSE_AUD, []() { return new HisenseHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HITACHI, []() { return new HitachiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HYUNDAI, []() { return new HyundaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_IVT, []() { return new IVTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MIDEA, []() { return new MideaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FA, []() { return new MitsubishiFAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FD, []() { return new MitsubishiFDHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FE, []() { return new MitsubishiFEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_FDTC, []() { return new MitsubishiHeavyFDTCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZJ, []() { return new MitsubishiHeavyZJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZM, []() { return new MitsubishiHeavyZMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZMP, []() { return new MitsubishiHeavyZMPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_KJ, []() { return new MitsubishiKJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSC, []() { return new MitsubishiMSCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSY, []() { return new MitsubishiMSYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_SEZ, []() { return new MitsubishiSEZKDXXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_CKP, []() { return new PanasonicCKPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_DKE, []() { return new PanasonicDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_EKE, []() { return new PanasonicEKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_JKE, []() { return new PanasonicJKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_LKE, []() { return new PanasonicLKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_NKE, []() { return new PanasonicNKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV, []() { return new SamsungAQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_FJM, []() { return new SamsungFJMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SHARP, []() { return new SharpHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA_DAISEIKAI, []() { return new ToshibaDaiseikaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA, []() { return new ToshibaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHLT01, []() { return new ZHLT01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_NIBE, []() { return new NibeHeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_1, []() { return new Qlima1HeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_2, []() { return new Qlima2HeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV12MSAN, []() { return new SamsungAQV12MSANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHJG01, []() { return new ZHJG01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }}, // NOLINT - {PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }}, // NOLINT - {PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }}, // NOLINT -}; +template struct ProtocolClass; + +template HeatpumpIR *create_heatpump_ir() { + return new typename ProtocolClass

::type(); // NOLINT +} + +// Codegen calls only the configured protocols, so the linker drops the other classes +#define HEATPUMPIR_PROTOCOL(protocol, cls) \ + template<> struct ProtocolClass { using type = cls; }; \ + template HeatpumpIR *create_heatpump_ir(); + +HEATPUMPIR_PROTOCOL(PROTOCOL_AUX, AUXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BALLU, BalluHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_MCA, CarrierMCAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_NQV, CarrierNQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC417, DaikinHeatpumpARC417IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC480, DaikinHeatpumpARC480A14IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN, DaikinHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ELECTROLUXYAL, ElectroluxYALHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUEGO, FuegoHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUJITSU_AWYZ, FujitsuHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREE, GreeGenericHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAA, GreeYAAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAN, GreeYANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAC, GreeYACHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYT, GreeYTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAP, GreeYAPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HISENSE_AUD, HisenseHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HITACHI, HitachiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HYUNDAI, HyundaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_IVT, IVTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MIDEA, MideaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FA, MitsubishiFAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FD, MitsubishiFDHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FE, MitsubishiFEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_FDTC, MitsubishiHeavyFDTCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZJ, MitsubishiHeavyZJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZM, MitsubishiHeavyZMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZMP, MitsubishiHeavyZMPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_KJ, MitsubishiKJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSC, MitsubishiMSCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSY, MitsubishiMSYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_SEZ, MitsubishiSEZKDXXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_CKP, PanasonicCKPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_DKE, PanasonicDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_EKE, PanasonicEKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_JKE, PanasonicJKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_LKE, PanasonicLKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_NKE, PanasonicNKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV, SamsungAQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_FJM, SamsungFJMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SHARP, SharpHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA_DAISEIKAI, ToshibaDaiseikaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA, ToshibaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHLT01, ZHLT01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_NIBE, NibeHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_1, Qlima1HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_2, Qlima2HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV12MSAN, SamsungAQV12MSANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHJG01, ZHJG01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_AIRWAY, AIRWAYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BGH_AUD, BGHHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_ALTDKE, PanasonicAltDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PHILCO_PHS32, PhilcoPHS32HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_VAILLANTVAI8, VaillantHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_R51M, R51MHeatpumpIR) + +#undef HEATPUMPIR_PROTOCOL void HeatpumpIRClimate::setup() { - auto protocol_constructor = PROTOCOL_CONSTRUCTOR_MAP.find(protocol_); - if (protocol_constructor == PROTOCOL_CONSTRUCTOR_MAP.end()) { - ESP_LOGE(TAG, "Invalid protocol"); - return; - } - this->heatpump_ir_ = protocol_constructor->second(); climate_ir::ClimateIR::setup(); if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index d00fa91d05a..dfa5c40366a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -89,25 +89,21 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 5, }; +/// Creates the HeatpumpIR library class for a protocol; instantiated in heatpumpir.cpp. +template HeatpumpIR *create_heatpump_ir(); + +/// Selects the protocol at compile time so the enum and the library object always match. +template struct ProtocolTag {}; + // Temperature const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: - HeatpumpIRClimate() - : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, - climate::CLIMATE_FAN_AUTO}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + template + explicit HeatpumpIRClimate(ProtocolTag

/*tag*/) : HeatpumpIRClimate(P, create_heatpump_ir

()) {} void setup() override; - void set_protocol(Protocol protocol) { - this->protocol_ = protocol; - if (this->has_fan_byte_presets_()) { - this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; - } - } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -119,6 +115,18 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void set_min_temperature(float temperature) { this->min_temperature_ = temperature; } protected: + HeatpumpIRClimate(Protocol protocol, HeatpumpIR *heatpump_ir) + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}), + heatpump_ir_(heatpump_ir), + protocol_(protocol) { + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } HeatpumpIR *heatpump_ir_; void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index 6cf1c87eaa5..5c830c8c923 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -5,6 +5,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -31,22 +32,21 @@ void HomeassistantButton::press_action() { } #endif - static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); - static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_BUTTON[] PROGMEM = "button.press"; + static const char SERVICE_INPUT_BUTTON[] PROGMEM = "input_button.press"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_BUTTON; - } else { - resp.service = SERVICE_BUTTON; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_BUTTON, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_BUTTON) : ProgmemStringRef::from_lit(SERVICE_BUTTON); resp.data.init(1); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h new file mode 100644 index 00000000000..65eb6ef5fd2 --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.h @@ -0,0 +1,136 @@ +#pragma once + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early. +template bool emit_utf8(uint32_t code_point, C &on_char) { + if (code_point == 0 || code_point > 0x10FFFF) + return false; + if (code_point < 0x80) { + on_char(static_cast(code_point)); + } else if (code_point < 0x800) { + on_char(static_cast(0xC0 | (code_point >> 6))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else if (code_point < 0x10000) { + on_char(static_cast(0xE0 | (code_point >> 12))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else { + on_char(static_cast(0xF0 | (code_point >> 18))); + on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } + return true; +} + +/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as + * `['Low', "It's high", 'caf\xe9']`, or of an enum, such as `[]`, where the + * quoted value stands for the item. + * + * Calls on_char for each decoded byte of an item and on_item at the end of each item. Returns false + * on malformed input; the callbacks may already have run by then. + */ +template bool parse_python_list(StringRef src, C &&on_char, O &&on_item) { + const char *pos = src.c_str(); + const char *const end = pos + src.size(); + auto skip_spaces = [&]() { + while (pos < end && *pos == ' ') + pos++; + }; + + skip_spaces(); + if (pos == end || *pos != '[') + return false; + pos++; + skip_spaces(); + if (pos < end && *pos == ']') { + pos++; + } else { + while (true) { + skip_spaces(); + // An enum member is shown as ; the quoted value is the item + const bool enum_member = pos < end && *pos == '<'; + if (enum_member) { + pos++; + while (pos < end && (std::isalnum(static_cast(*pos)) || *pos == '_' || *pos == '.')) + pos++; + if (pos == end || *pos != ':') + return false; + pos++; + skip_spaces(); + } + if (pos == end || (*pos != '\'' && *pos != '"')) + return false; + const char quote = *pos++; + while (true) { + if (pos == end) + return false; + const char c = *pos++; + if (c == quote) + break; + if (c != '\\') { + on_char(c); + continue; + } + if (pos == end) + return false; + const char escape = *pos++; + switch (escape) { + case '\\': + case '\'': + case '"': + on_char(escape); + break; + case 'n': + on_char('\n'); + break; + case 'r': + on_char('\r'); + break; + case 't': + on_char('\t'); + break; + case 'x': + case 'u': + case 'U': { + const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); + if (static_cast(end - pos) < digits) + return false; + auto code_point = parse_hex(pos, digits); + if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) + return false; + pos += digits; + break; + } + default: + return false; + } + } + if (enum_member) { + skip_spaces(); + if (pos == end || *pos != '>') + return false; + pos++; + } + on_item(); + skip_spaces(); + if (pos == end) + return false; + const char separator = *pos++; + if (separator == ']') + break; + if (separator != ',') + return false; + } + } + skip_spaces(); + return pos == end; +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index df92aad38eb..73efc8e5922 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -5,6 +5,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -89,31 +90,29 @@ void HomeassistantNumber::control(float value) { this->publish_state(value); - static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); - static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); - static constexpr char INPUT_PREFIX[] = "input_"; - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto VALUE_KEY = StringRef::from_lit("value"); + // In flash on the ESP8266 and encoded from there + static const char NUMBER_SERVICE_NAME[] PROGMEM = "number.set_value"; + static const char INPUT_NUMBER_SERVICE_NAME[] PROGMEM = "input_number.set_value"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char VALUE_KEY[] PROGMEM = "value"; api::HomeassistantActionRequest resp; - // input_number entities have their own set_value action - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = INPUT_NUMBER_SERVICE_NAME; - } else { - resp.service = NUMBER_SERVICE_NAME; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, INPUT_NUMBER_SERVICE_NAME, sizeof("input_") - 1) == 0; + resp.service = input_helper ? ProgmemStringRef::from_lit(INPUT_NUMBER_SERVICE_NAME) + : ProgmemStringRef::from_lit(NUMBER_SERVICE_NAME); resp.data.init(2); auto &entity_id = resp.data.emplace_back(); - entity_id.key = ENTITY_ID_KEY; - entity_id.value = StringRef(this->entity_id_); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &entity_value = resp.data.emplace_back(); - entity_value.key = VALUE_KEY; + entity_value.key = ProgmemStringRef::from_lit(VALUE_KEY); // Stack buffer - no heap allocation; %g produces shortest representation char value_buf[16]; buf_append_printf(value_buf, sizeof(value_buf), 0, "%g", value); - entity_value.value = StringRef(value_buf); + entity_value.value = ProgmemStringRef(StringRef(value_buf)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index e8ad0fc23b8..1bdf0f2cade 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -13,6 +13,7 @@ from .. import ( CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["api"] +AUTO_LOAD = ["homeassistant"] CONF_MAX_OPTIONS = "max_options" CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size" diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 189a6b1f376..75b77ca7bb8 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -2,128 +2,19 @@ #include +#include "../homeassistant.h" + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { ESPHOME_LOG_TAG(TAG, "homeassistant.select"); -namespace { - -/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early. -template bool emit_utf8(uint32_t code_point, C &on_char) { - if (code_point == 0 || code_point > 0x10FFFF) - return false; - if (code_point < 0x80) { - on_char(static_cast(code_point)); - } else if (code_point < 0x800) { - on_char(static_cast(0xC0 | (code_point >> 6))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } else if (code_point < 0x10000) { - on_char(static_cast(0xE0 | (code_point >> 12))); - on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } else { - on_char(static_cast(0xF0 | (code_point >> 18))); - on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); - on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } - return true; -} - -/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as - * `['Low', "It's high", 'caf\xe9']`. - * - * Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false - * on malformed input; the callbacks may already have run by then. - */ -template bool parse_option_list(StringRef src, C &&on_char, O &&on_option) { - const char *pos = src.c_str(); - const char *const end = pos + src.size(); - auto skip_spaces = [&]() { - while (pos < end && *pos == ' ') - pos++; - }; - - skip_spaces(); - if (pos == end || *pos != '[') - return false; - pos++; - skip_spaces(); - if (pos < end && *pos == ']') { - pos++; - } else { - while (true) { - skip_spaces(); - if (pos == end || (*pos != '\'' && *pos != '"')) - return false; - const char quote = *pos++; - while (true) { - if (pos == end) - return false; - const char c = *pos++; - if (c == quote) - break; - if (c != '\\') { - on_char(c); - continue; - } - if (pos == end) - return false; - const char escape = *pos++; - switch (escape) { - case '\\': - case '\'': - case '"': - on_char(escape); - break; - case 'n': - on_char('\n'); - break; - case 'r': - on_char('\r'); - break; - case 't': - on_char('\t'); - break; - case 'x': - case 'u': - case 'U': { - const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); - if (static_cast(end - pos) < digits) - return false; - auto code_point = parse_hex(pos, digits); - if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) - return false; - pos += digits; - break; - } - default: - return false; - } - } - on_option(); - skip_spaces(); - if (pos == end) - return false; - const char separator = *pos++; - if (separator == ']') - break; - if (separator != ',') - return false; - } - } - skip_spaces(); - return pos == end; -} - -} // namespace - void HomeassistantSelect::setup() { this->options_buffer_ = std::make_unique(this->options_buffer_size_); this->option_list_ = std::make_unique(this->max_options_); @@ -145,7 +36,7 @@ void HomeassistantSelect::options_changed_(StringRef options) { size_t match_len = 0; bool matching = active != nullptr; optional new_active; - bool valid = parse_option_list( + bool valid = parse_python_list( options, [&](char c) { bytes++; @@ -182,7 +73,7 @@ void HomeassistantSelect::options_changed_(StringRef options) { size_t offset = 0; size_t option_start = 0; size_t index = 0; - parse_option_list( + parse_python_list( options, [&](char c) { buffer[offset++] = c; }, [&]() { buffer[offset++] = '\0'; @@ -237,27 +128,26 @@ void HomeassistantSelect::control(size_t index) { } #endif - static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); - static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto OPTION_KEY = StringRef::from_lit("option"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_SELECT[] PROGMEM = "select.select_option"; + static const char SERVICE_INPUT_SELECT[] PROGMEM = "input_select.select_option"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char OPTION_KEY[] PROGMEM = "option"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_SELECT; - } else { - resp.service = SERVICE_SELECT; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_SELECT, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_SELECT) : ProgmemStringRef::from_lit(SERVICE_SELECT); resp.data.init(2); auto &entity_id = resp.data.emplace_back(); - entity_id.key = ENTITY_ID_KEY; - entity_id.value = StringRef(this->entity_id_); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &option = resp.data.emplace_back(); - option.key = OPTION_KEY; - option.value = StringRef(this->option_at(index)); + option.key = ProgmemStringRef::from_lit(OPTION_KEY); + option.value = ProgmemStringRef(StringRef(this->option_at(index))); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 841f16df200..f6fbdc1ef48 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -1,6 +1,7 @@ #include "homeassistant_switch.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -47,21 +48,18 @@ void HomeassistantSwitch::write_state(bool state) { } #endif - static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on"); - static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + // In flash on the ESP8266 and encoded from there + static const char SERVICE_ON[] PROGMEM = "homeassistant.turn_on"; + static const char SERVICE_OFF[] PROGMEM = "homeassistant.turn_off"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; api::HomeassistantActionRequest resp; - if (state) { - resp.service = SERVICE_ON; - } else { - resp.service = SERVICE_OFF; - } + resp.service = state ? ProgmemStringRef::from_lit(SERVICE_ON) : ProgmemStringRef::from_lit(SERVICE_OFF); resp.data.init(1); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index 7155f6ee189..2a5598756a5 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -6,6 +6,7 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -86,27 +87,26 @@ void HomeassistantText::control(const std::string &value) { } #endif - static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); - static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); - static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - static constexpr auto VALUE_KEY = StringRef::from_lit("value"); - static constexpr char INPUT_PREFIX[] = "input_"; + // In flash on the ESP8266 and encoded from there + static const char SERVICE_TEXT[] PROGMEM = "text.set_value"; + static const char SERVICE_INPUT_TEXT[] PROGMEM = "input_text.set_value"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char VALUE_KEY[] PROGMEM = "value"; api::HomeassistantActionRequest resp; - if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { - resp.service = SERVICE_INPUT_TEXT; - } else { - resp.service = SERVICE_TEXT; - } + // An input helper entity id starts with the "input_" its action name starts with + const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_TEXT, sizeof("input_") - 1) == 0; + resp.service = + input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_TEXT) : ProgmemStringRef::from_lit(SERVICE_TEXT); resp.data.init(2); auto &entity_id_kv = resp.data.emplace_back(); - entity_id_kv.key = ENTITY_ID_KEY; - entity_id_kv.value = StringRef(this->entity_id_); + entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_)); auto &value_kv = resp.data.emplace_back(); - value_kv.key = VALUE_KEY; - value_kv.value = StringRef(value.data(), value.size()); + value_kv.key = ProgmemStringRef::from_lit(VALUE_KEY); + value_kv.value = ProgmemStringRef(value.data(), value.size()); api::global_api_server->send_homeassistant_action(resp); } diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index f753534efd9..2cc455e3f45 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -59,8 +59,8 @@ namespace esphome::host { char **get_argv() { return s_argv; } const std::string &get_exe_path() { - static const std::string empty; - return s_exe_path != nullptr ? *s_exe_path : empty; + static const std::string EMPTY; + return s_exe_path != nullptr ? *s_exe_path : EMPTY; } void arm_reexec(const std::string &path) { diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 9108c1ea9d3..caee316a53e 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" #include "core.h" -#include +#include #include #include #include diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 9e203efcb9c..f8b7a0c5f08 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,8 +39,8 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; - memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); + static constexpr uint8_t HOST_MAC_ADDRESS[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; + memcpy(mac, HOST_MAC_ADDRESS, sizeof(HOST_MAC_ADDRESS)); } } // namespace esphome diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 9a8208e96ea..0f6b03ff0b5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "preferences.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -45,11 +46,10 @@ void HostPreferences::setup_() { break; if (fread(&len, sizeof(len), 1, fp) != 1) break; - uint8_t data[len]; - if (fread(data, sizeof(uint8_t), len, fp) != len) + std::vector data(len); + if (fread(data.data(), sizeof(uint8_t), len, fp) != len) break; - std::vector vec(data, data + len); - this->data_[key] = vec; + this->data_[key] = std::move(data); } fclose(fp); } diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 89fe5f8b1cc..a97aee14622 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -270,6 +270,7 @@ HTTP_REQUEST_ACTION_SCHEMA = cv.Schema( cv.Optional(CONF_ON_RESPONSE): automation.validate_automation(single=True), cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), cv.Optional(CONF_MAX_RESPONSE_BUFFER_SIZE, default="1kB"): cv.validate_bytes, + cv.Optional(CONF_TIMEOUT): cv.templatable(cv.positive_time_period_milliseconds), } ) HTTP_REQUEST_GET_ACTION_SCHEMA = automation.maybe_conf( @@ -347,6 +348,10 @@ async def http_request_action_to_code( cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) + if CONF_TIMEOUT in config: + template_ = await cg.templatable(config[CONF_TIMEOUT], args, cg.uint32) + cg.add(var.set_timeout(template_)) + if CONF_BODY in config: template_ = await cg.templatable(config[CONF_BODY], args, cg.std_string) cg.add(var.set_body(template_)) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 424fcf84e86..713de247c5b 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -252,6 +252,8 @@ class HttpContainer : public Parented { virtual void end() = 0; void set_secure(bool secure) { this->secure_ = secure; } + void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } + uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } void set_chunked(bool chunked) { this->is_chunked_ = chunked; } size_t get_bytes_read() const { return this->bytes_read_; } @@ -283,6 +285,7 @@ class HttpContainer : public Parented { protected: size_t bytes_read_{0}; + uint32_t watchdog_timeout_{0}; bool secure_{false}; bool is_chunked_{false}; ///< True if response uses chunked transfer encoding std::vector

response_headers_{}; @@ -339,6 +342,11 @@ class HttpRequestComponent : public Component { uint32_t get_timeout() const { return this->timeout_; } void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } + uint32_t get_watchdog_timeout(uint32_t timeout_ms) const { + if (this->watchdog_timeout_ == 0 || timeout_ms <= this->timeout_) + return this->watchdog_timeout_; + return this->watchdog_timeout_ + 3 * (timeout_ms - this->timeout_); + } void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } @@ -412,7 +420,7 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads - return this->perform(url, method, body, request_headers, {}); + return this->perform(url, method, body, request_headers, {}, this->timeout_); } std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers) { @@ -437,7 +445,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower, this->timeout_); } // Remove before 2027.1.0 @@ -453,7 +461,7 @@ class HttpRequestComponent : public Component { lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url.c_str(), method.c_str(), body, - std::vector
(request_headers.begin(), request_headers.end()), lower); + std::vector
(request_headers.begin(), request_headers.end()), lower, this->timeout_); } // Remove before 2027.1.0 @@ -463,13 +471,19 @@ class HttpRequestComponent : public Component { const std::vector &lower_case_collect_headers) { return this->perform(url.c_str(), method.c_str(), body, std::vector
(request_headers.begin(), request_headers.end()), - lower_case_collect_headers); + lower_case_collect_headers, this->timeout_); } std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, request_headers, lower_case_collect_headers); + return this->perform(url, method, body, request_headers, lower_case_collect_headers, this->timeout_); + } + std::shared_ptr start(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { + return this->perform(url, method, body, request_headers, lower_case_collect_headers, timeout_ms); } std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, @@ -480,7 +494,8 @@ class HttpRequestComponent : public Component { protected: virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) = 0; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; uint16_t redirect_limit_{}; @@ -494,6 +509,7 @@ template class HttpRequestSendAction final : public Action class HttpRequestSendAction final : public Actiontimeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout(); auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, - request_headers, this->lower_case_collect_headers_); + request_headers, this->lower_case_collect_headers_, timeout); auto captured_args = std::make_tuple(x...); @@ -565,7 +582,7 @@ template class HttpRequestSendAction final : public Actionparent_->get_timeout(); + const uint32_t read_timeout = timeout; while (container->get_bytes_read() < max_length) { int read_or_error = container->read(buf + read_index, std::min(max_length - read_index, 512)); App.feed_wdt(); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index deb9c0fec25..2062122adf6 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -26,7 +26,8 @@ static constexpr int ESP8266_SSL_ERR_OOM = -1000; std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { if (!network::is_connected()) { this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); @@ -40,8 +41,9 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); + container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); - watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(container->get_watchdog_timeout()); if (this->follow_redirects_) { container->client_.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); @@ -90,7 +92,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons } container->client_.setReuse(true); - container->client_.setTimeout(this->timeout_); + container->client_.setTimeout(timeout_ms); if (this->useragent_ != nullptr) { container->client_.setUserAgent(this->useragent_); @@ -203,7 +205,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons // and sets content_length = bytes_read_ so is_read_complete() returns true. int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); WiFiClient *stream_ptr = this->client_.getStreamPtr(); if (stream_ptr == nullptr) { @@ -398,7 +400,7 @@ int HttpContainerArduino::read_chunked_(uint8_t *buf, size_t max_len, WiFiClient } void HttpContainerArduino::end() { - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); this->client_.end(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 62737f4d0d1..d5e3f499da3 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -56,7 +56,8 @@ class HttpRequestArduino final : public HttpRequestComponent { protected: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; #ifdef USE_ESP8266 uint16_t tls_buffer_size_rx_{512}; uint16_t tls_buffer_size_tx_{512}; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 0130445acdc..65d9fb0255e 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -20,7 +20,8 @@ ESPHOME_LOG_TAG(TAG, "http_request"); std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t) { if (!network::is_connected()) { this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); @@ -53,7 +54,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c for (const auto &[name, value] : request_headers) { h_headers.emplace(name, value); } - httplib::Client client(scheme_host.c_str()); + httplib::Client client(scheme_host); if (!client.is_valid()) { ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; @@ -114,7 +115,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c } container->content_length = container->response_body_.size(); - for (auto header : response.headers) { + for (const auto &header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 0ae9f2e27b9..245aba7340e 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -20,7 +20,8 @@ class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } protected: diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index e429df9b971..879abb662b7 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -52,7 +52,8 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { if (!network::is_connected()) { this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); @@ -82,7 +83,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch config.url = url; config.method = method_idf; - config.timeout_ms = this->timeout_; + config.timeout_ms = timeout_ms; config.disable_auto_redirect = !this->follow_redirects_; config.max_redirection_count = this->redirect_limit_; config.auth_type = HTTP_AUTH_TYPE_BASIC; @@ -104,7 +105,8 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch config.buffer_size_tx = this->buffer_size_tx_; const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); + const uint32_t watchdog_timeout = this->get_watchdog_timeout(timeout_ms); + watchdog::WatchdogManager wdm(watchdog_timeout); config.event_handler = http_event_handler; @@ -117,6 +119,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch std::shared_ptr container = std::make_shared(client); container->set_parent(this); + container->set_watchdog_timeout(watchdog_timeout); container->set_secure(secure); @@ -261,7 +264,7 @@ bool HttpContainerIDF::is_read_complete() const { // This is passed through as a negative return value, which callers treat as an error. int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); // Check if we've already read all expected content (non-chunked and no-body only). // Use the base class check here, NOT the override: esp_http_client_is_complete_data_received() @@ -304,7 +307,7 @@ void HttpContainerIDF::end() { if (this->client_ == nullptr) { return; // Already cleaned up } - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); esp_http_client_close(this->client_); esp_http_client_cleanup(this->client_); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index f84dc9576bd..9e7864bb56c 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -43,7 +43,8 @@ class HttpRequestIDF final : public HttpRequestComponent { protected: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; uint16_t buffer_size_tx_{}; diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 226b59a2912..5f2149747e1 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -160,7 +160,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // 24-bit: 3 bytes per pixel, tightly packed format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - // Note: 24-bit is always RGB order in LVGL + // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; #else ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); return; @@ -169,7 +170,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // Non-LVGL mode: standard 24-bit RGB888 format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; + // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; #endif } else { ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 453c2513fa7..5bf64609f53 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -144,10 +144,15 @@ def validate_config(config: ConfigType) -> ConfigType: return config +def _host_is_linux() -> bool: + """The host I2C bus needs Linux i2c-dev.""" + return sys.platform.lower().startswith("linux") + + def validate_host_config(config: ConfigType) -> ConfigType: if CORE.is_host: # Host I2C is currently only supported on Linux - if not sys.platform.lower().startswith("linux"): + if not _host_is_linux(): raise cv.Invalid( "I2C is only supported on Linux for the host platform. " f"Current platform: {sys.platform}" @@ -446,7 +451,7 @@ def final_validate_device_schema( ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_platform_filter = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, @@ -463,3 +468,15 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) + + +def FILTER_SOURCE_FILES() -> list[str]: + excluded = _platform_filter() + # An empty list means the target isn't known yet. Otherwise the host bus is + # only kept for the host platform, and it needs Linux i2c-dev + # (validate_host_config rejects host I2C elsewhere). Leaving it out lets host + # builds without a bus, like the C++ unit tests with their fake bus, build on + # macOS too. + if excluded and "i2c_bus_host.cpp" not in excluded and not _host_is_linux(): + excluded.append("i2c_bus_host.cpp") + return excluded diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 4dc15681bfa..e4ea186199d 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -1,6 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import audio, esp32, speaker +from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -14,9 +15,11 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +import esphome.final_validate as fv from esphome.types import ConfigType from .. import ( + CONF_I2S_AUDIO_ID, CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, @@ -44,6 +47,7 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_KEEP_ALIVE = "keep_alive" CONF_SPDIF_MODE = "spdif_mode" I2SAudioSpeakerBase = i2s_audio_ns.class_( @@ -173,6 +177,7 @@ BASE_SCHEMA = ( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), + cv.Optional(CONF_KEEP_ALIVE, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -235,6 +240,23 @@ def _final_validate(config: ConfigType) -> None: raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") if config[CONF_MCLK_MULTIPLE] != 256: raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + if config[CONF_KEEP_ALIVE]: + raise cv.Invalid("SPDIF mode does not support 'keep_alive'") + + if config[CONF_KEEP_ALIVE]: + # A kept channel holds the port between sessions, so nothing else may share the bus + bus = config[CONF_I2S_AUDIO_ID] + full_config = fv.full_config.get() + on_bus = sum( + 1 + for domain in (speaker.DOMAIN, MICROPHONE_DOMAIN) + for entry in full_config.get(domain, []) + if entry.get(CONF_I2S_AUDIO_ID) == bus + ) + if on_bus > 1: + raise cv.Invalid( + "'keep_alive' needs its own I2S bus; another speaker or microphone shares this one" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -262,6 +284,8 @@ async def to_code(config: ConfigType) -> None: if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + if config[CONF_KEEP_ALIVE]: + cg.add(var.set_keep_alive(True)) # The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 5054478ceb2..3938d777717 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -334,7 +334,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { if (!this->pause_state_) { while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { if (audio_source->available() == 0) { - size_t bytes_read = audio_source->fill(read_timeout_ticks, false); + size_t bytes_read = audio_source->fill(read_timeout_ticks); if (bytes_read == 0) { break; // No upstream data within the read budget; silence-pad the remainder. } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 8d586ea4e01..85c0e6358a2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -56,8 +56,9 @@ void I2SAudioSpeakerBase::dump_config() { ESP_LOGCONFIG(TAG, "Speaker:\n" " Pin: %d\n" - " Buffer duration: %" PRIu32, - static_cast(this->dout_pin_), this->buffer_duration_ms_); + " Buffer duration: %" PRIu32 "\n" + " Keep alive: %s", + static_cast(this->dout_pin_), this->buffer_duration_ms_, YESNO(this->keep_alive_)); if (this->timeout_.has_value()) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); } @@ -105,7 +106,13 @@ void I2SAudioSpeakerBase::loop() { vTaskDelete(this->speaker_task_handle_); this->speaker_task_handle_ = nullptr; - this->stop_i2s_driver_(); + // The task raises this bit before it exits, possibly a loop pass before TASK_STOPPED is seen, so read it + // live here; a channel the task could not bring up is rebuilt next time rather than kept + const bool task_failed = xEventGroupGetBits(this->event_group_) & SpeakerEventGroupBits::ERR_ESP_NO_MEM; + if (task_failed) { + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); + } + this->stop_i2s_driver_(this->keep_alive_ && !task_failed); // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at // the top of loop(): the audio source's task can raise a start at any point above, including // during stop_i2s_driver_(), and nothing would ever re-issue it. @@ -121,11 +128,6 @@ void I2SAudioSpeakerBase::loop() { this->state_ = speaker::STATE_STOPPED; } - if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { - ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } - // Handle the speaker's state switch (this->state_) { case speaker::STATE_STARTING: @@ -133,8 +135,8 @@ void I2SAudioSpeakerBase::loop() { break; } - // Still starting up or winding down from a previous run - if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + // Still winding down from a previous run + if (this->speaker_task_handle_ != nullptr) { break; } @@ -154,7 +156,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); this->status_momentary_error(1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough @@ -270,31 +272,25 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize I2S channel"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return err; } + // Both queues are drained by the speaker task before it preloads, whether the channel is new or kept if (this->i2s_event_queue_ == nullptr) { this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); - } else { - // Reset queue to clear any stale events from previous task - xQueueReset(this->i2s_event_queue_); } // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue // so a fully-saturated DMA pipeline cannot overflow either side before drain. if (this->write_records_queue_ == nullptr) { this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); - } else { - xQueueReset(this->write_records_queue_); } if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return ESP_ERR_NO_MEM; } @@ -302,22 +298,30 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c return ESP_OK; } -void I2SAudioSpeakerBase::stop_i2s_driver_() { +void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) { if (this->tx_handle_ != nullptr) { i2s_channel_disable(this->tx_handle_); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; - - // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only - // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that - // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our - // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a - // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - this->park_dout_pin_(); + // A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and + // clock reconfiguration is the audible pop + if (!keep_channel) { + this->delete_channel_(); + } } this->parent_->unlock(); } +void I2SAudioSpeakerBase::delete_channel_() { + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + this->park_dout_pin_(); +} + bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { int64_t now = esp_timer_get_time(); @@ -368,10 +372,18 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea this->current_stream_info_.bytes_to_samples(bytes_read)); } +bool I2SAudioSpeakerBase::esp32_mono_swap_needed_() const { +#ifdef USE_ESP32_VARIANT_ESP32 + return this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16; +#else + return false; +#endif // USE_ESP32_VARIANT_ESP32 +} + void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { + if (this->esp32_mono_swap_needed_()) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 965eede0201..07b7e5d1905 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -54,6 +54,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } void set_timeout(uint32_t ms) { this->timeout_ = ms; } + void set_keep_alive(bool keep_alive) { this->keep_alive_ = keep_alive; } void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } /// @brief Get the I2S TX channel handle @@ -126,12 +127,15 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, size_t event_queue_size); - /// @brief Stops the I2S driver and unlocks the I2S port - void stop_i2s_driver_(); + /// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated + void stop_i2s_driver_(bool keep_channel); /// @brief Detaches dout from the I2S signal and drives it low void park_dout_pin_(); + /// @brief Deletes the I2S channel and parks dout; the caller holds the bus lock + void delete_channel_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} @@ -156,9 +160,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); + /// @brief Whether the output is 16-bit mono on the original ESP32, which sends each pair of samples swapped. + bool esp32_mono_swap_needed_() const; + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is - /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. + /// Only applies when esp32_mono_swap_needed_() is true. `data` must start on a DMA word and hold whole pairs. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); @@ -177,6 +183,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public optional timeout_; bool pause_state_{false}; + bool keep_alive_{false}; // Smooths software gain changes. The main loop posts targets, the speaker task processes; // GainRamp's mailbox makes that safe. The main loop is the only poster. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index fbc1edbde93..16abb2f22ef 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -44,10 +44,10 @@ static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURAT // it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back // from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a // noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. -static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info, uint32_t frame_multiple) { const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); - return std::min(frames_from_duration, max_frames); + return (std::min(frames_from_duration, max_frames) / frame_multiple) * frame_multiple; } void I2SAudioSpeaker::dump_config() { @@ -75,6 +75,9 @@ void I2SAudioSpeaker::dump_config() { void I2SAudioSpeaker::run_speaker_task() { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + // Stale records from the previous run would pair with the wrong DMA events + xQueueReset(this->i2s_event_queue_); + xQueueReset(this->write_records_queue_); const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; // Ensure ring buffer duration is at least the duration of all DMA buffers @@ -82,10 +85,12 @@ void I2SAudioSpeaker::run_speaker_task() { // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. + // Hand over whole ESP32 mono sample pairs so writes stay word-aligned + const size_t source_alignment = bytes_per_frame * this->dma_frame_multiple_(); + const bool pair_aligned = source_alignment > bytes_per_frame; + // Round the ring buffer size down to a multiple of source_alignment so the wrap boundary avoids splices. const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / source_alignment) * source_alignment; // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. const uint8_t channels = this->current_stream_info_.get_channels(); @@ -105,7 +110,8 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes( + dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_())); } // dma_buffer_bytes counts output-format bytes; convert with the output stream info. const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); @@ -125,7 +131,7 @@ void I2SAudioSpeaker::run_speaker_task() { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, - static_cast(bytes_per_frame)); + static_cast(source_alignment)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -283,7 +289,7 @@ void I2SAudioSpeaker::run_speaker_task() { if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { - size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2); if (bytes_read > 0) { // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; @@ -292,9 +298,20 @@ void I2SAudioSpeaker::run_speaker_task() { // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source // currently exposes. Frame counts are shared between input and output; only the byte widths differ. - const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); const uint32_t frames_room = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + uint32_t padding_frames = 0; + if (pair_aligned && frames_available == 0 && frames_room >= 2 && !audio_source->has_buffered_data()) { + // Silence padding follows, so write a held half pair now with a silent partner rather than strand it. + // Only the real frame is reported as played. + const size_t real_bytes = audio_source->expose_padded_partial(bytes_per_frame); + if (real_bytes > 0) { + this->apply_software_volume_(audio_source->mutable_data(), real_bytes); + frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + padding_frames = frames_available - this->current_stream_info_.bytes_to_frames(real_bytes); + } + } const uint32_t frames_to_write = std::min(frames_available, frames_room); if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer @@ -323,13 +340,17 @@ void I2SAudioSpeaker::run_speaker_task() { ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, (unsigned) output_bytes); audio_source->consume(input_bytes); - real_frames_total += frames_to_write; + real_frames_total += frames_to_write - padding_frames; partial_write_failure = true; break; } audio_source->consume(input_bytes); bytes_written_total += output_bytes; - real_frames_total += frames_to_write; + real_frames_total += frames_to_write - padding_frames; + if (padding_frames > 0) { + // The source was already empty; skip waiting on another fill() before the silence padding + break; + } } if (real_frames_total > 0) { last_data_received_time = millis(); @@ -389,8 +410,6 @@ void I2SAudioSpeaker::run_speaker_task() { } esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { - this->current_stream_info_ = audio_stream_info; - if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT // Can't reconfigure I2S bus, so the sample rate must match the configured value ESP_LOGE(TAG, "Incompatible stream settings"); @@ -428,8 +447,18 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } + // Reuse a kept channel while the stream format matches + if (this->tx_handle_ != nullptr) { + if (this->current_stream_info_ == audio_stream_info) { + return ESP_OK; + } + this->delete_channel_(); + } + + this->current_stream_info_ = audio_stream_info; + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. - uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_()); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 4b52dcd52a9..01390dfdf70 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -24,6 +24,9 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase { void run_speaker_task() override; esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + /// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned. + uint32_t dma_frame_multiple_() const { return this->esp32_mono_swap_needed_() ? 2 : 1; } + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; }; diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 2803b439156..3efebabd885 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -3,4 +3,5 @@ DOMAIN = "ili9xxx" DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 64f87c167c1..5928eb5771f 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -220,8 +220,10 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'ili9xxx' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 2529e54021b..77cee44ae68 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -2,12 +2,13 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "ili9xxx_defines.h" #include "ili9xxx_init.h" namespace esphome::ili9xxx { -static const char *const TAG = "ili9xxx"; +ESPHOME_LOG_TAG(TAG, "ili9xxx"); const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6 enum ILI9XXXColorMode { diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 2029058899f..6e11975b6c3 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -142,7 +142,8 @@ void ImprovBLEComponent::loop() { this->set_status_indicator_state_(false); if (this->should_start_ && this->setup_complete_) { - if (this->service_->is_created()) { + // A service that an earlier session stopped stays STOPPED and can be started again + if (this->service_->is_created() || this->service_->is_stopped()) { this->service_->start(); } else if (this->service_->is_running()) { // Start by advertising the device name first BEFORE setting any state diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 76fbadd1968..d9c80ebf43d 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -37,7 +37,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] @@ -348,11 +347,8 @@ def _final_validate(config: ConfigType) -> None: config ) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 565f6f7f8bb..3612b795b98 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -987,10 +987,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c } case COLOR_BITNESS_888: { const size_t i = source_idx * 3; - color_value = - big_endian - ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] - : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + color_value = ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); break; } default: diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index e1273587c45..e99e905ebf5 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -204,6 +204,7 @@ class IT8951Display : public Display, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; } protected: int get_height_internal() override { return this->height_; } diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 936ad83d39d..e1129ac9524 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -20,7 +21,6 @@ LightOutControlSelect = ld2410_ns.class_("LightOutControlSelect", select.Select) CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" -CONF_OUT_PIN_LEVEL = "out_pin_level" CONFIG_SCHEMA = { diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 40a075e5d54..5249e9b97a6 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -20,7 +21,6 @@ LightOutControlSelect = LD2412_ns.class_("LightOutControlSelect", select.Select) CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" -CONF_OUT_PIN_LEVEL = "out_pin_level" CONFIG_SCHEMA = { diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index a54e83f2875..066ace871f8 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType @@ -16,6 +17,8 @@ LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDev CONF_LD2450_ID = "ld2450_id" +CONF_POLYGON_ZONE_ID = "polygon_zone_id" +CONF_POLYGON_ZONES = "polygon_zones" CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,6 +53,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), ) +# Polygon zone sources build only when zones are configured +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "polygon.cpp": "LD2450_POLYGON_ZONE_COUNT", + "polygon_zone.cpp": "LD2450_POLYGON_ZONE_COUNT", + } +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index c7c1b6b46af..41f514472e5 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import ( + CONF_FILTERS, CONF_HAS_MOVING_TARGET, CONF_HAS_STILL_TARGET, CONF_HAS_TARGET, @@ -11,7 +12,14 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + FILTER_SOURCE_FILES, + LD2450Component, +) +from .text import PolygonZone DEPENDENCIES = ["ld2450"] @@ -19,6 +27,18 @@ ICON_MEDITATION = "mdi:meditation" ICON_SHIELD_ACCOUNT = "mdi:shield-account" ICON_TARGET_ACCOUNT = "mdi:target-account" + +def _reject_filters(config: ConfigType) -> ConfigType: + # Clearing a polygon makes presence unknown without going through filters, so they would get out of step + if CONF_FILTERS in config: + raise cv.Invalid( + "Polygon zone presence binary sensors can't have filters; use the ld2450 number platform's " + "'presence_timeout' to keep presence on after targets leave", + path=[CONF_FILTERS], + ) + return config + + CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), @@ -37,6 +57,16 @@ CONFIG_SCHEMA = { filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MEDITATION, ), + cv.Optional(CONF_POLYGON_ZONES): cv.ensure_list( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ).extend( + { + cv.Required(CONF_POLYGON_ZONE_ID): cv.use_id(PolygonZone), + } + ), + _reject_filters, + ), } @@ -46,3 +76,7 @@ async def to_code(config: ConfigType) -> None: await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + for presence_conf in config.get(CONF_POLYGON_ZONES, []): + presence = await binary_sensor.new_binary_sensor(presence_conf) + zone = await cg.get_variable(presence_conf[CONF_POLYGON_ZONE_ID]) + cg.add(zone.set_presence_binary_sensor(presence)) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b43493c26e9..d2a0e04d122 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -179,6 +179,11 @@ void LD2450Component::setup() { this->pref_ = this->presence_timeout_number_->make_entity_preference(); this->set_presence_timeout(); } +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + for (PolygonZone *zone : this->polygon_zones_) { + zone->setup(); + } #endif this->restart_and_read_all_info(); } @@ -265,6 +270,12 @@ void LD2450Component::dump_config() { LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); LOG_BUTTON(" ", "Restart", this->restart_button_); #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + ESP_LOGCONFIG(TAG, "Polygon Zones:"); + for (PolygonZone *zone : this->polygon_zones_) { + zone->dump_config(); + } +#endif } void LD2450Component::loop() { @@ -465,15 +476,15 @@ void LD2450Component::handle_periodic_data_() { #if defined(USE_BINARY_SENSOR) || defined(USE_SENSOR) || defined(USE_TEXT_SENSOR) // Loop thru targets for (index = 0; index < MAX_TARGETS; index++) { -#ifdef USE_SENSOR // X start = TARGET_X + index * 8; is_moving = false; - // tx is used for further calculations, so always needs to be populated + // tx and ty are used for further calculations, so always need to be populated tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); // Y start = TARGET_Y + index * 8; ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); +#ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; @@ -542,6 +553,23 @@ void LD2450Component::handle_periodic_data_() { still_target_count = target_count - moving_target_count; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + const uint32_t now = App.get_loop_component_start_time(); + for (PolygonZone *zone : this->polygon_zones_) { + if (!zone->has_polygon()) + continue; + bool target_inside = false; + for (const Target &target : this->target_info_) { + // Untracked targets are stored as (0, 0) + if ((target.x != 0 || target.y != 0) && zone->contains(target.x, target.y)) { + target_inside = true; + break; + } + } + zone->update(target_inside, now, this->get_timeout_status_(zone->get_last_inside_ms())); + } +#endif + #ifdef USE_SENSOR // Loop thru zones uint8_t zone_still_targets = 0; @@ -602,7 +630,7 @@ void LD2450Component::handle_periodic_data_() { } } #endif -#ifdef USE_SENSOR +#ifdef USE_BINARY_SENSOR // For presence timeout check if (target_count > 0) { this->presence_millis_ = App.get_loop_component_start_time(); diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index d6285cce8af..f85a26ba675 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -24,6 +24,10 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef LD2450_POLYGON_ZONE_COUNT +#include "polygon_zone.h" +#endif + #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" @@ -137,6 +141,9 @@ class LD2450Component : public Component, public uart::UARTDevice { void set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s); +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + void register_polygon_zone(PolygonZone *zone) { this->polygon_zones_.push_back(zone); } #endif void reset_radar_zone(); void set_radar_zone(int32_t zone_type, int32_t zone1_x1, int32_t zone1_y1, int32_t zone1_x2, int32_t zone1_y2, @@ -167,7 +174,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t presence_millis_ = 0; uint32_t still_presence_millis_ = 0; uint32_t moving_presence_millis_ = 0; - uint32_t timeout_ = 5; + uint32_t timeout_ = DEFAULT_PRESENCE_TIMEOUT * 1000; // presence_timeout, in ms uint8_t buffer_data_[MAX_LINE_LENGTH]; uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; @@ -200,6 +207,10 @@ class LD2450Component : public Component, public uart::UARTDevice { std::array direction_text_sensors_{}; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + StaticVector polygon_zones_; +#endif + LazyCallbackManager data_callback_; }; diff --git a/esphome/components/ld2450/polygon.cpp b/esphome/components/ld2450/polygon.cpp new file mode 100644 index 00000000000..46d607d3519 --- /dev/null +++ b/esphome/components/ld2450/polygon.cpp @@ -0,0 +1,123 @@ +#include "polygon.h" + +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ld2450 { + +namespace { + +class Scanner { + public: + Scanner(const char *str, size_t len) : pos_(str), end_(str + len) {} + + bool at_end() const { return this->pos_ == this->end_; } + + void skip_spaces() { + while (this->pos_ != this->end_ && *this->pos_ == ' ') + this->pos_++; + } + + bool consume(char c) { + this->skip_spaces(); + if (this->pos_ != this->end_ && *this->pos_ == c) { + this->pos_++; + return true; + } + return false; + } + + /// Read an integer in [min, max]; returns false if there is none or it is out of range. + bool read_int(int16_t min, int16_t max, int16_t &out) { + bool negative = this->consume('-'); + int32_t value = 0; + uint8_t digits = 0; + while (this->pos_ != this->end_ && *this->pos_ >= '0' && *this->pos_ <= '9') { + // Six digits is already beyond any valid coordinate; stop before value can overflow + if (++digits > 6) + return false; + value = value * 10 + (*this->pos_ - '0'); + this->pos_++; + } + if (digits == 0) + return false; + if (negative) + value = -value; + if (value < min || value > max) + return false; + out = static_cast(value); + return true; + } + + protected: + const char *pos_; + const char *end_; +}; + +} // namespace + +bool Polygon::is_valid() const { + if (this->count == 0) + return true; + if (this->count < MIN_POLYGON_POINTS || this->count > MAX_POLYGON_POINTS) + return false; + for (uint8_t i = 0; i < this->count; i++) { + const Point &p = this->points[i]; + if (p.x < POLYGON_MIN_X || p.x > POLYGON_MAX_X || p.y < POLYGON_MIN_Y || p.y > POLYGON_MAX_Y) + return false; + } + return true; +} + +bool Polygon::contains(int16_t x, int16_t y) const { + bool inside = false; + for (uint8_t i = 0, j = this->count - 1; i < this->count; j = i++) { + const Point &a = this->points[i]; + const Point &b = this->points[j]; + if ((a.y > y) == (b.y > y)) + continue; + // Does the edge a-b cross the ray going from (x, y) towards +x? Compare + // x - a.x < (y - a.y) * (b.x - a.x) / (b.y - a.y) without division; values fit in 32 bits. + const int32_t dy = static_cast(b.y) - a.y; + const int32_t lhs = (static_cast(x) - a.x) * dy; + const int32_t rhs = (static_cast(y) - a.y) * (static_cast(b.x) - a.x); + if (dy > 0 ? lhs < rhs : lhs > rhs) + inside = !inside; + } + return inside; +} + +bool Polygon::parse(const char *str, size_t len) { + Polygon result; + Scanner scanner(str, len); + scanner.skip_spaces(); + while (!scanner.at_end()) { + if (result.count == MAX_POLYGON_POINTS) + return false; + Point &p = result.points[result.count]; + if (!scanner.read_int(POLYGON_MIN_X, POLYGON_MAX_X, p.x) || !scanner.consume(',') || + !scanner.read_int(POLYGON_MIN_Y, POLYGON_MAX_Y, p.y)) + return false; + result.count++; + bool separator = scanner.consume(';'); + scanner.skip_spaces(); + if (!separator && !scanner.at_end()) + return false; + } + if (!result.is_valid()) + return false; + *this = result; + return true; +} + +void Polygon::format(char *buf) const { + size_t pos = 0; + buf[0] = '\0'; + for (uint8_t i = 0; i < this->count; i++) { + pos = buf_append_printf(buf, POLYGON_STR_SIZE, pos, "%s%d,%d", i == 0 ? "" : ";", this->points[i].x, + this->points[i].y); + } +} + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon.h b/esphome/components/ld2450/polygon.h new file mode 100644 index 00000000000..24e5be8855b --- /dev/null +++ b/esphome/components/ld2450/polygon.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ld2450 { + +static constexpr uint8_t MIN_POLYGON_POINTS = 3; +// The most points whose text always fits Home Assistant's 255-character limit: 23 x "-4860,7560" + 22 x ';' = 252 +static constexpr uint8_t MAX_POLYGON_POINTS = 23; +// Coordinate limits of the radar detection area, in mm +static constexpr int16_t POLYGON_MIN_X = -4860; +static constexpr int16_t POLYGON_MAX_X = 4860; +static constexpr int16_t POLYGON_MIN_Y = 0; +static constexpr int16_t POLYGON_MAX_Y = 7560; +// Longest point is "-4860,7560;" (11 characters), plus the null terminator +static constexpr size_t POLYGON_STR_SIZE = MAX_POLYGON_POINTS * 11 + 1; + +struct Point { + int16_t x; + int16_t y; +}; + +/// A polygon in radar coordinates. Trivially copyable so it can be saved to preferences as-is. +struct Polygon { + std::array points{}; + uint8_t count{0}; + + /// True when the polygon has no points, which disables the zone. + bool empty() const { return this->count == 0; } + + /// True when the polygon is empty, or has a valid number of points that are all inside the radar area. + bool is_valid() const; + + /// Return whether the point is inside the polygon (even-odd rule). Always false for an empty polygon. + bool contains(int16_t x, int16_t y) const; + + /** Parse "x,y;x,y;..." (mm) into this polygon. + * + * Spaces around numbers and a trailing ';' are allowed. An empty or all-space string gives an empty polygon. + * @return false if the text is invalid; the polygon is then left unchanged. + */ + bool parse(const char *str, size_t len); + + /// Write the polygon as "x,y;x,y;..." into buf, which must hold at least POLYGON_STR_SIZE bytes. + void format(char *buf) const; +}; + +// text.py counts on this size +static_assert(sizeof(Polygon) == 94, "Polygon layout changed; update POLYGON_SAVED_BYTES in text.py"); + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon_zone.cpp b/esphome/components/ld2450/polygon_zone.cpp new file mode 100644 index 00000000000..ecafd40360b --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.cpp @@ -0,0 +1,60 @@ +#include "polygon_zone.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/core/log.h" + +namespace esphome::ld2450 { + +ESPHOME_LOG_TAG(TAG, "ld2450.polygon_zone"); + +void PolygonZone::setup() { + // Bump the version when Polygon's layout changes + this->pref_ = this->make_entity_preference(1); + Polygon saved; + if (this->pref_.load(&saved)) { + if (saved.is_valid()) { + this->polygon_ = saved; + } else { + ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", this->get_name().c_str()); + } + } + // Presence stays unknown until the first radar frame + this->publish_polygon_(); +} + +void PolygonZone::dump_config() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + LOG_TEXT(" ", "PolygonZone", this); + ESP_LOGCONFIG(TAG, " Polygon: '%s'", polygon_s); + LOG_BINARY_SENSOR(" ", "Presence", this->presence_); +} + +void PolygonZone::control(const std::string &value) { + Polygon polygon; + if (!polygon.parse(value.c_str(), value.size())) { + ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", this->get_name().c_str(), + value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS); + } else if (!this->pref_.save(&polygon)) { + // On ESP8266, the space for saved settings is small and can run out with several zones + ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", this->get_name().c_str()); + } else { + this->polygon_ = polygon; + // A target seen inside the previous polygon must not keep the new one on + this->last_inside_ms_ = 0; + if (this->polygon_.empty()) + this->presence_->invalidate_state(); + } + // On error this publishes the previous polygon again, so the frontend shows the value in use + this->publish_polygon_(); +} + +void PolygonZone::publish_polygon_() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + this->publish_state(polygon_s); +} + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/polygon_zone.h b/esphome/components/ld2450/polygon_zone.h new file mode 100644 index 00000000000..5a6caaa3282 --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text/text.h" +#include "esphome/core/preferences.h" +#include "polygon.h" + +namespace esphome::ld2450 { + +/** A zone with any polygon shape. The text state holds the polygon, so it can be edited and read back from HA. + * + * The polygon is in the radar's raw coordinates (mm), and targets are checked against it as decoded from each + * radar frame, before any sensor filter. Filters on the target X/Y sensors change only what they publish, so a + * filter that changes their values (multiply, offset, lambda, ...) puts them in a different frame from the polygon. + * + * Like the hub's has_target binary sensors, presence stays on until presence_timeout has passed since a target was + * last inside the zone. + */ +class PolygonZone : public text::Text { + public: + /// Config validation guarantees every zone gets exactly one presence binary sensor. + void set_presence_binary_sensor(binary_sensor::BinarySensor *presence) { this->presence_ = presence; } + + /// Load the saved polygon and publish it. Called from the hub's setup(). + void setup(); + void dump_config(); + /** Publish presence for one radar frame. A zone without a polygon keeps presence unknown. + * + * @param target_inside whether any target is inside the zone in this frame. + * @param now the frame's time, in ms. + * @param timed_out whether presence_timeout has passed since get_last_inside_ms(). + */ + void update(bool target_inside, uint32_t now, bool timed_out) { + if (this->polygon_.empty()) + return; + if (target_inside) { + this->last_inside_ms_ = now; + this->presence_->publish_state(true); + } else if (timed_out) { + this->presence_->publish_state(false); + } + } + /// When a target was last inside the zone, in ms; 0 if never since the polygon was set. + uint32_t get_last_inside_ms() const { return this->last_inside_ms_; } + bool contains(int16_t x, int16_t y) const { return this->polygon_.contains(x, y); } + bool has_polygon() const { return !this->polygon_.empty(); } + + protected: + void control(const std::string &value) override; + void publish_polygon_(); + + binary_sensor::BinarySensor *presence_{nullptr}; + Polygon polygon_; + ESPPreferenceObject pref_; + uint32_t last_inside_ms_{0}; +}; + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 6f2056330bd..4600f27ce0d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -17,7 +17,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/ld2450/text.py b/esphome/components/ld2450/text.py new file mode 100644 index 00000000000..ebe8fc77131 --- /dev/null +++ b/esphome/components/ld2450/text.py @@ -0,0 +1,120 @@ +from esphome import preferences +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + DOMAIN, + FILTER_SOURCE_FILES, + LD2450Component, + ld2450_ns, +) + +DEPENDENCIES = ["ld2450"] + +ICON_VECTOR_POLYGON = "mdi:vector-polygon" + +# Must match MIN_POLYGON_POINTS and MAX_POLYGON_POINTS in polygon.h +MIN_POLYGON_POINTS = 3 +MAX_POLYGON_POINTS = 23 +# Matches what Polygon::parse() accepts, apart from coordinate ranges, which only the device checks: +# empty, or MIN_POLYGON_POINTS to MAX_POLYGON_POINTS "x,y" points separated by ';'. HA validates input with it. +# No character classes: the HA frontend compiles it with the JS "v" flag, HA core with Python re. +POLYGON_PATTERN = ( + r"^ *$|^ *-?\d{1,4} *, *\d{1,4} *" + rf"(; *-?\d{{1,4}} *, *\d{{1,4}} *){{{MIN_POLYGON_POINTS - 1},{MAX_POLYGON_POINTS - 1}}};? *$" +) +# Home Assistant's limit for a state; the longest polygon the device publishes is 252 characters +POLYGON_MAX_LENGTH = 255 + +# sizeof(Polygon), pinned by a static_assert in polygon.h +POLYGON_SAVED_BYTES = 94 + +PolygonZone = ld2450_ns.class_("PolygonZone", text.Text) + +_request_polygon_zone_slot = cg.slot_counter("LD2450_POLYGON_ZONE_COUNT") + + +def _require_manual_id(config: ConfigType) -> ConfigType: + if not config[CONF_ID].is_manual: + raise cv.Invalid( + f"'{CONF_ID}' is required, so the zone's presence binary sensor can refer to it", + path=[CONF_ID], + ) + return config + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), + # Polygons are in raw radar mm, checked before sensor filters (polygon_zone.h) + cv.Required(CONF_POLYGON_ZONES): cv.ensure_list( + text.text_schema( + PolygonZone, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_VECTOR_POLYGON, + mode="TEXT", + ), + _require_manual_id, + ), + } +) + + +def _final_validate(config: ConfigType) -> None: + """Each polygon zone must have exactly one presence binary sensor, on the same LD2450.""" + full_config = fv.full_config.get() + # Polygon zone id -> the LD2450 id of each presence binary sensor that uses it + references: dict[str, list[str]] = {} + for binary_sensor_conf in full_config.get("binary_sensor", []): + if binary_sensor_conf.get(CONF_PLATFORM) != "ld2450": + continue + hub_id = binary_sensor_conf[CONF_LD2450_ID].id + for presence_conf in binary_sensor_conf.get(CONF_POLYGON_ZONES, []): + zone_id = presence_conf[CONF_POLYGON_ZONE_ID].id + references.setdefault(zone_id, []).append(hub_id) + + zone_hub_id = config[CONF_LD2450_ID].id + for index, zone_conf in enumerate(config[CONF_POLYGON_ZONES]): + zone_id = zone_conf[CONF_ID].id + hub_ids = references.get(zone_id, []) + if len(hub_ids) != 1: + problem = ( + "has no presence binary sensor" + if not hub_ids + else "is used by more than one presence binary sensor" + ) + raise cv.Invalid( + f"Polygon zone '{zone_id}' {problem}; each zone needs exactly one, under " + f"'binary_sensor: - platform: ld2450' -> '{CONF_POLYGON_ZONES}'", + path=[CONF_POLYGON_ZONES, index], + ) + if hub_ids[0] != zone_hub_id: + raise cv.Invalid( + f"Polygon zone '{zone_id}' belongs to LD2450 '{zone_hub_id}', but its presence " + f"binary sensor is under LD2450 '{hub_ids[0]}'; use the same '{CONF_LD2450_ID}'", + path=[CONF_POLYGON_ZONES, index], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2450_ID]) + # Each zone saves its polygon; the ESP8266 checks that every component's saved values fit + preferences.reserve_preferences( + len(config[CONF_POLYGON_ZONES]), POLYGON_SAVED_BYTES + ) + for zone_conf in config[CONF_POLYGON_ZONES]: + zone = await text.new_text( + zone_conf, max_length=POLYGON_MAX_LENGTH, pattern=POLYGON_PATTERN + ) + _request_polygon_zone_slot(str(hub)) + cg.add(hub.register_polygon_zone(zone)) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 3eaf6c7fcad..01821a4f7ef 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -14,7 +14,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 1a4234ceaf0..5cb18878bc3 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import CORE from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType @@ -65,6 +66,10 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + use_timer1(DOMAIN) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 8f569086de2..b903b2752ed 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -246,7 +246,7 @@ static void ble_scan_callback(void *param) { memcpy(slot->data, info->data, slot->data_len); s_ble->push_scan_report(slot); -} +} // NOLINT(clang-analyzer-unix.Malloc) the slot is a pool entry handed to push_scan_report(), not a leak BLEScanReport *LN882HBLE::allocate_scan_report() { BLEScanReport *slot = this->report_pool_.allocate(); @@ -287,6 +287,7 @@ void LN882HBLE::enable() { return; this->state_ = BLEComponentState::ENABLING; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; soc_module_clk_gate_enable(CLK_G_BLE); diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 24a6586ae2f..ac7bfef48af 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -142,6 +142,8 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas } #endif // USE_ESP8266 +uint8_t Logger::get_log_level(const char *tag) { return this->level_for(tag); } + inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Skip the lookup when no per-tag levels are set diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b82aaa7c219..475ec93e81f 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -178,6 +178,8 @@ class Logger final : public Component { void set_log_level(const char *tag, uint8_t log_level); #endif uint8_t get_log_level() { return this->current_level_; } + /// The level in force for a tag: its own when one is set, else the default + uint8_t get_log_level(const char *tag); // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -511,6 +513,14 @@ class LoggerMessageTrigger final : public Triggerget_log_level(esp32::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + esp32::crash_handler_log(); + esp32::crash_handler_clear(); +} +#endif + } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 6822e8f590f..418ab3d95d9 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -48,6 +48,11 @@ const LogString *Logger::get_uart_selection_() { #endif } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return esp8266::crash_handler_has_data(); } +void log_crash_to_client() { esp8266::crash_handler_log(); } +#endif + } // namespace esphome::logger #if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index 518a1d7b1d2..6d190df5cdb 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -49,5 +49,10 @@ const LogString *Logger::get_uart_selection_() { } } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return rp2::crash_handler_has_data(); } +void log_crash_to_client() { rp2::crash_handler_log(); } +#endif + } // namespace esphome::logger #endif // USE_RP2 diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index b02a610c0a8..e34735a4160 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -1,15 +1,48 @@ #include "lsm6ds.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::lsm6ds { ESPHOME_LOG_TAG(TAG, "lsm6ds"); -static const struct { - uint8_t who_am_i; - const char *const name; -} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; +// Indexed by the accel range field, which is not in ascending order (datasheet Table 3) +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±16g", "±4g", "±8g"); + +static const LogString *gyro_range_to_str(LSM6DSGyroRange range) { + switch (range) { + case LSM6DS_GYRO_RANGE_125: + return LOG_STR("±125dps"); + case LSM6DS_GYRO_RANGE_250: + return LOG_STR("±250dps"); + case LSM6DS_GYRO_RANGE_500: + return LOG_STR("±500dps"); + case LSM6DS_GYRO_RANGE_1000: + return LOG_STR("±1000dps"); + case LSM6DS_GYRO_RANGE_2000: + return LOG_STR("±2000dps"); + default: + return LOG_STR("unknown"); + } +} + +const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { + switch (who_am_i) { + case LSM6DS_WHO_AM_I_LSM6DS3: + return LOG_STR("LSM6DS3"); + case LSM6DS_WHO_AM_I_LSM6DS3TR_C: + return LOG_STR("LSM6DS3TR-C"); + case LSM6DS_WHO_AM_I_LSM6DSO: + return LOG_STR("LSM6DSO"); + default: + return nullptr; + } +} + +uint16_t temperature_sensitivity_from_who_am_i(uint8_t who_am_i) { + return who_am_i == LSM6DS_WHO_AM_I_LSM6DS3 ? 16 : 256; +} void LSM6DSComponent::setup() { MotionComponent::setup(); @@ -19,20 +52,15 @@ void LSM6DSComponent::setup() { this->mark_failed(); return; } - const char *chip_name = nullptr; - for (const auto &chip : CHIP_IDS) { - if (chip.who_am_i == who_am_i) { - chip_name = chip.name; - break; - } - } + const LogString *chip_name = chip_name_from_who_am_i(who_am_i); if (chip_name == nullptr) { ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); return; } - ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i); this->chip_name_ = chip_name; + this->temperature_sensitivity_ = temperature_sensitivity_from_who_am_i(who_am_i); // 2. Software reset — clears all registers to defaults if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { @@ -85,34 +113,10 @@ void LSM6DSComponent::dump_config() { ESP_LOGCONFIG(TAG, "LSM6DS IMU:\n" " Chip type: %s\n", - this->chip_name_); + LOG_STR_ARG(this->chip_name_ != nullptr ? this->chip_name_ : LOG_STR("Unknown"))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - // Accel range — index into the sensitivity table (datasheet Table 3) - static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; - - const char *gyro_str; - switch (this->gyro_range_) { - case LSM6DS_GYRO_RANGE_125: - gyro_str = "±125dps"; - break; - case LSM6DS_GYRO_RANGE_250: - gyro_str = "±250dps"; - break; - case LSM6DS_GYRO_RANGE_500: - gyro_str = "±500dps"; - break; - case LSM6DS_GYRO_RANGE_1000: - gyro_str = "±1000dps"; - break; - case LSM6DS_GYRO_RANGE_2000: - gyro_str = "±2000dps"; - break; - default: - gyro_str = "unknown"; - break; - } auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); ESP_LOGCONFIG(TAG, @@ -120,7 +124,8 @@ void LSM6DSComponent::dump_config() { " Accel data rate : %dHz\n" " Gyro range : %s\n" " Gyro data rate : %dHz", - ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX)), + accel_odr, LOG_STR_ARG(gyro_range_to_str(this->gyro_range_)), gyro_odr); } // update_data() @@ -187,12 +192,12 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { // Temperature (lazy — only read if a listener is registered) // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when // temperature is not needed. - // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + // Formula: T(°C) = raw / sensitivity + 25.0, sensitivity in LSB/°C per chip (0 LSB at 25 °C) if (!this->temperature_callback_.empty()) { uint8_t raw_t[2]; if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); - float temperature = (temp_raw / 256.0f) + 25.0f; + float temperature = temp_raw / static_cast(this->temperature_sensitivity_) + 25.0f; this->temperature_callback_.call(temperature); } } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 47d6f55939e..d754b08a252 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/motion/motion_component.h" @@ -8,15 +9,19 @@ namespace esphome::lsm6ds { // ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; -static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS -static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS -static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC -static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 -static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable -static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA -static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB -static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) -static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB +// WHO_AM_I values; the LSM6DS3 (DocID026899) and LSM6DSO (DS12140) use the same registers as the TR-C here +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DS3 = 0x69; +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DS3TR_C = 0x6A; +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DSO = 0x6C; // also the LSM6DSO32, whose accel ranges differ (unsupported) +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB // Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) static const uint8_t LSM6DS_BURST_LEN = 12; @@ -82,6 +87,11 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── +/// Chip name for a WHO_AM_I value, or nullptr when the chip is not supported. +const LogString *chip_name_from_who_am_i(uint8_t who_am_i); +/// Temperature sensitivity in LSB/°C for a supported WHO_AM_I value, from the chip's datasheet. +uint16_t temperature_sensitivity_from_who_am_i(uint8_t who_am_i); + class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; @@ -97,13 +107,14 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } protected: - const char *chip_name_{"Unknown"}; + const LogString *chip_name_{nullptr}; bool update_data(motion::MotionData &data) override; LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + uint16_t temperature_sensitivity_{256}; // LSB/°C: 16 on the LSM6DS3, 256 on the others LazyCallbackManager temperature_callback_{}; }; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e3087ba9f0..810d15f7005 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -112,6 +112,7 @@ from .widgets import ( set_obj_properties, ) from .widgets.keyboard import attach_textareas +from .widgets.menu import add_root_back_button_triggers, validate_menu_pages # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -333,6 +334,17 @@ def final_validation(config_list): path = global_config.get_path_for_id(name) widget_conf = global_config.get_config_for_path(path[:-1]) widget_type.final_validate(name, conf, widget_conf, path[1:]) + validate_menu_pages(global_config) + + +def lv_conf_path_flag(lv_conf_h_file: Path) -> str: + """The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell + lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments + (ESP-IDF, Zephyr) the build flags pass through.""" + # forward slashes for Windows, then escape what shlex or a quoted CMake + # argument would not pass through literally + path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix()) + return f'-DLV_CONF_PATH=\\"{path}\\"' async def to_code(configs): @@ -503,6 +515,7 @@ async def to_code(configs): await finish_list_triggers() await generate_triggers() + await add_root_back_button_triggers() await generate_align_tos(configs[0]) await attach_textareas() for config in configs: @@ -563,9 +576,7 @@ async def to_code(configs): if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") - # handle windows paths in a way that doesn't break the generated C++ - lv_conf_h_path = Path(lv_conf_h_file).as_posix() - cg.add_build_flag(f'-DLV_CONF_PATH=\\"{lv_conf_h_path}\\"') + cg.add_build_flag(lv_conf_path_flag(lv_conf_h_file)) cg.add_build_flag("-DLV_KCONFIG_IGNORE") for prop in df.get_remapped_uses(): diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 69e15bdeca6..203a75b0c8e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -25,6 +25,7 @@ KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" +KEY_MENU_SET_PAGE_ACTIONS = "menu_set_page_actions" KEY_NAMED_STYLES = "named_styles" KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" @@ -172,6 +173,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_menu_set_page_actions() -> list: + return _get_data(KEY_MENU_SET_PAGE_ACTIONS, []) + + def get_list_triggers() -> dict: return _get_data(KEY_LIST_TRIGGERS, {}) @@ -682,6 +687,12 @@ LV_MENU_MODES = LvConstant( "BOTTOM_FIXED", ) +LV_MENU_ROOT_BACK_BUTTON_MODES = LvConstant( + "LV_MENU_ROOT_BACK_BUTTON_", + "DISABLED", + "ENABLED", +) + LV_CHART_TYPES = ( "NONE", "LINE", @@ -762,6 +773,7 @@ CONF_GRID_ROW_ALIGN = "grid_row_align" CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" +CONF_HEADER_STYLE = "header_style" CONF_HOME = "home" CONF_IMAGE = "image" CONF_INDICATORS = "indicators" @@ -778,6 +790,7 @@ CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" CONF_MAPPING = "mapping" +CONF_MENU = "menu" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" @@ -788,6 +801,7 @@ CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" +CONF_ON_ROOT_BACK_BUTTON_CLICK = "on_root_back_button_click" CONF_ON_SELECT = "on_select" CONF_OPA = "opa" CONF_NEXT = "next" @@ -810,6 +824,7 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" +CONF_ROOT_PAGE = "root_page" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" @@ -817,9 +832,12 @@ CONF_SCROLL_DIR = "scroll_dir" CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" +CONF_SECTIONS = "sections" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" +CONF_SIDEBAR_PAGE = "sidebar_page" +CONF_SIDEBAR_HEADER_STYLE = "sidebar_header_style" CONF_SPIN_TIME = "spin_time" CONF_SRC = "src" CONF_START_ANGLE = "start_angle" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0b375404d74..9bc83800051 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -1009,6 +1009,13 @@ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { } #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e) { + return lv_event_get_code(e) == LV_EVENT_CLICKED && + lv_menu_back_button_is_root(menu, static_cast(lv_event_get_target(e))); +} +#endif // USE_LVGL_MENU + lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { auto *indev = lv_indev_get_act(); if (indev == nullptr) { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4da8a6a1a30..35e5335c10e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -143,6 +143,12 @@ lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index); lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +// True if `e` is a click on `menu`'s back button while already at the root page -- the one +// case LVGL's own docs say the app must handle itself (there is no default behavior). +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e); +#endif // USE_LVGL_MENU + // Parent class for things that wrap an LVGL object class LvCompound { public: @@ -214,7 +220,7 @@ enum class Orientation : uint8_t { }; class LvglComponent final : public PollingComponent { - constexpr static const char *const TAG = "lvgl"; + constexpr static const char *const TAG = "lvgl"; // NOLINT: lvgl does not build for ESP8266 public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, diff --git a/esphome/components/lvgl/widgets/menu.py b/esphome/components/lvgl/widgets/menu.py new file mode 100644 index 00000000000..e32a7e9ba35 --- /dev/null +++ b/esphome/components/lvgl/widgets/menu.py @@ -0,0 +1,326 @@ +from esphome import automation +from esphome.automation import Trigger +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_BUTTON, CONF_ID, CONF_PAGES, CONF_TRIGGER_ID +from esphome.final_validate import FinalValidateConfig +from esphome.types import ConfigType + +from ..automation import action_to_code +from ..defines import ( + CONF_HEADER_MODE, + CONF_HEADER_STYLE, + CONF_IMAGE, + CONF_MAIN, + CONF_MENU, + CONF_ON_ROOT_BACK_BUTTON_CLICK, + CONF_PAGE, + CONF_ROOT_BACK_BTN, + CONF_ROOT_PAGE, + CONF_SECTIONS, + CONF_SIDEBAR_HEADER_STYLE, + CONF_SIDEBAR_PAGE, + CONF_TITLE, + LV_MENU_MODES, + LV_MENU_ROOT_BACK_BUTTON_MODES, + TYPE_FLEX, + get_menu_set_page_actions, + get_widget_map, + literal, +) +from ..lv_validation import lv_text +from ..lvcode import ( + EVENT_ARG, + LambdaContext, + LocalVariable, + LvConditional, + lv, + lv_add, + lv_expr, + lv_obj, +) +from ..schemas import container_schema, part_schema +from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr, lvgl_ns +from . import ( + Widget, + WidgetType, + get_widget_, + get_widgets, + set_obj_properties, + widget_to_code, +) +from .obj import obj_spec + +# Local to the "menu" widget only -- not a shared config concept. +CONF_SIDEBAR = "sidebar" + +# Tag types, only used to distinguish declare_id()/use_id() checks for pages and +# sections -- like tileview's lv_tileview_tile_t they carry no extra behaviour, but +# they must have their own names: id type checks compare the type name, so reusing +# `lv_obj_t` here would let any object be passed where a page is required. The +# generated code always declares the variable as `lv_obj_t *`. +lv_menu_page_t = LvType("lv_menu_page_t") +lv_menu_section_t = LvType("lv_menu_section_t") + +lv_menu_t = LvType( + "lv_menu_t", + largs=[(lv_obj_t_ptr, "page")], + lvalue=lambda w: lv_expr.menu_get_cur_main_page(w.obj), + has_on_value=True, +) + + +class MenuSectionType(WidgetType): + """ + A grouped, bordered set of rows inside a menu page (lv_menu_section_create). + Only valid as a direct child of a menu page, so it's not exposed in the + generic `widgets:` menu -- reached only via a page's `sections:` key. + """ + + def __init__(self): + super().__init__( + "menu_section", lv_menu_section_t, (CONF_MAIN,), {}, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + return lv_expr.menu_section_create(parent) + + +menu_section_spec = MenuSectionType() + +MENU_PAGE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TITLE): lv_text, + cv.Optional(CONF_SECTIONS): cv.ensure_list( + container_schema( + menu_section_spec, + {cv.GenerateID(): cv.declare_id(lv_menu_section_t)}, + ) + ), + } +) + + +class MenuPageType(WidgetType): + """ + A page of the menu (lv_menu_page_create), shown one at a time via + `lvgl.menu.set_page`. Like tabview's tabs/tileview's tiles, pages are only + ever declared via the menu's own `pages:` key, not the generic `widgets:` + menu -- reachable content, not a freestanding widget type. + """ + + def __init__(self): + super().__init__( + "menu_page", lv_menu_page_t, (CONF_MAIN,), MENU_PAGE_SCHEMA, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + title = config.get(CONF_TITLE) + return lv_expr.menu_page_create( + parent, await lv_text.process(title) if title is not None else cg.nullptr + ) + + async def on_create(self, var: cg.MockObj, config: dict): + # Runs before the page's ungrouped `widgets:` are added, so sections come first. + for section_conf in config.get(CONF_SECTIONS, ()): + await widget_to_code(section_conf, menu_section_spec, var) + return var + + +menu_page_spec = MenuPageType() + +MENU_SCHEMA = cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list( + container_schema( + menu_page_spec, {cv.GenerateID(): cv.declare_id(lv_menu_page_t)} + ) + ), + cv.Length(min=1), + ), + cv.Required(CONF_ROOT_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_HEADER_MODE, default="top_fixed"): LV_MENU_MODES.one_of, + cv.Optional( + CONF_ROOT_BACK_BTN, default="disabled" + ): LV_MENU_ROOT_BACK_BUTTON_MODES.one_of, + cv.Optional(CONF_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_SIDEBAR_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_ON_ROOT_BACK_BUTTON_CLICK): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template())} + ), + } +) + + +class MenuType(WidgetType): + def __init__(self): + super().__init__( + CONF_MENU, lv_menu_t, (CONF_MAIN,), schema=MENU_SCHEMA, modify_schema={} + ) + + def get_uses(self): + return CONF_BUTTON, CONF_IMAGE, TYPE_FLEX + + def validate(self, value: ConfigType) -> ConfigType: + # The sidebar header only exists once a sidebar page has been set. + if CONF_SIDEBAR_HEADER_STYLE in value and CONF_SIDEBAR_PAGE not in value: + raise cv.Invalid( + f"'{CONF_SIDEBAR_HEADER_STYLE}' requires '{CONF_SIDEBAR_PAGE}'", + path=[CONF_SIDEBAR_HEADER_STYLE], + ) + # Runs before the schema, so the default has not been applied yet and an + # invalid value is left for the schema to report. + back_btn = ( + str(value.get(CONF_ROOT_BACK_BTN, "disabled")) + .upper() + .removeprefix(LV_MENU_ROOT_BACK_BUTTON_MODES.prefix) + ) + if CONF_ON_ROOT_BACK_BUTTON_CLICK in value and back_btn != "ENABLED": + raise cv.Invalid( + f"'{CONF_ON_ROOT_BACK_BUTTON_CLICK}' requires " + f"'{CONF_ROOT_BACK_BTN}: enabled'", + path=[CONF_ON_ROOT_BACK_BUTTON_CLICK], + ) + return value + + async def to_code(self, w: Widget, config: dict): + await w.set_property( + "mode_header", await LV_MENU_MODES.process(config[CONF_HEADER_MODE]) + ) + await w.set_property( + "mode_root_back_button", + await LV_MENU_ROOT_BACK_BUTTON_MODES.process(config[CONF_ROOT_BACK_BTN]), + ) + for page_conf in config[CONF_PAGES]: + await widget_to_code(page_conf, menu_page_spec, w.obj) + + root_page = await get_widget_(config[CONF_ROOT_PAGE]) + lv.menu_set_page(w.obj, root_page.obj) + if sidebar_id := config.get(CONF_SIDEBAR_PAGE): + sidebar_page = await get_widget_(sidebar_id) + lv.menu_set_sidebar_page(w.obj, sidebar_page.obj) + + if header_style := config.get(CONF_HEADER_STYLE): + with LocalVariable( + "menu_header", lv_obj_t, rhs=lv_expr.menu_get_main_header(w.obj) + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), header_style) + if sidebar_header_style := config.get(CONF_SIDEBAR_HEADER_STYLE): + with LocalVariable( + "menu_sidebar_header", + lv_obj_t, + rhs=lv_expr.menu_get_sidebar_header(w.obj), + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), sidebar_header_style) + + +menu_spec = MenuType() + + +def _menu_page_owners(global_config: FinalValidateConfig) -> dict[str, tuple]: + """ + Map every declared menu page id to the config path of the menu that owns it. + + A page id is always declared inside its menu's `pages:` list, so the owning menu + is three steps up the path (`id` -> list index -> `pages`). + """ + return { + str(declared_id): tuple(path[:-3]) + for declared_id, path in global_config.declare_ids + if declared_id.type is lv_menu_page_t + } + + +def validate_menu_pages(global_config: FinalValidateConfig) -> None: + """ + Check that a menu only ever refers to pages of its own. + + `lv_menu_page_create()` makes a page a child of one specific menu, so handing a + page of another menu to `root_page:`, `sidebar_page:` or `lvgl.menu.set_page` + can only misbehave at run time. It also hangs code generation when the other + menu is defined later, since the waiting menu's page never appears. + """ + owners = _menu_page_owners(global_config) + for menu_path in set(owners.values()): + menu_config = global_config.get_config_for_path(list(menu_path)) + for key in (CONF_ROOT_PAGE, CONF_SIDEBAR_PAGE): + page_id = menu_config.get(key) + if page_id is not None and owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'{page_id}' is not a page of this menu; " + f"'{key}' must name one of the menu's own 'pages:'", + # Paths reported from final validation are relative to the + # component config, so drop the leading domain name. + path=[*menu_path[1:], key], + ) + for config in get_menu_set_page_actions(): + menu_id = config[CONF_ID] + page_id = config[CONF_PAGE] + menu_path = tuple(global_config.get_path_for_id(menu_id)[:-1]) + if owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'lvgl.menu.set_page' page '{page_id}' is not a page of menu '{menu_id}'" + ) + + +async def add_root_back_button_triggers() -> None: + """ + Wire up on_root_back_button_click for every menu, once all widgets exist. + Actions like `lvgl.widget.hide` on the menu itself need the widget map fully + populated, so this must run as a deferred pass rather than inline in + MenuType.to_code -- doing it inline deadlocks the code-generation coroutine + scheduler. + """ + # Snapshot: build_automation() can create widgets (e.g. `lvgl.list.add`). + for w in list(get_widget_map().values()): + if not isinstance(w.type, MenuType): + continue + for conf in w.config.get(CONF_ON_ROOT_BACK_BUTTON_CLICK, ()): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await automation.build_automation(trigger, [], conf) + async with LambdaContext(EVENT_ARG, where=conf[CONF_TRIGGER_ID]) as context: + with LvConditional( + lvgl_ns.lv_menu_back_button_is_root_click(w.obj, literal("event")) + ): + lv_add(trigger.trigger()) + lv_obj.add_event_cb( + w.obj, await context.get_lambda(), LV_EVENT.CLICKED, cg.nullptr + ) + + +def _record_set_page_action(config: ConfigType) -> ConfigType: + """Remember the action so validate_menu_pages() can check the page's owner once + the whole config is known.""" + get_menu_set_page_actions().append(config) + return config + + +@automation.register_action( + "lvgl.menu.set_page", + ObjUpdateAction, + cv.All( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_menu_t), + cv.Required(CONF_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR, default=False): cv.boolean, + } + ), + _record_set_page_action, + ), + synchronous=True, +) +async def menu_set_page(config, action_id, template_arg, args): + widget = await get_widgets(config) + page = await get_widget_(config[CONF_PAGE]) + sidebar = config[CONF_SIDEBAR] + + async def do_set_page(w: Widget): + if sidebar: + lv.menu_set_sidebar_page(w.obj, page.obj) + else: + lv.menu_set_page(w.obj, page.obj) + + return await action_to_code(widget, do_set_page, action_id, template_arg, args) diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index d6790caa35d..e4985c91fa1 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -19,7 +19,7 @@ using alloc_string_t = std::basic_string, RAMAlloca * @tparam V The type of the value in the mapping. Should be a basic type or pointer. */ -static const char *const TAG = "mapping"; +ESPHOME_LOG_TAG(TAG, "mapping"); template class Mapping { public: diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e49a3897fee..8c28603e78c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -1,9 +1,12 @@ import esphome.codegen as cg +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( + CONF_API, CONF_DISABLED, + CONF_ETHERNET, CONF_ID, CONF_OPENTHREAD, CONF_PORT, @@ -186,10 +189,16 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def _openthread_srp_registers_services() -> bool: + """Without border_router, the OpenThread SRP client registers the services instead of mDNS.""" + openthread_config = CORE.config.get(CONF_OPENTHREAD) + return openthread_config is not None and CONF_BORDER_ROUTER not in openthread_config + + def request_service_enable_disable() -> bool: """Request MDNSComponent::set_service_enabled() support. - ESP32 only, not with OpenThread. Returns True when the + ESP32 only, not with the OpenThread SRP client. Returns True when the USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. Public API for external components. Do not remove. @@ -199,7 +208,7 @@ def request_service_enable_disable() -> bool: mdns_config is None or mdns_config[CONF_DISABLED] or not CORE.is_esp32 - or CONF_OPENTHREAD in CORE.config + or _openthread_srp_registers_services() ): return False cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") @@ -285,6 +294,23 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # With both wifi and ethernet, the _esphomelib "network" TXT value follows the + # connected interface. The OpenThread SRP client registers the services once, + # so it keeps the fixed value. + if ( + CONF_API in CORE.config + and CONF_WIFI in CORE.config + and CONF_ETHERNET in CORE.config + and not _openthread_srp_registers_services() + ): + from esphome.components import network + + cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT") + # Where the network component tracks the default route (ESP32), follow + # it; otherwise mdns checks the connected interface every second. + if await network.register_active_interface_listener(var): + cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER") + for service in config[CONF_SERVICES]: txt_records = [ await _mdns_txt_record_templated(var, txt_key, txt_value) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4397c101fc5..32a84671fea 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,57 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_(const char *network) { + // With no interface connected, keep the last value instead of flapping to wifi + if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} + +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +void MDNSComponent::on_active_interface(network::NetworkInterface interface) { + switch (interface) { + case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET: + this->update_network_txt_(NETWORK_ETHERNET); + break; + case network::NetworkInterface::NETWORK_INTERFACE_WIFI: + this->update_network_txt_(NETWORK_WIFI); + break; + default: + break; + } +} +#endif +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +215,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..1ff92f07f34 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,13 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the +// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports +// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS. +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +#include "esphome/components/network/network_component.h" +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -73,6 +80,10 @@ class MDNSComponent final : public Component , public ethernet::EthernetIPStateListener #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + , + public network::ActiveInterfaceListener +#endif { public: void setup() override; @@ -115,8 +126,8 @@ class MDNSComponent final : public Component #ifndef USE_MDNS_STORE_SERVICES #error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" #endif -#ifdef USE_OPENTHREAD -#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#ifdef USE_OPENTHREAD_SRP +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with the OpenThread SRP client" #endif /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. @@ -134,6 +145,10 @@ class MDNSComponent final : public Component } #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + void on_active_interface(network::NetworkInterface interface) override; +#endif + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, const network::IPAddress &dns2) override; @@ -150,6 +165,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Publish network as the "network" TXT value if it changed; nullptr keeps the current value. + void update_network_txt_(const char *network); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 1df5b9217e5..7953eea3091 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { ESPHOME_LOG_TAG(TAG, "mdns"); -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP static esp_err_t add_service(const MDNSService &service) { // Stack buffer for up to 16 txt records, heap fallback for more SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); @@ -30,7 +30,7 @@ static esp_err_t add_service(const MDNSService &service) { #endif static void register_esp32(MDNSComponent *comp, StaticVector &services) { -#ifdef USE_OPENTHREAD +#ifdef USE_OPENTHREAD_SRP // OpenThread handles service registration via SRP client // Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent #else @@ -62,7 +62,7 @@ static void register_esp32(MDNSComponent *comp, StaticVectoris_ready()) { @@ -87,12 +87,30 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr ESP_LOGW(TAG, "Service %s not found", service_type); return false; } -#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD_SRP -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER) + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); +#endif +} void MDNSComponent::on_shutdown() { -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent #endif diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..f27b6b49812 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,27 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 9477ebc850e..ca65fa1a5e6 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -13,6 +13,7 @@ from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, + CONF_FRAMEWORK, CONF_ID, CONF_INTERNAL, CONF_MICROPHONE, @@ -26,10 +27,12 @@ from esphome.const import ( CONF_TYPE, CONF_URL, CONF_USERNAME, + PLATFORM_ESP32, TYPE_GIT, TYPE_LOCAL, ) from esphome.core import CORE, HexInt +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,6 +44,8 @@ DEPENDENCIES = ["microphone"] DOMAIN = "micro_wake_word" +CONF_TASK_CORE = "task_core" + CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" @@ -407,6 +412,27 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config +def _validate_task_core(config: ConfigType) -> ConfigType: + if config[CONF_TASK_CORE] != 1: + return config + esp32.only_on_variant( + supported=[ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H4, + esp32.VARIANT_ESP32S31, + ], + msg_prefix="micro_wake_word task_core: 1", + )(config) + sdkconfig = fv.full_config.get()[PLATFORM_ESP32][CONF_FRAMEWORK][ + esp32.CONF_SDKCONFIG_OPTIONS + ] + if sdkconfig.get("CONFIG_FREERTOS_UNICORE") == "y": + raise cv.Invalid("task_core: 1 requires FreeRTOS dual-core mode") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -427,6 +453,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( @@ -509,6 +536,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( }, extra=cv.ALLOW_EXTRA, ), + _validate_task_core, _feature_step_size_validate, ) @@ -521,6 +549,12 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + cg.add_define( + "MICRO_WAKE_WORD_TASK_CORE", + cg.RawExpression("tskNO_AFFINITY") + if config[CONF_TASK_CORE] == -1 + else config[CONF_TASK_CORE], + ) if config[CONF_PREFER_INTERNAL_RAM]: cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 4ae66d69bf0..872dd7bd79a 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,6 +17,10 @@ namespace esphome::micro_wake_word { +// Config validation checks the YAML, this checks the build: a unicore sdkconfig reached any other way +static_assert(MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY || MICRO_WAKE_WORD_TASK_CORE < portNUM_PROCESSORS, + "micro_wake_word task_core is not a core of this build"); + ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; @@ -84,6 +88,12 @@ static const LogString *micro_wake_word_state_to_string(State state) { void MicroWakeWord::dump_config() { ESP_LOGCONFIG(TAG, "microWakeWord:"); + // tskNO_AFFINITY is a cast expression, so this cannot be a preprocessor test; the compiler folds it + if (MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY) { + ESP_LOGCONFIG(TAG, " Inference task core: any"); + } else { + ESP_LOGCONFIG(TAG, " Inference task core: %d", MICRO_WAKE_WORD_TASK_CORE); + } ESP_LOGCONFIG(TAG, " models:"); for (auto &model : this->wake_word_models_) { model->log_model_config(); @@ -211,7 +221,7 @@ void MicroWakeWord::inference_task(void *params) { xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); // The frontend buffers samples internally and only emits a feature once it has a full window, so we can // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() @@ -477,7 +487,8 @@ void MicroWakeWord::loop() { } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, - (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_, + MICRO_WAKE_WORD_TASK_CORE)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error(1000); } diff --git a/esphome/components/microphone/microphone_source.cpp b/esphome/components/microphone/microphone_source.cpp index 288e9f8c8e5..8ef091d3a40 100644 --- a/esphome/components/microphone/microphone_source.cpp +++ b/esphome/components/microphone/microphone_source.cpp @@ -1,9 +1,20 @@ #include "microphone_source.h" +#include + +#include // esp-audio-libs +#include // esp-audio-libs + namespace esphome::microphone { -static const int32_t Q25_MAX_VALUE = (1 << 25) - 1; -static const int32_t Q25_MIN_VALUE = ~Q25_MAX_VALUE; +// Builds the exact LinearGain for an integer factor without the float dB conversion: the factor is +// mantissa / 2^31 * 2^exponent, with the exponent set to the factor's bit width. +static esp_audio_libs::gain::LinearGain factor_to_linear_gain(uint32_t factor) { + esp_audio_libs::gain::LinearGain gain; + gain.exponent = 32 - __builtin_clz(factor); + gain.mantissa_q31 = static_cast(factor << (31 - gain.exponent)); + return gain; +} audio::AudioStreamInfo MicrophoneSource::get_audio_stream_info() { return audio::AudioStreamInfo(this->bits_per_sample_, this->channels_.count(), @@ -26,50 +37,26 @@ void MicrophoneSource::stop() { } void MicrophoneSource::process_audio_(const std::vector &data, std::vector &filtered_data) { - // - Bit depth conversions are obtained by truncating bits or padding with zeros - no dithering is applied. - // - In the comments, Qxx refers to a fixed point number with xx bits of precision for representing fractional values. - // For example, audio with a bit depth of 16 can store a sample in a int16, which can be considered a Q15 number. - // - All samples are converted to Q25 before applying the gain factor - this results in a small precision loss for - // data with 32 bits per sample. Since the maximum gain factor is 64 = (1<<6), this ensures that applying the gain - // will never overflow a 32 bit signed integer. This still retains more bit depth than what is audibly noticeable. - // - Loops for reading/writing data buffers are unrolled, assuming little endian, for a small performance increase. + const audio::AudioStreamInfo source_info = this->mic_->get_audio_stream_info(); + const uint32_t total_frames = source_info.bytes_to_frames(data.size()); + const uint8_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t source_bytes_per_sample = this->mic_->get_audio_stream_info().samples_to_bytes(1); - const uint32_t source_channels = this->mic_->get_audio_stream_info().get_channels(); - - const size_t source_bytes_per_frame = this->mic_->get_audio_stream_info().frames_to_bytes(1); - - const uint32_t total_frames = this->mic_->get_audio_stream_info().bytes_to_frames(data.size()); - const size_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t target_bytes_per_frame = target_bytes_per_sample * this->channels_.count(); - - filtered_data.resize(target_bytes_per_frame * total_frames); - - uint8_t *current_data = filtered_data.data(); - - for (uint32_t frame_index = 0; frame_index < total_frames; ++frame_index) { - for (uint32_t channel_index = 0; channel_index < source_channels; ++channel_index) { - if (this->channels_.test(channel_index)) { - // Channel's current sample is included in the target mask. Convert bits per sample, if necessary. - - const uint32_t sample_index = frame_index * source_bytes_per_frame + channel_index * source_bytes_per_sample; - - int32_t sample = audio::unpack_audio_sample_to_q31(&data[sample_index], source_bytes_per_sample); // Q31 - sample >>= 6; // Q31 -> Q25 - - // Apply gain using multiplication - sample *= this->gain_factor_; // Q25 - - // Clamp ``sample`` in case gain multiplication overflows 25 bits - sample = clamp(sample, Q25_MIN_VALUE, Q25_MAX_VALUE); // Q25 - - sample *= (1 << 6); // Q25 -> Q31 - - audio::pack_q31_as_audio_sample(sample, current_data, target_bytes_per_sample); - current_data = current_data + target_bytes_per_sample; - } + // Enabled channels in ascending order, which is their order in each output frame + std::array channel_map; + uint8_t target_channels = 0; + for (size_t channel = 0; channel < this->channels_.size(); ++channel) { + if (this->channels_.test(channel)) { + channel_map[target_channels++] = static_cast(channel); } } + + filtered_data.resize(static_cast(total_frames) * target_bytes_per_sample * target_channels); + + // Applies the gain at full precision before narrowing; samples saturate at full scale, and narrowing rounds + esp_audio_libs::pcm_convert::copy_frames_with_gain(data.data(), filtered_data.data(), source_info.samples_to_bytes(1), + source_info.get_channels(), target_bytes_per_sample, + channel_map.data(), target_channels, + factor_to_linear_gain(this->gain_factor_), total_frames); } } // namespace esphome::microphone diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 30771d25ca2..e362c241e8c 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -5,7 +5,7 @@ namespace esphome::midea::ac { -const char *const Constants::TAG = "midea"; +const char Constants::TAG[] PROGMEM = "midea"; const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; const char *const Constants::SILENT = "Silent"; const char *const Constants::TURBO = "Turbo"; diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4a888ee8ff2..62e93d2465a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -17,7 +17,7 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: - static const char *const TAG; + static const char TAG[]; // PROGMEM, like ESPHOME_LOG_TAG static const char *const FREEZE_PROTECTION; static const char *const SILENT; static const char *const TURBO; diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 24bbfe76b0b..fce8ab99a9e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -127,6 +127,19 @@ ClimateTraits AirConditioner::traits() { return traits; } +#ifdef USE_REMOTE_TRANSMITTER +static const LogString *display_control_str(DisplayControl control) { + switch (control) { + case DisplayControl::DISPLAY_CONTROL_UART: + return LOG_STR("UART"); + case DisplayControl::DISPLAY_CONTROL_IR: + return LOG_STR("IR"); + default: + return LOG_STR("AUTO"); + } +} +#endif + void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" @@ -135,7 +148,10 @@ void AirConditioner::dump_config() { " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER - ESP_LOGCONFIG(Constants::TAG, " [x] Using RemoteTransmitter"); + ESP_LOGCONFIG(Constants::TAG, + " [x] Using RemoteTransmitter\n" + " [x] Display control: %s", + LOG_STR_ARG(display_control_str(this->display_control_))); #endif if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { this->base_.getCapabilities().dump(); @@ -183,16 +199,20 @@ void AirConditioner::do_swing_step() { } void AirConditioner::do_display_toggle() { - if (this->base_.getCapabilities().supportLightControl()) { - this->base_.displayToggle(); - } else { #ifdef USE_REMOTE_TRANSMITTER + // AUTO uses IR unless the appliance reports light control over UART. Without a transmitter + // built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it + // without advertising it in the 0xB5 capabilities report. + const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR || + (this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO && + !this->base_.getCapabilities().supportLightControl()); + if (use_ir) { IrSpecialData data(0x08); this->transmitter_.transmit(data); -#else - ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component"); -#endif + return; } +#endif + this->base_.displayToggle(); } } // namespace esphome::midea::ac diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..607027d9962 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,12 +21,20 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; +// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle() +enum class DisplayControl : uint8_t { + DISPLAY_CONTROL_UART, // always the UART request + DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR + DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter +}; + class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } + void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; /* ############### */ @@ -54,6 +62,7 @@ class AirConditioner final : public ApplianceBase ConfigType: + """`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not.""" + if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config: + raise cv.Invalid( + f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set" + ) + return config + CONFIG_SCHEMA = cv.All( climate.climate_schema(AirConditioner) @@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_BEEPER, default=False): cv.boolean, cv.Optional(CONF_AUTOCONF, default=True): cv.boolean, + cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control, cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes), cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( validate_swing_modes @@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + validate_display_control_with_transmitter, cv.only_on( [ PLATFORM_ESP32, @@ -204,6 +227,7 @@ async def to_code(config): cg.add(var.set_transmitter(transmitter_)) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) + cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) if CONF_SUPPORTED_SWING_MODES in config: diff --git a/esphome/components/midea_portasplit/__init__.py b/esphome/components/midea_portasplit/__init__.py new file mode 100644 index 00000000000..e94ead74d25 --- /dev/null +++ b/esphome/components/midea_portasplit/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_portasplit" diff --git a/esphome/components/midea_portasplit/climate.py b/esphome/components/midea_portasplit/climate.py new file mode 100644 index 00000000000..efac42a403b --- /dev/null +++ b/esphome/components/midea_portasplit/climate.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@Fexiven"] +DEPENDENCIES = ["uart"] + +midea_portasplit_ns = cg.esphome_ns.namespace("midea_portasplit") +PortaSplitClimate = midea_portasplit_ns.class_( + "PortaSplitClimate", climate.Climate, uart.UARTDevice, cg.Component +) + +CONFIG_SCHEMA = ( + climate.climate_schema(PortaSplitClimate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, baud_rate=9600, require_tx=True, require_rx=True +) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp new file mode 100644 index 00000000000..a1f22226d9c --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -0,0 +1,337 @@ +#include "midea_portasplit.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::midea_portasplit { + +ESPHOME_LOG_TAG(TAG, "midea_portasplit"); + +static constexpr uint32_t FOLLOWUP_TIMEOUT_ID = 0; + +static constexpr uint32_t STATUS_INTERVAL_MS = 30000; +static constexpr uint32_t NETWORK_INTERVAL_MS = 10000; +static constexpr uint32_t FOLLOWUP_DELAY_MS = 1500; +// At 9600 baud bytes within a frame arrive about 1 ms apart. +static constexpr uint32_t RX_TIMEOUT_MS = 100; + +// Ten header bytes plus CRC8 and checksum. +static constexpr size_t FRAME_OVERHEAD = 12; +static constexpr size_t MAX_TX_PAYLOAD = 24; + +static constexpr float MIN_TARGET_TEMP = 16.0f; +static constexpr float MAX_TARGET_TEMP = 30.0f; + +// The AC expects its Wi-Fi module to answer the 0x63 keepalive poll and to send periodic 0x0D network status +// frames. These frames use the XOR sync byte (0x1E ^ 0xAC = 0xB2). The AC does not use the IP address; the +// fixed frame below was captured from the original module. +static constexpr uint8_t MODULE_FRAME[31] PROGMEM = {0xAA, 0x1E, 0xAC, 0xB2, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, + 0x01, 0x04, 0xC0, 0xA8, 0xB2, 0x72, 0x00, 0x00, 0x00, 0x01, 0x01, + 0x01, 0x28, 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00}; +static constexpr uint8_t STATUS_QUERY[12] PROGMEM = {0x41, 0x21, 0x00, 0xFF, 0x03, 0xFF, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00}; +// Same capability queries the original module sends at boot. +static constexpr uint8_t CAPS_0[] PROGMEM = {0xB5, 0x01, 0x00}; +static constexpr uint8_t CAPS_1[] PROGMEM = {0xB5, 0x01, 0x01, 0x01}; + +// Two's complement of the sum of all bytes after the 0xAA start marker. +static uint8_t checksum(const uint8_t *frame, size_t len) { return (uint8_t) -sum_bytes(frame + 1, len - 1); } + +static uint8_t climate_mode_to_midea(climate::ClimateMode mode) { + switch (mode) { + case climate::CLIMATE_MODE_COOL: + return 2; + case climate::CLIMATE_MODE_DRY: + return 3; + case climate::CLIMATE_MODE_HEAT: + return 4; + case climate::CLIMATE_MODE_FAN_ONLY: + return 5; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return 1; + } +} + +static climate::ClimateMode midea_mode_to_climate(uint8_t mode) { + switch (mode) { + case 2: + return climate::CLIMATE_MODE_COOL; + case 3: + return climate::CLIMATE_MODE_DRY; + case 4: + return climate::CLIMATE_MODE_HEAT; + case 5: + return climate::CLIMATE_MODE_FAN_ONLY; + case 1: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +static uint8_t fan_mode_to_midea(climate::ClimateFanMode mode) { + switch (mode) { + case climate::CLIMATE_FAN_QUIET: + return 20; + case climate::CLIMATE_FAN_LOW: + return 40; + case climate::CLIMATE_FAN_MEDIUM: + return 60; + case climate::CLIMATE_FAN_HIGH: + return 80; + default: + return FAN_AUTO; + } +} + +// The AC reports its own fan steps (30 for low, 50 for medium), so classify by range. +static climate::ClimateFanMode midea_fan_to_climate(uint8_t fan) { + if (fan > 100) + return climate::CLIMATE_FAN_AUTO; + if (fan <= 30) + return climate::CLIMATE_FAN_QUIET; + if (fan <= 50) + return climate::CLIMATE_FAN_LOW; + if (fan <= 70) + return climate::CLIMATE_FAN_MEDIUM; + return climate::CLIMATE_FAN_HIGH; +} + +void PortaSplitClimate::setup() { + // The status interval starts with a random offset of up to 5 s, which also serves as the boot query. + this->set_timeout(10000, [this]() { this->send_capability_queries_(); }); + this->set_interval(STATUS_INTERVAL_MS, [this]() { this->send_status_query_(); }); + this->set_interval(NETWORK_INTERVAL_MS, [this]() { this->send_module_frame_(0x0D, this->net_counter_++, 0x08); }); +} + +void PortaSplitClimate::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t buf[32]; + size_t n; + while ((n = std::min((size_t) this->available(), sizeof(buf))) != 0 && this->read_array(buf, n)) { + for (size_t i = 0; i < n; i++) + this->rx_feed_(buf[i]); + this->rx_last_byte_ = now; + } + // The line went quiet mid-frame; drop it so its bytes cannot swallow the next one. + if (this->rx_len_ != 0 && now - this->rx_last_byte_ > RX_TIMEOUT_MS) + this->rx_len_ = 0; +} + +void PortaSplitClimate::dump_config() { + ESP_LOGCONFIG(TAG, "Midea PortaSplit:"); + this->dump_traits_(TAG); +} + +climate::ClimateTraits PortaSplitClimate::traits() { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_visual_min_temperature(MIN_TARGET_TEMP); + traits.set_visual_max_temperature(MAX_TARGET_TEMP); + traits.set_visual_temperature_step(0.5f); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + traits.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_VERTICAL, + }); + traits.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_BOOST, + climate::CLIMATE_PRESET_ECO, + climate::CLIMATE_PRESET_AWAY, // Frost protection (8 degC heating) + }); + return traits; +} + +void PortaSplitClimate::control(const climate::ClimateCall &call) { + if (call.get_mode().has_value()) { + const climate::ClimateMode mode = *call.get_mode(); + this->st_power_ = mode != climate::CLIMATE_MODE_OFF; + if (this->st_power_) + this->st_mode_ = climate_mode_to_midea(mode); + // Frost protection only runs while heating. + if (mode != climate::CLIMATE_MODE_HEAT) + this->st_frost_ = false; + } + if (call.get_target_temperature().has_value()) { + const float target = clamp(*call.get_target_temperature(), MIN_TARGET_TEMP, MAX_TARGET_TEMP); + this->st_target_half_ = (uint8_t) lroundf(target * 2.0f); + } + if (call.get_fan_mode().has_value()) + this->st_fan_ = fan_mode_to_midea(*call.get_fan_mode()); + if (call.get_swing_mode().has_value()) + this->st_swing_ = *call.get_swing_mode() == climate::CLIMATE_SWING_VERTICAL; + if (call.get_preset().has_value()) { + const climate::ClimatePreset preset = *call.get_preset(); + this->st_turbo_ = preset == climate::CLIMATE_PRESET_BOOST; + this->st_sleep_ = preset == climate::CLIMATE_PRESET_SLEEP; + this->st_eco_ = preset == climate::CLIMATE_PRESET_ECO; + this->st_frost_ = preset == climate::CLIMATE_PRESET_AWAY; + if (this->st_frost_) { + this->st_mode_ = climate_mode_to_midea(climate::CLIMATE_MODE_HEAT); + this->st_power_ = true; + } + } + this->send_set_command_(); +} + +void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len) { + uint8_t f[FRAME_OVERHEAD + MAX_TX_PAYLOAD] = {0}; + const size_t total = FRAME_OVERHEAD + len; + f[0] = 0xAA; + f[1] = (uint8_t) (total - 1); + f[2] = 0xAC; + // f[3] is the sync byte and must stay 0x00 on the PortaSplit; f[4..7] are reserved. + f[8] = proto; + f[9] = type; + progmem_memcpy(&f[10], payload, len); + f[10 + len] = crc8(&f[10], (uint8_t) len); + f[11 + len] = checksum(f, 11 + len); + this->write_array(f, total); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(sizeof(f))]; + ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); +#endif +} + +void PortaSplitClimate::send_set_command_() { + uint8_t p[MAX_TX_PAYLOAD] = {0}; + const uint8_t whole = this->st_target_half_ >> 1; + const bool half = this->st_target_half_ & 1; + p[0] = 0x40; + p[1] = 0x42; // 0x02 is mandatory, 0x40 enables the beeper + if (this->st_power_) + p[1] |= 0x01; + p[2] = (uint8_t) ((this->st_mode_ << 5) | (half ? 0x10 : 0x00) | ((whole - 16) & 0x0F)); + p[3] = this->st_fan_; + p[4] = 0x7F; + p[5] = 0x7F; + p[6] = 0xF0; + p[7] = this->st_swing_ ? 0x3C : 0x30; + p[8] = this->st_turbo_ ? 0x20 : 0x00; + p[9] = (uint8_t) ((this->st_ion_ ? 0x20 : 0x00) | (this->st_eco_ ? 0x80 : 0x00)); + p[10] = this->st_sleep_ ? 0x01 : 0x00; + p[18] = (uint8_t) ((whole - 12) & 0x1F); + p[19] = 0x80; + p[21] = 0x0A; + p[22] = this->st_frost_ ? 0x80 : 0x00; + p[23] = this->msg_id_++; + this->send_frame_(0x02, 0x02, p, sizeof(p)); + // Read back the state the AC actually applied. + this->set_timeout(FOLLOWUP_TIMEOUT_ID, FOLLOWUP_DELAY_MS, [this]() { this->send_status_query_(); }); +} + +void PortaSplitClimate::send_status_query_() { + uint8_t p[sizeof(STATUS_QUERY)]; + progmem_memcpy(p, STATUS_QUERY, sizeof(p)); + p[11] = this->msg_id_++; + this->send_frame_(0x00, 0x03, p, sizeof(p)); +} + +void PortaSplitClimate::send_capability_queries_() { + this->send_frame_(0x03, 0x03, CAPS_0, sizeof(CAPS_0)); + this->set_timeout(1000, [this]() { this->send_frame_(0x03, 0x03, CAPS_1, sizeof(CAPS_1)); }); +} + +void PortaSplitClimate::send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags) { + uint8_t f[sizeof(MODULE_FRAME)]; + progmem_memcpy(f, MODULE_FRAME, sizeof(f)); + f[6] = counter; + f[9] = type; + f[25] = wifi_flags; + f[30] = checksum(f, 30); + this->write_array(f, sizeof(f)); +} + +void PortaSplitClimate::rx_feed_(uint8_t b) { + if (this->rx_len_ == 0 && b != 0xAA) + return; + this->rx_[this->rx_len_++] = b; + if (this->rx_len_ < 3) + return; + const size_t total = (size_t) this->rx_[1] + 1; + // Every frame carries the appliance type 0xAC; anything else is a false start. + if (this->rx_len_ == 3 && (total < FRAME_OVERHEAD || total > MAX_FRAME || this->rx_[2] != 0xAC)) { + this->rx_len_ = 0; + return; + } + if (this->rx_len_ < total) + return; + this->rx_len_ = 0; + if (checksum(this->rx_.data(), total - 1) != this->rx_[total - 1]) { + ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); + return; + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_FRAME)]; + ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); +#endif + this->handle_frame_(this->rx_.data(), total); +} + +void PortaSplitClimate::handle_frame_(const uint8_t *f, size_t len) { + if (f[9] == 0x63) { + this->send_module_frame_(0x63, 0, 0x00); + } else if (f[9] == 0x05 && f[10] == 0xB5) { + // The original module acknowledges telemetry pushes by echoing them back. + this->write_array(f, len); + } else if (f[10] == 0xC0) { + this->handle_c0_(f, len); + } +} + +void PortaSplitClimate::handle_c0_(const uint8_t *f, size_t len) { + if (len < 26) + return; + const uint8_t mode = (f[12] >> 5) & 0x07; + const uint8_t target_half = (uint8_t) ((((f[12] & 0x0F) + 16) << 1) | ((f[12] & 0x10) ? 1 : 0)); + const climate::ClimateFanMode fan = midea_fan_to_climate(f[13]); + + this->st_power_ = f[11] & 0x01; + if (mode >= 1 && mode <= 5) + this->st_mode_ = mode; + this->st_target_half_ = target_half; + this->st_fan_ = fan_mode_to_midea(fan); + this->st_swing_ = (f[17] & 0x0F) == 0x0C; + this->st_turbo_ = f[18] & 0x20; + this->st_ion_ = f[19] & 0x20; + this->st_eco_ = f[19] & 0x10; // Set command uses bit 7, readback uses bit 4. + this->st_sleep_ = f[20] & 0x01; + this->st_frost_ = len > 31 && (f[31] & 0x80); + + this->mode = this->st_power_ ? midea_mode_to_climate(this->st_mode_) : climate::CLIMATE_MODE_OFF; + this->target_temperature = target_half / 2.0f; + // 0x00 and 0xFF mean the indoor probe gave no reading. + const uint8_t raw_temp = f[21]; + this->current_temperature = (raw_temp == 0x00 || raw_temp == 0xFF) ? NAN : ((float) raw_temp - 50.0f) / 2.0f; + this->fan_mode = fan; + this->swing_mode = this->st_swing_ ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + if (this->st_frost_) { + this->preset = climate::CLIMATE_PRESET_AWAY; + } else if (this->st_turbo_) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (this->st_sleep_) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (this->st_eco_) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else { + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->publish_state(); +} + +} // namespace esphome::midea_portasplit diff --git a/esphome/components/midea_portasplit/midea_portasplit.h b/esphome/components/midea_portasplit/midea_portasplit.h new file mode 100644 index 00000000000..22e71a42793 --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.h @@ -0,0 +1,63 @@ +#pragma once + +// Midea PortaSplit climate component. +// +// The PortaSplit uses sync byte 0x00 for climate frames and silently drops the +// XOR sync byte (LEN ^ 0xAC) frames that the `midea` component sends, which is +// why the stock `midea` platform cannot drive this unit. +// +// Frame: AA | LEN | AC | SYNC | 00 00 00 00 | PROTO | TYPE | PAYLOAD | CRC8 | CS + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/climate/climate.h" + +#include + +namespace esphome::midea_portasplit { + +static constexpr uint8_t FAN_AUTO = 102; + +class PortaSplitClimate : public climate::Climate, public uart::UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + protected: + static constexpr size_t MAX_FRAME = 128; + + // The payload may live in PROGMEM. + void send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len); + void send_set_command_(); + void send_status_query_(); + void send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags); + void send_capability_queries_(); + + void rx_feed_(uint8_t b); + void handle_frame_(const uint8_t *f, size_t len); + void handle_c0_(const uint8_t *f, size_t len); + + uint32_t rx_last_byte_{0}; + // Last known device state, mirrored from C0 so a set command does not clobber it. + uint8_t st_target_half_{48}; // Target temperature in half degrees + uint8_t st_mode_{1}; + uint8_t st_fan_{FAN_AUTO}; + bool st_power_ : 1 {false}; + bool st_swing_ : 1 {false}; + bool st_turbo_ : 1 {false}; + bool st_ion_ : 1 {false}; + bool st_sleep_ : 1 {false}; + bool st_eco_ : 1 {false}; + bool st_frost_ : 1 {false}; + + uint8_t msg_id_{1}; + uint8_t net_counter_{0}; + uint8_t rx_len_{0}; + std::array rx_{}; +}; + +} // namespace esphome::midea_portasplit diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index a27be568f25..64166012c20 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -297,15 +297,10 @@ void MipiDsi::draw_pixel_at(int x, int y, Color color) { break; } case display::COLOR_BITNESS_888: - if (this->color_mode_ == display::COLOR_ORDER_BGR) { - this->buffer_[pos * 3] = color.b; - this->buffer_[pos * 3 + 1] = color.g; - this->buffer_[pos * 3 + 2] = color.r; - } else { - this->buffer_[pos * 3] = color.r; - this->buffer_[pos * 3 + 1] = color.g; - this->buffer_[pos * 3 + 2] = color.b; - } + // The panel applies the colour order, so the buffer keeps the standard b,g,r layout + this->buffer_[pos * 3] = color.b; + this->buffer_[pos * 3 + 1] = color.g; + this->buffer_[pos * 3 + 2] = color.r; break; case display::COLOR_BITNESS_332: break; @@ -341,19 +336,12 @@ void MipiDsi::fill(Color color) { } case display::COLOR_BITNESS_888: - if (this->color_mode_ == display::COLOR_ORDER_BGR) { - for (size_t i = 0; i != this->width_ * this->height_; i++) { - this->buffer_[i * 3 + 0] = color.b; - this->buffer_[i * 3 + 1] = color.g; - this->buffer_[i * 3 + 2] = color.r; - } - } else { - for (size_t i = 0; i != this->width_ * this->height_; i++) { - this->buffer_[i * 3 + 0] = color.r; - this->buffer_[i * 3 + 1] = color.g; - this->buffer_[i * 3 + 2] = color.b; - } + for (size_t i = 0; i != this->width_ * this->height_; i++) { + this->buffer_[i * 3 + 0] = color.b; + this->buffer_[i * 3 + 1] = color.g; + this->buffer_[i * 3 + 2] = color.r; } + break; default: break; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4243d3116cf..ba3f29ca05a 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -18,7 +18,7 @@ namespace esphome::mipi_dsi { -constexpr static const char *const TAG = "display.mipi_dsi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; @@ -39,8 +39,6 @@ class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} - display::ColorOrder get_color_mode() { return this->color_mode_; } - void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } @@ -102,7 +100,6 @@ class MipiDsi final : public display::Display { uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes bool invert_colors_{}; - display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; display::ColorBitness color_depth_; uint8_t pixel_mode_{}; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 8b29e273abd..01b1ebee91f 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "esphome/core/gpio.h" +#include "esphome/core/log.h" #include "esphome/components/display/display.h" #include #ifdef USE_SPI @@ -10,7 +11,7 @@ namespace esphome::mipi_rgb { -constexpr static const char *const TAG = "display.mipi_rgb"; +ESPHOME_LOG_TAG(TAG, "display.mipi_rgb"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py index 9e3fe2a476f..62f8c7789d4 100644 --- a/esphome/components/mipi_rgb/models/__init__.py +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip): def transforms(self) -> set[str]: """ Return the set of transformations supported by this driver chip. - RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY. + A model with an empty init sequence has no command interface at all, so it + cannot apply any transform in hardware. """ + if self.initsequence is not None and len(self.initsequence) == 0: + return set() return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48a2cafbdd3..f55ae723780 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -7,10 +7,11 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome::mipi_spi { -constexpr static const char *const TAG = "display.mipi_spi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_spi"); // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; @@ -129,19 +130,8 @@ class MipiSpi : public display::Display, } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - int get_width() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return HEIGHT; - return WIDTH; - } - - int get_height() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return WIDTH; - return HEIGHT; - } + int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; } + int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; } void set_init_sequence(const uint8_t *sequence, size_t len) { this->init_sequence_ = sequence; @@ -580,12 +570,12 @@ class MipiSpiBuffer if (this->auto_clear_enabled_) { this->clear(); } - if (this->page_ != nullptr) { + if (this->show_test_card_) { + this->test_card(); + } else if (this->page_ != nullptr) { this->page_->get_writer()(*this); } else if (this->writer_.has_value()) { (*this->writer_)(*this); - } else { - this->test_card(); } #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE esph_log_v(TAG, "Drawing from line %d took %dms", this->start_line_, millis() - lap); @@ -620,6 +610,9 @@ class MipiSpiBuffer #endif } + // With hardware rotation the buffer is written in drawn coordinates. + bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } + // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { if (this->is_point_clipped(x, y)) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 23867aef914..f52199093f8 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -302,7 +302,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptrfill(ticks_to_wait, false); + size_t bytes_read = audio_source->fill(ticks_to_wait); uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 826d4e32ef2..fe426827ce6 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -53,7 +53,6 @@ from esphome.const import ( CONF_SUBSCRIBE_QOS, CONF_TOPIC, CONF_TOPIC_PREFIX, - CONF_TRIGGER_ID, CONF_USE_ABBREVIATIONS, CONF_USERNAME, CONF_WILL_MESSAGE, @@ -119,18 +118,6 @@ MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTMessageTrigger = mqtt_ns.class_( - "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component -) -MQTTJsonMessageTrigger = mqtt_ns.class_( - "MQTTJsonMessageTrigger", automation.Trigger.template(cg.JsonObjectConst) -) -MQTTConnectTrigger = mqtt_ns.class_( - "MQTTConnectTrigger", automation.Trigger.template(cg.bool_) -) -MQTTDisconnectTrigger = mqtt_ns.class_( - "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) -) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) MQTTAlarmControlPanelComponent = mqtt_ns.class_( @@ -229,7 +216,6 @@ JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( "on_json_message", "the parsed JSON document", { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, }, @@ -295,21 +281,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REBOOT_TIMEOUT, default="15min" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_CONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTConnectTrigger), - } - ), - cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTDisconnectTrigger - ), - } - ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation(), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(), cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, cv.Optional(CONF_PAYLOAD): cv.string_strict, @@ -346,6 +321,18 @@ def exp_mqtt_message(config): ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "set_on_connect", [(cg.bool_, "session_present")] + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, + "set_on_disconnect", + [(MQTTClientDisconnectReason, "reason")], + ), +) + + @coroutine_with_priority(CoroPriority.WEB) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -460,29 +447,37 @@ async def to_code(config): cg.add_define("USE_MQTT_IDF_ENQUEUE") # end esp-idf + # The client queues subscriptions until it connects, so they can be made at construction. for conf in config.get(CONF_ON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC]) - cg.add(trig.set_qos(conf[CONF_QOS])) - if CONF_PAYLOAD in conf: - cg.add(trig.set_payload(conf[CONF_PAYLOAD])) - await cg.register_component(trig, conf) - await automation.build_automation(trig, [(cg.std_string, "x")], conf) + callback = await automation.build_trigger_callback( + [(cg.std_string, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.std_string, "payload")], + forward=["payload"], + # The length is compared first; the literal stays in flash on ESP8266. + when=automation.ApplyCall( + "StringRef(payload) == {}", + ((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),), + ), + ) + cg.add( + var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]) + ) for conf in config.get(CONF_ON_JSON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC], conf[CONF_QOS]) - await automation.build_automation(trig, [(cg.JsonObjectConst, "x")], conf) - - for conf in config.get(CONF_ON_CONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.bool_, "session_present")], conf + callback = await automation.build_trigger_callback( + [(cg.JsonObjectConst, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.JsonObject, "root")], + forward=["root"], + ) + cg.add( + var.subscribe_json( + cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS] + ) ) - for conf in config.get(CONF_ON_DISCONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(MQTTClientDisconnectReason, "reason")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_publish_nan_as_none(config[CONF_PUBLISH_NAN_AS_NONE])) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 6e5fad3efc7..dd376251399 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -16,6 +16,7 @@ #include "lwip/dns.h" #include "lwip/err.h" #include "mqtt_component.h" +#include "mqtt_topic_match.h" #ifdef USE_API #include "esphome/components/api/api_server.h" @@ -49,6 +50,13 @@ MQTTClientComponent::MQTTClientComponent() { } // Connection +// Room for "esphome//" with the longest kind, "discover" +static constexpr size_t ESPHOME_TOPIC_BUFFER_SIZE = sizeof("esphome/discover/") + ESPHOME_DEVICE_NAME_MAX_LEN; + +static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const char *kind) { + buf_append_printf(topic, sizeof(topic), 0, "esphome/%s/%s", kind, App.get_name().c_str()); +} + void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { @@ -78,6 +86,13 @@ void MQTTClientComponent::setup() { this, [](void *self, uint8_t level, const char *tag, const char *message, size_t message_len) { static_cast(self)->on_log(level, tag, message, message_len); }); +#ifdef USE_MQTT_CRASH_LOG + // `esphome logs` announces itself here, like an API client's log subscription, and the record stays + // loggable until the next boot, so the offer does too. A log topic that drops errors would swallow the + // replay, so it is not offered. The topic is rebuilt from the device name when needed, so nothing is + // stored for it. + this->crash_log_offered_ = logger::crash_log_pending() && this->log_level_ >= ESPHOME_LOG_LEVEL_ERROR; +#endif } #endif @@ -87,10 +102,8 @@ void MQTTClientComponent::setup() { 2); // Format topic on stack - subscribe() copies it - // "esphome/ping/" (13) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t ping_topic_buffer_size = 13 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char ping_topic[ping_topic_buffer_size]; - buf_append_printf(ping_topic, sizeof(ping_topic), 0, "esphome/ping/%s", App.get_name().c_str()); + char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(ping_topic, "ping"); this->subscribe( ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); } @@ -105,10 +118,8 @@ void MQTTClientComponent::send_device_info_() { return; } // Format topic on stack to avoid heap allocation - // "esphome/discover/" (17) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t topic_buffer_size = 17 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char topic[topic_buffer_size]; - buf_append_printf(topic, sizeof(topic), 0, "esphome/discover/%s", App.get_name().c_str()); + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "discover"); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson this->publish_json( @@ -354,6 +365,14 @@ void MQTTClientComponent::check_connected() { delay(100); // NOLINT this->resubscribe_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + // Best effort: a refused subscription is tried again at the next connection + if (this->crash_log_offered_) { + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "logs"); + this->subscribe_(topic, 0); + } +#endif this->send_device_info_(); for (MQTTComponent *component : this->children_) @@ -461,34 +480,31 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } -void MQTTClientComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - MQTTSubscription subscription{ - .topic = topic, - .qos = qos, - .callback = std::move(callback), - .subscribed = false, - .resubscribe_timeout = 0, - }; - this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); +#ifdef USE_MQTT_CRASH_LOG +void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) { + // Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash + // log for every line it logs + if (!this->crash_log_offered_ || !payload.empty()) + return; + char logs_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(logs_topic, "logs"); + if (topic == logs_topic) + logger::log_crash_to_client(); } +#endif -void MQTTClientComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - auto f = [callback](const std::string &topic, const std::string &payload) { - json::parse_json(payload, [topic, callback](JsonObject root) -> bool { - callback(topic, root); - return true; - }); - }; - MQTTSubscription subscription{ - .topic = topic, +void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, + uint8_t qos) { + auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ + .topic = std::move(topic), .qos = qos, - .callback = f, .subscribed = false, + .boxed = boxed, + .removed = false, + .callback = callback, .resubscribe_timeout = 0, - }; + }); this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); } void MQTTClientComponent::unsubscribe(const std::string &topic) { @@ -502,10 +518,25 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { this->status_momentary_warning(1000); } - auto it = subscriptions_.begin(); - while (it != subscriptions_.end()) { - if (it->topic == topic) { - it = subscriptions_.erase(it); + for (auto &subscription : this->subscriptions_) { + if (subscription.topic == topic) + subscription.removed = true; + } + // A callback may be running; the outermost dispatch erases them once it returns. + if (this->dispatching_) { + this->removed_while_dispatching_ = true; + } else { + this->erase_removed_subscriptions_(); + } +} + +void MQTTClientComponent::erase_removed_subscriptions_() { + auto it = this->subscriptions_.begin(); + while (it != this->subscriptions_.end()) { + if (it->removed) { + if (it->boxed) + it->callback.free_boxed(); + it = this->subscriptions_.erase(it); } else { ++it; } @@ -581,63 +612,6 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } -/** Check if the message topic matches the given subscription topic - * - * INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings. - * - * @param message The message topic that was received from the MQTT server. Note: this must not contain - * wildcard characters as mandated by the MQTT spec. - * @param subscription The subscription topic we are matching against. - * @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$". - * @param past_separator Are we past the first '/' topic separator. - * @return true if the subscription topic matches the message topic, false otherwise. - */ -static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { - // Reached end of both strings at the same time, this means we have a successful match - if (*message == '\0' && *subscription == '\0') - return true; - - // Either the message or the subscribe are at the end. This means they don't match. - if (*message == '\0' || *subscription == '\0') - return false; - - bool do_wildcards = is_normal || past_separator; - - if (*subscription == '+' && do_wildcards) { - // single level wildcard - // consume + from subscription - subscription++; - // consume everything from message until '/' found or end of string - while (*message != '\0' && *message != '/') { - message++; - } - // after this, both pointers will point to a '/' or to the end of the string - - return topic_match(message, subscription, is_normal, true); - } - - if (*subscription == '#' && do_wildcards) { - // multilevel wildcard - MQTT mandates that this must be at end of subscribe topic - return true; - } - - // this handles '/' and normal characters at the same time. - if (*message != *subscription) - return false; - - past_separator = past_separator || *subscription == '/'; - - // consume characters - subscription++; - message++; - - return topic_match(message, subscription, is_normal, past_separator); -} - -static bool topic_match(const char *message, const char *subscription) { - return topic_match(message, subscription, *message != '\0' && *message != '$', false); -} - void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { #ifdef USE_ESP8266 // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. @@ -660,9 +634,24 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { #endif - for (auto &subscription : this->subscriptions_) { - if (topic_match(topic.c_str(), subscription.topic.c_str())) - subscription.callback(topic, payload); +#ifdef USE_MQTT_CRASH_LOG + this->handle_crash_log_request_(topic, payload); +#endif + // Indexed with the count taken up front: a callback may subscribe, which can reallocate the + // vector, and the new entry waits for the next message. + // Restored rather than cleared, so only the outermost dispatch erases what was unsubscribed meanwhile. + const bool was_dispatching = this->dispatching_; + this->dispatching_ = true; + const size_t count = this->subscriptions_.size(); + for (size_t i = 0; i < count; i++) { + const auto &subscription = this->subscriptions_[i]; + if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str())) + subscription.callback.call(topic, payload); + } + this->dispatching_ = was_dispatching; + if (!was_dispatching && this->removed_while_dispatching_) { + this->removed_while_dispatching_ = false; + this->erase_removed_subscriptions_(); } #ifdef USE_ESP8266 }); @@ -762,29 +751,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -// MQTTMessageTrigger -MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::setup() { - global_mqtt_client->subscribe( - this->topic_, - [this](const std::string &topic, const std::string &payload) { - if (this->payload_.has_value() && payload != *this->payload_) { - return; - } - - this->trigger(payload); - }, - this->qos_); -} -void MQTTMessageTrigger::dump_config() { - ESP_LOGCONFIG(TAG, - "MQTT Message Trigger:\n" - " Topic: '%s'\n" - " QoS: %u", - this->topic_.c_str(), this->qos_); -} -float MQTTMessageTrigger::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index ced9c84e100..1de100019e3 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -24,6 +24,11 @@ #include +// The crash log replay needs the logger, which mqtt does not depend on +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#define USE_MQTT_CRASH_LOG +#endif + namespace esphome::mqtt { /** Callback for MQTT events. @@ -31,19 +36,24 @@ namespace esphome::mqtt { using mqtt_on_connect_callback_t = std::function; using mqtt_on_disconnect_callback_t = std::function; -/** Callback for MQTT subscriptions. - * - * First parameter is the topic, the second one is the payload. - */ -using mqtt_callback_t = std::function; -using mqtt_json_callback_t = std::function; +/// Stored callback of an MQTT subscription: the topic, then the payload. +using mqtt_subscription_callback_t = Callback; + +// Remove before 2027.5.0 +using mqtt_callback_t ESPDEPRECATED("subscribe() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; +using mqtt_json_callback_t ESPDEPRECATED( + "subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; /// internal struct for MQTT subscriptions. struct MQTTSubscription { std::string topic; uint8_t qos; - mqtt_callback_t callback; bool subscribed; + bool boxed; // made by create_boxed(), freed on unsubscribe + bool removed; // unsubscribed while a callback ran, erased once dispatch returns + mqtt_subscription_callback_t callback; uint32_t resubscribe_timeout; }; @@ -165,12 +175,21 @@ class MQTTClientComponent final : public Component { bool is_log_message_enabled() const; /** Subscribe to an MQTT topic and call callback when a message is received. + * + * A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is + * boxed on the heap and freed by unsubscribe(). * * @param topic The topic. Wildcards are currently not supported. * @param callback The callback function. * @param qos The QoS of this subscription. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline(); + this->add_subscription_(std::move(topic), + boxed ? mqtt_subscription_callback_t::create_boxed(std::forward(callback)) + : mqtt_subscription_callback_t::create(std::forward(callback)), + boxed, qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -181,11 +200,21 @@ class MQTTClientComponent final : public Component { * received. * @param qos The QoS of this subscription. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + static_assert(std::is_invocable_v &, const std::string &, JsonObject>, + "subscribe_json() needs a callback that does not mutate its captures"); + this->subscribe( + std::move(topic), + [cb = std::forward(callback)](const std::string &topic, const std::string &payload) { + call_json(cb, topic, payload); + }, + qos); + } /** Unsubscribe from an MQTT topic. * * If multiple existing subscriptions to the same topic exist, all of them will be removed. + * From inside a subscription callback they stop receiving messages at once and are erased once it returns. * * @param topic The topic to unsubscribe from. * Must match the topic in the original subscribe or subscribe_json call exactly. @@ -285,6 +314,17 @@ class MQTTClientComponent final : public Component { bool subscribe_(const char *topic, uint8_t qos); void resubscribe_subscription_(MQTTSubscription *sub); + template static void call_json(const F &callback, const std::string &topic, const std::string &payload) { + JsonDocument doc = json::parse_json(payload); + if (doc.overflowed() || doc.isNull()) + return; + callback(topic, doc.as()); + } + void erase_removed_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + void handle_crash_log_request_(const std::string &topic, const std::string &payload); +#endif + void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); MQTTCredentials credentials_; @@ -327,6 +367,9 @@ class MQTTClientComponent final : public Component { bool dns_resolved_{false}; bool dns_resolve_error_{false}; bool enable_on_boot_{true}; +#ifdef USE_MQTT_CRASH_LOG + bool crash_log_offered_{false}; +#endif std::vector children_; uint32_t reboot_timeout_{300000}; uint32_t connect_begin_; @@ -336,48 +379,12 @@ class MQTTClientComponent final : public Component { bool publish_nan_as_none_{false}; bool wait_for_connection_{false}; + bool dispatching_{false}; + bool removed_while_dispatching_{false}; }; extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger final : public Trigger, public Component { - public: - explicit MQTTMessageTrigger(std::string topic); - - void set_qos(uint8_t qos) { this->qos_ = qos; } - void set_payload(const std::string &payload) { this->payload_ = payload; } - void setup() override; - void dump_config() override; - float get_setup_priority() const override; - - protected: - std::string topic_; - uint8_t qos_{0}; - optional payload_; -}; - -class MQTTJsonMessageTrigger final : public Trigger { - public: - explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { - global_mqtt_client->subscribe_json( - topic, [this](const std::string &topic, JsonObject root) { this->trigger(root); }, qos); - } -}; - -class MQTTConnectTrigger final : public Trigger { - public: - explicit MQTTConnectTrigger(MQTTClientComponent *client) { - client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); - } -}; - -class MQTTDisconnectTrigger final : public Trigger { - public: - explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { - client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); - } -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index adb5a958287..7e4b8be725d 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -342,14 +342,6 @@ bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } -void MQTTComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - global_mqtt_client->subscribe(topic, std::move(callback), qos); -} - -void MQTTComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - global_mqtt_client->subscribe_json(topic, callback, qos); -} - MQTTComponent::MQTTComponent() = default; float MQTTComponent::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 141b5f12b38..461db5d3e76 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -259,7 +259,9 @@ class MQTTComponent : public Component { * @param callback The callback that will be called when a message with matching topic is received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe(std::move(topic), std::forward(callback), qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -270,7 +272,9 @@ class MQTTComponent : public Component { * received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe_json(std::move(topic), std::forward(callback), qos); + } protected: static constexpr uint32_t SEND_DEFER_ID = 0; diff --git a/esphome/components/mqtt/mqtt_topic_match.h b/esphome/components/mqtt/mqtt_topic_match.h new file mode 100644 index 00000000000..84172bc805e --- /dev/null +++ b/esphome/components/mqtt/mqtt_topic_match.h @@ -0,0 +1,28 @@ +#pragma once + +namespace esphome::mqtt { + +/// True if the message topic matches the subscription, with + and # wildcards. +/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics. +inline bool topic_match(const char *message, const char *subscription) { + // First-level wildcards do not match topics that begin with '$' + bool wildcards = *message != '$'; + while (*message != '\0' && *subscription != '\0') { + if (wildcards && *subscription == '#') + return true; + if (wildcards && *subscription == '+') { + subscription++; + while (*message != '\0' && *message != '/') + message++; + continue; + } + if (*message != *subscription) + return false; + wildcards |= *subscription == '/'; + message++; + subscription++; + } + return *message == *subscription; // a match only if both ended +} + +} // namespace esphome::mqtt diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1c08202d411..0227e0a6bd4 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -27,7 +27,8 @@ _LOGGER = logging.getLogger(__name__) # High performance networking tracking infrastructure # Components can request high performance networking and this configures lwip and WiFi settings -KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" +KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" # lwip tier +KEY_HIGH_PERFORMANCE_WIFI = "high_performance_wifi" # WiFi driver buffers CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" CONF_TCP_SEND_BUFFER = "tcp_send_buffer" @@ -100,6 +101,37 @@ if ( network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) + +_request_active_interface_listener_slot = cg.slot_counter( + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT" +) + + +def has_active_interface() -> bool: + """Return True when NetworkComponent tracks the interface holding the default route. + + That is ESP32 with more than one interface in 'network: priority:', the same + condition that emits USE_NETWORK_DEFAULT_ROUTE. + """ + return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1 + + +async def register_active_interface_listener(listener: cg.MockObj) -> bool: + """Register an ActiveInterfaceListener with the NetworkComponent. + + The listener is notified from the main loop when the interface holding the + default route changes. Returns False, and registers nothing, when + has_active_interface() is False; the caller then needs another way to + follow interface changes. + """ + if not has_active_interface(): + return False + _request_active_interface_listener_slot() + network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID]) + cg.add(network.add_active_interface_listener(listener)) + return True + + IPAddress = network_ns.class_("IPAddress") @@ -194,9 +226,26 @@ def require_high_performance_networking() -> None: _request_high_performance_networking, ) """ - # Only set up once (idempotent - multiple components can call this) - if not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False): - CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + require_high_performance_wifi() + + +def require_high_performance_wifi() -> None: + """Request only the WiFi driver part of high performance networking. + + For traffic that passes through the device below its own sockets, such as the + NAT of a WISP access point: more RX/TX buffers, AMPDU aggregation and a deeper + lwip input mailbox help every forwarded packet, while the larger TCP windows + would only serve the device's own connections and cost internal RAM for nothing. + ``network: enable_high_performance: false`` turns the mailbox part off like the + lwip tier; the WiFi driver settings follow the component request alone. + """ + CORE.data[KEY_HIGH_PERFORMANCE_WIFI] = True + + +def has_high_performance_wifi() -> bool: + """True when a component asked for the high performance WiFi driver settings.""" + return CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI, False) def has_high_performance_networking() -> bool: @@ -402,7 +451,7 @@ async def to_code(config: ConfigType) -> None: # frameworks build from source. # The ethernet/wifi-only assumption behind the arbitration is enforced in # _final_validate() so a future unsupported type fails as a config error. - if len(priority_list) > 1 and CORE.is_esp32: + if has_active_interface(): cg.add_define("USE_NETWORK_DEFAULT_ROUTE") # Have lwIP switch to the DNS servers of the netif that owns the # default route whenever the arbitration changes it. @@ -488,6 +537,13 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox) + elif ( + CORE.is_esp32 and enable_high_perf is not False and has_high_performance_wifi() + ): + # Forwarded frames still queue through tcpip_input, so the WiFi-only tier deepens that mailbox too + add_idf_sdkconfig_option( + "CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", TCPIP_RECVMBOX_OPTIMIZED + ) # After the high performance block so an explicit size wins over the # bundle's 65534 (last write wins in the sdkconfig store). diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index fad7a385fdd..ec6959c2d00 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -78,16 +78,24 @@ void NetworkComponent::loop() { // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; // it selects the reported address in util.cpp and doubles as the route-order // pivot here — the two uses must stay in sync. - esp_netif_t *best; + esp_netif_t *wifi = connected_wifi_netif(); + esp_netif_t *ethernet = connected_ethernet_netif(); #ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI - best = connected_wifi_netif(); - if (best == nullptr) - best = connected_ethernet_netif(); + esp_netif_t *best = wifi != nullptr ? wifi : ethernet; #else - best = connected_ethernet_netif(); - if (best == nullptr) - best = connected_wifi_netif(); + esp_netif_t *best = ethernet != nullptr ? ethernet : wifi; #endif + NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE; + if (best != nullptr) { + active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI; + } + if (active != this->active_interface_) { + this->active_interface_ = active; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + for (auto *listener : this->active_interface_listeners_) + listener->on_active_interface(active); +#endif + } if (best == nullptr) { // Forget the last winner: stopping its netif cleared lwIP's default route and // IDF's manual override suppresses re-election, so reconnect must re-assert it. diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 870530974ea..eff08524a6f 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,12 +4,32 @@ #include "esphome/core/component.h" #ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/helpers.h" // Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h // into this header. using esp_netif_t = struct esp_netif_obj; #endif namespace esphome::network { + +#ifdef USE_NETWORK_DEFAULT_ROUTE +/// The interface holding the default route. Lists only the interfaces loop() arbitrates +/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together. +enum class NetworkInterface : uint8_t { + NETWORK_INTERFACE_NONE, + NETWORK_INTERFACE_ETHERNET, + NETWORK_INTERFACE_WIFI, +}; + +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT +/// Notified from the main loop when the interface holding the default route changes. +class ActiveInterfaceListener { + public: + virtual void on_active_interface(NetworkInterface interface) = 0; +}; +#endif +#endif + class NetworkComponent final : public Component { public: void setup() override; @@ -20,6 +40,14 @@ class NetworkComponent final : public Component { #ifdef USE_NETWORK_DEFAULT_ROUTE void loop() override; + /// The first connected interface in network: priority order; NONE while none is connected. + NetworkInterface get_active_interface() const { return this->active_interface_; } +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + void add_active_interface_listener(ActiveInterfaceListener *listener) { + this->active_interface_listeners_.push_back(listener); + } +#endif + protected: // Verify-lwIP-route interval for the settled state; keeps the global lwIP core // mutex off the hot loop path. @@ -27,6 +55,10 @@ class NetworkComponent final : public Component { // Last netif this component made the default; avoids redundant esp_netif calls. esp_netif_t *default_netif_{nullptr}; uint32_t last_route_check_{0}; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + StaticVector active_interface_listeners_; +#endif + NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE}; #endif }; } // namespace esphome::network diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 11485fdcf03..f0a4c59dca8 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -2,10 +2,40 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #ifdef USE_NETWORK namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +ESPHOME_LOG_TAG(TAG, "network"); + +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses) { + esp_ip6_addr_t ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + size_t count = esp_netif_get_all_ip6(netif, ip6s); + // Slot 0 holds the IPv4 address + if (count > addresses.size() - 1) { + // Warn once per interface + static esp_netif_t *warned[3]{}; + for (auto &slot : warned) { + if (slot == netif) { + break; + } + if (slot == nullptr) { + slot = netif; + ESP_LOGW(TAG, "%s has %zu IPv6 addresses, only %zu are reported", esp_netif_get_desc(netif), count, + addresses.size() - 1); + break; + } + } + count = addresses.size() - 1; + } + for (size_t i = 0; i < count; i++) { + addresses[i + 1] = IPAddress(&ip6s[i]); + } +} +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 57c5a66833b..7b3cceaea21 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -18,9 +18,17 @@ #ifdef USE_OPENTHREAD #include "esphome/components/openthread/openthread.h" #endif +#ifdef USE_ESP32 +#include "esp_netif.h" +#endif namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +/// Copies the IPv6 addresses of an esp_netif into `addresses[1..]`, warning once when some do not fit. +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses); +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). @@ -44,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return true; #endif -#ifdef USE_OPENTHREAD +#if defined(USE_OPENTHREAD) && !defined(USE_OPENTHREAD_BORDER_ROUTER) + // A border router is reached over its Wi-Fi backbone, so the Thread link alone does not count if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) return true; #endif diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 181dd63bb21..46a1314d23c 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -38,6 +38,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog .cipher_id = NOISE_CIPHER_CHACHAPOLY, .hash_id = NOISE_HASH_SHA256, .hybrid_id = NOISE_DH_NONE, + .reserved = {}, }; int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index fcdffc0c645..b0c375eb53d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -40,6 +40,8 @@ from esphome.const import ( CONF_ENABLE_OTA_ROLLBACK, CONF_FRAMEWORK, CONF_ID, + CONF_INVERTED, + CONF_NUMBER, CONF_OTA, CONF_RESET_PIN, CONF_SAFE_MODE, @@ -66,15 +68,17 @@ from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG, SELF_RESET_PINS from .const import ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, ) from .framework import ( check_and_install, + ensure_nordic_nrfutil, get_build_env, get_build_paths, setup_platformio_python_env, @@ -192,13 +196,16 @@ def set_framework(config: ConfigType) -> ConfigType: )(config) -BOOTLOADERS = [ +# Bootloaders flashed over USB serial DFU: the Adafruit ones with adafruit-nrfutil, the factory Open +# DFU bootloader with Nordic's nrfutil; mcuboot uses mcumgr instead +SERIAL_DFU_BOOTLOADERS = ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - BOOTLOADER_MCUBOOT, -] + BOOTLOADER_NRF, +) +BOOTLOADERS = [*SERIAL_DFU_BOOTLOADERS, BOOTLOADER_MCUBOOT] _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) @@ -250,6 +257,18 @@ _DFU_SCHEMA = cv.Schema( ) +def _default_self_reset_pin(config: ConfigType) -> ConfigType: + # The factory bootloader ignores the retained register the touch sets, so a board with a GPIO + # wired to RESET resets itself through that pin + dfu = config.get(CONF_DFU) + number = SELF_RESET_PINS.get(config[CONF_BOARD]) + if dfu is not None and number and CONF_RESET_PIN not in dfu: + dfu[CONF_RESET_PIN] = pins.gpio_output_pin_schema( + {CONF_NUMBER: number, CONF_INVERTED: True} + ) + return config + + def _dfu_schema(value: bool | ConfigType) -> ConfigType: if isinstance(value, bool): if not value: @@ -300,6 +319,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _default_self_reset_pin, _resolve_toolchain, set_framework, ) @@ -311,6 +331,15 @@ def _validate_mcumgr(config): raise cv.Invalid(f"'{bootloader}' bootloader does not support DFU") +def _validate_open_dfu_toolchain(config: ConfigType) -> None: + # The deprecated PlatformIO toolchain configures every non-mcuboot upload for a touch-reset + # bootloader, which the factory Open DFU bootloader is not + if config[KEY_BOOTLOADER] == BOOTLOADER_NRF and CORE.using_toolchain_platformio: + raise cv.Invalid( + f"'{BOOTLOADER_NRF}' bootloader requires 'toolchain: {Toolchain.SDK_NRF}'" + ) + + def _final_validate(config): # Remove before 2027.2.0 @@ -319,6 +348,7 @@ def _final_validate(config): "The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. " "Please use 'toolchain: sdk-nrf' instead." ) + _validate_open_dfu_toolchain(config) if CONF_DFU in config: _validate_mcumgr(config) @@ -470,6 +500,15 @@ async def to_code(config: ConfigType) -> None: }; """ ) + if config[CONF_BOARD] == "nrf52840dongle": + # The board DTS enables uart0 on edge GPIOs (P0.17, P0.22, P0.24) although nothing is wired to it + zephyr_add_overlay( + """ + &uart0 { + status = "disabled"; + }; + """ + ) zephyr_add_prj_conf("REBOOT", True) # some boards enable USB and UART by default. @@ -535,12 +574,12 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" - DFU_PATH = "firmware.zip" HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1 without OTA, SDK 3.4.0+ HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2 to 3.3.x, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent - if (build_dir / UF2_PATH).is_file(): + has_uf2 = (build_dir / UF2_PATH).is_file() + if has_uf2: types = [ { "title": "UF2 package (recommended)", @@ -548,12 +587,6 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "file": UF2_PATH, "download": f"{storage_json.name}.uf2", }, - { - "title": "DFU package", - "description": "For flashing via adafruit-nrfutil using USB CDC.", - "file": DFU_PATH, - "download": f"dfu-{storage_json.name}.zip", - }, ] else: types = [ @@ -568,6 +601,16 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "download": f"{storage_json.name}.hex", }, ] + # Bootloaders without a UF2 family (the dongle's Open DFU bootloader) still get a DFU package + if (build_dir / DFU_PACKAGE).is_file(): + types += [ + { + "title": "DFU package", + "description": "For flashing via nrfutil using USB CDC.", + "file": DFU_PACKAGE, + "download": f"dfu-{storage_json.name}.zip", + }, + ] if (build_dir / APP_IMAGE_PATH).is_file(): types += [ { @@ -592,6 +635,105 @@ def _upload_using_platformio( return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) +# USB vendor and product id the nRF52840 Dongle enumerates with while its Open DFU bootloader runs +OPEN_DFU_USB_ID = (0x1915, 0x521F) +DFU_PACKAGE = "firmware.zip" # the serial DFU package, next to the firmware in the build directory + + +def _reset_into_bootloader(host: str) -> None: + """Send the 1200 bps touch that resets an Adafruit bootloader into DFU mode.""" + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + +def _wait_for_port_permissions(host: str) -> None: + """Give udev a moment to set up the re-enumerated port before using it.""" + import time as _time + + from esphome.__main__ import check_permissions + + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + return + except EsphomeError: + _time.sleep(0.05) + check_permissions(host) # raises with helpful message + + +def _open_dfu_ports() -> set[str]: + import serial.tools.list_ports as _list_ports + + return { + port.device + for port in _list_ports.comports(include_links=True) + if (port.vid, port.pid) == OPEN_DFU_USB_ID + } + + +def _enter_open_dfu(host: str) -> str: + """The port of the Open DFU bootloader: ``host`` when it is one already, otherwise the one that + appears after the 1200 bps touch reboots an application built with ``dfu:`` into it. The + bootloader is its own USB device, so it never comes back under the application's path, and + only a port that was not there before the touch counts, not another dongle's bootloader.""" + import time as _time + + import serial as _serial + + before = _open_dfu_ports() + if host in before: + return host + try: + _serial.Serial(host, baudrate=1200, timeout=1).close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if appeared := _open_dfu_ports() - before: + port = min(appeared) + _wait_for_port_permissions(port) + return port + raise EsphomeError( + f"{host} did not enter the Open DFU bootloader. An application built with 'dfu:' enters it " + "on upload; otherwise press the RESET button on the side of the dongle (the red LED pulses " + "while the bootloader runs) and upload again." + ) + + def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type @@ -610,97 +752,57 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if get_port_type(host) == PortType.SERIAL: check_permissions(host) - if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: mcumgr_device = host + elif CORE.using_toolchain_platformio: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True + elif bootloader not in SERIAL_DFU_BOOTLOADERS: + raise EsphomeError( + f"Serial upload is not implemented for the {bootloader} bootloader" + ) else: - if not CORE.using_toolchain_platformio: - bootloader = zephyr_data()[KEY_BOOTLOADER] - if bootloader not in ( - BOOTLOADER_ADAFRUIT, - BOOTLOADER_ADAFRUIT_NRF52_SD132, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - ): - raise EsphomeError("Not implemented yet") + env = get_build_env(None) # no compile, just the DFU tool + dfu_package = CORE.relative_pioenvs_path(CORE.name) / DFU_PACKAGE + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + if bootloader == BOOTLOADER_NRF: + # The factory Open DFU bootloader speaks Nordic's Secure DFU protocol + dfu_cmd = [ + ensure_nordic_nrfutil(env), + "nrf5sdk-tools", + "dfu", + "usb-serial", + "-pkg", + str(dfu_package), + "-p", + _enter_open_dfu(host), + ] + else: check_and_install() paths = get_build_paths() - env = get_build_env(None) # no compile, just nrfutil - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - if not dfu_package.is_file(): - raise EsphomeError("Firmware not found. Please compile first.") - import time as _time - - import serial as _serial - import serial.tools.list_ports as _list_ports - - try: - ser = _serial.Serial(host, baudrate=1200, timeout=1) - ser.close() - except _serial.SerialException as err: - raise EsphomeError(f"Failed to open {host}: {err}") from err - - # Wait for device to reset (port disappears) - deadline = _time.monotonic() + 5 - while _time.monotonic() < deadline: - _time.sleep(0.1) - if host not in {p.device for p in _list_ports.comports()}: - break - else: - _LOGGER.warning( - "Device did not leave %s within 5 s; " - "it may not have entered bootloader mode", - host, - ) - - # Wait for DFU port to reappear - deadline = _time.monotonic() + 10 - while _time.monotonic() < deadline: - _time.sleep(0.1) - if host in {p.device for p in _list_ports.comports()}: - break - else: - raise EsphomeError( - f"DFU port {host!r} did not reappear within 10 s. " - "Check that the device entered DFU mode." - ) - - # Wait for udev to finish setting up device permissions - deadline = _time.monotonic() + 5 - while _time.monotonic() < deadline: - try: - check_permissions(host) - break - except EsphomeError: - _time.sleep(0.05) - else: - check_permissions(host) # raises with helpful message - - python = str(paths["python_executable"]) - if not run_command_ok( - [ - python, - "-m", - "nordicsemi.__main__", - "dfu", - "serial", - "-pkg", - str(dfu_package), - "-p", - host, - "-b", - "115200", - "--singlebank", - ], - env=env, - stream_output=True, - ): - raise EsphomeError("nRF52 serial DFU upload failed") - else: - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: serial upload + _reset_into_bootloader(host) + _wait_for_port_permissions(host) + dfu_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ] + if not run_command_ok(dfu_cmd, env=env, stream_output=True): + raise EsphomeError("nRF52 serial DFU upload failed") + return True if host == "PYOCD": if not CORE.using_toolchain_platformio: @@ -1099,13 +1201,35 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError(f"Failed to generate UF2 from {hex_file.name}") - if bootloader in ( - BOOTLOADER_ADAFRUIT, - BOOTLOADER_ADAFRUIT_NRF52_SD132, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - ): - dfu_package = build_dir / "firmware.zip" + # The serial DFU package follows the hex it is made from; a package from another bootloader's + # build would otherwise be offered for download with this one + dfu_package = build_dir / DFU_PACKAGE + if bootloader not in SERIAL_DFU_BOOTLOADERS: + dfu_package.unlink(missing_ok=True) + elif bootloader == BOOTLOADER_NRF: + # The factory Open DFU bootloader speaks Nordic's Secure DFU protocol, which only Nordic's own + # tool packages + if not run_command_ok( + [ + ensure_nordic_nrfutil(env), + "nrf5sdk-tools", + "pkg", + "generate", + "--hw-version", + "52", + "--sd-req", + "0x00", + "--application-version", + "1", + "--application", + str(zephyr_dir / "zephyr.hex"), + str(dfu_package), + ], + env=env, + stream_output=True, + ): + raise EsphomeError("Failed to create the DFU package") + else: genpkg_cmd = [ str(paths["python_executable"]), "-m", @@ -1118,6 +1242,6 @@ def run_compile(args, config: ConfigType) -> bool: genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] if not run_command_ok(genpkg_cmd, env=env, stream_output=True): - raise EsphomeError("Failed to create adafruit DFU package") + raise EsphomeError("Failed to create the DFU package") return True diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 564bf560d69..3f5d9d1b0e3 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -6,6 +6,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, ) BOARDS_ZEPHYR = { @@ -33,8 +34,24 @@ BOARDS_ZEPHYR = { BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ] }, + # Nordic nRF52840 Dongle (PCA10059): factory Open DFU bootloader at 0xE0000, kept in place + "nrf52840dongle": {KEY_BOOTLOADER: [BOOTLOADER_NRF]}, } +# Boards with a GPIO wired to their RESET pin: driving it low is a pin reset, which the factory +# bootloader takes as its cue to stay in DFU mode (Nordic's own USB DFU trigger does the same) +SELF_RESET_PINS = { + "nrf52840dongle": "P0.19", +} + +# Bootloaders whose layout has a SoftDevice and the Adafruit bootloader, between which the mcumgr OTA +# image slot is placed +MCUMGR_OTA_BOOTLOADERS = ( + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, +) + # https://github.com/ffenix113/zigbee_home/blob/17bb7b9e9d375e756da9e38913f53303937fb66a/types/board/known_boards.go # https://learn.adafruit.com/introducing-the-adafruit-nrf52840-feather?view=all#hathach-memory-map BOOTLOADER_CONFIG = { @@ -50,4 +67,9 @@ BOOTLOADER_CONFIG = { Section("SoftDevice", 0x0, 0x27000, "flash_primary"), Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"), ], + # The board's BOARD_HAS_NRF5_BOOTLOADER already reserves the MBR page at 0x0, so the application + # links at 0x1000, where the bootloader loads it; only the bootloader itself is reserved here + BOOTLOADER_NRF: [ + Section("open_bootloader", 0xE0000, 0x20000, "flash_primary"), + ], } diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index 977ca2252ae..166e79bacc6 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,6 +2,7 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" +BOOTLOADER_NRF = "nrf" EXTRA_ADC = [ "VDD", diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 00796562f42..6c60e58462a 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -19,6 +19,7 @@ from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( create_venv, download_and_extract, + download_with_resume, get_python_env_executable_path, rmdir, run_command_ok, @@ -680,6 +681,98 @@ def _check_and_install(version: str) -> None: _install_toolchain() +# Nordic's nrfutil launcher, one static binary per host; its commands are installed from Nordic's +# package index by the launcher itself. The path carries no version, so the file is pinned by the +# size and digest Nordic's file store reported for launcher 8.2.1 (2026-09-09) +NORDIC_NRFUTIL_URL = "https://files.nordicsemi.com/artifactory/swtools/external/nrfutil/executables/{triple}/{name}" +_NRFUTIL_LAUNCHERS: dict[str, tuple[int, str]] = { + "x86_64-unknown-linux-gnu": ( + 6725408, + "1cf4bd1d3d64f43a6b9602d8d8e8d647bec0d26d517ba72fd154f95546b50260", + ), + "aarch64-unknown-linux-gnu": ( + 5506040, + "e7310f072132766e69b43c833c6a87a53367ea68d5b8a5f7c6fa1a45993ecde4", + ), + "aarch64-apple-darwin": ( + 5263376, + "1b993eece5a6e3e079ce25fa31a134ce4c12fb51735d10114a2ea4a1df048591", + ), + "x86_64-apple-darwin": ( + 6389488, + "a5ffea65325e5d38eae00f612472e18ffe2c036f800240873d9f702c1b53d945", + ), + "x86_64-pc-windows-msvc": ( + 5939032, + "22cb1bd03fc29016670c1fc8408a78bd213286ecc1156c70712485d77b097c75", + ), +} +_NRFUTIL_OS_SUFFIX = { + "linux": "unknown-linux-gnu", + "macos": "apple-darwin", + "windows": "pc-windows-msvc", +} +# The nrf5sdk-tools command gives the launcher the pkg and dfu subcommands; a new version reinstalls +NRF5SDK_TOOLS_VERSION = "1.1.0" + + +def _nrfutil_target_triple() -> str: + sysname, machine, _ = _get_toolchain_platform_info() + triple = f"{machine}-{_NRFUTIL_OS_SUFFIX.get(sysname, sysname)}" + if triple not in _NRFUTIL_LAUNCHERS: + raise EsphomeError(f"Nordic's nrfutil is not available for {sysname} {machine}") + return triple + + +def ensure_nordic_nrfutil(env: dict[str, str]) -> str: + """Nordic's nrfutil with its nrf5sdk-tools command, downloaded into the tools cache on first use. + + The launcher keeps its installed commands under NRFUTIL_HOME, which is set in ``env`` so that + the copy here never touches a user's own ~/.nrfutil. Raises EsphomeError when the download or + the command install fails, for instance without network. + """ + # The downloaded file is a bootstrap launcher: on its first run it installs the real core into + # NRFUTIL_HOME/bin and runs that, so it must not sit there itself or it runs itself forever + root = get_sdk_nrf_tools_path() / "nrfutil" + home = root / "home" + suffix = ".exe" if os.name == "nt" else "" + exe = root / "launcher" / f"nrfutil{suffix}" + command = home / "bin" / f"nrfutil-nrf5sdk-tools{suffix}" + env["NRFUTIL_HOME"] = str(home) + sentinel = root / f".ready-{NRF5SDK_TOOLS_VERSION}" + + # A cache that lost its launcher or command (a partial clean-up) is installed again + def installed() -> bool: + return sentinel.exists() and exe.is_file() and command.is_file() + + if installed(): + return str(exe) + with _install_lock("nrfutil"): + if not installed(): + exe.parent.mkdir(parents=True, exist_ok=True) + _LOGGER.info("Downloading Nordic's nrfutil ...") + triple = _nrfutil_target_triple() + size, sha256 = _NRFUTIL_LAUNCHERS[triple] + download_with_resume( + NORDIC_NRFUTIL_URL.format(triple=triple, name=exe.name), + exe, + sha256=sha256, + size=size, + ) + exe.chmod(0o755) + _LOGGER.info("Installing nrfutil's nrf5sdk-tools command ...") + if ( + not run_command_ok( + [str(exe), "install", f"nrf5sdk-tools={NRF5SDK_TOOLS_VERSION}"], + env=env, + ) + or not command.is_file() + ): + raise EsphomeError("Failed to install nrfutil's nrf5sdk-tools command") + sentinel.touch() + return str(exe) + + def _install_toolchain() -> None: toolchain_version = _get_toolchain_version() toolchains_dir = _get_toolchain_path(toolchain_version) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index cfcdfe77833..026951be93d 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -75,6 +75,11 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: dict[str, Any]) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + # Per hub: every hub programs the timer and the interrupt has one instance pointer + use_timer1(f"{DOMAIN} {config[CONF_ID]}") if CORE.is_esp32: include_builtin_idf_component("esp_driver_gptimer") diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2c278f12f49..181bcea1d55 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,22 +1,33 @@ import re from typing import Any -from esphome import automation +from esphome import automation, pins import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32H4, VARIANT_ESP32H21, + VARIANT_ESP32S2, + VARIANT_ESP32S3, VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, only_on_variant, + request_software_coexistence, require_mbedtls_ecp, + require_mbedtls_tls, require_mbedtls_tls_extras, require_mbedtls_tls_server, + require_vfs_dir, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -26,13 +37,25 @@ from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, CONF_CHANNEL, CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, CONF_FRAMEWORK, + CONF_HARDWARE_UART, CONF_ID, + CONF_INVERTED, CONF_LOG_LEVEL, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, CONF_OUTPUT_POWER, + CONF_RESET_PIN, + CONF_RX_PIN, + CONF_TX_PIN, CONF_USE_ADDRESS, + CONF_WIFI, KEY_CORE, KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, @@ -61,6 +84,7 @@ from .const import ( CONF_PAN_ID, CONF_POLL_PERIOD, CONF_PSKC, + CONF_RCP, CONF_SRP_ID, CONF_TLV, OPENTHREAD_CONFIG_PREFIX, @@ -71,10 +95,6 @@ DOMAIN = "openthread" AUTO_LOAD = ["network"] -# Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time -# TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet -CONFLICTS_WITH = ["wifi"] - IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", "ERROR": "CRIT", @@ -89,6 +109,43 @@ CONF_DEVICE_TYPES = [ "MTD", ] +# Variants with both Wi-Fi and an 802.15.4 radio +NATIVE_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32C5, + VARIANT_ESP32C6, +} +# Variants with Wi-Fi, for an external RCP +RCP_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +} +# ESP-IDF options a border router needs on top of the plain OpenThread ones +BORDER_ROUTER_SDKCONFIG_OPTIONS: dict[str, bool | int] = { + "CONFIG_OPENTHREAD_PLATFORM_NETIF": True, + "CONFIG_OPENTHREAD_BORDER_ROUTER": True, + # The advertising proxy registers the Thread devices' services with mDNS; the SDK default of 10 fills up fast + "CONFIG_MDNS_MAX_SERVICES": 20, + "CONFIG_LWIP_IPV6_FORWARD": True, + "CONFIG_LWIP_IPV6_NUM_ADDRESSES": 12, + "CONFIG_LWIP_MULTICAST_PING": True, + "CONFIG_LWIP_NETIF_STATUS_CALLBACK": True, + "CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT": True, + "CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT": True, + "CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM": True, + "CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM": True, + "CONFIG_MDNS_MULTIPLE_INSTANCE": True, + "CONFIG_MBEDTLS_CMAC_C": True, + "CONFIG_MBEDTLS_SSL_PROTO_DTLS": True, + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE": True, + "CONFIG_MBEDTLS_ECJPAKE_C": True, +} OT_HEADER_DIR = "openthread" OT_HEADER_FILE = "esphome_ot_config.h" OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") @@ -108,10 +165,18 @@ def _validate_txpower(value: Any) -> int | float: return value # Unsupported, fail later with clear error +def _get_rcp(config: ConfigType) -> ConfigType | None: + return config.get(CONF_BORDER_ROUTER, {}).get(CONF_RCP) + + def set_sdkconfig_options(config: ConfigType) -> None: - # and expose options for using SPI/UART RCPs - add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) is not None + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", not rcp) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_SPINEL_UART", rcp) + if not rcp: + add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) # There is a conflict if the logger's uart also uses the default UART, which is seen as a watchdog failure on "ot_cli" add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CLI", False) @@ -123,45 +188,55 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; - # setting one under sdkconfig_options keeps TLS in the build automatically. - # The crypto platform uses AES-CCM and deterministic ECDSA directly. - require_mbedtls_tls_server() + # OpenThread's SRP and border-router code uses these mbedTLS crypto features directly. require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") ) + # Widens the TLS role only where TLS is compiled in: a commissioner or border agent enabled + # through sdkconfig_options needs the DTLS server side too + require_mbedtls_tls_server() + if border_router: + require_mbedtls_tls() if not config.get(CONF_TLV): - if pan_id := config.get(CONF_PAN_ID): + if (pan_id := config.get(CONF_PAN_ID)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) if channel := config.get(CONF_CHANNEL): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", channel) - if network_key := config.get(CONF_NETWORK_KEY): + if (network_key := config.get(CONF_NETWORK_KEY)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:032x}" ) - if network_name := config.get(CONF_NETWORK_NAME): + if (network_name := config.get(CONF_NETWORK_NAME)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", network_name) if (ext_pan_id := config.get(CONF_EXT_PAN_ID)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:016x}" ) if (mesh_local_prefix := config.get(CONF_MESH_LOCAL_PREFIX)) is not None: add_idf_sdkconfig_option( "CONFIG_OPENTHREAD_MESH_LOCAL_PREFIX", f"{mesh_local_prefix}".lower() ) if (pskc := config.get(CONF_PSKC)) is not None: - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() - ) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:032x}") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", not border_router) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", not border_router) + if not border_router: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + + if border_router: + # Border routing follows the Wi-Fi link + wifi.request_wifi_connect_state_listener() + for name, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + add_idf_sdkconfig_option(name, value) + if not rcp: + # The native 802.15.4 radio shares the RF frontend with Wi-Fi + request_software_coexistence() # TODO: Add support for synchronized sleepy end devices (SSED) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) @@ -196,6 +271,30 @@ openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) + +def _validate_reset_pin(value: object) -> ConfigType: + value = {CONF_NUMBER: value} if not isinstance(value, dict) else dict(value) + value.setdefault(CONF_INVERTED, True) + return pins.internal_gpio_output_pin_schema(value) + + +# The pin registry rejects a pin used twice, so RX, TX and reset need no check of their own +_RCP_SCHEMA = cv.Schema( + { + cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_BAUD_RATE, default=460800): cv.int_range(min=9600), + cv.Optional(CONF_RESET_PIN): _validate_reset_pin, + } +) + +_BORDER_ROUTER_SCHEMA = cv.Schema({cv.Optional(CONF_RCP): _RCP_SCHEMA}) + + +def _validate_border_router(value: object) -> ConfigType: + return _BORDER_ROUTER_SCHEMA({} if value is None else value) + + _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, @@ -227,21 +326,44 @@ def _validate(config: ConfigType) -> ConfigType: raise cv.Invalid( f"{CONF_POLL_PERIOD} can only be used with {CONF_DEVICE_TYPE}: MTD" ) + if CONF_BORDER_ROUTER in config and device_type != "FTD": + raise cv.Invalid("OpenThread Border Router requires 'device_type: FTD'.") return config -def _require_vfs_select(config: ConfigType) -> ConfigType: - """Register VFS select requirement during config validation.""" +def _require_vfs(config: ConfigType) -> ConfigType: + """Register VFS requirements during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: require_vfs_select() + if _get_rcp(config) is not None: + require_vfs_dir() return config def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config + # Older 5.5 releases keep the first lock mutex when a timed wait for the second one runs out + cv.require_framework_version( + esp_idf=cv.Version(5, 5, 5), + extra_message="OpenThread requires ESP-IDF 5.5.5 or newer; older 5.5 releases can deadlock the OpenThread lock", + )(config) + if CONF_BORDER_ROUTER in config: + variant = get_esp32_variant() + if _get_rcp(config) is not None: + if variant not in RCP_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router RCP mode requires a Wi-Fi-capable ESP32 variant." + ) + elif variant not in NATIVE_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router with a native radio requires an ESP32 variant " + "with both Wi-Fi and IEEE 802.15.4 support. " + "Configure 'rcp:' to use an external OpenThread Radio Co-Processor." + ) + return config return only_on_variant( supported=[ VARIANT_ESP32C5, @@ -288,8 +410,8 @@ def _validate_tlv_hex(value: Any) -> str: raise cv.Invalid("TLV must have an even number of hex characters") try: raw = bytes.fromhex(s) - except ValueError as e: - raise cv.Invalid(f"TLV must be valid hex: {e}") from e + except ValueError as err: + raise cv.Invalid(f"TLV must be valid hex: {err}") from err if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") return s @@ -301,6 +423,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(OpenThreadComponent), cv.GenerateID(CONF_SRP_ID): cv.declare_id(OpenThreadSrpComponent), cv.GenerateID(CONF_MDNS_ID): cv.use_id(MDNSComponent), + cv.Optional(CONF_BORDER_ROUTER): _validate_border_router, cv.Optional(CONF_DEVICE_TYPE, default="FTD"): cv.one_of( *CONF_DEVICE_TYPES, upper=True ), @@ -324,11 +447,11 @@ CONFIG_SCHEMA = cv.All( cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), _validate_platform, _validate, - _require_vfs_select, + _require_vfs, ) -def _final_validate(_: ConfigType) -> None: +def _final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() network_config = full_config.get(NETWORK_DOMAIN, {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -337,6 +460,43 @@ def _final_validate(_: ConfigType) -> None: "Please set `enable_ipv6: true` in the `network` configuration." ) + wifi_config = full_config.get(CONF_WIFI) + if CONF_BORDER_ROUTER not in config: + if wifi_config is not None: + raise cv.Invalid( + "OpenThread can only be used with Wi-Fi when 'border_router:' is configured." + ) + else: + if wifi_config is None: + raise cv.Invalid("OpenThread Border Router requires a Wi-Fi STA backbone.") + if CONF_AP in wifi_config: + raise cv.Invalid( + "OpenThread Border Router does not support Wi-Fi AP or fallback AP mode." + ) + if not wifi_config.get(CONF_NETWORKS): + raise cv.Invalid( + "OpenThread Border Router requires at least one configured Wi-Fi STA network." + ) + if not wifi_config[CONF_ENABLE_ON_BOOT]: + raise cv.Invalid( + "OpenThread Border Router requires Wi-Fi 'enable_on_boot: true'." + ) + if _get_rcp(config) is not None: + if full_config.get("uart") is not None: + raise cv.Invalid( + "OpenThread RCP uses ESP-IDF's dedicated Spinel UART driver and " + "cannot currently be combined with the ESPHome UART component." + ) + if ( + (logger_config := full_config.get(CONF_LOGGER)) is not None + and logger_config.get(CONF_HARDWARE_UART) == "UART1" + and logger_config.get(CONF_BAUD_RATE, 0) > 0 + ): + raise cv.Invalid( + "OpenThread RCP reserves UART1; configure the logger to use USB " + "or another UART." + ) + if ( (esp32_config := full_config.get(PLATFORM_ESP32)) is not None and (fw_config := esp32_config.get(CONF_FRAMEWORK)) is not None @@ -346,6 +506,8 @@ def _final_validate(_: ConfigType) -> None: ot_log_level = IDF_TO_OT_LOG_LEVEL.get(log_level, log_level) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_LOG_LEVEL_{ot_log_level}", True) + return config + FINAL_VALIDATE_SCHEMA = _final_validate @@ -361,6 +523,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config: ConfigType) -> None: + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) + # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -368,24 +533,47 @@ async def to_code(config: ConfigType) -> None: require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") + if border_router: + cg.add_define("USE_OPENTHREAD_BORDER_ROUTER") + else: + # The SRP client registers the mDNS services with the Thread network + cg.add_define("USE_OPENTHREAD_SRP") + if rcp is not None: + cg.add_define("USE_OPENTHREAD_RCP_UART") if config.get(CONF_FORCE_DATASET): cg.add_define("USE_OPENTHREAD_FORCE_DATASET") if tlv := config.get(CONF_TLV): cg.add_define("USE_OPENTHREAD_TLVS", tlv) - # OpenThread SRP needs access to mDNS services after setup - enable_mdns_storage() + if not border_router: + # OpenThread SRP needs access to mDNS services after setup + enable_mdns_storage() - ot = cg.new_Pvariable(config[CONF_ID]) + if rcp is None: + ot = cg.new_Pvariable(config[CONF_ID]) + else: + reset_pin = ( + await cg.gpio_pin_expression(rcp[CONF_RESET_PIN]) + if CONF_RESET_PIN in rcp + else cg.nullptr + ) + ot = cg.new_Pvariable( + config[CONF_ID], + rcp[CONF_BAUD_RATE], + rcp[CONF_RX_PIN], + rcp[CONF_TX_PIN], + reset_pin, + ) add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) - srp = cg.new_Pvariable(config[CONF_SRP_ID]) - mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) - cg.add(srp.set_mdns(mdns_component)) - await cg.register_component(srp, config) + if not border_router: + mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) + srp = cg.new_Pvariable(config[CONF_SRP_ID]) + cg.add(srp.set_mdns(mdns_component)) + await cg.register_component(srp, config) if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index bd9c8cfa1c7..b5481e7b7aa 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -9,6 +9,7 @@ CONF_NETWORK_KEY = "network_key" CONF_PAN_ID = "pan_id" CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" +CONF_RCP = "rcp" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 4986850cd4f..92599603bef 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -16,6 +16,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_err.h" +#include "esp_openthread_border_router.h" +#endif + ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { @@ -25,6 +30,14 @@ OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines- OpenThreadComponent::OpenThreadComponent() { global_openthread_component = this; } +#ifdef USE_OPENTHREAD_RCP_UART +OpenThreadComponent::OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, + GPIOPin *rcp_reset_pin) + : rcp_baud_rate_(rcp_baud_rate), rcp_rx_pin_(rcp_rx_pin), rcp_tx_pin_(rcp_tx_pin), rcp_reset_pin_(rcp_reset_pin) { + global_openthread_component = this; +} +#endif + void OpenThreadComponent::dump_config() { ESP_LOGCONFIG(TAG, "Open Thread:"); #if CONFIG_OPENTHREAD_FTD @@ -43,6 +56,24 @@ void OpenThreadComponent::dump_config() { if (this->output_power_.has_value()) { ESP_LOGCONFIG(TAG, " Output power: %" PRId8 "dBm", *this->output_power_); } +#ifdef USE_OPENTHREAD_RCP_UART + ESP_LOGCONFIG(TAG, + " Radio: External RCP\n" + " Transport: UART1 at %" PRIu32 " baud\n" + " RX Pin: GPIO%u\n" + " TX Pin: GPIO%u", + this->rcp_baud_rate_, this->rcp_rx_pin_, this->rcp_tx_pin_); + if (this->rcp_reset_pin_ != nullptr) { + LOG_PIN(" RCP Reset Pin: ", this->rcp_reset_pin_); + } +#else + ESP_LOGCONFIG(TAG, " Radio: Native 802.15.4"); +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + ESP_LOGCONFIG(TAG, " Border Router: ENABLED\n" + " Backbone: Wi-Fi STA\n" + " Experimental: YES"); +#endif } void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { @@ -58,6 +89,9 @@ void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) // Gets the off-mesh routable address std::optional OpenThreadComponent::get_omr_address() { + // A failed setup never creates the lock, and acquire() would abort waiting for it + if (this->is_failed()) + return {}; InstanceLock lock = InstanceLock::acquire(); return this->get_omr_address_(lock); } @@ -84,6 +118,7 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } +#ifdef USE_OPENTHREAD_SRP void OpenThreadComponent::defer_factory_reset_external_callback() { ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); this->defer([this]() { this->factory_reset_external_callback_(); }); @@ -122,6 +157,16 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { + // A start-up failure on the OpenThread task reaches the component as a deferred mark_failed(); + // the task itself may already be gone, and acquire() would abort instead of waiting + if (global_openthread_component->is_failed() +#ifdef USE_ESP32 + || global_openthread_component->is_task_exited() +#endif + ) { + this->mark_failed(); + return; + } otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); @@ -227,7 +272,11 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } +#endif + bool OpenThreadComponent::teardown() { + // A setup() failure marks the stage complete itself; a failure with the task running, such as a + // border router that did not come up or an RCP that stays silent, still needs the stop below switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -238,31 +287,48 @@ bool OpenThreadComponent::teardown() { ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); return false; } - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; - ESP_LOGV(TAG, "Clear SRP"); + // The OpenThread task may have completed on its own in the meantime; never move back from there + TeardownStage expected = TeardownStage::TEARDOWN_STAGE_NOT_STARTED; + if (!this->teardown_stage_.compare_exchange_strong(expected, TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS)) { + break; + } otInstance *instance = lock.get_instance(); + // Stop mirroring address changes onto the netif. Every multicast group and address the + // stack drops from here on would otherwise become an MLD message that is sent, through + // the event loop, after the clean-up has deleted the glue's packet queue, which aborts. + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGW(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + } + this->border_router_started_ = false; + } +#endif +#ifdef USE_OPENTHREAD_SRP + ESP_LOGV(TAG, "Clear SRP"); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); +#endif if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable Thread during teardown"); } if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); } - // Release the lock before stopping -- openthread_stop_() (esp_openthread_stop() on - // ESP32) acquires it internally, and the lock is not recursive. } // Stop OpenThread global_openthread_component = nullptr; ESP_LOGV(TAG, "Stop OpenThread"); - int error = this->openthread_stop_(); - if (error != 0) { + if (int error = this->openthread_stop_(); error != 0) { + // Nothing will mark the stage complete for us; do not wait out the teardown timeout ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } } break; case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS: - // Unreachable today; teardown is synchronous on both platforms. Kept for a future - // graceful-exit path, or a platform whose teardown() cannot be made synchronous. + // On ESP32 the OpenThread task is leaving its main loop and cleaning up; it marks the + // stage complete when done. Zephyr completes synchronously in openthread_stop_(). break; case TeardownStage::TEARDOWN_STAGE_COMPLETED: ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete"); @@ -271,8 +337,17 @@ bool OpenThreadComponent::teardown() { return this->teardown_stage_ == TeardownStage::TEARDOWN_STAGE_COMPLETED; } -void OpenThreadComponent::on_factory_reset(std::function callback) { - this->factory_reset_external_callback_ = std::move(callback); +void OpenThreadComponent::on_factory_reset(const std::function &callback) { +#ifndef USE_OPENTHREAD_SRP + // Nothing registered with an SRP server to remove first + callback(); +#else + if (this->is_failed()) { + // No SRP registration to remove; reset right away + callback(); + return; + } + this->factory_reset_external_callback_ = callback; ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); @@ -284,6 +359,7 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { return; } ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +#endif } void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 8599304c89a..4041e21894e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -4,7 +4,11 @@ #include "esphome/components/mdns/mdns_component.h" #include "esphome/components/network/ip_address.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esphome/components/wifi/wifi_component.h" +#endif #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include #include @@ -25,22 +29,47 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; +#ifdef USE_OPENTHREAD_BORDER_ROUTER +class OpenThreadComponent final : public Component, public wifi::WiFiConnectStateListener { +#else class OpenThreadComponent final : public Component { +#endif public: OpenThreadComponent(); +#ifdef USE_OPENTHREAD_RCP_UART + OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, GPIOPin *rcp_reset_pin); +#endif ~OpenThreadComponent(); void dump_config() override; void setup() override; +#ifdef USE_OPENTHREAD_BORDER_ROUTER + void loop() override; + /// Border routing follows the Wi-Fi backbone; loop() does the work and sleeps in between + void on_wifi_connect_state(StringRef ssid, std::span bssid) override { this->enable_loop(); } +#endif bool teardown() override; - float get_setup_priority() const override { return setup_priority::WIFI; } + float get_setup_priority() const override { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + // After every network interface, whichever order network.priority gives them: the backbone netif has to exist + return setup_priority::BEFORE_CONNECTION; +#else + return setup_priority::WIFI; +#endif + } bool is_connected() const { return this->connected_; } - /// Returns true once esp_openthread_start() has completed and the OT lock is usable. + /// Returns true once the OpenThread stack is initialized and the OT lock is usable. bool is_lock_initialized() const { return this->lock_initialized_; } +#ifdef USE_ESP32 + /// True once the OpenThread task has torn the stack down; its lock never comes back + bool is_task_exited() const { return this->task_exited_; } +#endif network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); - void on_factory_reset(std::function callback); + void on_factory_reset(const std::function &callback); +#ifdef USE_OPENTHREAD_SRP void defer_factory_reset_external_callback(); +#endif /// Returns nullptr when no explicit use_address is configured and the address is /// derived at runtime from the device name (see network::get_use_address_to()). @@ -55,25 +84,54 @@ class OpenThreadComponent final : public Component { void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); +#ifdef USE_OPENTHREAD_RCP_UART + static void rcp_failure_handler(); +#endif protected: /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. */ void apply_linkmode_(otInstance *instance); +#ifdef USE_ESP32 + void ot_main_(); + void mark_task_failed_(const LogString *reason); +#endif +#ifdef USE_OPENTHREAD_RCP_UART + void reset_rcp_(bool recovery_attempt); +#endif std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); +#ifdef USE_OPENTHREAD_SRP std::function factory_reset_external_callback_; +#endif #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; #endif std::optional output_power_{}; std::atomic lock_initialized_{false}; - // Only ever written from teardown(), on the main task -- no atomic needed. - TeardownStage teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; + std::atomic teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; std::atomic connected_{false}; +#ifdef USE_ESP32 + std::atomic task_exited_{false}; +#endif +#ifdef USE_OPENTHREAD_RCP_UART + uint32_t rcp_baud_rate_{0}; + uint8_t rcp_rx_pin_{0}; + uint8_t rcp_tx_pin_{0}; + GPIOPin *rcp_reset_pin_{nullptr}; + // Both only touched on the OpenThread task, by the failure handler + uint8_t rcp_reset_attempts_{0}; + uint32_t last_rcp_reset_ms_{0}; + static constexpr uint32_t RCP_RESET_FORGET_MS = 10 * 60 * 1000; +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + std::atomic mainloop_running_{false}; + bool border_router_started_{false}; + bool backbone_up_{false}; // what the stack was told about the infra interface last +#endif private: // Stores a pointer to a string literal (static storage duration). @@ -83,6 +141,7 @@ class OpenThreadComponent final : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_OPENTHREAD_SRP class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } @@ -101,6 +160,7 @@ class OpenThreadSrpComponent final : public Component { std::vector> memory_pool_; void *pool_alloc_(size_t size); }; +#endif // RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the // guard may or may not own the lock (try_acquire can fail), so check it with diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index f3860b2cc81..e1194272973 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -1,86 +1,202 @@ #include "esphome/core/defines.h" #if defined(USE_OPENTHREAD) && defined(USE_ESP32) +#include +#include +#include +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include +#endif #include "openthread.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_openthread_border_router.h" +#include "esphome/components/wifi/wifi_component.h" +#ifndef USE_OPENTHREAD_RCP_UART +#include "esp_coexist.h" +#endif +#endif +#ifdef USE_OPENTHREAD_RCP_UART +#include "esp_openthread_spinel.h" +#endif #include "esp_log.h" #include "esp_openthread.h" #include "esp_openthread_lock.h" - +#include "esp_openthread_types.h" #include "esp_task_wdt.h" +#include "esphome/components/network/util.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #include "esp_err.h" #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" -#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { void OpenThreadComponent::setup() { - // Used eventfds: - // * netif - // * ot task queue - // * radio driver +#ifdef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_t *backbone_netif = wifi::global_wifi_component->get_esp_netif_sta(); + if (backbone_netif == nullptr) { + ESP_LOGE(TAG, "Wi-Fi STA backbone netif is not initialized"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + return; + } + esp_openthread_set_backbone_netif(backbone_netif); + wifi::global_wifi_component->add_connect_state_listener(this); +#endif + + // Used eventfds: netif, ot task queue, radio driver, and one more for a border router on the + // native radio (the UART Spinel transport has none) +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + constexpr size_t max_eventfds = 4; +#else + constexpr size_t max_eventfds = 3; +#endif esp_vfs_eventfd_config_t eventfd_config = { - .max_fds = 3, + .max_fds = max_eventfds, }; - // Network interface setup handled by network component - ESP_ERROR_CHECK(nvs_flash_init()); + // Network interface setup handled by network component. + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_init() + // aborts without it, so fail this component instead. + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + return; + } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); - esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), - .platform_config = { - .radio_config = - { - .radio_mode = RADIO_MODE_NATIVE, - .radio_uart_config = {}, - }, - .host_config = - { - // There is a conflict between esphome's logger which also - // claims the usb serial jtag device. - // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, - // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), - }, - .port_config = - { - .storage_partition_name = "nvs", - .netif_queue_size = 10, - .task_queue_size = 10, - }, - }}; + // Own task instead of esp_openthread_start(): the dataset activation has to run on the task + // that holds the OpenThread task-switching lock, and a border router must not become the + // default netif + if (xTaskCreate( + [](void *arg) { + static_cast(arg)->ot_main_(); + vTaskDelete(nullptr); + }, + CONFIG_OPENTHREAD_TASK_NAME, CONFIG_OPENTHREAD_TASK_SIZE, this, CONFIG_OPENTHREAD_TASK_PRIORITY, + nullptr) != pdPASS) { + ESP_LOGE(TAG, "Failed to create OpenThread task"); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + } +} + +void OpenThreadComponent::ot_main_() { + esp_openthread_platform_config_t config = { + .radio_config = + { +#ifdef USE_OPENTHREAD_RCP_UART + .radio_mode = RADIO_MODE_UART_RCP, + .radio_uart_config = + { + .port = UART_NUM_1, + .uart_config = + { + .baud_rate = static_cast(this->rcp_baud_rate_), + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }, + .rx_pin = static_cast(this->rcp_rx_pin_), + .tx_pin = static_cast(this->rcp_tx_pin_), + }, +#else + .radio_mode = RADIO_MODE_NATIVE, + .radio_uart_config = {}, +#endif + }, + .host_config = + { + // There is a conflict between esphome's logger which also + // claims the usb serial jtag device. + // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, + // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), + }, + .port_config = + { + .storage_partition_name = "nvs", + .netif_queue_size = 10, + .task_queue_size = 10, + }, + }; + +#ifdef USE_OPENTHREAD_RCP_UART + // Without a reset pin there is nothing to do on a failure beyond the Spinel driver's own recovery + if (this->rcp_reset_pin_ != nullptr) { + this->rcp_reset_pin_->setup(); + esp_openthread_register_rcp_failure_handler(OpenThreadComponent::rcp_failure_handler); + esp_openthread_set_coprocessor_reset_failure_callback(OpenThreadComponent::rcp_failure_handler); + this->reset_rcp_(false); + } +#endif + + ESP_ERROR_CHECK(esp_openthread_init(&config)); + + // Initialize the esp_netif bindings + esp_netif_config_t netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(); + esp_netif_t *netif = esp_netif_new(&netif_config); + assert(netif != nullptr); + ESP_ERROR_CHECK(esp_netif_attach(netif, esp_openthread_netif_glue_init(&config))); +#ifndef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_set_default_netif(netif); +#endif + + ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - ESP_ERROR_CHECK(esp_openthread_start(&config)); - // Mark lock as initialized so InstanceLock callers know it's safe to acquire. - // Must be set after esp_openthread_start() which creates the internal semaphore. - this->lock_initialized_ = true; - // Fetch OT instance once to avoid repeated call into OT stack otInstance *instance = esp_openthread_get_instance(); - { - InstanceLock lock = InstanceLock::acquire(); + bool launch_mainloop = true; +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + // The native 802.15.4 radio shares the RF frontend with Wi-Fi. Coexistence has to be on before + // the Thread network starts below, or Wi-Fi scanning and association can fail outright. + if (const esp_err_t err = esp_coex_wifi_i154_enable(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable Wi-Fi/802.15.4 coexistence: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Coexistence could not be enabled")); + launch_mainloop = false; + } +#endif + { + // One lock for the whole bring-up, so the main loop (actions, teardown, the SRP component) + // cannot call into the stack half way through. The lock is recursive: hold it before + // telling the other tasks that it exists, then let the guard take over. + esp_openthread_lock_acquire(portMAX_DELAY); + this->lock_initialized_ = true; + InstanceLock lock = InstanceLock::acquire(); + esp_openthread_lock_release(); + +#ifndef USE_OPENTHREAD_BORDER_ROUTER this->apply_linkmode_(instance); +#endif if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); } } + ESP_LOGI(TAG, "Activating dataset..."); otOperationalDatasetTlvs dataset = {}; - + bool stored_dataset = false; #ifndef USE_OPENTHREAD_FORCE_DATASET // Check if openthread has a valid dataset from a previous execution - otError error = otDatasetGetActiveTlvs(instance, &dataset); - if (error != OT_ERROR_NONE) { + if (otDatasetGetActiveTlvs(instance, &dataset) != OT_ERROR_NONE) { // Make sure the length is 0 so we fallback to the configuration dataset.mLength = 0; } else { + stored_dataset = true; ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); } #endif @@ -88,54 +204,219 @@ void OpenThreadComponent::setup() { #ifdef USE_OPENTHREAD_TLVS if (dataset.mLength == 0) { // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start - size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; - if (len > sizeof(dataset.mTlvs)) { - ESP_LOGW(TAG, "TLV buffer too small, truncating"); - len = sizeof(dataset.mTlvs); - } + constexpr size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; + static_assert(len <= sizeof(dataset.mTlvs), "OpenThread dataset TLV is too long"); parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); dataset.mLength = len; } #endif - // Pass the existing dataset, or NULL which will use the preprocessor definitions - ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + if (dataset.mLength > 0 && !otDatasetIsValid(&dataset, true)) { + if (stored_dataset) { + ESP_LOGE(TAG, "Stored OpenThread Active Operational Dataset is invalid; set force_dataset: true to replace it"); + } else { + ESP_LOGE(TAG, "Configured OpenThread Active Operational Dataset TLV is invalid"); + } + this->mark_task_failed_(LOG_STR("Invalid dataset")); + launch_mainloop = false; + } else if (launch_mainloop) { + // esp_openthread_auto_start() enables IPv6, which (for Border Router builds) opens a UDP socket + // via otPlatUdpSocket(). That path releases and re-acquires the OpenThread task-switching lock, + // which asserts unless this task holds the OpenThread lock, as it does here. + if (const esp_err_t err = esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start OpenThread: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Start failed")); + launch_mainloop = false; + } + } - // Register state change callback to update connected_ reactively instead of polling - otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); - if (ot_err != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + if (launch_mainloop) { + // Register state change callback to update connected_ reactively instead of polling + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to register state change callback: %d", ot_err); + // Without this callback connected_ would never update, so the component would look + // healthy forever while never actually reporting the device as connected. + this->mark_task_failed_(LOG_STR("State callback could not be registered")); + launch_mainloop = false; + } } } - ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); + if (launch_mainloop) { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = true; +#endif + const esp_err_t err = esp_openthread_launch_mainloop(); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = false; +#endif + if (err != ESP_OK) { + ESP_LOGE(TAG, "OpenThread main loop failed: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Main loop failed")); + } + } + + // Clean up. Taking the lock once more lets every guard finish; the stack is stopped and the + // lock withdrawn while it is held. + esp_openthread_lock_acquire(portMAX_DELAY); + // Best effort: after a normal teardown all of this is done already + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + (void) esp_openthread_border_router_deinit(); + this->border_router_started_ = false; + } +#endif + (void) otThreadSetEnabled(instance, false); + (void) otIp6SetEnabled(instance, false); + // Nothing runs the main loop any more, so the stack's reaction to the disables (the border + // agent closing its sockets, for one) is run here, with the lock held + for (uint8_t i = 0; i < 16 && otTaskletsArePending(instance); i++) { + otTaskletsProcess(instance); + } + this->lock_initialized_ = false; + this->connected_ = false; + this->task_exited_ = true; + esp_openthread_lock_release(); + esp_openthread_netif_glue_deinit(); + esp_netif_destroy(netif); + // Finalizing the instance closes the sockets the stack still holds, which hands the task + // switching lock back and forth, so it has to be held here; deinit destroys it at the end + esp_openthread_lock_acquire(portMAX_DELAY); + esp_openthread_deinit(); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } -int OpenThreadComponent::openthread_stop_() { - // Clean up - reset lock flag before deinit destroys the semaphore - this->lock_initialized_ = false; - int error = esp_openthread_stop(); - // Mark complete even on failure: we're already mid-shutdown/reboot, so there's no - // recovery path to retry into -- leaving the stage stuck would only burn the full - // teardown timeout for no benefit. - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; - return error; +// Runs on the OpenThread task; the component state belongs to the main loop, and the log goes +// out from there too, where an abort that follows on the task cannot swallow it +void OpenThreadComponent::mark_task_failed_(const LogString *reason) { + this->defer([this, reason]() { + ESP_LOGE(TAG, "OpenThread task failed: %s", LOG_STR_ARG(reason)); + this->mark_failed(); + }); } +#ifdef USE_OPENTHREAD_RCP_UART +void OpenThreadComponent::rcp_failure_handler() { + if (global_openthread_component == nullptr) { + ESP_LOGE(TAG, "RCP failure reported after OpenThread component was torn down"); + return; + } + global_openthread_component->reset_rcp_(true); +} + +void OpenThreadComponent::reset_rcp_(bool recovery_attempt) { + if (recovery_attempt) { + static constexpr uint8_t MAX_RESET_ATTEMPTS = 5; + uint8_t attempt = this->rcp_reset_attempts_; + if (attempt > MAX_RESET_ATTEMPTS) { + return; // Gave up earlier; the driver keeps reporting the failure + } + // An RCP that ran long enough since its last reset gets its attempts back; one that keeps failing does not + const uint32_t now = millis(); + if (attempt != 0 && now - this->last_rcp_reset_ms_ > RCP_RESET_FORGET_MS) { + attempt = 0; + } + this->rcp_reset_attempts_ = ++attempt; + if (attempt > MAX_RESET_ATTEMPTS) { + ESP_LOGE(TAG, "RCP failed to recover after %u reset attempts", MAX_RESET_ATTEMPTS); + this->mark_task_failed_(LOG_STR("RCP does not recover")); + return; + } + ESP_LOGW(TAG, "RCP failure detected, reset attempt %u of %u", attempt, MAX_RESET_ATTEMPTS); + this->last_rcp_reset_ms_ = now; + } + this->rcp_reset_pin_->digital_write(true); + vTaskDelay(pdMS_TO_TICKS(10)); + this->rcp_reset_pin_->digital_write(false); + vTaskDelay(pdMS_TO_TICKS(30)); +} +#endif + +int OpenThreadComponent::openthread_stop_() { return esp_openthread_mainloop_exit(); } + network::IPAddresses OpenThreadComponent::get_ip_addresses() { network::IPAddresses addresses; - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - esp_netif_t *netif = esp_netif_get_default_netif(); - count = esp_netif_get_all_ip6(netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); +#if USE_NETWORK_IPV6 + // The task's clean-up destroys the netif under the lock, so the netif is only read while it is held; + // the OpenThread task releases the lock between events, so the wait is short + auto lock = InstanceLock::try_acquire(10); + if (!lock) { + ESP_LOGV(TAG, "Instance lock busy, no addresses this time"); + return addresses; } + if (esp_netif_t *netif = esp_openthread_get_netif(); netif != nullptr) { + network::fill_ip6_addresses(netif, addresses); + } +#endif return addresses; } +#ifdef USE_OPENTHREAD_BORDER_ROUTER +static constexpr uint32_t BORDER_ROUTER_RETRY_MS = 10000; + +void OpenThreadComponent::loop() { + // Thread starts on its own. Border routing starts once the Wi-Fi backbone has an address. When the + // backbone goes away the stack is told so it pauses routing on that interface; when it comes back + // the border router is created afresh, since the port's infra interface keeps the socket and the + // addresses of the backbone as it was. The Wi-Fi listener enables the loop on every link change + if (!this->mainloop_running_) { + return; + } + const bool backbone_up = wifi::global_wifi_component->is_connected(); + if (backbone_up == this->backbone_up_) { + this->disable_loop(); + return; + } + // Never wait for the lock on the main loop; the OpenThread task releases it between events + auto lock = InstanceLock::try_acquire(0); + if (!lock) { + return; + } + auto retry_later = [this](const LogString *reason) { + this->status_set_error(reason); + this->disable_loop(); + this->set_timeout(BORDER_ROUTER_RETRY_MS, [this]() { this->enable_loop(); }); + }; + if (!backbone_up) { + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone lost, border routing pauses"); + esp_netif_t *backbone = esp_openthread_get_backbone_netif(); + // A refused report is not retried: the border router is created afresh when the backbone returns + if (otPlatInfraIfStateChanged(lock.get_instance(), esp_netif_get_netif_impl_index(backbone), false) != + OT_ERROR_NONE) { + ESP_LOGW(TAG, "Stack did not take the backbone loss, routes stay until Wi-Fi returns"); + } + } + this->backbone_up_ = false; + this->disable_loop(); + return; + } + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone back, border routing restarts"); + // The old router stays marked as started until it is gone, so a retry tears it down first + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router deinit failed")); + return; + } + this->border_router_started_ = false; + } + if (const esp_err_t err = esp_openthread_border_router_init(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router init failed")); + return; + } + this->status_clear_error(); + this->border_router_started_ = true; + this->backbone_up_ = true; + ESP_LOGI(TAG, "Border routing started"); + this->disable_loop(); +} +#endif + // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } @@ -143,7 +424,23 @@ InstanceLock InstanceLock::try_acquire(int delay) { if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } - return InstanceLock(esp_openthread_lock_acquire(delay)); + if (!esp_openthread_lock_acquire(delay)) { + return InstanceLock(false); + } + // The clean-up may have released the lock to a waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + return InstanceLock(false); + } + return InstanceLock(true); +} + +// The lock went away with the task, so waiting for it could never end +static void abort_if_task_exited() { + if (global_openthread_component->is_task_exited()) { + ESP_LOGE(TAG, "OpenThread task has exited, cannot acquire instance lock"); + abort(); + } } InstanceLock InstanceLock::acquire() { @@ -153,11 +450,17 @@ InstanceLock InstanceLock::acquire() { ESP_LOGE(TAG, "OpenThread component torn down, cannot acquire instance lock"); abort(); } + // A failed setup() never creates the lock; say so now instead of after the wait below + if (global_openthread_component->is_failed() && !global_openthread_component->is_lock_initialized()) { + ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); + abort(); + } // Wait for the lock to be created before attempting to acquire it. - // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). + // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_init(). constexpr uint32_t lock_init_timeout_ms = 10000; uint32_t start = millis(); while (!global_openthread_component->is_lock_initialized()) { + abort_if_task_exited(); if (millis() - start > lock_init_timeout_ms) { ESP_LOGE(TAG, "OpenThread lock not initialized after %" PRIu32 "ms, aborting", lock_init_timeout_ms); abort(); @@ -166,8 +469,14 @@ InstanceLock InstanceLock::acquire() { esp_task_wdt_reset(); } while (!esp_openthread_lock_acquire(100)) { + abort_if_task_exited(); esp_task_wdt_reset(); } + // The clean-up may have released the lock to this waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + abort_if_task_exited(); + } return InstanceLock(true); } diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 923b0afe18a..765d6a04c08 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -32,7 +32,7 @@ ESPHOME_LOG_TAG(TAG, "ota"); constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; -ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { +ssize_t read_header(const char *path, uint8_t *buf, size_t len) { int fd = ::open(path, O_RDONLY); if (fd < 0) return -1; @@ -50,7 +50,7 @@ struct ElfIdent { uint16_t e_type; }; -ElfIdent parse_elf_(const uint8_t *buf, size_t len) { +ElfIdent parse_elf(const uint8_t *buf, size_t len) { ElfIdent out{}; if (len < EI_NIDENT + 4) return out; @@ -76,17 +76,17 @@ ElfIdent parse_elf_(const uint8_t *buf, size_t len) { return out; } -bool validate_elf_(const char *staging_path, const std::string &exe_path) { +bool validate_elf(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { ESP_LOGE(TAG, "ELF header read failed"); return false; } - ElfIdent new_id = parse_elf_(new_buf, new_n); - ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + ElfIdent new_id = parse_elf(new_buf, new_n); + ElfIdent cur_id = parse_elf(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); return false; @@ -122,7 +122,7 @@ struct MachOIdent { uint32_t cpusubtype; }; -MachOIdent parse_macho_(const uint8_t *buf, size_t len) { +MachOIdent parse_macho(const uint8_t *buf, size_t len) { MachOIdent out{}; // mach_header is the common prefix of mach_header and mach_header_64; // cputype/cpusubtype/filetype have identical offsets in both. @@ -153,18 +153,18 @@ MachOIdent parse_macho_(const uint8_t *buf, size_t len) { return out; } -bool validate_macho_(const char *staging_path, const std::string &exe_path) { +bool validate_macho(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(sizeof(struct mach_header)) || cur_n < static_cast(sizeof(struct mach_header))) { ESP_LOGE(TAG, "Mach-O header read failed"); return false; } - MachOIdent new_id = parse_macho_(new_buf, new_n); - MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + MachOIdent new_id = parse_macho(new_buf, new_n); + MachOIdent cur_id = parse_macho(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); return false; @@ -182,11 +182,11 @@ bool validate_macho_(const char *staging_path, const std::string &exe_path) { } #endif // __APPLE__ -bool validate_executable_(const char *staging_path, const std::string &exe_path) { +bool validate_executable(const char *staging_path, const std::string &exe_path) { #ifdef __linux__ - return validate_elf_(staging_path, exe_path); + return validate_elf(staging_path, exe_path); #elif defined(__APPLE__) - return validate_macho_(staging_path, exe_path); + return validate_macho(staging_path, exe_path); #else (void) staging_path; (void) exe_path; @@ -298,7 +298,7 @@ OTAResponseTypes HostOTABackend::end() { ::close(this->fd_); this->fd_ = -1; - if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + if (!validate_executable(this->staging_path_.c_str(), this->final_path_)) { ::unlink(this->staging_path_.c_str()); this->staging_path_.clear(); return OTA_RESPONSE_ERROR_UPDATE_END; diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py new file mode 100644 index 00000000000..d50e8205a17 --- /dev/null +++ b/esphome/components/pca9505/__init__.py @@ -0,0 +1,79 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import gpio_expander, i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INTERRUPT_PIN, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["gpio_expander"] +CODEOWNERS = ["@ssieb", "@ajfriesen"] +DEPENDENCIES = ["i2c"] +DOMAIN = "pca9505" +MULTI_CONF = True + +pca9505_ns = cg.esphome_ns.namespace("pca9505") + +PCA9505Component = pca9505_ns.class_("PCA9505Component", cg.Component, i2c.I2CDevice) +PCA9505GPIOPin = pca9505_ns.class_( + "PCA9505GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9505Component) +) + +CONF_PCA9505 = "pca9505" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA9505Component), + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x20)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + +def validate_mode(value: ConfigType) -> ConfigType: + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +PCA9505_PIN_SCHEMA = pins.gpio_base_schema( + PCA9505GPIOPin, + cv.int_range(min=0, max=39), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, +).extend( + { + cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) +async def pca9505_pin_to_code(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCA9505]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp new file mode 100644 index 00000000000..20f62c14721 --- /dev/null +++ b/esphome/components/pca9505/pca9505.cpp @@ -0,0 +1,165 @@ +#include "pca9505.h" +#include "esphome/core/log.h" + +namespace esphome::pca9505 { + +// Each register block holds one byte per port. Add the port number to reach a single +// port, or set the auto-increment bit to transfer the whole block in one go. +static constexpr uint8_t INPUT_REG = 0x00; +static constexpr uint8_t OUTPUT_REG = 0x08; +static constexpr uint8_t INVERT_REG = 0x10; +static constexpr uint8_t CONFIG_REG = 0x18; +static constexpr uint8_t MASK_REG = 0x20; +static constexpr uint8_t AUTO_INCREMENT = 0x80; + +ESPHOME_LOG_TAG(TAG, "pca9505"); + +void PCA9505Component::setup() { + // Test to see if device exists + if (!this->read_bank_(0)) { + ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); + this->mark_failed(); + return; + } + + // No polarity inversion + this->write_all_banks_(INVERT_REG, 0x00); + // All outputs low, before anything is switched to being an output + this->write_all_banks_(OUTPUT_REG, 0x00); + // All inputs at initialization. The part sees a 1 as an input, so this is the + // inverse of config_, which is all zeroes here. + this->write_all_banks_(CONFIG_REG, 0xFF); + + // Mask every interrupt. This must be written, not assumed: the chip keeps its + // registers through an MCU reboot, so a previous run may have left pins unmasked. + for (uint8_t &bank : this->mask_) { + bank = 0xFF; + } + this->write_all_banks_(MASK_REG, 0xFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read - only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + + // Nothing to poll until pin_mode() registers an input pin. + this->disable_loop(); +} + +void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } + +void PCA9505Component::loop() { + // Invalidate the cache so the next digital_read() triggers a fresh I2C read + this->reset_pin_cache_(); + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { + this->disable_loop(); + } +} + +void PCA9505Component::dump_config() { + ESP_LOGCONFIG(TAG, "PCA9505:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } + +bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } + +void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (value) { + this->output_[bank] |= mask; + } else { + this->output_[bank] &= ~mask; + } + this->write_bank_(OUTPUT_REG, bank, this->output_[bank]); +} + +void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (flags & gpio::FLAG_INPUT) { + this->config_[bank] &= ~mask; + // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + this->mask_[bank] &= ~mask; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } + } else if (flags & gpio::FLAG_OUTPUT) { + this->config_[bank] |= mask; + // An output must never raise INT. + this->mask_[bank] |= mask; + } + // Invert as the part sees a 1 as an input + this->write_bank_(CONFIG_REG, bank, ~this->config_[bank]); + // Without an interrupt pin INT is unconnected, so the masks written at setup can stay + if (this->interrupt_pin_ != nullptr) { + this->write_bank_(MASK_REG, bank, this->mask_[bank]); + } +} + +bool PCA9505Component::read_bank_(uint8_t bank) { + if (this->is_failed()) + return false; + + if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { + if (this->is_failed()) + return false; + + if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { + if (this->is_failed()) + return false; + + uint8_t values[BANK_COUNT]; + for (uint8_t &bank : values) { + bank = value; + } + if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } + +void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } + +bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } + +void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } + +size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); +} + +} // namespace esphome::pca9505 diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h new file mode 100644 index 00000000000..29c4f18361c --- /dev/null +++ b/esphome/components/pca9505/pca9505.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::pca9505 { + +/// Number of I/O pins on the PCA9505 and PCA9506. +static constexpr uint8_t PIN_COUNT = 40; +/// The pins are grouped into five ports of eight, each with its own set of registers. +static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; + +class PCA9505Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + + void setup() override; + void loop() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + protected: + static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + bool read_bank_(uint8_t bank); + bool write_bank_(uint8_t reg, uint8_t bank, uint8_t value); + bool write_all_banks_(uint8_t reg, uint8_t value); + + /// The pin config per bank - 1 means OUTPUT, 0 means INPUT + uint8_t config_[BANK_COUNT]{}; + /// The output state per bank - 1 means HIGH, 0 means LOW + uint8_t output_[BANK_COUNT]{}; + /// The input pin states per bank - 1 means HIGH, 0 means LOW + uint8_t input_[BANK_COUNT]{}; + /// Which pins may pull INT low - 1 means masked, matching the chip + uint8_t mask_[BANK_COUNT]{}; + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +class PCA9505GPIOPin final : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::pca9505 diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 8ce76e9781c..bafe19ed2a4 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -37,7 +37,8 @@ void PCF85063Component::read_time() { .second = uint8_t(pcf85063_.reg.second + 10 * pcf85063_.reg.second_10), .minute = uint8_t(pcf85063_.reg.minute + 10u * pcf85063_.reg.minute_10), .hour = uint8_t(pcf85063_.reg.hour + 10u * pcf85063_.reg.hour_10), - .day_of_week = uint8_t(pcf85063_.reg.weekday), + // The chip counts weekdays 0-6 from Sunday, ESPTime counts 1-7 + .day_of_week = uint8_t(this->pcf85063_.reg.weekday + 1), .day_of_month = uint8_t(pcf85063_.reg.day + 10u * pcf85063_.reg.day_10), .month = uint8_t(pcf85063_.reg.month + 10u * pcf85063_.reg.month_10), .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), @@ -62,7 +63,7 @@ void PCF85063Component::write_time() { pcf85063_.reg.month_10 = now.month / 10; pcf85063_.reg.day = now.day_of_month % 10; pcf85063_.reg.day_10 = now.day_of_month / 10; - pcf85063_.reg.weekday = now.day_of_week; + this->pcf85063_.reg.weekday = now.day_of_week - 1; pcf85063_.reg.hour = now.hour % 10; pcf85063_.reg.hour_10 = now.hour / 10; pcf85063_.reg.minute = now.minute % 10; diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 5502eac6ec8..3a9099f3210 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -50,13 +50,15 @@ static constexpr size_t PN532_FRAME_MAX_DATA_SIZE = 254; static constexpr size_t PN532_FRAME_MAX_SIZE = 1 + 6 + PN532_FRAME_MAX_DATA_SIZE + 2; /// Holds one frame in either direction so bus traffic never allocates using PN532Frame = StaticVector; -/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads -using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Ultralight/NTAG tag (the user memory of an NTAG216) +static constexpr uint32_t MIFARE_ULTRALIGHT_MAX_NDEF_SIZE = 888; /// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; /// A MIFARE READ answers with 16 bytes: one Classic block or four Ultralight pages static constexpr size_t MIFARE_READ_SIZE = 16; using MifareReadData = std::array; +/// Pages 3 to 6, read in one go: the capability container and the start of the NDEF TLV +using UltralightHeader = std::array; class PN532BinarySensor; @@ -134,10 +136,11 @@ class PN532 : public PollingComponent { bool write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); std::unique_ptr read_mifare_ultralight_tag_(nfc::NfcTagUid &uid); - bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + /// Fills `data` with the bytes from `start_page` onward + bool read_mifare_ultralight_bytes_(uint8_t start_page, std::span data); bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, + bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint32_t &message_length, uint8_t &message_start_index); bool write_mifare_ultralight_page_(uint8_t page_num, std::span write_data); bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index afb60bd843a..e5d764d732f 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include "pn532.h" @@ -10,10 +11,9 @@ namespace esphome::pn532 { ESPHOME_LOG_TAG(TAG, "pn532.mifare_ultralight"); std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { - UltralightReadBuffer data; + UltralightHeader data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all - if (!this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, - data)) { + if (!this->read_mifare_ultralight_bytes_(3, data)) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } @@ -22,49 +22,68 @@ std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid & return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - uint8_t message_length; + uint32_t message_length; uint8_t message_start_index; if (!this->find_mifare_ultralight_ndef_(data, message_length, message_start_index)) { ESP_LOGW(TAG, "Couldn't find NDEF message"); return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - ESP_LOGVV(TAG, "NDEF message length: %u, start: %u", message_length, message_start_index); + ESP_LOGVV(TAG, "NDEF message length: %" PRIu32 ", start: %u", message_length, message_start_index); if (message_length == 0) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - // we already read pages 3-6 earlier -- pick up where we left off so we're not re-reading pages - const uint8_t read_length = message_length + message_start_index > 12 ? message_length + message_start_index - 12 : 0; + + // data[2] is the CC byte read earlier as part of pages 3-6, same value used by read_mifare_ultralight_capacity_() + const uint32_t capacity = data[2] * 8U; + const uint32_t total_length = message_length + message_start_index; + if (total_length > capacity) { + ESP_LOGW(TAG, "NDEF message of %" PRIu32 " bytes at offset %u exceeds tag capacity %" PRIu32, message_length, + message_start_index, capacity); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + if (message_length > MIFARE_ULTRALIGHT_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + + // we already read pages 3-6 earlier, pick up where we left off so we're not re-reading pages + constexpr size_t header_message_bytes = MIFARE_READ_SIZE - nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // pages 4 to 6 + const uint16_t read_length = + total_length > header_message_bytes ? static_cast(total_length - header_message_bytes) : 0; + // A message up to the old 255 byte limit stays on the stack, a longer one borrows the heap for the read + SmallBufferWithHeapFallback buffer(data.size() + read_length); + std::span message(buffer.get(), data.size() + read_length); + std::copy(data.begin(), data.end(), message.begin()); if (read_length) { - if (!read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, read_length, data)) { + if (!this->read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, + message.subspan(data.size()))) { ESP_LOGE(TAG, "Error reading tag data"); return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } } // skip page 3 as well as any bytes ahead of message_start_index const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - if (skip >= data.size()) { + if (skip >= message.size()) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } return make_unique(uid, nfc::NFC_FORUM_TYPE_2, - make_unique(std::span(data).subspan(skip))); + make_unique(std::span(message).subspan(skip))); } -bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { +bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, std::span data) { MifareReadData chunk; - for (uint8_t i = 0; i * MIFARE_READ_SIZE < num_bytes; i++) { + for (uint8_t i = 0; i * MIFARE_READ_SIZE < data.size(); i++) { // a READ returns 4 pages (16 bytes) if (!this->mifare_read_(uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), chunk)) { return false; } // keep only the bytes still wanted from this read - const uint16_t remaining = num_bytes - i * MIFARE_READ_SIZE; - const size_t count = std::min(MIFARE_READ_SIZE, remaining); - for (const uint8_t byte : std::span(chunk).subspan(0, count)) { - data.push_back(byte); - } + const size_t offset = i * MIFARE_READ_SIZE; + const size_t count = std::min(MIFARE_READ_SIZE, data.size() - offset); + std::copy_n(chunk.begin(), count, data.begin() + offset); } char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -82,15 +101,15 @@ bool PN532::is_mifare_ultralight_formatted_(const std::span page_ } uint16_t PN532::read_mifare_ultralight_capacity_() { - UltralightReadBuffer data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { + std::array data; + if (this->read_mifare_ultralight_bytes_(3, data)) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; } return 0; } -bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, +bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint32_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -98,16 +117,31 @@ bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_t return false; } + uint8_t tlv_offset; if (page_3_to_6[p4_offset + 0] == 0x03) { - message_length = page_3_to_6[p4_offset + 1]; - message_start_index = 2; - return true; + tlv_offset = 0; } else if (page_3_to_6[p4_offset + 5] == 0x03) { - message_length = page_3_to_6[p4_offset + 6]; - message_start_index = 7; - return true; + tlv_offset = 5; + } else { + return false; } - return false; + + // Long-form TLV length: 0x03 0xFF LEN_HI LEN_LO, used whenever the message is >= 255 bytes. + // Short-form is just 0x03 LEN. See NFC Forum Type 2 Tag Operation spec section 2.3. + if (page_3_to_6[p4_offset + tlv_offset + 1] == 0xFF) { + if (page_3_to_6.size() <= p4_offset + tlv_offset + 3) { + // long-form marker present but the length bytes weren't read -- fail explicitly instead of + // misreporting a 255-byte short-form message + return false; + } + message_length = + (static_cast(page_3_to_6[p4_offset + tlv_offset + 2]) << 8) | page_3_to_6[p4_offset + tlv_offset + 3]; + message_start_index = tlv_offset + 4; + } else { + message_length = page_3_to_6[p4_offset + tlv_offset + 1]; + message_start_index = tlv_offset + 2; + } + return true; } bool PN532::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 00c2e043102..b2bd7bd9bd7 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -1,5 +1,5 @@ #include "prometheus_handler.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_HOST) #include "esphome/core/application.h" namespace esphome::prometheus { diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 9dcbf7e6869..ae5bbcae74a 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 4f35d5bf365..5c9402c2018 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -1,11 +1,16 @@ #include "qmi8658.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::qmi8658 { ESPHOME_LOG_TAG(TAG, "qmi8658"); +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g"); +PROGMEM_STRING_TABLE(GyroRangeStrings, "±16°/s", "±32°/s", "±64°/s", "±128°/s", "±256°/s", "±512°/s", "±1024°/s", + "±2048°/s"); + // Acceleration scale (g per LSB), indexed by accel_range_ >> 4. // Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). static constexpr float ACCEL_SCALE[] = { @@ -86,12 +91,10 @@ void QMI8658Component::dump_config() { return; } - static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; - static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", - "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; - - ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); - ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Accel range : %s", + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_ >> 4, AccelRangeStrings::LAST_INDEX))); + ESP_LOGCONFIG(TAG, " Gyro range : %s", + LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_ >> 4, GyroRangeStrings::LAST_INDEX))); MotionComponent::dump_config(); } diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 222041837bd..356d78469fe 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config: list[ConfigType]) -> None: - cg.add_library("wjtje/qr-code-generator-library", "^1.7.0") + cg.add_library("wjtje/qr-code-generator-library", "1.7.0") for entry in config: var = cg.new_Pvariable(entry[CONF_ID]) diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index abf64f675af..a6dee79abb0 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -6,4 +6,5 @@ CONF_DRAW_FROM_ORIGIN = "draw_from_origin" DEPRECATED_COMPONENT = """ The 'qspi_dbi' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index dce1a95687d..cf2a6441a6c 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -165,8 +165,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'qspi_dbi' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 8a1bf0d4c24..eca062f4711 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -8,12 +8,13 @@ #include "esphome/components/display/display.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_rgb.h" namespace esphome::qspi_dbi { -constexpr static const char *const TAG = "display.qspi_dbi"; +ESPHOME_LOG_TAG(TAG, "display.qspi_dbi"); static const uint8_t SW_RESET_CMD = 0x01; static const uint8_t SLEEP_OUT = 0x11; static const uint8_t NORON = 0x13; diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index d46858d3977..1c74c8d5ac3 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -392,6 +392,7 @@ async def to_code(config: ConfigType) -> None: "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] ) # back-compat alias cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 20629fd7305..6b274b9b734 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -4,4 +4,5 @@ DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_rgb' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 1ca29a32597..be6ca6c2a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -132,8 +132,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + "The 'rpi_dpi_rgb' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_rgb' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index df0e2b0b167..ad2f6e80c26 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -16,7 +16,7 @@ namespace esphome::rpi_dpi_rgb { -constexpr static const char *const TAG = "rpi_dpi_rgb"; +ESPHOME_LOG_TAG(TAG, "rpi_dpi_rgb"); class RpiDpiRgb final : public display::Display { public: diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 47b8f26e3ff..8bff8bb58dc 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -14,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] DOMAIN = "rtttl" CONF_RTTTL = "rtttl" +# The player keeps the song length in 16 bits +MAX_SONG_LENGTH = 65535 CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") @@ -97,16 +99,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +def _static_song(config: ConfigType, value: str) -> str: + # A constant song plays straight from static storage, flash on ESP8266, with no copy per play + return f"esphome::rtttl::StaticSong{{{cg.FlashStringLiteral(value)}}}" + + automation.register_apply_action( "rtttl.play", cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), - cv.Required(CONF_RTTTL): cv.templatable(cv.string), + cv.Required(CONF_RTTTL): cv.templatable( + cv.All(cv.string, cv.ByteLength(max=MAX_SONG_LENGTH)) + ), }, key=CONF_RTTTL, ), - automation.ApplyField(CONF_RTTTL, "play", cg.std_string), + automation.ApplyField(CONF_RTTTL, "play", cg.std_string, const_fn=_static_song), ) automation.register_apply_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index fb2bf63a646..a1d49c65c72 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -1,5 +1,8 @@ #include "rtttl.h" +#include #include +#include +#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -147,14 +150,14 @@ void Rtttl::loop() { #endif // USE_SPEAKER // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case - while (this->position_ < this->rtttl_.length()) { - char c = this->rtttl_[this->position_]; + while (this->position_ < this->song_len_) { + char c = this->song_at_(this->position_); if (c != ',' && c != ' ') break; this->position_++; } - if (this->position_ >= this->rtttl_.length()) { + if (this->position_ >= this->song_len_) { this->finish_(); return; } @@ -169,12 +172,12 @@ void Rtttl::loop() { this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_; } - uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]); + uint8_t note_index_in_octave = note_index_from_char(this->song_at_(this->position_)); this->position_++; // Now, get optional '#' sharp - if (this->rtttl_[this->position_] == '#') { + if (this->song_at_(this->position_) == '#') { note_index_in_octave++; this->position_++; } @@ -192,7 +195,7 @@ void Rtttl::loop() { } // Now, get optional '.' dotted note - if (this->rtttl_[this->position_] == '.') { + if (this->song_at_(this->position_) == '.') { this->note_duration_ += this->note_duration_ / 2; // Duration +50% this->position_++; } @@ -264,15 +267,68 @@ void Rtttl::loop() { } void Rtttl::play(std::string rtttl) { - if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { - size_t pos = this->rtttl_.find(':'); - size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); - ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); + if (this->is_busy_()) { return; } + this->owned_song_ = std::move(rtttl); + this->set_song_(this->owned_song_.data(), this->owned_song_.size()); + this->start_(); +} - this->rtttl_ = std::move(rtttl); +void Rtttl::play(StaticSong song) { + if (this->is_busy_()) { + return; + } + // Release a previously owned song; the static one needs no storage. + std::string().swap(this->owned_song_); + const char *data = reinterpret_cast(song.rtttl); + this->set_song_(data, ESPHOME_strlen_P(data)); + this->start_(); +} +void Rtttl::set_song_(const char *song, size_t len) { + this->song_ = song; + // Anything past what the 16 bit length holds is dropped; the validator keeps configured songs shorter. + this->song_len_ = static_cast(std::min(len, std::numeric_limits::max())); +} + +bool Rtttl::is_busy_() const { + if (this->state_ == State::STOPPED || this->state_ == State::STOPPING) { + return false; + } + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->song_find_(':', 0, this->song_len_)); + ESP_LOGW(TAG, "Already playing: %s", name); + return true; +} + +size_t Rtttl::song_find_(char c, size_t from, size_t end) const { + for (size_t pos = from; pos < end; pos++) { + if (this->song_at_(pos) == c) + return pos; + } + return end; +} + +size_t Rtttl::song_find_control_(char key, size_t from, size_t end) const { + size_t pos = from; + while ((pos = this->song_find_(key, pos, end)) < end) { + if (this->song_at_(pos + 1) == '=') + return pos; + pos++; + } + return end; +} + +void Rtttl::song_name_(char *buf, size_t size, size_t name_len) const { + size_t len = std::min(name_len, size - 1); + for (size_t pos = 0; pos < len; pos++) { + buf[pos] = this->song_at_(pos); + } + buf[len] = '\0'; +} + +void Rtttl::start_() { this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR; this->default_octave_ = DEFAULT_OCTAVE; this->note_duration_ = 0; @@ -281,9 +337,9 @@ void Rtttl::play(std::string rtttl) { uint16_t num; // Used for: default note-denominator, default octave, BPM // Get name - this->position_ = this->rtttl_.find(':'); + this->position_ = this->song_find_(':', 0, this->song_len_); - if (this->position_ == std::string::npos) { + if (this->position_ == this->song_len_) { ESP_LOGE(TAG, "Unable to determine name; missing ':'"); return; } @@ -292,18 +348,20 @@ void Rtttl::play(std::string rtttl) { static_cast(SONG_NAME_LENGTH_LIMIT)); return; } - ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->position_); + ESP_LOGD(TAG, "Playing song %s", name); size_t name_end_position = this->position_; - size_t control_end = this->rtttl_.find(':', name_end_position + 1); - if (control_end == std::string::npos) { + size_t control_end = this->song_find_(':', name_end_position + 1, this->song_len_); + if (control_end == this->song_len_) { ESP_LOGE(TAG, "Missing second ':'"); return; } // Get default duration - size_t pos = this->rtttl_.find("d=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + size_t pos = this->song_find_control_('d', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); } else { this->position_ = pos + 2; @@ -317,8 +375,8 @@ void Rtttl::play(std::string rtttl) { } // Get default octave - pos = this->rtttl_.find("o=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('o', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); } else { this->position_ = pos + 2; @@ -332,8 +390,8 @@ void Rtttl::play(std::string rtttl) { } // Get BPM - pos = this->rtttl_.find("b=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('b', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); } else { this->position_ = pos + 2; @@ -387,7 +445,7 @@ void Rtttl::stop() { } #endif // USE_SPEAKER - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } @@ -411,7 +469,7 @@ void Rtttl::finish_() { #endif // USE_SPEAKER // Ensure no more notes are played in case finish_() is called for an error. - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 055204571e1..ade2dcd3205 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -14,6 +15,10 @@ namespace esphome::rtttl { +namespace testing { +class RtttlParserAccess; +} // namespace testing + inline constexpr uint8_t DEFAULT_NOTE_DENOMINATOR = 4; // Default note-denominator (quarter note) inline constexpr uint8_t DEFAULT_OCTAVE = 6; // Default octave for a note (see: `MIN_OCTAVE`, `MAX_OCTAVE` in `rtttl.cpp`) @@ -26,6 +31,11 @@ enum class State : uint8_t { STOPPING, }; +/// A song in storage that outlives playback, such as a literal from generated code; PROGMEM on ESP8266. +struct StaticSong { + ProgmemStr rtttl; +}; + class Rtttl final : public Component { public: #ifdef USE_OUTPUT @@ -38,7 +48,10 @@ class Rtttl final : public Component { void dump_config() override; void loop() override; + /// Play a song; the string is copied so it may come from anywhere. void play(std::string rtttl); + /// Play a song from static storage without copying it. + void play(StaticSong song); void stop(); float get_gain() { return this->gain_; } @@ -55,11 +68,29 @@ class Rtttl final : public Component { protected: inline uint16_t get_integer_() { uint16_t ret = 0; - while (isdigit(this->rtttl_[this->position_])) { - ret = (ret * 10) + (this->rtttl_[this->position_++] - '0'); + while (isdigit(this->song_at_(this->position_))) { + ret = (ret * 10) + (this->song_at_(this->position_++) - '0'); } return ret; } + /// The byte of the song at pos, or NUL past its end; the read is flash safe for a PROGMEM song on ESP8266. + char song_at_(size_t pos) const { + if (pos >= this->song_len_) + return '\0'; + return static_cast(progmem_read_byte(reinterpret_cast(this->song_) + pos)); + } + /// Index of the first `c` in [from, end), or `end` when absent. + size_t song_find_(char c, size_t from, size_t end) const; + /// Index of `key` followed by '=' in [from, end), or `end` when absent. + size_t song_find_control_(char key, size_t from, size_t end) const; + /// Copies the first name_len bytes of the song, its name, into buf for logging. + void song_name_(char *buf, size_t size, size_t name_len) const; + /// Points the view at a song, clamped to what song_len_ can hold. + void set_song_(const char *song, size_t len); + /// Parses the header and starts playback of the song the view points at. + void start_(); + /// Logs and returns true when a song is still playing, so a new one must wait. + bool is_busy_() const; /** * @brief Finalizes the playback of the RTTTL string. * @@ -70,26 +101,30 @@ class Rtttl final : public Component { void finish_(); void set_state_(State state); - /// The RTTTL string to play. - std::string rtttl_; - /// The current position in the RTTTL string. - size_t position_{0}; - /// The default duration of a note (e.g. 4 for a quarter note). - uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; - /// The default octave for a note. - uint8_t default_octave_{DEFAULT_OCTAVE}; - /// The duration of the current note in milliseconds. - uint16_t note_duration_{0}; - /// The duration of a whole note in milliseconds. - uint16_t wholenote_duration_; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; + /// The song being played; points at owned_song_ or at static storage. + const char *song_{nullptr}; + /// Backing store for play(std::string). + std::string owned_song_; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; + /// Length of the song in bytes; songs are a few hundred bytes, so 16 bits is ample. + uint16_t song_len_{0}; + /// The current position in the song. + uint16_t position_{0}; + /// The duration of the current note in milliseconds. + uint16_t note_duration_{0}; + /// The duration of a whole note in milliseconds. + uint16_t wholenote_duration_; + /// The default duration of a note (e.g. 4 for a quarter note). + uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; + /// The default octave for a note. + uint8_t default_octave_{DEFAULT_OCTAVE}; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. @@ -113,6 +148,8 @@ class Rtttl final : public Component { /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; #endif + + friend class testing::RtttlParserAccess; }; } // namespace esphome::rtttl diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index fcf5cb770c1..bccf5248687 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -115,7 +115,7 @@ class PNGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_PNG") - cg.add_library("pngle", "1.1.0") + cg.add_library("kikuchan98/pngle", "1.1.0") class QOIFormat(Format): diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 2c783a0df30..61b152d7d36 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -15,7 +15,7 @@ class Component; // Forward declaration namespace runtime_stats { -static const char *const TAG = "runtime_stats"; +ESPHOME_LOG_TAG(TAG, "runtime_stats"); class RuntimeStatsCollector final { public: diff --git a/esphome/components/rx8111/__init__.py b/esphome/components/rx8111/__init__.py new file mode 100644 index 00000000000..61505825072 --- /dev/null +++ b/esphome/components/rx8111/__init__.py @@ -0,0 +1,4 @@ +"""Epson RX8111CE real-time clock.""" + +CODEOWNERS = ["@techdregs"] +DOMAIN = "rx8111" diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp new file mode 100644 index 00000000000..b3f26cb2611 --- /dev/null +++ b/esphome/components/rx8111/rx8111.cpp @@ -0,0 +1,143 @@ +#include "rx8111.h" +#include "esphome/core/log.h" + +namespace esphome::rx8111 { + +ESPHOME_LOG_TAG(TAG, "rx8111"); + +static constexpr uint8_t RX8111_REG_SEC = 0x10; +static constexpr uint8_t RX8111_REG_FLAG = 0x1E; +static constexpr uint8_t RX8111_REG_CONTROL = 0x1F; +static constexpr uint8_t RX8111_FLAG_POR = 0x80; +static constexpr uint8_t RX8111_FLAG_VLF = 0x02; +static constexpr uint8_t RX8111_FLAG_XST = 0x01; +static constexpr uint8_t RX8111_CTRL_STOP = 0x01; +// FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write. +static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C; + +static constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +static constexpr uint8_t uint8_to_bcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } + +void RX8111Component::setup() { this->read_time(); } + +void RX8111Component::update() { this->read_time(); } + +void RX8111Component::dump_config() { + ESP_LOGCONFIG(TAG, "RX8111:"); + LOG_I2C_DEVICE(this); + RealTimeClock::dump_config(); +} + +void RX8111Component::read_time() { + uint8_t flags; + if (!this->read_byte(RX8111_REG_FLAG, &flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) { + ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time"); + this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time")); + return; + } + + uint8_t buf[7]; + // I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot. + if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF}; + for (size_t i = 0; i < sizeof(buf); i++) { + if (i == 3) + continue; // Weekday uses one-hot encoding, not BCD. + buf[i] &= CALENDAR_MASKS[i]; + if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) { + ESP_LOGW(TAG, "Invalid BCD in RTC time"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + } + const uint8_t onehot = buf[3]; + if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) { + ESP_LOGW(TAG, "Invalid RTC weekday"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + const uint8_t weekday = static_cast(__builtin_ctz(onehot) + 1); + ESPTime rtc_time{ + .second = bcd_to_uint8(buf[0]), + .minute = bcd_to_uint8(buf[1]), + .hour = bcd_to_uint8(buf[2]), + .day_of_week = weekday, + .day_of_month = bcd_to_uint8(buf[4]), + .month = bcd_to_uint8(buf[5]), + .year = static_cast(bcd_to_uint8(buf[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { + ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + if (ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week != weekday) { + ESP_LOGW(TAG, "RTC weekday does not match calendar date"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + + this->status_clear_warning(); + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.hour, rtc_time.minute, rtc_time.second); + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +void RX8111Component::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid() || now.year > 2099) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC"); + return; + } + if (!this->set_stop_bit_(true)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + uint8_t buf[7]; + buf[0] = uint8_to_bcd(now.second); + buf[1] = uint8_to_bcd(now.minute); + buf[2] = uint8_to_bcd(now.hour); + buf[3] = 1 << (now.day_of_week - 1); + buf[4] = uint8_to_bcd(now.day_of_month); + buf[5] = uint8_to_bcd(now.month); + buf[6] = uint8_to_bcd(static_cast(now.year - 2000)); + const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); + // Always try to restart the counter, even after a failed calendar write. + const bool restarted = this->set_stop_bit_(false); + if (!written || !restarted) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); +} + +bool RX8111Component::set_stop_bit_(bool stop) { + uint8_t control; + if (!this->read_byte(RX8111_REG_CONTROL, &control)) + return false; + if (stop) { + control |= RX8111_CTRL_STOP; + } else { + control &= ~RX8111_CTRL_STOP; + } + return this->write_byte(RX8111_REG_CONTROL, control); +} + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/rx8111.h b/esphome/components/rx8111/rx8111.h new file mode 100644 index 00000000000..9405d362b0a --- /dev/null +++ b/esphome/components/rx8111/rx8111.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8111 { + +class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool set_stop_bit_(bool stop); +}; + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py new file mode 100644 index 00000000000..0b166807a22 --- /dev/null +++ b/esphome/components/rx8111/time.py @@ -0,0 +1,36 @@ +"""Epson RX8111CE time platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@techdregs"] +DEPENDENCIES = ["i2c"] + +rx8111_ns = cg.esphome_ns.namespace("rx8111") +RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice) + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(RX8111Component)} +).extend(i2c.i2c_device_schema(0x32)) + +RX8111_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(RX8111Component)} +) + +automation.register_apply_action( + "rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) +automation.register_apply_action( + "rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index a764b74581f..5dd25ec2bb8 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -157,7 +157,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); } else { auto stride = x_offset + w + x_pad; - auto data = ptr + (stride * y_offset + x_offset) * 2; + const auto *data = ptr + (stride * y_offset + x_offset) * 2; SDL_UpdateTexture(this->texture_, &rect, data, stride * 2); } this->redraw_(rect); @@ -199,7 +199,7 @@ void Sdl::process_key(uint32_t keycode, bool down) { callback->second(down); } -Sdl *Sdl::instance_for_window_(uint32_t window_id) { +Sdl *Sdl::instance_for_window(uint32_t window_id) { SDL_Window *window = SDL_GetWindowFromID(window_id); if (window == nullptr) return nullptr; @@ -293,7 +293,7 @@ void Sdl::loop() { continue; } - Sdl *target = instance_for_window_(window_id); + Sdl *target = instance_for_window(window_id); if (target == nullptr) { // Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise // input that stops working leaves no trace at all. diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 54f0d2573fd..c927004e1f6 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { -constexpr static const char *const TAG = "sdl"; +ESPHOME_LOG_TAG(TAG, "sdl"); class Sdl final : public display::Display, public snapshot::Snapshot { public: @@ -42,7 +42,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { float get_setup_priority() const override { return setup_priority::HARDWARE; } void dump_config() override { LOG_DISPLAY("", "SDL", this); } template void add_key_listener(int32_t keycode, F &&callback) { - if (!this->key_callbacks_.count(keycode)) { + if (!this->key_callbacks_.contains(keycode)) { this->key_callbacks_[keycode] = CallbackManager(); } this->key_callbacks_[keycode].add(std::forward(callback)); @@ -66,7 +66,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { bool capture_bgr(uint8_t *dest, size_t row_stride) override; void handle_event_(const SDL_Event &event); /// The display owning the given window, or nullptr if it is not one of ours. - static Sdl *instance_for_window_(uint32_t window_id); + static Sdl *instance_for_window(uint32_t window_id); SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index c5d12924583..e86909d0491 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -162,6 +162,7 @@ bool MR60BHA2Component::validate_message_() { void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, const uint8_t *data, size_t length) { switch (frame_type) { case BREATH_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->breath_rate_sensor_ != nullptr && length >= 4) { uint32_t current_breath_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_breath_rate_int != 0) { @@ -170,11 +171,14 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->breath_rate_sensor_->publish_state(breath_rate_float); } } +#endif break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->has_target_binary_sensor_ != nullptr && length >= 2) { uint16_t has_target_int = encode_uint16(data[1], data[0]); this->has_target_binary_sensor_->publish_state(has_target_int); +#ifdef USE_SENSOR if (has_target_int == 0) { if (this->breath_rate_sensor_ != nullptr) this->breath_rate_sensor_->publish_state(0.0); @@ -185,9 +189,12 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c if (this->num_targets_sensor_ != nullptr) this->num_targets_sensor_->publish_state(0); } +#endif } +#endif break; case HEART_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->heart_rate_sensor_ != nullptr && length >= 4) { uint32_t current_heart_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_heart_rate_int != 0) { @@ -196,8 +203,10 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->heart_rate_sensor_->publish_state(heart_rate_float); } } +#endif break; case DISTANCE_TYPE_BUFFER: +#ifdef USE_SENSOR if (length >= 1 && data[0] != 0) { if (this->distance_sensor_ != nullptr && length >= 8) { uint32_t current_distance_int = encode_uint32(data[7], data[6], data[5], data[4]); @@ -206,12 +215,15 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->distance_sensor_->publish_state(distance_float); } } +#endif break; case PRINT_CLOUD_BUFFER: +#ifdef USE_SENSOR if (this->num_targets_sensor_ != nullptr && length >= 4) { uint32_t current_num_targets_int = encode_uint32(data[3], data[2], data[1], data[0]); this->num_targets_sensor_->publish_state(current_num_targets_int); } +#endif break; default: break; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 332c59061b1..6b5fb087171 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -96,6 +96,7 @@ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_ return calculate_checksum(data, len) == expected_checksum; } +#ifdef USE_SELECT static uint8_t find_nearest_index(float value, const float *arr, int size) { int nearest_index = 0; float min_diff = std::abs(value - arr[0]); @@ -108,6 +109,7 @@ static uint8_t find_nearest_index(float value, const float *arr, int size) { } return nearest_index; } +#endif /** * @brief Convert a float value to a byte array. @@ -250,15 +252,19 @@ void MR60FDA2Component::split_frame_(uint8_t buffer) { void MR60FDA2Component::process_frame_() { switch (this->current_frame_type_) { case IS_FALL_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->fall_detected_binary_sensor_ != nullptr) { this->fall_detected_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); } +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->people_exist_binary_sensor_ != nullptr) this->people_exist_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; @@ -293,6 +299,7 @@ void MR60FDA2Component::process_frame_() { float install_height_float = 0; float height_threshold_float = 0; uint32_t current_sensitivity = 0; +#ifdef USE_SELECT if (this->install_height_select_ != nullptr) { uint32_t current_install_height_int = encode_uint32(current_data_buf_[3], current_data_buf_[2], current_data_buf_[1], current_data_buf_[0]); @@ -318,6 +325,7 @@ void MR60FDA2Component::process_frame_() { uint32_t select_index = find_nearest_index(current_sensitivity, SENSITIVITY, 3); this->sensitivity_select_->publish_state(select_index); } +#endif ESP_LOGD(TAG, "Mounting height: %.2f, Height threshold: %.2f, Sensitivity: %" PRIu32, install_height_float, height_threshold_float, current_sensitivity); diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 91762e0f079..5087b112a8d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts // Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; +static constexpr uint32_t TIMEOUT_STARTUP = 3; +// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2), +// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1) +static constexpr uint32_t START_MEASUREMENT_DELAY = 50; +static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; +static constexpr uint32_t FAN_CLEANING_DELAY = 10000; +static constexpr uint32_t SHT_HEATER_DELAY = 20000; +static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; +static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; +static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; @@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() { return; } - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->on_measurement_started_(); this->initialized_ = true; ESP_LOGD(TAG, "Initialized"); } @@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() { } void SEN6XComponent::update() { - if (!this->initialized_) { + if (!this->initialized_ || !this->measuring_) { return; } @@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// True while the previous command's execution or settling time has not elapsed +bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } + +void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { + this->command_started_at_ = millis(); + this->command_wait_ = wait_ms; +} + +void SEN6XComponent::on_measurement_started_() { + this->measuring_ = true; + this->set_command_wait_(START_MEASUREMENT_DELAY); + // SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then + if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) { + this->co2_started_at_ = millis(); + this->co2_wait_ = CO2_CONDITIONING_DELAY; + } + // Values need the warm-up period again after every start + this->startup_complete_ = false; + this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); +} + +void SEN6XComponent::start_measurement() { + if (!this->initialized_ || this->measuring_) + return; + // The CO2 conditioning window only blocks restarting, not other commands + if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_); + return; + } + this->on_measurement_started_(); +} + +void SEN6XComponent::stop_measurement() { + if (!this->initialized_ || !this->measuring_) + return; + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + // Stop any in-flight polling before the device goes idle + this->cancel_timeout(TIMEOUT_POLL); + if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_); + return; + } + this->measuring_ = false; + this->set_command_wait_(STOP_MEASUREMENT_DELAY); +} + +void SEN6XComponent::start_fan_cleaning() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.22) + if (this->measuring_) { + ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(FAN_CLEANING_DELAY); +} + +void SEN6XComponent::activate_sht_heater() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.23) + if (this->measuring_) { + ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) { + this->status_set_warning(); + ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(SHT_HEATER_DELAY); +} + SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { if (product_name == "SEN62") return SEN62; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 64ce3371fce..588a466d714 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void start_measurement(); + void stop_measurement(); + void start_fan_cleaning(); + void activate_sht_heater(); protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); @@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool command_blocked_() const; + void set_command_wait_(uint32_t wait_ms); + void on_measurement_started_(); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); @@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; Sen6xType sen6x_type_{UNKNOWN}; + // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap + uint32_t command_started_at_{0}; + uint32_t command_wait_{0}; + uint32_t co2_started_at_{0}; + uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; @@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 4c0242f2e3f..a5c46801887 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,3 +1,5 @@ +from esphome import automation +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX @@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + + +SEN6X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN6XComponent), + } +) + + +for _name, _call in ( + ("sen6x.start_measurement", "start_measurement()"), + ("sen6x.stop_measurement", "stop_measurement()"), + ("sen6x.start_fan_cleaning", "start_fan_cleaning()"), + ("sen6x.activate_sht_heater", "activate_sht_heater()"), +): + automation.register_apply_action( + _name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 8b5eaeeed23..701676503f4 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -243,6 +243,14 @@ std::optional SendspinHub::get_pairing_token() const { return this->client_->pairing_token(); } +// THREAD CONTEXT: Main loop +StringRef SendspinHub::get_client_id() const { + if (this->client_ == nullptr) { + return StringRef(); + } + return StringRef(this->client_->client_id()); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index cd8d0d0afaf..23c60d215b6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -198,6 +198,12 @@ class SendspinHub final : public Component, /// started once; it stays available after the client stops. Main loop only. std::optional get_pairing_token() const; + /// @brief Returns the Sendspin client_id: base64url of the client's static public key. + /// + /// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into + /// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only. + StringRef get_client_id() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index a8767280504..d5baac3c9df 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -109,6 +109,10 @@ async def to_code(config: ConfigType) -> None: channel = await cg.get_variable(config[CONF_UART_ID]) cg.add(var.set_usb_channel(channel)) cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") + if uart.is_host_uart(config[CONF_UART_ID], CORE.config): + host_uart = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_host_uart(host_uart)) + cg.add_define("USE_SERIAL_PROXY_HOST_UART") cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 9623eb21b3c..6d3b389e5f6 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -18,6 +18,10 @@ #include "esphome/components/usb_uart/usb_uart.h" #endif +#ifdef USE_SERIAL_PROXY_HOST_UART +#include "esphome/components/uart/uart_component_host.h" +#endif + namespace esphome::serial_proxy { ESPHOME_LOG_TAG(TAG, "serial_proxy"); @@ -379,6 +383,19 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + // The UART goes first, so a failure leaves every line as it was. A line with a GPIO pin keeps + // the UART's current state; with both on GPIO there is nothing to send + bool applied = true; + if (this->rts_pin_ == nullptr || this->dtr_pin_ == nullptr) { + this->with_modem_uart_([this, dtr, rts, &applied](auto *uart) { + applied = uart->set_modem_control(this->dtr_pin_ != nullptr ? uart->get_dtr() : dtr, + this->rts_pin_ != nullptr ? uart->get_rts() : rts); + }); + } + if (!applied) { + ESP_LOGW(TAG, "Modem pins did not reach the UART on serial proxy [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR; + } if (this->rts_pin_ != nullptr) { this->rts_state_ = rts; this->rts_pin_->digital_write(rts); @@ -418,6 +435,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_class = this->usb_channel_->get_interface_class(); + msg.usb.port_number = this->usb_channel_->get_port_number(); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); @@ -453,8 +472,16 @@ void SerialProxy::on_usb_connection_changed_(bool connected) { #endif uint32_t SerialProxy::get_modem_pins() const { - return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + bool rts = this->rts_state_; + bool dtr = this->dtr_state_; + this->with_modem_uart_([this, &rts, &dtr](auto *uart) { + if (this->rts_pin_ == nullptr) + rts = uart->get_rts(); + if (this->dtr_pin_ == nullptr) + dtr = uart->get_dtr(); + }); + return (rts ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (dtr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 2b8f933f889..3c7e92baa71 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -29,6 +29,12 @@ struct UsbDeviceInfo; } // namespace esphome::usb_host #endif +#ifdef USE_SERIAL_PROXY_HOST_UART +namespace esphome::uart { +class HostUartComponent; +} // namespace esphome::uart +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -162,10 +168,12 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values uint32_t get_modem_pins() const; - /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values + /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values. + /// A line is driven by its GPIO pin when one is configured, otherwise by the UART if it can. uint32_t get_configured_modem_pins() const { - return (this->rts_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + const bool uart_lines = this->with_modem_uart_([](auto *) {}); + return (this->rts_pin_ != nullptr || uart_lines ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (this->dtr_pin_ != nullptr || uart_lines ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } /// Flush the serial port (block until all TX data is sent) @@ -183,6 +191,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } #endif +#ifdef USE_SERIAL_PROXY_HOST_UART + /// Attach the host UART behind this port (from code generation) + void set_host_uart(uart::HostUartComponent *host_uart) { this->host_uart_ = host_uart; } +#endif + #ifdef USE_API /// Send this port's identity to one client void send_identity(api::APIConnection *api_connection); @@ -237,6 +250,24 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif + /// Call f with the UART behind this port if that UART drives the modem lines itself. + /// Returns false when it cannot. Typed pointers, so the calls are direct. + template bool with_modem_uart_(F &&f) const { +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + if (this->usb_channel_ != nullptr) { + f(this->usb_channel_); + return true; + } +#endif +#ifdef USE_SERIAL_PROXY_HOST_UART + if (this->host_uart_ != nullptr) { + f(this->host_uart_); + return true; + } +#endif + return false; + } + /// Time the wire needs for the given number of bytes at the current framing uint32_t wire_time_ms_(size_t bytes) const; @@ -318,6 +349,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// The USB UART channel behind this port; nullptr on non-USB ports usb_uart::USBUartChannel *usb_channel_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_HOST_UART + /// The host UART behind this port; nullptr on other ports + uart::HostUartComponent *host_uart_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index 32b4810d7a2..8dee068b607 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -21,6 +21,7 @@ #include "esphome/core/defines.h" #ifdef USE_SHD_FIRMWARE_DATA +#include "esphome/core/hal.h" #include "stm32flash.h" namespace esphome::shelly_dimmer { @@ -38,118 +39,128 @@ constexpr uint32_t SZ_256K = 0x00040000; /* * Page-size for page-by-page flash erase. * Arrays are zero terminated; last non-zero value is automatically repeated + * In PROGMEM and read by direct indexing: each entry is an aligned 32-bit load, which is safe from flash on ESP8266 */ /* fixed size pages */ -constexpr uint32_t p_128[] = {SZ_128, 0}; // NOLINT -constexpr uint32_t p_256[] = {SZ_256, 0}; // NOLINT -constexpr uint32_t p_1k[] = {SZ_1K, 0}; // NOLINT -constexpr uint32_t p_2k[] = {SZ_2K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_128[] PROGMEM = {SZ_128, 0}; // NOLINT +alignas(4) constexpr uint32_t p_256[] PROGMEM = {SZ_256, 0}; // NOLINT +alignas(4) constexpr uint32_t p_1k[] PROGMEM = {SZ_1K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_2k[] PROGMEM = {SZ_2K, 0}; // NOLINT /* F2 and F4 page size */ -constexpr uint32_t f2f4[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT +alignas(4) constexpr uint32_t f2f4[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT /* F4 dual bank page size */ -constexpr uint32_t f4db[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT - SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; +alignas(4) constexpr uint32_t f4db[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT + SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; /* F7 page size */ -constexpr uint32_t f7[] = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT +alignas(4) constexpr uint32_t f7[] PROGMEM = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT /* * Device table, corresponds to the "Bootloader device-dependant parameters" * table in ST document AN2606. * Note that the option bytes upper range is inclusive! */ -constexpr stm32_dev_t DEVICES[] = { - /* ID "name" SRAM-address-range FLASH-address-range PPS PSize +alignas(4) constexpr stm32_dev_t DEVICES[] PROGMEM = { + /* ID SRAM-address-range FLASH-address-range PPS PSize Option-byte-addr-range System-mem-addr-range Flags */ /* F0 */ - {0x440, "STM32F030x8/F05xxx", 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x442, "STM32F030xC/F09xxx", 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, F_OBLL}, - {0x444, "STM32F03xx4/6", 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x445, "STM32F04xxx/F070x6", 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC400, 0x1FFFF800, 0}, - {0x448, "STM32F070xB/F071xx/F72xx", 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, 0}, + // STM32F030x8/F05xxx + {0x440, 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F030xC/F09xxx + {0x442, 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, + F_OBLL}, + // STM32F03xx4/6 + {0x444, 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F04xxx/F070x6 + {0x445, 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC400, 0x1FFFF800, 0}, + // STM32F070xB/F071xx/F72xx + {0x448, 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, 0}, /* F1 */ - {0x412, "STM32F10xxx Low-density", 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x410, "STM32F10xxx Medium-density", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x414, "STM32F10xxx High-density", 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x420, "STM32F10xxx Medium-density VL", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x428, "STM32F10xxx High-density VL", 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x418, "STM32F105xx/F107xx", 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFB000, 0x1FFFF800, 0}, - {0x430, "STM32F10xxx XL-density", 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFE000, 0x1FFFF800, 0}, + // STM32F10xxx Low-density + {0x412, 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density + {0x410, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density + {0x414, 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density VL + {0x420, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density VL + {0x428, 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F105xx/F107xx + {0x418, 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFB000, 0x1FFFF800, 0}, + // STM32F10xxx XL-density + {0x430, 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFE000, 0x1FFFF800, 0}, /* F2 */ - {0x411, "STM32F2xxxx", 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F2xxxx + {0x411, 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F3 */ - {0x432, "STM32F373xx/F378xx", 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFD800, 0x1FFFF800, 0}, - {0x422, "STM32F302xB(C)/F303xB(C)/F358xx", 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x439, "STM32F301xx/F302x4(6/8)/F318xx", 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x438, "STM32F303x4(6/8)/F334xx/F328xx", 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x446, "STM32F302xD(E)/F303xD(E)/F398xx", 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F373xx/F378xx + {0x432, 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xB(C)/F303xB(C)/F358xx + {0x422, 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F301xx/F302x4(6/8)/F318xx + {0x439, 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F303x4(6/8)/F334xx/F328xx + {0x438, 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xD(E)/F303xD(E)/F398xx + {0x446, 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, /* F4 */ - {0x413, "STM32F40xxx/41xxx", 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x419, "STM32F42xxx/43xxx", 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x423, "STM32F401xB(C)", 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x433, "STM32F401xD(E)", 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x458, "STM32F410xx", 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x431, "STM32F411xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x421, "STM32F446xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x434, "STM32F469xx", 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F40xxx/41xxx + {0x413, 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F42xxx/43xxx + {0x419, 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xB(C) + {0x423, 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xD(E) + {0x433, 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F410xx + {0x458, 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F411xx + {0x431, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F446xx + {0x421, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F469xx + {0x434, 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F7 */ - {0x449, "STM32F74xxx/75xxx", 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, - 0x1FF00000, 0x1FF0EDC0, 0}, + // STM32F74xxx/75xxx + {0x449, 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, 0x1FF00000, 0x1FF0EDC0, 0}, /* L0 */ - {0x425, "STM32L031xx/041xx", 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x417, "STM32L05xxx/06xxx", 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x447, "STM32L07xxx/08xxx", 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, + // STM32L031xx/041xx + {0x425, 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L05xxx/06xxx + {0x417, 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L07xxx/08xxx + {0x447, 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, /* L1 */ - {0x416, "STM32L1xxx6(8/B)", 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, F_NO_ME}, - {0x429, "STM32L1xxx6(8/B)A", 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x427, "STM32L1xxxC", 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, - {0x436, "STM32L1xxxD", 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, 0}, - {0x437, "STM32L1xxxE", 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, F_NO_ME}, + // STM32L1xxx6(8/B) + {0x416, 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + F_NO_ME}, + // STM32L1xxx6(8/B)A + {0x429, 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L1xxxC + {0x427, 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxD + {0x436, 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxE + {0x437, 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + F_NO_ME}, /* L4 */ - {0x415, "STM32L476xx/486xx", 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, - 0x1FFF0000, 0x1FFF7000, 0}, + // STM32L476xx/486xx + {0x415, 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, 0x1FFF0000, 0x1FFF7000, 0}, /* These are not (yet) in AN2606: */ - {0x641, "Medium_Density PL", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x9a8, "STM32W-128K", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x9b0, "STM32W-256K", 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x0, "", 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, + // Medium_Density PL + {0x641, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32W-128K + {0x9a8, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + // STM32W-256K + {0x9b0, 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, }; } // namespace esphome::shelly_dimmer diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index c33876e4943..5996929ab84 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -117,7 +117,7 @@ namespace esphome::shelly_dimmer { namespace { int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) { - if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end)) + if (addr < stm->dev->fl_start || addr > stm->dev->fl_end) return 0; int page = 0; @@ -729,7 +729,7 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (auto i = 0; i < len; i++) { + for (unsigned int i = 0; i < len; i++) { cs ^= data[i]; buf[i + 1] = data[i]; } diff --git a/esphome/components/shelly_dimmer/stm32flash.h b/esphome/components/shelly_dimmer/stm32flash.h index 9a81f07373e..9aa2e470808 100644 --- a/esphome/components/shelly_dimmer/stm32flash.h +++ b/esphome/components/shelly_dimmer/stm32flash.h @@ -72,17 +72,18 @@ using stm32_cmd_t = struct Stm32Cmd { uint8_t crc; }; +// Word-only fields: the table is in PROGMEM and aligned 32-bit loads from flash are safe on ESP8266 using stm32_dev_t = struct Stm32Dev { // NOLINT - const uint16_t id; - const char *name; + const uint32_t id; const uint32_t ram_start, ram_end; const uint32_t fl_start, fl_end; - const uint16_t fl_pps; // pages per sector + const uint32_t fl_pps; // pages per sector const uint32_t *fl_ps; // page size const uint32_t opt_start, opt_end; const uint32_t mem_start, mem_end; const uint32_t flags; }; +static_assert(sizeof(stm32_dev_t) == 12 * sizeof(uint32_t), "stm32_dev_t must stay word-only for flash reads"); using stm32_t = struct Stm32 { uart::UARTDevice *stream; diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp index 5650c1c404b..73a16b896e2 100644 --- a/esphome/components/snapshot/gif.cpp +++ b/esphome/components/snapshot/gif.cpp @@ -50,9 +50,9 @@ struct BitWriter { uint32_t buffer{0}; unsigned count{0}; - void put(uint32_t code, unsigned size) { - this->buffer |= code << this->count; - this->count += size; + void put(uint32_t value, unsigned bits) { + this->buffer |= value << this->count; + this->count += bits; while (this->count >= 8) { this->out.push_back(static_cast(this->buffer & 0xFF)); this->buffer >>= 8; diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 58406f17ffd..fb99154cc06 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -55,7 +55,7 @@ std::string sanitise_filename(const char *const name, const char *extension, boo break; } char c = *p; - if (!(std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-')) { + if (!std::isalnum(static_cast(c)) && c != '.' && c != '_' && c != '-') { c = '_'; changed = true; } diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp index 4ec0a373550..52e9c2eec5e 100644 --- a/esphome/components/socket/lwip_raw_common_impl.cpp +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -81,14 +81,13 @@ bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { - if (!sockaddr_to_lwip(addr, addrlen, ip, port)) - return false; + const bool ok = sockaddr_to_lwip(addr, addrlen, ip, port); #if LWIP_IPV6 // Only promote wildcard binds — a specific address must keep filtering - if (family == AF_INET6 && ip_addr_isany_val(*ip)) + if (ok && family == AF_INET6 && ip_addr_isany_val(*ip)) IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); #endif - return true; + return ok; } int lwip_err_to_errno(err_t err) { diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h index f77ee050555..517a83375df 100644 --- a/esphome/components/socket/tcp_client_link.h +++ b/esphome/components/socket/tcp_client_link.h @@ -61,6 +61,8 @@ class TcpClientLink { void close(); bool connected() const { return this->connected_; } + /// A connect attempt is in flight. + bool connecting() const { return this->sock_ != nullptr && !this->connected_; } bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); } /// Shared retry clock, also usable for a listen socket. void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); } diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index 1e51e45b7b3..a9bd0e257f5 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -81,8 +81,7 @@ void SoundLevelComponent::loop() { } // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. - // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); if (this->audio_source_->available() == 0) { // No new audio available for processing diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index 20b9c25c2e9..a5b10a0e9a6 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -7,7 +7,7 @@ namespace esphome::spi_led_strip { -static const char *const TAG = "spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "spi_led_strip"); class SpiLedStrip final : public light::AddressableLight, public spi::SPIDevice { diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index d44f8c68590..fe29d794141 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -8,13 +8,14 @@ #include "esphome/core/component.h" #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_rgb.h" namespace esphome::st7701s { -constexpr static const char *const TAG = "display.st7701s"; +ESPHOME_LOG_TAG(TAG, "display.st7701s"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 176eb233e17..d6e5035a9a4 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -7,4 +7,5 @@ st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ The 'st7735' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 5cbcee12ab3..d6774f4639e 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -92,8 +92,10 @@ async def setup_st7735(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7735' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index e67f4809128..dfe139d0183 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -7,4 +7,5 @@ st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ The 'st7789v' component is deprecated and no new functionality will be added to it. PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index fa72c7c328a..82d3f0b26d6 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -183,8 +183,10 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7789v' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6c1281c89b1..0bac0eb0466 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -1,4 +1,5 @@ #include "sx126x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -313,6 +314,11 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX126xError::SX126X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX126xError::INVALID_PARAMS; @@ -333,6 +339,7 @@ SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio1_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > TRANSMIT_TIMEOUT_MS) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX126xError::TIMEOUT; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 536bb93c48f..aa275c9e972 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -48,7 +48,7 @@ enum SX126xBw : uint8_t { SX126X_BW_500000, }; -enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX126X_ERROR_NOT_READY }; class SX126xListener { public: diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 16c3d1dd21f..fb816094c29 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -1,4 +1,5 @@ #include "sx127x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -256,6 +257,11 @@ size_t SX127x::get_max_packet_size() { } SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX127xError::SX127X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX127xError::INVALID_PARAMS; @@ -292,6 +298,7 @@ SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio0_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > 4000) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX127xError::TIMEOUT; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 27031d98b2b..547c52e7bb3 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -36,7 +36,7 @@ enum SX127xBw : uint8_t { SX127X_BW_500_0, }; -enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX127X_ERROR_NOT_READY }; class SX127xListener { public: diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 90bd9f46911..c1c770aa591 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current" CONF_PRECHARGE_CURRENT = "precharge_current" CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" +CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) SY6970Listener = sy6970_ns.class_("SY6970Listener") +I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") + +# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on +I2C_WATCHDOG_TIMEOUTS = { + "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, + "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, + "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, + "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, +} CONFIG_SCHEMA = ( cv.Schema( @@ -43,6 +53,9 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean, cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean, + cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum( + I2C_WATCHDOG_TIMEOUTS, upper=True + ), } ) .extend(cv.polling_component_schema("5s")) @@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_PRECHARGE_CURRENT], config[CONF_CHARGE_ENABLED], config[CONF_ENABLE_ADC], + config[CONF_I2C_WATCHDOG_TIMEOUT], ) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 3f381002cbd..5e1611ea558 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -6,6 +6,25 @@ namespace esphome::sy6970 { ESPHOME_LOG_TAG(TAG, "sy6970"); +static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) { + switch (timeout) { + case I2C_WATCHDOG_DISABLED: + return LOG_STR("Disabled"); + case I2C_WATCHDOG_40S: + return LOG_STR("40s"); + case I2C_WATCHDOG_80S: + return LOG_STR("80s"); + case I2C_WATCHDOG_160S: + return LOG_STR("160s"); + default: + return LOG_STR("Unknown"); + } +} + +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0; +// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000; + bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) // This includes unused registers 0x0F, 0x10 for performance @@ -51,6 +70,11 @@ void SY6970Component::setup() { ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id); } + // Service the watchdog first, so the settings below are not reverted while they are written + ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s", + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); + this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_); + // Apply configuration options (all have defaults now) ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_)); this->set_led_enabled(this->led_enabled_); @@ -85,9 +109,11 @@ void SY6970Component::dump_config() { " Charge Current: %u mA\n" " Precharge Current: %u mA\n" " Charge Enabled: %s\n" - " ADC Enabled: %s", + " ADC Enabled: %s\n" + " I2C Watchdog Timeout: %s", ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_, - this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_)); + this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_), + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); if (this->is_failed()) { @@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) { this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00); } +void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) { + if (this->is_failed()) + return; + + if (timeout != I2C_WATCHDOG_DISABLED) { + // Changing the timeout does not restart the chip's timer, which may be close to expiring + this->reset_i2c_watchdog(); + } + + // REG07 bits 5:4 (WATCHDOG[1:0]) + bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast(timeout) << 4); + if (!written) { + ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout))); + } + + if (timeout == I2C_WATCHDOG_DISABLED && written) { + this->i2c_watchdog_timeout_ = timeout; + this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID); + return; + } + if (timeout != I2C_WATCHDOG_DISABLED) { + this->i2c_watchdog_timeout_ = timeout; + } + // Keeps kicking after a failed write too: the chip may still be on its 40 s default + this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS, + [this]() { this->reset_i2c_watchdog(); }); +} + +bool SY6970Component::reset_i2c_watchdog() { + if (this->is_failed()) + return false; + + // WD_RST, self-clearing + if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) { + ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert"); + return false; + } + return true; +} + } // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 06f0615ab4b..b9c4f1d4870 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -62,6 +62,15 @@ enum ChargeStatus { CHARGE_STATUS_CHARGE_DONE = 3, }; +// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults, +// so an enabled watchdog is kicked on its own interval. +enum I2CWatchdogTimeout { + I2C_WATCHDOG_DISABLED = 0, + I2C_WATCHDOG_40S = 1, + I2C_WATCHDOG_80S = 2, + I2C_WATCHDOG_160S = 3, +}; + // Structure to hold all register data read in one transaction struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) @@ -76,14 +85,16 @@ class SY6970Listener { class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, - uint16_t precharge_current, bool charge_enabled, bool enable_adc) + uint16_t precharge_current, bool charge_enabled, bool enable_adc, + I2CWatchdogTimeout i2c_watchdog_timeout) : led_enabled_(led_enabled), input_current_limit_(input_current_limit), charge_voltage_(charge_voltage), charge_current_(charge_current), precharge_current_(precharge_current), charge_enabled_(charge_enabled), - enable_adc_(enable_adc) {} + enable_adc_(enable_adc), + i2c_watchdog_timeout_(i2c_watchdog_timeout) {} void setup() override; void dump_config() override; void update() override; @@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void set_charge_enabled(bool enabled); void set_led_enabled(bool enabled); void set_enable_adc_measure(bool enabled = true); + void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout); + + /// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written. + bool reset_i2c_watchdog(); protected: bool read_all_registers_(); @@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { uint16_t precharge_current_; bool charge_enabled_; bool enable_adc_; + I2CWatchdogTimeout i2c_watchdog_timeout_; }; } // namespace esphome::sy6970 diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index a40829f3153..e9149e0dae1 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -2,12 +2,13 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/time.h" namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) -constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { +static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = { 3, // NONE 3, // ERROR 4, // WARN @@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Syslog PRI calculation: facility * 8 + severity int severity = 7; if ((unsigned) level <= 7) { - severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; + severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 8a954b181eb..6d8c24918d3 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType: CONF_SUPPLEMENTAL_HEATING_ACTION, CONF_SUPPLEMENTAL_HEATING_DELTA, ], - CONF_MIN_COOLING_OFF_TIME: [ - CONF_COOL_ACTION, - ], - CONF_MIN_COOLING_RUN_TIME: [ - CONF_COOL_ACTION, - ], CONF_MIN_FANNING_OFF_TIME: [ CONF_FAN_ONLY_ACTION, ], @@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{req_trigger} must be defined to use {config_trigger}" ) + # Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action. + for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME): + if ( + timer in config + and CONF_COOL_ACTION not in config + and CONF_DRY_ACTION not in config + ): + raise cv.Invalid( + f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}" + ) + if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: diff --git a/esphome/components/tmc2209/sensor/__init__.py b/esphome/components/tmc2209/sensor/__init__.py new file mode 100644 index 00000000000..dd5aadcf7ca --- /dev/null +++ b/esphome/components/tmc2209/sensor/__init__.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.tmc22xx import CONF_ANALOG_CURRENT_SCALE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import TMC2209Stepper, tmc2209_ns + +CONF_ACTUAL_CURRENT = "actual_current" +CONF_MOTOR_LOAD = "motor_load" +CONF_PWM_GRAD_AUTO = "pwm_grad_auto" +CONF_PWM_OFS_AUTO = "pwm_ofs_auto" +CONF_PWM_SCALE_AUTO = "pwm_scale_auto" +CONF_PWM_SCALE_SUM = "pwm_scale_sum" +CONF_STALLGUARD_RESULT = "stallguard_result" +CONF_TMC2209_ID = "tmc2209_id" + +TMC2209Sensor = tmc2209_ns.class_("TMC2209Sensor", cg.PollingComponent) + +_diagnostic_schema = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TMC2209Sensor), + cv.GenerateID(CONF_TMC2209_ID): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_ACTUAL_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_MOTOR_LOAD): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_STALLGUARD_RESULT): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_SUM): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_OFS_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_GRAD_AUTO): _diagnostic_schema, + } +).extend(cv.polling_component_schema("60s")) + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_ACTUAL_CURRENT not in config: + return config + full_config = fv.full_config.get() + path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1] + if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE): + raise cv.Invalid( + f"'{CONF_ACTUAL_CURRENT}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', " + "the current then depends on VREF", + path=[CONF_ACTUAL_CURRENT], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TMC2209_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) + await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACTUAL_CURRENT, var.set_actual_current_sensor) + await sensors(CONF_STALLGUARD_RESULT, var.set_stallguard_result_sensor) + await sensors(CONF_MOTOR_LOAD, var.set_motor_load_sensor) + await sensors(CONF_PWM_SCALE_SUM, var.set_pwm_scale_sum_sensor) + await sensors(CONF_PWM_SCALE_AUTO, var.set_pwm_scale_auto_sensor) + await sensors(CONF_PWM_OFS_AUTO, var.set_pwm_ofs_auto_sensor) + await sensors(CONF_PWM_GRAD_AUTO, var.set_pwm_grad_auto_sensor) diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp new file mode 100644 index 00000000000..a3a6237bca3 --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp @@ -0,0 +1,95 @@ +#include "tmc2209_sensor.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209.sensor"); + +using tmc22xx::TMC22XXStepper; + +// Highest StallGuard result, reached with no load +static constexpr float STALLGUARD_RESULT_MAX = 510.0f; + +static void publish_field(sensor::Sensor *sensor, const optional &data, const RegisterField &field) { + if (sensor == nullptr) + return; + if (!data.has_value()) { + sensor->publish_state(NAN); + return; + } + uint32_t value = TMC22XXStepper::extract_field(*data, field); + sensor->publish_state(field.is_signed ? static_cast(static_cast(value)) : static_cast(value)); +} + +void TMC2209Sensor::update() { + if (this->parent_->is_failed()) + return; + + // Each failed read blocks the loop for the UART timeout. Leave the bus alone while the driver is known to be + // unreachable or after the first failed read; the remaining sensors then publish NaN. + bool reachable = !this->parent_->is_unreachable(); + auto read = [this, &reachable](uint8_t reg) -> optional { + if (!reachable) + return {}; + auto data = this->parent_->read_register(reg); + reachable = data.has_value(); + return data; + }; + + if (this->actual_current_sensor_ != nullptr) { + auto drv_status = read(tmc22xx::REG_DRV_STATUS); + float current = NAN; + if (drv_status.has_value()) { + auto scale = static_cast(TMC22XXStepper::extract_field(*drv_status, tmc22xx::CS_ACTUAL)); + current = this->parent_->scale_to_current(scale); + } + this->actual_current_sensor_->publish_state(current); + } + + // StallGuard only measures the load while the motor turns + bool moving = !this->parent_->has_reached_target(); + if (this->stallguard_result_sensor_ != nullptr || (this->motor_load_sensor_ != nullptr && moving)) { + auto sg_result = read(REG_SG_RESULT); + publish_field(this->stallguard_result_sensor_, sg_result, SG_RESULT); + if (this->motor_load_sensor_ != nullptr) { + // 100% is the load at which a stall is reported, where the result drops below twice the threshold + float stall_result = 2.0f * this->parent_->get_stallguard_threshold(); + float load = NAN; + if (moving && sg_result.has_value() && stall_result < STALLGUARD_RESULT_MAX) { + float result = TMC22XXStepper::extract_field(*sg_result, SG_RESULT); + load = (STALLGUARD_RESULT_MAX - result) / (STALLGUARD_RESULT_MAX - stall_result) * 100.0f; + } + this->motor_load_sensor_->publish_state(load); + } + } else if (this->motor_load_sensor_ != nullptr) { + this->motor_load_sensor_->publish_state(NAN); + } + + if (this->pwm_scale_sum_sensor_ != nullptr || this->pwm_scale_auto_sensor_ != nullptr) { + auto pwm_scale = read(tmc22xx::REG_PWM_SCALE); + publish_field(this->pwm_scale_sum_sensor_, pwm_scale, tmc22xx::PWM_SCALE_SUM); + publish_field(this->pwm_scale_auto_sensor_, pwm_scale, tmc22xx::PWM_SCALE_AUTO); + } + + if (this->pwm_ofs_auto_sensor_ != nullptr || this->pwm_grad_auto_sensor_ != nullptr) { + auto pwm_auto = read(tmc22xx::REG_PWM_AUTO); + publish_field(this->pwm_ofs_auto_sensor_, pwm_auto, tmc22xx::PWM_OFS_AUTO); + publish_field(this->pwm_grad_auto_sensor_, pwm_auto, tmc22xx::PWM_GRAD_AUTO); + } +} + +void TMC2209Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209 Sensor:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Actual Current", this->actual_current_sensor_); + LOG_SENSOR(" ", "StallGuard Result", this->stallguard_result_sensor_); + LOG_SENSOR(" ", "Motor Load", this->motor_load_sensor_); + LOG_SENSOR(" ", "PWM Scale Sum", this->pwm_scale_sum_sensor_); + LOG_SENSOR(" ", "PWM Scale Auto", this->pwm_scale_auto_sensor_); + LOG_SENSOR(" ", "PWM Offset Auto", this->pwm_ofs_auto_sensor_); + LOG_SENSOR(" ", "PWM Gradient Auto", this->pwm_grad_auto_sensor_); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.h b/esphome/components/tmc2209/sensor/tmc2209_sensor.h new file mode 100644 index 00000000000..4ebbf82259a --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/tmc2209/tmc2209.h" +#include "esphome/core/component.h" + +namespace esphome::tmc2209 { + +class TMC2209Sensor : public PollingComponent { + public: + explicit TMC2209Sensor(TMC2209Stepper *parent) : parent_(parent) {} + + void update() override; + void dump_config() override; + + void set_actual_current_sensor(sensor::Sensor *sensor) { this->actual_current_sensor_ = sensor; } + void set_stallguard_result_sensor(sensor::Sensor *sensor) { this->stallguard_result_sensor_ = sensor; } + void set_motor_load_sensor(sensor::Sensor *sensor) { this->motor_load_sensor_ = sensor; } + void set_pwm_scale_sum_sensor(sensor::Sensor *sensor) { this->pwm_scale_sum_sensor_ = sensor; } + void set_pwm_scale_auto_sensor(sensor::Sensor *sensor) { this->pwm_scale_auto_sensor_ = sensor; } + void set_pwm_ofs_auto_sensor(sensor::Sensor *sensor) { this->pwm_ofs_auto_sensor_ = sensor; } + void set_pwm_grad_auto_sensor(sensor::Sensor *sensor) { this->pwm_grad_auto_sensor_ = sensor; } + + protected: + TMC2209Stepper *parent_; + sensor::Sensor *actual_current_sensor_{nullptr}; + sensor::Sensor *stallguard_result_sensor_{nullptr}; + sensor::Sensor *motor_load_sensor_{nullptr}; + sensor::Sensor *pwm_scale_sum_sensor_{nullptr}; + sensor::Sensor *pwm_scale_auto_sensor_{nullptr}; + sensor::Sensor *pwm_ofs_auto_sensor_{nullptr}; + sensor::Sensor *pwm_grad_auto_sensor_{nullptr}; +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index f1c83485fa3..49923316884 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -9,10 +9,12 @@ using tmc22xx::RegisterField; // StallGuard and CoolStep registers, which the TMC2208 does not have static constexpr uint8_t REG_TCOOLTHRS = 0x14; static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_SG_RESULT = 0x41; static constexpr uint8_t REG_COOLCONF = 0x42; static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SG_RESULT{REG_SG_RESULT, 0, 10, false}; static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; @@ -30,6 +32,7 @@ class TMC2209Stepper : public tmc22xx::TMC22XXStepper { /// A stall is reported when the StallGuard result drops below twice this value. void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + uint8_t get_stallguard_threshold() const { return extract_field(this->sgthrs_, SGTHRS); } /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index da760dde2c5..82ac29c7d5b 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -24,7 +24,9 @@ CONF_CLOCK_FREQUENCY = "clock_frequency" CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" +CONF_HEND = "hend" CONF_HOLD_CURRENT = "hold_current" +CONF_HSTRT = "hstrt" CONF_IHOLD = "ihold" CONF_IHOLDDELAY = "iholddelay" CONF_INDEX_PIN = "index_pin" @@ -34,9 +36,18 @@ CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" +CONF_PWM_AUTOGRAD = "pwm_autograd" +CONF_PWM_AUTOSCALE = "pwm_autoscale" +CONF_PWM_FREQ = "pwm_freq" +CONF_PWM_GRAD = "pwm_grad" +CONF_PWM_LIM = "pwm_lim" +CONF_PWM_OFS = "pwm_ofs" +CONF_PWM_REG = "pwm_reg" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TBL = "tbl" +CONF_TOFF = "toff" CONF_TPOWERDOWN = "tpowerdown" CONF_TPWM_THRESHOLD = "tpwm_threshold" CONF_VSENSE = "vsense" @@ -110,6 +121,29 @@ def _validate_analog_current_scale(config: ConfigType) -> ConfigType: return config +def _validate_chopconf(config: ConfigType) -> ConfigType: + # Only values given as constants can be checked here, not lambdas + toff, tbl = config.get(CONF_TOFF), config.get(CONF_TBL) + # TBL is never written at setup, so the chip may still hold a blank time below 2 + if toff == 1 and (tbl is None or (isinstance(tbl, int) and tbl < 2)): + raise cv.Invalid( + f"'{CONF_TOFF}: 1' needs '{CONF_TBL}' set to 2 or higher in the same " + "action", + path=[CONF_TOFF], + ) + hend, hstrt = config.get(CONF_HEND), config.get(CONF_HSTRT) + # Register values are offsets: HEND 0-15 is -3 to 12, HSTRT 0-7 is 1 to 8 + if isinstance(hend, int) and isinstance(hstrt, int): + hysteresis = (hend - 3) + (hstrt + 1) + if hysteresis > 16: + raise cv.Invalid( + f"'{CONF_HEND}' and '{CONF_HSTRT}' give a hysteresis of " + f"{hysteresis}, the limit is 16", + path=[CONF_HEND], + ) + return config + + def final_validate(config: ConfigType) -> ConfigType: """Reject two drivers of the family with the same address on one UART bus.""" for other in fv.full_config.get().get("stepper", []): @@ -225,3 +259,46 @@ def register_actions(domain: str, cls: MockObjClass) -> None: CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode ), ) + + automation.register_apply_action( + f"{domain}.chopconf", + cv.All( + cv.Schema( + { + **id_schema, + cv.Optional(CONF_TOFF): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_TBL): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_HEND): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_HSTRT): cv.templatable(cv.int_range(0, 7)), + } + ), + _validate_chopconf, + ), + automation.ApplyField(CONF_TOFF, "set_toff", cg.uint8), + automation.ApplyField(CONF_TBL, "set_tbl", cg.uint8), + automation.ApplyField(CONF_HEND, "set_hend", cg.uint8), + automation.ApplyField(CONF_HSTRT, "set_hstrt", cg.uint8), + ) + + automation.register_apply_action( + f"{domain}.pwmconf", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_PWM_OFS): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_GRAD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_FREQ): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_PWM_AUTOSCALE): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_AUTOGRAD): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_REG): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_PWM_LIM): cv.templatable(cv.int_range(0, 15)), + } + ), + automation.ApplyField(CONF_PWM_OFS, "set_pwm_ofs", cg.uint8), + automation.ApplyField(CONF_PWM_GRAD, "set_pwm_grad", cg.uint8), + automation.ApplyField(CONF_PWM_FREQ, "set_pwm_freq", cg.uint8), + automation.ApplyField(CONF_PWM_AUTOSCALE, "set_pwm_autoscale", cg.bool_), + automation.ApplyField(CONF_PWM_AUTOGRAD, "set_pwm_autograd", cg.bool_), + automation.ApplyField(CONF_PWM_REG, "set_pwm_reg", cg.uint8), + automation.ApplyField(CONF_PWM_LIM, "set_pwm_lim", cg.uint8), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 2aed97bea10..7bb756819bd 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -362,6 +362,13 @@ void TMC22XXStepper::set_enabled(bool enabled) { this->enabled_ = enabled; } +void TMC22XXStepper::set_toff(uint8_t toff) { + this->toff_ = toff; + // Without an ENN pin, TOFF=0 is what keeps the outputs disabled + if (this->enabled_ || this->enn_pin_ != nullptr) + this->write_field(TOFF, toff); +} + void TMC22XXStepper::set_microsteps(uint16_t microsteps) { for (uint8_t mres = 0; mres <= 8; mres++) { if ((256u >> mres) == microsteps) { diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 546a27e70d3..2e8644de53a 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -33,6 +33,8 @@ static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +static constexpr uint8_t REG_PWM_SCALE = 0x71; +static constexpr uint8_t REG_PWM_AUTO = 0x72; // Readable registers that a driver reset sets back to their defaults; the last written values are restored static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; @@ -55,11 +57,26 @@ static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +static constexpr RegisterField HSTRT{REG_CHOPCONF, 4, 3, false}; +static constexpr RegisterField HEND{REG_CHOPCONF, 7, 4, false}; +static constexpr RegisterField TBL{REG_CHOPCONF, 15, 2, false}; static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; +static constexpr RegisterField PWM_OFS{REG_PWMCONF, 0, 8, false}; +static constexpr RegisterField PWM_GRAD{REG_PWMCONF, 8, 8, false}; +static constexpr RegisterField PWM_FREQ{REG_PWMCONF, 16, 2, false}; +static constexpr RegisterField PWM_AUTOSCALE{REG_PWMCONF, 18, 1, false}; +static constexpr RegisterField PWM_AUTOGRAD{REG_PWMCONF, 19, 1, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_REG{REG_PWMCONF, 24, 4, false}; +static constexpr RegisterField PWM_LIM{REG_PWMCONF, 28, 4, false}; +static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; +static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; +static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; +static constexpr RegisterField PWM_GRAD_AUTO{REG_PWM_AUTO, 16, 8, false}; enum StandstillMode : uint8_t { STANDSTILL_MODE_NORMAL = 0, @@ -129,6 +146,8 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// True while the driver does not answer, until the status poll reads it again (every 10 s). + bool is_unreachable() const { return this->status_poll_skip_ > 0; } /// Called with the condition and whether it became active or cleared. template void add_on_status_callback(F &&callback) { @@ -151,6 +170,20 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + + /// Chopper off time (1-15). TOFF=0 is used to disable the outputs, so this is applied on the next enable. + void set_toff(uint8_t toff); + void set_tbl(uint8_t tbl) { this->write_field(TBL, tbl); } + void set_hend(uint8_t hend) { this->write_field(HEND, hend); } + void set_hstrt(uint8_t hstrt) { this->write_field(HSTRT, hstrt); } + + void set_pwm_ofs(uint8_t ofs) { this->write_field(PWM_OFS, ofs); } + void set_pwm_grad(uint8_t grad) { this->write_field(PWM_GRAD, grad); } + void set_pwm_freq(uint8_t freq) { this->write_field(PWM_FREQ, freq); } + void set_pwm_autoscale(bool enable) { this->write_field(PWM_AUTOSCALE, enable); } + void set_pwm_autograd(bool enable) { this->write_field(PWM_AUTOGRAD, enable); } + void set_pwm_reg(uint8_t reg) { this->write_field(PWM_REG, reg); } + void set_pwm_lim(uint8_t lim) { this->write_field(PWM_LIM, lim); } /// Convert a current scale (0-31) to A RMS, without analog current scale. float scale_to_current(uint8_t scale); diff --git a/esphome/components/tmp102/text_sensor.py b/esphome/components/tmp102/text_sensor.py new file mode 100644 index 00000000000..5d901bfab89 --- /dev/null +++ b/esphome/components/tmp102/text_sensor.py @@ -0,0 +1,31 @@ +"""TMP102 threshold status entity.""" + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC +).extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "text_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_TEXT_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await text_sensor.new_text_sensor(config) + cg.add(parent.set_threshold_status_text_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index bb82975dbc3..06d94f1ba86 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -1,8 +1,14 @@ #include "tmp102.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/core/progmem.h" +#endif #include +#ifdef USE_TMP102_TEXT_SENSOR +#include +#endif namespace esphome::tmp102 { @@ -12,6 +18,30 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; + +enum TMP102ThresholdStatus : uint8_t { + TMP102_THRESHOLD_STATUS_NOT_CONFIGURED, + TMP102_THRESHOLD_STATUS_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_OK, + TMP102_THRESHOLD_STATUS_RESTORE_RESET, + TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERY_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERED, + TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED, + TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED, + TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_COUNT, +}; + +#ifdef USE_TMP102_TEXT_SENSOR +PROGMEM_STRING_TABLE(ThresholdStatusStrings, "Not configured", "Setup failed", "OK", "Restored thresholds reset", + "Restore reset; save failed", "Preference save failed", "Configuration recovery failed", + "Configuration restored", "Write failed: high threshold", "Write failed: low threshold", + "Rejected: sensor setup failed"); +static_assert(ThresholdStatusStrings::COUNT == TMP102_THRESHOLD_STATUS_COUNT); +#endif + #if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; @@ -42,15 +72,30 @@ static uint16_t encode_temperature(float temperature, bool extended) { return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); } #endif +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void TMP102Component::setup() { - if (!this->configure_) +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->setup_configured_(); return; + } +#endif + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_NOT_CONFIGURED); +} +#endif + +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::setup_configured_() { + bool corrected = false; #ifdef USE_TMP102_NUMBER float restored[2]{}; - this->prepare_number_limits_(restored); + bool restore_corrected[2]{}; + corrected = this->prepare_number_limits_(restored, restore_corrected); #endif if (!this->write_configuration_()) { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); this->mark_failed(); return; } @@ -61,14 +106,17 @@ void TMP102Component::setup() { if (control == nullptr) continue; const float value = this->get_limit_temperature_(static_cast(limit)); - if (value != restored[limit]) + if (restore_corrected[limit] || value != restored[limit]) saved &= control->save_value_(value); control->publish_state(value); } if (!saved) { ESP_LOGW(TAG, "Failed to save threshold preferences"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED); + return; } #endif + this->publish_threshold_status_(corrected ? TMP102_THRESHOLD_STATUS_RESTORE_RESET : TMP102_THRESHOLD_STATUS_OK); } #endif @@ -83,6 +131,9 @@ void TMP102Component::dump_config() { #ifdef USE_TMP102_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); #endif +#ifdef USE_TMP102_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Threshold Status", this->threshold_status_text_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -203,6 +254,9 @@ bool TMP102Component::check_configuration_() { this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERY_FAILED); + } else { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERED); } return false; } @@ -228,22 +282,25 @@ void TMP102Component::set_limit_control(TMP102LimitNumber *number) { this->limit_controls_[number->limit_type_] = number; } -void TMP102Component::prepare_number_limits_(float *restored) { +bool TMP102Component::prepare_number_limits_(float *restored, bool *restore_corrected) { const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + bool corrected = false; for (uint8_t limit = 0; limit < 2; ++limit) { TMP102LimitNumber *control = this->limit_controls_[limit]; if (control == nullptr) continue; - restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended), restore_corrected[limit]); + corrected |= restore_corrected[limit]; raw[limit] = encode_temperature(restored[limit], extended); } if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); - return; + return true; } this->low_limit_ = raw[TMP102_LIMIT_LOW]; this->high_limit_ = raw[TMP102_LIMIT_HIGH]; + return corrected; } float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { @@ -261,10 +318,13 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + this->publish_threshold_rejection_(high, decode_temperature(raw, extended), decode_temperature(other, extended)); return false; } if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { this->status_set_warning(); + this->publish_threshold_status_(high ? TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED + : TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED); return false; } (high ? this->high_limit_ : this->low_limit_) = raw; @@ -273,6 +333,32 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper #endif #endif +#ifdef USE_TMP102_TEXT_SENSOR +void TMP102Component::publish_threshold_status_(uint8_t status) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[40]; + ESPHOME_strncpy_P( + text, + reinterpret_cast(ThresholdStatusStrings::get_progmem_str(status, TMP102_THRESHOLD_STATUS_OK)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->threshold_status_text_sensor_->publish_state(text); +} + +void TMP102Component::publish_threshold_rejection_(bool high, float value, float other) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[96]; + if (high) { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: high %.4f°C crosses low %.4f°C"), value, other); + } else { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: low %.4f°C crosses high %.4f°C"), value, other); + } + this->threshold_status_text_sensor_->publish_state(text); +} +#endif + #ifdef USE_TMP102_BINARY_SENSOR void TMP102Component::invalidate_alert_() { if (this->alert_binary_sensor_ != nullptr) @@ -294,7 +380,7 @@ bool TMP102Component::read_alert_state_() { #endif #ifdef USE_TMP102_NUMBER -float TMP102LimitNumber::load_value_(float fallback) { +float TMP102LimitNumber::load_value_(float fallback, bool &corrected) { float value = fallback; if (this->restore_value_) { this->pref_ = this->make_entity_preference(); @@ -303,6 +389,7 @@ float TMP102LimitNumber::load_value_(float fallback) { if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); value = fallback; + corrected = true; } return value; } @@ -313,6 +400,7 @@ void TMP102LimitNumber::control(float value) { // A chip that failed setup holds nothing the entity could show if (this->parent_->is_failed()) { ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED); return; } const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); @@ -320,6 +408,9 @@ void TMP102LimitNumber::control(float value) { this->publish_state(value); if (accepted && !this->save_value_(value)) { ESP_LOGW(TAG, "Failed to save threshold preference"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED); + } else if (accepted) { + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_OK); } } #endif diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index d88dde78344..34cdfc973c1 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -12,11 +12,20 @@ #include "esphome/core/preferences.h" #endif #include "esphome/components/sensor/sensor.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include namespace esphome::tmp102 { +#ifdef USE_HOST +namespace testing { +class TMP102Test; +} +#endif + #ifdef USE_TMP102_NUMBER enum TMP102LimitType : uint8_t { TMP102_LIMIT_LOW = 0, @@ -31,8 +40,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #ifdef USE_TMP102_BINARY_SENSOR SUB_BINARY_SENSOR(alert) #endif -#ifdef USE_TMP102_CONFIGURE +#ifdef USE_TMP102_TEXT_SENSOR + SUB_TEXT_SENSOR(threshold_status) +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void setup() override; +#endif +#ifdef USE_TMP102_CONFIGURE void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { this->config_ = config; this->high_limit_ = high_limit; @@ -47,6 +61,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: +#ifdef USE_TMP102_TEXT_SENSOR + void publish_threshold_status_(uint8_t status); + void publish_threshold_rejection_(bool high, float value, float other); +#else + void publish_threshold_status_(uint8_t) {} + void publish_threshold_rejection_(bool, float, float) {} +#endif void set_warning_and_invalidate_alert_() { this->status_set_warning(); #ifdef USE_TMP102_BINARY_SENSOR @@ -58,6 +79,7 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void invalidate_alert_(); #endif #ifdef USE_TMP102_CONFIGURE + void setup_configured_(); bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); bool check_configuration_(); @@ -67,11 +89,10 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu friend class TMP102LimitNumber; bool set_limit_temperature_(TMP102LimitType limit, float temperature); float get_limit_temperature_(TMP102LimitType limit) const; - void prepare_number_limits_(float *restored); + bool prepare_number_limits_(float *restored, bool *restore_corrected); TMP102LimitNumber *limit_controls_[2]{}; #endif - uint16_t config_{0}; uint16_t high_limit_{0}; uint16_t low_limit_{0}; @@ -87,7 +108,10 @@ class TMP102LimitNumber final : public number::Number, public Parented bool: + return CORE.is_host and any( + conf[CONF_ID] == uart_id for conf in full_config.get(DOMAIN) or [] + ) + + def validate_raw_data(value): if isinstance(value, str): return value.encode("utf-8") diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 7fb704536dd..2cb9d497241 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -9,11 +9,11 @@ #error This HostUartComponent implementation is not supported on this host OS #endif -#include -#include +#include +#include +#include #include #include -#include #include #include @@ -210,7 +210,6 @@ void HostUartComponent::write_array(const uint8_t *data, size_t len) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); } #endif - return; } bool HostUartComponent::peek_byte(uint8_t *data) { @@ -286,6 +285,36 @@ UARTFlushResult HostUartComponent::flush() { return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } +bool HostUartComponent::set_modem_control(bool dtr, bool rts) { + if (this->file_descriptor_ == -1) { + return false; + } + // One TIOCMSET so the device never sees a state in between the old and the new one + int bits; + if (ioctl(this->file_descriptor_, TIOCMGET, &bits) == -1) { + this->update_error_(strerror(errno)); + return false; + } + bits = (bits & ~(TIOCM_DTR | TIOCM_RTS)) | (dtr ? TIOCM_DTR : 0) | (rts ? TIOCM_RTS : 0); + if (ioctl(this->file_descriptor_, TIOCMSET, &bits) == -1) { + this->update_error_(strerror(errno)); + return false; + } + return true; +} + +bool HostUartComponent::get_dtr() const { return (this->get_modem_bits_() & TIOCM_DTR) != 0; } + +bool HostUartComponent::get_rts() const { return (this->get_modem_bits_() & TIOCM_RTS) != 0; } + +int HostUartComponent::get_modem_bits_() const { + int bits = 0; + if (this->file_descriptor_ != -1) { + ioctl(this->file_descriptor_, TIOCMGET, &bits); + } + return bits; +} + void HostUartComponent::update_error_(const std::string &error) { if (this->first_error_.empty()) { this->first_error_ = error; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index bca62debf10..55e89d892a9 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -2,6 +2,8 @@ #ifdef USE_HOST +#include + #include "esphome/core/component.h" #include "esphome/core/log.h" #include "uart_component.h" @@ -10,7 +12,7 @@ namespace esphome::uart { class HostUartComponent final : public UARTComponent, public Component { public: - virtual ~HostUartComponent(); + ~HostUartComponent(); void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -19,10 +21,15 @@ class HostUartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; UARTFlushResult flush() override; - void set_name(std::string port_name) { port_name_ = port_name; }; + /// Drive DTR/RTS. Returns false when the port is closed or the ioctl fails. + bool set_modem_control(bool dtr, bool rts); + bool get_dtr() const; + bool get_rts() const; + void set_name(std::string port_name) { this->port_name_ = std::move(port_name); } protected: void update_error_(const std::string &error); + int get_modem_bits_() const; void check_logger_conflict() override {} std::string port_name_; std::string first_error_; diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index aaab8e22a18..bbb6f15f292 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -12,6 +12,7 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -39,7 +40,7 @@ namespace esphome::usb_host { // - Main loop controls backpressure by deciding when to restart after consuming data // The atomic bitmask ensures thread-safe allocation/deallocation without mutex blocking. -static const char *const TAG = "usb_host"; +ESPHOME_LOG_TAG(TAG, "usb_host"); // Forward declarations struct TransferRequest; diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index ed5a034b82f..ad4e6a2140b 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -10,6 +10,11 @@ namespace esphome::usb_uart { using namespace bytebuffer; +static constexpr uint8_t CH341_REQ_MODEM_CTRL = 0xA4; +// Modem control bits, sent inverted +static constexpr uint8_t CH341_BIT_DTR = 1 << 5; +static constexpr uint8_t CH341_BIT_RTS = 1 << 6; + struct CH34xEntry { const char *name; uint16_t pid; @@ -145,21 +150,23 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo return true; } case 1: - this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + this->modem_control_transfer(channel); return true; default: return false; } } -std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { - auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); - // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare - for (auto &cdc_dev : result) { - cdc_dev.interrupt_interface_number = 0xFF; - } - return result; +void USBUartTypeCH34X::modem_control_transfer(USBUartChannelBase *channel) { + uint8_t request = CH341_REQ_MODEM_CTRL + channel->index_; + if (channel->index_ >= 2) + request += 0xE; + const uint8_t control = (channel->dtr_ ? CH341_BIT_DTR : 0) | (channel->rts_ ? CH341_BIT_RTS : 0); + ESP_LOGD(TAG, "MODEM_CTRL: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + // Integer promotion makes this 16 bits wide, as in Linux: 0xFF9F with both lines on + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, request, static_cast(~control), 0); } + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e920da6c267..0629e8f77ad 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -43,6 +43,12 @@ static constexpr uint8_t SET_BAUDRATE = 0x1E; // Set the baud rate. static constexpr uint8_t SET_CHARS = 0x19; // Set special characters. static constexpr uint8_t VENDOR_SPECIFIC = 0xFF; // Vendor specific command. +// SET_MHS value bits. The high byte selects which lines the low byte changes. +static constexpr uint16_t CONTROL_DTR = 0x0001; +static constexpr uint16_t CONTROL_RTS = 0x0002; +static constexpr uint16_t CONTROL_WRITE_DTR = 0x0100; +static constexpr uint16_t CONTROL_WRITE_RTS = 0x0200; + std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev_hdl) { const usb_config_desc_t *config_desc; const usb_device_desc_t *device_desc; @@ -88,11 +94,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } - if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); - } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); - } + CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN + ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber} + : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}; + eps.bulk_interface_class = data_desc->bInterfaceClass; + cdc_devs.push_back(eps); } return cdc_devs; } @@ -119,10 +125,22 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); return true; } + case 3: // Assert DTR+RTS (init only) + if (reload) + return false; + this->modem_control_transfer(channel); + return true; default: return false; } } + +void USBUartTypeCP210X::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = + CONTROL_WRITE_DTR | CONTROL_WRITE_RTS | (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0); + ESP_LOGD(TAG, "SET_MHS: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_MHS, value, channel->index_); +} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index e10da78aaf0..45acdb08f90 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -13,6 +13,13 @@ namespace esphome::usb_uart { using namespace bytebuffer; +static constexpr uint8_t SIO_SET_MODEM_CTRL = 0x01; +// The high byte selects which lines the low byte changes +static constexpr uint16_t SIO_SET_DTR_MASK = 0x0100; +static constexpr uint16_t SIO_SET_RTS_MASK = 0x0200; +static constexpr uint16_t SIO_DTR_HIGH = 0x0001; +static constexpr uint16_t SIO_RTS_HIGH = 0x0002; + // FTDI chip family identifiers. These map to USB device bcdDevice values // and determine how baudrate divisors and clock sources are calculated. enum FtdiChipType { @@ -217,6 +224,7 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; return eps; } @@ -372,14 +380,21 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b case 3: // set modem control DTR+RTS (init only) if (reload) return false; - this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeFT23XX::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = + SIO_SET_DTR_MASK | SIO_SET_RTS_MASK | (channel->dtr_ ? SIO_DTR_HIGH : 0) | (channel->rts_ ? SIO_RTS_HIGH : 0); + ESP_LOGD(TAG, "SIO_SET_MODEM_CTRL: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, SIO_SET_MODEM_CTRL, value, + channel->cdc_dev_.bulk_interface_number + 1); +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 36107c093c4..89bcaa6dad3 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,10 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}; + eps.bulk_interface_class = intf->bInterfaceClass; + eps.interrupt_interface_class = intf->bInterfaceClass; + cdc_devs.push_back(eps); break; // PL2303 is single-port } } @@ -279,13 +282,19 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b // Assert DTR + RTS (init only) if (reload) return false; - this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypePL2303::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0); + ESP_LOGD(TAG, "PL2303: SET_CONTROL_REQUEST DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, value, channel->cdc_dev_.bulk_interface_number); +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index f61642566cb..4c48aa1fd5b 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -41,14 +41,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() { if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { if (!this->claim_comm_interface_) { - ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", - channel->cdc_dev_.interrupt_interface_number); + ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number); } else { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); @@ -497,16 +499,17 @@ void USBUartTypeCdcAcm::on_disconnected() { channel->initialised_.store(false); // The descriptors these point into are freed with the device channel->cdc_dev_ = {}; + channel->dtr_ = true; + channel->rts_ = true; } USBClient::on_disconnected(); } +static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; + bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { - static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; - static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; - static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS switch (step) { case 0: { @@ -535,38 +538,61 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // Assert DTR+RTS to signal DTE is present (init only). if (reload) return false; - this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeCdcAcm::modem_control_transfer(USBUartChannelBase *channel) { + static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; + static constexpr uint16_t CDC_CONTROL_LINE_DTR = 1 << 0; + static constexpr uint16_t CDC_CONTROL_LINE_RTS = 1 << 1; + const uint16_t value = (channel->dtr_ ? CDC_CONTROL_LINE_DTR : 0) | (channel->rts_ ? CDC_CONTROL_LINE_RTS : 0); + ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, value, + channel->cdc_dev_.interrupt_interface_number); +} + void USBUartComponent::enable_channels() { this->cfg_single_ = nullptr; - this->cfg_pending_reload_ = nullptr; + // Init sends every channel's current line settings and DTR/RTS + for (auto *channel : this->channels_) { + channel->reload_pending_ = false; + channel->modem_pending_ = false; + } this->cfg_channel_idx_ = 0; - this->start_config_(false); + this->start_config_(ConfigMode::CONFIG_MODE_INIT); } void USBUartComponent::apply_channel_settings(USBUartChannelBase *channel) { if (this->cfg_active_) { // A config sequence is already running. Defer this reload until it finishes to preserve // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an - // in-flight callback complete against fresh state). The pending slot coalesces multiple + // in-flight callback complete against fresh state). The per-channel flag coalesces multiple // requests; the channel's live settings are read when the reload eventually runs. - // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. - this->cfg_pending_reload_ = channel; + channel->reload_pending_ = true; return; } this->cfg_single_ = channel; - this->start_config_(true); + this->start_config_(ConfigMode::CONFIG_MODE_RELOAD); } -void USBUartComponent::start_config_(bool reload) { - this->cfg_reload_ = reload; - this->cfg_device_phase_ = !reload; +void USBUartComponent::apply_modem_control(USBUartChannelBase *channel) { + if (this->cfg_active_) { + // Deferred like a reload. The transfer sends whatever dtr_/rts_ hold when it runs, so a + // channel that is set several times before then only sends its latest state. + channel->modem_pending_ = true; + return; + } + this->cfg_single_ = channel; + this->start_config_(ConfigMode::CONFIG_MODE_MODEM); +} + +void USBUartComponent::start_config_(ConfigMode mode) { + this->cfg_mode_ = mode; + this->cfg_device_phase_ = mode == ConfigMode::CONFIG_MODE_INIT; this->cfg_step_ = 0; this->cfg_ok_ = true; this->cfg_in_flight_ = false; @@ -619,8 +645,9 @@ bool USBUartComponent::run_config_machine_() { // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing // cfg_done_ with acquire ordering), so it is safe to read. - ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, - this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + const bool init = this->cfg_mode_ == ConfigMode::CONFIG_MODE_INIT; + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d mode=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, static_cast(this->cfg_mode_), this->cfg_ok_); // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. if (this->cfg_device_phase_) { @@ -642,17 +669,24 @@ bool USBUartComponent::run_config_machine_() { if (!this->cfg_ok_) { // A previous step in this channel's sequence failed. Abort the rest. On a full init, // mark the channel uninitialised so data flow isn't started on a misconfigured channel; - // on a reload, leave the already-working channel as it was. - if (!this->cfg_reload_) + // otherwise, leave the already-working channel as it was. + if (init) channel->initialised_.store(false); - } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + } else if (this->cfg_mode_ == ConfigMode::CONFIG_MODE_MODEM) { + // A single transfer, so only the first step issues one + if (this->cfg_step_ == 0) { + this->modem_control_transfer(channel); + this->cfg_in_flight_ = true; + return true; + } + } else if (this->config_step(channel, this->cfg_step_, !init, this->cfg_ok_, this->cfg_response_)) { this->cfg_in_flight_ = true; return true; } } // Channel finished (or aborted). On full init, kick off data flow if still initialised. - if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { + if (channel != nullptr && init && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); @@ -670,11 +704,24 @@ bool USBUartComponent::run_config_machine_() { this->report_connected_(); } - // If the machine just went idle and a reload was requested while it was busy, start it now. - if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { - this->cfg_single_ = this->cfg_pending_reload_; - this->cfg_pending_reload_ = nullptr; - this->start_config_(true); + // If the machine just went idle and a run was requested while it was busy, start it now. + // A channel's pending modem change waits for its pending reload: the reload's init-only + // steps are skipped, so it cannot undo the modem lines. + if (!this->cfg_active_) { + for (auto *pending : this->channels_) { + if (pending->reload_pending_) { + pending->reload_pending_ = false; + this->cfg_single_ = pending; + this->start_config_(ConfigMode::CONFIG_MODE_RELOAD); + break; + } + if (pending->modem_pending_) { + pending->modem_pending_ = false; + this->cfg_single_ = pending; + this->start_config_(ConfigMode::CONFIG_MODE_MODEM); + break; + } + } } return true; } @@ -710,6 +757,18 @@ void USBUartChannelBase::load_settings(bool /*dump_config*/) { this->parent_->apply_channel_settings(this); } +bool USBUartChannelBase::set_modem_control(bool dtr, bool rts) { + if (!this->initialised_.load()) + return false; + const bool changed = dtr != this->dtr_ || rts != this->rts_; + this->dtr_ = dtr; + this->rts_ = rts; + // Nothing to send when the request matches the previous one + if (changed) + this->parent_->apply_modem_control(this); + return true; +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..56525f29159 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -9,6 +9,7 @@ #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -19,7 +20,7 @@ class USBUartComponent; class USBUartChannelBase; class USBUartTypePL2303; -static const char *const TAG = "usb_uart"; +ESPHOME_LOG_TAG(TAG, "usb_uart"); static constexpr uint8_t USB_CDC_SUBCLASS_ACM = 0x02; static constexpr uint8_t USB_SUBCLASS_COMMON = 0x02; @@ -39,6 +40,9 @@ struct CdcEps { // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; + // bInterfaceClass of each interface above, which decides how a host binds the port + uint8_t bulk_interface_class{0}; + uint8_t interrupt_interface_class{0}; }; enum CH34xChipType : uint8_t { @@ -100,6 +104,13 @@ static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE // Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; +/// What the config state machine is applying +enum class ConfigMode : uint8_t { + CONFIG_MODE_INIT, ///< Full initialisation of every channel after the device connected + CONFIG_MODE_RELOAD, ///< Re-apply the line settings of one already-open channel + CONFIG_MODE_MODEM, ///< Apply the DTR/RTS state of one already-open channel +}; + class RingBuffer { public: RingBuffer(uint16_t buffer_size) : buffer_size_(buffer_size), buffer_(new uint8_t[buffer_size]) {} @@ -186,6 +197,21 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// bInterfaceClass of the interface `get_interface_number()` names + uint8_t get_interface_class() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class + : this->cdc_dev_.bulk_interface_class; + } + + /// Index of this port among the ports on its interface. Every driver here exposes one + /// port per interface; a multiplexed driver would index its channels. + uint8_t get_port_number() const { return 0; } + + /// Store DTR/RTS and send them to the device. False when not connected. + bool set_modem_control(bool dtr, bool rts); + bool get_dtr() const { return this->dtr_; } + bool get_rts() const { return this->rts_; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -211,6 +237,12 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented &data = {}); - // (Re)start the config state machine. reload=false runs full init over all channels; - // reload=true re-applies settings to cfg_single_ only. - void start_config_(bool reload); + // (Re)start the config state machine. INIT runs over all channels; the other modes act + // on cfg_single_ only. + void start_config_(ConfigMode mode); // Advance the config state machine; called from loop(). Returns true if it did work. bool run_config_machine_(); @@ -271,6 +306,9 @@ class USBUartComponent : public usb_host::USBClient { // Optional one-time device-level setup run before the per-channel phase on init only // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // Issue the one control transfer, via config_transfer_(), that puts the channel's dtr_ + // and rts_ on the wire. + virtual void modem_control_transfer(USBUartChannelBase *channel) = 0; // The device is only usable once the config machine has applied every channel's line // settings, so the connected report waits for run_config_machine_() to finish the init @@ -279,14 +317,13 @@ class USBUartComponent : public usb_host::USBClient { std::vector channels_{}; // Config state machine - USBUartChannelBase *cfg_single_{nullptr}; // non-null: reload of a single channel - USBUartChannelBase *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy - std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads - uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + USBUartChannelBase *cfg_single_{nullptr}; // non-null: single-channel run (RELOAD or MODEM) + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; + ConfigMode cfg_mode_{ConfigMode::CONFIG_MODE_INIT}; bool cfg_active_{false}; - bool cfg_reload_{false}; bool cfg_device_phase_{false}; bool cfg_in_flight_{false}; bool cfg_ok_{true}; @@ -302,6 +339,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; // Each claimed interface pins one host hardware channel per endpoint; skipping // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). bool claim_comm_interface_{true}; @@ -314,16 +352,22 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: - USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) { + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The + // comm interface is left in place unclaimed: on the CDC-style chips it is still the + // interface a host binds, and so the one the port is identified by. + this->claim_comm_interface_ = false; + } void dump_config() override; protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; - std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: CH34xChipType chiptype_{CHIP_UNKNOWN}; @@ -341,6 +385,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; uint8_t chip_type_{255}; }; @@ -364,6 +409,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 1d66ca5950f..a0fa321812c 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -224,11 +224,11 @@ void VoiceAssistant::stream_api_audio_() { while (true) { // fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available() // reports the currently exposed bytes either way. - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); size_t available = this->audio_source_->available(); size_t available2 = 0; if (this->audio_source2_ != nullptr) { - this->audio_source2_->fill(0, false); + this->audio_source2_->fill(0); available2 = this->audio_source2_->available(); } @@ -383,14 +383,13 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { - // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { this->stream_api_audio_(); } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used while (true) { - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); size_t available = this->audio_source_->available(); if (available == 0) { break; diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 873c5b5a497..50144b6df73 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 72d3bf75b1c..38098971f4f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include "esphome/core/progmem.h" diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d9a7d65e8ce..456c9be2c9a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -959,10 +959,39 @@ void AsyncEventSourceResponse::process_close_() { } } +void AsyncEventSourceResponse::refresh_lru_() { + // httpd only advances a session's LRU counter when a request arrives, and an event stream receives one + // request ever, so the purge that frees a socket for a new client would evict the live streams first. + // The HTTPD task owns the session table, so the refresh runs there. + // A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh. + if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0) + return; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS || + this->lru_work_queued_.load(std::memory_order_acquire)) { + return; + } + this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval + this->lru_work_queued_.store(true, std::memory_order_release); + if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) { + this->lru_work_queued_.store(false, std::memory_order_release); + } +} + +void AsyncEventSourceResponse::refresh_lru_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (response->owns_session_(fd)) { + httpd_sess_update_lru_counter(response->hd_, fd); + } + // Release self only after the HTTPD-task callback has finished every access. + response->lru_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::close_session_work(void *arg) { auto *response = static_cast(arg); const int fd = response->fd_.load(); - if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + if (response->owns_session_(fd)) { // The HTTPD task remains the session owner. Shutting the socket down makes // its next select/recv path delete the session and invoke destroy(). shutdown(fd, SHUT_RDWR); @@ -976,7 +1005,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { // httpd frees a session before closing its socket, so the fd may already be a new client's. // Treated as would-block; the stall timer ends a session that never becomes ours again. const int fd = this->fd_.load(); - if (httpd_sess_get_ctx(this->hd_, fd) != this) { + if (!this->owns_session_(fd)) { return 0; } struct msghdr msg {}; @@ -1085,6 +1114,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } + this->refresh_lru_(); drain_tail_(); process_deferred_queue_(); if (this->close_requested_) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 4d4490d9076..c25dd8d609f 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -339,10 +339,15 @@ class AsyncEventSourceResponse { }; void process_close_(); static void close_session_work(void *arg); + void refresh_lru_(); + static void refresh_lru_work(void *arg); + // httpd frees a session before closing its socket, so the fd may already belong to a new client + bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; } - // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object. bool safe_to_delete_() const { - return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) && + !this->lru_work_queued_.load(std::memory_order_acquire); } static void destroy(void *p); @@ -357,11 +362,13 @@ class AsyncEventSourceResponse { RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; + uint32_t last_lru_refresh_ms_{0}; uint16_t tail_cap_{0}; uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + std::atomic lru_work_queued_{false}; // Main-loop only; the HTTPD task never reads or writes these flags. bool close_requested_{false}; bool close_retry_warning_logged_{false}; @@ -383,6 +390,7 @@ class AsyncEventSourceResponse { static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; + static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 667e288fd72..5879729a22b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -18,8 +18,9 @@ from esphome.components.esp32 import ( from esphome.components.network import ( add_use_address, get_network_priority, - has_high_performance_networking, + has_high_performance_wifi, ip_address_literal, + require_high_performance_wifi, ) from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform @@ -30,6 +31,7 @@ from esphome.const import ( CONF_CERTIFICATE, CONF_CERTIFICATE_AUTHORITY, CONF_CHANNEL, + CONF_CHANNELS, CONF_DNS1, CONF_DNS2, CONF_DOMAIN, @@ -157,9 +159,15 @@ def has_native_wifi( CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" +CONF_DEVICE_INFO = "device_info" +CONF_DPP = "dpp" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_ON_DPP_URI = "on_dpp_uri" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_PRIVATE_KEY = "private_key" +CONF_AP_MODE = "ap_mode" +CONF_DNS_SERVERS = "dns_servers" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ @@ -170,10 +178,15 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_AP_TIMEOUT_PERIOD = cv.time_period(DEFAULT_AP_TIMEOUT) DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. +# Similar to AP timeout, but reduced to allow repetitive enrollment attempts +# in the case of failure. Does not affect AP or scanning. +DEFAULT_DPP_TIMEOUT = "1min" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -209,6 +222,32 @@ WIFI_8266_PHY_MODES = { "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, } + +WIFI_AP_MODE_FALLBACK = "FALLBACK" +WIFI_AP_MODE_WISP = "WISP" + + +def _validate_ap_mode(value: str) -> str: + value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value) + if value == WIFI_AP_MODE_WISP: + # esp_netif_napt_enable() exists from ESP-IDF 5.1 + cv.require_framework_version( + esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT" + )(value) + return value + + +def _validate_dns_servers(value: list) -> list: + if any(ip.is_unspecified for ip in value): + raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer") + if len(value) > 1: + cv.require_framework_version( + esp_idf=cv.Version(6, 0, 0), + extra_message="The access point offers a second DNS server from ESP-IDF 6.0", + )(value) + return value + + WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) @@ -220,6 +259,14 @@ WiFiConfigureAction = wifi_ns.class_( ) +def _validate_dpp_device_info(value: object) -> str: + """The supplicant puts the text on a space separated command line and reads it up to the next space.""" + value = cv.string_strict(value) + if any(c.isspace() for c in value): + raise cv.Invalid("device_info cannot contain whitespace") + return value + + def validate_password(value): value = cv.string_strict(value) if not value: @@ -286,6 +333,26 @@ EAP_AUTH_SCHEMA = cv.All( cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE), ) +# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method +DPP_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict), + cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info, + cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list( + cv.int_range(min=1, max=14) + ), + cv.Optional( + CONF_TIMEOUT, default=DEFAULT_DPP_TIMEOUT + ): cv.positive_time_period_milliseconds, + } +) + + +def _dpp_schema(value: ConfigType | None) -> ConfigType: + # A bare `dpp:` takes every default + return DPP_SCHEMA({} if value is None else value) + + WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), @@ -304,6 +371,12 @@ WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( cv.Optional( CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AP_MODE, default=WIFI_AP_MODE_FALLBACK): _validate_ap_mode, + cv.Optional(CONF_DNS_SERVERS): cv.All( + cv.ensure_list(cv.ipv4address), + cv.Length(min=1, max=2), + _validate_dns_servers, + ), } ) @@ -312,6 +385,19 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) + if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + if not config.get(CONF_PASSWORD): + raise cv.Invalid( + "WISP mode requires a password for the access point", + path=[CONF_PASSWORD], + ) + if config[CONF_AP_TIMEOUT].total_milliseconds == 0: + raise cv.Invalid( + "ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0", + path=[CONF_AP_TIMEOUT], + ) + elif CONF_DNS_SERVERS in config: + raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS]) if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " @@ -361,16 +447,42 @@ def _apply_min_auth_mode_default(config): def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config + has_dpp = CONF_DPP in config full_config = fv.full_config.get() has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config - if not (has_sta or has_ap or has_improv or has_improv_serial): + if not (has_sta or has_ap or has_dpp or has_improv or has_improv_serial): raise cv.Invalid( - "Please specify at least an SSID or an Access Point to create." + "Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)." ) - if has_ap and not has_captive_portal and not has_web_server: + if CONF_ON_DPP_URI in config and not has_dpp: + raise cv.Invalid(f"'{CONF_ON_DPP_URI}' requires '{CONF_DPP}'") + + ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP + if ap_is_wisp: + if not has_sta: + raise cv.Invalid( + "WISP mode requires at least one STA network (wifi.ssid or wifi.networks)" + ) + if has_captive_portal: + raise cv.Invalid( + "captive_portal is not supported together with ap_mode: WISP" + ) + # Every client packet passes through the WiFi driver, none through the device's own sockets + require_high_performance_wifi() + if CONF_DNS_SERVERS not in config[CONF_AP]: + # The access point passes the station's resolvers on, which a static address has to name + for network in [config, *config.get(CONF_NETWORKS, [])]: + static = network.get(CONF_MANUAL_IP) + if static and static[CONF_DNS1].is_unspecified: + raise cv.Invalid( + "manual_ip needs dns1 in WISP mode unless ap.dns_servers is set", + path=[CONF_MANUAL_IP, CONF_DNS1], + ) + + if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp: _LOGGER.warning( "WiFi AP is configured but neither captive_portal nor web_server is enabled. " "The AP will not be usable for configuration or monitoring. " @@ -543,6 +655,7 @@ CONFIG_SCHEMA = cv.All( ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, + cv.Optional(CONF_DPP): cv.All(cv.only_on_esp32, _dpp_schema), cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT @@ -589,6 +702,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_DPP_URI): automation.validate_automation(single=True), cv.Optional(CONF_USE_PSRAM): cv.All( cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), @@ -708,19 +822,45 @@ async def to_code(config): lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) # Skip the setter when the config matches the C++ initializer. - if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD: cg.add(var.set_ap_timeout(ap_timeout)) + if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + cg.add_define("USE_WIFI_AP_WISP") + add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True) + add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True) + if dns_servers := conf.get(CONF_DNS_SERVERS): + cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS") + # As lwIP stores an IPv4 address: the four bytes read as one little-endian word + for i, ip in enumerate(dns_servers): + cg.add_define( + f"WIFI_AP_WISP_DNS_{i}", int.from_bytes(ip.packed, "little") + ) cg.add_define("USE_WIFI_AP") # The LN882H radio cannot run the AP and STA together; the fallback AP # takes turns with the networks instead. if CORE.is_ln882x: cg.add_define("USE_WIFI_AP_EXCLUSIVE") + # The Pico W driver drops the AP station events; wifi_component_pico_w.cpp counts them + if CORE.is_rp2: + cg.add_build_flag("-Wl,--wrap=cyw43_cb_process_async_event") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. if CORE.is_esp32: request_wifi(ap=CONF_AP in config) + if (dpp := config.get(CONF_DPP)) is not None: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True) + if (key := dpp.get(CONF_PRIVATE_KEY)) is not None: + cg.add(var.set_dpp_key(key)) + if (info := dpp.get(CONF_DEVICE_INFO)) is not None: + cg.add(var.set_dpp_device_info(info)) + cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS]))) + if (timeout := dpp[CONF_TIMEOUT]) != cv.time_period(DEFAULT_DPP_TIMEOUT): + cg.add(var.set_dpp_timeout(timeout)) + cg.add_define("USE_WIFI_DPP") + # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) @@ -823,8 +963,8 @@ async def to_code(config): if config.get(CONF_USE_PSRAM): add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) - # Apply high performance WiFi settings if high performance networking is enabled - if CORE.is_esp32 and has_high_performance_networking(): + # Apply high performance WiFi settings if a component asked for them + if CORE.is_esp32 and has_high_performance_wifi(): # Check if PSRAM is guaranteed (set by psram component during final validation) psram_guaranteed = psram_is_guaranteed() @@ -891,6 +1031,12 @@ async def to_code(config): var.get_disconnect_trigger(), [], on_disconnect_config ) + if (on_dpp_uri := config.get(CONF_ON_DPP_URI)) is not None: + cg.add_define("USE_WIFI_DPP_URI_TRIGGER") + await automation.build_callback_automation( + var, "add_on_dpp_uri_callback", [(cg.const_char_ptr, "uri")], on_dpp_uri + ) + CORE.add_job(final_step) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 62732e09153..c37ec44384b 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -187,11 +187,11 @@ bool CompactString::operator==(const StringRef &other) const { /// │ ↓ │ /// │ 5. FAILED → RESTARTING_ADAPTER │ /// │ - Normal: restart adapter, clear state │ -/// │ - AP/improv active: skip restart, just disconnect │ +/// │ - AP/DPP/improv active: skip restart, just disconnect │ /// │ ↓ │ /// │ 6. Loop back to start: │ /// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ -/// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │ +/// │ - If AP/DPP/improv active → RETRY_HIDDEN (blind retry, see below)│ /// │ - Otherwise → SCAN_CONNECTING (rescan) │ /// │ ↓ │ /// │ 7. RESCAN → Apply stored priorities, sort again │ @@ -655,12 +655,17 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) - // The access point and the portals on it are provisioning surfaces: once the window has - // closed, end them (mirrors the teardown on a successful connection). The fallback block in - // loop() is gated so none of them is started again afterwards. +#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP))) + // The access point, the portals on it and the DPP listen are provisioning surfaces: once the + // window has closed, end them (mirrors the teardown on a successful connection). The blocks in + // loop() that start them are gated so none of them is started again afterwards. A bridge AP + // carries no portal and stays up. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); @@ -672,8 +677,9 @@ void WiFiComponent::setup() { return; } #endif - this->wifi_mode_({}, false); + this->disable_ap_(); } +#endif // USE_WIFI_AP }); } #endif @@ -690,7 +696,8 @@ void WiFiComponent::setup() { void WiFiComponent::start() { ESP_LOGCONFIG(TAG, "Starting"); - this->last_connected_ = millis(); + // The loop's time, not millis(): a wifi.enable from a trigger in this pass must not stamp a time after now + this->set_last_connected_(App.get_loop_component_start_time()); uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL; @@ -790,6 +797,11 @@ void WiFiComponent::start() { this->start_ap_portal_(); #endif // USE_WIFI_AP } +#ifdef USE_WIFI_DPP + // Without networks there is nothing to try first, so listen for a configurator right away + if (!this->has_sta() && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(millis()); +#endif #ifdef USE_IMPROV_BLE if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) @@ -842,14 +854,15 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); - // Skip cooldown if new credentials were provided while connecting + // Credentials not tried yet: retry whatever the driver reports. The flag outlives an adapter restart, + // which only lands back here, and start_connecting() clears it once they are tried if (this->skip_cooldown_next_cycle_) { - this->skip_cooldown_next_cycle_ = false; - this->check_connecting_finished(now); + ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); + this->retry_connect(); break; } // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP - bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_(); + bool portal_active = this->is_provisioning_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -879,7 +892,7 @@ void WiFiComponent::loop() { this->retry_connect(); } else { this->status_clear_warning(); - this->last_connected_ = now; + this->set_last_connected_(now); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the @@ -911,8 +924,8 @@ void WiFiComponent::loop() { // The networks are not tried while the AP is up, so it pauses for // them once nobody has used it for a while. Without networks the AP // is all there is and stays. - if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && - (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + if (this->has_sta() && now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) this->pause_exclusive_ap_(); break; #endif @@ -922,38 +935,42 @@ void WiFiComponent::loop() { return; } -#ifdef USE_WIFI_AP - bool provisioning_closed = false; -#ifdef USE_PROVISIONING +#ifdef USE_WIFI_AP_WISP + // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout + if (!this->ap_setup_ && this->ap_dns_known_() && + (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) { + ESP_LOGI(TAG, "Starting AP"); + this->ap_changed_ = now; + this->setup_ap_config_(); + } +#elif defined(USE_WIFI_AP) // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. - provisioning_closed = - provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); -#endif - if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { + if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) { + const uint32_t since_connected = now - this->last_connected_; if (this->ap_timeout_ != 0 && - (now - this->last_connected_ > this->ap_timeout_) #ifdef USE_WIFI_AP_EXCLUSIVE - // After a pause, or a start that failed, the networks get a full - // ap_timeout before the AP is tried again. - && now - this->ap_exclusive_changed_ > this->ap_timeout_ + // After a pause, or a start that failed, the networks get a full ap_timeout before the AP is tried again + since_connected > this->ap_timeout_ && now - this->ap_changed_ > this->ap_timeout_ +#else + // Tried in whole ap_timeout slots after the last connection; after UINT8_MAX slots it is no longer tried + this->ap_start_failures_ != UINT8_MAX && since_connected / this->ap_timeout_ > this->ap_start_failures_ #endif ) { ESP_LOGI(TAG, "Starting fallback AP"); #ifdef USE_WIFI_AP_EXCLUSIVE - this->ap_exclusive_changed_ = now; + this->ap_changed_ = now; #endif this->setup_ap_config_(); - // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. - if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { -#ifdef USE_CAPTIVE_PORTAL - // Reset so we force one full scan after captive portal starts - // (previous scans were filtered because captive portal wasn't active yet) - this->has_completed_scan_after_captive_portal_start_ = false; -#endif - this->start_ap_portal_(); +#ifndef USE_WIFI_AP_EXCLUSIVE + if (!this->ap_setup_) { + // Skip the next slot too, so at least a full ap_timeout passes wherever in its slot this try ran + uint32_t slot = since_connected / this->ap_timeout_ + 1; + this->ap_start_failures_ = slot < UINT8_MAX ? slot : UINT8_MAX; } +#endif + this->start_ap_portal_(); } } #endif // USE_WIFI_AP @@ -966,29 +983,43 @@ void WiFiComponent::loop() { improv_ble::global_improv_component->start(); } } - #endif - if (!this->has_ap() && this->reboot_timeout_ != 0) { - if (now - this->last_connected_ > this->reboot_timeout_) { - bool suppress = false; + if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { + bool suppress = false; +#if defined(USE_WIFI_AP) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // The Realtek driver reports no stations and the Lightning one is unverified, so a running fallback AP + // holds the reboot there as it always did + suppress = this->ap_setup_; +#elif defined(USE_WIFI_AP) + // A station on the fallback AP is using the device + suppress = this->ap_setup_ && this->ap_clients_ != 0; +#endif #ifdef USE_PROVISIONING - // Don't reboot while a provisioning window is pending (device unprovisioned). - // The device is legitimately waiting to be onboarded (Wi-Fi must come up - // before the controller can set credentials), and an auto-reboot would reopen - // the window without the deliberate power cycle / reset that is meant to be - // required. Resumes normal reboot behavior once provisioned. - suppress = provisioning::global_provisioning_manager != nullptr && - provisioning::global_provisioning_manager->window_pending(); + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress |= provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); #endif - if (!suppress) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); - } + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); } } } +#ifdef USE_WIFI_DPP + // Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again + // here, at most once per timeout + if (!this->has_sta() && !this->dpp_listening_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && + now - this->dpp_listen_started_ > this->dpp_timeout_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) { + this->start_dpp_listen_(now); + } +#endif + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Check if power save mode needs to be updated based on high-performance requests if (this->high_performance_semaphore_ != nullptr) { @@ -1062,10 +1093,12 @@ void WiFiComponent::setup_ap_config_() { this->ap_.set_ssid(ssid_buf); } } + // Reset before the AP exists: afterwards only the platform's event path writes the counter + this->ap_clients_ = 0; this->ap_setup_ = this->wifi_start_ap_(this->ap_); -#ifdef USE_WIFI_AP_EXCLUSIVE - // A failed start must not leave the radio in AP mode, which would refuse - // the STA attempts until the next try. +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse + // the STA attempts until the next try, and a bridge would take clients it cannot serve. if (!this->ap_setup_) this->wifi_mode_({}, false); #endif @@ -1105,10 +1138,7 @@ void WiFiComponent::pause_exclusive_ap_() { ESP_LOGI(TAG, "Pausing AP to try the networks"); // Scanning and connecting turn STA back on. this->disable_ap_(); - this->ap_setup_ = false; - // Clients drop with the AP without a disconnect event for each. - this->ap_clients_ = 0; - this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; @@ -1117,10 +1147,22 @@ void WiFiComponent::pause_exclusive_ap_() { } #endif +#ifdef USE_WIFI_AP_WISP +void WiFiComponent::fail_bridge_() { + this->ap_setup_ = false; + this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each + this->wifi_mode_({}, false); +} +#endif + void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); + // The fallback block may bring the AP back after a later disconnect; its clients dropped with it without + // a disconnect event for each + this->ap_setup_ = false; + this->ap_clients_ = 0; } void WiFiComponent::set_ap(const WiFiAP &ap) { @@ -1142,7 +1184,7 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; - // When new credentials are set (e.g., from improv), skip cooldown to retry immediately + // New credentials end the next cooldown early; start_connecting() clears this once they are tried this->skip_cooldown_next_cycle_ = true; #ifdef USE_WIFI_LEASE_CACHE // Index 0 is a different network now, so its cached lease must not be re-applied @@ -1211,29 +1253,18 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { WiFiAP sta{}; sta.set_ssid(ssid); sta.set_password(password); + // No retry here: a captive portal save runs in lwIP's context on the ESP8266, where retry_connect() yields and + // panics. set_sta() flags the credentials and loop() tries them this->set_sta(sta); - - // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) - this->connect_soon_(); -} - -void WiFiComponent::connect_soon_() { #ifdef USE_WIFI_AP_EXCLUSIVE - // New credentials from the portal: the AP pauses for them once its answer - // is out, and the STA state machine picks them up from there. + // The AP pauses for the new credentials once its answer is out, and the STA state machine picks them up if (this->ap_setup_) { this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); - return; } #endif - // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing - if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { - ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); - this->retry_connect(); - } } void WiFiComponent::start_connecting(const WiFiAP &ap) { @@ -1325,18 +1356,30 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; + // Every attempt passes through here, so the credentials set_sta() flagged are tried now + this->skip_cooldown_next_cycle_ = false; if (!this->wifi_sta_connect_(ap)) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); // Enter cooldown to allow WiFi hardware to stabilize // (immediate failure suggests hardware not ready, different from connection timeout) - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - } else { - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + this->start_cooldown_(); + return; } + this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->action_started_ = millis(); } +void WiFiComponent::start_cooldown_() { + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); +#ifdef USE_WIFI_DPP + // The cooldown is the configurator's window; the attempt that ends it stops the listen again + if (!this->dpp_listening_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(this->action_started_); +#endif +} + const LogString *get_signal_bars(int8_t rssi) { // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 @@ -1449,9 +1492,21 @@ void WiFiComponent::disable() { this->state_ = WIFI_COMPONENT_STATE_DISABLED; #ifdef USE_WIFI_LEASE_CACHE this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); +#ifdef USE_WIFI_AP + // The AP went down with the radio; enable() has to configure it, and its portal, again + this->end_ap_portal_(); + this->ap_setup_ = false; + this->ap_clients_ = 0; +#endif +#ifdef USE_WIFI_AP_WISP + this->ap_changed_ = 0; // at once, not after ap_timeout +#endif } void WiFiComponent::start_scanning() { @@ -1696,6 +1751,12 @@ void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, " Disabled"); return; } +#ifdef USE_WIFI_DPP + ESP_LOGCONFIG(TAG, + " DPP channels: %s\n" + " DPP timeout: %" PRIu32 " ms", + this->dpp_channels_, this->dpp_timeout_); +#endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) const char *band_mode_s; switch (this->band_mode_) { @@ -1742,17 +1803,22 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; -#ifdef USE_WIFI_AP +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients if (this->has_ap()) { this->disable_ap_(); } #endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); } #endif + // The fallback and reboot checks later in this pass must see the connection, not the old timestamp + this->set_last_connected_(now); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; // Refresh is_connected() cache; loop()'s refresh ran before this transition. this->update_connected_state_(); @@ -1922,7 +1988,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // No hidden networks - always go through RESTARTING_ADAPTER phase // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RETRY_HIDDEN: @@ -1945,7 +2011,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: @@ -1975,14 +2041,14 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { // attempting to connect to all configured networks in sequence. // Captive portal needs scan results to show available networks. // If captive portal is active, only skip scanning if we've done a scan after it started. - // If only improv is active (no captive portal), skip scanning since improv doesn't need results. + // If only DPP or improv is active (no captive portal), skip scanning since neither needs results. if (this->is_captive_portal_active_()) { if (this->has_completed_scan_after_captive_portal_start_) { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_improv_ble_active_()) { - // Improv doesn't need scan results + } else if (this->is_improv_ble_active_() || this->is_dpp_active_()) { + // Improv and DPP don't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; @@ -2072,7 +2138,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the portal/improv connection - if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { + if (!this->is_provisioning_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2080,15 +2146,14 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle - // This is critical for captive portal/improv flow: when determine_next_phase_() + // This is critical for DPP/captive portal/improv flow: when determine_next_phase_() // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_() // will see BLIND_RETRY mode and treat ALL networks as candidates, // effectively cycling through all configured networks without scan results. this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY; // Always enter cooldown after restart (or skip-restart) to allow stabilization // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); + this->start_cooldown_(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; @@ -2324,6 +2389,32 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::start_dpp_listen_(uint32_t now) { + this->dpp_listen_started_ = now; + this->dpp_listening_ = this->wifi_dpp_start_listen_(); +} + +void WiFiComponent::stop_dpp_listen_() { + if (!this->dpp_listening_) + return; + this->wifi_dpp_stop_listen_(); + this->dpp_listening_ = false; +} +#endif + +bool WiFiComponent::is_dpp_active_() { +#ifdef USE_WIFI_DPP + // A configurator is waited for once the networks have had their timeout, or from boot without any; the retry + // machine then treats DPP like a portal (blind retries, a long cooldown) whether the supplicant is listening at + // this moment or not + return !this->is_provisioning_closed_() && + (!this->has_sta() || App.get_loop_component_start_time() - this->last_connected_ > this->dpp_timeout_); +#else + return false; +#endif +} + bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2343,7 +2434,12 @@ bool WiFiComponent::is_ap_portal_active_() { // global_web_server needs no null check: codegen always instantiates WebServer when // USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. void WiFiComponent::start_ap_portal_() { + // Only behind an AP that came up; a fallback AP (networks and an ap_timeout) is retried by loop() + if (!this->ap_setup_) + return; #ifdef USE_CAPTIVE_PORTAL + // Force one full scan now that the portal lists networks (earlier scans were filtered) + this->has_completed_scan_after_captive_portal_start_ = false; if (captive_portal::global_captive_portal != nullptr) captive_portal::global_captive_portal->start(); #endif @@ -2369,6 +2465,19 @@ bool WiFiComponent::is_improv_ble_active_() { #endif } +bool WiFiComponent::is_provisioning_active_() { + return this->is_ap_portal_active_() || this->is_improv_ble_active_() || this->is_dpp_active_(); +} + +bool WiFiComponent::is_provisioning_closed_() { +#ifdef USE_PROVISIONING + // The device stays unprovisionable until it is power-cycled + return provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); +#else + return false; +#endif +} + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) bool WiFiComponent::request_high_performance() { // Already configured for high performance - request satisfied diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c1f4061a534..1048df19c39 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,13 @@ extern "C" { } #include + +#ifdef USE_WIFI_AP +// The linker routes the driver's firmware events here; wifi/__init__.py adds the wrap flag +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) @@ -479,6 +487,21 @@ class WiFiComponent final : public Component { void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + // Code generation passes string literals that live in flash + void set_dpp_key(const char *key) { this->dpp_key_ = key; } + void set_dpp_device_info(const char *info) { this->dpp_device_info_ = info; } + void set_dpp_channels(const char *channels) { this->dpp_channels_ = channels; } + void set_dpp_timeout(uint32_t timeout) { this->dpp_timeout_ = timeout; } +#ifdef USE_WIFI_DPP_URI_TRIGGER + template void add_on_dpp_uri_callback(F &&callback) { + this->dpp_uri_callback_.add(std::forward(callback)); + } +#endif + /// The Easy Connect URI to show as a QR code, nullptr until the supplicant has generated it + const char *get_dpp_uri() const { return this->dpp_uri_.get(); } +#endif + void enable(); void disable(); bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } @@ -721,6 +744,19 @@ class WiFiComponent final : public Component { #endif #endif // USE_WIFI_AP +#ifdef USE_WIFI_AP_WISP + /// Whether there is an IPv4 resolver to offer AP clients: a configured one, or the station's + bool ap_dns_known_(); + /// Hand the AP DHCP server its resolvers; false when one could not be set + bool write_ap_dns_(); + /// Take the bridge down so loop() runs its setup again after ap_timeout + void fail_bridge_(); +#ifndef USE_WIFI_AP_WISP_STATIC_DNS + /// Pass the station's resolvers on to AP clients when they changed + void apply_ap_dns_(); +#endif +#endif + void print_connect_params_(); WiFiAP build_params_for_current_phase_(); @@ -786,8 +822,6 @@ class WiFiComponent final : public Component { return true; } - void connect_soon_(); - bool wifi_loop_(); #ifdef USE_ESP8266 void process_pending_callbacks_(); @@ -831,6 +865,23 @@ class WiFiComponent final : public Component { network::IPAddress wifi_gateway_ip_(); network::IPAddress wifi_dns_ip_(int num); +#ifdef USE_WIFI_DPP + // Starts listening for a DPP configurator; the platform half reports whether the driver accepted + void start_dpp_listen_(uint32_t now); + void stop_dpp_listen_(); +#endif + /// Wait before the next attempt; while a configurator is waited for, the wait is its listen window + void start_cooldown_(); +#ifdef USE_WIFI_DPP + void wifi_dpp_init_(); + bool wifi_dpp_start_listen_(); + void wifi_dpp_stop_listen_(); +#endif + bool is_dpp_active_(); + /// A portal, improv or DPP is waiting for credentials, so the retry machine must not disturb the radio + bool is_provisioning_active_(); + /// The provisioning window has closed, so no new provisioning surface may start + bool is_provisioning_closed_(); bool is_captive_portal_active_(); /// captive_portal or the web_server AP mode is serving a user on the access point bool is_ap_portal_active_(); @@ -906,6 +957,11 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); +#ifdef USE_WIFI_AP + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + friend void ::__wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); + // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #ifdef USE_LIBRETINY @@ -967,12 +1023,19 @@ class WiFiComponent final : public Component { // the disconnect is treated as roaming-related and the attempts counter is preserved. static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD = 30 * 1000; // 30 seconds + void set_last_connected_(uint32_t now) { + this->last_connected_ = now; +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_EXCLUSIVE) + this->ap_start_failures_ = 0; +#endif + } + // 4-byte members float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + uint32_t ap_changed_{0}; // When the AP was last started or paused #endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; @@ -982,6 +1045,17 @@ class WiFiComponent final : public Component { // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; +#endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + const char *dpp_key_{nullptr}; + const char *dpp_device_info_{nullptr}; + const char *dpp_channels_{nullptr}; + std::unique_ptr dpp_uri_; // written by the event task before the URI event is queued +#ifdef USE_WIFI_DPP_URI_TRIGGER + CallbackManager dpp_uri_callback_; +#endif + uint32_t dpp_timeout_{60000}; // Keep in sync with DEFAULT_DPP_TIMEOUT in __init__.py + uint32_t dpp_listen_started_{0}; #endif // 1-byte enums and integers @@ -1066,8 +1140,19 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#ifdef USE_WIFI_DPP + bool dpp_listening_{false}; +#endif +#ifdef USE_WIFI_AP + // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. + // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and + // in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up + // and only writes it (a reset) while the AP is down, so no lock is needed. + uint8_t ap_clients_{0}; +#ifndef USE_WIFI_AP_EXCLUSIVE + // Fallback AP starts that failed since the last connection; each one waits another ap_timeout + uint8_t ap_start_failures_{0}; +#endif #endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 41b51460006..61c66079b63 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -572,6 +572,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STACONNECTED: { +#ifdef USE_WIFI_AP + global_wifi_component->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -581,6 +584,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STADISCONNECTED: { +#ifdef USE_WIFI_AP + if (global_wifi_component->ap_clients_ > 0) + global_wifi_component->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index bdca350ddeb..4b857b98141 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,4 +1,5 @@ #include "wifi_component.h" +#include "esphome/components/network/util.h" #ifdef USE_WIFI #ifdef USE_ESP32 @@ -24,6 +25,10 @@ #endif #endif +#ifdef USE_WIFI_DPP +#include +#endif + #ifdef USE_WIFI_AP #include "dhcpserver/dhcpserver.h" #endif // USE_WIFI_AP @@ -64,6 +69,15 @@ static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid- static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_WIFI_DPP +/// Terminated copies of the received station credentials; the supplicant's wifi_config_t would grow every event + +static void copy_terminated(char *dst, const uint8_t *src, size_t len) { + memcpy(dst, src, len); + dst[len] = '\0'; +} +#endif + struct IDFWiFiEvent { esp_event_base_t event_base; int32_t event_id; @@ -76,6 +90,10 @@ struct IDFWiFiEvent { wifi_event_ap_stadisconnected_t ap_stadisconnected; wifi_event_ap_probe_req_rx_t ap_probe_req_rx; wifi_event_bss_rssi_low_t bss_rssi_low; +#ifdef USE_WIFI_DPP + SavedWifiSettings *dpp_credentials; // owned by the event; the rare 98 byte record does not widen the union + wifi_event_dpp_failed_t dpp_failed; +#endif ip_event_got_ip_t ip_got_ip; #if USE_NETWORK_IPV6 ip_event_got_ip6_t ip_got_ip6; @@ -125,6 +143,23 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) { memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t)); +#ifdef USE_WIFI_DPP + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_URI_READY) { + // Kept on the component for the whole boot; the queued event only announces it + const auto *uri = static_cast(event_data); + const size_t len = strnlen(uri->uri, uri->uri_data_len); + auto copy = std::make_unique(len + 1); + copy_terminated(copy.get(), reinterpret_cast(uri->uri), len); + global_wifi_component->dpp_uri_ = std::move(copy); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const auto &sta = static_cast(event_data)->wifi_cfg.sta; + auto *credentials = new SavedWifiSettings; // NOLINT(cppcoreguidelines-owning-memory) + copy_terminated(credentials->ssid, sta.ssid, sizeof(sta.ssid)); + copy_terminated(credentials->password, sta.password, sizeof(sta.password)); + event.data.dpp_credentials = credentials; + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_FAILED) { + memcpy(&event.data.dpp_failed, event_data, sizeof(wifi_event_dpp_failed_t)); +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t)); @@ -141,6 +176,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory) memcpy(to_send, &event, sizeof(IDFWiFiEvent)); if (!global_wifi_component->event_queue_.push(to_send)) { +#ifdef USE_WIFI_DPP + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) + delete to_send->data.dpp_credentials; // NOLINT(cppcoreguidelines-owning-memory) +#endif delete to_send; // NOLINT(cppcoreguidelines-owning-memory) } } @@ -224,6 +263,9 @@ void WiFiComponent::wifi_lazy_init_() { } s_wifi_started = true; this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif return; } @@ -242,9 +284,32 @@ void WiFiComponent::wifi_lazy_init_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::wifi_dpp_init_() { + // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + esp_err_t err = esp_supp_dpp_init(); +#else + esp_err_t err = esp_supp_dpp_init(nullptr); +#endif + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); + return; + } + err = esp_supp_dpp_bootstrap_gen(this->dpp_channels_, DPP_BOOTSTRAP_QR_CODE, this->dpp_key_, this->dpp_device_info_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_bootstrap_gen"), esp_err_to_name(err)); + } +} +#endif + bool WiFiComponent::wifi_mode_(optional sta, optional ap) { esp_err_t err; wifi_mode_t current_mode = WIFI_MODE_NULL; @@ -354,6 +419,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // enable STA if (!this->wifi_mode_(true, {})) return false; +#ifdef USE_WIFI_DPP + // A connect attempt ends the supplicant's listen; the next cooldown starts it again + this->stop_dpp_listen_(); +#endif // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t wifi_config_t conf; @@ -795,14 +864,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(s_sta_netif, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; } @@ -928,6 +990,15 @@ const char *get_disconnect_reason_str(uint8_t reason) { } } +#ifdef USE_WIFI_AP +// The driver's own station list cannot drift, so the count is read from it rather than kept by arithmetic +static void read_ap_clients(uint8_t &count) { + wifi_sta_list_t sta_list; + if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) + count = sta_list.num; +} +#endif + bool WiFiComponent::wifi_loop_() { // Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_), // while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa. @@ -945,6 +1016,11 @@ bool WiFiComponent::wifi_loop_() { uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); +#ifdef USE_WIFI_AP + // A dropped join or leave is made up for by reading the list again + if (this->ap_setup_) + read_ap_clients(this->ap_clients_); +#endif } return true; } @@ -1054,6 +1130,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) + this->apply_ap_dns_(); +#endif #ifdef USE_WIFI_LEASE_CACHE this->save_lease_settings_(); // events are drained on the main loop #endif @@ -1172,6 +1251,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); this->ap_started_ = true; +#ifdef USE_WIFI_AP_WISP + // NAPT needs the interface up, so it cannot be part of the setup. Without it the clients have no way out, so + // the AP goes down and its setup runs again, which brings it back up through this event. + if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err)); + this->fail_bridge_(); + } +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); @@ -1186,6 +1273,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STACONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_staconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -1194,6 +1284,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STADISCONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_stadisconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -1201,6 +1294,31 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); #endif +#ifdef USE_WIFI_DPP + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_URI_READY) { + ESP_LOGD(TAG, "DPP URI: %s", this->dpp_uri_.get()); +#ifdef USE_WIFI_DPP_URI_TRIGGER + this->dpp_uri_callback_.call(this->dpp_uri_.get()); +#endif + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const std::unique_ptr credentials(data->data.dpp_credentials); + this->dpp_listening_ = false; + ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid); + // The reboot timer starts over for the connect attempt, as after a boot with saved credentials + this->set_last_connected_(App.get_loop_component_start_time()); + // Saved like portal credentials; from a cooldown this already restarts the retries + this->save_wifi_sta(credentials->ssid, credentials->password); + if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) { + // Without networks the station machine was never started; an AP without a portal sits in AP + this->start_connecting(*this->get_selected_sta_()); + } + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_FAILED) { + this->dpp_listening_ = false; + ESP_LOGW(TAG, "DPP failed: %s", esp_err_to_name(data->data.dpp_failed.failure_reason)); +#endif // USE_WIFI_DPP + #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { const auto &it = data->data.ip_assigned_ip_to_client; @@ -1349,6 +1467,19 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { } #endif +#ifdef USE_WIFI_AP_WISP + // Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would + // offer the AP's own address, so a failure fails the setup and it is tried again. + uint8_t dns_offer = 1; + err = + esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable DHCP DNS option: %s", esp_err_to_name(err)); + } + if (err != ESP_OK || !this->write_ap_dns_()) + return false; +#endif + err = esp_netif_dhcps_start(s_ap_netif); if (err != ESP_OK) { @@ -1419,6 +1550,20 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP +bool WiFiComponent::wifi_dpp_start_listen_() { + esp_err_t err = esp_supp_dpp_start_listen(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_start_listen"), esp_err_to_name(err)); + return false; + } + ESP_LOGD(TAG, "DPP listening for a configurator"); + return true; +} + +void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); } +#endif + bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } bssid_t WiFiComponent::wifi_bssid() { @@ -1489,6 +1634,81 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_ip); } +#ifdef USE_WIFI_AP_WISP +// A DHCP server interface takes a backup resolver from ESP-IDF 6.0 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +static constexpr size_t AP_DNS_COUNT = 2; +#else +static constexpr size_t AP_DNS_COUNT = 1; +#endif +#ifdef USE_WIFI_AP_WISP_STATIC_DNS +// The configured resolvers as lwIP stores them, from codegen +static constexpr uint32_t AP_DNS[] = { + WIFI_AP_WISP_DNS_0, +#ifdef WIFI_AP_WISP_DNS_1 + WIFI_AP_WISP_DNS_1, +#endif +}; +#endif + +static uint32_t ap_dns_to_offer(size_t i) { +#ifdef USE_WIFI_AP_WISP_STATIC_DNS + return i < std::size(AP_DNS) ? AP_DNS[i] : 0; +#else + return ip_addr_get_ip4_u32(dns_getserver(i)); +#endif +} + +bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; } + +bool WiFiComponent::write_ap_dns_() { + bool ok = true; + for (size_t i = 0; i < AP_DNS_COUNT; i++) { + const uint32_t addr = ap_dns_to_offer(i); + if (addr == 0) + continue; + esp_netif_dns_info_t info{}; + info.ip.type = ESP_IPADDR_TYPE_V4; + info.ip.u_addr.ip4.addr = addr; + esp_err_t err = esp_netif_set_dns_info(s_ap_netif, static_cast(i), &info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set DHCP DNS server %u: %s", (unsigned) i, esp_err_to_name(err)); + ok = false; + } else { + ESP_LOGV(TAG, "DHCP DNS server: " IPSTR, IP2STR(&info.ip.u_addr.ip4)); + } + } + return ok; +} + +#ifndef USE_WIFI_AP_WISP_STATIC_DNS +void WiFiComponent::apply_ap_dns_() { + if (!this->ap_setup_) + return; // loop() brings the bridge up once the station has a resolver, and its setup writes them + // The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the + // next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address. + bool changed = false; + for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) { + const uint32_t addr = ap_dns_to_offer(i); + esp_netif_dns_info_t current{}; + changed = + addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast(i), ¤t) != ESP_OK || + current.ip.u_addr.ip4.addr != addr); + } + if (!changed) + return; + // The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined +#ifdef USE_WIFI_MANUAL_IP + const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip()); +#else + const bool ok = this->wifi_ap_ip_config_({}); +#endif + if (!ok) + this->fail_bridge_(); +} +#endif // USE_WIFI_AP_WISP_STATIC_DNS +#endif // USE_WIFI_AP_WISP + } // namespace esphome::wifi #endif // USE_ESP32 #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ca32f3d3ffa..099eefd61da 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -629,7 +629,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP this->ap_clients_++; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -641,7 +641,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP if (this->ap_clients_ > 0) this->ap_clients_--; #endif @@ -881,6 +881,7 @@ bool WiFiComponent::wifi_loop_() { // so if pop() returned nullptr above we can skip this check. uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { + // A dropped station event cannot be made up for: the BK72xx core has no station query ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped); } return true; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 5b05132f4f7..c1620154e6c 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -59,6 +59,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); } else { +#ifdef USE_WIFI_AP + // Takes down the Arduino AP interface and its DHCP server; a later beginAP() is refused + // while they are still up + WiFi.disconnectAP(); +#endif cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, false, CYW43_COUNTRY_WORLDWIDE); } this->ap_started_ = ap.value(); @@ -238,12 +243,16 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // Pass nullptr for empty password — CYW43 uses the password pointer (not length) // to choose between OPEN and WPA2 auth mode. const char *ap_password = ap.password_.empty() ? nullptr : ap.password_.c_str(); - WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1); + if (WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1) != WL_CONNECTED) { + ESP_LOGW(TAG, "beginAP failed"); + return false; + } return true; } network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.softAPIP()}; } + #endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { @@ -372,5 +381,36 @@ bool WiFiComponent::wifi_loop_() { void WiFiComponent::wifi_pre_setup_() {} } // namespace esphome::wifi + +#ifdef USE_WIFI_AP +// Firmware event ids the driver header leaves out +static constexpr uint32_t AP_STATION_ASSOC_IND = 8; + +// The driver hands every firmware event to cyw43_cb_process_async_event and ignores the AP station +// ones, so they are counted here before it sees them. Runs in the driver's context. Verified with +// arduino-pico 6.1.1 on a Pico 2W: a join gives AUTH_IND then ASSOC_IND, a leave gives DISASSOC_IND. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" void __real_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev) { + if (ev->interface == CYW43_ITF_AP) { + switch (ev->event_type) { + case AP_STATION_ASSOC_IND: + // A refused association carries its status here and has no leave event to balance it. A + // reassociation comes from a station that is already counted, whether or not its leave was seen. + if (ev->status == CYW43_STATUS_SUCCESS) + esphome::wifi::global_wifi_component->ap_clients_++; + break; + case CYW43_EV_DISASSOC_IND: + case CYW43_EV_DEAUTH_IND: + if (esphome::wifi::global_wifi_component->ap_clients_ > 0) + esphome::wifi::global_wifi_component->ap_clients_--; + break; + } + } + __real_cyw43_cb_process_async_event(cb_data, ev); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif // USE_WIFI_AP #endif #endif diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ed7069a5403..44c8be9e374 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( @@ -102,7 +102,7 @@ def _validate_bootloader(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader == BOOTLOADER_MCUBOOT: return - if bootloader not in BOOTLOADER_CONFIG: + if bootloader not in MCUMGR_OTA_BOOTLOADERS: raise cv.Invalid(f"{bootloader} does not support OTA") framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 68c0d005b57..29bbdc2b0b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -5,7 +5,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_check.h" -#include "nvs_flash.h" #include "zigbee_attribute_esp32.h" #include "zigbee_esp32.h" #include "esphome/core/application.h" diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d468c9881d0..f7da147b88f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import random import esphome.codegen as cg @@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.date_code, # Local build time, matching the esp32 implementation # (App.get_build_time() in C++). - datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), + dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index 1dbce873073..12ea8e8ade0 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" -static const char *const TAG = "Zio Ultrasonic"; +ESPHOME_LOG_TAG(TAG, "Zio Ultrasonic"); namespace esphome::zio_ultrasonic { diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 61092ec4272..9b8f9f98642 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -442,6 +442,16 @@ class ID: ) +# Replaced by write_cpp with a #line back into main.cpp +LINE_DIRECTIVE_RESET = "// ESPHOME_LINE_DIRECTIVE_RESET" + + +def line_directive(document: str, line: int) -> str: + """A #line directive naming the 1-based ``line`` of ``document``.""" + escaped = document.replace("\\", "\\\\") + return f'#line {line} "{escaped}"' + + class DocumentLocation: def __init__(self, document: str, line: int, column: int): self.document: str = document @@ -457,8 +467,7 @@ class DocumentLocation: @property def as_line_directive(self): - document_path = str(self.document).replace("\\", "\\\\") - return f'#line {self.line + 1} "{document_path}"' + return line_directive(str(self.document), self.line + 1) class DocumentRange: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 08f1e31d6c2..c901ff3e844 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -43,6 +43,9 @@ // Feature flags #define USE_ALARM_CONTROL_PANEL #define USE_AREAS +#define USE_AS7341 +#define USE_AS7343 +#define USE_AS734X_RGB #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER @@ -58,6 +61,7 @@ #define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER +#define USE_CRASH_HANDLER #define USE_DATETIME #define USE_DATETIME_DATE #define USE_DATETIME_DATETIME @@ -69,6 +73,7 @@ #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION +#define USE_EMONTX_VERSION_TEXT_SENSOR #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT @@ -152,10 +157,12 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2 #define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 @@ -216,6 +223,7 @@ #define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 #define USE_NETWORK #define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI @@ -270,6 +278,7 @@ #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TMP102_NUMBER +#define USE_TMP102_TEXT_SENSOR #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX @@ -317,7 +326,6 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 -#define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 #define API_WIZARD_DATA_SIZE 1 #define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 @@ -328,7 +336,7 @@ #define USE_NOISE #define USE_NOISE_SPARE_EPHEMERAL #define USE_SHA256 -#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 +#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_WIREGUARD @@ -417,7 +425,9 @@ #define USE_ESPNOW #define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 +#ifndef USE_HOST // web_server, captive_portal: no ESPAsyncWebServer on host #define USE_CAPTIVE_PORTAL +#endif #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE #define USE_ESP32_BLE_MAX_CONNECTIONS 3 @@ -475,6 +485,7 @@ #define USE_SPI #define USE_SPI_PSRAM_DMA #define USE_VOICE_ASSISTANT +#ifndef USE_HOST #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST @@ -486,11 +497,18 @@ #define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP +#endif #define USE_WIFI_11KV_SUPPORT +#define USE_WIFI_DPP +#define USE_WIFI_DPP_URI_TRIGGER #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE #define USE_WIFI_LEASE_CACHE_IN_FLASH +#define USE_WIFI_AP_WISP +#define USE_WIFI_AP_WISP_STATIC_DNS +#define WIFI_AP_WISP_DNS_0 0x08080808 +#define WIFI_AP_WISP_DNS_1 0x04040808 #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS @@ -519,7 +537,8 @@ #endif #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) +#endif #define USE_ETHERNET #define USE_ETHERNET_LAN8720 #define USE_ETHERNET_RTL8201 @@ -550,22 +569,33 @@ #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 -#endif #ifdef USE_ESP32 +#define MICRO_WAKE_WORD_TASK_CORE 0 #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_OPENTHREAD_SRP #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32S3) +// Border router with a UART RCP; a native-radio border router (C5/C6) adds only the coexistence call +#define USE_OPENTHREAD +#define USE_OPENTHREAD_BORDER_ROUTER +#define USE_OPENTHREAD_RCP_UART +#endif #if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_ESPECTRE #endif -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP #define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifdef USE_ETHERNET +#define USE_MDNS_ACTIVE_INTERFACE_LISTENER +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif #endif #endif @@ -596,6 +626,9 @@ #define USE_ESP8266_LOGGER_SERIAL #define USE_ESP8266_LOGGER_SERIAL1 #define USE_ESP8266_PREFERENCES_FLASH +#define ESP8266_PREFERENCES_FLASH_WORDS 128 +#define ESP8266_PREFERENCES_RTC_EBOOT_WORDS 32 +#define ESP8266_PREFERENCES_RTC_NORMAL_WORDS 78 #define USE_ESP8266_UART_SERIAL #define USE_ESP8266_UART_SERIAL1 #define USE_HTTP_REQUEST_ESP8266_HTTPS @@ -646,6 +679,7 @@ #define USE_ETHERNET_SPI #endif #define USE_ETHERNET_W5500 +#define USE_MDNS_DYNAMIC_NETWORK_TXT #define USE_WIFI_IP_STATE_LISTENERS #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define USE_ETHERNET_IP_STATE_LISTENERS @@ -683,7 +717,10 @@ #endif #ifdef USE_HOST +#define USE_ESPHOME_HOST_MAC_ADDRESS \ + { 0x98, 0x35, 0x69, 0xab, 0xf6, 0x79 } #define USE_HTTP_REQUEST_RESPONSE +#define USE_SERIAL_PROXY_HOST_UART // Host only: the uart arm would shadow the native logger UART arms in other envs #define USE_IMPROV_SERIAL_UART #define USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 906f9fb12ab..85fb260192c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef USE_ESP32 @@ -315,6 +316,57 @@ size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size // Parsing & formatting +// A plain decimal converts exactly with one division: the digits accumulate +// into an integer that stays below 2^24 and the fraction's scale is a power of +// ten up to 1e9 (2^9 * 5^9), so both convert to float exactly and IEEE division +// rounds the quotient correctly, the same result strtof produces. +static constexpr uint32_t PARSE_FLOAT_MANTISSA_LIMIT = 1000000; // one more digit stays below 2^24 +static constexpr uint32_t PARSE_FLOAT_MAX_FRACTION_DIGITS = 9; + +optional parse_float(const char *str) { + const char *p = str; + const bool negative = *p == '-'; + if (negative || *p == '+') + p++; + const char *digits = p; + const char *dot = nullptr; + uint32_t mantissa = 0; + for (;; p++) { + const uint32_t d = static_cast(*p) - '0'; + if (d > 9) { + if (*p == '.' && dot == nullptr) { + dot = p; + continue; + } + break; + } + if (mantissa >= PARSE_FLOAT_MANTISSA_LIMIT) + break; + mantissa = mantissa * 10 + d; + } + const uint32_t fraction_digits = dot != nullptr ? p - dot - 1 : 0; + const bool any_digit = p - digits > (dot != nullptr ? 1 : 0); + if (*p == '\0' && any_digit && fraction_digits <= PARSE_FLOAT_MAX_FRACTION_DIGITS) { + // The mantissa is exact in a float, so the sign goes on before the one rounding step + float value = static_cast(mantissa); + if (negative) + value = -value; + if (fraction_digits != 0) { + uint32_t scale = 10; + for (uint32_t i = 1; i < fraction_digits; i++) + scale *= 10; + value /= static_cast(scale); + } + return value; + } + // Exponents, hex, inf, nan, leading whitespace and longer inputs take the libc path. + char *end = nullptr; + const float value = ::strtof(str, &end); + if (end == str || *end != '\0' || value == HUGE_VALF) + return {}; + return value; +} + size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { @@ -546,7 +598,7 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Fast float-to-string for accuracy_decimals 0-4 (covers sensors and small PID coefficients). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. // Caller must guarantee value is finite and |value| * mult fits in uint32_t. static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) { @@ -573,9 +625,9 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // Fast path for accuracy 0-3, finite values whose scaled magnitude fits in uint32_t. - // For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf. - if (accuracy_decimals <= 3 && std::isfinite(value)) { + // Fast path for accuracy 0-4, finite values whose scaled magnitude fits in uint32_t. + // For 4 decimals that's |value| < ~4.29e5; larger totals fall through to snprintf. + if (accuracy_decimals <= 4 && std::isfinite(value)) { const uint32_t mult = small_pow10(accuracy_decimals); if (std::fabs(value) < static_cast(UINT32_MAX) / mult) { return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bcb03b010f6..c51ef4a652d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1299,13 +1299,11 @@ template::value && std::is_signed parse_number(const std::string &str) { return parse_number(str.c_str()); } +/// Parse a decimal floating-point number; plain decimals skip the libc parser. +optional parse_float(const char *str); /// Parse a decimal floating-point number from a null-terminated string. template::value), int> = 0> optional parse_number(const char *str) { - char *end = nullptr; - float value = ::strtof(str, &end); - if (end == str || *end != '\0' || value == HUGE_VALF) - return {}; - return value; + return parse_float(str); } /// Parse a decimal floating-point number. template::value), int> = 0> @@ -1447,8 +1445,8 @@ inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, con return buf; } -/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. -inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } +/// Return 10^n for small non-negative n (0-4) as uint32_t, avoiding float. +inline uint32_t small_pow10(int8_t n) { return n == 4 ? 10000 : n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } /// Minimum buffer size for uint32_to_str: 10 digits + null terminator. static constexpr size_t UINT32_MAX_STR_SIZE = 11; @@ -1727,7 +1725,8 @@ enum class TemperatureUnit : uint8_t { /// No null check, no exceptions, no heap allocation for small trivially-copyable callables. /// /// With C++20 if constexpr, automatically detects [this] lambdas (sizeof <= sizeof(void*), -/// trivially copyable) and stores them inline. Larger callables are heap-allocated. +/// trivially copyable, callable as const) and stores them inline. Larger callables, and mutable +/// ones whose state must survive between calls, are heap-allocated. template struct Callback; template struct Callback { @@ -1742,11 +1741,19 @@ template struct Callback { /// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances. void call(Ts... args) const { this->fn_(this->ctx_, std::forward(args)...); } + /// Whether create() stores F inline in the ctx pointer. The inline path invokes a copy, so a + /// callable that mutates its captures (a mutable lambda) is kept whole on the heap instead. + template static constexpr bool fits_inline() { + using DecayF = std::decay_t; + return sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v && + std::is_invocable_v; + } + /// Create from any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline in the ctx pointer without heap allocation. template static Callback create(F &&callable) { using DecayF = std::decay_t; - if constexpr (sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v) { + if constexpr (fits_inline()) { // Small trivial callable (e.g. [this]() { this->method(); }) - store inline in ctx. // Safe under C++20 (P0593R6): byte copy into aligned storage implicitly // creates objects of implicit-lifetime types (trivially copyable qualifies). @@ -1769,6 +1776,28 @@ template struct Callback { return {[](void *c, Ts... args) { (*static_cast(c))(args...); }, static_cast(stored)}; } } + + /// Heap home of a callable stored by create_boxed(); the header knows how to free it. + struct Box { + void (*free)(Box *box); + }; + + /// Store any callable on the heap with a deleter, for an owner that can later free_boxed() it. + template static Callback create_boxed(F &&callable) { + struct Boxed : Box { + std::decay_t fn; + }; + auto *box = new Boxed{{[](Box *b) { delete static_cast(b); }}, // NOLINT(cppcoreguidelines-owning-memory) + std::forward(callable)}; + return {[](void *c, Ts... args) { static_cast(static_cast(c))->fn(args...); }, + static_cast(box)}; + } + + /// Free the callable of a Callback made by create_boxed(); the Callback must not be called afterwards. + void free_boxed() const { + auto *box = static_cast(this->ctx_); + box->free(box); + } }; /// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp. diff --git a/esphome/core/log.h b/esphome/core/log.h index 145ba3a6f8a..840da292aee 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,8 +190,9 @@ struct LogString; #endif -// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with -// the _P functions, never strcmp/strlen. +// Declares a static log tag at namespace scope, in a source file or a header; on ESP8266 it lives in flash, so +// read it with the _P functions, never strcmp/strlen. Not usable for a class member: the ESP8266 form is a +// PROGMEM array, which cannot be initialized in a class. #ifdef USE_ESP8266 #define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag #else diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 54fc42dde16..02ec4778237 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -25,6 +26,7 @@ #define ESPHOME_strncmp_P strncmp_P #define ESPHOME_strncasecmp_P strncasecmp_P #define ESPHOME_strlen_P strlen_P +#define ESPHOME_strstr_P strstr_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; // Storage class for PROGMEM_STRING_TABLE data; LOG_STR_ARG treats a LogString as PGM_P @@ -42,6 +44,7 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strncmp_P strncmp #define ESPHOME_strncasecmp_P strncasecmp #define ESPHOME_strlen_P strlen +#define ESPHOME_strstr_P strstr // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; // No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. @@ -68,6 +71,7 @@ template struct FixedString { }; /// Compile-time string table that packs strings into a single blob with offset lookup. +/// Blobs over 255 bytes have no offsets and are scanned, so keep those for rarely read strings. /// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266. /// /// Example: @@ -78,6 +82,11 @@ template struct FixedString { template struct ProgmemStringTable { static constexpr size_t COUNT = sizeof...(Strs); static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1)); + /// Length of the longest string, for sizing a RAM copy on ESP8266 + static constexpr size_t MAX_LENGTH = std::max({Strs.size()...}); + /// One-byte offsets address up to 255 bytes; a larger blob has no offset table and is scanned instead, + /// which costs less flash than wider offsets for the rarely read tables that get this big. + static constexpr bool INDEXED = BLOB_SIZE <= UINT8_MAX; /// Generate packed string blob at compile time static constexpr auto make_blob() { @@ -91,18 +100,28 @@ template struct ProgmemStringTable { return result; } - /// Generate offset table at compile time (uint8_t limits blob to 255 bytes) + /// Generate offset table at compile time static constexpr auto make_offsets() { static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string"); static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices"); - static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings"); - std::array result{}; - size_t pos = 0, idx = 0; - ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); - return result; + if constexpr (INDEXED) { + std::array result{}; + size_t pos = 0, idx = 0; + ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); + return result; + } else { + return std::array{}; + } } }; +/// Skip idx strings in a packed NUL-separated blob that may live in flash. +inline const char *progmem_skip_strings(const char *blob, uint8_t idx) { + while (idx-- > 0) + blob += ESPHOME_strlen_P(blob) + 1; + return blob; +} + // Forward declaration for LogString (defined in log.h) struct LogString; @@ -115,6 +134,7 @@ struct LogString; static constexpr size_t COUNT = Table::COUNT; \ static constexpr uint8_t LAST_INDEX = COUNT - 1; \ static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \ + static constexpr size_t MAX_LENGTH = Table::MAX_LENGTH; \ static constexpr auto BLOB ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_blob(); \ static constexpr auto OFFSETS ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_offsets(); \ static const char *get_(uint8_t idx, uint8_t fallback) { \ @@ -122,7 +142,10 @@ struct LogString; idx = fallback; \ /* std::launder is used here to prevent the inter-procedural analysis that */ \ /* causes the false positive that the string is not null terminated */ \ - return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + if constexpr (Table::INDEXED) \ + return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + else \ + return std::launder(::esphome::progmem_skip_strings(BLOB.data(), idx)); \ } \ static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \ return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \ diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 87629c2507d..dd7309f8f09 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -7,7 +7,7 @@ namespace esphome { bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram) { + bool use_psram, BaseType_t core_id) { if (this->handle_ != nullptr) { // Task is already created; must call destroy() first return false; @@ -38,8 +38,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return false; } - this->handle_ = - xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); + this->handle_ = xTaskCreateStaticPinnedToCore(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), + this->tcb_.get(), core_id); if (this->handle_ == nullptr) { this->deallocate(); return false; diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 10c55f70c62..999e92a4b41 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -37,9 +37,10 @@ class StaticTask { /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM + /// @param core_id CPU core, or tskNO_AFFINITY to leave scheduling unrestricted /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram); + bool use_psram, BaseType_t core_id = tskNO_AFFINITY); /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index e9e31411e42..7deea8ff664 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -189,24 +190,6 @@ inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { r inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } -/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. -struct ProgmemStringRef { - PGM_P str; - size_t len; -}; - -inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { - return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); -} - -inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } - -inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { - // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes - return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); -} - -inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { @@ -241,6 +224,49 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { str.append(rhs.c_str(), rhs.size()); return str; } + +/// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations. +/// progmem_ptr() is safe as a "%s" log argument; compare the contents with equals(). +class ProgmemStringRef { + public: + constexpr ProgmemStringRef() = default; + constexpr ProgmemStringRef(const char *s, size_t len) : ref_(s, len) {} + constexpr explicit ProgmemStringRef(const StringRef &s) : ref_(s) {} + /// A PROGMEM char array, with its length known at compile time + template static constexpr ProgmemStringRef from_lit(const char (&s)[N]) { return {s, N - 1}; } + + constexpr const char *progmem_ptr() const { return this->ref_.c_str(); } + constexpr size_t size() const { return this->ref_.size(); } + constexpr bool empty() const { return this->ref_.empty(); } + + bool equals(const StringRef &other) const { +#ifdef USE_ESP8266 + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return other.size() == this->ref_.size() && + (other.empty() || memcmp_P(other.c_str(), this->ref_.c_str(), other.size()) == 0); +#else + return other == this->ref_; +#endif + } + + // Remove before 2027.5.0 + [[deprecated( + "May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char * + c_str() const { + return this->ref_.c_str(); + } + + private: + StringRef ref_; +}; + +#ifdef USE_ESP8266 +inline bool operator==(const StringRef &lhs, const ProgmemStringRef &rhs) { return rhs.equals(lhs); } +inline bool operator!=(const StringRef &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(lhs); } +inline bool operator==(const std::string &lhs, const ProgmemStringRef &rhs) { return rhs.equals(StringRef(lhs)); } +inline bool operator!=(const std::string &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(StringRef(lhs)); } +#endif + // String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) // Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. namespace internal { diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9fc1c717032..e2353db28f4 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -22,7 +22,7 @@ namespace esphome { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -static const char *const TAG = "wake"; +ESPHOME_LOG_TAG(TAG, "wake"); namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 1196669662c..d0fc126957f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -8,6 +8,7 @@ from typing import Any from esphome.core import ( CORE, ID, + LINE_DIRECTIVE_RESET, Define, EnumValue, EsphomeError, @@ -214,9 +215,13 @@ class LambdaExpression(Expression): if self.return_type is not None: cpp += f" -> {self.return_type}" cpp += " {\n" + body = f"{self.content}\n" if self.source is not None: - cpp += f"{self.source.as_line_directive}\n" - cpp += f"{self.content}\n}}" + body = f"{self.source.as_line_directive}\n{body}" + # A #line in the body, the source's or lvgl's own marks, needs the reset after it + if "#line " in body: + body += f"{LINE_DIRECTIVE_RESET}\n" + cpp += f"{body}}}" return indent_all_but_first_and_last(cpp) @property @@ -946,8 +951,7 @@ async def process_lambda( parts[i * 3 + 2] = "" if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: - location = value.esp_range.start_mark - location.line += value.content_offset + location = value.content_start else: location = None return LambdaExpression(parts, parameters, capture, return_type, location) diff --git a/esphome/espota2.py b/esphome/espota2.py index 14e163676d1..aaef178753a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -70,11 +70,13 @@ CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 CLIENT_FEATURE_SUPPORTS_NOISE = 0x08 CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10 +CLIENT_FEATURE_SUPPORTS_UDP = 0x20 SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 SERVER_FEATURE_SUPPORTS_NOISE = 0x04 # Binding once offered: the device then expects the image size and a deflate stream SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08 +SERVER_FEATURE_SUPPORTS_UDP = 0x10 # Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE) DEFLATE_WINDOW_BITS = 12 @@ -103,17 +105,64 @@ COMPRESS_LEVEL = 9 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time -# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# to clean up a half-open connection (its handshake watchdog runs at 60s) before it # accepts a new one, so wait between attempts instead of failing the upload outright. # Every resolved address is tried once, and this many extra attempts are shared # across the addresses on top of that. -EXTRA_UPLOAD_ATTEMPTS = 2 +EXTRA_UPLOAD_ATTEMPTS = 4 +# Once a connection has reached the device, a lossy link (not a dead device) is +# the likely cause, so keep trying like an API reconnect would +EXTRA_UPLOAD_ATTEMPTS_REACHED = 8 UPLOAD_RETRY_DELAY = 5.0 +# A SYN getting through is the cheap step; an unreachable address still fails fast +CONNECT_TIMEOUT = 20.0 +# Handshake waits; as patient as an API connect on a lossy link, and matching the +# device's OTA_SOCKET_TIMEOUT_HANDSHAKE +SETUP_TIMEOUT = 60.0 # Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA # (105 s) so a stalled session is gone before a retry, and long enough for lwIP # to get a lost chunk ack through after the retransmit run seen in practice DATA_PHASE_TIMEOUT = 160.0 +# UDP data phase; wire format in ota_esphome_udp.cpp +UDP_MSG_DATA = 0x01 +UDP_MSG_PING = 0x02 +UDP_MSG_PROBE = 0x03 +UDP_FLAG_COMMITTED = 0x80 +UDP_MSG_ACK = 0x40 +UDP_HEADER_SIZE = 9 +UDP_ACK_HEADER_SIZE = 12 +# Stream bytes per datagram, within one Ethernet frame +UDP_MAX_PAYLOAD = 1400 +# Messages the device queues unconsumed (UDP_WINDOW in ota_esphome.h); as many +# as TCP's window holds, so UDP costs the device no more memory than TCP +UDP_WINDOW = 4 +# Probe this long before falling back to TCP, enough to ride out a radio busy +# with BLE; the device waits 80 s +UDP_PROBE_TIMEOUT = 15.0 +UDP_PROBE_INTERVAL = 0.1 +# Resend timeout bounds around srtt + 4 * rttvar; random loss never grows it +UDP_RESEND_INITIAL = 0.5 +UDP_RESEND_MIN = 0.2 +UDP_RESEND_MAX = 2.0 +# Acks still asking for the head after later messages arrived; resend it once +UDP_DUP_ACKS = 2 +# Progress acks in a row with no round trip to time: the link is slower than +# the resend timer, so back it off (random loss still yields samples) +UDP_UNTIMED_ACKS = 8 +# Refusals in a row with no ACK between: the device's UDP port is closed +UDP_REFUSALS = 3 +UDP_FALLBACK = b"\x00" +# The UDP format this client speaks; devices decline values they do not know +UDP_REQUEST = b"\x01" +# A plain handshake exchange slower than this hid a retransmit (the device's +# lwIP waits about a second before resending); UDP is used from then on +UDP_SLOW_EXCHANGE = 0.75 +# getsockopt(IPPROTO_TCP) option, size, offset and format of the retransmit +# count: tcp_info.tcpi_total_retrans (Linux), tcp_connection_info. +# tcpi_txretransmitbytes (macOS) +_TCP_RETRANS_OPTION = {"linux": (11, 104, 100, "I"), "darwin": (0x106, 80, 72, "Q")} + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -511,6 +560,10 @@ class NoiseSocketWrapper: self._decrypt = None self._buffer = b"" + def use_transport(self, transport: UdpChannel) -> None: + """Continue the session over another byte stream.""" + self._sock = transport + # Only harmless socket controls pass through; byte-moving methods are # deliberately absent so plaintext cannot leak past the transport. def settimeout(self, timeout: float | None) -> None: @@ -613,19 +666,316 @@ class NoiseSocketWrapper: return data +class _UdpMessage: + """One datagram of the UDP stream and its send history.""" + + __slots__ = ("held", "last_send", "payload", "resend", "sends", "seq") + + def __init__(self, seq: int, payload: bytes) -> None: + self.seq = seq + self.payload = payload + self.last_send = 0.0 + self.sends = 0 + self.resend = False # duplicate acks asked for it + self.held = False # the device keeps it, waiting for an earlier one + + +class UdpChannel: + """Byte stream over numbered UDP datagrams, standing in for the socket. + + The device acks every datagram on arrival and says how many it has room + for; each is resent after TCP's srtt + 4 * rttvar, so loss slows the + transfer instead of ending it. It backs off only while acks come back + untimed, i.e. the link is slower than the timer, never on random loss. + """ + + def __init__(self, sock: socket.socket, token: bytes) -> None: + self._sock = sock + self._token = token + self._timeout = DATA_PHASE_TIMEOUT + self._next_seq = 0 + self._queue: list[_UdpMessage] = [] # not yet received, oldest first + self._rx = bytearray() + self._device_bytes = 0 + self._committed = False + self._srtt: float | None = None + self._rttvar = 0.0 + # Doubles while acks come back untimed, so a link slower than the + # timer still yields messages sent once to time (Karn, RFC 6298) + self._backoff = 1 + self._untimed = 0 + self._dup_acks = 0 + self._free = UDP_WINDOW # the device's empty slots, as of its last ACK + self._last_ping = 0.0 + self._last_progress = time.monotonic() + self._sends = 0 + # Last socket error, e.g. refused once the device's socket is gone + self._last_error: OSError | None = None + self._refusals = 0 + + def settimeout(self, timeout: float) -> None: + self._timeout = timeout + + def setsockopt(self, level: int, optname: int, value: int) -> None: + """TCP tuning does not apply.""" + + def close(self) -> None: + _LOGGER.debug( + "UDP: %d messages in %d datagrams, smoothed RTT %.0f ms", + self._next_seq, + self._sends, + (self._srtt or 0) * 1000, + ) + self._sock.close() + + def probe(self) -> bool: + """True once the device answers over UDP within UDP_PROBE_TIMEOUT.""" + deadline = time.monotonic() + UDP_PROBE_TIMEOUT + try: + while (now := time.monotonic()) < deadline: + self._send(UDP_MSG_PROBE, 0, b"") + self._receive(min(UDP_PROBE_INTERVAL, deadline - now)) + if self._committed: + return True + except OTANetworkError: + pass # rejected, e.g. by a firewall + return False + + def sendall(self, data: bytes) -> None: + for offset in range(0, len(data), UDP_MAX_PAYLOAD): + self._queue.append( + _UdpMessage(self._next_seq, data[offset : offset + UDP_MAX_PAYLOAD]) + ) + self._next_seq = (self._next_seq + 1) & 0xFFFF + # Return once everything is at least in flight, or the device spoke (an error) + while len(self._queue) > UDP_WINDOW and not self._rx: + self._pump() + + def pending(self) -> bool: + """Whether the device sent bytes nobody has read yet.""" + return bool(self._rx) + + def recv(self, amount: int) -> bytes: + while not self._rx: + self._pump() + data = bytes(self._rx[:amount]) + del self._rx[:amount] + return data + + def flush(self, timeout: float) -> None: + """Best effort: wait until the device has every message.""" + deadline = time.monotonic() + timeout + with contextlib.suppress(OTANetworkError): + while self._queue and time.monotonic() < deadline: + self._pump() + + def _rto(self) -> float: + if self._srtt is None: + rto = UDP_RESEND_INITIAL + else: + rto = max(self._srtt + 4 * self._rttvar, UDP_RESEND_MIN) + return min(rto * self._backoff, UDP_RESEND_MAX) + + def _send(self, msg_type: int, seq: int, payload: bytes) -> None: + flags = UDP_FLAG_COMMITTED if self._committed else 0 + header = ( + bytes([msg_type | flags]) + + self._token + + seq.to_bytes(2, "big") + + (self._device_bytes & 0xFFFF).to_bytes(2, "big") + ) + self._sends += 1 + try: + self._sock.send(header + payload) + except OSError as err: + # A full buffer or ICMP error is just more loss, but worth reporting + self._socket_error(err) + + def _pump(self) -> None: + now = time.monotonic() + if now - self._last_progress > self._timeout: + reason = f" (last error: {self._last_error})" if self._last_error else "" + raise OTANetworkError( + f"no progress over UDP for {self._timeout:.0f}s{reason}" + ) + rto = self._rto() + wake = now + rto + # At least the head, which doubles as the probe while the device is busy + in_flight = self._queue[: max(self._free, 1)] + for message in in_flight: + if message.held: + continue + if message.sends == 0 or message.resend or now - message.last_send >= rto: + message.last_send = now + message.sends += 1 + message.resend = False + self._send(UDP_MSG_DATA, message.seq, message.payload) + wake = min(wake, message.last_send + rto) + if all(message.held for message in in_flight): + # Nothing to send: ask for the device's responses + if now - self._last_ping >= rto: + self._last_ping = now + self._send(UDP_MSG_PING, 0, b"") + wake = self._last_ping + rto + self._receive(wake - now) + + def _receive(self, wait: float) -> None: + import select + + # Wait for the first datagram, then drain whatever else arrived + while select.select([self._sock], [], [], max(wait, 0))[0]: + wait = 0 + try: + data = self._sock.recv(2048) + except OSError as err: + self._socket_error(err) + continue + self._handle_ack(data) + + def _socket_error(self, err: OSError) -> None: + if self._last_error is None or err.errno != self._last_error.errno: + _LOGGER.debug("UDP socket error: %s", err) + self._last_error = err + # Windows reports the ICMP port unreachable as a reset + if isinstance(err, (ConnectionRefusedError, ConnectionResetError)): + self._refusals += 1 + if self._refusals >= UDP_REFUSALS: + raise OTANetworkError(f"device closed its UDP port: {err}") + + def _sample_rtt(self, sample: float) -> None: + # RFC 6298 smoothing + self._backoff = 1 + self._untimed = 0 + if self._srtt is None: + self._srtt, self._rttvar = sample, sample / 2 + else: + self._rttvar = 0.75 * self._rttvar + 0.25 * abs(self._srtt - sample) + self._srtt = 0.875 * self._srtt + 0.125 * sample + + def _handle_ack(self, data: bytes) -> None: + if ( + len(data) < UDP_ACK_HEADER_SIZE + or data[0] != UDP_MSG_ACK + or data[1:5] != self._token + ): + return + now = time.monotonic() + self._committed = True + self._refusals = 0 + received = int.from_bytes(data[5:7], "big") + prompted = int.from_bytes(data[7:9], "big") + self._free = data[11] >> 4 + if self._queue: + head = self._queue[0].seq + index = (prompted - head) & 0xFFFF + # This datagram's own round trip; only from messages sent once (Karn) + timed = index < len(self._queue) and self._queue[index].sends == 1 + if timed: + self._sample_rtt(now - self._queue[index].last_send) + done = (received - head) & 0xFFFF + if 0 < done <= len(self._queue): + del self._queue[:done] + self._last_progress = now + self._dup_acks = 0 + if not timed: + self._untimed += 1 + if self._untimed >= UDP_UNTIMED_ACKS: + self._untimed = 0 + if self._rto() < UDP_RESEND_MAX: + self._backoff *= 2 + elif done == 0 and self._queue[0].sends: + # A later message arrived while the head is missing; resend it once + self._dup_acks += 1 + if self._dup_acks == UDP_DUP_ACKS: + self._queue[0].resend = True + # Rebuilt from every ACK, so a reordered one cannot leave a stale mark + for k, message in enumerate(self._queue[:UDP_WINDOW]): + message.held = (received + k - message.seq) & 0xFFFF == 0 and bool( + data[11] & (1 << k) + ) + skip = (self._device_bytes - int.from_bytes(data[9:11], "big")) & 0xFFFF + fresh = data[UDP_ACK_HEADER_SIZE + skip :] + if skip <= len(data) - UDP_ACK_HEADER_SIZE and fresh: + self._rx += fresh + self._device_bytes += len(fresh) + self._last_progress = now + + +def _tcp_retransmits(sock: socket.socket) -> int | None: + """Segments this side retransmitted, where the OS reports it.""" + import struct + import sys + + if (option := _TCP_RETRANS_OPTION.get(sys.platform)) is None: + return None + optname, size, offset, fmt = option + try: + info = sock.getsockopt(socket.IPPROTO_TCP, optname, size) + except (OSError, TypeError): + return None + if len(info) < offset + struct.calcsize(fmt): + return None + return struct.unpack_from(fmt, info, offset)[0] + + +def _link_lossy( + tcp_sock: socket.socket, slowest_exchange: float, prefer_udp: bool +) -> bool: + """Whether the handshake showed loss, so the data phase should use UDP.""" + return ( + prefer_udp + or slowest_exchange > UDP_SLOW_EXCHANGE + or bool(_tcp_retransmits(tcp_sock)) + ) + + +def _start_udp( + sock: socket.socket | NoiseSocketWrapper, tcp_sock: socket.socket, lossy: bool +) -> UdpChannel | None: + """Move the data phase to UDP; None keeps it on TCP.""" + # A clean link asks to stay on TCP + send_check(sock, UDP_REQUEST if lossy else UDP_FALLBACK, "UDP request") + token = receive_exactly(sock, 4, "UDP token", None, decode=False) + if not lossy: + _LOGGER.debug("Link looks clean, staying on TCP") + return None + if token == bytes(4): + _LOGGER.debug("Device declined UDP") + return None + udp_sock = socket.socket(tcp_sock.family, socket.SOCK_DGRAM) + try: + udp_sock.connect(tcp_sock.getpeername()) + except OSError as err: + udp_sock.close() + _LOGGER.info("UDP unavailable (%s), using TCP", err) + else: + channel = UdpChannel(udp_sock, token) + if channel.probe(): + _LOGGER.info("Using UDP") + return channel + channel.close() + _LOGGER.info("UDP blocked, using TCP") + # Raw, outside any Noise frame: forging it only forces the TCP path + send_check(tcp_sock, UDP_FALLBACK, "UDP fallback") + return None + + def _negotiate_session( sock: socket.socket, noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, -) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]: +) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool, float]: """Negotiate through the Noise handshake; returns the socket to go on with - (wrapped once encrypted), version, features, extended.""" + (wrapped once encrypted), version, features, extended and the slowest + plaintext exchange in seconds.""" # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + exchange_start = time.monotonic() send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) + slowest_exchange = time.monotonic() - exchange_start _LOGGER.debug("Device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: @@ -639,9 +989,11 @@ def _negotiate_session( | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | CLIENT_FEATURE_SUPPORTS_DEFLATE + | CLIENT_FEATURE_SUPPORTS_UDP ) if noise_psk: features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE + exchange_start = time.monotonic() send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -649,6 +1001,7 @@ def _negotiate_session( "features", None, # Accept any response )[0] + slowest_exchange = max(slowest_exchange, time.monotonic() - exchange_start) extended_proto = False if features == RESPONSE_FEATURE_FLAGS: @@ -705,7 +1058,7 @@ def _negotiate_session( _LOGGER.debug("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) - return sock, version, features, extended_proto + return sock, version, features, extended_proto, slowest_exchange def perform_ota( @@ -718,6 +1071,7 @@ def perform_ota( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, on_upload_start: Callable[[], None] | None = None, + prefer_udp: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -740,7 +1094,9 @@ def perform_ota( file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) - sock, version, features, extended_proto = _negotiate_session( + # sock may become a Noise wrapper or a UDP channel; UDP needs the peer + tcp_sock = sock + sock, version, features, extended_proto, slowest_exchange = _negotiate_session( sock, noise_psk, plaintext_fallback, allow_plaintext_upload ) _LOGGER.info("Connection established; device supports OTA version %s", version) @@ -832,117 +1188,153 @@ def perform_ota( if on_upload_start is not None: on_upload_start() # the device may commit anything sent from here + udp: UdpChannel | None = None + # Still under SETUP_TIMEOUT, which the device's commit wait is sized for + if extended_proto and features & SERVER_FEATURE_SUPPORTS_UDP: + udp = _start_udp( + sock, tcp_sock, _link_lossy(tcp_sock, slowest_exchange, prefer_udp) + ) + if udp is not None: + # The device drops TCP once it hears the committed channel + if isinstance(sock, NoiseSocketWrapper): + sock.use_transport(udp) + else: + sock = udp + sock.settimeout(DATA_PHASE_TIMEOUT) - - if extended_proto: - send_check(sock, ota_type, "ota type") - - upload_size = len(upload_contents) - # The device erases flash between receiving the size and acking the - # prepare, so this window shows the erase cost (near zero when the - # device erases lazily during the upload) - prepare_start = time.perf_counter() - send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size") - if deflate: - # Own frame: an encrypted session carries one field per frame - send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size") - receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) - prepare_duration = time.perf_counter() - prepare_start - _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) - - # The device hashes what it writes: the inflated image, else the received bytes - upload_md5 = hashlib.md5(file_contents if deflate else upload_contents).hexdigest() - _LOGGER.debug("MD5 of upload is %s", upload_md5) - - send_check(sock, upload_md5, "file checksum") - receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK) - - # Disable nodelay for transfer - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) - # Limit send buffer (usually around 100kB) in order to have progress bar - # show the actual progress - - sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE) - start_time = time.perf_counter() - - offset = 0 - progress = ProgressBar("Uploading") try: - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + if extended_proto: + send_check(sock, ota_type, "ota type") - try: - sock.sendall(chunk) - except OSError as err: - # A send failure can hide an error byte the device reported - # just before dropping the connection; surface that as the - # real, non-retryable cause when it is available - try: - sock.settimeout(1.0) - check_error(recv_decode(sock, 1), None) - except (OSError, OTANetworkError) as probe_err: - _LOGGER.debug( - "No device error behind the send failure: %s", probe_err - ) - raise OTANetworkError(f"sending data: {err}") from err + upload_size = len(upload_contents) + # The device erases flash between receiving the size and acking the + # prepare, so this window shows the erase cost (near zero when the + # device erases lazily during the upload) + prepare_start = time.perf_counter() + send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size") + if deflate: + # Own frame: an encrypted session carries one field per frame + send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size") + receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) + prepare_duration = time.perf_counter() - prepare_start + _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) + + # The device hashes what it writes: the inflated image, else the received bytes + upload_md5 = hashlib.md5( + file_contents if deflate else upload_contents + ).hexdigest() + _LOGGER.debug("MD5 of upload is %s", upload_md5) + + send_check(sock, upload_md5, "file checksum") + receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK) + + # Disable nodelay for transfer + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) + # Limit send buffer (usually around 100kB) in order to have progress bar + # show the actual progress + + sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE) + start_time = time.perf_counter() + + offset = 0 + progress = ProgressBar("Uploading") + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) - if version >= OTA_VERSION_2_0: try: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err except OTANetworkError as err: + if udp is None: + raise + # Over UDP: surface a device error code if one arrived, and + # treat a failure on the last chunk like a lost final ack + if udp.pending(): + receive_exactly(sock, 1, "data", []) if offset < upload_size: raise - # The device already had the complete image when this ack - # was lost, so it may be committing; do not retry raise _committed_error(err) from err - progress.update(offset / upload_size) - except OTAError: - # Terminate the progress bar line before the error is logged + # The UDP channel acks every message itself, so anything the device + # sends mid upload is an error code + if udp is not None and udp.pending() and offset < upload_size: + receive_exactly(sock, 1, "data", []) + if version >= OTA_VERSION_2_0 and udp is None: + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err + + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() - raise - progress.done() - # Enable nodelay for last checks - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - duration = time.perf_counter() - start_time + # Enable nodelay for last checks + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + duration = time.perf_counter() - start_time - _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) + _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - # Once the device has the complete image it commits the update and - # reboots on its own; the exact commit point is not observable from - # here, so treat everything past the data phase as non-retryable. A - # re-upload could flash a device that already updated successfully. - commit_start = time.perf_counter() - try: - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - except OTANetworkError as err: - raise _committed_error(err) from err - commit_duration = time.perf_counter() - commit_start + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + commit_start = time.perf_counter() + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err + commit_duration = time.perf_counter() - commit_start - # Sum of the named windows so the breakdown is self consistent; connect, - # handshake, auth, and the one MD5 round trip are not included - _LOGGER.info( - "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", - prepare_duration + duration + commit_duration, - prepare_duration, - duration, - commit_duration, - ) + # Sum of the named windows so the breakdown is self consistent; connect, + # handshake, auth, and the one MD5 round trip are not included + _LOGGER.info( + "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", + prepare_duration + duration + commit_duration, + prepare_duration, + duration, + commit_duration, + ) - try: - send_check(sock, RESPONSE_OK, "end acknowledgement") - except OTANetworkError as err: - # The device treats a missing end acknowledgement as non-fatal and is - # already rebooting into the new firmware, so the update succeeded - _LOGGER.warning("Failed sending end acknowledgement: %s", err) - _LOGGER.info("OTA successful (end acknowledgement not delivered)") - else: - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") + if udp is not None: + # Our end acknowledgement also tells the device we have its + # responses, ending its wait before it reboots + udp.flush(2.0) + finally: + if udp is not None: + udp.close() # Do not connect logs until it is fully on time.sleep(1) @@ -1121,7 +1513,7 @@ def run_ota_impl_( # are shared across the addresses, cycling through them. Wait before an # attempt when the previous one actually reached the device, or when # revisiting an address, so a flaky link can recover and the device can - # clean up a half-open connection (its handshake watchdog runs at 20s); + # clean up a half-open connection (its handshake watchdog runs at 60s); # moving on to the next address family stays immediate. Known limitation: # a silent mid-transfer drop with no reset can wedge the device until its # 105s data timeout, which outlasts this budget; the retries target the @@ -1139,6 +1531,8 @@ def run_ota_impl_( on_upload_start() on_upload_start = None # once per upload, whatever the retries do + # A network failure means a lossy link, so later attempts send data over UDP + prefer_udp = False while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if not reconnect and (reached_device or attempt >= len(res)): @@ -1153,7 +1547,7 @@ def run_ota_impl_( reconnect = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) - sock.settimeout(20.0) + sock.settimeout(CONNECT_TIMEOUT) try: sock.connect(sa) except OSError as err: @@ -1164,7 +1558,9 @@ def run_ota_impl_( continue _LOGGER.info("Connected to %s", sa[0]) + sock.settimeout(SETUP_TIMEOUT) reached_device = True + total_attempts = max(total_attempts, len(res) + EXTRA_UPLOAD_ATTEMPTS_REACHED) with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota( @@ -1177,6 +1573,7 @@ def run_ota_impl_( encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, on_upload_start=upload_started, + prefer_udp=prefer_udp, ) except OTAKeyRejected as err: # The device sent a reject and closed on its own, so reconnect @@ -1196,6 +1593,7 @@ def run_ota_impl_( raise except OTAHandshakeNetworkError as err: last_error = str(err) + prefer_udp = True if encryption.handshake_fault_falls_back(): encryption.downgrade(last_error) continue @@ -1205,6 +1603,7 @@ def run_ota_impl_( except OTANetworkError as err: # Transient network failure; retry last_error = str(err) + prefer_udp = True _LOGGER.warning("%s", last_error) attempt += 1 continue diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1ede1f4d8de..5ed84fa2a3a 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 2.2.0 esphome/esp-audio-libs: - version: 4.0.1 + version: 4.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: @@ -21,6 +21,8 @@ dependencies: version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 + espressif/esp-nn: + version: 1.1.2 espressif/esp32-camera: version: 2.1.7 espressif/mdns: @@ -83,6 +85,13 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + espressif/ksz8851snl: + version: "1.2.0" + rules: + - if: "idf_version >=6.0.0" + # Keep in sync with esphome/components/ethernet/__init__.py + espressif/ch390: + version: "0.3.0" espressif/esp_tinyusb: version: "2.2.1" rules: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 62deafb09a2..6f0426e4f49 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING import paho.mqtt.client as mqtt from esphome.const import ( + CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE_AUTHORITY, CONF_CLIENT_CERTIFICATE, @@ -21,7 +22,9 @@ from esphome.const import ( CONF_MQTT, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_PORT, + CONF_RETAIN, CONF_SKIP_CERT_CN_CHECK, CONF_TOPIC, CONF_TOPIC_PREFIX, @@ -319,13 +322,36 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): return 1 _LOGGER.info("Starting log output from %s", topic) - def on_message(client, userdata, msg): - time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") - payload = msg.payload.decode(errors="backslashreplace") - message = time_ + payload - safe_print(message) + logs_topic = f"esphome/logs/{config[CONF_ESPHOME][CONF_NAME]}" + # Ask when the retained birth message reports online: a request sent while the device is down is + # lost, and only a retained message tells a CLI that connects later that the device is already up. + # An empty payload deletes the retained message instead, so it counts as no birth message. + birth = config.get(CONF_MQTT, {}).get(CONF_BIRTH_MESSAGE) or {} + birth_payload = birth.get(CONF_PAYLOAD) + birth_topic = birth.get(CONF_TOPIC) if birth_payload else None + ask_on_connect = birth_topic is None or not birth[CONF_RETAIN] - return initialize(config, [topic], on_message, None, username, password, client_id) + def request_crash_log(client): + _LOGGER.debug("Requesting the crash log via topic: %s", logs_topic) + client.publish(logs_topic, None, retain=False) + + def on_message(client, userdata, msg): + payload = msg.payload.decode(errors="backslashreplace") + if msg.topic == birth_topic and payload == birth_payload: + request_crash_log(client) + # The birth topic may sit inside the log subscription, so print by what was asked for + if mqtt.topic_matches_sub(topic, msg.topic): + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") + safe_print(time_ + payload) + + def on_connect(client, userdata, flags, return_code): + if ask_on_connect: + request_crash_log(client) + + subscriptions = [topic] if birth_topic is None else [topic, birth_topic] + return initialize( + config, subscriptions, on_message, on_connect, username, password, client_id + ) def clear_topic(config, topic, username=None, password=None, client_id=None): diff --git a/esphome/preferences.py b/esphome/preferences.py index 6e7e4eda33d..c9a465d6252 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -102,3 +102,17 @@ def is_in_flash(value: str) -> bool: if not in_flash: request_rtc_storage() return in_flash + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Reserve saved values from to_code so a platform with a small store can check they all fit. + + in_flash is the value's own storage (is_in_flash(config[CONF_STORAGE])); leave it None for a + component that calls the two-argument make_preference() and takes the platform default. + """ + if CORE.is_esp8266: + from esphome.components.esp8266.const import reserve_preferences as reserve + + reserve(count, value_bytes, in_flash) diff --git a/esphome/writer.py b/esphome/writer.py index 559d7d85828..934595ef7e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -15,7 +15,7 @@ from esphome.const import ( SOURCE_FILE_EXTENSIONS, __version__, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -514,6 +514,25 @@ const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; """ +def _reset_line_directives(text: str, path: Path) -> str: + """Point the code after each lambda back at its own line of *path*. + + A lambda's #line directive points the compiler at the YAML; without a reset + every later line of main.cpp would be reported against the YAML too. + """ + if LINE_DIRECTIVE_RESET not in text: + return text + document = str(path.absolute()) + lines = text.split("\n") + for index, line in enumerate(lines): + if line.endswith(LINE_DIRECTIVE_RESET): + # The directive numbers the line after it: index + 2, counted from 1 + lines[index] = line.replace( + LINE_DIRECTIVE_RESET, line_directive(document, index + 2) + ) + return "\n".join(lines) + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): @@ -540,7 +559,7 @@ def write_cpp(code_s): f"{code_format[1] + CPP_AUTO_GENERATE_BEGIN}\n{code_s}{CPP_AUTO_GENERATE_END}" ) full_file += code_format[2] - write_file_if_changed(path, full_file) + write_file_if_changed(path, _reset_line_directives(full_file, path)) def clean_cmake_cache(): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b5e2ec17e8a..d887cab959f 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -29,6 +29,7 @@ from esphome.config_helpers import Extend, Remove from esphome.const import CONF_DEFAULTS from esphome.core import ( CORE, + DocumentLocation, DocumentRange, EsphomeError, Lambda, @@ -53,8 +54,11 @@ _LOADED_SECRET_NAMES: set[str] = set() # Set while a secret_values_registered context is active; the dumper records # every emitted `!secret` name into it. _EMITTED_SECRET_NAMES: set[str] | None = None -# Not thread-safe — config processing is single-threaded today. -_load_listeners: list[Callable[[Path], None]] = [] +# Called with every file the YAML loader opens; ``track_yaml_loads`` builds on it. +# Used by device-builder (esphome/device-builder), whose listener filters by +# thread because the hook is process-global; keep the name and the per-file +# ``Path`` signature. Not otherwise a supported API: use at your own risk. +load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] @@ -126,11 +130,11 @@ def track_yaml_loads() -> Generator[list[Path]]: def _on_load(fname: Path) -> None: loaded.append(Path(fname).resolve()) - _load_listeners.append(_on_load) + load_listeners.append(_on_load) try: yield loaded finally: - _load_listeners.remove(_on_load) + load_listeners.remove(_on_load) class ESPHomeDataBase: @@ -142,14 +146,20 @@ class ESPHomeDataBase: def content_offset(self): return getattr(self, "_content_offset", 0) + @property + def content_start(self) -> DocumentLocation: + """Where the value's own text starts: the line after the marker for a block scalar.""" + start = self._esp_range.start_mark + return DocumentLocation( + start.document, start.line + self.content_offset, start.column + ) + def from_node(self, node): # pylint: disable=attribute-defined-outside-init self._esp_range = DocumentRange.from_marks(node.start_mark, node.end_mark) - if ( - isinstance(node, yaml.ScalarNode) - and node.style is not None - and node.style in "|>" - ): + # Only block scalars (| and >) start their content on the next line. A plain + # scalar's style is "", which `in "|>"` would also match. + if isinstance(node, yaml.ScalarNode) and node.style in ("|", ">"): self._content_offset = 1 def from_database(self, database): @@ -660,7 +670,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: secrets.add(Path(fname).resolve()) with track_yaml_loads() as loaded: - _load_listeners.append(_capture_secret) + load_listeners.append(_capture_secret) try: try: data = load_yaml(config_path) @@ -672,7 +682,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: data, warn_on_unresolved=False, warn_on_load_error=False ) finally: - _load_listeners.remove(_capture_secret) + load_listeners.remove(_capture_secret) # Deduplicate while preserving first-seen order. seen: set[Path] = set() @@ -1113,15 +1123,19 @@ def load_yaml( def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> Any: """Load a YAML file.""" - for listener in _load_listeners: + for listener in tuple(load_listeners): listener(fname) try: with fname.open(encoding="utf-8") as f_handle: res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err - # Top-level !include returns a deferred IncludeFile; resolve it so - # callers always receive the final content. + return res + + +def _resolve_include(res: Any) -> Any: + # A document that is just `!include` parses to a deferred IncludeFile; resolve it so + # every parse_yaml caller, including the VS Code and ACE loaders, gets the final content. if isinstance(res, IncludeFile): res = res.load() if res.should_load() else {} return res @@ -1147,7 +1161,7 @@ def parse_yaml( # tracked mode; reject the combination instead of half-applying it. raise ValueError("track_document_range=False requires the default yaml_loader") try: - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, @@ -1159,13 +1173,15 @@ def parse_yaml( # readable exceptions # Rewind the stream so we can try again file_handle.seek(0, 0) - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomePurePythonLoader, file_name, file_handle, yaml_loader, track_document_range=track_document_range, ) + # Resolved after the fallback so a failed first parse never loads the include twice + return _resolve_include(res) def _load_yaml_internal_with_type( diff --git a/platformio.ini b/platformio.ini index 0d6338d6cd1..fe61470a358 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,9 +46,9 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea improv/Improv@1.2.7 ; improv_serial / improv_ble - kikuchan98/pngle@1.1.0 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -82,7 +82,7 @@ lib_deps = esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) - heman/AsyncMqttClient-esphome@1.0.0 ; mqtt + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = @@ -252,8 +252,8 @@ lib_deps = DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial - kikuchan98/pngle@1.1.0 ; online_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -599,6 +599,8 @@ build_flags = ${flags:clangtidy.build_flags} -DUSE_BK72XX -DUSE_LIBRETINY_VARIANT_BK7231N + ; The libretiny component adds this at code generation so FreeRTOSQueue can use xQueueCreateStatic + -DconfigSUPPORT_STATIC_ALLOCATION=1 build_unflags = ${common.build_unflags} @@ -654,6 +656,23 @@ build_flags = build_unflags = ${common.build_unflags} +[env:host-tidy] +extends = env:host +; every library a header under esphome/ may include: a missing one is a +; fatal error that breaks the all-include analysis for every later header +lib_deps = + ${common.lib_deps} + ${common:idf-component-libs.lib_deps} + ${env:host.lib_deps} +build_flags = + ${env:host.build_flags} + ${flags:clangtidy.build_flags} + ; analyze with the same language model as the embedded targets + -fno-exceptions + -fno-rtti + -I/usr/include/SDL2 ; sdl + -D__LINUX__ ; runtime_image: JPEGDEC's host detection, see its __init__.py + ;;;;;;;; nRF52 ;;;;;;;; [env:nrf52] diff --git a/requirements.txt b/requirements.txt index a9dbaa3b8d7..13625818ad8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,16 +10,16 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.4.0 +esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.10.0 +aioesphomeapi==46.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 +backports.zstd==1.8.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 @@ -30,10 +30,10 @@ smpclient==7.3.0 smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 -py7zr==1.1.3 -platformdirs==4.12.3 # native esp-idf toolchain global cache dir +py7zr==1.1.4 +platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 diff --git a/requirements_test.txt b/requirements_test.txt index 0f7d3762554..b36562727f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ -pylint==4.1.2 +pylint==4.1.3 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index f7680f105ca..fd60b2452c6 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -87,10 +87,13 @@ ISOLATED_COMPONENTS = { "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", + "hdmi_cec": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with lightwaverf and opentherm", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", + "lightwaverf": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with hdmi_cec and opentherm", "mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle", "openthread": "Conflicts with wifi: used by most components", "openthread_info": "Conflicts with wifi: used by most components", + "opentherm": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with hdmi_cec and lightwaverf", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", @@ -98,6 +101,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host", "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a143a850326..2f601f5c947 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -536,16 +536,19 @@ def create_field_type_info( f"track_presence on field '{field.name}' has no effect; it requires " "a non-repeated message field in a message that is decoded" ) - if get_field_opt(field, pb.progmem, False) and ( - field.label == FieldDescriptorProto.LABEL_REPEATED - or field.type != 12 - or not get_field_opt(field, pb.pointer_to_buffer, False) - or get_field_opt(field, pb.fixed_array_size) is not None - ): - raise ValueError( - f"progmem on field '{field.name}' requires a non-repeated bytes field " - "with pointer_to_buffer" + if get_field_opt(field, pb.progmem, False): + is_pointer_bytes = ( + field.type == FieldDescriptorProto.TYPE_BYTES + and get_field_opt(field, pb.pointer_to_buffer, False) + and get_field_opt(field, pb.fixed_array_size) is None ) + if field.label == FieldDescriptorProto.LABEL_REPEATED or not ( + field.type == FieldDescriptorProto.TYPE_STRING or is_pointer_bytes + ): + raise ValueError( + f"progmem on field '{field.name}' requires a non-repeated string field, " + "or a bytes field with pointer_to_buffer" + ) if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this is a packed_buffer field (zero-copy packed repeated) if get_field_opt(field, pb.packed_buffer, False): @@ -1155,6 +1158,11 @@ class PointerToBufferTypeBase(TypeInfo): """Get the wire type for this field.""" return WireType.LENGTH_DELIMITED # Uses wire type 2 + @property + def progmem(self) -> bool: + """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" + return get_field_opt(self._field, pb.progmem, False) + def get_estimated_size(self) -> int: # field ID + length varint + typical data (assume small for pointer fields) return self.calculate_field_id_size() + 2 + 16 @@ -1168,11 +1176,6 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): reference_type = "const uint8_t*" const_reference_type = "const uint8_t*" - @property - def progmem(self) -> bool: - """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" - return get_field_opt(self._field, pb.progmem, False) - @property def public_content(self) -> list[str]: # Use uint16_t for length - max packet size is well below 65535 @@ -1252,6 +1255,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase): @property def public_content(self) -> list[str]: + if self.progmem: + return [f"ProgmemStringRef {self.field_name}{{}};"] if self._starts_null: return [ f"StringRef {self.field_name}{{nullptr, 0}}; // null until set, encode only" @@ -1267,9 +1272,19 @@ class PointerToStringBufferType(PointerToBufferTypeBase): ) tag = self.calculate_tag() if tag < 128: - return _encode_call( - "encode_short_string_force", str(tag), f"this->{self.field_name}" + encode_fn = ( + "encode_short_progmem_string_force" + if self.progmem + else "encode_short_string_force" ) + return _encode_call(encode_fn, str(tag), f"this->{self.field_name}") + if self.progmem: + return _encode_call( + "encode_progmem_string", + str(self.number), + f"this->{self.field_name}", + force=self.force, + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()", @@ -1297,6 +1312,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase): @property def dump_content(self) -> str: + if self.progmem: + return f'dump_progmem_string_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' return f'dump_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' def get_size_calculation(self, name: str, force: bool = False) -> str: @@ -3246,8 +3263,9 @@ namespace esphome::api { #ifdef USE_ESP8266 // Out-of-line to avoid inlining strlen_P/memcpy_P at every call site -void DumpBuffer::append_p_esp8266(const char *str) { - size_t len = strlen_P(str); +void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); } + +void DumpBuffer::append_p_esp8266(const char *str, size_t len) { size_t space = CAPACITY - 1 - pos_; if (len > space) len = space; @@ -3368,6 +3386,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co progmem_memcpy(data_buf, data, len); dump_bytes_field(out, field_name, data_buf, len, indent); } + +// Helper for string fields in flash +static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value, + int indent = 2) { + append_field_prefix(out, field_name, indent); + out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\\n"); +} #pragma GCC diagnostic pop """ diff --git a/script/ci-custom.py b/script/ci-custom.py index 8361387af01..5b268d62969 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1269,11 +1269,15 @@ def lint_no_std_nothrow(fname, match): ) +# Every C++ source and header; plain C files cannot use the macro +LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] + + @lint_re_check( - r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" - r"\s*(?:=\s*\{?|[{(])\s*\"", + r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?(?:\w+::)*TAG|\s(?:const\s+)?(?:\w+::)*TAG\s*\[\w*\])" + r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_macro(fname, match): return ( @@ -1281,6 +1285,8 @@ def lint_log_tag_macro(fname, match): f"ESP8266.\n" f" Before: {highlight('static const char *const TAG = "name";')}\n" f" After: {highlight('ESPHOME_LOG_TAG(TAG, "name");')}\n" + f"A class member tag cannot use the macro; declare {highlight('static const char TAG[];')} in the class " + f"and define {highlight('const char Foo::TAG[] PROGMEM = "name";')} in the .cpp.\n" f"Never pass such a TAG to set_timeout/set_interval names or string functions.\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @@ -1288,11 +1294,11 @@ def lint_log_tag_macro(fname, match): @lint_re_check( r"(?:\b(?:set_timeout|set_interval|set_retry|cancel_timeout|cancel_interval|cancel_retry|defer)" - r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*TAG\b" - r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", + r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*(?:\w+::)*TAG\b" + r"|\bstd::string\s+\w+\s*[={(]\s*(?:\w+::)*TAG\b", mask=True, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_as_string(fname, match): return ( diff --git a/script/clang-tidy b/script/clang-tidy index a2becb59cc8..3f923403a38 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -68,6 +68,9 @@ def clang_options(idedata, environment): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] + # env:host compiles with the system g++, so there is no triplet and the + # host's own target and headers are the right ones. + native = environment.startswith("host") if triplet.startswith("xtensa-"): # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF # toolchain headers (xtruntime, xtensa/config) need the GCC core config @@ -83,6 +86,8 @@ def clang_options(idedata, environment): cmd.append("-D__XTENSA__") cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") + elif native: + cmd.append("-Qunused-arguments") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so # compile for the actual triplet. Espressif's RISC-V GCC -march adds @@ -118,7 +123,7 @@ def clang_options(idedata, environment): "-mfp16-format=ieee", "--param=min-pagesize=0", ) - else: + elif not native: cmd.extend( [ # disable built-in include directories from the host @@ -127,10 +132,11 @@ def clang_options(idedata, environment): ) # set flags + if not native: + # disable built-in include directories from the host + cmd.append("-nostdinc") cmd.extend( [ - # disable built-in include directories from the host - "-nostdinc", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers @@ -314,11 +320,17 @@ def run_tidy( if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all # fixed-address. + disabled_checks.append("-clang-analyzer-core.FixedAddressDereference") + if args.environment.startswith("host"): + # glibc's assert() expands to a call the check mistakes for a + # constant condition. + disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"] + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")): # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher - # recursion on lvgl_esphome.h under the RP2 defines. + # recursion on lvgl_esphome.h under the RP2 defines and on the + # all-include file under the host defines. disabled_checks += [ - "-clang-analyzer-core.FixedAddressDereference", "-bugprone-pointer-arithmetic-on-polymorphic-object", "-cert-ctr56-cpp", ] @@ -327,6 +339,8 @@ def run_tidy( if disabled_checks: invocation.append("--checks=" + ",".join(disabled_checks)) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + # Vendored third-party headers are kept as upstream wrote them. + invocation.append("--exclude-header-filter=.*/http_request/httplib\\.h") invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -448,6 +462,11 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + # Not every tidy environment carries googletest (LibreTiny does not), + # so a platform grep that matched a unit test would fail on + # . The tests are analyzed by the full ESP32 IDF run, + # which has no --grep. + files = [f for f in files if not f.startswith("tests/")] if args.exclude_grep: excluded = set(filter_grep(files, args.exclude_grep)) @@ -464,11 +483,16 @@ def main(): # Add header file for checking (before early exit check) if args.all_headers and args.split_at in (None, 1): + all_headers = [ + os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) + ] + # The path filter applies to headers too: a component left out because + # it does not build in this environment must not come back via the + # all-include file. + if args.files: + all_headers = [p for p in all_headers if file_name_re.search(p)] # When --changed is used, only include changed headers instead of all headers if args.changed: - all_headers = [ - os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) - ] changed_headers = filter_changed(all_headers) if changed_headers: build_all_include(changed_headers) @@ -476,7 +500,7 @@ def main(): else: print("No changed headers to check") else: - build_all_include() + build_all_include(all_headers) files.insert(0, temp_header_file) print(f"Added all-include header file, new total: {len(files)}") diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9d146c94a0b..e1483446d4d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -49,6 +49,7 @@ Options: from __future__ import annotations import argparse +import ast from collections import Counter from collections.abc import Callable from enum import StrEnum @@ -58,6 +59,7 @@ import math import os from pathlib import Path import statistics +import subprocess import sys from typing import Any @@ -69,6 +71,7 @@ from clang_tidy_hash import ( ) from helpers import ( CPP_FILE_EXTENSIONS, + ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH, INTEGRATION_TESTS_PATH, PYTHON_FILE_EXTENSIONS, @@ -805,6 +808,9 @@ def determine_cpp_unit_tests( # Paths within tests/benchmarks/ that contain component benchmark files BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" +# The Python benchmark that imports components; its COMPONENTS tuple says which ones +PYTHON_IMPORT_BENCHMARK_FILE = "tests/benchmarks/python/test_component_import_bench.py" + # Files that, when changed, should trigger benchmark runs BENCHMARK_INFRASTRUCTURE_FILES = frozenset( { @@ -815,6 +821,53 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( ) +def _benchmark_components_tuple(path: Path) -> frozenset[str]: + """Return the benchmark file's ``COMPONENTS`` tuple. + + Raises when the file has none, so renaming it fails CI instead of quietly + turning the trigger off. + """ + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "COMPONENTS" for t in node.targets + ): + return frozenset(ast.literal_eval(node.value)) + raise RuntimeError(f"{PYTHON_IMPORT_BENCHMARK_FILE} has no COMPONENTS tuple") + + +# Imports the given components and prints every component whose module got loaded +_IMPORT_PROBE = ( + "import importlib, sys\n" + "for c in sys.argv[1:]: importlib.import_module('esphome.components.' + c)\n" + "print(*{n.split('.')[2] for n in sys.modules if n.startswith('esphome.components.')})" +) + + +def python_import_benchmarked_components() -> frozenset[str] | None: + """Return the components loaded by importing the benchmark's ``COMPONENTS``. + + Imported in a fresh interpreter from the checkout, so the set is exactly what + the benchmark times. None when the import fails. + """ + path = Path(root_path) / PYTHON_IMPORT_BENCHMARK_FILE + if not path.is_file(): + return frozenset() + result = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE, *_benchmark_components_tuple(path)], + cwd=root_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print( + f"Benchmark import probe failed, running benchmarks:\n{result.stderr}", + file=sys.stderr, + ) + return None + return frozenset(result.stdout.split()) + + def should_run_benchmarks(branch: str | None = None) -> bool: """Determine if benchmarks (C++ and Python) should run based on changed files. @@ -831,6 +884,8 @@ def should_run_benchmarks(branch: str | None = None) -> bool: 4. A directly changed component has benchmark files (no dependency expansion) 5. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) + 6. A Python file changed in a component that importing the Python import + benchmark's ``COMPONENTS`` loads, even if it has no C++ benchmark Unlike unit tests, benchmarks do NOT expand to dependent components. Changing ``sensor`` does not trigger ``api`` benchmarks just because @@ -862,6 +917,19 @@ def should_run_benchmarks(branch: str | None = None) -> bool: ): return True + # The Python import benchmark times importing its components and what they pull + # in, so their Python sources move its numbers even without a C++ benchmark. + # A failed import runs the benchmarks rather than risk skipping them. + changed_python = { + get_component_from_path(f) + for f in files + if f.startswith(ESPHOME_COMPONENTS_PATH) and f.endswith(PYTHON_FILE_EXTENSIONS) + } + if changed_python: + loaded = python_import_benchmarked_components() + if loaded is None or changed_python & loaded: + return True + # Check if any directly changed component has benchmarks benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH if not benchmarks_dir.is_dir(): diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 63dbeffd772..8d5ddfc39f8 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -7,6 +7,7 @@ CONFIG_IEEE802154_ENABLED=y CONFIG_OPENTHREAD_ENABLED=y CONFIG_OPENTHREAD_RADIO_NATIVE=y +CONFIG_OPENTHREAD_FTD=y # zigbee CONFIG_ZB_ENABLED=y diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 index 15b97eb1b41..5204d95db52 100644 --- a/sdkconfig.defaults.esp32s3 +++ b/sdkconfig.defaults.esp32s3 @@ -10,3 +10,28 @@ CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 + +# OpenThread Border Router with external UART RCP. +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_FTD=y +CONFIG_OPENTHREAD_PLATFORM_NETIF=y +CONFIG_OPENTHREAD_BORDER_ROUTER=y +CONFIG_OPENTHREAD_RADIO_SPINEL_UART=y +CONFIG_OPENTHREAD_CLI=n +CONFIG_OPENTHREAD_CONSOLE_ENABLE=n +CONFIG_OPENTHREAD_DIAG=n +CONFIG_OPENTHREAD_DNS64_CLIENT=n +CONFIG_OPENTHREAD_SRP_CLIENT=n +CONFIG_LWIP_IPV6_FORWARD=y +CONFIG_LWIP_IPV6_NUM_ADDRESSES=12 +CONFIG_LWIP_MULTICAST_PING=y +CONFIG_LWIP_NETIF_STATUS_CALLBACK=y +CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT=y +CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT=y +CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM=y +CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM=y +CONFIG_MDNS_MULTIPLE_INSTANCE=y +CONFIG_MBEDTLS_CMAC_C=y +CONFIG_MBEDTLS_SSL_PROTO_DTLS=y +CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=y +CONFIG_MBEDTLS_ECJPAKE_C=y diff --git a/tests/benchmarks/components/display/__init__.py b/tests/benchmarks/components/display/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/display/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp new file mode 100644 index 00000000000..da96dd83b9a --- /dev/null +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" + +namespace esphome::display::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated block stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// A buffered display without its own draw_pixels_at(), so every block goes +// through the base class fallback and the DisplayBuffer pixel path. +class BenchDisplay : public DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; +}; + +// Deterministic pseudo random pixel bytes, sized for the widest format. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 3; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +// One full block the way LVGL hands a display its rendering. +static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian, + DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + BenchDisplay display; + display.set_rotation(rotation); + for (auto _ : state) { + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); } +BENCHMARK(DrawPixelsAt_RGB565_BigEndian); + +static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); } +BENCHMARK(DrawPixelsAt_RGB565_LittleEndian); + +static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); } +BENCHMARK(DrawPixelsAt_RGB888); + +static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); } +BENCHMARK(DrawPixelsAt_RGB332); + +static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB565_Rotated); + +static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB888_Rotated); + +static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB332_Rotated); + +} // namespace esphome::display::benchmarks diff --git a/tests/benchmarks/components/image/__init__.py b/tests/benchmarks/components/image/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/image/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp new file mode 100644 index 00000000000..6bdd0acffbc --- /dev/null +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated draw of the image stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is Image::draw() plus the DisplayBuffer pixel path every +// buffered display shares. Everything fits the host cache, so this counts +// the per pixel work, not what a frame buffer in PSRAM costs. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; + + public: + uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; } +}; + +// Models a driver with a bulk path: rows of little endian RGB565 are copied +// straight into a 16 bit frame, as the TFT drivers do for their own format. +// Image::draw() only reaches it once it hands opaque images to +// draw_pixels_at(); until then this case matches ImageDraw_RGB565. +class BulkBenchDisplay : public BenchDisplay { + public: + BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (bitness != display::COLOR_BITNESS_565 || big_endian) { + BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = x_offset + w + x_pad; + for (int y = 0; y < h; y++) { + memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2, + w * 2); + } + } + + protected: + std::unique_ptr frame565_; +}; + +// Draws the image at rotation 0 and 90 and checks the second frame is the +// transpose of the first, which is what a wrong traversal order would break. +static bool frames_match(Image &image) { + BenchDisplay plain; + BenchDisplay rotated; + rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES); + image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF); + image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF); + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + // DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees + if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x)) + return false; + } + } + return true; +} + +// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of +// pixels. Sized for the widest format, the image reads only what it needs. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 4; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +static void draw_image(benchmark::State &state, ImageType type, Transparency transparency, + display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, type, transparency); + BenchDisplay display; + display.set_rotation(rotation); + if (!frames_match(image)) { + state.SkipWithError("rotated draw is not the transpose of the plain draw"); + return; + } + + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_Binary); + +static void ImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); +} +BENCHMARK(ImageDraw_Grayscale); + +static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB565); + +static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB); + +// The same opaque RGB565 image on a display with a bulk path. +static void ImageDraw_RGB565_Bulk(benchmark::State &state) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + BulkBenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} +BENCHMARK(ImageDraw_RGB565_Bulk); + +static void ImageDraw_RGB_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Rotated); + +static void ImageDraw_Binary_Transparent(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Binary_Transparent); + +static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey); + +static void ImageDraw_Grayscale_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_Grayscale_Alpha); + +static void ImageDraw_RGB_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB_Alpha); + +static void ImageDraw_RGB_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB_ChromaKey); + +static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey); + +static void ImageDraw_RGB565_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB565_Alpha); + +static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB111); + +static void ImageDraw_RGB111_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB111_Alpha); + +// The same cases with the display rotated 90 degrees, which walks the other loop order. +static void ImageDraw_Binary_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Rotated); + +static void ImageDraw_Grayscale_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Rotated); + +static void ImageDraw_RGB565_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Rotated); + +static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Transparent_Rotated); + +static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated); + +static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated); + +static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Alpha_Rotated); + +static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated); + +static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated); + +static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Alpha_Rotated); + +static void ImageDraw_RGB111_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Rotated); + +static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Alpha_Rotated); + +} // namespace esphome::image::benchmarks diff --git a/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp new file mode 100644 index 00000000000..fd3cf6fb773 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is the image draw plus the DisplayBuffer pixel path every +// buffered display shares. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kWidth * kHeight)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kWidth; } + int get_height_internal() override { return kHeight; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; } + + std::unique_ptr frame_; +}; + +// A runtime image filled the way a decoder fills it, one draw_pixel() per +// pixel, then published as decoded so draw() uses the buffer. +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian) + : RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {} + + bool fill() { + if (this->resize(kWidth, kHeight) == 0) + return false; + uint32_t seed = 11897; + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + seed = seed * 1103515245u + 12345u; + // Every fourth pixel is transparent so the chroma key cases hit both branches + const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF; + this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha)); + } + } + this->width_ = this->buffer_width_; + this->height_ = this->buffer_height_; + this->data_start_ = this->buffer_; + return true; + } +}; + +// The decoder side: writing every pixel of the image into its buffer. +static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) { + BenchImage image(type, transparency, false); + for (auto _ : state) { + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + benchmark::DoNotOptimize(image.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +// The display side: drawing the decoded image onto a buffered display. +static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency, + bool big_endian = false) { + BenchImage image(type, transparency, big_endian); + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + BenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void RuntimeImageFill_RGB565(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB565); + +static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageFill_RGB565_ChromaKey); + +static void RuntimeImageFill_RGB(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB); + +static void RuntimeImageDraw_RGB565(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB565); + +static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true); +} +BENCHMARK(RuntimeImageDraw_RGB565_BigEndian); + +static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey); + +static void RuntimeImageDraw_RGB(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB); + +static void RuntimeImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_Grayscale); + +} // namespace esphome::runtime_image::benchmarks diff --git a/tests/benchmarks/python/test_component_import_bench.py b/tests/benchmarks/python/test_component_import_bench.py new file mode 100644 index 00000000000..35981cb7ee4 --- /dev/null +++ b/tests/benchmarks/python/test_component_import_bench.py @@ -0,0 +1,105 @@ +"""CodSpeed benchmarks for importing entity components. + +Every ``esphome config`` or ``esphome compile`` imports the Python module of +each component the YAML uses. A component that imports its optional +integrations (mqtt, web_server, zigbee) at module scope drags their whole +import chains in for every user, so the cost of a fresh import is what these +benchmarks pin down. + +The core of esphome is imported once up front and stays loaded; each run +drops only the component modules from ``sys.modules`` and imports the +component again, so the import machinery and the module bodies of the +component and everything it pulls in run every time, as they do in a new +process, while a change to the core moves no number here. The modules and +registry entries the session had before a case are put back afterwards, so +later tests keep the objects they already hold. + +Skipped when ``pytest-codspeed`` isn't installed so the regular unit-test +suite keeps working unchanged. +""" + +from __future__ import annotations + +from collections.abc import Generator, Iterable +import importlib +import sys + +import pytest + +pytest.importorskip("pytest_codspeed") + +# Load the core before the first case so no case pays for it. +from esphome import automation, pins # noqa: E402 +import esphome.codegen # noqa: E402, F401 +import esphome.config_validation # noqa: E402, F401 + +COMPONENTS = ("binary_sensor", "sensor", "logger") +COMPONENT_PACKAGE = "esphome.components" +COMPONENT_PREFIX = COMPONENT_PACKAGE + "." +# Core registries that component module bodies write to when they are imported +REGISTRIES = ( + automation.ACTION_REGISTRY, + automation.CONDITION_REGISTRY, + pins.PIN_SCHEMA_REGISTRY, +) + + +def _component_modules() -> dict[str, object]: + # The package itself goes too: ``from esphome.components import x`` is + # answered from its attributes, so a stale package would hide the import. + return { + n: m + for n, m in sys.modules.items() + if n == COMPONENT_PACKAGE or n.startswith(COMPONENT_PREFIX) + } + + +def _forget_component_modules() -> None: + for name in _component_modules(): + del sys.modules[name] + + +def _import_component(name: str) -> object: + _forget_component_modules() + return importlib.import_module(COMPONENT_PREFIX + name) + + +_MISSING = object() + + +def _parent_bindings(names: Iterable[str]) -> list[tuple[object, str, object]]: + # An import binds the module to its parent package; a package may hold something else + # under that name (substitutions keeps a Jinja() instance as ``jinja``), so the value + # the parent has now is what goes back, not the module. + bindings = [] + for name in names: + parent, _, child = name.rpartition(".") + bindings.append( + (sys.modules[parent], child, getattr(sys.modules[parent], child, _MISSING)) + ) + return bindings + + +@pytest.fixture +def restore_component_modules() -> Generator[None]: + saved = _component_modules() + saved_bindings = _parent_bindings(saved) + saved_registries = [dict(registry) for registry in REGISTRIES] + yield + _forget_component_modules() + sys.modules.update(saved) + for parent, child, value in saved_bindings: + if value is _MISSING: + delattr(parent, child) + else: + setattr(parent, child, value) + # The re-imported bodies registered their actions, conditions and pin schemas again + for registry, entries in zip(REGISTRIES, saved_registries, strict=True): + registry.clear() + registry.update(entries) + + +@pytest.mark.usefixtures("restore_component_modules") +@pytest.mark.parametrize("component", COMPONENTS) +def test_import_component(benchmark, component: str) -> None: + benchmark(_import_component, component) diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index 69c9605a675..2b7662b6655 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -8,18 +8,13 @@ import pytest from esphome.components.api import ( _action_strings, - _action_strings_size, _has_action_metadata, - _validate_esp8266_action_strings, validate_variable, ) from esphome.config_validation import Invalid -from esphome.const import PlatformFramework from esphome.core import CORE from esphome.cpp_generator import safe_exp from esphome.helpers import fnv1_hash -from tests.component_tests.helpers import get_define_value -from tests.component_tests.types import SetCoreConfigCallable CONFIG = "tests/component_tests/api/test_action_metadata.yaml" CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml" @@ -45,7 +40,6 @@ def test_metadata_is_emitted_as_progmem_table( ) assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None def test_esp8266_strings_are_shared_progmem_arrays( @@ -69,15 +63,13 @@ def test_esp8266_strings_are_shared_progmem_arrays( ) -def test_esp8266_sizes_scratch_buffer_for_largest_action( +def test_esp8266_encodes_action_strings_from_flash( generate_main: Callable[[str | Path], str], ) -> None: - """ESP8266 gets a scratch buffer define equal to the byte total of the largest action.""" - generate_main(CONFIG_ESP8266) + """ESP8266 uses the same flash string table.""" + main_cpp = generate_main(CONFIG_ESP8266) - # play_buzzer: name, description, two variable names, one description, one example, - # each with a terminator - assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117" + assert "api_action0_strings[] PROGMEM" in main_cpp def test_shorthand_variables_emit_no_metadata( @@ -113,36 +105,8 @@ def test_variable_rejects_invalid(value: object) -> None: validate_variable(value) -def _oversized_action_config() -> dict: - return { - "actions": [ - { - "action": "big", - "description": "x" * 300, - "variables": {"a": {"type": "string", "example": "y" * 300}}, - } - ] - } - - -def test_esp8266_rejects_actions_over_string_budget( - set_core_config: SetCoreConfigCallable, -) -> None: - set_core_config(PlatformFramework.ESP8266_ARDUINO) - with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"): - _validate_esp8266_action_strings(_oversized_action_config()) - - -def test_other_platforms_have_no_string_budget( - set_core_config: SetCoreConfigCallable, -) -> None: - set_core_config(PlatformFramework.ESP32_IDF) - config = _oversized_action_config() - assert _validate_esp8266_action_strings(config) is config - - -def test_empty_metadata_is_unset_and_not_counted() -> None: - """An empty description or example emits nullptr and takes no scratch space.""" +def test_empty_metadata_is_unset() -> None: + """An empty description or example emits nullptr.""" conf = { "action": "a", "description": "", @@ -150,8 +114,6 @@ def test_empty_metadata_is_unset_and_not_counted() -> None: } strings = _action_strings(conf, has_metadata=True) assert strings == ["a", None, "b", None, "ex"] - # Every emitted string counts its terminator: "a" + "b" + "ex" - assert _action_strings_size(strings) == 2 + 2 + 3 def test_empty_metadata_does_not_enable_the_define() -> None: diff --git a/tests/component_tests/as734x/__init__.py b/tests/component_tests/as734x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/as734x/test_as734x.py b/tests/component_tests/as734x/test_as734x.py new file mode 100644 index 00000000000..d668523c816 --- /dev/null +++ b/tests/component_tests/as734x/test_as734x.py @@ -0,0 +1,122 @@ +"""Tests for the as734x band to counts index mapping and band validation. + +The C++ readout maps publish each band from a fixed index, so the order of BANDS_43 is a +contract with as734x.cpp. Reordering either side only moves readings to the wrong entity, +which no build test would notice, so the indices are pinned here. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.as734x import ( + CONF_AS734X_ID, + CONFIG_SCHEMA as HUB_SCHEMA, + sensor as as734x_sensor, +) +from esphome.config import Config +from esphome.const import CONF_COUNTS, CONF_ID, CONF_TYPE, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG = "tests/component_tests/as734x/test_as734x.yaml" + +BAND_INDEX = { + "f1": 0, + "f2": 1, + "fz": 2, + "f3": 3, + "f4": 4, + "fy": 5, + "f5": 6, + "fxl": 7, + "f6": 8, + "f7": 9, + "f8": 10, + "nir": 11, + "clear": 12, +} +AS7341_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "nir", "clear") + + +def test_as7341_bands_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in AS7341_BANDS: + assert f"hub41->set_counts_sensor(s41_{band}, {BAND_INDEX[band]});" in main_cpp + + +def test_as7343_bands_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band, index in BAND_INDEX.items(): + assert f"hub43->set_counts_sensor(s43_{band}, {index});" in main_cpp + + +@pytest.mark.parametrize("band", ["fz", "fy", "fxl"]) +def test_as7341_rejects_as7343_only_band( + band: str, set_core_config: SetCoreConfigCallable +) -> None: + full = Config() + full["as734x"] = [{CONF_ID: ID("hub41"), CONF_TYPE: "AS7341"}] + full.declare_ids.append((ID("hub41"), ["as734x", 0, CONF_ID])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + + with pytest.raises(cv.Invalid, match=f"no {band} band"): + as734x_sensor.FINAL_VALIDATE_SCHEMA( + {CONF_AS734X_ID: ID("hub41"), CONF_COUNTS: {"f1": {}, band: {}}} + ) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ({"type": "AS7343", "atime": 0, "astep": 0}, "must not both be 0"), + # about 140 s with three cycles, against the default 60 s interval + ( + {"type": "AS7343", "atime": 255, "astep": 65534}, + "longer than the update_interval", + ), + # about 93 s with two cycles, rejected only if a third cycle were counted + ( + {"type": "AS7341", "atime": 255, "astep": 65534, "update_interval": "100s"}, + None, + ), + ], +) +def test_exposure_bound( + config: dict, error: str | None, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + if error is None: + HUB_SCHEMA(config) + return + with pytest.raises(cv.Invalid, match=error): + HUB_SCHEMA(config) + + +def test_as7343_basic_counts_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in ("f1", "fz", "clear"): + assert ( + f"hub43->set_basic_counts_sensor(b43_{band}, {BAND_INDEX[band]});" + in main_cpp + ) + + +def test_as7341_calibration_spreads_over_the_band_slots(generate_main) -> None: + """The AS7341 has ten bands, but the C++ side indexes all thirteen slots. + + The three bands it does not have keep the neutral value, so a correction or dark + current given for F3 reaches slot 3 rather than slot 2. + """ + main_cpp = generate_main(CONFIG) + + assert ( + "hub41->set_channel_correction({1.1f, 1.2f, 1.0f, 1.3f, 1.4f, 1.0f, 1.5f, " + "1.0f, 1.6f, 1.7f, 1.8f, 1.9f, 2.0f});" in main_cpp + ) + assert ( + "hub41->set_dark_current({0.1f, 0.2f, 0.0f, 0.3f, 0.4f, 0.0f, 0.5f, " + "0.0f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f});" in main_cpp + ) diff --git a/tests/component_tests/as734x/test_as734x.yaml b/tests/component_tests/as734x/test_as734x.yaml new file mode 100644 index 00000000000..da014d32e6a --- /dev/null +++ b/tests/component_tests/as734x/test_as734x.yaml @@ -0,0 +1,56 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: 21 + scl: 22 + +as734x: + - id: hub41 + type: AS7341 + normalize_basic_counts: ALL + calibration: + channel_correction: [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0] + dark_current: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + - id: hub43 + type: AS7343 + address: 0x3A + +sensor: + - platform: as734x + as734x_id: hub41 + counts: + f1: {id: s41_f1, name: s41 f1} + f2: {id: s41_f2, name: s41 f2} + f3: {id: s41_f3, name: s41 f3} + f4: {id: s41_f4, name: s41 f4} + f5: {id: s41_f5, name: s41 f5} + f6: {id: s41_f6, name: s41 f6} + f7: {id: s41_f7, name: s41 f7} + f8: {id: s41_f8, name: s41 f8} + nir: {id: s41_nir, name: s41 nir} + clear: {id: s41_clear, name: s41 clear} + - platform: as734x + as734x_id: hub43 + counts: + f1: {id: s43_f1, name: s43 f1} + f2: {id: s43_f2, name: s43 f2} + fz: {id: s43_fz, name: s43 fz} + f3: {id: s43_f3, name: s43 f3} + f4: {id: s43_f4, name: s43 f4} + fy: {id: s43_fy, name: s43 fy} + f5: {id: s43_f5, name: s43 f5} + fxl: {id: s43_fxl, name: s43 fxl} + f6: {id: s43_f6, name: s43 f6} + f7: {id: s43_f7, name: s43 f7} + f8: {id: s43_f8, name: s43 f8} + nir: {id: s43_nir, name: s43 nir} + clear: {id: s43_clear, name: s43 clear} + basic_counts: + f1: {id: b43_f1, name: b43 f1} + fz: {id: b43_fz, name: b43 fz} + clear: {id: b43_clear, name: b43 clear} + saturation_level: {id: sat43, name: sat 43} diff --git a/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml new file mode 100644 index 00000000000..73d993fb290 --- /dev/null +++ b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: epaper_spi + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: diff --git a/tests/component_tests/display/config/it8951_owner_auto_id.yaml b/tests/component_tests/display/config/it8951_owner_auto_id.yaml new file mode 100644 index 00000000000..ed6b0fd8994 --- /dev/null +++ b/tests/component_tests/display/config/it8951_owner_auto_id.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + model: m5stack-m5paper + +fake_display_writer: diff --git a/tests/component_tests/display/config/no_owner.yaml b/tests/component_tests/display/config/no_owner.yaml new file mode 100644 index 00000000000..4a4714779a2 --- /dev/null +++ b/tests/component_tests/display/config/no_owner.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 diff --git a/tests/component_tests/display/config/owner_by_id.yaml b/tests/component_tests/display/config/owner_by_id.yaml new file mode 100644 index 00000000000..e3be24deffe --- /dev/null +++ b/tests/component_tests/display/config/owner_by_id.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: + display_id: epaper_display diff --git a/tests/component_tests/display/external_components/fake_display_writer/__init__.py b/tests/component_tests/display/external_components/fake_display_writer/__init__.py new file mode 100644 index 00000000000..5e1388578fa --- /dev/null +++ b/tests/component_tests/display/external_components/fake_display_writer/__init__.py @@ -0,0 +1,20 @@ +"""External component that draws into a display from outside its lambda or pages.""" + +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["display"] +DOMAIN = "fake_display_writer" + + +def _register_writer(config: ConfigType) -> ConfigType: + display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}), + _register_writer, +) diff --git a/tests/component_tests/display/test_external_writer.py b/tests/component_tests/display/test_external_writer.py new file mode 100644 index 00000000000..c51bf15b579 --- /dev/null +++ b/tests/component_tests/display/test_external_writer.py @@ -0,0 +1,98 @@ +"""Tests for the display external writer registry.""" + +from collections.abc import Callable, Generator +from pathlib import Path +import sys + +import pytest + +from esphome import loader +from esphome.components.display import ( + Display, + get_external_writers, + has_external_writer, + register_external_writer, +) +from esphome.core import ID +from esphome.final_validate import full_config + +TEST_CARD = "show_test_card()" +UPDATE_NEVER = "set_update_interval(4294967295UL)" + + +@pytest.fixture +def unload_fake_writer() -> Generator[None]: + """Drop the fake external component so later tests start clean.""" + yield + loader._COMPONENT_CACHE.pop("fake_display_writer", None) + sys.modules.pop("esphome.components.fake_display_writer", None) + loader.clear_component_meta_finders() + + +def test_unresolved_id_matches_after_resolution() -> None: + """An auto-picked ID registered during schema validation matches once resolved.""" + pending = ID(None, is_declaration=False, type=Display) + register_external_writer(pending, "owner") + declared = ID("epd", is_declaration=True, type=Display) + + assert get_external_writers(declared) == [] + assert has_external_writer(declared) is False + + pending.id = "epd" + assert get_external_writers(declared) == ["owner"] + assert has_external_writer(declared) is True + assert get_external_writers(ID("other", is_declaration=True, type=Display)) == [] + + +def test_all_owners_returned() -> None: + register_external_writer(ID("epd"), "first") + register_external_writer(ID("epd"), "second") + register_external_writer(ID("other"), "third") + + assert get_external_writers(ID("epd")) == ["first", "second"] + + +def test_lvgl_counts_as_external_writer() -> None: + assert has_external_writer(ID("epd")) is False + full_config.get()["lvgl"] = [] + assert has_external_writer(ID("epd")) is True + + +def test_drivers_show_test_card_without_owner( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("no_owner.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" in main_cpp + assert UPDATE_NEVER not in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +@pytest.mark.parametrize( + "config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"] +) +def test_owned_display_has_no_test_card( + config_file: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The owner picks the display ID automatically, so it resolves after the schema runs.""" + main_cpp = generate_main(component_config_path(config_file)) + + assert TEST_CARD not in main_cpp + assert UPDATE_NEVER in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +def test_only_owned_display_loses_test_card( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("owner_by_id.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" not in main_cpp + assert f"epaper_display->{UPDATE_NEVER}" in main_cpp + assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp diff --git a/tests/component_tests/emontx/test_lock_on_reboot.py b/tests/component_tests/emontx/test_lock_on_reboot.py new file mode 100644 index 00000000000..6b6ba2881f6 --- /dev/null +++ b/tests/component_tests/emontx/test_lock_on_reboot.py @@ -0,0 +1,38 @@ +"""Tests for the emontx lock_on_reboot option: auto (the default), true or false.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import ( + CONF_LOCK_ON_REBOOT, + CONFIG_SCHEMA, + LOCK_ON_REBOOT_AUTO, +) + + +def _lock_key() -> cv.Optional: + return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT) + + +def test_default_is_auto() -> None: + assert _lock_key().default() == LOCK_ON_REBOOT_AUTO + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("auto", LOCK_ON_REBOOT_AUTO), + ("AUTO", LOCK_ON_REBOOT_AUTO), + (True, True), + (False, False), + ("yes", True), + ("off", False), + ], +) +def test_accepted_values(value: object, expected: object) -> None: + assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected + + +def test_other_values_rejected() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA.schema[_lock_key()]("sometimes") diff --git a/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml new file mode 100644 index 00000000000..fb6eeec8d77 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 4b8f5788925..81e197abf81 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates( # init_sequence_length, ...); a length of 0 confirms the empty init # sequence array was generated instead of raising during code generation. assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) + + +def test_test_card_overrides_update_interval( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With nothing else to draw, update_interval is not left at never for the test card.""" + main_cpp = generate_main(component_config_path("test_card_update_interval.yaml")) + + match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp) + assert match is not None + assert int(match.group(1)) != 4294967295 diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml new file mode 100644 index 00000000000..a6f8877e31b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd8c099c09f..3cde877f7ad 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2240,9 +2240,10 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" - generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + """The Border Router needs TLS/DTLS server support and OpenThread crypto extras.""" + generate_main(component_config_path("mbedtls_tls_openthread_border_router.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_required is True assert mbedtls.tls_server_required is True assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py index 806b1775d29..34e77d84a2f 100644 --- a/tests/component_tests/gpio_expander/test_init.py +++ b/tests/component_tests/gpio_expander/test_init.py @@ -49,6 +49,7 @@ def test_allow_other_uses_rejected(stage_esp32: None) -> None: "pi4ioe5v6408", "mcp23016", "mcp23017", + "pca9505", ], ) def test_component_schemas_route_through_validator( diff --git a/tests/component_tests/i2s_audio/__init__.py b/tests/component_tests/i2s_audio/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/i2s_audio/config/keep_alive.yaml b/tests/component_tests/i2s_audio/config/keep_alive.yaml new file mode 100644 index 00000000000..fd418bd4af5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml new file mode 100644 index 00000000000..2eecf2088fa --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO13 diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml new file mode 100644 index 00000000000..c95ff1abbc6 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + +microphone: + - platform: i2s_audio + i2s_din_pin: GPIO34 + adc_type: external + pdm: false diff --git a/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml new file mode 100644 index 00000000000..0e194bebef5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + spdif_mode: true + use_apll: true + sample_rate: 48000 + channel: stereo + keep_alive: true diff --git a/tests/component_tests/i2s_audio/test_config.py b/tests/component_tests/i2s_audio/test_config.py new file mode 100644 index 00000000000..bd5c3a598fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_config.py @@ -0,0 +1,36 @@ +"""Exercise the I2S speaker keep_alive validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_keep_alive_generates_setter( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("keep_alive.yaml")) + assert "set_keep_alive(true)" in source + + +@pytest.mark.parametrize( + ("config_file", "message"), + [ + ("keep_alive_shared_bus.yaml", "needs its own I2S bus"), + ("keep_alive_shared_mic.yaml", "needs its own I2S bus"), + ("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"), + ], +) +def test_keep_alive_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], + config_file: str, + message: str, +) -> None: + CORE.config_path = component_config_path(config_file) + assert read_config({}) is None + assert message in capsys.readouterr().out diff --git a/tests/component_tests/ld2450/__init__.py b/tests/component_tests/ld2450/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ld2450/config/esp8266_zones.yaml b/tests/component_tests/ld2450/config/esp8266_zones.yaml new file mode 100644 index 00000000000..19794626809 --- /dev/null +++ b/tests/component_tests/ld2450/config/esp8266_zones.yaml @@ -0,0 +1,31 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +uart: + tx_pin: GPIO0 + rx_pin: GPIO2 + baud_rate: 256000 + +ld2450: + id: radar + +text: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - id: couch + name: Couch + - id: desk + name: Desk + +binary_sensor: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - polygon_zone_id: couch + name: Couch Occupied + - polygon_zone_id: desk + name: Desk Occupied diff --git a/tests/component_tests/ld2450/test_polygon_pattern.py b/tests/component_tests/ld2450/test_polygon_pattern.py new file mode 100644 index 00000000000..4fb57ae6894 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_pattern.py @@ -0,0 +1,64 @@ +"""Tests for the polygon zone text pattern and maximum length. + +Home Assistant validates polygon input with the pattern before it reaches the device, so the pattern must accept +what Polygon::parse() accepts. Coordinate ranges are the one exception: only the device checks those. +""" + +from __future__ import annotations + +import re + +import pytest + +from esphome.components.ld2450.text import ( + MAX_POLYGON_POINTS, + POLYGON_MAX_LENGTH, + POLYGON_PATTERN, +) + +# The same inputs as the C++ parser tests (ld2450_polygon.cpp), without out-of-range values +ACCEPTED = [ + "", + " ", + "-1000,500;1000,500;0,3000", + " -1000 , 500 ; 1000,500; 0,3000; ", + "-4860,0;4860,0;4860,7560", +] +REJECTED = [ + "0,0", + "0,0;100,100", + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + "1,2;\t3,4;5,6", +] + + +@pytest.mark.parametrize("value", ACCEPTED) +def test_pattern_accepts_valid_polygons(value: str) -> None: + assert re.fullmatch(POLYGON_PATTERN, value) + + +@pytest.mark.parametrize("value", REJECTED) +def test_pattern_rejects_invalid_polygons(value: str) -> None: + assert not re.fullmatch(POLYGON_PATTERN, value) + + +def test_point_count_limits() -> None: + assert re.fullmatch(POLYGON_PATTERN, ";".join(["0,0"] * MAX_POLYGON_POINTS)) + assert not re.fullmatch( + POLYGON_PATTERN, ";".join(["0,0"] * (MAX_POLYGON_POINTS + 1)) + ) + + +def test_longest_polygon_fits_max_length() -> None: + longest = ";".join(["-4860,7560"] * MAX_POLYGON_POINTS) + assert re.fullmatch(POLYGON_PATTERN, longest) + assert len(longest) <= POLYGON_MAX_LENGTH + # MAX_POLYGON_POINTS is the most that always fits + assert len(longest + ";-4860,7560") > POLYGON_MAX_LENGTH diff --git a/tests/component_tests/ld2450/test_polygon_zone_presence.py b/tests/component_tests/ld2450/test_polygon_zone_presence.py new file mode 100644 index 00000000000..8ea8e3a67a0 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zone_presence.py @@ -0,0 +1,45 @@ +"""Tests for the ld2450 polygon zone presence binary sensor schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.binary_sensor import ( + CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_FILTERS, CONF_NAME, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _presence(**extra: object) -> ConfigType: + return { + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: "couch_zone", CONF_NAME: "Couch Occupied", **extra} + ] + } + + +def test_presence_without_filters_passes( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(_presence()) + + +def test_presence_with_filters_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Clearing a polygon bypasses filters, so they would get out of step with the zone.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone presence binary sensors can't have filters; .*presence_timeout", + ): + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)( + _presence(**{CONF_FILTERS: [{"delayed_off": "1s"}]}) + ) diff --git a/tests/component_tests/ld2450/test_polygon_zones.py b/tests/component_tests/ld2450/test_polygon_zones.py new file mode 100644 index 00000000000..f63d3336782 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zones.py @@ -0,0 +1,163 @@ +"""Tests for the ld2450 polygon zone validation. + +Polygon zones are declared in the text platform and their presence sensors in the +binary sensor platform, so the pairing between the two is checked at final validation. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.text import ( + CONFIG_SCHEMA as TEXT_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as TEXT_FINAL_VALIDATE_SCHEMA, + POLYGON_SAVED_BYTES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_NAME, CONF_PLATFORM, PlatformFramework +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld2450_hub" +OTHER_HUB_ID = "ld2450_other" + + +def _text_config(*zone_ids: str) -> ConfigType: + """A text platform config declaring the given polygon zones.""" + return { + "ld2450_id": ID(HUB_ID, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_ID: ID(zone_id, is_declaration=True), CONF_NAME: zone_id} + for zone_id in zone_ids + ], + } + + +def _binary_sensors(*zone_ids: str, hub_id: str = HUB_ID) -> list[ConfigType]: + """A binary sensor platform list with one presence sensor per given zone reference.""" + return [ + { + CONF_PLATFORM: "ld2450", + "ld2450_id": ID(hub_id, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: ID(zone_id, is_declaration=False)} + for zone_id in zone_ids + ], + } + ] + + +def test_zones_paired_with_one_presence_pass( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", "desk")}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_presence_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch")}, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'desk' has no presence binary sensor; .* @ data\['polygon_zones'\]\[1\]$", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_any_binary_sensor_platform_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={}) + + with pytest.raises(cv.Invalid, match="'couch' has no presence binary sensor"): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_with_two_presences_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Two references to one zone are rejected, even across binary sensor platform entries.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + "binary_sensor": _binary_sensors("couch") + _binary_sensors("couch") + }, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'couch' is used by more than one presence binary sensor; ", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_other_platforms_are_ignored( + set_core_config: SetCoreConfigCallable, +) -> None: + """Binary sensors of other platforms never count as presence for a zone.""" + other = {CONF_PLATFORM: "template", CONF_POLYGON_ZONES: []} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": [other, *_binary_sensors("couch")]}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_without_id_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The presence sensor refers to the zone by id, so an automatic id is no use.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=r"^'id' is required, so the zone's presence"): + TEXT_CONFIG_SCHEMA({CONF_POLYGON_ZONES: [{CONF_NAME: "Couch Zone Polygon"}]}) + + +def test_presence_on_another_ld2450_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The zone's own LD2450 drives its presence, so the sensor must name the same one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", hub_id=OTHER_HUB_ID)}, + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^Polygon zone 'couch' belongs to LD2450 'ld2450_hub', but its presence binary " + r"sensor is under LD2450 'ld2450_other'; .* @ data\['polygon_zones'\]\[0\]$" + ), + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_esp8266_zones_reserve_their_saved_space( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every zone reserves its polygon, so the ESP8266 can check that all saved values fit together.""" + generate_main(component_config_path("esp8266_zones.yaml")) + + words = preference_words(POLYGON_SAVED_BYTES) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(words, False)] * 2 diff --git a/tests/component_tests/lvgl/config/image.png b/tests/component_tests/lvgl/config/image.png new file mode 100644 index 00000000000..dec8dd15317 Binary files /dev/null and b/tests/component_tests/lvgl/config/image.png differ diff --git a/tests/component_tests/lvgl/config/list_outside_block_test.yaml b/tests/component_tests/lvgl/config/list_outside_block_test.yaml index 44407348999..58898c89b12 100644 --- a/tests/component_tests/lvgl/config/list_outside_block_test.yaml +++ b/tests/component_tests/lvgl/config/list_outside_block_test.yaml @@ -30,7 +30,7 @@ display: image: - platform: file - file: mdi:battery + file: image.png id: my_image resize: 8x8 type: binary diff --git a/tests/component_tests/lvgl/config/menu_test.yaml b/tests/component_tests/lvgl/config/menu_test.yaml new file mode 100644 index 00000000000..df0faeb1bc3 --- /dev/null +++ b/tests/component_tests/lvgl/config/menu_test.yaml @@ -0,0 +1,81 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + widgets: + - menu: + id: test_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: main_page + sidebar_page: nav_page + header_style: + bg_color: 0xFF0000 + sidebar_header_style: + bg_color: 0x00FF00 + on_root_back_button_click: + - logger.log: "root back clicked" + on_value: + - logger.log: "page changed" + pages: + - id: main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: advanced_page + - id: advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug" + - id: nav_page + title: "Navigate" + widgets: + - button: + id: nav_button + text: "Go" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: main_page + sidebar: true diff --git a/tests/component_tests/lvgl/test_lv_conf_path_flag.py b/tests/component_tests/lvgl/test_lv_conf_path_flag.py new file mode 100644 index 00000000000..e13a0bcc376 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_path_flag.py @@ -0,0 +1,39 @@ +"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host +build and the native ESP8266 build shell-lex build flags, while the ESP-IDF +and Zephyr builds write each flag into a quoted CMake argument; both must +decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the +project path contains a space.""" + +from __future__ import annotations + +from pathlib import Path +import re +import shlex + +import pytest + +from esphome.components.lvgl import lv_conf_path_flag + + +def _cmake_quoted_argument(flag: str) -> str: + """Decode ``flag`` as CMake decodes a quoted argument: a backslash escapes the next character.""" + return re.sub(r"\\(.)", r"\1", flag) + + +@pytest.mark.parametrize( + "build_path", + [ + "/home/user/.esphome/build/node", + "/Users/me/with space/.esphome/build/node", + "/srv/odd 'quote' $(and)/build", + "C:/Users/Kris Maddens/esphome/.esphome/build/node", + ], + ids=["plain", "space", "shell-specials", "windows"], +) +def test_flag_is_one_argument_everywhere(build_path: str) -> None: + lv_conf_h_file = Path(build_path) / "src" / "lv_conf.h" + flag = lv_conf_path_flag(lv_conf_h_file) + expected = f'-DLV_CONF_PATH="{lv_conf_h_file.as_posix()}"' + + assert shlex.split(flag) == [expected] + assert _cmake_quoted_argument(flag) == expected diff --git a/tests/component_tests/lvgl/test_menu.py b/tests/component_tests/lvgl/test_menu.py new file mode 100644 index 00000000000..b496075e934 --- /dev/null +++ b/tests/component_tests/lvgl/test_menu.py @@ -0,0 +1,410 @@ +"""Tests for the LVGL ``menu`` widget: schema validation for the ``menu``/ +``menu_page``/``menu_section`` widget types and the ``lvgl.menu.set_page`` +action, and the code they generate. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.widgets.menu import MENU_PAGE_SCHEMA, MENU_SCHEMA +from esphome.config import load_config, read_config +import esphome.config_validation as cv +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# The menu widget's own schema +# --------------------------------------------------------------------------- + + +class TestMenuSchema: + def test_pages_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['pages'\]" + ): + MENU_SCHEMA({"root_page": "main"}) + + def test_root_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['root_page'\]" + ): + MENU_SCHEMA({"pages": [{"id": "main"}]}) + + def test_sidebar_page_optional(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert "sidebar_page" not in config + + def test_header_mode_and_root_back_btn_defaults(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert config["header_mode"] == "LV_MENU_HEADER_TOP_FIXED" + assert config["root_back_btn"] == "LV_MENU_ROOT_BACK_BUTTON_DISABLED" + + def test_header_mode_accepts_bare_choice(self) -> None: + config = MENU_SCHEMA( + { + "pages": [{"id": "main"}], + "root_page": "main", + "header_mode": "bottom_fixed", + } + ) + assert config["header_mode"] == "LV_MENU_HEADER_BOTTOM_FIXED" + + +# --------------------------------------------------------------------------- +# A menu page's own schema (title optional, sections optional) +# --------------------------------------------------------------------------- + + +class TestMenuPageSchema: + def test_empty_page_is_valid(self) -> None: + config = MENU_PAGE_SCHEMA({}) + assert "title" not in config + assert "sections" not in config + + def test_title_optional(self) -> None: + config = MENU_PAGE_SCHEMA({"title": "Settings"}) + assert config["title"] == "Settings" + + def test_section_id_is_optional(self) -> None: + """cv.GenerateID() means a section's id: is optional, unlike a page's.""" + config = MENU_PAGE_SCHEMA({"sections": [{}]}) + assert config["sections"][0]["id"].is_declaration + + +# --------------------------------------------------------------------------- +# lvgl.menu.set_page action schema +# --------------------------------------------------------------------------- + + +class TestMenuSetPageActionSchema: + @property + def schema(self): + return ACTION_REGISTRY["lvgl.menu.set_page"].raw_schema + + def test_id_and_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['page'\]" + ): + self.schema({"id": "my_menu"}) + + def test_sidebar_defaults_false(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page"}) + assert config["sidebar"] is False + + def test_sidebar_can_be_set_true(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page", "sidebar": True}) + assert config["sidebar"] is True + + +# --------------------------------------------------------------------------- +# Pages belong to exactly one menu +# --------------------------------------------------------------------------- + +# Boilerplate around the widgets under test: an LVGL instance needs a display. +MENU_CONFIG_TEMPLATE = """ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: +{widgets} +""" + + +def _config_errors(tmp_path: Path, widgets: str) -> list[str]: + """Validate a config built from the given `widgets:` block and return the + validation errors as text.""" + config_path = tmp_path / "menu.yaml" + config_path.write_text(MENU_CONFIG_TEMPLATE.format(widgets=widgets)) + original_path = CORE.config_path + try: + CORE.config_path = config_path + return [str(err) for err in load_config({}).errors] + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_root_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_empty_pages_list_is_rejected(tmp_path: Path) -> None: + """Without this, a menu with no pages of its own escapes the page-owner check.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: [] + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("length of value must be at least 1" in err for err in errors) + + +def test_sidebar_header_style_without_sidebar_page_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_header_style: + bg_color: 0x101010 + pages: + - id: menu_a_page +""", + ) + assert any( + "'sidebar_header_style' requires 'sidebar_page'" in err for err in errors + ) + + +def test_root_back_button_click_needs_root_back_button_enabled(tmp_path: Path) -> None: + """With the default `root_back_btn: disabled` the button never appears, so the + trigger could never fire.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + on_root_back_button_click: + - logger.log: "never" + pages: + - id: menu_a_page +""", + ) + assert any( + "'on_root_back_button_click' requires 'root_back_btn: enabled'" in err + for err in errors + ) + + +def test_sidebar_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_set_page_action_with_a_page_of_another_menu_is_rejected( + tmp_path: Path, +) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + pages: + - id: menu_a_page + widgets: + - button: + text: "Go" + on_click: + - lvgl.menu.set_page: + id: menu_a + page: menu_b_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any( + "page 'menu_b_page' is not a page of menu 'menu_a'" in err for err in errors + ) + + +def test_section_id_is_not_accepted_where_a_page_is_required(tmp_path: Path) -> None: + """The page and section tag types are distinct, so a section id can't be + passed off as a page.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_section + pages: + - id: menu_a_page + sections: + - id: menu_a_section +""", + ) + assert any( + "ID 'menu_a_section' of type lv_menu_section_t doesn't inherit from " + "lv_menu_page_t" in err + for err in errors + ) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared menu-widget YAML config once per + module -- see test_widget_state.py for why this is module-scoped and + inlines the generate_main fixture logic rather than depending on it. + """ + config_path = Path(request.fspath).parent / "config" / "menu_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_menu_created_with_header_and_back_button_modes(main_cpp: str) -> None: + assert "test_menu = lv_menu_create(lvgl_id->get_screen_active());" in main_cpp + assert "lv_menu_set_mode_header(test_menu, LV_MENU_HEADER_TOP_FIXED);" in main_cpp + assert ( + "lv_menu_set_mode_root_back_button(test_menu, LV_MENU_ROOT_BACK_BUTTON_ENABLED);" + in main_cpp + ) + + +def test_page_created_with_title(main_cpp: str) -> None: + assert 'main_page = lv_menu_page_create(test_menu, "Settings");' in main_cpp + assert 'advanced_page = lv_menu_page_create(test_menu, "Advanced");' in main_cpp + + +def test_section_created_under_its_page_not_the_menu(main_cpp: str) -> None: + assert "wifi_section = lv_menu_section_create(main_page);" in main_cpp + assert "wifi_switch = lv_switch_create(wifi_section);" in main_cpp + + +def test_ungrouped_widget_created_directly_under_page(main_cpp: str) -> None: + assert "advanced_button = lv_button_create(main_page);" in main_cpp + + +def test_sections_created_before_ungrouped_widgets(main_cpp: str) -> None: + assert main_cpp.index("wifi_section = lv_menu_section_create(main_page);") < ( + main_cpp.index("advanced_button = lv_button_create(main_page);") + ) + + +def test_root_page_and_sidebar_page_set_at_boot(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, main_page);" in main_cpp + assert "lv_menu_set_sidebar_page(test_menu, nav_page);" in main_cpp + + +def test_header_style_applied_via_main_header_accessor(main_cpp: str) -> None: + assert ( + "lv_obj_t *menu_header_VAR_ = lv_menu_get_main_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_header_VAR_, lv_color_make(255, 0, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_sidebar_header_style_applied_via_sidebar_header_accessor( + main_cpp: str, +) -> None: + assert ( + "lv_obj_t *menu_sidebar_header_VAR_ = lv_menu_get_sidebar_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_sidebar_header_VAR_, lv_color_make(0, 255, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_root_back_button_click_guarded_by_helper(main_cpp: str) -> None: + """The on_root_back_button_click trigger must only fire when the guard + helper (which wraps lv_menu_back_button_is_root) returns true. + """ + assert ( + "lv_obj_add_event_cb(test_menu, [](lv_event_t * event) -> void {" + ) in main_cpp + assert ( + "if (lvgl::lv_menu_back_button_is_root_click(test_menu, event)) {\n" + " trigger_id->trigger();\n" + " }" + ) in main_cpp + assert "}, LV_EVENT_CLICKED, nullptr);" in main_cpp + + +def test_set_page_action_targets_main_by_default(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, advanced_page);" in main_cpp + + +def test_set_page_action_targets_sidebar_when_flagged(main_cpp: str) -> None: + assert "lv_menu_set_sidebar_page(test_menu, main_page);" in main_cpp + + +def test_on_value_passes_current_main_page(main_cpp: str) -> None: + callback = re.search( + r"add_event_cb\(test_menu, (.*?)\}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr\);", + main_cpp, + re.DOTALL, + ) + assert callback is not None + assert "->trigger(lv_menu_get_cur_main_page(test_menu), event);" in callback[1] diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py index eacf722f177..ecd3cdeb92a 100644 --- a/tests/component_tests/mdns/test_service_enable_disable.py +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -38,6 +38,19 @@ def test_esp32_adds_define_and_keeps_services_stored( assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() +def test_esp32_border_router_adds_define( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_config( + set_core_config, + PlatformFramework.ESP32_IDF, + {"mdns": {CONF_DISABLED: False}, "openthread": {"border_router": {}}}, + ) + + assert mdns.request_service_enable_disable() is True + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + @pytest.mark.parametrize( "platform_framework", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index e677577ec08..58e7ba9d959 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -93,12 +93,12 @@ def test_transform_matches_model_support( from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS - # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays # never support axis swapping). - model = MODELS["ESP32-8048S070"] + model = MODELS["ST7701S"] assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} - base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21} CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) # An unsupported axis may be explicitly disabled (a harmless no-op)... @@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) +def test_pure_rgb_model_has_no_hardware_transform( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model with no init sequence has no command interface, so no transform + can be applied in hardware and every rotation must be done in software. + + Previously such models claimed mirror_x/mirror_y support, so a rotation of + 180 was handed to the (nonexistent) hardware and the display was not rotated. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["ESP32-8048S070"] + assert model.initsequence == () + assert model.transforms == set() + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + for rotation in (90, 180, 270): + config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation}) + assert not model.rotation_as_transform(config) + metadata = get_display_metadata(config["id"]) + assert metadata.rotation == rotation + assert not metadata.has_hardware_rotation + + with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + def test_metadata_records_rotation( set_core_config: SetCoreConfigCallable, ) -> None: diff --git a/tests/component_tests/network/config/high_perf_wifi_only.yaml b/tests/component_tests/network/config/high_perf_wifi_only.yaml new file mode 100644 index 00000000000..61dfde3e039 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/network/config/priority_ethernet_first_api.yaml b/tests/component_tests/network/config/priority_ethernet_first_api.yaml new file mode 100644 index 00000000000..31ff65b5bc2 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first_api.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/config/priority_rp2040_api.yaml b/tests/component_tests/network/config/priority_rp2040_api.yaml new file mode 100644 index 00000000000..a74442032da --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040_api.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/test_active_interface.py b/tests/component_tests/network/test_active_interface.py new file mode 100644 index 00000000000..2509f24ffb6 --- /dev/null +++ b/tests/component_tests/network/test_active_interface.py @@ -0,0 +1,52 @@ +"""mDNS follows the interface holding the default route through an +ActiveInterfaceListener, only where NetworkComponent tracks that interface +(ESP32 with more than one interface) and the network TXT record exists.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT" +LISTENER_DEFINES = { + "USE_MDNS_ACTIVE_INTERFACE_LISTENER", + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT", +} + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml")) + + assert "add_active_interface_listener(" in main_cpp + assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE} + + +@pytest.mark.parametrize( + ("fixture", "dynamic_txt"), + [ + # No api means no _esphomelib service, so no network TXT record + pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"), + # No route arbitration on RP2; mDNS keeps its periodic check there + pytest.param("priority_rp2040_api.yaml", True, id="rp2040"), + ], +) +def test_mdns_does_not_register( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + dynamic_txt: bool, +) -> None: + main_cpp = generate_main(component_config_path(fixture)) + + assert "add_active_interface_listener(" not in main_cpp + assert not LISTENER_DEFINES & _defines() + assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt diff --git a/tests/component_tests/network/test_high_performance.py b/tests/component_tests/network/test_high_performance.py index c291806ac4b..8452a56a057 100644 --- a/tests/component_tests/network/test_high_performance.py +++ b/tests/component_tests/network/test_high_performance.py @@ -9,7 +9,10 @@ from pathlib import Path import pytest -from esphome.components.network import require_high_performance_networking +from esphome.components.network import ( + require_high_performance_networking, + require_high_performance_wifi, +) from tests.component_tests.network import sdkconfig_option @@ -52,3 +55,15 @@ def test_lwip_tier( assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale # Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers + + +def test_wifi_only_request_leaves_lwip_alone( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A WiFi-only request sizes the driver buffers but not the TCP windows.""" + require_high_performance_wifi() + generate_main(component_config_path("high_perf_wifi_only.yaml")) + assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") is None + assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == 64 + assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == 64 diff --git a/tests/component_tests/openthread/config/border_router.yaml b/tests/component_tests/openthread/config/border_router.yaml new file mode 100644 index 00000000000..04e033f55ab --- /dev/null +++ b/tests/component_tests/openthread/config/border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: openthread-border-router + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/config/border_router_rcp.yaml b/tests/component_tests/openthread/config/border_router_rcp.yaml new file mode 100644 index 00000000000..26204f9719b --- /dev/null +++ b/tests/component_tests/openthread/config/border_router_rcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: openthread-border-router-rcp + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index c0e155ec7e3..96aeb92ffaa 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -15,7 +15,7 @@ from esphome.components.esp32 import ( from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX import esphome.config_validation as cv -from esphome.const import PlatformFramework, Toolchain +from esphome.const import KEY_FRAMEWORK_VERSION, PlatformFramework, Toolchain from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -42,7 +42,9 @@ def test_credentials_are_sensitive( """Thread credentials are tagged for redaction without changing their value or the hexadecimal integer type used to build sdkconfig options.""" set_core_config( - PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: VARIANT_ESP32C6}, ) validated = CONFIG_SCHEMA(config) assert isinstance(validated[field], Sensitive) diff --git a/tests/component_tests/openthread/test_openthread.py b/tests/component_tests/openthread/test_openthread.py new file mode 100644 index 00000000000..11b6854b5a9 --- /dev/null +++ b/tests/component_tests/openthread/test_openthread.py @@ -0,0 +1,324 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.const import CONF_BORDER_ROUTER +from esphome.components.esp32 import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32S3, +) +from esphome.components.esp32.const import ( + KEY_BOARD, + KEY_ESP32, + KEY_IDF_VERSION, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) +from esphome.components.openthread import ( + _RCP_SCHEMA, + BORDER_ROUTER_SDKCONFIG_OPTIONS, + RCP_BORDER_ROUTER_VARIANTS, + _final_validate, + _validate, + _validate_platform, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_RCP +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, + CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_INVERTED, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, + CONF_OPENTHREAD, + CONF_RX_PIN, + CONF_TX_PIN, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_FRAMEWORK_VERSION, + PlatformFramework, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG_DIR = Path(__file__).parent / "config" +CONF_NETWORK = "network" +DEVICE_TYPE_FTD = "FTD" +DEVICE_TYPE_MTD = "MTD" +RCP_PINS = {CONF_RX_PIN: 18, CONF_TX_PIN: 17} + + +@pytest.fixture +def otbr_config() -> ConfigType: + return { + CONF_ID: "openthread_component", + CONF_BORDER_ROUTER: {}, + CONF_DEVICE_TYPE: DEVICE_TYPE_FTD, + } + + +@pytest.fixture +def full_config(otbr_config: ConfigType) -> ConfigType: + return { + CONF_OPENTHREAD: otbr_config, + CONF_NETWORK: {CONF_ENABLE_IPV6: True}, + CONF_WIFI: { + CONF_ENABLE_ON_BOOT: True, + CONF_NETWORKS: [{"ssid": "test"}], + }, + } + + +def _set_esp32_idf_core( + set_core_config: SetCoreConfigCallable, + variant: str = VARIANT_ESP32C6, + idf_version: cv.Version | None = None, +) -> None: + if idf_version is None: + idf_version = cv.Version(5, 5, 5) + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: idf_version}, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_IDF_VERSION: idf_version, + KEY_VARIANT: variant, + KEY_SDKCONFIG_OPTIONS: {}, + }, + ) + + +def _run_final_validation( + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + token = fv.full_config.set(full_config) + try: + _final_validate(otbr_config) + finally: + fv.full_config.reset(token) + + +def test_plain_openthread_uses_the_srp_client( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "no_custom_header.yaml") + + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_SRP" in defines + assert "USE_OPENTHREAD_BORDER_ROUTER" not in defines + assert ( + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_OPENTHREAD_SRP_CLIENT"] + is True + ) + + +def test_border_router_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "border_router.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + for option, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + assert sdkconfig[option] == value + assert sdkconfig["CONFIG_SW_COEXIST_ENABLE"] is True + assert sdkconfig["CONFIG_OPENTHREAD_SRP_CLIENT"] is False + assert sdkconfig["CONFIG_OPENTHREAD_DNS64_CLIENT"] is False + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_MASTERKEY"] + == "00112233445566778899aabbccddeeff" + ) + assert sdkconfig["CONFIG_OPENTHREAD_NETWORK_EXTPANID"] == "00000000000000ab" + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_PSKC"] + == "000000000000000000000000000000cd" + ) + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_BORDER_ROUTER" in defines + assert "USE_OPENTHREAD_SRP" not in defines + + +def test_border_router_rcp_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + cpp_main = generate_main(CONFIG_DIR / "border_router_rcp.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_NATIVE"] is False + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_SPINEL_UART"] is True + assert sdkconfig["CONFIG_VFS_SUPPORT_DIR"] is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is not True + assert any(define.name == "USE_OPENTHREAD_RCP_UART" for define in CORE.defines) + assert "OpenThreadComponent(460800, 18, 17, nullptr)" in cpp_main + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda config, _: config.pop(CONF_WIFI), + "requires a Wi-Fi STA backbone", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_NETWORKS: []}), + "requires at least one configured", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_ENABLE_ON_BOOT: False}), + "enable_on_boot: true", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_AP: {}}), + "does not support Wi-Fi AP", + ), + ( + lambda config, _: config[CONF_NETWORK].update({CONF_ENABLE_IPV6: False}), + "requires IPv6 to be enabled", + ), + ], +) +def test_border_router_rejects_incompatible_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + mutate: Any, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config) + mutate(full_config, otbr_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +def test_border_router_requires_ftd( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config) + otbr_config[CONF_DEVICE_TYPE] = DEVICE_TYPE_MTD + otbr_config[CONF_USE_ADDRESS] = "test.local" + + with pytest.raises(cv.Invalid, match="device_type: FTD"): + _validate(otbr_config) + + +def test_native_border_router_rejects_variant_without_ieee802154( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid, match="both Wi-Fi and IEEE 802.15.4"): + _validate_platform(otbr_config) + + +def test_requires_idf_5_5_5(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_idf_core(set_core_config, idf_version=cv.Version(5, 5, 2)) + + with pytest.raises(cv.Invalid, match="at least framework version 5.5.5"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +def test_rejects_arduino(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="incompatible with ESP32 using arduino"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +@pytest.mark.parametrize("variant", sorted(RCP_BORDER_ROUTER_VARIANTS)) +def test_border_router_rcp_accepts_wifi_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + assert _validate_platform(otbr_config) is otbr_config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32H2, VARIANT_ESP32H4]) +def test_border_router_rcp_rejects_variants_without_wifi( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + with pytest.raises(cv.Invalid, match="requires a Wi-Fi-capable ESP32 variant"): + _validate_platform(otbr_config) + + +@pytest.mark.parametrize( + ("extra_config", "message"), + [ + ({"uart": [{}]}, "cannot currently be combined"), + ( + {CONF_LOGGER: {"hardware_uart": "UART1", "baud_rate": 115200}}, + "reserves UART1", + ), + ], +) +def test_border_router_rcp_rejects_uart_conflicts( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + extra_config: ConfigType, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + full_config.update(extra_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +@pytest.mark.parametrize( + "rcp_config", + [ + {**RCP_PINS, CONF_BAUD_RATE: 0}, + {**RCP_PINS, CONF_BAUD_RATE: 9599}, + {CONF_RX_PIN: {CONF_NUMBER: 18, CONF_INVERTED: True}, CONF_TX_PIN: 17}, + ], +) +def test_rcp_schema_rejects_bad_options( + set_core_config: SetCoreConfigCallable, rcp_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid): + _RCP_SCHEMA(rcp_config) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C5, VARIANT_ESP32C6]) +def test_native_border_router_accepts_wifi_ieee802154_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + + assert _validate_platform(otbr_config) is otbr_config + + +def test_border_router_accepts_supported_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + _set_esp32_idf_core(set_core_config) + + _run_final_validation(otbr_config, full_config) diff --git a/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml new file mode 100644 index 00000000000..31e2818d936 --- /dev/null +++ b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: heater + name: Heater + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle diff --git a/tests/component_tests/thermostat/config/dry_only.yaml b/tests/component_tests/thermostat/config/dry_only.yaml new file mode 100644 index 00000000000..f4dd0d6ca3b --- /dev/null +++ b/tests/component_tests/thermostat/config/dry_only.yaml @@ -0,0 +1,30 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: dehumidifier + name: Dehumidifier + sensor: temp_sensor + min_idle_time: 0s + min_cooling_off_time: 0s + min_cooling_run_time: 0s + min_fanning_off_time: 0s + min_fanning_run_time: 0s + idle_action: + - logger.log: idle + fan_only_action: + - logger.log: fan only + dry_action: + - logger.log: dry diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py new file mode 100644 index 00000000000..53be07d1f72 --- /dev/null +++ b/tests/component_tests/thermostat/test_validation.py @@ -0,0 +1,36 @@ +"""Tests for the thermostat config validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_dry_only_config_is_valid( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers.""" + main_cpp = generate_main(component_config_path("dry_only.yaml")) + + assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_supports_dry(true);" in main_cpp + assert "dehumidifier->set_supports_cool(" not in main_cpp + + +def test_cooling_timer_needs_cool_or_dry_action( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """A cooling timer with neither cool_action nor dry_action is still rejected.""" + CORE.config_path = component_config_path("cooling_timer_without_action.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "cool_action or dry_action must be defined to use min_cooling_off_time" + in captured.out + ) diff --git a/tests/component_tests/tmp102/config/duplicate_status.yaml b/tests/component_tests/tmp102/config/duplicate_status.yaml new file mode 100644 index 00000000000..d67c61be41b --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_status.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: parent_sensor + name: Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: parent_sensor + name: First Status + - platform: tmp102 + tmp102_id: parent_sensor + name: Second Status diff --git a/tests/component_tests/tmp102/config/status.yaml b/tests/component_tests/tmp102/config/status.yaml new file mode 100644 index 00000000000..e2f2318da4f --- /dev/null +++ b/tests/component_tests/tmp102/config/status.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: parent_sensor + name: Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: parent_sensor + name: Threshold Status diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index b933db1d9bc..036dee4b740 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -58,6 +58,30 @@ def test_duplicate_alert_entities_are_rejected( ) +def test_status_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("status.yaml")) + assert "set_threshold_status_text_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_TEXT_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_status_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_status.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 text_sensor may reference each temperature sensor" + in capsys.readouterr().out + ) + + def test_threshold_numbers_enable_configuration_once( generate_main: Callable[[Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/apc_proteous/common.h b/tests/components/apc_proteous/common.h new file mode 100644 index 00000000000..a3baa5131de --- /dev/null +++ b/tests/components/apc_proteous/common.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/apc_proteous/apc_proteous_cover.h" + +namespace esphome::apc_proteous::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(const char *data) { this->rx.insert(this->rx.end(), data, data + strlen(data)); } + std::string tx_string() const { return std::string(this->tx.begin(), this->tx.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +class TestableAPCProteousCover : public APCProteousCover { + public: + using APCProteousCover::COMMAND_ACK_MS; + using APCProteousCover::COMMAND_QUIET_MS; + using APCProteousCover::MAX_COMMAND_RETRIES; + using APCProteousCover::MAX_RESPONSE_LEN; + using APCProteousCover::command_retries_; + using APCProteousCover::last_command_tx_; + using APCProteousCover::pending_command_; + using APCProteousCover::pending_command_time_; + using APCProteousCover::rx_len_; + using APCProteousCover::target_position_; +}; + +} // namespace esphome::apc_proteous::testing diff --git a/tests/components/apc_proteous/common.yaml b/tests/components/apc_proteous/common.yaml new file mode 100644 index 00000000000..1e1217c04ca --- /dev/null +++ b/tests/components/apc_proteous/common.yaml @@ -0,0 +1,5 @@ +cover: + - platform: apc_proteous + id: apc_proteous_gate + name: Gate + device_class: gate diff --git a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp new file mode 100644 index 00000000000..566701338b3 --- /dev/null +++ b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp @@ -0,0 +1,415 @@ +#include + +#include "esphome/core/application.h" + +#include "../common.h" + +namespace esphome::apc_proteous::testing { + +using cover::COVER_CLOSED; +using cover::COVER_OPEN; +using cover::COVER_OPERATION_CLOSING; +using cover::COVER_OPERATION_IDLE; +using cover::COVER_OPERATION_OPENING; + +namespace { +class APCProteousCoverTest : public ::testing::Test { + protected: + void SetUp() override { + this->cover_.set_uart_parent(&this->uart_); + this->cover_.setup(); + // setup() may restore a saved position, so start every test from a known state. + this->cover_.position = 0.5f; + this->cover_.current_operation = COVER_OPERATION_IDLE; + this->cover_.add_on_state_callback([this]() { this->publishes_++; }); + this->end_quiet_period_(); + } + + void feed_(const char *data) { + this->uart_.push_rx(data); + this->cover_.loop(); + } + + void end_quiet_period_() { + this->cover_.last_command_tx_ = + App.get_loop_component_start_time() - TestableAPCProteousCover::COMMAND_QUIET_MS - 1; + } + + // Pretend the pending command was sent long enough ago for a retry to be due. + void expire_ack_window_() { + this->cover_.pending_command_time_ = + App.get_loop_component_start_time() - TestableAPCProteousCover::COMMAND_ACK_MS - 1; + this->end_quiet_period_(); + } + + void send_position_(float position) { this->cover_.make_call().set_position(position).perform(); } + + MockUARTComponent uart_; + TestableAPCProteousCover cover_; + int publishes_{0}; +}; +} // namespace + +TEST_F(APCProteousCoverTest, TraitsSupportPositionStopAndToggle) { + auto traits = this->cover_.get_traits(); + EXPECT_TRUE(traits.get_supports_position()); + EXPECT_TRUE(traits.get_supports_stop()); + EXPECT_TRUE(traits.get_supports_toggle()); + EXPECT_FALSE(traits.get_is_assumed_state()); +} + +// s-status bit 0 is "operating", bit 1 is the direction (set when closing). +TEST_F(APCProteousCoverTest, SStatusSetsOperation) { + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); + EXPECT_EQ(this->publishes_, 1); + + this->feed_("?s=03\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_CLOSING); + EXPECT_EQ(this->publishes_, 2); + + this->feed_("?s=02\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 3); +} + +TEST_F(APCProteousCoverTest, UnchangedStatusDoesNotPublish) { + this->feed_("?s=00\r"); + EXPECT_EQ(this->publishes_, 0); + this->feed_("?x=32\r"); + EXPECT_EQ(this->publishes_, 0); +} + +// x-status is the position in percent, sent as hex. +TEST_F(APCProteousCoverTest, XStatusSetsPosition) { + this->feed_("?x=19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); + EXPECT_EQ(this->publishes_, 1); + + this->feed_("?x=4B\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.75f); + EXPECT_EQ(this->publishes_, 2); +} + +TEST_F(APCProteousCoverTest, XStatusNearEndsSnapsToEndpoints) { + this->feed_("?x=62\r"); // 98% + EXPECT_FLOAT_EQ(this->cover_.position, COVER_OPEN); + this->feed_("?x=02\r"); // 2% + EXPECT_FLOAT_EQ(this->cover_.position, COVER_CLOSED); + this->feed_("?x=03\r"); // 3% is outside the margin + EXPECT_FLOAT_EQ(this->cover_.position, 0.03f); + this->feed_("?x=61\r"); // 97% is outside the margin + EXPECT_FLOAT_EQ(this->cover_.position, 0.97f); +} + +TEST_F(APCProteousCoverTest, MalformedFramesAreIgnored) { + this->feed_("?x\r"); // too short + this->feed_("!x=10\r"); // wrong start character + this->feed_("?x:10\r"); // missing '=' + this->feed_("?x=zz\r"); // not hex + this->feed_("?q=10\r"); // unknown type + EXPECT_FLOAT_EQ(this->cover_.position, 0.5f); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 0); + EXPECT_EQ(this->cover_.rx_len_, 0); +} + +TEST_F(APCProteousCoverTest, LineFeedsAreIgnored) { + this->feed_("\n?x=\n19\r\n"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); +} + +TEST_F(APCProteousCoverTest, FrameSplitAcrossReadsIsAssembled) { + this->feed_("?x="); + EXPECT_EQ(this->publishes_, 0); + this->feed_("19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); +} + +// Noise without a carriage return must not grow the buffer without limit, nor stop the +// next frame from being read. +TEST_F(APCProteousCoverTest, UnterminatedNoiseIsDiscarded) { + this->feed_("0123456789ABCDEF"); + this->feed_("?x=19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); + + this->feed_("0123456789ABCDEF0123456789ABCDEF"); + EXPECT_LE(this->cover_.rx_len_, TestableAPCProteousCover::MAX_RESPONSE_LEN); +} + +TEST_F(APCProteousCoverTest, OpenSendsOpenCommand) { + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); + EXPECT_NE(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, CloseSendsCloseCommand) { + this->cover_.make_call().set_command_close().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + EXPECT_NE(this->cover_.pending_command_, nullptr); +} + +// The published state only changes once the controller reports it. +TEST_F(APCProteousCoverTest, CommandDoesNotPublishState) { + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 0); +} + +TEST_F(APCProteousCoverTest, OpenIsNotResentWhileAlreadyOpening) { + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_open().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, CloseIsNotResentWhileAlreadyClosing) { + this->feed_("?s=03\r"); + this->cover_.make_call().set_command_close().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// An open command reverses a closing gate. +TEST_F(APCProteousCoverTest, OpenIsSentWhileClosing) { + this->feed_("?s=03\r"); + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); +} + +// The stop command toggles start/stop, so sending it to an idle gate would start it. +TEST_F(APCProteousCoverTest, StopIsIgnoredWhenIdle) { + this->cover_.make_call().set_command_stop().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenMoving) { + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// A stop straight after an open must cancel it, even before the controller reports motion. +TEST_F(APCProteousCoverTest, StopCancelsUnacknowledgedCommand) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, StopClearsPartialTarget) { + this->send_position_(0.8f); + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +TEST_F(APCProteousCoverTest, ToggleSendsStartCommand) { + this->cover_.make_call().set_command_toggle().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, ToggleClearsPendingCommandAndTarget) { + this->send_position_(0.8f); + this->uart_.tx.clear(); + this->cover_.make_call().set_command_toggle().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +TEST_F(APCProteousCoverTest, PartialPositionAboveCurrentOpens) { + this->send_position_(0.8f); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); +} + +TEST_F(APCProteousCoverTest, PartialPositionBelowCurrentCloses) { + this->send_position_(0.2f); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); +} + +TEST_F(APCProteousCoverTest, PartialPositionAtCurrentSendsNothing) { + this->send_position_(0.5f); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenOpeningReachesTarget) { + this->send_position_(0.7f); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + + this->feed_("?x=3C\r"); // 60%, short of the target + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_("?x=46\r"); // 70% + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + + // The gate keeps moving until the controller reports it idle; the stop is not repeated. + this->uart_.tx.clear(); + this->feed_("?x=48\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenClosingReachesTarget) { + this->send_position_(0.3f); + this->feed_("?s=03\r"); + this->uart_.tx.clear(); + + this->feed_("?x=28\r"); // 40%, short of the target + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_("?x=1C\r"); // 28%, past the target + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// A full open runs to the limit switch, so the position must not trigger a stop. +TEST_F(APCProteousCoverTest, FullOpenIsNotStoppedByPosition) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + this->feed_("?x=50\r"); + this->feed_("?x=64\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// A full open replaces an earlier partial target. +TEST_F(APCProteousCoverTest, FullOpenCancelsPartialTarget) { + this->send_position_(0.7f); + this->cover_.make_call().set_command_open().perform(); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + this->feed_("?x=46\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, ReportedMotionAcknowledgesCommand) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?s=03\r"); // motion in the wrong direction does not count + EXPECT_NE(this->cover_.pending_command_, nullptr); + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, CommandIsNotRetriedBeforeAckTimeout) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, UnacknowledgedCommandIsRetried) { + this->cover_.make_call().set_command_close().perform(); + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + EXPECT_EQ(this->cover_.command_retries_, 1); +} + +TEST_F(APCProteousCoverTest, RetriesStopAfterLimit) { + this->cover_.make_call().set_command_open().perform(); + for (int i = 0; i < TestableAPCProteousCover::MAX_COMMAND_RETRIES; i++) { + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); + } + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, PartialTargetIsClearedWhenRetriesRunOut) { + this->send_position_(0.8f); + for (int i = 0; i <= TestableAPCProteousCover::MAX_COMMAND_RETRIES; i++) { + this->expire_ack_window_(); + this->cover_.update(); + } + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +// While a reversing command is unconfirmed, the reported direction is still the old one and must +// not be compared with the target. +TEST_F(APCProteousCoverTest, ReversingPartialMoveWaitsForConfirmedDirection) { + this->feed_("?s=01\r"); + this->send_position_(0.3f); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + this->uart_.tx.clear(); + + this->feed_("?x=28\r"); // 40%, still reported as opening + EXPECT_TRUE(this->uart_.tx.empty()); + + this->feed_("?s=03\r"); + this->feed_("?x=1C\r"); // 28%, past the target + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// The gate reports idle right after a command, before it starts moving; that must not drop the target. +TEST_F(APCProteousCoverTest, PartialTargetIsKeptWhileCommandIsUnconfirmed) { + this->send_position_(0.8f); + this->feed_("?s=00\r"); + EXPECT_TRUE(this->cover_.target_position_.has_value()); +} + +// A partial move that stops short of its target must not stop a later move started from the remote. +TEST_F(APCProteousCoverTest, PartialTargetIsClearedWhenConfirmedMoveStops) { + this->send_position_(0.8f); + this->feed_("?s=01\r"); + this->feed_("?s=00\r"); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + + this->uart_.tx.clear(); + this->feed_("?s=01\r"); + this->feed_("?x=55\r"); // 85% + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, CommandIsNotRetriedOnceAtEndpoint) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?x=64\r"); + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, UpdateAlternatesQueries) { + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?x\r"); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); +} + +TEST_F(APCProteousCoverTest, UpdateHoldsOffPollingAfterCommand) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->end_quiet_period_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); +} + +// A command echo left without a terminator must not merge with the next response. +TEST_F(APCProteousCoverTest, UpdateDiscardsStaleEcho) { + this->feed_("*6"); + EXPECT_NE(this->cover_.rx_len_, 0); + this->cover_.update(); + EXPECT_EQ(this->cover_.rx_len_, 0); + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); +} + +} // namespace esphome::apc_proteous::testing diff --git a/tests/components/apc_proteous/test.esp32-idf.yaml b/tests/components/apc_proteous/test.esp32-idf.yaml new file mode 100644 index 00000000000..954a2def240 --- /dev/null +++ b/tests/components/apc_proteous/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc_proteous: !include common.yaml diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml new file mode 100644 index 00000000000..48f77ac907e --- /dev/null +++ b/tests/components/as734x/common.yaml @@ -0,0 +1,87 @@ +as734x: + - id: as7341_hub + type: AS7341 + i2c_id: i2c_bus + update_interval: 15s + gain: X8 + atime: 120 + astep: 99 + normalize_basic_counts: BANDS + calibration: + channel_correction: [1.0, 1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09] + dark_current: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] + glass_attenuation_factor: 1.2 + - id: as7343_hub + type: AS7343 + i2c_id: i2c_bus + address: 0x3A + gain: X512 + normalize_basic_counts: CLEAR + - id: tcs3448_hub + type: TCS3448 + i2c_id: i2c_bus + gain: X2048 + calibration: + dark_current: + [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] + +sensor: + - platform: as734x + as734x_id: as7341_hub + counts: + f1: + name: AS7341 F1 + nir: + name: AS7341 NIR + clear: + name: AS7341 Clear + basic_counts: + f1: + name: AS7341 F1 basic + clear: + name: AS7341 Clear basic + saturation_level: + name: AS7341 Saturation + illuminance: + name: AS7341 Illuminance + irradiance_photopic: + name: AS7341 Photopic irradiance + irradiance_par: + name: AS7341 PAR + ppfd: + name: AS7341 PPFD + color_temperature: + name: AS7341 Colour temperature + - platform: as734x + as734x_id: as7343_hub + counts: + fz: + name: AS7343 FZ + fxl: + name: AS7343 FXL + clear: + name: AS7343 Clear + basic_counts: + fz: + name: AS7343 FZ basic + fy: + name: AS7343 FY basic + saturation_level: + name: AS7343 Saturation + illuminance: + name: AS7343 Illuminance + color_temperature: + name: AS7343 Colour temperature + - platform: as734x + as734x_id: tcs3448_hub + counts: + f1: + name: TCS3448 F1 + fy: + name: TCS3448 FY + +text_sensor: + - platform: as734x + as734x_id: as7343_hub + rgb_hex: + name: AS7343 RGB diff --git a/tests/components/as734x/test.esp32-idf.yaml b/tests/components/as734x/test.esp32-idf.yaml new file mode 100644 index 00000000000..c2f5846bc52 --- /dev/null +++ b/tests/components/as734x/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + as734x: !include common.yaml diff --git a/tests/components/as734x/test.esp8266-ard.yaml b/tests/components/as734x/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b94a2dc317d --- /dev/null +++ b/tests/components/as734x/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + as734x: !include common.yaml diff --git a/tests/components/as734x/test.rp2040-ard.yaml b/tests/components/as734x/test.rp2040-ard.yaml new file mode 100644 index 00000000000..36538043f2a --- /dev/null +++ b/tests/components/as734x/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + as734x: !include common.yaml diff --git a/tests/components/bm8563/__init__.py b/tests/components/bm8563/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/bm8563/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/bm8563/test_bm8563.cpp b/tests/components/bm8563/test_bm8563.cpp new file mode 100644 index 00000000000..3514702e3b4 --- /dev/null +++ b/tests/components/bm8563/test_bm8563.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/bm8563/bm8563.h" +#include "esphome/core/time.h" + +namespace esphome::bm8563::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x06; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 16 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class BM8563Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + BM8563 rtc_; + unsigned syncs_{0}; +}; + +TEST_F(BM8563Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(BM8563Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::bm8563::testing diff --git a/tests/components/bmi270/test.esp8266-ard.yaml b/tests/components/bmi270/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7163710d74b --- /dev/null +++ b/tests/components/bmi270/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bmi270: !include common.yaml diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 295a68d262b..916fedec3d1 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -12,6 +12,7 @@ esphome: bq25186: - id: bq25186_charger + i2c_id: i2c_bus configure_on_boot: false fast_charge_current: 150mA battery_regulation_voltage: 4.2V @@ -20,6 +21,7 @@ bq25186: charge_enabled: true # Second charger with the boot-time write enabled - id: bq25186_boot + i2c_id: i2c_bus address: 0x6B configure_on_boot: true fast_charge_current: 20mA diff --git a/tests/components/core/test_callback.cpp b/tests/components/core/test_callback.cpp new file mode 100644 index 00000000000..7cf969734ad --- /dev/null +++ b/tests/components/core/test_callback.cpp @@ -0,0 +1,92 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +using IntCallback = Callback; + +// Counts destructions of the copies a callable carries, to check a boxed capture is freed exactly once. +struct DestroyCounter { + int *destroyed; + bool live{true}; + explicit DestroyCounter(int *count) : destroyed(count) {} + DestroyCounter(const DestroyCounter &other) = delete; + DestroyCounter(DestroyCounter &&other) noexcept : destroyed(other.destroyed) { other.live = false; } + DestroyCounter &operator=(const DestroyCounter &other) = delete; + DestroyCounter &operator=(DestroyCounter &&other) = delete; + ~DestroyCounter() { + if (this->live) + (*this->destroyed)++; + } +}; + +template bool fits_inline(const F & /*callable*/) { return IntCallback::fits_inline(); } + +TEST(CallbackFitsInline, PointerSizedConstCallableFits) { + int value = 0; + EXPECT_TRUE(fits_inline([&value](int x) { value = x; })); +} + +TEST(CallbackFitsInline, MutableCallableDoesNotFit) { + int calls = 0; + EXPECT_FALSE(fits_inline([calls](int) mutable { calls++; })); +} + +TEST(CallbackFitsInline, LargeCallableDoesNotFit) { + int a = 0; + int b = 0; + EXPECT_FALSE(fits_inline([&a, &b](int x) { a = b = x; })); +} + +TEST(CallbackCreate, InlineCallableIsCalled) { + int value = 0; + auto cb = IntCallback::create([&value](int x) { value = x; }); + cb.call(42); + EXPECT_EQ(value, 42); +} + +int mutable_seen = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// A pointer-sized mutable lambda used to be stored inline and run on a copy, losing its state. +TEST(CallbackCreate, MutableStateIsKeptAcrossCalls) { + auto counter = [n = 0](int x) mutable { mutable_seen = (n += x); }; + static_assert(sizeof(counter) <= sizeof(void *) && std::is_trivially_copyable_v); + // create() never frees its heap path; held for the whole run like a callback registered in setup(). + static auto cb = IntCallback::create(counter); + cb.call(1); + cb.call(2); + EXPECT_EQ(mutable_seen, 3); +} + +TEST(CallbackCreateBoxed, MutableStateIsKeptAcrossCalls) { + int seen = 0; + auto cb = IntCallback::create_boxed([count = 0, &seen](int x) mutable { + count += x; + seen = count; + }); + cb.call(1); + cb.call(2); + cb.call(3); + EXPECT_EQ(seen, 6); + cb.free_boxed(); +} + +TEST(CallbackCreateBoxed, FreeDestroysCaptureExactlyOnce) { + int destroyed = 0; + int calls = 0; + { + auto cb = IntCallback::create_boxed([counter = DestroyCounter(&destroyed), &calls](int) { calls++; }); + EXPECT_EQ(destroyed, 0); + cb.call(0); + cb.call(0); + EXPECT_EQ(destroyed, 0); + cb.free_boxed(); + EXPECT_EQ(destroyed, 1); + } + // The Callback itself owns nothing, so going out of scope frees nothing more + EXPECT_EQ(destroyed, 1); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index ac71cf82327..67e112d0cf7 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -166,6 +166,7 @@ TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); } TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } +TEST(SmallPow10, Four) { EXPECT_EQ(small_pow10(4), 10000u); } // --- frac_to_str_unchecked() --- diff --git a/tests/components/core/test_parse_float.cpp b/tests/components/core/test_parse_float.cpp new file mode 100644 index 00000000000..1d52f8afc8f --- /dev/null +++ b/tests/components/core/test_parse_float.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static uint32_t bits(float value) { + uint32_t out; + memcpy(&out, &value, sizeof(out)); + return out; +} + +// strtof is the reference: every accepted input must give the same bits. +static void expect_matches_strtof(const char *str) { + char *end = nullptr; + const float expected = ::strtof(str, &end); + const auto parsed = parse_number(str); + if (!parsed.has_value()) { + ADD_FAILURE() << str << " was rejected"; + return; + } + const float actual = parsed.value(); + EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected; +} + +TEST(ParseFloat, PlainDecimalsMatchStrtof) { + for (const char *str : + {"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3", + "2.675", "9999999", "0.9999999", "1234.567", "-1234.567", "0.000001", "0.000000001", "12.", ".5", + "-.5", "007", "00.50", "123.4567", "0.009999999", "0.001234567", "-0.009999999"}) { + expect_matches_strtof(str); + } +} + +TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) { + char buf[16]; + for (int i = -20000; i <= 20000; i++) { + snprintf(buf, sizeof(buf), "%s%d.%02d", i < 0 ? "-" : "", std::abs(i) / 100, std::abs(i) % 100); + expect_matches_strtof(buf); + } +} + +// A fraction longer than the fast path accepts must fall back, however long it is. +TEST(ParseFloat, LongFractionsMatchStrtof) { + std::string str = "0."; + str.append(300, '0'); + str += "25"; + expect_matches_strtof(str.c_str()); + expect_matches_strtof(("-" + str).c_str()); + str = "1."; + str.append(300, '0'); + expect_matches_strtof(str.c_str()); +} + +TEST(ParseFloat, LibcPathInputsMatchStrtof) { + for (const char *str : {"1e3", "-2.5E-2", "0x10", "nan", " 12", "123456789", "16777215", "1.0000000", "0.0000000001", + "0.00000000001", "1.23456789", "3.4028235e38"}) { + expect_matches_strtof(str); + } +} + +// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser +// rejects both; the negative forms pass through, as they did before the fast path. +TEST(ParseFloat, RejectsInvalidInput) { + for (const char *str : + {"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) { + EXPECT_FALSE(parse_number(str).has_value()) << str; + } +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_progmem_string_ref.cpp b/tests/components/core/test_progmem_string_ref.cpp new file mode 100644 index 00000000000..1d996f36db7 --- /dev/null +++ b/tests/components/core/test_progmem_string_ref.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/progmem.h" +#include "esphome/core/string_ref.h" + +namespace esphome::core::testing { + +TEST(ProgmemStringRef, EqualsComparesLengthAndContent) { + ProgmemStringRef ref("Kitchen", 7); + EXPECT_TRUE(ref.equals(StringRef("Kitchen"))); + EXPECT_FALSE(ref.equals(StringRef("Kitche"))); + EXPECT_FALSE(ref.equals(StringRef("Kitchens"))); + EXPECT_FALSE(ref.equals(StringRef("Bedroom"))); +} + +TEST(ProgmemStringRef, FromLitTakesTheLengthAtCompileTime) { + static const char NAME[] PROGMEM = "entity_id"; + constexpr ProgmemStringRef ref = ProgmemStringRef::from_lit(NAME); + static_assert(ref.size() == 9); + EXPECT_TRUE(ref.equals(StringRef("entity_id"))); +} + +TEST(ProgmemStringRef, DefaultIsEmpty) { + ProgmemStringRef ref; + EXPECT_TRUE(ref.empty()); + EXPECT_EQ(ref.size(), 0u); + EXPECT_TRUE(ref.equals(StringRef(""))); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_progmem_string_table.cpp b/tests/components/core/test_progmem_string_table.cpp new file mode 100644 index 00000000000..e6820ec50de --- /dev/null +++ b/tests/components/core/test_progmem_string_table.cpp @@ -0,0 +1,73 @@ +#include + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::core::testing { + +PROGMEM_STRING_TABLE(SmallStrings, "OFF", "HEAT", "COOL", "UNKNOWN"); + +// Eight strings of 40 characters: a 328-byte blob, past what one-byte offsets can address, so it is scanned. +PROGMEM_STRING_TABLE(LargeStrings, "0123456789012345678901234567890123456789", + "1123456789012345678901234567890123456789", "2123456789012345678901234567890123456789", + "3123456789012345678901234567890123456789", "4123456789012345678901234567890123456789", + "5123456789012345678901234567890123456789", "6123456789012345678901234567890123456789", + "7123456789012345678901234567890123456789"); + +// Five 50-character strings pack into exactly 255 bytes; one more byte switches to scanning. +PROGMEM_STRING_TABLE(Exactly255, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789"); +PROGMEM_STRING_TABLE(Exactly256, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789x"); + +TEST(ProgmemStringTable, ScansInsteadOfIndexingAfter255Bytes) { + static_assert(Exactly255::BLOB_SIZE == 255); + static_assert(Exactly255::Table::INDEXED); + static_assert(Exactly256::BLOB_SIZE == 256); + static_assert(!Exactly256::Table::INDEXED); + static_assert(Exactly256::OFFSETS.empty()); + EXPECT_EQ(Exactly255::OFFSETS[4], 204); + EXPECT_STREQ(reinterpret_cast(Exactly256::get_progmem_str(4, 0)), + "01234567890123456789012345678901234567890123456789x"); +} + +TEST(ProgmemStringTable, SmallTableKeepsOneByteOffsets) { + static_assert(SmallStrings::BLOB_SIZE <= 255); + static_assert(SmallStrings::Table::INDEXED); + static_assert(SmallStrings::MAX_LENGTH == 7); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(0, SmallStrings::LAST_INDEX)), "OFF"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(2, SmallStrings::LAST_INDEX)), "COOL"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(200, SmallStrings::LAST_INDEX)), "UNKNOWN"); +} + +TEST(ProgmemStringTable, LargeTableIsScanned) { + static_assert(LargeStrings::BLOB_SIZE > 255); + static_assert(!LargeStrings::Table::INDEXED); + static_assert(LargeStrings::MAX_LENGTH == 40); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(0, LargeStrings::LAST_INDEX)), + "0123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(7, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(8, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(3, LargeStrings::LAST_INDEX)), + "3123456789012345678901234567890123456789"); +} + +TEST(ProgmemStringTable, StrstrFindsTableEntryInRamString) { + const char *line = "MODE HEAT ON"; + const auto *heat = reinterpret_cast(SmallStrings::get_progmem_str(1, SmallStrings::LAST_INDEX)); + const auto *cool = reinterpret_cast(SmallStrings::get_progmem_str(2, SmallStrings::LAST_INDEX)); + EXPECT_EQ(ESPHOME_strstr_P(line, heat), line + 5); + EXPECT_EQ(ESPHOME_strstr_P(line, cool), nullptr); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp index 381a742a9cd..5ffb84e1296 100644 --- a/tests/components/core/test_value_accuracy.cpp +++ b/tests/components/core/test_value_accuracy.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -64,6 +65,67 @@ TEST(ValueAccuracyToBuf, ThreeDecimals) { EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); } +TEST(ValueAccuracyToBuf, FourDecimals) { + EXPECT_EQ(va_to_string(0.0f, 4), "0.0000"); + EXPECT_EQ(va_to_string(0.0001f, 4), "0.0001"); + EXPECT_EQ(va_to_string(0.0030f, 4), "0.0030"); + EXPECT_EQ(va_to_string(-0.0008f, 4), "-0.0008"); + EXPECT_EQ(va_to_string(23.456f, 4), va_reference(23.456f, 4)); +} + +TEST(ValueAccuracyToBuf, FourDecimalPidSteps) { + for (int step = 0; step <= 30; step++) { + float value = step * 0.0001f; + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)) << "step=" << step; + } +} + +TEST(ValueAccuracyToBuf, FourDecimalRoundingBoundaries) { + for (int step = -1000; step <= 1000; step++) { + float midpoint = static_cast((step + 0.5) / 10000.0); + for (float value : {std::nextafter(midpoint, -INFINITY), midpoint, std::nextafter(midpoint, INFINITY)}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)) << "value=" << value; + } + } + for (float value : {0.03125f, -0.03125f, 0.09375f, -0.09375f, 0.99995f, -0.99995f}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + } +} + +TEST(ValueAccuracyToBuf, FourDecimalUint32Boundary) { + float limit = static_cast(UINT32_MAX) / 10000; + for (float value : {std::nextafter(limit, 0.0f), limit, std::nextafter(limit, INFINITY), 1.0e6f, + std::numeric_limits::max()}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + EXPECT_EQ(va_to_string(-value, 4), va_reference(-value, 4)); + } +} + +TEST(ValueAccuracyToBuf, FourDecimalSpecialValuesAndFallback) { + for (float value : {-0.0f, -0.00001f, NAN, INFINITY, -INFINITY}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + } + EXPECT_EQ(va_to_string(0.00001f, 5), "0.00001"); + EXPECT_EQ(va_to_string(-0.00001f, 5), "-0.00001"); +} + +TEST(ValueAccuracyToBuf, FourDecimalBufferAndLength) { + struct { + char before{'B'}; + char buffer[VALUE_ACCURACY_MAX_LEN]; + char after{'A'}; + } guarded; + for (float value : {0.0f, -0.0f, 0.0001f, -0.0030f, 429496.0f, 1.0e6f}) { + size_t len = value_accuracy_to_buf(std::span(guarded.buffer), value, 4); + EXPECT_EQ(guarded.before, 'B'); + EXPECT_EQ(guarded.after, 'A'); + ASSERT_LT(len, VALUE_ACCURACY_MAX_LEN); + EXPECT_EQ(guarded.buffer[len], '\0'); + EXPECT_EQ(strlen(guarded.buffer), len); + EXPECT_EQ(std::string(guarded.buffer, len), va_reference(value, 4)); + } +} + // --- Negative values --- TEST(ValueAccuracyToBuf, NegativeValues) { @@ -121,7 +183,7 @@ TEST(ValueAccuracyToBuf, Rounding) { TEST(ValueAccuracyToBuf, MatchesSnprintf) { float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; - int8_t test_accuracies[] = {0, 1, 2, 3}; + int8_t test_accuracies[] = {0, 1, 2, 3, 4}; for (float value : test_values) { for (int8_t acc : test_accuracies) { @@ -223,7 +285,7 @@ TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) { TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) { const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"}; float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f}; - int8_t accs[] = {0, 1, 2, 3}; + int8_t accs[] = {0, 1, 2, 3, 4}; for (const char *u : units) { for (float v : values) { for (int8_t a : accs) { diff --git a/tests/components/cse7766/common.h b/tests/components/cse7766/common.h new file mode 100644 index 00000000000..b69c04d3287 --- /dev/null +++ b/tests/components/cse7766/common.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::cse7766::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override {} + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::cse7766::testing diff --git a/tests/components/cse7766/sensor/cse7766_test.cpp b/tests/components/cse7766/sensor/cse7766_test.cpp new file mode 100644 index 00000000000..e5bcda8fa98 --- /dev/null +++ b/tests/components/cse7766/sensor/cse7766_test.cpp @@ -0,0 +1,117 @@ +#include + +#include +#include + +#include "esphome/components/cse7766/cse7766.h" +#include "esphome/components/sensor/sensor.h" +#include "../common.h" + +namespace esphome::cse7766::testing { + +namespace { + +// Coefficients and cycles taken from a Sonoff S60 (CSE7759B). With this power coefficient a power +// cycle of 2507613 is about 2.16 W, and the cycle register advances by about 1050000 per second. +constexpr uint32_t VOLTAGE_COEFF = 0x030BB0; +constexpr uint32_t VOLTAGE_CYCLE = 0x000352; +constexpr uint32_t CURRENT_COEFF = 0x004137; +constexpr uint32_t CURRENT_CYCLE = 0x053339; +constexpr uint32_t POWER_COEFF = 5425000; +constexpr uint32_t LOW_LOAD_CYCLE = 2507613; +constexpr uint32_t SHORT_CYCLE = 900000; // about 6 W, just under 1 s +constexpr uint32_t HIGH_LOAD_CYCLE = 2000; + +constexpr uint8_t HEADER_OK = 0x55; +constexpr uint8_t HEADER_POWER_CYCLE_EXCEEDS_RANGE = 0xF2; +constexpr uint8_t ADJ_NEW_POWER = 0x71; +constexpr uint8_t ADJ_NO_NEW_POWER = 0x61; + +void put24(std::vector &frame, uint32_t value) { + frame.push_back(value >> 16); + frame.push_back(value >> 8); + frame.push_back(value); +} + +std::vector make_frame(uint8_t header, uint32_t power_cycle, uint8_t adj) { + std::vector frame{header, 0x5A}; + put24(frame, VOLTAGE_COEFF); + put24(frame, VOLTAGE_CYCLE); + put24(frame, CURRENT_COEFF); + put24(frame, CURRENT_CYCLE); + put24(frame, POWER_COEFF); + put24(frame, power_cycle); + frame.push_back(adj); + frame.push_back(0x0B); + frame.push_back(0x81); + uint8_t checksum = 0; + for (size_t i = 2; i < frame.size(); i++) { + checksum += frame[i]; + } + frame.push_back(checksum); + return frame; +} + +class CSE7766Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_power_sensor(&this->power_); + } + + float feed_(uint8_t header, uint32_t power_cycle, uint8_t adj) { + this->uart_.push_rx(make_frame(header, power_cycle, adj)); + this->sut_.loop(); + return this->power_.state; + } + + MockUARTComponent uart_; + CSE7766Component sut_; + sensor::Sensor power_; +}; + +} // namespace + +TEST_F(CSE7766Test, NewMeasurementIsReported) { + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER), 2.163f, 0.001f); +} + +// At low load one power cycle spans many frames. Frames in between carry the last cycle or the +// time since the last pulse, and must not be reported as 0 W. +TEST_F(CSE7766Test, LowLoadKeepsLastMeasurementBetweenPulses) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 4900000, ADJ_NO_NEW_POWER), 2.163f, 0.001f); +} + +// Below 1 s the chip does not send waiting times in normal operation, so such a frame +// means the pulse is overdue and 0 W is reported at once, as before +TEST_F(CSE7766Test, ShortCycleReportsZeroWithoutNewMeasurement) { + this->feed_(HEADER_OK, SHORT_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 1050000, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, OverduePulseReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 2 * LOW_LOAD_CYCLE + 1, ADJ_NO_NEW_POWER), 0.0f); + // Stays 0 even when a later frame shows a short cycle again, until a new measurement arrives + EXPECT_EQ(this->feed_(HEADER_OK, 1000000, ADJ_NO_NEW_POWER), 0.0f); +} + +// A large load switching off: the first frame without a new measurement already shows about one +// second since the last pulse, far beyond the 2 ms cycle, so 0 W is reported at once. +TEST_F(CSE7766Test, HighLoadSwitchingOffReportsZeroAtOnce) { + EXPECT_NEAR(this->feed_(HEADER_OK, HIGH_LOAD_CYCLE, ADJ_NEW_POWER), 2712.5f, 0.1f); + EXPECT_EQ(this->feed_(HEADER_OK, 1072601, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, NoMeasurementYetReportsZero) { EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); } + +TEST_F(CSE7766Test, PowerCycleExceedsRangeReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_POWER_CYCLE_EXCEEDS_RANGE, 9000000, ADJ_NO_NEW_POWER), 0.0f); + EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); +} + +} // namespace esphome::cse7766::testing diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fd695b9ca48..fc642a84e9d 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -121,7 +121,7 @@ struct Rig { DaikinClimate climate; Rig() { - // All instances share one preference key; clear it so setup starts from defaults + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); static const bool APP_CONSTRUCTED = (new (&App) Application(), true); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 5a71cf41b7f..0664a82571c 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -143,7 +143,7 @@ struct Fixture { /// Pass keep_preferences to restore what an earlier fixture saved explicit Fixture(bool keep_preferences = false) { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (!keep_preferences && global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does; the boost timer needs its scheduler diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml new file mode 100644 index 00000000000..2a35b04794d --- /dev/null +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -0,0 +1,8 @@ +# Declaring the entity domains makes the C++ unit test build define USE_SENSOR and friends. +# Loaded with plain yaml.safe_load, so no ESPHome tags here. +binary_sensor: +sensor: +text_sensor: +number: +switch: +select: diff --git a/tests/components/dfrobot_sen0609/common-speed.yaml b/tests/components/dfrobot_sen0609/common-speed.yaml new file mode 100644 index 00000000000..623d808fb75 --- /dev/null +++ b/tests/components/dfrobot_sen0609/common-speed.yaml @@ -0,0 +1,36 @@ +dfrobot_sen0609: + id: sen0609_speed + model: SEN0610 + mode: speed_and_distance + +binary_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + has_target: + name: Target + +sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + target_distance: + name: Target Distance + target_speed: + name: Target Speed + target_energy: + name: Target Energy + +number: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + min_range: + name: Minimum Range + max_range: + name: Maximum Range + threshold_factor: + name: Threshold Factor + +switch: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + micro_motion: + name: Micro Motion diff --git a/tests/components/dfrobot_sen0609/common.h b/tests/components/dfrobot_sen0609/common.h new file mode 100644 index 00000000000..7e4ea62f84d --- /dev/null +++ b/tests/components/dfrobot_sen0609/common.h @@ -0,0 +1,118 @@ +#pragma once +#include +#include +#include +#include "esphome/components/dfrobot_sen0609/dfrobot_sen0609.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/application.h" + +namespace esphome::dfrobot_sen0609::testing { + +// Records what the hub writes and hands it what the test queued as module output. +class FakeUART : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } + bool peek_byte(uint8_t *data) override { + if (this->available() == 0) + return false; + *data = this->rx[this->rx_pos]; + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (this->available() < len) + return false; + memcpy(data, this->rx.data() + this->rx_pos, len); + this->rx_pos += len; + return true; + } + size_t available() override { return this->rx.size() - this->rx_pos; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + + std::string tx; + std::string rx; + size_t rx_pos{0}; +}; + +// The hub publishes read values; writes are driven through control_number() directly. +class TestNumber : public number::Number { + protected: + void control(float value) override {} +}; + +class TestSwitch : public switch_::Switch { + protected: + void write_state(bool state) override {} +}; + +class TestSelect : public select::Select { + protected: + void control(size_t index) override {} +}; + +class SEN0609Test : public ::testing::Test { + protected: + explicit SEN0609Test(RunMode mode = RunMode::RUN_MODE_PRESENCE) : hub_(mode) {} + + void SetUp() override { + this->hub_.set_uart_parent(&this->uart_); + this->hub_.set_has_target_binary_sensor(&this->has_target_); + } + + // Runs the hub's loop `ms` later than the previous run + void run_(uint32_t ms = 16) { + this->now_ += ms; + LoopBlockingGuard guard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + void feed_(const std::string &text) { + this->uart_.rx += text; + this->run_(); + } + + // Feeds a presence frame every second, so the frame watchdog stays quiet, for `seconds` or until the hub sends + void feed_frames_(int seconds) { + for (int i = 0; i < seconds && this->uart_.tx.empty(); i++) { + this->feed_("$DFHPD,0, , , *\r\n"); + this->run_(1000); + } + } + + // Runs the loop until the hub writes a full command line, and returns it without the line end + std::string next_command_(uint32_t max_ms = 5000) { + for (uint32_t waited = 0; waited <= max_ms; waited += 16) { + const size_t end = this->uart_.tx.find("\r\n"); + if (end != std::string::npos) { + std::string command = this->uart_.tx.substr(0, end); + this->uart_.tx.erase(0, end + 2); + return command; + } + this->run_(); + } + return ""; + } + + // Answers a command the way the module does: echo, optional lines, Done and the prompt + void answer_(const std::string &command, const std::string &lines = "") { + this->feed_(command + "\r\n" + lines + "Done\r\nDFRobot:/>"); + } + + // Expects `command` next and answers it + void expect_and_answer_(const std::string &command, const std::string &lines = "") { + ASSERT_EQ(this->next_command_(), command); + this->answer_(command, lines); + } + + FakeUART uart_; + DFRobotSEN0609Component hub_; + binary_sensor::BinarySensor has_target_; + uint32_t now_{1000}; +}; + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml new file mode 100644 index 00000000000..aca15b906fc --- /dev/null +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -0,0 +1,51 @@ +dfrobot_sen0609: + id: sen0609_presence + mode: presence + +binary_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + has_target: + name: Presence + +text_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + version: + name: Software Version + hardware_version: + name: Hardware Version + +number: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + min_range: + name: Minimum Range + max_range: + name: Maximum Range + trigger_range: + name: Trigger Range + hold_sensitivity: + name: Hold Sensitivity + trigger_sensitivity: + name: Trigger Sensitivity + on_latency: + name: On Latency + off_latency: + name: Off Latency + inhibit_time: + name: Inhibit Time + +switch: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + run_led: + name: Run LED + out_led: + name: Out LED + +select: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + out_pin_level: + name: Out Pin Level diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp new file mode 100644 index 00000000000..7044d12675b --- /dev/null +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp @@ -0,0 +1,72 @@ +#include "common.h" + +namespace esphome::dfrobot_sen0609::testing { + +class SEN0609FrameTest : public SEN0609Test { + protected: + SEN0609FrameTest() : SEN0609Test(RunMode::RUN_MODE_SPEED_AND_DISTANCE) {} + + void SetUp() override { + SEN0609Test::SetUp(); + this->hub_.set_target_distance_sensor(&this->distance_); + this->hub_.set_target_speed_sensor(&this->speed_); + this->hub_.set_target_energy_sensor(&this->energy_); + } + + sensor::Sensor distance_; + sensor::Sensor speed_; + sensor::Sensor energy_; +}; + +TEST_F(SEN0609FrameTest, PresenceFrames) { + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); + this->feed_("$DFHPD,0, , , *\r\n"); + EXPECT_FALSE(this->has_target_.state); +} + +TEST_F(SEN0609FrameTest, TargetFrame) { + this->feed_("$DFDMD,1,0,2.35,-0.40,1234,0,0*\r\n"); + EXPECT_TRUE(this->has_target_.state); + EXPECT_FLOAT_EQ(this->distance_.state, 2.35f); + EXPECT_FLOAT_EQ(this->speed_.state, -0.40f); + EXPECT_FLOAT_EQ(this->energy_.state, 1234.0f); +} + +TEST_F(SEN0609FrameTest, NoTargetMakesValuesUnknown) { + this->feed_("$DFDMD,1,0,2.35,-0.40,1234,0,0*\r\n"); + this->feed_("$DFDMD,0,0,0,0,0,0,0*\r\n"); + EXPECT_FALSE(this->has_target_.state); + EXPECT_TRUE(std::isnan(this->distance_.state)); + EXPECT_TRUE(std::isnan(this->speed_.state)); + EXPECT_TRUE(std::isnan(this->energy_.state)); +} + +TEST_F(SEN0609FrameTest, MalformedFramesAreIgnored) { + this->feed_("$DFHPD,x, , , *\r\n"); + this->feed_("$DFDMD,1,0,abc,0.1,5*\r\n"); + this->feed_("$DFDMD,1,0,2.0*\r\n"); + this->feed_("$DFDMD,,0,2.0,0.1,5*\r\n"); + EXPECT_FALSE(this->has_target_.has_state()); + EXPECT_FALSE(this->distance_.has_state()); +} + +TEST_F(SEN0609FrameTest, FrameAfterPromptOnSameLine) { + this->feed_("DFRobot:/>$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); +} + +TEST_F(SEN0609FrameTest, FrameSplitAcrossReads) { + this->feed_("$DFDMD,1,0,3."); + EXPECT_FALSE(this->distance_.has_state()); + this->feed_("50,0.00,77,0,0*\r\n"); + EXPECT_FLOAT_EQ(this->distance_.state, 3.5f); +} + +TEST_F(SEN0609FrameTest, LongLineIsDroppedAndParserRecovers) { + this->feed_(std::string(200, 'x') + "\r\n"); + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); +} + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp new file mode 100644 index 00000000000..aa39c4f6a4d --- /dev/null +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -0,0 +1,589 @@ +#include "common.h" + +namespace esphome::dfrobot_sen0609::testing { + +class SEN0609SequencerTest : public SEN0609Test { + protected: + void SetUp() override { + SEN0609Test::SetUp(); + this->hub_.set_number(NUMBER_MIN_RANGE, &this->min_range_); + this->hub_.set_number(NUMBER_MAX_RANGE, &this->max_range_); + this->hub_.set_number(NUMBER_HOLD_SENSITIVITY, &this->hold_sensitivity_); + this->hub_.set_version_text_sensor(&this->version_); + } + + // Runs the boot sequence with a module that answers everything + void boot_() { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + } + + TestNumber min_range_; + TestNumber max_range_; + TestNumber hold_sensitivity_; + text_sensor::TextSensor version_; +}; + +TEST_F(SEN0609SequencerTest, BootReadsSettingsWithoutSaving) { + this->boot_(); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + EXPECT_EQ(this->version_.state, "V1.0.4"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ChangesMadeTogetherShareOneSave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->hub_.control_number(NUMBER_MAX_RANGE, 10.0f); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 10.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 10.00"); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, WriteWaitsBeforeStarting) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + EXPECT_EQ(this->next_command_(500), ""); + EXPECT_EQ(this->next_command_(), "sensorStop"); +} + +TEST_F(SEN0609SequencerTest, NotStoppedStopsAndRetries) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + // The module was started behind our back + this->feed_("setRange 1.00 12.00\r\nsensor is not stopped\r\nDFRobot:/>"); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); +} + +TEST_F(SEN0609SequencerTest, MalformedAnswerIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); +} + +TEST_F(SEN0609SequencerTest, NonFiniteAnswerIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response nan 12.0\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 inf\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); +} + +TEST_F(SEN0609SequencerTest, ErrorAnswerIsRetriedThenSkipped) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getRange"); + this->feed_("getRange\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + EXPECT_FALSE(this->min_range_.has_state()); +} + +TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "getRange"); + ASSERT_EQ(this->next_command_(), "getSensitivity"); + // The answer to the last getRange arrives after it timed out + this->feed_("getRange\r\nResponse 0.6 12.0\r\nDone\r\nDFRobot:/>"); + this->answer_("getSensitivity", "Response 7 5\r\n"); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); +} + +TEST_F(SEN0609SequencerTest, LateVersionLineIsStillUsed) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "getSWV"); + ASSERT_EQ(this->next_command_(), "sensorStart"); + // The answer to the last getSWV arrives after it timed out; the version line names its value + this->feed_("getSWV\r\nSoftwareVersion:V1.0.4\r\nDone\r\nDFRobot:/>"); + this->answer_("sensorStart"); + EXPECT_EQ(this->version_.state, "V1.0.4"); +} + +TEST_F(SEN0609SequencerTest, MissingVersionLineIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + EXPECT_EQ(this->version_.state, "V1.0.4"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, FailedConfigurationIsRetriedLater) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setRunApp 0"); + this->feed_("setRunApp 0\r\nError\r\nDFRobot:/>"); + } + EXPECT_TRUE(this->hub_.status_has_warning()); + // The sensor is not started in an unknown mode + EXPECT_EQ(this->next_command_(20000), ""); + ASSERT_EQ(this->next_command_(15000), "setRunApp 0"); + this->answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, SilentModuleBacksOffAndKeepsWork) { + this->hub_.setup(); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->run_(2100); // the last attempt times out + EXPECT_TRUE(this->hub_.status_has_warning()); + EXPECT_EQ(this->next_command_(20000), ""); + // The module shows up later; the boot work is still pending + ASSERT_EQ(this->next_command_(15000), "sensorStop"); + this->answer_("sensorStop"); + EXPECT_EQ(this->next_command_(), "setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, PairedValueUnknownIsReadBeforeWrite) { + // No boot reads, so the trigger sensitivity paired with hold sensitivity is unknown + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 3.0f); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, PairedValueReadFailureDropsWrite) { + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getSensitivity"); + this->feed_("getSensitivity\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_TRUE(this->hub_.status_has_warning()); + // Whether the change reached the module is unknown + EXPECT_TRUE(std::isnan(this->hold_sensitivity_.state)); +} + +TEST_F(SEN0609SequencerTest, ChangeAfterFailedBootReadIsWritten) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getRange"); + this->feed_("getRange\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ChangeBeforeBootReadWaitsForPairedValue) { + this->hub_.setup(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringWriteIsWrittenAgain) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_MIN_RANGE, 2.0f); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("setRange 2.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, ChangesDuringSequenceShareOneSave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_MAX_RANGE, 10.0f); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("setRange 1.00 10.00"); + // The save waits a second after the last change + EXPECT_EQ(this->next_command_(500), ""); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, ReadAfterFailedWriteKeepsNewerPartnerChange) { + this->boot_(); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 9.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setSensitivity 9 5"); + this->feed_("setSensitivity 9 5\r\nError\r\nDFRobot:/>"); + } + ASSERT_EQ(this->next_command_(), "getSensitivity"); + this->hub_.control_number(NUMBER_TRIGGER_SENSITIVITY, 4.0f); + this->answer_("getSensitivity", "Response 7 5\r\n"); + // The rejected hold sensitivity is put back, the newer trigger sensitivity is kept + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + this->expect_and_answer_("setSensitivity 7 4"); +} + +TEST_F(SEN0609SequencerTest, AutomationDuringReadKeepsItsChange) { + bool changed = false; + this->min_range_.add_on_state_callback([this, &changed](float) { + if (!changed) { + changed = true; + this->hub_.control_number(NUMBER_MAX_RANGE, 8.0f); + } + }); + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 8.0f); + this->expect_and_answer_("setRange 0.60 8.00"); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringReadIsKept) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + ASSERT_EQ(this->next_command_(), "getRange"); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + this->expect_and_answer_("setRange 1.00 12.00"); +} + +TEST_F(SEN0609SequencerTest, FailedWriteShowsModuleValueAgain) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->feed_("setRange 1.00 12.00\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_TRUE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { + this->hub_.setup(); + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->feed_("sensorStop\r\nsensor stopped already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), "setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, NotStoppedStopsAgainBeforeRetry) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRunApp 0"); + // The sensor was started again behind the hub's back + this->feed_("setRunApp 0\r\nsensor is not stopped\r\nDFRobot:/>"); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + EXPECT_EQ(this->next_command_(), "setUartOutput 1 1 1 1.0"); +} + +TEST_F(SEN0609SequencerTest, AlreadyStartedCountsAsStarted) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + // As left by an earlier sequence that failed + this->hub_.status_set_warning(); + ASSERT_EQ(this->next_command_(), "sensorStart"); + this->feed_("sensorStart\r\nsensor started already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, PresenceFramesKeepConfiguration) { + this->boot_(); + for (int i = 0; i < 15; i++) { + this->feed_("$DFHPD,0, , , *\r\n"); + this->run_(1000); + } + EXPECT_EQ(this->uart_.tx, ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, MissingPresenceFramesReapplyConfiguration) { + this->boot_(); + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); + EXPECT_EQ(this->next_command_(9000), ""); + // The module restarted on its own and stopped sending frames + ASSERT_EQ(this->next_command_(), "sensorStop"); + EXPECT_FALSE(this->has_target_.has_state()); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringBackOffKeepsBackOff) { + this->hub_.setup(); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->run_(2100); // the last attempt times out + EXPECT_TRUE(this->hub_.status_has_warning()); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + EXPECT_EQ(this->next_command_(20000), ""); + EXPECT_EQ(this->next_command_(15000), "sensorStop"); +} + +TEST_F(SEN0609SequencerTest, FailedSaveIsRetriedLater) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nError\r\nDFRobot:/>"); + } + // The sensor runs again meanwhile + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->feed_frames_(28); + EXPECT_EQ(this->uart_.tx, ""); + this->feed_frames_(4); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ErrorMentioningNoParameterIsNotASave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nError: no parameter saved\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), "saveConfig"); +} + +TEST_F(SEN0609SequencerTest, MinRangeAboveMaxRangeIsRefused) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 13.0f); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + this->hub_.control_number(NUMBER_MAX_RANGE, 0.5f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + EXPECT_EQ(this->next_command_(3000), ""); +} + +TEST_F(SEN0609SequencerTest, TriggerRangeOutsideRangeIsRefused) { + TestNumber trigger_range; + this->hub_.set_number(NUMBER_TRIGGER_RANGE, &trigger_range); + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getTrigRange", "Response 6.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + this->hub_.control_number(NUMBER_TRIGGER_RANGE, 13.0f); + EXPECT_FLOAT_EQ(trigger_range.state, 6.0f); + // Lowering the maximum below the trigger range is refused too + this->hub_.control_number(NUMBER_MAX_RANGE, 5.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + this->hub_.control_number(NUMBER_MIN_RANGE, 7.0f); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_EQ(this->next_command_(3000), ""); + this->hub_.control_number(NUMBER_TRIGGER_RANGE, 8.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setTrigRange 8.00"); +} + +class SEN0609SettingsTest : public SEN0609Test { + protected: + void SetUp() override { + SEN0609Test::SetUp(); + this->run_led_.set_restore_mode(switch_::SWITCH_ALWAYS_ON); + this->run_led_.set_inverted(true); + this->out_led_.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + this->out_pin_level_.traits.set_options({"low", "high"}); + this->hub_.set_switch(SWITCH_MICRO_MOTION, &this->micro_motion_); + this->hub_.set_switch(SWITCH_RUN_LED, &this->run_led_); + this->hub_.set_switch(SWITCH_OUT_LED, &this->out_led_); + this->hub_.set_out_pin_level_select(&this->out_pin_level_); + this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); + } + + void boot_() { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + // Inverted and on: the LED is off, which is mode 1 + this->expect_and_answer_("setLedMode 1 1"); + this->expect_and_answer_("setLedMode 2 1"); + this->expect_and_answer_("getMicroMotion", "Response 1\r\n"); + this->expect_and_answer_("getGpioMode 1", "Response 1 1\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + } + + TestSwitch micro_motion_; + TestSwitch run_led_; + TestSwitch out_led_; + TestSelect out_pin_level_; + text_sensor::TextSensor hardware_version_; +}; + +TEST_F(SEN0609SettingsTest, BootWritesLedsAndReadsSettings) { + this->boot_(); + EXPECT_TRUE(this->run_led_.state); + EXPECT_FALSE(this->out_led_.state); + EXPECT_TRUE(this->micro_motion_.state); + EXPECT_EQ(this->out_pin_level_.active_index(), optional(1)); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, FailedLedWriteShowsAcceptedStateAgain) { + this->boot_(); + this->hub_.control_switch(SWITCH_OUT_LED, true); + EXPECT_TRUE(this->out_led_.state); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setLedMode 2 0"); + this->feed_("setLedMode 2 0\r\nError\r\nDFRobot:/>"); + } + // The LEDs cannot be read back, so the state the module accepted at boot is shown again + EXPECT_FALSE(this->out_led_.state); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_TRUE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, FailedBootLedWriteIsRetriedLater) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setLedMode 1 1"); + this->feed_("setLedMode 1 1\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("setLedMode 2 1"); + this->expect_and_answer_("getMicroMotion", "Response 1\r\n"); + this->expect_and_answer_("getGpioMode 1", "Response 1 1\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + // The sensor runs without the LED state, which is written again later + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->feed_frames_(28); + EXPECT_EQ(this->uart_.tx, ""); + this->feed_frames_(4); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setLedMode 1 1"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, SwitchAndSelectChangesAreWrittenAndSaved) { + this->boot_(); + this->hub_.control_switch(SWITCH_MICRO_MOTION, false); + this->hub_.control_out_pin_level(0); + this->hub_.control_switch(SWITCH_OUT_LED, true); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setMicroMotion 0"); + this->expect_and_answer_("setGpioMode 1 0"); + this->expect_and_answer_("setLedMode 2 0"); + // Answers that refuse a command whose effect is already in place count as done + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nno parameter has been changed\r\nDFRobot:/>"); + ASSERT_EQ(this->next_command_(), "sensorStart"); + this->feed_("sensorStart\r\nsensor started already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml b/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml new file mode 100644 index 00000000000..969abd67776 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0609: !include common-speed.yaml diff --git a/tests/components/dfrobot_sen0609/test.esp32-idf.yaml b/tests/components/dfrobot_sen0609/test.esp32-idf.yaml new file mode 100644 index 00000000000..e3b03f3e4e1 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0609: !include common.yaml diff --git a/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml new file mode 100644 index 00000000000..57fa8a65364 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + dfrobot_sen0609: !include common.yaml diff --git a/tests/components/display/__init__.py b/tests/components/display/__init__.py new file mode 100644 index 00000000000..db75e3e6360 --- /dev/null +++ b/tests/components/display/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + +TESTS_DIR = Path(__file__).parent + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The column walk in Display::draw_pixels_at() is only built with PSRAM; define it + # when display's own tests are built so both walk orders run, and leave other + # components' test binaries on the plain build. + async def to_code_testing(config: ConfigType) -> None: + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + cg.add_define("USE_PSRAM") + + manifest.to_code = to_code_testing diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp new file mode 100644 index 00000000000..1278e2462df --- /dev/null +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" + +namespace esphome::display::testing { + +// Records every pixel write; pixel_axes_swapped() picks the walk order. +class RecordingDisplay : public Display { + public: + void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; } + bool pixel_axes_swapped() const override { return this->axes_swapped_; } + void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); } + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + + std::vector> sorted_pixels() const { + auto sorted = this->pixels_; + std::sort(sorted.begin(), sorted.end()); + return sorted; + } + size_t count() const { return this->pixels_.size(); } + const std::vector> &pixels() const { return this->pixels_; } + + protected: + bool axes_swapped_{false}; + std::vector> pixels_; +}; + +// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of +// right padding, so the source index arithmetic of both walks is exercised. +static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4; +static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD; +static constexpr int LINES = Y_OFFSET + H; + +static std::vector make_source(int bytes_per_pixel) { + std::vector source(LINE_STRIDE * LINES * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : source) + byte = value++; + return source; +} + +static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) { + const auto source = make_source(bytes_per_pixel); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + EXPECT_EQ(rows.count(), static_cast(W * H)); + EXPECT_EQ(columns.count(), static_cast(W * H)); + EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels()); +} + +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888) { expect_walks_match(COLOR_BITNESS_888, 3, false); } + +// One known pixel per bitness and byte order, so a decode that swapped bytes or channels +// could not pass the walk comparison above by being wrong the same way twice. +static void expect_golden(ColorBitness bitness, std::initializer_list bytes, bool big_endian, Color expected) { + const std::vector source(bytes); + RecordingDisplay display; + display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + ASSERT_EQ(display.count(), 1u); + EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); +} + +TEST(DrawPixelsAt, Golden565BigEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7 +} +TEST(DrawPixelsAt, Golden565LittleEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green +} +// 888 is always b,g,r in memory for RGB order, whatever the byte order flag says +TEST(DrawPixelsAt, Golden888IgnoresBigEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x30, 0x20, 0x10)); +} +TEST(DrawPixelsAt, Golden888LittleEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10)); +} +TEST(DrawPixelsAt, Golden332) { + expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3 +} + +// The column walk really is a different order, otherwise the tests above prove nothing. +TEST(DrawPixelsAt, ColumnWalkChangesOrder) { + const auto source = make_source(1); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, + X_PAD); + ASSERT_GT(rows.count(), 1u); + ASSERT_GT(columns.count(), 1u); + EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first + EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first +} + +} // namespace esphome::display::testing diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp new file mode 100644 index 00000000000..555611bc093 --- /dev/null +++ b/tests/components/display/test_test_card.cpp @@ -0,0 +1,80 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/core/application.h" + +namespace esphome::display::testing { + +// Records the colour of every pixel written, so a drawn test card shows up as non-black pixels. +class TestCardDisplay : public Display { + public: + void draw_pixel_at(int x, int y, Color color) override { this->colors_.push_back(color.raw_32); } + void update() override { this->do_update_(); } + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + + bool drew_test_card() const { + return std::any_of(this->colors_.begin(), this->colors_.end(), [](uint32_t c) { return c != 0; }); + } + void reset() { this->colors_.clear(); } + + protected: + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + std::vector colors_; +}; + +namespace { +class TestCard : public ::testing::Test { + protected: + void SetUp() override { + // test_card() stops the poller, which needs the scheduler; the test main does not construct App. + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + } + int writer_calls_{0}; + display_writer_t count_writer_() { + return [this](Display &) { this->writer_calls_++; }; + } +}; +} // namespace + +TEST_F(TestCard, DrawnOnEveryUpdateInsteadOfLambda) { + TestCardDisplay display; + display.set_writer(this->count_writer_()); + display.show_test_card(); + for (int i = 0; i != 3; i++) { + display.reset(); + display.update(); + EXPECT_TRUE(display.drew_test_card()) << "update " << i; + } + EXPECT_EQ(this->writer_calls_, 0); +} + +TEST_F(TestCard, DrawnOnEveryUpdateInsteadOfPages) { + TestCardDisplay display; + DisplayPage page(this->count_writer_()); + display.set_pages({&page}); + display.show_test_card(); + for (int i = 0; i != 3; i++) { + display.reset(); + display.update(); + EXPECT_TRUE(display.drew_test_card()) << "update " << i; + } + EXPECT_EQ(this->writer_calls_, 0); +} + +TEST_F(TestCard, LambdaRunsWithoutTestCard) { + TestCardDisplay display; + display.set_writer(this->count_writer_()); + display.update(); + display.update(); + EXPECT_FALSE(display.drew_test_card()); + EXPECT_EQ(this->writer_calls_, 2); +} + +} // namespace esphome::display::testing diff --git a/tests/components/emontx/common-version.yaml b/tests/components/emontx/common-version.yaml new file mode 100644 index 00000000000..1ec3733f771 --- /dev/null +++ b/tests/components/emontx/common-version.yaml @@ -0,0 +1,9 @@ +emontx: + id: test_emontx + lock_on_reboot: true + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 4af5a98572d..678d582f62f 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/emontx/emontx.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" namespace esphome::emontx::testing { @@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; +// The test main does not construct App as generated code does +inline void construct_app() { + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; +} + +inline void cancel_timers(EmonTx *emontx) { + for (uint32_t id = 0; id < 3; id++) + App.scheduler.cancel_timeout(emontx, id); + App.scheduler.call(millis()); +} + +// Lets timeouts due within duration_ms run; the scheduler reads the real clock +inline void run_timers(uint32_t duration_ms) { + App.scheduler.call(millis()); + delay(duration_ms); // NOLINT - test only + App.scheduler.call(millis()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 4a5e347053a..5c25e37abb5 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,7 +8,6 @@ button: emontx: id: test_emontx - lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" @@ -24,9 +23,3 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W - -text_sensor: - - platform: emontx - emontx_id: test_emontx - version: - name: emonTx firmware version diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 5d9fa096119..71bb6c70d36 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -13,11 +14,16 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); } + void TearDown() override { cancel_timers(&this->sut_); } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + MockUARTComponent uart_; EmonTx sut_; std::vector lines_; @@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { EXPECT_CALL(this->uart_, flush()).Times(1); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.on_shutdown(); EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); } @@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { EXPECT_CALL(this->uart_, flush()).Times(0); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.set_paused(true); this->sut_.on_shutdown(); EXPECT_TRUE(this->uart_.tx.empty()); } -TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) { - this->sut_.set_lock_on_reboot(true); +// The emonTx would join "emonunlock" to the pending empty line if both came in one burst +TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) { + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); - EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(300); + EXPECT_EQ(this->tx_(), "\nemonunlock\n"); } TEST_F(EmonTxTest, SetupSendsNothingByDefault) { @@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) { EXPECT_TRUE(this->uart_.tx.empty()); } +class EmonTxLockAutoTest : public EmonTxTest { + protected: + void SetUp() override { + EmonTxTest::SetUp(); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO); + } + + void report_version_(const char *version) { + this->uart_.push_rx(" - Version: "); + this->uart_.push_rx(version); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + } +}; + +// Only 'v' goes out until the reply shows the firmware knows 'emonunlock' +TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) { + this->sut_.setup(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) { + this->report_version_("v1.2.0"); + EXPECT_TRUE(this->uart_.tx.empty()); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); + + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) { + this->report_version_("v1.1.1"); + run_timers(300); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, VersionFormats) { + for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) { + this->report_version_("v0.99.0"); // So the next one is a change + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + this->uart_.tx.clear(); + } + for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) { + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + } +} + +// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock +TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) { + this->report_version_("v1.2.0"); + run_timers(300); + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); +} + +TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx("{\"MSG\":10}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\nv\n"); + + // Text format counts too + this->uart_.push_rx("MSG:11,V1:230.1\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); + + this->report_version_("v1.1.1"); + this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); +} + +// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v' +TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) { + this->sut_.setup(); + for (int msg = 10; msg < 20; msg++) { + char line[24]; + snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + EXPECT_EQ(this->tx_(), "\n\n\n"); +} + +// E.g. a downgrade: after the reboot, the new firmware never answers 'v' +TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) { + this->report_version_("v1.2.0"); + run_timers(300); + for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) { + char line[24]; + snprintf(line, sizeof(line), "MSG:%d\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) { + this->report_version_("v1.2.0"); + run_timers(300); + this->uart_.push_rx("MSG:50\nMSG:1\n"); + this->sut_.loop(); + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) { + this->report_version_("v1.2.0"); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(300); + EXPECT_TRUE(this->uart_.tx.empty()); + + // Resume asks for the version again, and its reply brings the unlock + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(300); + this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n"); +} + +// The lines of a 'v' reply before the version line are not data lines +TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx( + "> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml index 4be79a40dac..04cc5b89f56 100644 --- a/tests/components/emontx/test-actions.esp8266-ard.yaml +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -2,3 +2,4 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml emontx: !include common.yaml actions: !include common-actions.yaml + version: !include common-version.yaml diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py index 60a67b5a0b6..5bffc87e0f0 100644 --- a/tests/components/emontx/text_sensor/__init__.py +++ b/tests/components/emontx/text_sensor/__init__.py @@ -7,5 +7,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index fe905a0e958..34f6315f3ab 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -4,8 +4,6 @@ #include "esphome/components/emontx/emontx.h" #include "esphome/components/text_sensor/text_sensor.h" -#include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "../common.h" namespace esphome::emontx::testing { @@ -14,24 +12,15 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } - void TearDown() override { - App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); - App.scheduler.call(millis()); - } + void TearDown() override { cancel_timers(&this->sut_); } // Lets the delayed 'v' go out - static void run_timers() { - App.scheduler.call(millis()); - delay(300); // NOLINT - test only, the scheduler reads the real clock - App.scheduler.call(millis()); - } + static void run_timers() { testing::run_timers(300); } std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } @@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { } TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); run_timers(); @@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { EXPECT_EQ(this->tx_(), "\nv\n"); } +TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) { + this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + // The emonTx may have been flashed during the pause, with MSG already past its old value TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { this->sut_.set_paused(true); diff --git a/tests/components/epaper_spi/display/test_buffer_layout.cpp b/tests/components/epaper_spi/display/test_buffer_layout.cpp new file mode 100644 index 00000000000..34ffecc28e5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_buffer_layout.cpp @@ -0,0 +1,49 @@ +#include + +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" +#include "esphome/components/epaper_spi/epaper_waveshare_bwr.h" +#include "esphome/components/epaper_spi/epaper_weact_3c.h" + +namespace esphome::epaper_spi::testing { +namespace { + +template class Layout : public Driver { + public: + Layout(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {} + uint16_t row_width() const { return this->row_width_; } + size_t buffer_length() const { return this->buffer_length_; } + bool is_mono() const { return this->buffer_is_mono_(); } +}; + +/// Each driver's bits per pixel and plane count give the row width and buffer size its drawing code expects, and only +/// a single 1 bit per pixel plane takes the direct drawing paths. +TEST(EPaperBufferLayout, SizesFollowBitsPerPixelAndPlanes) { + Layout mono(21, 3); + EXPECT_EQ(mono.row_width(), 3); + EXPECT_EQ(mono.buffer_length(), 9u); + EXPECT_TRUE(mono.is_mono()); + + Layout gray(21, 3); + EXPECT_EQ(gray.row_width(), 6); + EXPECT_EQ(gray.buffer_length(), 18u); + EXPECT_FALSE(gray.is_mono()); + + Layout t133(20, 3); + EXPECT_EQ(t133.row_width(), 10); + EXPECT_EQ(t133.buffer_length(), 30u); + EXPECT_FALSE(t133.is_mono()); + + Layout weact(21, 3); + EXPECT_EQ(weact.row_width(), 3); + EXPECT_EQ(weact.buffer_length(), 18u); + EXPECT_FALSE(weact.is_mono()); + + Layout bwr(21, 3); + EXPECT_EQ(bwr.buffer_length(), 18u); + EXPECT_FALSE(bwr.is_mono()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 3839b4ff87a..f6091e5715c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -87,6 +87,40 @@ display: number: GPIO4 inverted: true + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g-v1 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi @@ -234,6 +268,27 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.66in-b + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661) - platform: epaper_spi spi_id: spi_bus diff --git a/tests/components/es9038q2m_katana/common.yaml b/tests/components/es9038q2m_katana/common.yaml new file mode 100644 index 00000000000..074eb47eeaa --- /dev/null +++ b/tests/components/es9038q2m_katana/common.yaml @@ -0,0 +1,16 @@ +esphome: + on_boot: + then: + - audio_dac.mute_off: + - audio_dac.mute_on: + - audio_dac.set_volume: + volume: 50% + +audio_dac: + - platform: es9038q2m_katana + i2c_id: i2c_bus + filter_shape: hybrid + bits_per_sample: 24bit + sample_rate: 96000 + deemphasis: 48khz + dop: true diff --git a/tests/components/es9038q2m_katana/test.esp32-idf.yaml b/tests/components/es9038q2m_katana/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5822be8f86 --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + es9038q2m_katana: !include common.yaml diff --git a/tests/components/es9038q2m_katana/test.esp8266-ard.yaml b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b2ff0d1aceb --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + es9038q2m_katana: !include common.yaml diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index a26c5d35a34..01b208995df 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -18,7 +18,7 @@ canbus: tx_pin: ${tx_pin} can_id: 4 bit_rate: 50kbps - mode: NORMAL + mode: NO_ACK on_frame: - can_id: 500 then: diff --git a/tests/components/esp32_can/test-20kbps.esp32-idf.yaml b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml new file mode 100644 index 00000000000..fb60cb7363f --- /dev/null +++ b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml @@ -0,0 +1,11 @@ +esp32: + framework: + advanced: + minimum_chip_revision: "2.0" + +canbus: + - platform: esp32_can + rx_pin: GPIO14 + tx_pin: GPIO12 + can_id: 4 + bit_rate: 20kbps diff --git a/tests/components/exponential_moving_average/common.h b/tests/components/exponential_moving_average/common.h index 599c36bb0d5..84b9a24a522 100644 --- a/tests/components/exponential_moving_average/common.h +++ b/tests/components/exponential_moving_average/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h" -#include "esphome/core/preferences.h" #ifdef USE_HOST #include "esphome/components/host/preferences.h" #endif @@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test { #ifdef USE_HOST host::setup_preferences(); #endif - global_preferences->reset(); } void TearDown() override { - global_preferences->reset(); if (this->saved_prefdir_.has_value()) { setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1); } else { diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml new file mode 100644 index 00000000000..94e5f2da2bc --- /dev/null +++ b/tests/components/fusb302b/common.yaml @@ -0,0 +1,27 @@ +fusb302b: + id: pd + interrupt_pin: ${interrupt_pin} + request_voltage: 20 + contract: + name: PD Contract + voltage: + name: PD Voltage + current: + name: PD Current + on_connect: + - logger.log: "Source attached" + on_disconnect: + - logger.log: "Source removed" + on_power_ready: + - if: + condition: + fusb302b.is_connected: pd + then: + - logger.log: "Power ready" + on_request_failed: + - logger.log: "Voltage request failed" + on_error: + - fusb302b.request_voltage: 5 + - fusb302b.request_voltage: + id: pd + voltage: !lambda "return 9;" diff --git a/tests/components/fusb302b/pd_test.cpp b/tests/components/fusb302b/pd_test.cpp new file mode 100644 index 00000000000..41e7e1b358a --- /dev/null +++ b/tests/components/fusb302b/pd_test.cpp @@ -0,0 +1,523 @@ +#include +#include + +#include "esphome/components/fusb302b/pd.h" + +namespace esphome::fusb302b::testing { + +static constexpr uint32_t fixed_pdo(uint16_t mv, uint16_t ma) { return ((mv / 50u) << 10) | (ma / 10u); } +// An augmented (PPS) object; only its type matters to the sink +static constexpr uint32_t AUGMENTED_PDO = static_cast(PD_PDO_TYPE_AUGMENTED) << 30; +// Unknown to the sink, so it must be answered with Reject +static constexpr uint8_t PD_CNTRL_DR_SWAP = 0x09; + +class FakePhy : public PowerDelivery { + public: + using PowerDelivery::ams_start_; + using PowerDelivery::check_ams_; + using PowerDelivery::check_transition_timeout_; + using PowerDelivery::check_request_retry_; + using PowerDelivery::contract_; + using PowerDelivery::handle_message_; + using PowerDelivery::handle_transmit_failed_; + using PowerDelivery::retry_request_pending_; + using PowerDelivery::set_state_and_contract_; + using PowerDelivery::wait_received_ms_; + + std::vector sent; + bool send_ok{true}; + int send_attempts{0}; + int state_changes{0}; + int soft_resets{0}; + int source_caps{0}; + int transition_timeouts{0}; + int request_failures{0}; + + void receive(uint8_t type, uint8_t id, std::vector objects = {}) { + PdMsg msg; + msg.type = type; + msg.id = id; + msg.num_of_obj = objects.size(); + for (size_t i = 0; i < objects.size(); i++) + msg.data_objects[i] = objects[i]; + this->handle_message_(msg); + } + void receive_source_caps(uint8_t id) { + this->receive(PD_DATA_SOURCE_CAP, id, + {fixed_pdo(5000, 3000), fixed_pdo(9000, 3000), fixed_pdo(15000, 3000), fixed_pdo(20000, 2250)}); + } + PackedContract contract() const { return PackedContract{this->contract_}; } + // What the PHY does when a source is attached + void attach() { this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); } + // Moves the timestamps back instead of sleeping, since millis() is real time on the host + void advance_ms(uint32_t ms) { + this->ams_start_ -= ms; + this->wait_received_ms_ -= ms; + } + + protected: + bool send_message(const PdMsg &msg) override { + this->send_attempts++; + if (!this->send_ok) + return false; + this->sent.push_back(msg); + return true; + } + void on_state_changed() override { this->state_changes++; } + void on_soft_reset_received() override { this->soft_resets++; } + void on_source_caps_received() override { + // The hook runs before the request is sent + EXPECT_EQ(this->sent.size(), static_cast(this->source_caps)); + this->source_caps++; + } + void on_transition_timeout() override { this->transition_timeouts++; } + void on_request_failed() override { this->request_failures++; } +}; + +TEST(FUSB302BPd, HeaderRoundTrip) { + PdMsg msg; + msg.type = PD_DATA_REQUEST; + msg.spec_rev = PD_SPEC_REV_2; + msg.id = 5; + msg.num_of_obj = 1; + // Type 2, spec revision 1 at bit 6, MessageID 5 at bit 9, one object at bit 12 + EXPECT_EQ(msg.get_coded_header(), 0x1A42); + + PdMsg decoded; + decoded.set_header(0x1A42 | (1u << 15)); + EXPECT_EQ(decoded.type, PD_DATA_REQUEST); + EXPECT_EQ(decoded.spec_rev, PD_SPEC_REV_2); + EXPECT_EQ(decoded.id, 5); + EXPECT_EQ(decoded.num_of_obj, 1); + EXPECT_TRUE(decoded.extended); +} + +TEST(FUSB302BPd, RequestsHighestVoltageNotAboveLimit) { + FakePhy pd; + pd.set_request_voltage(18); + pd.receive_source_caps(0); + EXPECT_EQ(pd.source_caps, 1); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); // 15 V is object 3 + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 300u); +} + +TEST(FUSB302BPd, LowestRequestPicksFirstObject) { + FakePhy pd; + pd.set_request_voltage(5); + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 1u); +} + +TEST(FUSB302BPd, NonFixedObjectKeepsItsPosition) { + FakePhy pd; + pd.set_request_voltage(9); + pd.receive(PD_DATA_SOURCE_CAP, 0, {fixed_pdo(5000, 3000), AUGMENTED_PDO, fixed_pdo(9000, 2000)}); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 200u); +} + +TEST(FUSB302BPd, NoFixedObjectFallsBackToObjectOne) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_DATA_SOURCE_CAP, 0, {AUGMENTED_PDO, AUGMENTED_PDO}); + ASSERT_EQ(pd.sent.size(), 1u); + const uint32_t rdo = pd.sent[0].data_objects[0]; + EXPECT_EQ(rdo >> 28, 1u); + EXPECT_EQ(rdo & 0x3FF, 30u); // 300 mA maximum + EXPECT_EQ((rdo >> 10) & 0x3FF, 10u); // 100 mA operating +} + +TEST(FUSB302BPd, AcceptThenPsRdyReachesExplicitContract) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 2.25f); +} + +TEST(FUSB302BPd, AcceptPublishesStateAndContractOnce) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.state_changes = 0; + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, AcceptForSameContractSkipsTransition) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + pd.receive(PD_CNTRL_PS_RDY, 5); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FALSE(pd.check_ams_()); +} + +TEST(FUSB302BPd, PsRdyWithoutAcceptIsIgnored) { + FakePhy pd; + pd.receive(PD_CNTRL_PS_RDY, 0); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + + pd.receive_source_caps(1); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterRejectIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.advance_ms(2001); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + // The missing PS_RDY is a transition timeout, not a failed request + EXPECT_EQ(pd.request_failures, 0); +} + +TEST(FUSB302BPd, UnansweredFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + EXPECT_TRUE(pd.check_ams_()); + pd.advance_ms(2001); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); + // Reported once + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, MissingPsRdyForSameContractFailsRequest) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + pd.advance_ms(2001); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_EQ(pd.transition_timeouts, 0); + + pd.advance_ms(2001); + pd.state_changes = 0; + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.transition_timeouts, 1); + + // Reported once, and a late PS_RDY does not bring back the unconfirmed contract + pd.check_transition_timeout_(); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.transition_timeouts, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); +} + +TEST(FUSB302BPd, RequestVoltageIsClamped) { + FakePhy pd; + pd.set_request_voltage(255); + EXPECT_EQ(pd.get_request_voltage(), MAX_REQUEST_VOLTAGE); + pd.set_request_voltage(0); + EXPECT_EQ(pd.get_request_voltage(), MIN_REQUEST_VOLTAGE); +} + +TEST(FUSB302BPd, WaitRepeatsRequestAfterSinkRequestTime) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 1u); + pd.advance_ms(101); + pd.check_request_retry_(); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[1].data_objects[0], pd.sent[0].data_objects[0]); + EXPECT_TRUE(pd.check_ams_()); +} + +TEST(FUSB302BPd, WaitRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + for (uint8_t i = 1; i <= 10; i++) { + pd.receive(PD_CNTRL_WAIT, i % 8); + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_EQ(pd.sent.size(), 11u); + EXPECT_EQ(pd.request_failures, 0); + pd.receive(PD_CNTRL_WAIT, 3); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 11u); +} + +TEST(FUSB302BPd, FailedRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.send_ok = false; + pd.receive_source_caps(0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, FailedRetryIsTriedAgain) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); + EXPECT_TRUE(pd.retry_request_pending_); + EXPECT_FALSE(pd.check_ams_()); + + pd.send_ok = true; + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); // waits another tSinkRequest first + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 2u); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, FailedRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + for (int i = 0; i < 20 && pd.retry_request_pending_; i++) { + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_FALSE(pd.retry_request_pending_); + // The first request, then one attempt for each of the 10 allowed repeats + EXPECT_EQ(pd.send_attempts, 11); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.set_request_voltage(9); + pd.receive_source_caps(3); + EXPECT_EQ(pd.request_failures, 0); + pd.receive(PD_CNTRL_REJECT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { + FakePhy pd; + pd.attach(); + pd.receive(PD_CNTRL_REJECT, 0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.request_failures, 0); +} + +TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) { + FakePhy pd; + pd.receive(PD_DATA_REQUEST, 0, {0}); + EXPECT_TRUE(pd.sent.empty()); + EXPECT_EQ(pd.source_caps, 0); +} + +TEST(FUSB302BPd, AcceptOutsideSequenceIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + pd.receive(PD_CNTRL_ACCEPT, 2); + pd.receive(PD_CNTRL_PS_RDY, 3); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + EXPECT_FALSE(pd.contract().is_set()); +} + +TEST(FUSB302BPd, GoodCrcMustMatchMessageId) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 1); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_GOODCRC, 0); // duplicate + pd.receive(PD_CNTRL_DR_SWAP, 2); + ASSERT_EQ(pd.sent.size(), 3u); + EXPECT_EQ(pd.sent[2].id, 1); +} + +TEST(FUSB302BPd, FailedTransmitAdvancesMessageId) { + FakePhy pd; + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + pd.handle_transmit_failed_(); + // A late GoodCRC for the failed message must not advance the counter a second time + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 1); +} + +TEST(FUSB302BPd, RetransmissionIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive_source_caps(0); + EXPECT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.source_caps, 1); +} + +TEST(FUSB302BPd, SoftResetIsAccepted) { + FakePhy pd; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_ACCEPT); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetHookRunsWhenAcceptFails) { + FakePhy pd; + pd.send_ok = false; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetDuringTransitionFallsBackToDefault) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.state_changes = 0; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); +} + +TEST(FUSB302BPd, UnknownControlMessageIsRejected) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_REJECT); +} + +TEST(FUSB302BPd, SinkCapAt5VHasOneObject) { + FakePhy pd; + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_SINK_CAP); + ASSERT_EQ(pd.sent[0].num_of_obj, 1); + const uint32_t pdo = pd.sent[0].data_objects[0]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 100u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_FALSE(pdo & (1u << 28)); + EXPECT_TRUE(pdo & (1u << 26)); +} + +TEST(FUSB302BPd, SinkCapListsRequestedVoltage) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + EXPECT_TRUE(pd.sent[0].data_objects[0] & (1u << 28)); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ(pdo >> 30, 0u); + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_EQ(pdo >> 25, 0u); // capability flags are only valid in the first object +} + +TEST(FUSB302BPd, SinkCapForOutOfRangeVoltageStaysValid) { + FakePhy pd; + pd.set_request_voltage(255); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo >> 20, 0u); // nothing spills into the peak current or flag bits +} + +} // namespace esphome::fusb302b::testing diff --git a/tests/components/fusb302b/test.esp32-idf.yaml b/tests/components/fusb302b/test.esp32-idf.yaml new file mode 100644 index 00000000000..b9ca02073b4 --- /dev/null +++ b/tests/components/fusb302b/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + fusb302b: !include common.yaml diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index ab39dfa61d5..2c0283215f6 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -121,3 +121,11 @@ switch: name: Haier health mode quiet_mode: name: Haier quiet mode + +select: + - platform: haier + haier_id: haier_ac + horizontal_airflow: + name: Haier airflow horizontal + vertical_airflow: + name: Haier airflow vertical diff --git a/tests/components/hdmi_cec/common.yaml b/tests/components/hdmi_cec/common.yaml new file mode 100644 index 00000000000..5ee1764e2e9 --- /dev/null +++ b/tests/components/hdmi_cec/common.yaml @@ -0,0 +1,18 @@ +hdmi_cec: + id: cec + pin: ${cec_pin} # HDMI pin 13, with HDMI pin 17 as ground + address: 0x5 + promiscuous_mode: true + on_message: + - opcode: 0x8F # Give Device Power Status + then: + - hdmi_cec.send: + destination: !lambda return source; + data: [0x90, 0x01] # Report Power Status: standby + - opcode: 0x8C # Give Device Vendor ID + then: + - lambda: |- + id(cec).send(0xf, {0x87, 0x00, 0x09, 0xb0}); + - data: [0x44, 0x41] # User Control Pressed: Volume Up + then: + - logger.log: "HDMI-CEC Volume Up" diff --git a/tests/components/hdmi_cec/test.bk72xx-ard.yaml b/tests/components/hdmi_cec/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..e4709b1d900 --- /dev/null +++ b/tests/components/hdmi_cec/test.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: P6 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.esp32-idf.yaml b/tests/components/hdmi_cec/test.esp32-idf.yaml new file mode 100644 index 00000000000..0210a56b6b6 --- /dev/null +++ b/tests/components/hdmi_cec/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO4 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.esp8266-ard.yaml b/tests/components/hdmi_cec/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7b3f9ffec0e --- /dev/null +++ b/tests/components/hdmi_cec/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO14 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.rp2040-ard.yaml b/tests/components/hdmi_cec/test.rp2040-ard.yaml new file mode 100644 index 00000000000..7b3f9ffec0e --- /dev/null +++ b/tests/components/hdmi_cec/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO14 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/homeassistant/test_python_list.cpp b/tests/components/homeassistant/test_python_list.cpp new file mode 100644 index 00000000000..0feaefee060 --- /dev/null +++ b/tests/components/homeassistant/test_python_list.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include + +#include "esphome/components/homeassistant/homeassistant.h" + +namespace esphome::homeassistant::testing { + +namespace { + +struct Parsed { + bool valid; + std::vector items; +}; + +Parsed parse(const char *text) { + Parsed result; + std::string item; + result.valid = parse_python_list( + StringRef(text), [&](char c) { item += c; }, + [&]() { + result.items.push_back(item); + item.clear(); + }); + return result; +} + +} // namespace + +TEST(PythonList, ReadsStringsWithTheirEscapes) { + auto parsed = parse(R"(['Low', "It's high", 'caf\xe9'])"); + ASSERT_TRUE(parsed.valid); + ASSERT_EQ(parsed.items.size(), 3u); + EXPECT_EQ(parsed.items[0], "Low"); + EXPECT_EQ(parsed.items[1], "It's high"); + EXPECT_EQ(parsed.items[2], "caf\xc3\xa9"); +} + +TEST(PythonList, ReadsTheValueOfEnumMembers) { + auto parsed = parse("[, ]"); + ASSERT_TRUE(parsed.valid); + ASSERT_EQ(parsed.items.size(), 2u); + EXPECT_EQ(parsed.items[0], "hs"); + EXPECT_EQ(parsed.items[1], "color_temp"); +} + +TEST(PythonList, AcceptsAnEmptyList) { + auto parsed = parse("[]"); + EXPECT_TRUE(parsed.valid); + EXPECT_TRUE(parsed.items.empty()); +} + +TEST(PythonList, RejectsMalformedLists) { + for (const char *text : {"", "[", "['a'", "['a',", "['a' 'b']", "['a'] x", "[1]", "['a\\q']", "[]", "[, ]", + "[]", "[]", "[]", "[<'a'>]"}) { + EXPECT_FALSE(parse(text).valid) << text; + } +} + +} // namespace esphome::homeassistant::testing diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index b82c0581d1b..180db583469 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -31,6 +31,10 @@ esphome: request_headers: Content-Type: application/json body: "Some data" + timeout: 15s + - http_request.get: + url: https://esphome.io + timeout: !lambda "return 10000;" - http_request.post: url: https://esphome.io request_headers: diff --git a/tests/components/ld2450/__init__.py b/tests/components/ld2450/__init__.py new file mode 100644 index 00000000000..a4f30dd1f7c --- /dev/null +++ b/tests/components/ld2450/__init__.py @@ -0,0 +1,9 @@ +from esphome.loader import FileResource +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ld2450 is MULTI_CONF, so no codegen runs here to keep polygon.cpp; the tests need it + manifest.resources = manifest.resources + [ + FileResource("esphome.components.ld2450", "polygon.cpp") + ] diff --git a/tests/components/ld2450/common.yaml b/tests/components/ld2450/common.yaml index 617228ca34a..f0d5ecb04fd 100644 --- a/tests/components/ld2450/common.yaml +++ b/tests/components/ld2450/common.yaml @@ -96,6 +96,20 @@ binary_sensor: name: Moving Target has_still_target: name: Still Target + polygon_zones: + - polygon_zone_id: ld2450_couch_zone + name: Couch Occupied + - polygon_zone_id: ld2450_desk_zone + name: Desk Occupied + +text: + - platform: ld2450 + ld2450_id: ld2450_radar + polygon_zones: + - id: ld2450_couch_zone + name: Couch Zone Polygon + - id: ld2450_desk_zone + name: Desk Zone Polygon switch: - platform: ld2450 diff --git a/tests/components/ld2450/ld2450_polygon.cpp b/tests/components/ld2450/ld2450_polygon.cpp new file mode 100644 index 00000000000..b087e83d9c8 --- /dev/null +++ b/tests/components/ld2450/ld2450_polygon.cpp @@ -0,0 +1,162 @@ +#include +#include +#include "esphome/components/ld2450/polygon.h" + +namespace esphome::ld2450::testing { + +static bool parse(Polygon &polygon, const char *str) { return polygon.parse(str, strlen(str)); } + +static std::string format(const Polygon &polygon) { + char buf[POLYGON_STR_SIZE]; + polygon.format(buf); + return buf; +} + +// --- Parsing --- + +TEST(LD2450PolygonTest, ParseValid) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_EQ(polygon.count, 3); + EXPECT_EQ(polygon.points[0].x, -1000); + EXPECT_EQ(polygon.points[0].y, 500); + EXPECT_EQ(polygon.points[2].x, 0); + EXPECT_EQ(polygon.points[2].y, 3000); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseAllowsSpacesAndTrailingSeparator) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, " -1000 , 500 ; 1000,500; 0,3000; ")); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseEmptyDisablesZone) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, "")); + EXPECT_TRUE(polygon.empty()); + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, " ")); + EXPECT_TRUE(polygon.empty()); + EXPECT_EQ(format(polygon), ""); +} + +TEST(LD2450PolygonTest, ParseLimits) { + Polygon polygon; + EXPECT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4861,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4861,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,-1;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,7561")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,99999999999")); +} + +TEST(LD2450PolygonTest, ParsePointCount) { + Polygon polygon; + EXPECT_FALSE(parse(polygon, "0,0")); + EXPECT_FALSE(parse(polygon, "0,0;100,100")); + + std::string text; + for (int i = 0; i < MAX_POLYGON_POINTS; i++) { + text += "-4860,7560;"; + } + ASSERT_TRUE(parse(polygon, text.c_str())); + EXPECT_EQ(polygon.count, MAX_POLYGON_POINTS); + // The longest possible polygon fits in the format buffer + EXPECT_EQ(format(polygon) + ";", text); + + text += "0,0"; + EXPECT_FALSE(parse(polygon, text.c_str())); +} + +TEST(LD2450PolygonTest, ParseInvalidKeepsPreviousPolygon) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + const char *invalid[] = { + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + // Only spaces are allowed around numbers, as in the text entity's pattern + "1,2;\t3,4;5,6", + }; + for (const char *str : invalid) { + EXPECT_FALSE(parse(polygon, str)) << str; + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000") << str; + } +} + +TEST(LD2450PolygonTest, IsValid) { + Polygon polygon; + EXPECT_TRUE(polygon.is_valid()); + polygon.count = 2; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = MAX_POLYGON_POINTS + 1; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = 3; + EXPECT_TRUE(polygon.is_valid()); + polygon.points[1].y = -5; + EXPECT_FALSE(polygon.is_valid()); +} + +// --- Point in polygon --- + +TEST(LD2450PolygonTest, ContainsEmpty) { + Polygon polygon; + EXPECT_FALSE(polygon.contains(0, 0)); + EXPECT_FALSE(polygon.contains(100, 1000)); +} + +TEST(LD2450PolygonTest, ContainsSquare) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,1000;1000,1000;1000,3000;-1000,3000")); + EXPECT_TRUE(polygon.contains(0, 2000)); + EXPECT_TRUE(polygon.contains(-999, 1001)); + EXPECT_TRUE(polygon.contains(999, 2999)); + EXPECT_FALSE(polygon.contains(0, 999)); + EXPECT_FALSE(polygon.contains(0, 3001)); + EXPECT_FALSE(polygon.contains(-1001, 2000)); + EXPECT_FALSE(polygon.contains(1001, 2000)); +} + +TEST(LD2450PolygonTest, ContainsWithReversedWinding) { + Polygon clockwise; + Polygon counter_clockwise; + ASSERT_TRUE(parse(clockwise, "0,1000;2000,3000;-2000,3000")); + ASSERT_TRUE(parse(counter_clockwise, "-2000,3000;2000,3000;0,1000")); + for (int16_t x = -2500; x <= 2500; x += 250) { + for (int16_t y = 500; y <= 3500; y += 250) { + EXPECT_EQ(clockwise.contains(x, y), counter_clockwise.contains(x, y)) << x << "," << y; + } + } + EXPECT_TRUE(clockwise.contains(0, 2000)); + EXPECT_FALSE(clockwise.contains(1500, 2000)); +} + +TEST(LD2450PolygonTest, ContainsConcave) { + // U shape: the notch between the two arms is outside + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-3000,1000;3000,1000;3000,4000;1000,4000;1000,2000;-1000,2000;-1000,4000;-3000,4000")); + EXPECT_TRUE(polygon.contains(-2000, 3000)); + EXPECT_TRUE(polygon.contains(2000, 3000)); + EXPECT_TRUE(polygon.contains(0, 1500)); + EXPECT_FALSE(polygon.contains(0, 3000)); + EXPECT_FALSE(polygon.contains(0, 4500)); +} + +TEST(LD2450PolygonTest, ContainsFarOutsideRadarArea) { + // The radar reports 15-bit coordinates; the cross products must not overflow + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560;-4860,7560")); + EXPECT_FALSE(polygon.contains(32767, 32767)); + EXPECT_FALSE(polygon.contains(-32767, -32767)); + EXPECT_FALSE(polygon.contains(-32767, 3000)); + EXPECT_TRUE(polygon.contains(0, 3000)); +} + +} // namespace esphome::ld2450::testing diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml index c1608f1a3cf..5bf3406938b 100644 --- a/tests/components/lp5562/common.yaml +++ b/tests/components/lp5562/common.yaml @@ -1,6 +1,7 @@ lp5562: - address: 0x30 id: lp5562_1 + i2c_id: i2c_bus output: - platform: lp5562 diff --git a/tests/components/lsm6ds/__init__.py b/tests/components/lsm6ds/__init__.py new file mode 100644 index 00000000000..3b260b767b0 --- /dev/null +++ b/tests/components/lsm6ds/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # lsm6ds declares i2c on its motion platform, which the harness does not walk + manifest.dependencies = manifest.dependencies + ["i2c", "motion"] diff --git a/tests/components/lsm6ds/test_chip_ids.cpp b/tests/components/lsm6ds/test_chip_ids.cpp new file mode 100644 index 00000000000..4b990d69b90 --- /dev/null +++ b/tests/components/lsm6ds/test_chip_ids.cpp @@ -0,0 +1,38 @@ +#include + +#include + +#include "esphome/components/lsm6ds/lsm6ds.h" + +namespace esphome::lsm6ds::testing { + +// WHO_AM_I values and temperature sensitivities from ST's datasheets: +// LSM6DS3 DocID026899, LSM6DS3TR-C DocID030071, LSM6DSO DS12140 + +static const char *name_of(uint8_t who_am_i) { + const LogString *name = chip_name_from_who_am_i(who_am_i); + return name == nullptr ? nullptr : LOG_STR_ARG(name); +} + +TEST(LSM6DSChipIds, Lsm6ds3) { + EXPECT_STREQ(name_of(0x69), "LSM6DS3"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x69), 16); +} + +TEST(LSM6DSChipIds, Lsm6ds3trC) { + EXPECT_STREQ(name_of(0x6A), "LSM6DS3TR-C"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x6A), 256); +} + +TEST(LSM6DSChipIds, Lsm6dso) { + EXPECT_STREQ(name_of(0x6C), "LSM6DSO"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x6C), 256); +} + +TEST(LSM6DSChipIds, UnknownIsRejected) { + EXPECT_EQ(name_of(0x00), nullptr); + EXPECT_EQ(name_of(0x6B), nullptr); + EXPECT_EQ(name_of(0xFF), nullptr); +} + +} // namespace esphome::lsm6ds::testing diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 952dd5f50f1..3ac6e6a81d6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,56 @@ lvgl: - label: text: "2,2" + - menu: + id: settings_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: settings_main_page + sidebar_page: settings_nav_page + header_style: + bg_color: 0x202020 + sidebar_header_style: + bg_color: 0x101010 + on_root_back_button_click: + - logger.log: "settings menu closed at root" + on_value: + - lambda: |- + ESP_LOGD("lvgl", "Menu page %p", page); + pages: + - id: settings_main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: open_advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_advanced_page + - id: settings_advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug options" + - id: settings_nav_page + title: "Navigate" + widgets: + - button: + id: nav_to_main_button + text: "Go to Settings" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_main_page + sidebar: true + # Sized from its text font, with a colour given as an id - color_picker: id: lv_color_picker_1 diff --git a/tests/components/lvgl/validate.host.yaml b/tests/components/lvgl/validate.host.yaml index 77db404a6ec..afdb89b9499 100644 --- a/tests/components/lvgl/validate.host.yaml +++ b/tests/components/lvgl/validate.host.yaml @@ -1,5 +1,5 @@ esphome: - name: lvgl-list-validate + name: lvgl-validate host: @@ -15,6 +15,46 @@ display: lvgl: displays: sdl0 widgets: + # A menu with no sidebar_page (optional), no page titles (optional) and a + # single empty page (no sections, no widgets) as its root. + - menu: + id: menu_a + root_page: menu_a_empty_page + pages: + - id: menu_a_empty_page + # A second, independent menu -- exercises sidebar_page, multiple + # sections, and a page with both sections and ungrouped widgets. + - menu: + id: menu_b + header_mode: bottom_fixed + root_back_btn: enabled + root_page: menu_b_page + sidebar_page: menu_b_nav_page + pages: + - id: menu_b_page + title: "Settings" + sections: + - id: menu_b_section_1 + widgets: + - label: {text: "Wifi"} + - id: menu_b_section_2 + widgets: + - label: {text: "Bluetooth"} + - switch: {} + widgets: + - button: + text: "Advanced" + on_click: + - lvgl.menu.set_page: {id: menu_b, page: menu_b_page} + - id: menu_b_nav_page + widgets: + - button: + text: "Nav" + on_click: + - lvgl.menu.set_page: + id: menu_b + page: menu_b_page + sidebar: true # Two independent lists, each with their own on_add/on_remove and, for list_a, # more than one automation under the same trigger key -- checks that the # per-list trigger bookkeeping is keyed correctly and doesn't require exactly diff --git a/tests/components/main.cpp b/tests/components/main.cpp index f1ceb732f91..4020dd9a505 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -2,6 +2,7 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/preferences.h" /* This special main.cpp replaces the default one. @@ -22,7 +23,12 @@ void original_setup() { // The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python class ResetAppListener : public ::testing::EmptyTestEventListener { - void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; } + void OnTestStart(const ::testing::TestInfo &) override { + esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; + // Entities of the same kind share preference keys, so a value an earlier test saved would be restored + if (esphome::global_preferences != nullptr) + esphome::global_preferences->reset(); + } }; void setup() { diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index fa3984d57e0..726013a0602 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -3,5 +3,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml - -<<: !include common.yaml + micro_wake_word: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index 8806d8046d9..3ed671a764d 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -6,4 +6,5 @@ packages: micro_wake_word: !include common.yaml micro_wake_word: + task_core: 1 prefer_internal_ram: true diff --git a/tests/components/midea/common-uart_only.yaml b/tests/components/midea/common-uart_only.yaml new file mode 100644 index 00000000000..d05df221732 --- /dev/null +++ b/tests/components/midea/common-uart_only.yaml @@ -0,0 +1,6 @@ +climate: + - platform: midea + id: midea_uart_only + name: Midea Climate UART Only + autoconf: true + display_control: UART diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index 25fc2debcd2..bade07be43f 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -33,6 +33,7 @@ climate: timeout: 2s beeper: false autoconf: true + display_control: AUTO visual: min_temperature: 17 °C max_temperature: 30 °C diff --git a/tests/components/midea/test-uart_only.esp32-idf.yaml b/tests/components/midea/test-uart_only.esp32-idf.yaml new file mode 100644 index 00000000000..ce09bbb7b7d --- /dev/null +++ b/tests/components/midea/test-uart_only.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common-uart_only.yaml + +# The MideaUART library includes esp_wifi.h on ESP-IDF +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/midea_portasplit/common.yaml b/tests/components/midea_portasplit/common.yaml new file mode 100644 index 00000000000..8e5c9519479 --- /dev/null +++ b/tests/components/midea_portasplit/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: midea_portasplit + id: midea_portasplit_unit + name: PortaSplit diff --git a/tests/components/midea_portasplit/test.esp32-idf.yaml b/tests/components/midea_portasplit/test.esp32-idf.yaml new file mode 100644 index 00000000000..844ec272d2c --- /dev/null +++ b/tests/components/midea_portasplit/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea_portasplit: !include common.yaml diff --git a/tests/components/midea_portasplit/test.esp8266-ard.yaml b/tests/components/midea_portasplit/test.esp8266-ard.yaml new file mode 100644 index 00000000000..a84a5a57722 --- /dev/null +++ b/tests/components/midea_portasplit/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea_portasplit: !include common.yaml diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 37db86d10a1..9b489154d02 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -81,7 +81,7 @@ struct Fixture { MistralIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/mqtt/common-triggers.yaml b/tests/components/mqtt/common-triggers.yaml new file mode 100644 index 00000000000..3bb22c7c53c --- /dev/null +++ b/tests/components/mqtt/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_message with a payload filter, a subscription callback larger than a pointer with its unsubscribe, +# a callback that unsubscribes itself, and a mutable callback, which keeps its state instead of being copied per call. +mqtt: + id: mqtt_client + on_message: + - topic: livingroom/ota_mode + payload: "ON" + qos: 1 + then: + - logger.log: Got livingroom/ota_mode ON + +esphome: + on_boot: + - lambda: |- + std::string tag = "boxed"; + id(mqtt_client).subscribe("some/topic/boxed", [tag](const std::string &topic, const std::string &payload) { + ESP_LOGD("test", "%s %s", tag.c_str(), payload.c_str()); + }); + id(mqtt_client).unsubscribe("some/topic/boxed"); + id(mqtt_client).subscribe("some/topic/once", [](const std::string &topic, const std::string &payload) { + id(mqtt_client).unsubscribe(topic); + }); + id(mqtt_client).subscribe("some/topic/counted", [n = 0](const std::string &topic, const std::string &payload) mutable { + ESP_LOGD("test", "message %d: %s", ++n, payload.c_str()); + }); diff --git a/tests/components/mqtt/test-triggers.esp32-idf.yaml b/tests/components/mqtt/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..b34863e8737 --- /dev/null +++ b/tests/components/mqtt/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + common: !include common.yaml + triggers: !include common-triggers.yaml diff --git a/tests/components/mqtt/test_topic_match.cpp b/tests/components/mqtt/test_topic_match.cpp new file mode 100644 index 00000000000..b51f45ce478 --- /dev/null +++ b/tests/components/mqtt/test_topic_match.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include "esphome/components/mqtt/mqtt_topic_match.h" + +namespace esphome::mqtt::testing { + +// The previous recursive implementation, kept to check the loop matches it +static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { + if (*message == '\0' && *subscription == '\0') + return true; + if (*message == '\0' || *subscription == '\0') + return false; + bool do_wildcards = is_normal || past_separator; + if (*subscription == '+' && do_wildcards) { + subscription++; + while (*message != '\0' && *message != '/') + message++; + return reference_match(message, subscription, is_normal, true); + } + if (*subscription == '#' && do_wildcards) + return true; + if (*message != *subscription) + return false; + past_separator = past_separator || *subscription == '/'; + return reference_match(message + 1, subscription + 1, is_normal, past_separator); +} + +static bool reference_match(const char *message, const char *subscription) { + return reference_match(message, subscription, *message != '\0' && *message != '$', false); +} + +TEST(MQTTTopicMatch, Exact) { + EXPECT_TRUE(topic_match("a/b/c", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b")); + EXPECT_FALSE(topic_match("a/b", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b/d")); +} + +TEST(MQTTTopicMatch, SingleLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/+/c")); + EXPECT_TRUE(topic_match("a/b/c", "+/+/+")); + EXPECT_TRUE(topic_match("a//c", "a/+/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/+")); + EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c")); +} + +TEST(MQTTTopicMatch, MultiLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/#")); + EXPECT_TRUE(topic_match("a/b/c", "#")); + EXPECT_TRUE(topic_match("a/b/c", "a/+/#")); + EXPECT_FALSE(topic_match("b/c", "a/#")); +} + +TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) { + EXPECT_FALSE(topic_match("$SYS/broker", "#")); + EXPECT_FALSE(topic_match("$SYS/broker", "+/broker")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+")); +} + +TEST(MQTTTopicMatch, EdgeCases) { + EXPECT_TRUE(topic_match("", "")); + EXPECT_FALSE(topic_match("a", "")); + // Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level + EXPECT_FALSE(topic_match("a/b", "a/b/#")); + EXPECT_FALSE(topic_match("a/", "a/+")); + EXPECT_TRUE(topic_match("a/$x", "a/+")); +} + +// Recursion overflowed the ESP32 loop stack at about 250 characters +TEST(MQTTTopicMatch, LongTopic) { + std::string topic(4096, 'x'); + topic[100] = '/'; + EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str())); + std::string other = topic; + other.back() = 'y'; + EXPECT_FALSE(topic_match(topic.c_str(), other.c_str())); +} + +TEST(MQTTTopicMatch, MatchesPreviousImplementation) { + // Small alphabet so wildcards, separators and '$' meet often + static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'}; + static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'}; + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + auto random_string = [&rng](const char *chars, size_t count) { + std::string s(rng() % 8, ' '); + for (char &c : s) + c = chars[rng() % count]; + return s; + }; + for (int i = 0; i < 20000; i++) { + std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS)); + std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS)); + ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()), + reference_match(message.c_str(), subscription.c_str())) + << "message='" << message << "' subscription='" << subscription << "'"; + } +} + +} // namespace esphome::mqtt::testing diff --git a/tests/components/nfc/test_ndef_record_uri.cpp b/tests/components/nfc/test_ndef_record_uri.cpp new file mode 100644 index 00000000000..5629c23c9f6 --- /dev/null +++ b/tests/components/nfc/test_ndef_record_uri.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_record_uri.h" + +namespace esphome::nfc::testing { + +static std::string decode(const std::vector &payload) { return NdefRecordUri(payload).get_payload(); } + +static uint8_t encoded_prefix(const std::string &uri) { return NdefRecordUri(uri).get_encoded_payload()[0]; } + +TEST(NdefRecordUri, DecodeExpandsPrefixCode) { + EXPECT_EQ(decode({0x01, 'a', '.', 'b'}), "http://www.a.b"); + EXPECT_EQ(decode({0x04, 'a', '.', 'b'}), "https://a.b"); + EXPECT_EQ(decode({0x07, 'a'}), "ftp://anonymous:anonymous@a"); +} + +// URI identifier codes from the NFC Forum URI Record Type Definition, kept separate from the +// component's table so a reordered entry fails here +static constexpr const char *SPEC_PREFIXES[] = {"", + "http://www.", + "https://www.", + "http://", + "https://", + "tel:", + "mailto:", + "ftp://anonymous:anonymous@", + "ftp://ftp.", + "ftps://", + "sftp://", + "smb://", + "nfs://", + "ftp://", + "dav://", + "news:", + "telnet://", + "imap:", + "rtsp://", + "urn:", + "pop:", + "sip:", + "sips:", + "tftp:", + "btspp://", + "btl2cap://", + "btgoep://", + "tcpobex://", + "irdaobex://", + "file://", + "urn:epc:id:", + "urn:epc:tag:", + "urn:epc:pat:", + "urn:epc:raw:", + "urn:epc:", + "urn:nfc:"}; + +TEST(NdefRecordUri, DecodeEveryPrefixCode) { + for (size_t code = 1; code < std::size(SPEC_PREFIXES); code++) + EXPECT_EQ(decode({static_cast(code), 'x'}), std::string(SPEC_PREFIXES[code]) + "x") << "code " << code; +} + +TEST(NdefRecordUri, DecodeAcceptsLastPrefixCode) { EXPECT_EQ(decode({0x23, 'x'}), "urn:nfc:x"); } + +TEST(NdefRecordUri, DecodeIgnoresUnknownPrefixCode) { + EXPECT_EQ(decode({0x00, 'x'}), "x"); + EXPECT_EQ(decode({0x24, 'x'}), "x"); +} + +// Encode takes the first prefix in table order that matches, so codes 0x1E to 0x23 are never written; +// these tests pin that current behaviour, not the spec's longest match +TEST(NdefRecordUri, EncodePicksFirstMatchingPrefix) { + EXPECT_EQ(encoded_prefix("https://www.a.b"), 0x02); + EXPECT_EQ(encoded_prefix("http://a.b"), 0x03); + EXPECT_EQ(encoded_prefix("urn:epc:id:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStopsBeforeLastPrefixCode) { + // urn:nfc: (0x23) is never chosen; the shorter urn: (0x13) matches first + EXPECT_EQ(encoded_prefix("urn:nfc:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStripsPrefix) { + const std::vector expected = {0x02, 'a', '.', 'b'}; + EXPECT_EQ(NdefRecordUri("https://www.a.b").get_encoded_payload(), expected); +} + +TEST(NdefRecordUri, EncodeWithoutPrefix) { + EXPECT_EQ(encoded_prefix("ht"), 0x00); + const std::vector expected = {0x00, 'x', 'y'}; + EXPECT_EQ(NdefRecordUri("xy").get_encoded_payload(), expected); +} + +} // namespace esphome::nfc::testing diff --git a/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml b/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..c65ad301f8a --- /dev/null +++ b/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,3 @@ +nrf52: + dcdc: False + dfu: true # the 1200 bps touch reboots into the factory bootloader diff --git a/tests/components/openthread/test-border-router.esp32-c5-idf.yaml b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-border-router.esp32-c6-idf.yaml b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-rcp.esp32-idf.yaml b/tests/components/openthread/test-rcp.esp32-idf.yaml new file mode 100644 index 00000000000..b784f54ff9d --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-idf.yaml @@ -0,0 +1,19 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/components/openthread/test-rcp.esp32-s3-idf.yaml b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml new file mode 100644 index 00000000000..6d643ff91e6 --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + reset_pin: GPIO16 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/components/pca9505/common.yaml b/tests/components/pca9505/common.yaml new file mode 100644 index 00000000000..53614dbcdda --- /dev/null +++ b/tests/components/pca9505/common.yaml @@ -0,0 +1,46 @@ +pca9505: + - id: pca9505_hub + i2c_id: i2c_bus + address: 0x26 + - id: pca9505_hub_second + interrupt_pin: ${interrupt_pin} + i2c_id: i2c_bus + address: 0x27 + +binary_sensor: + # Pins 0 and 39 are the first and last pins, so between them they cover every + # bank index the five-port register maths has to get right. + - platform: gpio + id: pca9505_input_first + name: PCA9505 Binary Sensor First + pin: + pca9505: pca9505_hub + number: 0 + mode: INPUT + inverted: true + - platform: gpio + id: pca9505_input_last + name: PCA9505 Binary Sensor Last + pin: + pca9505: pca9505_hub_second + number: 39 + mode: INPUT + inverted: false + +switch: + - platform: gpio + id: pca9505_output_low + name: PCA9505 Switch Low + pin: + pca9505: pca9505_hub + number: 7 + mode: OUTPUT + inverted: false + - platform: gpio + id: pca9505_output_high + name: PCA9505 Switch High + pin: + pca9505: pca9505_hub + number: 32 + mode: OUTPUT + inverted: true diff --git a/tests/components/pca9505/test.esp32-idf.yaml b/tests/components/pca9505/test.esp32-idf.yaml new file mode 100644 index 00000000000..0dc4dcfd657 --- /dev/null +++ b/tests/components/pca9505/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.esp8266-ard.yaml b/tests/components/pca9505/test.esp8266-ard.yaml new file mode 100644 index 00000000000..84c7105a5a0 --- /dev/null +++ b/tests/components/pca9505/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.rp2040-ard.yaml b/tests/components/pca9505/test.rp2040-ard.yaml new file mode 100644 index 00000000000..9d79eb4f3bd --- /dev/null +++ b/tests/components/pca9505/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp new file mode 100644 index 00000000000..4ee55fd805f --- /dev/null +++ b/tests/components/pca9505/test_pca9505.cpp @@ -0,0 +1,312 @@ +#include + +#include "esphome/components/pca9505/pca9505.h" + +namespace esphome::pca9505::testing { + +// Models the PCA9505 register map: five ports, each with its own input, output, +// polarity inversion and configuration byte. The command byte selects a register +// and its top bit asks the part to auto-increment through the block. +class FakeBus : public i2c::I2CBus { + public: + static constexpr uint8_t ADDRESS = 0x26; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, ADDRESS); + if (this->fail) + return i2c::ERROR_TIMEOUT; + + // Every transfer starts with the command byte. + EXPECT_GE(write_count, 1u); + const uint8_t command = write_buffer[0]; + const bool auto_increment = (command & 0x80) != 0; + uint8_t *block = this->block_for(command & 0x7F); + const uint8_t index = (command & 0x7F) % 8; + + if (read_count != 0) { + this->reads++; + // Without auto-increment the part keeps returning the same register. + EXPECT_TRUE(auto_increment || read_count == 1); + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = block[auto_increment ? index + i : index]; + return i2c::ERROR_OK; + } + + this->writes++; + EXPECT_TRUE(auto_increment || write_count == 2); + for (size_t i = 1; i < write_count; i++) + block[auto_increment ? index + i - 1 : index] = write_buffer[i]; + return i2c::ERROR_OK; + } + + uint8_t *block_for(uint8_t reg) { + if (reg < 0x08) + return this->input; + if (reg < 0x10) + return this->output; + if (reg < 0x18) + return this->invert; + if (reg < 0x20) + return this->config; + return this->mask; + } + + // Power-on reset defaults from the datasheet. + uint8_t input[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t output[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t invert[BANK_COUNT]{0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t config[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t mask[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + unsigned reads{0}; + unsigned writes{0}; + bool fail{false}; +}; + +class PCA9505Test : public ::testing::Test { + protected: + void SetUp() override { + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(FakeBus::ADDRESS); + } + + PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { + PCA9505GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(flags); + pin.set_inverted(inverted); + return pin; + } + + FakeBus bus_; + PCA9505Component component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(PCA9505Test, SetupLeavesEveryPinAnInputWithOutputsLow) { + this->component_.setup(); + + ASSERT_FALSE(this->component_.is_failed()); + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should still be an input"; + EXPECT_EQ(this->bus_.output[bank], 0x00) << "bank " << int(bank) << " should be driven low"; + EXPECT_EQ(this->bus_.invert[bank], 0x00) << "bank " << int(bank) << " should not be inverted"; + } +} + +TEST_F(PCA9505Test, SetupFailsWhenTheDeviceIsAbsent) { + this->bus_.fail = true; + this->component_.setup(); + + EXPECT_TRUE(this->component_.is_failed()); +} + +// The chip has 40 pins, so pin numbers reach past the width of an int shift. +// Every pin must land in its own bank and bit. +TEST_F(PCA9505Test, EachPinMapsToItsOwnBankAndBit) { + this->component_.setup(); + + for (uint8_t number = 0; number < PIN_COUNT; number++) { + PCA9505GPIOPin pin = this->make_pin_(number, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + const uint8_t want = (bank == number / 8) ? uint8_t(1 << (number % 8)) : 0x00; + ASSERT_EQ(this->bus_.output[bank], want) << "pin " << int(number) << " wrote outside its own bit"; + } + + pin.digital_write(false); + ASSERT_EQ(this->bus_.output[number / 8], 0x00) << "pin " << int(number) << " did not clear"; + } +} + +TEST_F(PCA9505Test, ConfiguringAnOutputOnlyTouchesItsOwnBank) { + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(35, gpio::FLAG_OUTPUT); + pin.setup(); + + // Pin 35 is bit 3 of bank 4. The part reads a 1 as an input, so that bit clears. + EXPECT_EQ(this->bus_.config[4], 0xFF & ~(1 << 3)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ReadsReportTheLevelOnTheHighestPin) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT); + pin.setup(); + + this->bus_.input[4] = 0x80; // pin 39 high + EXPECT_TRUE(pin.digital_read()); + + this->component_.loop(); // clears the cache so the next read hits the bus + this->bus_.input[4] = 0x00; + EXPECT_FALSE(pin.digital_read()); +} + +TEST_F(PCA9505Test, InvertedPinFlipsTheReportedLevel) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT, /*inverted=*/true); + pin.setup(); + + this->bus_.input[4] = 0x80; + EXPECT_FALSE(pin.digital_read()); + + this->component_.loop(); + this->bus_.input[4] = 0x00; + EXPECT_TRUE(pin.digital_read()); +} + +// Eight pins sharing a port must cost one transfer per loop, not eight. +TEST_F(PCA9505Test, PinsInOneBankShareASingleRead) { + this->component_.setup(); + + PCA9505GPIOPin pins[8]; + for (uint8_t i = 0; i < 8; i++) { + pins[i] = this->make_pin_(8 + i, gpio::FLAG_INPUT); + pins[i].setup(); + } + + this->bus_.input[1] = 0xA5; + const unsigned before = this->bus_.reads; + for (uint8_t i = 0; i < 8; i++) + EXPECT_EQ(pins[i].digital_read(), ((0xA5 >> i) & 1) != 0) << "pin " << int(8 + i); + EXPECT_EQ(this->bus_.reads - before, 1u); +} + +// Pins in different ports are cached separately, so each port costs its own read. +TEST_F(PCA9505Test, EachBankIsReadSeparately) { + this->component_.setup(); + + PCA9505GPIOPin pins[BANK_COUNT]; + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + pins[bank] = this->make_pin_(bank * 8, gpio::FLAG_INPUT); + pins[bank].setup(); + } + + const unsigned before = this->bus_.reads; + for (auto &pin : pins) + pin.digital_read(); + EXPECT_EQ(this->bus_.reads - before, BANK_COUNT); +} + +TEST_F(PCA9505Test, WritesAreSkippedOnceSetupHasFailed) { + this->bus_.fail = true; + this->component_.setup(); + ASSERT_TRUE(this->component_.is_failed()); + + this->bus_.fail = false; + const unsigned before = this->bus_.writes; + PCA9505GPIOPin pin = this->make_pin_(0, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + EXPECT_EQ(this->bus_.writes, before); +} + +// A 1 in a mask register blocks that pin from pulling INT low. +TEST_F(PCA9505Test, SetupMasksEveryInterrupt) { + // Pretend a previous run left port 2 unmasked. The chip keeps its registers + // through an MCU reboot, so setup must write them rather than assume the reset state. + this->bus_.mask[2] = 0x00; + this->component_.setup(); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be masked"; +} + +TEST_F(PCA9505Test, ConfiguringAnInputUnmasksOnlyThatPin) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + // Pin 34 is bit 2 of bank 4, and a 0 lets it through. + EXPECT_EQ(this->bus_.mask[4], 0xFF & ~(1 << 2)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ConfiguringAnOutputLeavesItMasked) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_OUTPUT); + pin.setup(); + + // An output must not be able to pull INT low. + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +TEST_F(PCA9505Test, WithoutAnInterruptPinTheMasksAreNotRewritten) { + this->component_.setup(); + const unsigned before = this->bus_.writes; + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + EXPECT_EQ(this->bus_.writes, before + 1); // the configuration register only + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +// Without an interrupt pin an input turns the polling loop on; with one it stays off. +TEST_F(PCA9505Test, AnInputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + PCA9505GPIOPin polled = this->make_pin_(3, gpio::FLAG_INPUT); + polled.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + PCA9505Component with_interrupt; + with_interrupt.set_i2c_bus(&this->bus_); + with_interrupt.set_i2c_address(FakeBus::ADDRESS); + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + PCA9505GPIOPin pin; + pin.set_parent(&with_interrupt); + pin.set_pin(3); + pin.set_flags(gpio::FLAG_INPUT); + pin.setup(); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +// With an interrupt pin a read is served from the cache until loop() clears it. +TEST_F(PCA9505Test, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(3, gpio::FLAG_INPUT); + pin.setup(); + + const unsigned before = this->bus_.reads; + pin.digital_read(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 1); + + this->component_.loop(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 2); +} + +// The loop only turns itself off once INT has gone high again, so a change that +// lands during the read cannot leave the cache stale with no edge to wake it. +TEST_F(PCA9505Test, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // the host pin reads back its inverted flag: INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +} // namespace esphome::pca9505::testing diff --git a/tests/components/pcf85063/__init__.py b/tests/components/pcf85063/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/pcf85063/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/pcf85063/test_pcf85063.cpp b/tests/components/pcf85063/test_pcf85063.cpp new file mode 100644 index 00000000000..0e5c5e6c20d --- /dev/null +++ b/tests/components/pcf85063/test_pcf85063.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/pcf85063/pcf85063.h" +#include "esphome/core/time.h" + +namespace esphome::pcf85063::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x08; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 18 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class PCF85063Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + PCF85063Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(PCF85063Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(PCF85063Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::pcf85063::testing diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index e7a5e2c18f2..293c363d5d6 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -69,7 +69,7 @@ struct Fixture { PDPioneerIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp index 75f9cf6d088..25125877f25 100644 --- a/tests/components/pn532/pn532_test.cpp +++ b/tests/components/pn532/pn532_test.cpp @@ -163,9 +163,8 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { pn532.responses.push_back(first); pn532.responses.push_back(second); - UltralightReadBuffer data; - ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); - ASSERT_EQ(data.size(), 20u); + std::array data{}; + ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, data)); EXPECT_EQ(data[15], 15); EXPECT_EQ(data[16], 0x10); EXPECT_EQ(data[19], 0x13); @@ -177,15 +176,14 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { TEST(PN532Mifare, UltralightReadRejectsBadResponses) { FakePN532 pn532; - UltralightReadBuffer data; + std::array data{}; pn532.responses.push_back({0x00, 0x01, 0x02}); // short response - EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, data)); std::vector failed(17, 0x00); failed[0] = 0x01; // timeout status pn532.responses.push_back(failed); - data.clear(); - EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, data)); } } // namespace esphome::pn532 diff --git a/tests/components/pn532/test_mifare_ultralight_ndef.cpp b/tests/components/pn532/test_mifare_ultralight_ndef.cpp new file mode 100644 index 00000000000..8d1547fc0e4 --- /dev/null +++ b/tests/components/pn532/test_mifare_ultralight_ndef.cpp @@ -0,0 +1,273 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_message.h" +#include "esphome/components/nfc/ndef_record.h" +#include "esphome/components/nfc/nfc.h" +#include "esphome/components/pn532/pn532.h" + +namespace esphome::pn532::testing { + +/// Exposes the protected Ultralight helpers and stands in for the bus. Each MIFARE READ is answered with the +/// 16 bytes of `tag_memory` starting at the requested page, so the full read path can run against synthetic tags. +class TestablePN532 : public PN532 { + public: + using PN532::find_mifare_ultralight_ndef_; + using PN532::read_mifare_ultralight_tag_; + + /// Tag memory starting at page 0 (4 bytes per page) + std::vector tag_memory; + + protected: + bool is_read_ready() override { return true; } + bool write_data(std::span data) override { + this->last_written_.assign(data.begin(), data.end()); + return true; + } + // only used for ACK frames; index 0 is the I2C status byte + bool read_data(PN532Frame &data, size_t len) override { + data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; + return true; + } + bool read_response(uint8_t command, PN532Frame &data) override { + // frame is preamble, start code (2), LEN, LCS, TFI, InDataExchange, Tg, READ, page, DCS, postamble + if (this->last_written_.size() < 3) + return false; + const size_t start = this->last_written_[this->last_written_.size() - 3] * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + data.clear(); + data.push_back(0x00); // status: success + for (size_t i = start; i < start + MIFARE_READ_SIZE; i++) { + data.push_back(i < this->tag_memory.size() ? this->tag_memory[i] : 0x00); + } + return true; + } + + std::vector last_written_; +}; + +/// Builds Ultralight/NTAG tag memory holding `tlv` at page 4, with a capability container that reports +/// `capacity_bytes` of user memory. +std::vector make_tag_memory(const std::vector &tlv, uint16_t capacity_bytes) { + // pages 0-2: UID, lock bytes; page 3: CC (magic, version, size / 8, access) + std::vector memory(12, 0x00); + memory.insert(memory.end(), {0xE1, 0x10, static_cast(capacity_bytes / 8), 0x00}); + memory.insert(memory.end(), tlv.begin(), tlv.end()); + memory.resize(16 + capacity_bytes, 0x00); + return memory; +} + +/// Wraps an encoded NDEF message in a Type 2 Tag TLV, using the long form when it is 255 bytes or more. +std::vector wrap_tlv(const std::vector &encoded) { + std::vector tlv = {0x03}; + if (encoded.size() < 255) { + tlv.push_back(static_cast(encoded.size())); + } else { + tlv.insert(tlv.end(), {0xFF, static_cast(encoded.size() >> 8), static_cast(encoded.size())}); + } + tlv.insert(tlv.end(), encoded.begin(), encoded.end()); + tlv.push_back(0xFE); + return tlv; +} + +// Builds a 16-byte page_3_to_6 buffer: 4 filler bytes for page 3 (unused by this function), +// followed by the given bytes starting at page 4, zero-padded out to the real 4-page read size. +std::vector make_page_3_to_6(std::initializer_list page4_onward) { + std::vector data = {0x00, 0x00, 0x00, 0x00}; + data.insert(data.end(), page4_onward); + data.resize(16, 0x00); + return data; +} + +TEST(FindMifareUltralightNdef, ShortFormAtPage4Start) { + TestablePN532 pn532; + // 0x03 LEN -- message starts right after. + auto data = make_page_3_to_6({0x03, 0x20}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x20u); + EXPECT_EQ(message_start_index, 2); +} + +TEST(FindMifareUltralightNdef, ShortFormAfterLeadingPadding) { + TestablePN532 pn532; + // A non-0x03 byte at page 4 (e.g. a lock/CC TLV) pushes the message TLV to offset 5. + auto data = make_page_3_to_6({0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x10u); + EXPECT_EQ(message_start_index, 7); +} + +// Regression test: prior to this fix, a >=255 byte NDEF message's TLV length was always read as +// a single byte, so the 0xFF long-form marker was mistaken for a length of 255 and the two real +// length bytes were mistaken for message content. That misalignment is what produced the +// "Corrupt record encountered; NdefMessage constructor aborting" error for large tags +TEST(FindMifareUltralightNdef, LongFormAtPage4Start) { + TestablePN532 pn532; + // 0x03 FF 01 2C -> long-form length = 0x012C = 300 + auto data = make_page_3_to_6({0x03, 0xFF, 0x01, 0x2C, 0xD1, 0x01, 0x0C, 0x55}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 300u); + EXPECT_EQ(message_start_index, 4); +} + +TEST(FindMifareUltralightNdef, LongFormAfterLeadingPadding) { + TestablePN532 pn532; + auto data = make_page_3_to_6({0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0x02, 0x00}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x0200u); // 512 + EXPECT_EQ(message_start_index, 9); +} + +TEST(FindMifareUltralightNdef, TooShortBufferIsRejected) { + TestablePN532 pn532; + std::vector data = {0x00, 0x00, 0x00, 0x00, 0x03, 0x05}; // well under the size floor + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +TEST(FindMifareUltralightNdef, NoTlvTagIsRejected) { + TestablePN532 pn532; + auto data = make_page_3_to_6({0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +// Regression test: the entry size guard (page_3_to_6.size() > p4_offset + 6) is loose enough to admit +// buffers that are too short to hold the two long-form length bytes once the TLV is offset by leading +// padding (tlv_offset == 5). Before this fix, that case silently fell through to the short-form branch +// and reported a bogus 255-byte message instead of failing. +TEST(FindMifareUltralightNdef, LongFormMarkerWithMissingLengthBytesIsRejected) { + TestablePN532 pn532; + // Padding pushes the TLV to offset 5; 0x03 FF present, but the two length bytes are missing (size 11). + std::vector data = {0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF}; + ASSERT_EQ(data.size(), 11u); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +/// End-to-end regression guard: builds a real 3-record NDEF message (URL, text, and a MIME +/// record) padded past 255 bytes and wraps it in a Type 2 Tag TLV the way a real tag stores it, +/// then runs the wrapper through find_mifare_ultralight_ndef_ to locate it, and feeds the located slice back +/// into NdefMessage to confirm all three records survive the round trip. +TEST(FindMifareUltralightNdef, LongFormMessageRoundTripsThroughNdefMessage) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + message.add_text_record("Hello, ESPHome!"); + + std::string json_payload = R"({"key":"value","pad":")" + std::string(255, 'x') + R"("})"; + auto json_record = make_unique(); + json_record->set_tnf(nfc::TNF_MIME_MEDIA); + json_record->set_type("application/json"); + json_record->set_payload(json_payload); + message.add_record(std::move(json_record)); + + auto encoded = message.encode(); + ASSERT_GE(encoded.size(), 255u) << "test fixture must exercise the long-form TLV path"; + + // Wrap exactly like write_mifare_ultralight_tag_ does: 0x03 FF LEN_HI LEN_LO ... 0xFE + std::vector wrapped = {0x03, 0xFF, static_cast((encoded.size() >> 8) & 0xFF), + static_cast(encoded.size() & 0xFF)}; + wrapped.insert(wrapped.end(), encoded.begin(), encoded.end()); + wrapped.push_back(0xFE); + + // Real tag memory is [page 3 (4 bytes, unused here)] [wrapped TLV ...]. + std::vector tag_memory = {0x00, 0x00, 0x00, 0x00}; + tag_memory.insert(tag_memory.end(), wrapped.begin(), wrapped.end()); + + // find_mifare_ultralight_ndef_ only ever sees the first 16 bytes (pages 3-6) when locating the + // TLV header -- exactly what read_mifare_ultralight_tag_'s first, fixed-size read provides. + std::vector page_3_to_6(tag_memory.begin(), tag_memory.begin() + 16); + + TestablePN532 pn532; + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(page_3_to_6, message_length, message_start_index)); + EXPECT_EQ(message_length, encoded.size()); + EXPECT_EQ(message_start_index, 4); + + // Reconstruct what read_mifare_ultralight_tag_ hands to NdefMessage: page 3 trimmed off, then + // sliced to [message_start_index, message_start_index + message_length). + std::vector full_tag_data(tag_memory.begin() + 4, tag_memory.end()); + std::vector message_bytes(full_tag_data.begin() + message_start_index, + full_tag_data.begin() + message_start_index + message_length); + + nfc::NdefMessage parsed(message_bytes); + const auto &records = parsed.get_records(); + ASSERT_EQ(records.size(), 3u); + EXPECT_EQ(records[0]->get_type(), "U"); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); + EXPECT_EQ(records[1]->get_type(), "T"); + EXPECT_EQ(records[1]->get_payload(), "Hello, ESPHome!"); + EXPECT_EQ(records[2]->get_type(), "application/json"); + EXPECT_EQ(records[2]->get_payload(), json_payload); +} + +/// End-to-end: a tag holding a message well past 255 bytes is read in full. The read buffer must grow to fit the +/// whole message; a fixed 255-byte buffer would cut it short and the records would fail to parse. +TEST(ReadMifareUltralightTag, LongFormMessageIsReadInFull) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + const std::string text(600, 'x'); + message.add_text_record(text); + auto encoded = message.encode(); + ASSERT_GT(encoded.size(), 600u); + + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory(wrap_tlv(encoded), 888); // NTAG216 + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + ASSERT_TRUE(tag->has_ndef_message()); + const auto &records = tag->get_ndef_message()->get_records(); + ASSERT_EQ(records.size(), 2u); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); + EXPECT_EQ(records[1]->get_payload(), text); +} + +TEST(ReadMifareUltralightTag, ShortFormMessageIsRead) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + auto encoded = message.encode(); + + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory(wrap_tlv(encoded), 144); // NTAG213 + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + ASSERT_TRUE(tag->has_ndef_message()); + const auto &records = tag->get_ndef_message()->get_records(); + ASSERT_EQ(records.size(), 1u); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); +} + +/// A length larger than the tag's capacity is rejected before any further pages are read. +TEST(ReadMifareUltralightTag, LengthBeyondCapacityIsRejected) { + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory({0x03, 0xFF, 0x01, 0x2C}, 144); // claims 300 bytes on a 144-byte tag + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + EXPECT_FALSE(tag->has_ndef_message()); +} + +} // namespace esphome::pn532::testing diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp new file mode 100644 index 00000000000..3c706f29877 --- /dev/null +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -0,0 +1,109 @@ +#include + +#include + +#include "esphome/components/rtttl/rtttl.h" + +namespace esphome::rtttl::testing { + +// Reads the parser state the tests check; Rtttl is final, so this is a friend rather than a subclass. +class RtttlParserAccess { + public: + static uint8_t denominator(const Rtttl &p) { return p.default_note_denominator_; } + static uint8_t octave(const Rtttl &p) { return p.default_octave_; } + static uint16_t wholenote(const Rtttl &p) { return p.wholenote_duration_; } + static uint16_t note_duration(const Rtttl &p) { return p.note_duration_; } + static uint32_t freq(const Rtttl &p) { return p.output_freq_; } + static size_t position(const Rtttl &p) { return p.position_; } + static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } + static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } +}; + +using Access = RtttlParserAccess; + +static const char SONG[] = "test:d=8,o=5,b=100:c"; + +TEST(RtttlParser, StaticSongParsesTheHeader) { + Rtttl player; + player.play(StaticSong{SONG}); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 100); + // Positioned on the first note after the second ':' + EXPECT_EQ(Access::position(player), 19u); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); +} + +TEST(RtttlParser, OwnedSongParsesTheSameAndOutlivesTheCaller) { + Rtttl player; + { + std::string song(SONG); + player.play(song); + } + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::at(player, 0), 't'); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); + EXPECT_EQ(Access::at(player, 100), '\0'); +} + +TEST(RtttlParser, ControlOrderDoesNotMatter) { + for (const char *song : {"a:b=100,o=5,d=8:c", "a:o=5,b=100,d=8:c", "a:d=8,b=100,o=5:c"}) { + Rtttl player; + player.play(StaticSong{song}); + EXPECT_EQ(Access::denominator(player), 8) << song; + EXPECT_EQ(Access::octave(player), 5) << song; + EXPECT_EQ(Access::wholenote(player), 2400) << song; + } +} + +TEST(RtttlParser, MissingControlsUseDefaults) { + Rtttl player; + player.play(StaticSong{"a::c"}); + EXPECT_EQ(Access::denominator(player), DEFAULT_NOTE_DENOMINATOR); + EXPECT_EQ(Access::octave(player), DEFAULT_OCTAVE); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 63); +} + +TEST(RtttlParser, NotesReadFromStaticStorage) { + Rtttl player; + player.play(StaticSong{"t:d=4,o=5,b=120:8c,p"}); + Access::set_running(player); + player.loop(); + // C5 at 523 Hz for an eighth of a 2000 ms whole note + EXPECT_EQ(Access::freq(player), 523u); + EXPECT_EQ(Access::note_duration(player), 250); + player.loop(); + // A pause for a default quarter note + EXPECT_EQ(Access::freq(player), 0u); + EXPECT_EQ(Access::note_duration(player), 500); + player.loop(); + // End of the song + EXPECT_EQ(Access::at(player, Access::position(player)), '\0'); +} + +TEST(RtttlParser, RefusesANewSongWhilePlaying) { + Rtttl player; + player.play(StaticSong{"first:d=8:c"}); + Access::set_running(player); + player.play(std::string("second:d=16:c")); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::at(player, 0), 'f'); +} + +TEST(RtttlParser, ClampsASongLongerThanTheLengthField) { + Rtttl player; + player.play(std::string(70000, 'a')); + EXPECT_EQ(Access::at(player, 65534), 'a'); + EXPECT_EQ(Access::at(player, 65535), '\0'); + EXPECT_EQ(Access::note_duration(player), 0); +} + +TEST(RtttlParser, RejectsASongWithoutAName) { + Rtttl player; + player.play(StaticSong{"no colon here"}); + EXPECT_EQ(Access::note_duration(player), 0); + EXPECT_EQ(Access::position(player), 13u); +} + +} // namespace esphome::rtttl::testing diff --git a/tests/components/rx8111/__init__.py b/tests/components/rx8111/__init__.py new file mode 100644 index 00000000000..ad1f584730f --- /dev/null +++ b/tests/components/rx8111/__init__.py @@ -0,0 +1,7 @@ +"""Load the RX8111 time platform's dependencies in the C++ test build.""" + +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/rx8111/common.yaml b/tests/components/rx8111/common.yaml new file mode 100644 index 00000000000..c53dcad6606 --- /dev/null +++ b/tests/components/rx8111/common.yaml @@ -0,0 +1,10 @@ +esphome: + on_boot: + then: + - rx8111.read_time: rtc + - rx8111.write_time: rtc + +time: + - platform: rx8111 + id: rtc + i2c_id: i2c_bus diff --git a/tests/components/rx8111/test.esp32-ard.yaml b/tests/components/rx8111/test.esp32-ard.yaml new file mode 100644 index 00000000000..9fc690ff826 --- /dev/null +++ b/tests/components/rx8111/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp32-idf.yaml b/tests/components/rx8111/test.esp32-idf.yaml new file mode 100644 index 00000000000..5368401cdae --- /dev/null +++ b/tests/components/rx8111/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp8266-ard.yaml b/tests/components/rx8111/test.esp8266-ard.yaml new file mode 100644 index 00000000000..0d9801376df --- /dev/null +++ b/tests/components/rx8111/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.nrf52-adafruit.yaml b/tests/components/rx8111/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..47fa151be55 --- /dev/null +++ b/tests/components/rx8111/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.rp2040-ard.yaml b/tests/components/rx8111/test.rp2040-ard.yaml new file mode 100644 index 00000000000..4e7128f3b2a --- /dev/null +++ b/tests/components/rx8111/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test_rx8111.cpp b/tests/components/rx8111/test_rx8111.cpp new file mode 100644 index 00000000000..15a7b75c5db --- /dev/null +++ b/tests/components/rx8111/test_rx8111.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include + +#include "esphome/components/rx8111/rx8111.h" + +namespace esphome::rx8111::testing { + +static constexpr uint8_t ADDRESS = 0x32; +static constexpr uint8_t SEC = 0x10; +static constexpr uint8_t WEEK = 0x13; +static constexpr uint8_t DAY = 0x14; +static constexpr uint8_t MONTH = 0x15; +static constexpr uint8_t FLAG = 0x1E; +static constexpr uint8_t CONTROL = 0x1F; + +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + std::vector writes; + int fail_read{-1}; + int fail_write{-1}; + bool latch_event_before_flag_write{false}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, + size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + EXPECT_GT(out_size, 0); + if (out_size == 0) + return i2c::ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (reg == this->fail_read) + return i2c::ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + } else { + if (reg == this->fail_write) + return i2c::ERROR_TIMEOUT; + this->writes.push_back(reg); + if (reg == FLAG) { + if (this->latch_event_before_flag_write) + this->regs[FLAG] |= 0x04; // EVF latches immediately before FLAG write. + // RX8111 flags are write-zero-to-clear; writing one leaves the bit unchanged. + this->regs[FLAG] &= out[1]; + } else { + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + } + } + return i2c::ERROR_OK; + } +}; + +class RX8111Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { this->syncs_++; }); + } + + RegisterBus bus_; + RX8111Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(RX8111Test, WritesCalendarAndPreservesEventFlagsWithoutReadModifyWrite) { + this->bus_.regs[FLAG] = 0xB8; // POR + existing UF/TF/AF. + this->bus_.regs[CONTROL] = 0x5C; + this->bus_.latch_event_before_flag_write = true; + const auto before = ESPTime::from_epoch_utc(::time(nullptr)); + this->rtc_.write_time(); + const auto after = ESPTime::from_epoch_utc(::time(nullptr)); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x5C); + EXPECT_EQ(this->bus_.regs[FLAG], 0x3C); // The newly latched EVF survives too. + auto bcd = [](int value) { return (value / 10) * 16 + value % 10; }; + EXPECT_TRUE((this->bus_.regs[SEC] == bcd(before.second) && this->bus_.regs[DAY] == bcd(before.day_of_month)) || + (this->bus_.regs[SEC] == bcd(after.second) && this->bus_.regs[DAY] == bcd(after.day_of_month))); + EXPECT_TRUE(this->bus_.regs[WEEK] == (1 << (before.day_of_week - 1)) || + this->bus_.regs[WEEK] == (1 << (after.day_of_week - 1))); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, SEC, CONTROL, FLAG})); +} + +TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) { + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + this->bus_.regs[FLAG] = 0x83; + this->bus_.writes.clear(); + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.regs[FLAG] = 0; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_FALSE(this->rtc_.status_has_warning()); + const uint8_t valid_weekday = this->bus_.regs[WEEK]; + const uint8_t valid_day = this->bus_.regs[DAY]; + const uint8_t valid_month = this->bus_.regs[MONTH]; + const uint8_t wrong_weekday = valid_weekday == 1 ? 2 : 1; + for (uint8_t weekday : {uint8_t{0x00}, uint8_t{0x03}, uint8_t{0x80}, wrong_weekday}) { + this->bus_.regs[WEEK] = weekday; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1) << "weekday " << unsigned(weekday); + EXPECT_TRUE(this->rtc_.status_has_warning()); + } + this->bus_.regs[WEEK] = valid_weekday; + this->bus_.regs[DAY] = 0x32; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = 0x31; + this->bus_.regs[MONTH] = 0x04; // April 31 is always invalid. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = valid_day; + this->bus_.regs[MONTH] = valid_month; + this->bus_.regs[SEC] = 0x6A; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x60; // ESPTime permits leap seconds, but RX8111 only counts 00..59. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x00; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 2); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, I2CFailuresDoNotSyncOrLeaveTheCounterStopped) { + this->bus_.fail_read = FLAG; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = SEC; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + this->bus_.fail_read = -1; + this->bus_.fail_write = CONTROL; + this->rtc_.write_time(); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.fail_write = SEC; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, CONTROL})); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); + this->bus_.fail_write = FLAG; + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0x83); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); +} + +} // namespace esphome::rx8111::testing diff --git a/tests/components/seeed_mr60bha2/common-binary-sensor.yaml b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml new file mode 100644 index 00000000000..b329cdbef90 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml @@ -0,0 +1,9 @@ +# The hub with only the presence binary sensor, which nests a USE_SENSOR guard in process_frame_() +seeed_mr60bha2: + id: my_seeed_mr60bha2_binary_sensor + +binary_sensor: + - platform: seeed_mr60bha2 + mr60bha2_id: my_seeed_mr60bha2_binary_sensor + has_target: + name: "Person Information Only" diff --git a/tests/components/seeed_mr60bha2/common-minimal.yaml b/tests/components/seeed_mr60bha2/common-minimal.yaml new file mode 100644 index 00000000000..571e5a57386 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the sensors and binary sensor that process_frame_() guards +seeed_mr60bha2: + id: my_seeed_mr60bha2_minimal diff --git a/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml new file mode 100644 index 00000000000..0f5c050ba73 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-binary-sensor.yaml diff --git a/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..a97c76ca583 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml new file mode 100644 index 00000000000..18801075447 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4f849c315e2 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60bha2: !include common.yaml diff --git a/tests/components/seeed_mr60fda2/common-minimal.yaml b/tests/components/seeed_mr60fda2/common-minimal.yaml new file mode 100644 index 00000000000..da329aefbe3 --- /dev/null +++ b/tests/components/seeed_mr60fda2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the binary sensors and selects that process_frame_() guards +seeed_mr60fda2: + id: my_seeed_mr60fda2_minimal diff --git a/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..4338267485c --- /dev/null +++ b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml new file mode 100644 index 00000000000..07faf09a8df --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..f059ca9f66f --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60fda2: !include common.yaml diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index c9b6f22c0fc..6ab80a76b21 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -48,3 +48,17 @@ sensor: formaldehyde: name: Formaldehyde address: 0x6B + +button: + - platform: template + name: Fan Cleaning + on_press: + - sen6x.stop_measurement: sen6x_sensor + - delay: 2s + - sen6x.start_fan_cleaning: sen6x_sensor + - delay: 11s + - sen6x.start_measurement: sen6x_sensor + - platform: template + name: SHT Heater + on_press: + - sen6x.activate_sht_heater: sen6x_sensor diff --git a/tests/components/serial_proxy/__init__.py b/tests/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..913e8d8b37e --- /dev/null +++ b/tests/components/serial_proxy/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness gives a multi-instance component an empty list and never calls its to_code, + # so take a single empty config instead and emit what the proxy messages and handlers + # need. No proxy is configured, so the count is 0, which keeps the capability messages as + # the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only. + manifest.multi_conf = False + + async def to_code_testing(config): + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/serial_proxy/test.host.yaml b/tests/components/serial_proxy/test.host.yaml new file mode 100644 index 00000000000..c186d71b9b5 --- /dev/null +++ b/tests/components/serial_proxy/test.host.yaml @@ -0,0 +1,14 @@ +# The host UART drives DTR/RTS itself, which exercises the host modem control path +network: + +api: + +uart: + - id: uart_bus + port: "/dev/ttyS0" + baud_rate: 9600 + +serial_proxy: + - id: serial_proxy_1 + name: Test Serial Port + port_type: RS232 diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp new file mode 100644 index 00000000000..c76757bbee6 --- /dev/null +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check. +static std::vector encode(const UsbDeviceDescriptor &msg) { + const uint32_t size = msg.calculate_size(); + APIBuffer buf; + EXPECT_TRUE(buf.resize(size == 0 ? 1 : size)); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buf.data()), size); + return {buf.data(), end}; +} + +TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x10C4; + msg.product_id = 0xEA60; + msg.bcd_device = 0x0100; + msg.interface_number = 1; + msg.interface_class = 0xFF; + msg.port_number = 2; + + const std::vector expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80, + 0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02}; + EXPECT_EQ(encode(msg), expected); +} + +TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { + // The new fields are additive with zero defaults, so an old client sees the message it always did + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01}; + EXPECT_EQ(encode(msg), expected); +} + +} // namespace esphome::api::testing diff --git a/tests/components/shelly_dimmer/common-update.yaml b/tests/components/shelly_dimmer/common-update.yaml new file mode 100644 index 00000000000..babf55b2300 --- /dev/null +++ b/tests/components/shelly_dimmer/common-update.yaml @@ -0,0 +1,15 @@ +light: + - platform: shelly_dimmer + name: Shelly Dimmer Light + power: + name: Shelly Dimmer Power + voltage: + name: Shelly Dimmer Voltage + current: + name: Shelly Dimmer Current + max_brightness: 500 + firmware: + version: "51.6" + update: true + nrst_pin: 13 + boot0_pin: 14 diff --git a/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml new file mode 100644 index 00000000000..185686969df --- /dev/null +++ b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + shelly_dimmer: !include common-update.yaml diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 98be8190b00..a5be9225e52 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -102,6 +102,19 @@ TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { EXPECT_TRUE(this->link_.in_backoff()); } +TEST_F(TcpClientLinkClock, ConnectingUntilThePendingAttemptEnds) { + this->link_.begin("link_test"); + EXPECT_FALSE(this->link_.connecting()); + this->set_pending_socket(); + this->link_.note_attempt(); + this->link_.poll(); + EXPECT_TRUE(this->link_.connecting()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.connecting()); + EXPECT_FALSE(this->link_.connected()); +} + TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { this->link_.set_reconnect_interval(20000); this->link_.begin("link_test"); @@ -177,6 +190,7 @@ TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); + EXPECT_FALSE(p.link_.connecting()); EXPECT_EQ(p.link_.queue(reinterpret_cast("ping"), 4), 4u); EXPECT_TRUE(p.link_.flush_tx()); char buf[8]; diff --git a/tests/components/sy6970/common.yaml b/tests/components/sy6970/common.yaml index 53699fe6fb8..74f57f28210 100644 --- a/tests/components/sy6970/common.yaml +++ b/tests/components/sy6970/common.yaml @@ -9,6 +9,7 @@ sy6970: precharge_current: 128 charge_enabled: true enable_adc: true + i2c_watchdog_timeout: 80S update_interval: 5s sensor: diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index e53c3ba8233..8714db23f42 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -78,9 +78,47 @@ esphome: seup: 1 sedn: 0 seimin: true + - tmc2209.chopconf: + id: tmc2209_step_dir + toff: 4 + tbl: 2 + hend: 0 + hstrt: !lambda "return 5;" + - tmc2209.pwmconf: + id: tmc2209_serial + pwm_ofs: 36 + pwm_grad: 14 + pwm_freq: 1 + pwm_autoscale: true + pwm_autograd: true + pwm_reg: 4 + pwm_lim: 12 - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial target: 1000 - tmc2209.disable: id: tmc2209_step_dir + +sensor: + - platform: tmc2209 + tmc2209_id: tmc2209_step_dir + update_interval: 10s + actual_current: + name: Actual Current + stallguard_result: + name: StallGuard Result + motor_load: + name: Motor Load + pwm_scale_sum: + name: PWM Scale Sum + pwm_scale_auto: + name: PWM Scale Auto + pwm_ofs_auto: + name: PWM Offset Auto + pwm_grad_auto: + name: PWM Gradient Auto + - platform: tmc2209 + tmc2209_id: tmc2209_serial + stallguard_result: + name: Serial StallGuard Result diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 346efac83ec..60de2d34958 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,11 +6,12 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - for platform in ("sensor", "binary_sensor", "number"): + for platform in ("sensor", "binary_sensor", "number", "text_sensor"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") cg.add_define("USE_TMP102_NUMBER") + cg.add_define("USE_TMP102_TEXT_SENSOR") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + [ @@ -18,4 +19,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "sensor", "binary_sensor", "number", + "text_sensor", ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index dfc2e2e2245..3d1f66da5bb 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -30,3 +30,8 @@ binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature name: TMP102 Alert + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml new file mode 100644 index 00000000000..21880a6338f --- /dev/null +++ b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: tmp102 + id: tmp102_text_sensor_parent + name: TMP102 Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_text_sensor_parent + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index 6c294f4cdc6..a5b65b060d4 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" @@ -29,6 +30,7 @@ class RegisterBus final : public i2c::I2CBus { unsigned transactions{0}; unsigned writes{0}; uint8_t pointer{0}; + std::function on_write; i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { @@ -42,6 +44,10 @@ class RegisterBus final : public i2c::I2CBus { return i2c::ERROR_NOT_ACKNOWLEDGED; ++this->writes; this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + if (this->on_write) { + auto callback = std::move(this->on_write); + callback(); + } } } if (out_size != 0) { @@ -59,6 +65,7 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + text_sensor::TextSensor status_; TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; @@ -122,6 +129,8 @@ class TMP102Test : public ::testing::Test { static void save(TMP102LimitNumber &number, float value) { ASSERT_TRUE(number.make_entity_preference().save(&value)); } + + static void fail_saves(TMP102LimitNumber &number) { number.pref_ = {}; } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -136,6 +145,24 @@ TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { EXPECT_EQ(this->bus_.registers, original); } +TEST_F(TMP102Test, ReadOnlyStatusDoesNotConfigureOrWriteRegisters) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + EXPECT_EQ(this->bus_.registers, original); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "Not configured"); +} + +TEST_F(TMP102Test, ConfiguredSetupPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "OK"); +} + TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { for (bool configured_extended : {false, true}) { this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, @@ -408,6 +435,7 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 20); save(this->low_, 35); @@ -416,30 +444,53 @@ TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { EXPECT_FLOAT_EQ(this->low_.state, 25); EXPECT_FLOAT_EQ(saved(this->high_), 30); EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); +} + +TEST_F(TMP102Test, InvalidRestoredPairAndSaveFailurePublishCombinedStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->bus_.on_write = [this]() { + fail_saves(this->high_); + fail_saves(this->low_); + }; + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->status_.state, "Restore reset; save failed"); } TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, NAN); save(this->low_, INFINITY); this->chip_.setup(); EXPECT_FLOAT_EQ(this->high_.state, 30); EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); } TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); this->bus_.fail_write = 3; this->chip_.setup(); EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_EQ(this->status_.state, "Setup failed"); EXPECT_FALSE(this->high_.has_state()); EXPECT_FLOAT_EQ(saved(this->high_), 90); EXPECT_FLOAT_EQ(saved(this->low_), 85); } TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); this->bus_.fail_write = 3; @@ -450,6 +501,7 @@ TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { EXPECT_FALSE(this->high_.has_state()); EXPECT_EQ(this->bus_.writes, writes); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Rejected: sensor setup failed"); } TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { @@ -464,6 +516,7 @@ TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { } TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); @@ -475,6 +528,47 @@ TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { EXPECT_FLOAT_EQ(this->high_.state, 90); EXPECT_EQ(publications, 1); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Write failed: high threshold"); + + this->bus_.fail_write = 2; + this->low_.make_call().set_value(80).perform(); + EXPECT_FLOAT_EQ(this->low_.state, 85); + EXPECT_FLOAT_EQ(saved(this->low_), 85); + EXPECT_EQ(this->status_.state, "Write failed: low threshold"); +} + +TEST_F(TMP102Test, PreferenceSaveFailurePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + fail_saves(this->high_); + this->high_.make_call().set_value(35).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_EQ(this->status_.state, "Preference save failed"); +} + +TEST_F(TMP102Test, RejectedNumberWritePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + this->high_.make_call().set_value(20).perform(); + EXPECT_EQ(this->status_.state, "Rejected: high 20.0000°C crosses low 25.0000°C"); + this->high_.make_call().set_value(35).perform(); + EXPECT_EQ(this->status_.state, "OK"); +} + +TEST_F(TMP102Test, ConfigurationRecoveryPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + this->bus_.registers[1] = 0; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration restored"); + + this->bus_.registers[1] = 0; + this->bus_.fail_write = 1; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration recovery failed"); } TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp index ef628abe54d..96cd288c61b 100644 --- a/tests/components/whirlpool/whirlpool_tests.cpp +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -7,7 +7,6 @@ #include "esphome/components/remote_base/remote_base.h" #include "esphome/components/whirlpool/whirlpool.h" -#include "esphome/core/preferences.h" namespace esphome::whirlpool::testing { @@ -122,9 +121,6 @@ struct Fixture { WhirlpoolClimate climate; explicit Fixture(Model model) { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it - if (global_preferences != nullptr) - global_preferences->reset(); this->climate.set_model(model); this->climate.set_transmitter(&this->transmitter); this->climate.setup(); diff --git a/tests/components/wifi/test-dpp.esp32-idf.yaml b/tests/components/wifi/test-dpp.esp32-idf.yaml new file mode 100644 index 00000000000..29ec40ba797 --- /dev/null +++ b/tests/components/wifi/test-dpp.esp32-idf.yaml @@ -0,0 +1,12 @@ +wifi: + dpp: + channels: [1, 6, 11] + device_info: ESPHome + timeout: 30s + on_dpp_uri: + - logger.log: + format: "DPP URI %s" + args: [uri] + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/test-wisp.esp32-idf.yaml b/tests/components/wifi/test-wisp.esp32-idf.yaml new file mode 100644 index 00000000000..757e825358c --- /dev/null +++ b/tests/components/wifi/test-wisp.esp32-idf.yaml @@ -0,0 +1,8 @@ +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index ffeec136d3a..6b552c33da8 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -2,6 +2,9 @@ wifi: min_auth_mode: WPA2 post_connect_roaming: true phy_mode: 11G + ap: + ssid: Fallback AP + password: fallback_password packages: - !include common.yaml diff --git a/tests/components/wifi/test.rp2040-ard.yaml b/tests/components/wifi/test.rp2040-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rp2040-ard.yaml +++ b/tests/components/wifi/test.rp2040-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/test.rtl87xx-ard.yaml b/tests/components/wifi/test.rtl87xx-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rtl87xx-ard.yaml +++ b/tests/components/wifi/test.rtl87xx-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml new file mode 100644 index 00000000000..cdb69281c45 --- /dev/null +++ b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml @@ -0,0 +1,6 @@ +# A bare dpp: takes every default +wifi: + dpp: + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml new file mode 100644 index 00000000000..cd54b3b144b --- /dev/null +++ b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml @@ -0,0 +1,16 @@ +# A second DNS server needs ESP-IDF 6.0; config-only, a build would re-use the 5.x build dir +esp32: + framework: + version: 6.0.1 + +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + dns_servers: + - 8.8.8.8 + - 8.8.4.4 + +packages: + wifi: !include common.yaml diff --git a/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml b/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..ada15caa1ec --- /dev/null +++ b/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zigbee: !include common_nrf52.yaml diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml new file mode 100644 index 00000000000..b3112d231dc --- /dev/null +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml @@ -0,0 +1,74 @@ +esphome: + name: uart-mock-dfrobot-sen0609 + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 9600 + # A small model of the module's command line: echo, answer, Done and the prompt + on_tx: + - then: + - lambda: |- + std::string cmd(data.begin(), data.end()); + if (cmd == "\r\n") + return; + static float min_range = 0.6f; + static float max_range = 12.0f; + std::string reply = cmd + "\r\n"; + if (cmd == "getRange") { + char buf[48]; + snprintf(buf, sizeof(buf), "Response %.1f %.1f\r\n", min_range, max_range); + reply += buf; + } else if (cmd.rfind("setRange ", 0) == 0) { + sscanf(cmd.c_str() + 9, "%f %f", &min_range, &max_range); + } else if (cmd == "saveConfig") { + ESP_LOGI("sim", "Saved range %.2f %.2f", min_range, max_range); + } else if (cmd == "getSWV") { + reply += "SoftwareVersion:V1.0.4\r\n"; + } + reply += "Done\r\nDFRobot:/>"; + id(mock_uart).inject_to_rx_buffer(std::vector(reply.begin(), reply.end())); + +interval: + - interval: 200ms + then: + - lambda: |- + // '$' is kept apart so it is not read as a substitution + static const std::string FRAME = std::string(1, '$') + "DFHPD,1, , , *\r\n"; + id(mock_uart).inject_to_rx_buffer(std::vector(FRAME.begin(), FRAME.end())); + +dfrobot_sen0609: + id: sen0609 + uart_id: mock_uart + +binary_sensor: + - platform: dfrobot_sen0609 + has_target: + name: Has Target + +number: + - platform: dfrobot_sen0609 + min_range: + name: Min Range + max_range: + name: Max Range + +text_sensor: + - platform: dfrobot_sen0609 + version: + name: Version diff --git a/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml new file mode 100644 index 00000000000..df0b4ceb518 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml @@ -0,0 +1,248 @@ +esphome: + name: uart-mock-ld2450-has-target + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for + # positive values. + injections: + # Target 1 at (-500, 1000) + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # No targets for 2 s: has_target must hold for the default 5 s presence_timeout + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +# Only has_target: no sensor platform and no presence_timeout number +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml new file mode 100644 index 00000000000..ad7aec4e8c0 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml @@ -0,0 +1,55 @@ +esphome: + name: uart-mock-ld2450-zone-restore + +host: +api: + batch_delay: 0ms +logger: + level: DEBUG + +preferences: + flash_write_interval: 0s + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: restore_zone + name: "Restore Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: restore_zone + name: "Restore Zone Presence" diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml new file mode 100644 index 00000000000..112e75f5ab7 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml @@ -0,0 +1,300 @@ +esphome: + name: uart-mock-ld2450-zone-timeout + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Target 1 is either at (-500, 1000), inside the zone, or at + # (300, 400), outside it. Coordinates are sign-magnitude, with the high bit set for positive values. + injections: + # Inside the zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside for one frame: radar noise that presence_timeout must hide + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Inside again: the last inside frame + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside: presence clears once presence_timeout passes + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: hold_zone + name: "Hold Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: hold_zone + name: "Hold Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml new file mode 100644 index 00000000000..5cc4784f814 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml @@ -0,0 +1,246 @@ +esphome: + name: uart-mock-ld2450-zones + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + injections: + # Frame 1: target 1 at (-500, 1000), target 2 at (200, 500) + # Coordinates are sign-magnitude, with the high bit set for positive values. + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0xC8, 0x80, 0xF4, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 2: target 1 at (300, 400), no other targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 3: no targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # No targets, frame after frame, until presence_timeout (1 s) has cleared every zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: left_zone + name: "Left Zone Polygon" + - id: near_zone + name: "Near Zone Polygon" + - id: unset_zone + name: "Unset Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: left_zone + name: "Left Zone Presence" + - polygon_zone_id: near_zone + name: "Near Zone Presence" + - polygon_zone_id: unset_zone + name: "Unset Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 17c4dea25f9..2102363b903 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -37,6 +37,59 @@ DEVICE_NAME = "host-ota-test" API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +_REAL_COMPRESS = zlib.compress + + +def _corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes: + """Reserved block type in the first header: rejected by the decoder on + every build, unlike a flipped data bit that may only fail the MD5.""" + out = bytearray(_REAL_COMPRESS(data, *args, **kwargs)) + out[0] |= 0x06 + return bytes(out) + + +@contextmanager +def _udp_mode( + monkeypatch: pytest.MonkeyPatch, + force: bool = True, + loss: float = 0.0, + blocked: bool = False, +) -> Generator[list[bool]]: + """Force the UDP data phase (unless force is False) and drop datagrams both + ways; yields one entry per upload, True when it used UDP.""" + import random + + rng = random.Random(1) + used: list[bool] = [] + real_start = espota2._start_udp + real_send = espota2.UdpChannel._send + real_ack = espota2.UdpChannel._handle_ack + + def start(*args: object) -> espota2.UdpChannel | None: + channel = real_start(*args) + used.append(channel is not None) + return channel + + def send(self: espota2.UdpChannel, *args: object) -> None: + if not blocked and rng.random() >= loss: + real_send(self, *args) + + def ack(self: espota2.UdpChannel, data: bytes) -> None: + if rng.random() >= loss: + real_ack(self, data) + + with monkeypatch.context() as m: + if force: + m.setattr(espota2, "UDP_SLOW_EXCHANGE", -1.0) + if blocked: + # Nothing answers, so do not wait the full probe + m.setattr(espota2, "UDP_PROBE_TIMEOUT", 1.0) + m.setattr(espota2, "_start_udp", start) + m.setattr(espota2.UdpChannel, "_send", send) + m.setattr(espota2.UdpChannel, "_handle_ack", ack) + yield used + + @contextmanager def _reserve_port() -> Generator[tuple[int, socket.socket]]: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -272,18 +325,9 @@ async def test_host_ota_deflate( errors.append(line) dev.on_log(line) - real_compress = zlib.compress - - def corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes: - # Reserved block type in the first header: rejected by the decoder on - # every build, unlike a flipped data bit that may only fail the MD5 - out = bytearray(real_compress(data, *args, **kwargs)) - out[0] |= 0x06 - return bytes(out) - def overlong_compress(data: bytes, *args: object, **kwargs: object) -> bytes: """A stream that inflates past the size the client announced.""" - return real_compress(data + bytes(8192), *args, **kwargs) + return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs) async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines): dev.proc = proc @@ -301,7 +345,7 @@ async def test_host_ota_deflate( # A corrupt stream fails the upload and leaves the device running with monkeypatch.context() as m: - m.setattr(zlib, "compress", corrupt_compress) + m.setattr(zlib, "compress", _corrupt_compress) await dev.refused_ota(None, None, "corrupt deflate stream was accepted") assert errors, "device did not report the corrupt stream" @@ -318,6 +362,29 @@ async def test_host_ota_deflate( await dev.ota(None, None, "upload after a rejected stream failed") assert dev.inflates == 2 + # Localhost is clean, so the client stays on TCP + with _udp_mode(monkeypatch, force=False) as used: + await dev.ota(None, None, "TCP upload failed") + assert used == [False], "a clean link asked for UDP" + + # A lossy link moves the data phase to UDP and completes through loss + with _udp_mode(monkeypatch, loss=0.2) as used: + await dev.ota(None, None, "UDP upload with loss failed") + assert used == [True], "the upload did not use UDP" + assert dev.inflates == 4 + + # Blocked UDP falls back to TCP + with _udp_mode(monkeypatch, blocked=True) as used: + await dev.ota(None, None, "fallback to TCP failed") + assert used == [False], "blocked UDP did not fall back to TCP" + + # A device error mid upload surfaces at once, not as a 160 s timeout + with _udp_mode(monkeypatch) as used, monkeypatch.context() as m: + m.setattr(zlib, "compress", _corrupt_compress) + async with asyncio.timeout(30): + await dev.refused_ota(None, None, "corrupt stream over UDP accepted") + assert used == [True] + @pytest.mark.asyncio async def test_host_ota_encrypted( @@ -325,6 +392,7 @@ async def test_host_ota_encrypted( write_yaml_config: ConfigWriter, compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], + monkeypatch: pytest.MonkeyPatch, ) -> None: """A client that leaves right after the handshake, as a key probe does, is a clean close, not an OTA error; a plaintext upload is refused; an @@ -348,6 +416,10 @@ async def test_host_ota_encrypted( None, None, "plaintext upload to an encrypted device must fail" ) await dev.ota(None, API_KEY, "encrypted OTA reported failure") + # Noise frames ride the UDP stream unchanged, through loss + with _udp_mode(monkeypatch, loss=0.2) as used: + await dev.ota(None, API_KEY, "encrypted UDP upload failed") + assert used == [True] @pytest.mark.asyncio diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py new file mode 100644 index 00000000000..2931ace2f4d --- /dev/null +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -0,0 +1,80 @@ +"""Integration test for the DFRobot SEN0609 component against a model of the module's command line. + +Checks the boot sequence (settings read back, presence frames parsed) and that a number change +reaches the module as setRange followed by saveConfig. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, EntityState, NumberInfo, TextSensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_dfrobot_sen0609( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Boot reads the module's settings; a number change is written and saved.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + saved = loop.create_future() + + def line_callback(line: str) -> None: + if "Saved range 0.60 8.00" in line and not saved.done(): + saved.set_result(True) + + states: dict[int, EntityState] = {} + + async def wait_for(condition, what: str) -> None: + try: + async with asyncio.timeout(10.0): + while not condition(): + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"Timeout waiting for {what}; states: {states}") + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + min_range = require_entity(entities, "min_range", NumberInfo) + max_range = require_entity(entities, "max_range", NumberInfo) + version = require_entity(entities, "version", TextSensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity): + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + await wait_for(lambda: state_of(has_target) is True, "presence") + await wait_for(lambda: state_of(version) == "V1.0.4", "software version") + await wait_for( + lambda: state_of(max_range) == pytest.approx(12.0), "maximum range" + ) + assert state_of(min_range) == pytest.approx(0.6) + + client.number_command(max_range.key, 8.0) + try: + await asyncio.wait_for(saved, timeout=10.0) + except TimeoutError: + pytest.fail("The new range was not written and saved") + await wait_for( + lambda: state_of(max_range) == pytest.approx(8.0), "new maximum range" + ) diff --git a/tests/integration/test_uart_mock_ld2450_has_target_only.py b/tests/integration/test_uart_mock_ld2450_has_target_only.py new file mode 100644 index 00000000000..56e66ad65fb --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_has_target_only.py @@ -0,0 +1,64 @@ +"""Integration test for LD2450 has_target without the sensor and number platforms. + +Tests: +1. has_target turns on when only binary sensors are configured (target coordinates are still decoded) +2. has_target holds for the default presence_timeout of 5 s, without the presence_timeout number +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The fixture sends one frame with a target, then 2 s of frames without one +SCENARIO_S = 2.1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_has_target_only( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test has_target with no sensor platform and no presence_timeout number.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + history: list[bool] = [] + turned_on = asyncio.get_running_loop().create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == has_target.key: + history.append(state.state) + if state.state and not turned_on.done(): + turned_on.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(turned_on, timeout=5.0) + await asyncio.sleep(SCENARIO_S) + + assert history == [True], ( + "has_target must turn on and hold through 2 s without targets, " + f"got {history}" + ) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py new file mode 100644 index 00000000000..2b25e65e190 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py @@ -0,0 +1,117 @@ +"""Integration test for restoring LD2450 polygon zones from saved preferences. + +Tests: +1. A polygon set from the API is restored after a restart +2. A saved polygon that is no longer valid is reported and leaves the zone disabled +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +import struct + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "uart-mock-ld2450-zone-restore" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +# The saved Polygon starts with its points as little-endian int16 x, y pairs +SAVED_POLYGON_PREFIX = struct.pack(" None: + """Test that polygon zones are restored after a restart, and invalid saved polygons are rejected.""" + port, port_socket = reserved_tcp_port + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + clear_host_prefs(DEVICE_NAME) + + binary_path = await compile_esphome(await write_yaml_config(yaml_config)) + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def initial_polygon(line_callback=None) -> str: + """Start the binary and return the zone's polygon state as first published.""" + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=line_callback + ), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda state: None)) + await helper.wait_for_initial_states() + state = helper.initial_states[polygon.key] + assert isinstance(state, TextState) + return state.state + + # First run: set a polygon, which is saved + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + loop = asyncio.get_running_loop() + published = loop.create_future() + + def on_state(state: object) -> None: + if ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not published.done() + ): + published.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + assert helper.initial_states[polygon.key].state == "" + + client.text_command(polygon.key, ZONE_POLYGON) + await asyncio.wait_for(published, timeout=5.0) + + # Second run: the polygon is restored + assert await initial_polygon() == ZONE_POLYGON + + # Third run: a saved polygon with too few points is rejected + prefs = read_host_prefs(DEVICE_NAME) + keys = [key for key, data in prefs.items() if data.startswith(SAVED_POLYGON_PREFIX)] + assert len(keys) == 1, f"saved polygon not found in {prefs}" + data = bytearray(prefs[keys[0]]) + data[COUNT_OFFSET] = 2 + prefs[keys[0]] = bytes(data) + write_host_prefs(DEVICE_NAME, prefs) + + warnings: list[str] = [] + + def capture_warning(line: str) -> None: + if "saved polygon is invalid" in line: + warnings.append(line) + + assert await initial_polygon(capture_warning) == "" + assert warnings, "expected a warning about the invalid saved polygon" + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py new file mode 100644 index 00000000000..9aba119713f --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py @@ -0,0 +1,102 @@ +"""Integration test for LD2450 polygon zone presence with presence_timeout. + +Tests: +1. A target leaving the zone for a single radar frame does not clear presence +2. Presence clears once presence_timeout has passed since a target was last inside the zone +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zone-timeout" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +PRESENCE_TIMEOUT_S = 1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zone_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that polygon zone presence follows presence_timeout.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # The polygon and presence_timeout are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "hold_zone_polygon", TextInfo) + presence = require_entity(entities, "hold_zone_presence", BinarySensorInfo) + timeout = require_entity(entities, "presence_timeout", NumberInfo) + + loop = asyncio.get_running_loop() + # (time received, state) for each presence change + presence_history: list[tuple[float, bool]] = [] + cleared = loop.create_future() + polygon_set = loop.create_future() + timeout_set = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == presence.key: + presence_history.append((loop.time(), state.state)) + if state.state is False and not cleared.done(): + cleared.set_result(None) + elif ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not polygon_set.done() + ): + polygon_set.set_result(None) + elif ( + isinstance(state, NumberState) + and state.key == timeout.key + and state.state == PRESENCE_TIMEOUT_S + and not timeout_set.done() + ): + timeout_set.set_result(None) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + client.text_command(polygon.key, ZONE_POLYGON) + client.number_command(timeout.key, PRESENCE_TIMEOUT_S) + await asyncio.wait_for(asyncio.gather(polygon_set, timeout_set), timeout=5.0) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(cleared, timeout=5.0) + + # The one-frame blip outside never reaches HA + assert [state for _, state in presence_history] == [True, False] + # The last inside frame comes 200 ms after the first, then presence_timeout must pass + (on_time, _), (off_time, _) = presence_history + assert off_time - on_time >= PRESENCE_TIMEOUT_S diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zones.py b/tests/integration/test_uart_mock_ld2450_polygon_zones.py new file mode 100644 index 00000000000..c77b7288e4c --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zones.py @@ -0,0 +1,160 @@ +"""Integration test for LD2450 polygon zones with mock UART. + +Tests: +1. Polygons start empty and their presence sensors start unknown +2. A polygon set from the API is published back in canonical form +3. Invalid polygon text is rejected and the previous polygon is published again +4. Presence turns on and off as targets move in and out of each polygon +5. A zone without a polygon keeps presence unknown, and clearing a polygon makes it unknown again +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from esphome.components.ld2450.text import POLYGON_MAX_LENGTH, POLYGON_PATTERN + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zones" +LEFT_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +NEAR_POLYGON = "0,0;1000,0;1000,600;0,600" + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zones( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2450 polygon zone configuration and presence detection.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # Polygons are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygons = { + name: require_entity(entities, f"{name}_zone_polygon", TextInfo) + for name in ("left", "near", "unset") + } + presences = { + name: require_entity(entities, f"{name}_zone_presence", BinarySensorInfo) + for name in ("left", "near", "unset") + } + presence_names = {info.key: name for name, info in presences.items()} + # None records presence going back to unknown + presence_history: dict[str, list[bool | None]] = { + name: [] for name in presences + } + waiter = StateWaiter() + expect = waiter.expect + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key in presence_names: + presence_history[presence_names[state.key]].append( + None if state.missing_state else state.state + ) + waiter.on_state(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, polygon in polygons.items(): + # Home Assistant validates input and the zone card finds zones with these + assert polygon.pattern == POLYGON_PATTERN + assert polygon.max_length == POLYGON_MAX_LENGTH + assert initial_state_helper.initial_states[polygon.key].state == "" + assert initial_state_helper.initial_states[ + presences[name].key + ].missing_state + + async def set_polygon(name: str, value: str, expected: str) -> None: + key = polygons[name].key + published = expect(lambda s: isinstance(s, TextState) and s.key == key) + client.text_command(key, value) + state = await published + assert state.state == expected + assert re.fullmatch(POLYGON_PATTERN, state.state) + + # Spaces and a trailing separator are accepted and removed + await set_polygon( + "left", f" {LEFT_POLYGON.replace(';', ' ; ')} ;", LEFT_POLYGON + ) + await set_polygon("near", NEAR_POLYGON, NEAR_POLYGON) + # Invalid text keeps the polygon in use + await set_polygon("near", "0,0;1000,0", NEAR_POLYGON) + await set_polygon("near", "junk", NEAR_POLYGON) + + # Zone presence follows presence_timeout; keep the scenario short + timeout = require_entity(entities, "presence_timeout", NumberInfo) + timeout_set = expect( + lambda s: ( + isinstance(s, NumberState) and s.key == timeout.key and s.state == 1 + ) + ) + client.number_command(timeout.key, 1) + await timeout_set + + left_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.state is False + ) + ) + near_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["near"].key + and s.state is False + ) + ) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + await asyncio.gather(left_cleared, near_cleared) + + # Frame 1: (-500, 1000) is in the left zone and (200, 500) is in the near zone. + # Frame 2: (300, 400) is in the near zone only; the left zone clears 1 s after frame 1. + # Frame 3 onwards: no targets; the near zone clears 1 s after frame 2. + assert presence_history["left"] == [True, False] + assert presence_history["near"] == [True, False] + assert presence_history["unset"] == [] + + left_unknown = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.missing_state + ) + ) + await set_polygon("left", "", "") + await left_unknown + assert presence_history["left"] == [True, False, None] diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index f303d19bb0d..e65bf9755e3 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -400,6 +400,9 @@ def test_ternary_error_message_names_the_literal() -> None: 'static constexpr char TAG[] = {"x"};', 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', + 'constexpr static const char *const TAG = "x";', + 'const char *const Constants::TAG = "x";', + 'const char Constants::TAG[] = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -408,12 +411,38 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: @pytest.mark.parametrize( "line", - ['ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";'], + [ + 'ESPHOME_LOG_TAG(TAG, "x");', + 'static const char *const NAME = "x";', + 'static const char *const TAG = "x"; // NOLINT', + 'const char Constants::TAG[] PROGMEM = "x";', + ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: assert not ci_custom.lint_log_tag_macro("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("fname", "covered"), + [ + ("esphome/components/x/x.cpp", True), + ("esphome/components/x/x.h", True), + ("esphome/components/x/sub/x.h", True), + ("esphome/core/wake/wake_host.cpp", True), + ("esphome/components/x/x.c", False), + ], +) +def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None: + for name, line in ( + ("lint_log_tag_macro", 'static const char *const TAG = "x";\n'), + ("lint_log_tag_as_string", "strcmp(TAG, name);\n"), + ): + lint = next( + c for c in ci_custom.LINT_CONTENT_CHECKS if c["func"].__name__ == name + ) + assert bool(ci_custom.run_check(lint, fname, fname, line)) is covered + + @pytest.mark.parametrize( "line", [ @@ -426,6 +455,8 @@ def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: "std::string(TAG);", "std::string name = TAG;", "std::string name{TAG};", + "strlen(Constants::TAG);", + "this->set_timeout(Constants::TAG, 100, cb);", ], ) def test_log_tag_as_string_flags_string_uses(line: str) -> None: diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index e63e0849deb..6b63712f2a4 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -4,6 +4,7 @@ from collections.abc import Generator import importlib.util import json from pathlib import Path +import subprocess import sys from unittest.mock import Mock, patch @@ -2784,6 +2785,87 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_python_import_benchmarked_components_imports_real_components() -> None: + """The set is what importing the real benchmark's components loads.""" + loaded = determine_jobs.python_import_benchmarked_components() + # mqtt is not listed in COMPONENTS; sensor imports it at module scope + assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded + + +def test_should_run_benchmarks_python_import_benchmarked_component() -> None: + """A Python change in a component the import benchmark loads runs benchmarks. + + logger has no C++ benchmark, so before this a change to its __init__.py + skipped the Python import benchmark that times it. + """ + for changed, expected in [ + ("esphome/components/logger/__init__.py", True), + ("esphome/components/logger/select/__init__.py", True), + ("esphome/components/mqtt/__init__.py", True), + ("esphome/components/logger/logger.cpp", False), + ("tests/components/logger/common.yaml", False), + ("esphome/components/wifi/__init__.py", False), + ]: + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "python_import_benchmarked_components", + return_value=frozenset({"logger", "mqtt"}), + ), + ): + assert determine_jobs.should_run_benchmarks() is expected, changed + + +def test_should_run_benchmarks_when_import_probe_fails() -> None: + """A component import that fails runs the benchmarks instead of skipping them.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/wifi/__init__.py"], + ), + patch.object( + determine_jobs, "python_import_benchmarked_components", return_value=None + ), + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_python_import_benchmarked_components_logs_probe_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + """A failed probe returns None and prints why on stderr, never stdout.""" + failed = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="ModuleNotFoundError: boom" + ) + with patch.object(determine_jobs.subprocess, "run", return_value=failed): + assert determine_jobs.python_import_benchmarked_components() is None + captured = capsys.readouterr() + assert "ModuleNotFoundError: boom" in captured.err + assert captured.out == "" + + +def test_python_import_benchmarked_components_missing_file(tmp_path: Path) -> None: + """Without the benchmark file no component is treated as benchmarked.""" + with patch.object(determine_jobs, "root_path", str(tmp_path)): + assert determine_jobs.python_import_benchmarked_components() == frozenset() + + +def test_python_import_benchmarked_components_requires_components( + tmp_path: Path, +) -> None: + """A benchmark file without COMPONENTS fails loudly instead of disabling the rule.""" + bench = tmp_path / determine_jobs.PYTHON_IMPORT_BENCHMARK_FILE + bench.parent.mkdir(parents=True) + bench.write_text('NAMES = ("logger",)\n') + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + pytest.raises(RuntimeError, match="COMPONENTS"), + ): + determine_jobs.python_import_benchmarked_components() + + def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None: """Test benchmarks trigger when a benchmarked component changes.""" # Create a fake benchmarks directory with an 'api' component diff --git a/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml b/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..30a65c9739d --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: nrf52840dongle + bootloader: nrf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 20b76697a19..23bef718694 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,7 @@ from typing import Any from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.types import ConfigType class ComponentManifestOverride: @@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No directory provides an ``override_manifest`` function. """ _replace_component_manifest(domain, manifest) + + +def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None: + """Keep the component's real ``to_code`` and add the given defines after it. + + For host test and benchmark builds of code that is gated on a platform + define, such as loops that only exist on boards with PSRAM. + """ + from esphome import codegen as cg + + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config: ConfigType) -> None: + await original_to_code(config) + for define in defines: + cg.add_define(define) + + if hasattr(original_to_code, "priority"): + to_code.priority = original_to_code.priority + manifest.to_code = to_code diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index ca3c0ce7b58..68e3cab2a21 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "sendspin": 6, }, "WizardCapabilities": {"configured": 1}, - "SendspinCapabilities": {"feature_flags": 1}, + "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 5e29b06ef07..9d8eab69b90 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -408,8 +408,39 @@ def test_progmem_rejected_in_a_decoded_message() -> None: def test_progmem_rejected_on_non_bytes_field() -> None: - """(progmem) only applies to bytes fields.""" - field = _field(9) + """(progmem) only applies to string and bytes fields.""" + field = _field(13) + field.options.Extensions[pb.progmem] = True + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) + + +@pytest.mark.parametrize( + ("force", "max_len", "encode_fn"), + [ + (True, 120, "encode_short_progmem_string_force("), + (False, None, "encode_progmem_string("), + (True, None, "encode_progmem_string_force("), + ], +) +def test_progmem_string_field_copies_from_flash( + force: bool, max_len: int | None, encode_fn: str +) -> None: + """A (progmem) string field is a ProgmemStringRef that encodes and dumps through the progmem helpers.""" + field = _field(9, force=force) + field.options.Extensions[pb.progmem] = True + if max_len is not None: + field.options.Extensions[pb.max_data_length] = max_len + ti = create_field_type_info(field, needs_decode=False) + assert "ProgmemStringRef value{};" in ti.public_content + assert encode_fn in ti.encode_content + assert "encode_string" not in ti.encode_content + assert "dump_progmem_string_field(" in ti.dump_content + + +def test_progmem_rejected_on_repeated_string() -> None: + """(progmem) on a repeated string field fails instead of silently using memcpy.""" + field = _field(9, repeated=True) field.options.Extensions[pb.progmem] = True with pytest.raises(ValueError, match="progmem on field 'value'"): create_field_type_info(field, needs_decode=False) diff --git a/tests/unit_tests/components/esp8266/test_preferences.py b/tests/unit_tests/components/esp8266/test_preferences.py new file mode 100644 index 00000000000..2111e068245 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_preferences.py @@ -0,0 +1,107 @@ +"""The config-time fit check must agree with make_preference() in preferences.cpp.""" + +import asyncio + +import pytest + +from esphome import preferences +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + check_preferences_fit, + preference_words, + reserve_preferences, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + CORE.config = {KEY_ESP8266: {"restore_from_flash": False}} + + +def _check() -> None: + asyncio.run(check_preferences_fit()) + + +@pytest.mark.parametrize( + ("value_bytes", "words"), [(0, 1), (1, 2), (4, 2), (5, 3), (94, 25)] +) +def test_preference_words_rounds_up_and_adds_the_checksum( + value_bytes: int, words: int +) -> None: + assert preference_words(value_bytes) == words + + +@pytest.mark.parametrize( + ("restore_from_flash", "in_flash", "fitting", "match"), + [ + # 25 words each: 3 in the 78-word region and 1 in the 32-word one + (False, False, 4, "RTC memory has 110"), + (False, None, 4, "RTC memory has 110"), + # an explicit RTC value stays in RTC whatever restore_from_flash says + (True, False, 4, "RTC memory has 110"), + # 125 of 128 flash words, by the two-argument default or explicitly + (True, None, 5, "flash store has 128"), + (True, True, 5, "flash store has 128"), + # explicit flash values without restore_from_flash use the 64-word store + (False, True, 2, "flash store has 64"), + ], +) +def test_reservations_add_up_against_the_store_they_land_in( + restore_from_flash: bool, in_flash: bool | None, fitting: int, match: str +) -> None: + CORE.config[KEY_ESP8266]["restore_from_flash"] = restore_from_flash + reserve_preferences(fitting - 1, 94, in_flash) # one component + reserve_preferences(1, 94, in_flash) # another component + _check() + reserve_preferences(1, 94, in_flash) + with pytest.raises(EsphomeError, match=match): + _check() + + +def test_values_that_cannot_split_between_the_regions_fail() -> None: + # 5 x 20 words is 100 of 110, but the normal region takes 3 and the eboot region 1 + reserve_preferences(5, 76) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_value_larger_than_either_region_fails() -> None: + reserve_preferences(1, 400) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_split_that_works_in_some_order_passes_with_a_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + # 78 + 32: one value fills each region exactly + reserve_preferences(1, 308) + reserve_preferences(1, 124) + _check() + assert "depends on setup order" in caplog.text + + +def test_nothing_reserved_passes_quietly(caplog: pytest.LogCaptureFixture) -> None: + _check() + assert caplog.text == "" + + +def test_the_check_runs_as_a_scheduled_job() -> None: + reserve_preferences(3, 400) + CORE.add_job(check_preferences_fit) + with pytest.raises(EsphomeError, match="cannot span"): + CORE.flush_tasks() + + +def test_components_reserve_through_esphome_preferences() -> None: + preferences.reserve_preferences(2, 94, in_flash=True) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(25, True), (25, True)] + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP8266] = {} + preferences.reserve_preferences(2, 94) + assert KEY_PREFERENCES not in CORE.data[KEY_ESP8266] diff --git a/tests/unit_tests/components/esp8266/test_timer1.py b/tests/unit_tests/components/esp8266/test_timer1.py new file mode 100644 index 00000000000..63ded333b0b --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_timer1.py @@ -0,0 +1,55 @@ +"""Two components that program hardware timer 1 are refused.""" + +import asyncio + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + check_timer1_users, + require_waveform, + use_timer1, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + + +def _check() -> None: + asyncio.run(check_timer1_users()) + + +def test_one_user_passes() -> None: + use_timer1("opentherm ot") + use_timer1("opentherm ot") + _check() + + +def test_two_opentherm_hubs_are_refused() -> None: + use_timer1("opentherm ot1") + use_timer1("opentherm ot2") + with pytest.raises(EsphomeError, match="opentherm ot1 and opentherm ot2"): + _check() + + +def test_two_users_are_refused() -> None: + use_timer1("opentherm") + use_timer1("hdmi_cec") + with pytest.raises( + EsphomeError, match="hdmi_cec and opentherm each need hardware timer 1" + ): + _check() + + +def test_waveform_outputs_share_the_timer_but_not_with_a_direct_user() -> None: + require_waveform() # esp8266_pwm + require_waveform() # ac_dimmer + _check() + use_timer1("lightwaverf") + with pytest.raises(EsphomeError, match="waveform generator"): + _check() diff --git a/tests/unit_tests/components/micro_wake_word/test_task_core.py b/tests/unit_tests/components/micro_wake_word/test_task_core.py new file mode 100644 index 00000000000..563022a2400 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_task_core.py @@ -0,0 +1,86 @@ +"""Validate inference task affinity before generating firmware.""" + +import pytest + +from esphome.components import audio, micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_ESP32, KEY_TARGET_PLATFORM, KEY_VARIANT +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + "variant", + ["ESP32", "ESP32S3", "ESP32P4", "ESP32H4", "ESP32S31", "ESP32S2", "ESP32C3"], +) +@pytest.mark.parametrize("core", [-1, 0, 1]) +def test_task_core_matches_target(variant: str, core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": core}) + if core == 1 and variant in ("ESP32S2", "ESP32C3"): + with pytest.raises(cv.Invalid, match="only available"): + mww._validate_task_core(config) + else: + assert mww._validate_task_core(config)["task_core"] == core + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("core", [-2, 2]) +def test_invalid_task_core(core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + mww.CONFIG_SCHEMA({"task_core": core}) + + +def test_default_task_core_is_unpinned() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + assert mww.CONFIG_SCHEMA({})["task_core"] == -1 + + +@pytest.mark.parametrize("unicore", ["y", "n", "true", "1"]) +def test_core_one_matches_freertos_unicore_setting(unicore: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + token = fv.full_config.set( + { + "esp32": { + "framework": {"sdkconfig_options": {"CONFIG_FREERTOS_UNICORE": unicore}} + } + } + ) + try: + if unicore == "y": + with pytest.raises(cv.Invalid, match="dual-core"): + mww._validate_task_core({"task_core": 1}) + else: + assert mww._validate_task_core({"task_core": 1})["task_core"] == 1 + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32S3", "ESP32C3"]) +def test_task_core_final_validation( + variant: str, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + # Isolate microphone capabilities; exercise the real final-validation chain. + monkeypatch.setattr( + audio, + "final_validate_audio_schema", + lambda *args, **kwargs: lambda config: config, + ) + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": 1}) + if variant == "ESP32C3": + with pytest.raises(cv.Invalid, match="only available"): + mww.FINAL_VALIDATE_SCHEMA(config) + else: + mww.FINAL_VALIDATE_SCHEMA(config) + finally: + fv.full_config.reset(token) diff --git a/tests/unit_tests/components/rx8111/test_rx8111.py b/tests/unit_tests/components/rx8111/test_rx8111.py new file mode 100644 index 00000000000..48b35d43e08 --- /dev/null +++ b/tests/unit_tests/components/rx8111/test_rx8111.py @@ -0,0 +1,38 @@ +"""Basic RX8111 configuration regressions.""" + +from pathlib import Path + +import pytest + +from esphome.config import validate_config +from esphome.core import CORE + + +def configuration(tmp_path: Path, actions: list | None = None) -> dict: + return { + "esphome": {"name": "rx8111-clock-test", "build_path": str(tmp_path / "build")}, + "esp32": {"board": "esp32dev", "framework": {"type": "esp-idf"}}, + "logger": {}, + "i2c": {"sda": 21, "scl": 22, "scan": False}, + "time": [{"platform": "rx8111", "id": "rtc"}], + "script": [ + { + "id": "clock_actions", + "then": actions or [{"rx8111.read_time": {"id": "rtc"}}], + } + ], + } + + +def validate_rx8111(tmp_path: Path, config: dict): + CORE.reset() + CORE.config_path = tmp_path / "test.yaml" + return validate_config(config, {}) + + +@pytest.mark.parametrize("action", ["rx8111.read_time", "rx8111.write_time"]) +def test_actions(tmp_path: Path, action: str) -> None: + for payload in ({}, {"id": "rtc"}): + assert not validate_rx8111( + tmp_path, configuration(tmp_path, [{action: payload}]) + ).errors diff --git a/tests/unit_tests/components/test_esp32_can.py b/tests/unit_tests/components/test_esp32_can.py new file mode 100644 index 00000000000..5b70ce1d812 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_can.py @@ -0,0 +1,54 @@ +"""Tests for ESP32 CAN bitrate validation.""" + +import pytest + +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32_can import canbus +import esphome.config_validation as cv +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("revision", "supported"), + [ + (None, False), + ("0.0", False), + ("1.0", False), + ("1.1", False), + ("2.0", True), + ("3.0", True), + ("3.1", True), + ], +) +def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None: + """Require the chip revision needed by the ESP-IDF timing macro.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + advanced = {} if revision is None else {"minimum_chip_revision": revision} + token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}}) + try: + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + if supported: + canbus.FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error: + canbus.FINAL_VALIDATE_SCHEMA(config) + assert error.value.path == ["bit_rate"] + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"]) +def test_other_bitrates(variant: str) -> None: + """Existing bitrates must not require a minimum chip revision.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("25kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"]) +def test_other_variants_20kbps(variant: str) -> None: + """Other supported ESP32 variants must retain 20 kbit/s support.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) diff --git a/tests/unit_tests/components/test_i2c.py b/tests/unit_tests/components/test_i2c.py new file mode 100644 index 00000000000..91304d69cf9 --- /dev/null +++ b/tests/unit_tests/components/test_i2c.py @@ -0,0 +1,51 @@ +"""I2C backend source selection.""" + +from unittest.mock import patch + +import pytest + +from esphome.components import i2c +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + +@pytest.mark.parametrize( + "build_os, host_bus_built", + [ + ("linux", True), + ("darwin", False), + ], +) +def test_host_bus_is_only_built_on_linux(build_os: str, host_bus_built: bool) -> None: + core_data = {KEY_CORE: {KEY_TARGET_PLATFORM: "host", KEY_TARGET_FRAMEWORK: "host"}} + with ( + patch("esphome.config_helpers.CORE.data", core_data), + patch.object(i2c.sys, "platform", build_os), + ): + excluded = i2c.FILTER_SOURCE_FILES() + assert ("i2c_bus_host.cpp" not in excluded) is host_bus_built + assert {"i2c_bus_arduino.cpp", "i2c_bus_esp_idf.cpp", "i2c_bus_zephyr.cpp"} <= set( + excluded + ) + + +@pytest.mark.parametrize("build_os", ["linux", "darwin"]) +def test_nothing_excluded_before_the_target_is_known(build_os: str) -> None: + with ( + patch("esphome.config_helpers.CORE.data", {}), + patch.object(i2c.sys, "platform", build_os), + ): + assert i2c.FILTER_SOURCE_FILES() == [] + + +@pytest.mark.parametrize("build_os", ["linux", "darwin"]) +def test_host_bus_left_out_for_other_targets(build_os: str) -> None: + core_data = { + KEY_CORE: {KEY_TARGET_PLATFORM: "esp32", KEY_TARGET_FRAMEWORK: "esp-idf"} + } + with ( + patch("esphome.config_helpers.CORE.data", core_data), + patch.object(i2c.sys, "platform", build_os), + ): + excluded = i2c.FILTER_SOURCE_FILES() + assert excluded.count("i2c_bus_host.cpp") == 1 + assert "i2c_bus_esp_idf.cpp" not in excluded diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 3899b3d8540..e52ec99418a 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -1,22 +1,43 @@ """Tests for WiFi component public helpers.""" +from ipaddress import IPv4Address + import pytest from esphome.components.esp32 import const +from esphome.components.network import ( + KEY_HIGH_PERFORMANCE_NETWORKING, + KEY_HIGH_PERFORMANCE_WIFI, +) from esphome.components.wifi import ( + CONF_AP_MODE, + CONF_AP_TIMEOUT, + CONF_DNS_SERVERS, + _validate_dpp_device_info, check_placeholder_credentials, + final_validate as wifi_final_validate, has_native_wifi, variant_has_wifi, + wifi_network_ap, ) +import esphome.config_validation as cv from esphome.const import ( CONF_AP, + CONF_DNS1, + CONF_MANUAL_IP, CONF_NETWORKS, + CONF_PASSWORD, CONF_SSID, CONF_WIFI, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLACEHOLDER_WIFI_SSID, Platform, ) -from esphome.core import EsphomeError, Lambda +from esphome.core import CORE, EsphomeError, Lambda +import esphome.final_validate as fv @pytest.mark.parametrize( @@ -197,3 +218,169 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: """A templated (Lambda) SSID is not a string and is skipped.""" config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) assert check_placeholder_credentials(config) is None + + +@pytest.mark.parametrize("info", ["ESPHome bench", "a\tb", " x"]) +def test_dpp_device_info_rejects_whitespace(info: str) -> None: + """The supplicant reads the text up to the first space.""" + with pytest.raises(cv.Invalid, match="whitespace"): + _validate_dpp_device_info(info) + assert _validate_dpp_device_info("bench-01") == "bench-01" + + +@pytest.fixture +def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" + params = getattr(request, "param", {}) + monkeypatch.setitem( + CORE.data, + KEY_CORE, + { + KEY_TARGET_PLATFORM: Platform.ESP32, + KEY_TARGET_FRAMEWORK: params.get("framework", "esp-idf"), + KEY_FRAMEWORK_VERSION: params.get("idf", cv.Version(5, 4, 0)), + }, + ) + + +def _wisp_ap(**overrides: object) -> dict: + return {CONF_AP_MODE: "WISP", CONF_PASSWORD: "wisp12345678", **overrides} + + +def _final_validate(config: dict, full_extra: dict | None = None) -> None: + token = fv.full_config.set({CONF_WIFI: config, **(full_extra or {})}) + try: + wifi_final_validate(config) + finally: + fv.full_config.reset(token) + + +@pytest.mark.usefixtures("wisp_core") +@pytest.mark.parametrize( + "ap", [{CONF_AP_MODE: "WISP"}, _wisp_ap(**{CONF_PASSWORD: ""})] +) +def test_wisp_requires_ap_password(ap: dict) -> None: + """WISP must not route upstream access through an open access point.""" + with pytest.raises(cv.Invalid, match="password"): + wifi_network_ap(ap) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_accepts_ap_password() -> None: + assert wifi_network_ap(_wisp_ap())[CONF_AP_MODE] == "WISP" + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_zero_ap_timeout() -> None: + """Zero disables the fallback AP; the bridge would retry a failed start on every loop pass.""" + with pytest.raises(cv.Invalid, match="ap_timeout"): + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "0s"})) + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "30s"})) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_reject_unspecified_address() -> None: + """0.0.0.0 would leave the clients with the access point as their resolver.""" + with pytest.raises(cv.Invalid, match="0.0.0.0"): + wifi_network_ap(_wisp_ap(dns_servers=["0.0.0.0"])) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_require_wisp() -> None: + with pytest.raises(cv.Invalid, match="dns_servers requires ap_mode: WISP"): + wifi_network_ap({CONF_DNS_SERVERS: ["1.1.1.1"]}) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_limited_to_two() -> None: + """The access point DHCP server has a main and a backup resolver.""" + with pytest.raises(cv.Invalid): + wifi_network_ap(_wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8", "9.9.9.9"])) + + +@pytest.mark.parametrize( + ("wisp_core", "valid"), + [({"idf": cv.Version(5, 5, 5)}, False), ({"idf": cv.Version(6, 0, 1)}, True)], + indirect=["wisp_core"], +) +def test_second_dns_server_requires_idf_6(wisp_core: None, valid: bool) -> None: + ap = _wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"]) + if valid: + wifi_network_ap(ap) + else: + with pytest.raises(cv.Invalid, match="6.0.0"): + wifi_network_ap(ap) + + +@pytest.mark.parametrize("wisp_core", [{"framework": "arduino"}], indirect=True) +def test_wisp_requires_esp_idf(wisp_core: None) -> None: + """NAT is only available with the ESP-IDF framework.""" + with pytest.raises(cv.Invalid, match="incompatible"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.parametrize("wisp_core", [{"idf": cv.Version(5, 0, 9)}], indirect=True) +def test_wisp_requires_idf_5_1(wisp_core: None) -> None: + """esp_netif_napt_enable() is missing before ESP-IDF 5.1.""" + with pytest.raises(cv.Invalid, match="5.1.0"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_requires_sta_network() -> None: + """WISP has nothing to share without an upstream network.""" + with pytest.raises(cv.Invalid, match="at least one STA network"): + _final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()}) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_captive_portal() -> None: + with pytest.raises(cv.Invalid, match="captive_portal"): + _final_validate( + {CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}} + ) + + +def _manual_ip(dns1: str) -> dict: + return {CONF_DNS1: IPv4Address(dns1)} + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_needs_dns1() -> None: + """The bridge passes the station's resolvers on, which a static address must name.""" + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(), + } + ) + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_MANUAL_IP: _manual_ip("0.0.0.0"), + CONF_NETWORKS: [{}], + CONF_AP: _wisp_ap(), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_with_dns1_or_dns_servers() -> None: + _final_validate( + {CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("1.1.1.1")}], CONF_AP: _wisp_ap()} + ) + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(**{CONF_DNS_SERVERS: [IPv4Address("1.1.1.1")]}), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_final_validate_accepts_sta_and_ap() -> None: + _final_validate({CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}) + # NAT needs the WiFi driver buffers, not the lwip TCP windows + assert CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI) is True + assert not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 883f9d8b3d5..bb565564c6e 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_BUILD_PATH, CONF_DEVICES, CONF_ESPHOME, + CONF_INCLUDES, + CONF_INCLUDES_C, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, @@ -36,7 +38,7 @@ from esphome.core.config import ( validate_hostname, ) -from .common import load_config_from_fixture +from .common import load_config_from_fixture, load_config_from_yaml FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" @@ -689,6 +691,32 @@ def test_valid_include_invalid_extension(tmp_path: Path) -> None: valid_include(str(invalid_file)) +def test_includes_in_a_package_resolve_next_to_the_package( + tmp_path: Path, yaml_file: Callable[[str], Path] +) -> None: + """A package's includes resolve next to the package file (issue #14583).""" + package_dir = tmp_path / "packages" / "helper" + package_dir.mkdir(parents=True) + (package_dir / "my_helper.h").write_text("#pragma once\n") + (package_dir / "my_c_helper.h").write_text("#pragma once\n") + (package_dir / "helper.yaml").write_text( + "esphome:\n includes:\n - my_helper.h\n includes_c:\n - my_c_helper.h\n" + ) + + result = load_config_from_yaml( + yaml_file, + "esphome:\n" + " name: test\n" + "host:\n" + "packages:\n" + " - !include packages/helper/helper.yaml\n", + ) + + assert result is not None + assert result[CONF_ESPHOME][CONF_INCLUDES] == [str(package_dir / "my_helper.h")] + assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")] + + def test_valid_project_name_valid() -> None: """Test valid_project_name accepts valid project names.""" assert valid_project_name("esphome.my_project") == "esphome.my_project" diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 9529e448008..ec88c583baa 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -1,16 +1,19 @@ from collections.abc import Iterator +import io import math +from pathlib import Path import pytest -from esphome import cpp_generator as cg, cpp_types as ct +from esphome import config_validation as cv, cpp_generator as cg, cpp_types as ct from esphome.const import ( KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP32, PLATFORM_ESP8266, ) -from esphome.core import CORE, ID, EsphomeError +from esphome.core import CORE, ID, LINE_DIRECTIVE_RESET, DocumentLocation, EsphomeError +from esphome.yaml_util import parse_yaml class TestExpressions: @@ -244,6 +247,22 @@ class TestLambdaExpression: "[captured_var](int32_t x) -> int32_t {\n return captured_var + x;\n}" ) + def test_str__with_source_marks_end_of_body(self) -> None: + """A lambda from the YAML marks the end of its body for the line reset.""" + target = cg.LambdaExpression( + ("return 42;",), (), "", int, source=DocumentLocation("test.yaml", 4, 0) + ) + + actual = str(target) + + assert actual == ( + "[]() -> int32_t {\n" + ' #line 5 "test.yaml"\n' + " return 42;\n" + f" {LINE_DIRECTIVE_RESET}\n" + "}" + ) + class TestCallLambda: """Tests for the call_lambda() function.""" @@ -735,6 +754,28 @@ class TestProcessLambda: with pytest.raises(AssertionError): await cg.process_lambda(lambda_obj, [(int,)]) + def test_str__with_line_mark_in_body_marks_end_of_body(self) -> None: + """A #line written into the body, as lvgl does, gets the reset too.""" + target = cg.LambdaExpression(('#line 3 "test.yaml"\nfoo();',), (), "", None) + + actual = str(target) + + assert actual == ( + f'[]() {{\n #line 3 "test.yaml"\n foo();\n {LINE_DIRECTIVE_RESET}\n}}' + ) + + async def test_process_lambda__twice_keeps_line(self) -> None: + """Processing one lambda twice, as a YAML alias does, keeps its line.""" + config = parse_yaml(Path("test.yaml"), io.StringIO("x: |-\n return 42;\n")) + lambda_obj = cv.lambda_(config["x"]) + + first = await cg.process_lambda(lambda_obj, []) + second = await cg.process_lambda(lambda_obj, []) + + # The body of the block scalar is the second line, 1 counted from 0 + assert first.source.line == 1 + assert second.source.line == 1 + @pytest.mark.asyncio async def test_templatable__string_with_std_string_returns_flash_literal() -> None: diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 0f5aae444d0..b694ce74533 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -96,15 +96,19 @@ def _nrf52_files(tmp_path: Path, *built: str) -> list[str]: [ # Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it. ( - ["zephyr/zephyr.uf2", "zephyr/app_update.bin"], + ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], ), - (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]), + (["zephyr/zephyr.uf2", "firmware.zip"], ["zephyr/zephyr.uf2", "firmware.zip"]), + # The DFU package is offered only when the build made one + (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2"]), ( ["zephyr/merged.hex", "zephyr/app_update.bin"], ["zephyr/merged.hex", "zephyr/app_update.bin"], ), (["zephyr/merged.hex"], ["zephyr/merged.hex"]), + # The dongle's Open DFU bootloader has no UF2 family, so its DFU package rides beside the HEX + (["zephyr/merged.hex", "firmware.zip"], ["zephyr/merged.hex", "firmware.zip"]), ([], ["zephyr/zephyr.hex"]), ], ) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 3cba1a7357d..43ad9131a96 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -1275,11 +1275,11 @@ def test_run_compile_stops_on_ninja_failure(setup_core: Path) -> None: @pytest.mark.parametrize("memory_ld_rc", [0, 3]) def test_run_compile_testing_mode_builds_memory_ld_first( - setup_core: Path, memory_ld_rc: int + setup_core: Path, memory_ld_rc: int, monkeypatch: pytest.MonkeyPatch ) -> None: """Testing mode builds and patches memory.ld before the main build.""" _setup_build(setup_core) - CORE.testing_mode = True + monkeypatch.setattr(CORE, "testing_mode", True) # CORE.reset() does not clear it targets: list[tuple[str, ...]] = [] def record(*run_targets: str, **kwargs: object) -> int: diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index e83335a175f..04b09b4d542 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -11,6 +11,7 @@ import logging from pathlib import Path import socket import struct +from typing import Self from unittest.mock import Mock, call, patch import zlib @@ -357,6 +358,7 @@ def test_perform_ota_successful_md5_auth( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -765,7 +767,7 @@ def test_run_ota_impl_successful( assert result_host == "192.168.1.100" # Verify socket was configured correctly - mock_socket.settimeout.assert_called_with(20.0) + mock_socket.settimeout.assert_called_with(espota2.SETUP_TIMEOUT) mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) mock_socket.close.assert_called_once() @@ -853,13 +855,22 @@ def test_run_ota_impl_network_error_retry_succeeds( assert result_host == "192.168.1.100" assert mock_perform_ota.call_count == 2 mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + # The failure marks the link lossy, so the retry moves the data to UDP + assert [c.kwargs["prefer_udp"] for c in mock_perform_ota.call_args_list] == [ + False, + True, + ] @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_network_error_exhausts_attempts( mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock ) -> None: - """Test run_ota_impl_ gives up after all attempts hit network errors.""" + """Test run_ota_impl_ gives up after all attempts hit network errors. + + Every attempt connected, so the budget is the longer one for a reachable + device on a lossy link. + """ mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") result_code, result_host = espota2.run_ota_impl_( @@ -868,8 +879,8 @@ def test_run_ota_impl_network_error_exhausts_attempts( assert result_code == 1 assert result_host is None - assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 - assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS_REACHED + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS_REACHED @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") @@ -890,12 +901,10 @@ def test_run_ota_impl_multiple_addresses_cycle( assert mock_socket.connect.call_args_list == [ call(DUAL_STACK_SA6), call(DUAL_STACK_SA4), - call(DUAL_STACK_SA6), - call(DUAL_STACK_SA4), - ] + ] * (1 + espota2.EXTRA_UPLOAD_ATTEMPTS // 2) # No connect ever reached the device, so the delay only applies before # the revisits - assert mock_sleep.call_count == 2 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") @@ -1079,6 +1088,7 @@ def test_perform_ota_successful_sha256_auth( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1136,6 +1146,7 @@ def test_perform_ota_sha256_fallback_to_md5( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1246,6 +1257,7 @@ def test_perform_ota_extended_protocol_app( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1307,6 +1319,7 @@ def test_perform_ota_successful_partition_table( | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + | espota2.CLIENT_FEATURE_SUPPORTS_UDP ] ) ) @@ -1572,3 +1585,468 @@ def test_perform_ota_with_deflate(mock_socket: Mock, server_features: int) -> No assert len(payload) == sent_size < len(original_content) assert zlib.decompress(payload, -espota2.DEFLATE_WINDOW_BITS) == original_content assert sent[5] == hashlib.md5(original_content).hexdigest().encode() + + +class _LossyUdpDevice: + """The device side of the UDP channel (ota_esphome_udp.cpp) on a lossy link.""" + + def __init__(self, token: bytes, loss: float, seed: int = 1) -> None: + import random + import threading + + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("127.0.0.1", 0)) + self.sock.settimeout(0.05) + self.token = token + self.loss = loss + self.random = random.Random(seed) + self.next_seq = 0 + self.slots: dict[int, bytes] = {} # early messages within the window + self.received: list[bytes] = [] + self.out = bytearray() + self.out_base = 0 + self.committed = False + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + + def __enter__(self) -> Self: + self._thread.start() + return self + + def __exit__(self, *exc: object) -> None: + self._stop.set() + self._thread.join() + self.sock.close() + + def _run(self) -> None: + while not self._stop.is_set(): + try: + data, peer = self.sock.recvfrom(2048) + except TimeoutError: + continue + if self.random.random() < self.loss: + continue + if data[1:5] != self.token: + continue + self.committed |= bool(data[0] & espota2.UDP_FLAG_COMMITTED) + acked = (int.from_bytes(data[7:9], "big") - self.out_base) & 0xFFFF + if acked <= len(self.out): + del self.out[:acked] + self.out_base += acked + seq = int.from_bytes(data[5:7], "big") + msg_type = data[0] & ~espota2.UDP_FLAG_COMMITTED + if ( + msg_type == espota2.UDP_MSG_DATA + and (seq - self.next_seq) & 0xFFFF < espota2.UDP_WINDOW + ): + self.slots.setdefault(seq, data[espota2.UDP_HEADER_SIZE :]) + # Consumed as soon as it is next, like a device that is never busy + while self.next_seq in self.slots: + self.received.append(self.slots.pop(self.next_seq)) + self.next_seq = (self.next_seq + 1) & 0xFFFF + if self.random.random() < self.loss: + continue + window = sum( + 1 << k + for k in range(1, espota2.UDP_WINDOW) + if (self.next_seq + k) & 0xFFFF in self.slots + ) + # Consumes at once, so every slot it does not hold is free + window |= (espota2.UDP_WINDOW - len(self.slots)) << 4 + self.sock.sendto( + bytes([espota2.UDP_MSG_ACK]) + + self.token + + self.next_seq.to_bytes(2, "big") + + seq.to_bytes(2, "big") + + (self.out_base & 0xFFFF).to_bytes(2, "big") + + bytes([window]) + + bytes(self.out), + peer, + ) + + +def _udp_client(device: _LossyUdpDevice) -> espota2.UdpChannel: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(device.sock.getsockname()) + return espota2.UdpChannel(sock, device.token) + + +@pytest.mark.parametrize("loss", [0.0, 0.5]) +def test_udp_channel_delivers_in_order(loss: float) -> None: + """The stream arrives intact and responses come back, despite loss.""" + data = bytes(range(256)) * 160 + with _LossyUdpDevice(b"\x01\x02\x03\x04", loss) as device: + channel = _udp_client(device) + assert channel.probe() + channel.sendall(data) + device.out += bytes([espota2.RESPONSE_RECEIVE_OK]) + assert channel.recv(1) == bytes([espota2.RESPONSE_RECEIVE_OK]) + channel.flush(30.0) + channel.close() + assert b"".join(device.received) == data + assert all(len(m) <= espota2.UDP_MAX_PAYLOAD for m in device.received) + assert device.committed + + +def test_start_udp_falls_back_when_blocked(mock_socket: Mock) -> None: + """No answer to the probes sends the fallback over TCP and stays on TCP.""" + with _LossyUdpDevice(b"\x05\x06\x07\x08", 1.0) as device: + mock_socket.family = socket.AF_INET + mock_socket.getpeername.return_value = device.sock.getsockname() + mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] + with patch.object(espota2, "UDP_PROBE_TIMEOUT", 0.3): + assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert mock_socket.sendall.call_args_list == [ + call(espota2.UDP_REQUEST), + call(espota2.UDP_FALLBACK), # raw on the TCP socket + ] + + +def test_start_udp_declined(mock_socket: Mock) -> None: + """A zero token means the device could not open UDP; nothing is probed.""" + mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] + assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + + +def test_start_udp_clean_link_stays_on_tcp(mock_socket: Mock) -> None: + """A clean handshake asks to stay on TCP and opens no UDP socket.""" + mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] + with patch.object(espota2.socket, "socket") as udp_socket: + assert espota2._start_udp(mock_socket, mock_socket, False) is None + udp_socket.assert_not_called() + assert mock_socket.sendall.call_args_list == [call(espota2.UDP_FALLBACK)] + + +@pytest.mark.parametrize( + ("slowest", "prefer_udp", "retransmits", "lossy"), + [ + (0.01, False, 0, False), + (0.01, False, None, False), + (espota2.UDP_SLOW_EXCHANGE + 0.1, False, 0, True), + (0.01, True, 0, True), + (0.01, False, 2, True), + ], +) +def test_link_lossy( + slowest: float, prefer_udp: bool, retransmits: int | None, lossy: bool +) -> None: + """A slow exchange, a retransmit or an earlier failure means UDP.""" + with patch.object(espota2, "_tcp_retransmits", return_value=retransmits): + assert espota2._link_lossy(Mock(), slowest, prefer_udp) is lossy + + +@pytest.mark.parametrize( + ("platform", "info", "expected"), + [ + ("linux", bytes(100) + (3).to_bytes(4, "little") + bytes(100), 3), + ("darwin", bytes(72) + (1460).to_bytes(8, "little") + bytes(8), 1460), + ("win32", b"", None), + ("linux", bytes(10), None), + ], +) +def test_tcp_retransmits(platform: str, info: bytes, expected: int | None) -> None: + """The retransmit counter is read where the OS reports it.""" + sock = Mock() + sock.getsockopt.return_value = info + with patch("sys.platform", platform): + assert espota2._tcp_retransmits(sock) == expected + + +def test_udp_channel_reports_socket_errors() -> None: + """A closed device port fails fast instead of waiting out the timeout.""" + gone = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + gone.bind(("127.0.0.1", 0)) + addr = gone.getsockname() + gone.close() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(addr) + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.settimeout(10.0) + with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"): + channel.sendall(bytes(espota2.UDP_MAX_PAYLOAD * 5)) + # A probe that is refused falls back instead + assert not espota2.UdpChannel(sock, b"\x01\x01\x01\x01").probe() + channel.close() + + +def test_udp_channel_no_progress_names_last_error() -> None: + """Without refusals, the no-progress timeout names the last socket error.""" + sock = Mock() + sock.send.side_effect = BlockingIOError(35, "full") + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.settimeout(0.2) + channel.sendall(b"x") + with ( + patch("select.select", return_value=([], [], [])), + pytest.raises(espota2.OTANetworkError, match="no progress.*full"), + ): + while True: + channel._pump() + + +def test_udp_channel_ack_resets_refusals() -> None: + """Refusals only count in a row; an ACK in between resets them.""" + channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01") + ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40" + for _ in range(espota2.UDP_REFUSALS * 2): + channel._socket_error(ConnectionRefusedError(61, "refused")) + channel._handle_ack(ack) + # Windows' form of the same refusal counts too + with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"): + for _ in range(espota2.UDP_REFUSALS): + channel._socket_error(ConnectionResetError(10054, "forcibly closed")) + + +def _udp_handshake() -> list[bytes]: + """TCP recv responses up to auth, from a device that offers UDP.""" + return [ + bytes([espota2.RESPONSE_OK]), + bytes([espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_FEATURE_FLAGS]), + bytes([espota2.SERVER_FEATURE_SUPPORTS_UDP]), + bytes([espota2.RESPONSE_AUTH_OK]), + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_over_udp(mock_socket: Mock) -> None: + """The data phase moves to the channel, which acks chunks itself.""" + mock_socket.recv.side_effect = _udp_handshake() + udp = Mock() + udp.pending.return_value = False + udp.recv.side_effect = [ + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + bytes([espota2.RESPONSE_RECEIVE_OK]), + bytes([espota2.RESPONSE_UPDATE_END_OK]), + ] + with patch.object(espota2, "_start_udp", return_value=udp) as start: + espota2.perform_ota( + mock_socket, None, io.BytesIO(bytes(20000)), "test.bin", prefer_udp=True + ) + assert start.call_args.args[2] is True # an earlier failure means lossy + assert udp.recv.call_count == 4 # no chunk acks + udp.flush.assert_called_once() + udp.close.assert_called_once() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_over_udp_device_error(mock_socket: Mock) -> None: + """A device error mid upload stops the upload and is not retried.""" + mock_socket.recv.side_effect = _udp_handshake() + mock_socket.getsockopt.return_value = b"" # no retransmit counter + udp = Mock() + udp.pending.return_value = True + udp.recv.side_effect = [ + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), + ] + with ( + patch.object(espota2, "_start_udp", return_value=udp), + pytest.raises(espota2.OTAError, match="Writing OTA data") as exc, + ): + espota2.perform_ota(mock_socket, None, io.BytesIO(bytes(20000)), "test.bin") + assert not isinstance(exc.value, espota2.OTANetworkError) + udp.close.assert_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_udp_declined_stays_on_tcp(mock_socket: Mock) -> None: + """Without a channel the upload continues on TCP with chunk acks.""" + mock_socket.recv.side_effect = [ + *_udp_handshake(), + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + *_UPLOAD_TAIL, + ] + with patch.object(espota2, "_start_udp", return_value=None): + espota2.perform_ota( + mock_socket, None, io.BytesIO(b"x" * 100), "test.bin", prefer_udp=True + ) + assert mock_socket.sendall.call_args_list[-1] == call(bytes([espota2.RESPONSE_OK])) + + +def test_udp_channel_records_socket_errors() -> None: + """Send and receive errors are recorded like loss.""" + sock = Mock() + sock.send.side_effect = BlockingIOError(35, "full") + sock.recv.side_effect = ConnectionRefusedError(61, "refused") + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel._send(espota2.UDP_MSG_PING, 0, b"") + assert isinstance(channel._last_error, BlockingIOError) + with patch("select.select", side_effect=[([sock], [], []), ([], [], [])]): + channel._receive(0.0) + assert isinstance(channel._last_error, ConnectionRefusedError) + + +def test_start_udp_probe_succeeds(mock_socket: Mock) -> None: + """A device that answers the probe gets the data phase over UDP.""" + with _LossyUdpDevice(b"\x05\x06\x07\x09", 0.0) as device: + mock_socket.family = socket.AF_INET + mock_socket.getpeername.return_value = device.sock.getsockname() + mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x09"] + channel = espota2._start_udp(mock_socket, mock_socket, True) + assert channel is not None + channel.close() + assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + + +def test_udp_channel_pings_when_all_held() -> None: + """With every message in flight already held, it asks for responses instead, + at most once per resend timeout.""" + device = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + device.bind(("127.0.0.1", 0)) + device.settimeout(1.0) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(device.getsockname()) + channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01") + channel.sendall(b"x") + channel._queue[0].held = True + with patch.object(channel, "_receive"): + channel._pump() + channel._pump() + assert device.recv(2048)[0] == espota2.UDP_MSG_PING + device.settimeout(0.1) + with pytest.raises(TimeoutError): + device.recv(2048) + channel.close() + device.close() + + +def test_udp_channel_ignores_foreign_datagrams() -> None: + """Short, non-ACK or wrong-token datagrams change nothing.""" + channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01") + for data in (b"\x40", bytes(espota2.UDP_ACK_HEADER_SIZE), b"\x40" + bytes(11)): + channel._handle_ack(data) + assert not channel.pending() + # An ACK before the head was ever sent is no duplicate + channel.sendall(b"x") + ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40" + channel._handle_ack(ack) + channel._handle_ack(ack) + assert not channel._queue[0].resend + + +def test_udp_channel_logs_each_socket_error_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """A repeated error is recorded but logged only when it changes.""" + channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01") + with caplog.at_level(logging.DEBUG, "esphome.espota2"): + channel._socket_error(ConnectionRefusedError(61, "refused")) + channel._socket_error(ConnectionRefusedError(61, "refused again")) + assert caplog.text.count("UDP socket error") == 1 + assert "again" in str(channel._last_error) + + +def test_tcp_retransmits_unsupported_option() -> None: + """An OS that rejects the option reports nothing.""" + sock = Mock() + sock.getsockopt.side_effect = OSError("not supported") + with patch("sys.platform", "linux"): + assert espota2._tcp_retransmits(sock) is None + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_handshake_failure_prefers_udp( + firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """A handshake that dies after reaching the device moves the retry to UDP.""" + mock_perform_ota.side_effect = [ + espota2.OTAHandshakeNetworkError("noise handshake: timed out"), + None, + ] + result_code, _ = espota2.run_ota_impl_("test.local", 3232, None, str(firmware_file)) + assert result_code == 0 + assert [c.kwargs["prefer_udp"] for c in mock_perform_ota.call_args_list] == [ + False, + True, + ] + + +def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None: + """A UDP socket that cannot reach the peer stays on TCP.""" + mock_socket.family = socket.AF_INET + mock_socket.getpeername.side_effect = OSError("not connected") + mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] + assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert mock_socket.sendall.call_args_list == [ + call(espota2.UDP_REQUEST), + call(espota2.UDP_FALLBACK), + ] + + +def test_udp_channel_backs_off_when_acks_come_back_untimed() -> None: + """Progress acks that only answer resent messages mean the link is slower + than the timer: it backs off, up to its cap, until a sample resets it.""" + token = b"\x01\x01\x01\x01" + channel = espota2.UdpChannel(Mock(), token) + channel._queue = [espota2._UdpMessage(seq, b"x") for seq in range(40)] + rtos = [] + for received in range(1, 33): + for message in channel._queue[:1]: + message.sends = 2 # every message was resent before its ack + rtos.append(channel._rto()) + ack = ( + bytes([espota2.UDP_MSG_ACK]) + + token + + received.to_bytes(2, "big") + + (received - 1).to_bytes(2, "big") + + bytes(2) + + b"\x40" + ) + channel._handle_ack(ack) + n = espota2.UDP_UNTIMED_ACKS + assert rtos[0] == espota2.UDP_RESEND_INITIAL + assert rtos[n] == espota2.UDP_RESEND_INITIAL * 2 + assert rtos[-1] == espota2.UDP_RESEND_MAX + channel._sample_rtt(0.1) + assert channel._rto() == pytest.approx(0.3) # srtt 0.1 + 4 * rttvar 0.05 + + +@pytest.mark.usefixtures("mock_time") +@pytest.mark.parametrize( + ("size", "pending", "match", "retryable"), + [ + (100, False, "may have already committed", False), # one, final chunk + (20000, False, "no progress", True), # fails on an earlier chunk + (20000, True, "Writing OTA data", False), # device error waiting + ], +) +def test_perform_ota_over_udp_send_failure( + mock_socket: Mock, size: int, pending: bool, match: str, retryable: bool +) -> None: + """A UDP send failure surfaces a waiting device error, and on the last + chunk is not retried, like a lost final chunk ack over TCP.""" + mock_socket.recv.side_effect = _udp_handshake() + udp = Mock() + udp.pending.return_value = pending + udp.recv.side_effect = [ + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + bytes([espota2.RESPONSE_BIN_MD5_OK]), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), + ] + # ota type, size and MD5 go through; the first data chunk fails + udp.sendall.side_effect = [None] * 3 + [espota2.OTANetworkError("no progress")] + with ( + patch.object(espota2, "_start_udp", return_value=udp), + pytest.raises(espota2.OTAError, match=match) as exc, + ): + espota2.perform_ota( + mock_socket, None, io.BytesIO(bytes(size)), "test.bin", prefer_udp=True + ) + assert isinstance(exc.value, espota2.OTANetworkError) is retryable + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_tcp_send_network_error_unchanged(mock_socket: Mock) -> None: + """Over TCP an OTANetworkError from a send stays retryable, even on the + last chunk.""" + mock_socket.recv.side_effect = _no_auth_handshake(espota2.OTA_VERSION_2_0) + mock_socket.sendall.side_effect = [None] * 4 + [ + espota2.OTANetworkError("sending frame") + ] + with pytest.raises(espota2.OTANetworkError, match="sending frame"): + espota2.perform_ota(mock_socket, None, io.BytesIO(b"x" * 100), "test.bin") diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 1adace67395..c316b55a9cf 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -76,9 +76,11 @@ class FakeEncryptedDevice(threading.Thread): prologue_features_override: int | None = None, connections: int = 1, drop_handshakes: int = 0, + udp: bool = False, reject_reason: str | None = None, ) -> None: super().__init__(daemon=True) + self.udp = udp # offer UDP; the data phase then has no chunk acks self.connections = connections self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times self.reject_reason = reject_reason # refuse every handshake with this reason @@ -125,6 +127,8 @@ class FakeEncryptedDevice(threading.Thread): sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED])) return server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0 + if self.udp: + server_flags |= espota2.SERVER_FEATURE_SUPPORTS_UDP sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])) if not (noise_negotiated and self.offer_noise): # A device that does not require encryption continues in @@ -217,7 +221,7 @@ class FakeEncryptedDevice(threading.Thread): acked = 0 while len(received) < size: received += recv_data(size - len(received)) - if self.version >= espota2.OTA_VERSION_2_0: + if self.version >= espota2.OTA_VERSION_2_0 and not self.udp: while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or ( len(received) == size and acked < size ): @@ -858,16 +862,23 @@ def test_probe_ota_key_resolution_failure( ) -> None: """A name that never resolves is retried until the deadline, then reported.""" pytest.importorskip("aioesphomeapi.noise") + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + with ( patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, - patch("time.sleep"), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), caplog.at_level(logging.WARNING), ): assert ( - espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=10) is not ProbeResult.ACCEPTED ) - assert resolve.call_count > 1 + # Looked up at 0, 3, 6 and 9 s; the last pause stops at the deadline + assert resolve.call_count == 4 assert any("failed, unreachable" in r.message for r in caplog.records) @@ -906,6 +917,54 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt( assert not any("Retrying in plaintext" in r.message for r in caplog.records) +class _TcpAsUdp: + """Stands in for a UdpChannel, carrying its byte stream over the TCP socket.""" + + def __init__(self, sock: socket.socket) -> None: + self._sock = sock + self.flushed = False + self.closed = False + + def settimeout(self, timeout: float | None) -> None: + self._sock.settimeout(timeout) + + def setsockopt(self, level: int, optname: int, value: int) -> None: + self._sock.setsockopt(level, optname, value) + + def sendall(self, data: bytes) -> None: + self._sock.sendall(data) + + def recv(self, amount: int) -> bytes: + return self._sock.recv(amount) + + def pending(self) -> bool: + return False + + def flush(self, timeout: float) -> None: + self.flushed = True + + def close(self) -> None: + self.closed = True + + +def test_encrypted_upload_over_udp() -> None: + """After the handshake, Noise frames continue on the UDP channel.""" + pytest.importorskip("aioesphomeapi.noise") + firmware = bytes(range(256)) * 80 + device = FakeEncryptedDevice(udp=True) + channels: list[_TcpAsUdp] = [] + + def start_udp(sock: Any, tcp_sock: socket.socket, lossy: bool) -> _TcpAsUdp: + channels.append(_TcpAsUdp(tcp_sock)) + return channels[0] + + with patch("time.sleep"), patch.object(espota2, "_start_udp", start_udp): + _upload(device, firmware, PSK) + device.join_and_check() + assert device.received == firmware + assert channels[0].flushed and channels[0].closed + + def test_probe_ota_key_recomputes_the_budget_after_connect() -> None: """A slow connect leaves less for the handshake than the budget had.""" clock = [0.0] diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 1ae10d0eb54..ad45db88ca2 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -9,9 +9,112 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME +from esphome.const import ( + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_ESPHOME, + CONF_MQTT, + CONF_NAME, + CONF_PAYLOAD, + CONF_RETAIN, + CONF_TOPIC, +) from esphome.core import EsphomeError -from esphome.mqtt import get_esphome_device_ip +from esphome.mqtt import get_esphome_device_ip, show_logs + + +@pytest.mark.parametrize( + "birth", + [ + None, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "online", CONF_RETAIN: False}, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "", CONF_RETAIN: True}, + ], + ids=["no birth message", "unretained birth message", "empty birth payload"], +) +@patch("esphome.mqtt.initialize") +def test_show_logs_asks_on_connect( + mock_initialize: MagicMock, birth: dict | None +) -> None: + """Without a retained birth message nothing tells a later CLI the device is up, so it asks on connect.""" + config = _discovery_config() + if birth is not None: + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = birth + + show_logs(config) + + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + if birth is None or not birth[CONF_PAYLOAD]: + assert mock_initialize.call_args.args[1] == ["test-device/debug"] + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_requests_crash_log_when_device_reports_online( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """With a retained birth message the request follows the device coming online, not the CLI connecting.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config) + + assert mock_initialize.call_args.args[1] == [ + "test-device/debug", + "test-device/status", + ] + on_message = mock_initialize.call_args.args[2] + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_not_called() + + on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline")) + client.publish.assert_not_called() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + mock_print.assert_not_called() + + on_message( + client, None, MagicMock(topic="test-device/debug", payload=b"[I][app]: hi") + ) + assert mock_print.call_args.args[0].endswith("[I][app]: hi") + client.publish.assert_called_once() + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_prints_a_birth_message_inside_the_log_subscription( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """A birth topic covered by the log topic is still printed, and still requests the crash log.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config, topic="test-device/#") + + on_message = mock_initialize.call_args.args[2] + client = MagicMock() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + assert mock_print.call_args.args[0].endswith("online") def test_get_esphome_device_ip_empty_broker() -> None: diff --git a/tests/unit_tests/test_nrf52_config.py b/tests/unit_tests/test_nrf52_config.py index 4d9d281ebfe..eb13a55e488 100644 --- a/tests/unit_tests/test_nrf52_config.py +++ b/tests/unit_tests/test_nrf52_config.py @@ -1,12 +1,111 @@ """Tests for the nRF52 configuration validation.""" +from collections.abc import Generator + import pytest -from esphome.components.nrf52 import _detect_bootloader +from esphome.components.nrf52 import ( + _default_self_reset_pin, + _detect_bootloader, + _validate_open_dfu_toolchain, + set_core_data, +) +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_NRF +from esphome.components.zephyr.const import KEY_PM_STATIC, KEY_ZEPHYR +from esphome.components.zephyr_mcumgr.ota import _validate_bootloader +from esphome.config import path_context import esphome.config_validation as cv +from esphome.const import CONF_INVERTED, CONF_NUMBER, KEY_CORE, Toolchain +from esphome.core import CORE def test_detect_bootloader_reports_a_missing_board() -> None: """The bootloader check runs before the schema, so it reports the missing key.""" with pytest.raises(cv.Invalid, match="'board' is a required option"): _detect_bootloader({}) + + +def test_dongle_defaults_to_nrf_bootloader() -> None: + """The dongle's only bootloader is the factory one, so it is selected without a config line.""" + config = _detect_bootloader({"board": "nrf52840dongle"}) + assert config["bootloader"] == BOOTLOADER_NRF + + +def test_dongle_rejects_foreign_bootloader() -> None: + with pytest.raises(cv.Invalid, match="nrf52840dongle does not support"): + _detect_bootloader({"board": "nrf52840dongle", "bootloader": "mcuboot"}) + + +def _dongle_core_data() -> None: + CORE.data[KEY_CORE] = {} + CORE.testing_mode = ( + False # the real flash, not the larger one faked for grouped tests + ) + set_core_data({"board": "nrf52840dongle", "bootloader": BOOTLOADER_NRF}) + + +@pytest.fixture +def _path_ctx() -> Generator[None]: + """A pin schema reports its errors against the config path, so one has to be set.""" + token = path_context.set(["nrf52"]) + yield + path_context.reset(token) + + +def test_nrf_bootloader_reserves_factory_region() -> None: + """Only the Open DFU bootloader is registered as a static partition; the board's own + nrf5_mbr partition takes the first page, so the app lands at 0x1000 where the bootloader loads it.""" + _dongle_core_data() + registered = {s.name: s for s in CORE.data[KEY_ZEPHYR][KEY_PM_STATIC]} + assert {s.name for s in BOOTLOADER_CONFIG[BOOTLOADER_NRF]} <= set(registered) + assert "mbr" not in registered + assert registered["open_bootloader"].address == 0xE0000 + assert registered["open_bootloader"].end_address == 0x100000 + + +def test_nrf_bootloader_rejects_mcumgr_ota() -> None: + """OTA with mcumgr places its slot against a SoftDevice, which this layout has none of.""" + _dongle_core_data() + with pytest.raises(cv.Invalid, match="does not support OTA"): + _validate_bootloader({}) + + +def test_nrf_bootloader_accepts_the_sdk_nrf_toolchain() -> None: + CORE.toolchain = Toolchain.SDK_NRF + _validate_open_dfu_toolchain({"bootloader": BOOTLOADER_NRF}) + + +def test_nrf_bootloader_rejects_the_platformio_toolchain() -> None: + """The deprecated PlatformIO upload path assumes a touch-reset bootloader.""" + CORE.toolchain = Toolchain.PLATFORMIO + with pytest.raises(cv.Invalid, match="sdk-nrf"): + _validate_open_dfu_toolchain({"bootloader": BOOTLOADER_NRF}) + + +@pytest.mark.usefixtures("_path_ctx") +def test_dongle_dfu_resets_itself_through_the_pin_wired_to_reset() -> None: + """The factory bootloader ignores the touch's register, so P0.19 pulls RESET for it.""" + _dongle_core_data() + config = _default_self_reset_pin({"board": "nrf52840dongle", "dfu": {}}) + pin = config["dfu"]["reset_pin"] + assert pin[CONF_NUMBER] == 19 + assert pin[CONF_INVERTED] is True + + +@pytest.mark.usefixtures("_path_ctx") +def test_dfu_reset_pin_given_by_the_user_is_kept() -> None: + _dongle_core_data() + config = _default_self_reset_pin( + {"board": "nrf52840dongle", "dfu": {"reset_pin": {"number": 5}}} + ) + assert config["dfu"]["reset_pin"] == {"number": 5} + + +@pytest.mark.usefixtures("_path_ctx") +def test_other_boards_get_no_reset_pin() -> None: + _dongle_core_data() + config = _default_self_reset_pin( + {"board": "adafruit_itsybitsy_nrf52840", "dfu": {}} + ) + assert "reset_pin" not in config["dfu"] diff --git a/tests/unit_tests/test_nrf52_nrfutil.py b/tests/unit_tests/test_nrf52_nrfutil.py new file mode 100644 index 00000000000..c544dbfd8d5 --- /dev/null +++ b/tests/unit_tests/test_nrf52_nrfutil.py @@ -0,0 +1,157 @@ +"""Nordic's nrfutil is downloaded into the tools cache and keeps its commands there.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.components.nrf52 import framework +from esphome.components.nrf52.framework import ( + _NRFUTIL_LAUNCHERS, + NRF5SDK_TOOLS_VERSION, + _nrfutil_target_triple, + ensure_nordic_nrfutil, +) +from esphome.core import EsphomeError + +LINUX = ("linux", "x86_64", "tar.xz") + + +@pytest.mark.parametrize( + ("host", "triple"), + [ + (("macos", "aarch64", "tar.xz"), "aarch64-apple-darwin"), + (("macos", "x86_64", "tar.xz"), "x86_64-apple-darwin"), + (LINUX, "x86_64-unknown-linux-gnu"), + (("windows", "x86_64", "7z"), "x86_64-pc-windows-msvc"), + ], +) +def test_launcher_triple_follows_the_host( + host: tuple[str, str, str], triple: str +) -> None: + with patch.object(framework, "_get_toolchain_platform_info", return_value=host): + assert _nrfutil_target_triple() == triple + + +def test_unknown_host_is_a_clear_error() -> None: + with ( + patch.object( + framework, + "_get_toolchain_platform_info", + return_value=("freebsd", "x86_64", "tar.xz"), + ), + pytest.raises(EsphomeError, match="freebsd x86_64"), + ): + _nrfutil_target_triple() + + +# The launcher and its command carry the host's executable suffix +SUFFIX = ".exe" if os.name == "nt" else "" + + +def _fake_download(url: str, dest: Path, **kwargs: object) -> None: + dest.write_bytes(b"launcher") + + +def _fake_install(cmd: list[str], env: dict[str, str]) -> bool: + """The launcher installs its command under NRFUTIL_HOME.""" + command = Path(env["NRFUTIL_HOME"]) / "bin" / f"nrfutil-nrf5sdk-tools{SUFFIX}" + command.parent.mkdir(parents=True, exist_ok=True) + command.write_bytes(b"command") + return True + + +def test_first_use_downloads_and_installs_into_the_cache(tmp_path: Path) -> None: + env: dict[str, str] = {} + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object( + framework, "download_with_resume", side_effect=_fake_download + ) as download, + patch.object(framework, "run_command_ok", side_effect=_fake_install) as install, + ): + exe = ensure_nordic_nrfutil(env) + + # The launcher sits outside the home it installs into, and the command version is pinned + assert exe == str(tmp_path / "nrfutil" / "launcher" / f"nrfutil{SUFFIX}") + assert env["NRFUTIL_HOME"] == str(tmp_path / "nrfutil" / "home") + # The launcher comes from the host's URL and is checked against the pinned digest + size, sha256 = _NRFUTIL_LAUNCHERS["x86_64-unknown-linux-gnu"] + assert download.call_args[0][0].endswith( + f"/x86_64-unknown-linux-gnu/nrfutil{SUFFIX}" + ) + assert download.call_args[1] == {"sha256": sha256, "size": size} + assert install.call_args[0][0] == [ + exe, + "install", + f"nrf5sdk-tools={NRF5SDK_TOOLS_VERSION}", + ] + assert (tmp_path / "nrfutil" / f".ready-{NRF5SDK_TOOLS_VERSION}").exists() + + +def _installed_cache(tmp_path: Path, with_command: bool = True) -> Path: + root = tmp_path / "nrfutil" + (root / "launcher").mkdir(parents=True) + (root / "launcher" / f"nrfutil{SUFFIX}").write_bytes(b"launcher") + if with_command: + (root / "home" / "bin").mkdir(parents=True) + (root / "home" / "bin" / f"nrfutil-nrf5sdk-tools{SUFFIX}").write_bytes( + b"command" + ) + (root / f".ready-{NRF5SDK_TOOLS_VERSION}").touch() + return root + + +def test_installed_cache_is_used_without_network(tmp_path: Path) -> None: + root = _installed_cache(tmp_path) + env: dict[str, str] = {} + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "download_with_resume") as download, + patch.object(framework, "run_command_ok") as install, + ): + assert ensure_nordic_nrfutil(env) == str(root / "launcher" / f"nrfutil{SUFFIX}") + download.assert_not_called() + install.assert_not_called() + assert env["NRFUTIL_HOME"] == str(root / "home") + + +def test_a_cache_that_lost_its_command_is_installed_again(tmp_path: Path) -> None: + _installed_cache(tmp_path, with_command=False) + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", side_effect=_fake_install) as install, + ): + ensure_nordic_nrfutil({}) + install.assert_called_once() + + +def test_install_without_the_command_file_is_a_failure(tmp_path: Path) -> None: + """The launcher's exit code alone does not prove the command is in place.""" + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", return_value=True), + pytest.raises(EsphomeError, match="nrf5sdk-tools"), + ): + ensure_nordic_nrfutil({}) + assert not list((tmp_path / "nrfutil").glob(".ready-*")) + + +def test_failed_install_leaves_no_ready_marker(tmp_path: Path) -> None: + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="nrf5sdk-tools"), + ): + ensure_nordic_nrfutil({}) + assert not list((tmp_path / "nrfutil").glob(".ready-*")) diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index 9091b429f63..cd5500caa6e 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -6,7 +6,10 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.nrf52.const import ( + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, +) from esphome.components.zephyr.const import ( KEY_BOARD, KEY_BOOTLOADER, @@ -217,14 +220,32 @@ class TestUploadProgramSerialPlatformio: # --------------------------------------------------------------------------- +def _prepare_serial_dfu( + tmp_path: Path, bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +) -> tuple[dict, Path]: + """Set up CORE for a serial DFU upload with a built firmware.zip; returns (paths, dfu_package).""" + _setup_nrf52_core(bootloader=bootloader, build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + dfu_package = CORE.relative_pioenvs_path(CORE.name) / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + return _make_paths(tmp_path), dfu_package + + def _enter_serial_dfu_patches( - stack: ExitStack, host: str, tmp_path: Path, paths: dict + stack: ExitStack, + host: str, + tmp_path: Path, + paths: dict, + usb_id: tuple[int, int] | None = None, ) -> MagicMock: """Enter all context managers needed for the serial DFU happy path. Returns the mock for ``run_command_ok`` so callers can inspect calls. comports() returns [] on the first call (port disappeared) and a list - containing the host on every subsequent call (port reappeared). Patches + containing the host on every subsequent call (port reappeared). With + ``usb_id`` set, the port is present with that vid/pid on every call, as it + is for a bootloader that is entered by hand. Patches are applied directly on the real pyserial module attributes so they are visible to the deferred ``import serial[.tools.list_ports] as _x`` statements inside upload_program. @@ -236,11 +257,13 @@ def _enter_serial_dfu_patches( _comports_calls = [0] - def _comports(): + def _comports(**_kwargs): _comports_calls[0] += 1 - if _comports_calls[0] == 1: + if _comports_calls[0] == 1 and usb_id is None: return [] # port disappeared → disappear loop breaks - return [MagicMock(device=host)] # port back → reappear loop breaks + # port back → reappear loop breaks + vid, pid = usb_id or (None, None) + return [MagicMock(device=host, vid=vid, pid=pid)] stack.enter_context( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) @@ -254,6 +277,12 @@ def _enter_serial_dfu_patches( patch("esphome.components.nrf52.get_build_env", return_value={}) ) stack.enter_context(patch("time.sleep")) + stack.enter_context( + patch( + "esphome.components.nrf52.ensure_nordic_nrfutil", + return_value="/opt/nordic/nrfutil", + ) + ) # Patch directly on the real pyserial module so the deferred imports inside # upload_program see our mocks regardless of how sys.modules is cached. stack.enter_context(patch.object(serial, "Serial")) @@ -266,25 +295,6 @@ def _enter_serial_dfu_patches( class TestUploadProgramSerialDfu: - def test_unsupported_bootloader_raises( - self, setup_core: Path, tmp_path: Path - ) -> None: - """An unknown bootloader must raise EsphomeError before touching the port.""" - from esphome.components.nrf52 import upload_program - from esphome.upload_targets import PortType - - _setup_nrf52_core( - bootloader="unknown_bootloader", build_path=tmp_path / "build" - ) - CORE.config_path = tmp_path / "test.yaml" - - with ( - patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), - patch("esphome.__main__.check_permissions"), - pytest.raises(EsphomeError, match="Not implemented"), - ): - upload_program(config={}, args=None, host="/dev/ttyACM0") - def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: """Missing firmware.zip must raise EsphomeError before opening the serial port.""" from esphome.components.nrf52 import upload_program @@ -313,13 +323,7 @@ class TestUploadProgramSerialDfu: """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" from esphome.components.nrf52 import upload_program - _setup_nrf52_core(build_path=tmp_path / "build") - CORE.config_path = tmp_path / "test.yaml" - paths = _make_paths(tmp_path) - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - dfu_package.parent.mkdir(parents=True, exist_ok=True) - dfu_package.touch() + paths, dfu_package = _prepare_serial_dfu(tmp_path) host = "/dev/ttyACM0" with ExitStack() as stack: @@ -338,17 +342,175 @@ class TestUploadProgramSerialDfu: assert host in cmd assert "--singlebank" in cmd + def test_serial_upload_refuses_an_unlisted_bootloader( + self, setup_core: Path, tmp_path: Path + ) -> None: + """A bootloader without a serial DFU path fails before any tool runs.""" + from esphome.components.nrf52 import upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, "future") + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + with pytest.raises(EsphomeError, match="not implemented for the future"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + + def test_open_dfu_bootloader_skips_the_touch( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The factory Open DFU bootloader has no auto-entry, so no 1200 bps touch is sent.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, dfu_package = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=OPEN_DFU_USB_ID + ) + assert upload_program(config={}, args=None, host=host) is True + assert not serial.Serial.mock_calls # pylint: disable=no-member + + # Nordic's tool speaks the Secure DFU protocol of this bootloader + cmd = mock_run.call_args[0][0] + assert cmd[:4] == ["/opt/nordic/nrfutil", "nrf5sdk-tools", "dfu", "usb-serial"] + assert str(dfu_package) in cmd + assert host in cmd + + def test_open_dfu_application_port_is_touched_and_the_bootloader_port_flashed( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An application port gets the 1200 bps touch; the bootloader then appears as its own port.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, dfu_package = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + bootloader_port = "/dev/ttyACM1" + calls = [0] + + def _comports(**_kwargs): + calls[0] += 1 + app = MagicMock(device=host, vid=0x2FE3, pid=0x0100) + if calls[0] == 1: + return [app] # still the application when the upload starts + return [ + MagicMock( + device=bootloader_port, + vid=OPEN_DFU_USB_ID[0], + pid=OPEN_DFU_USB_ID[1], + ) + ] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + assert upload_program(config={}, args=None, host=host) is True + serial.Serial.assert_called_once_with(host, baudrate=1200, timeout=1) # pylint: disable=no-member + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("-p") + 1] == bootloader_port + assert str(dfu_package) in cmd + + def test_open_dfu_leaves_another_dongle_in_its_bootloader_alone( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Only a bootloader port that appears after the touch is the touched dongle's.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + other = MagicMock( + device="/dev/ttyACM2", vid=OPEN_DFU_USB_ID[0], pid=OPEN_DFU_USB_ID[1] + ) + calls = [0] + + def _comports(**_kwargs): + calls[0] += 1 + if calls[0] == 1: + return [MagicMock(device=host, vid=0x2FE3, pid=0x0100), other] + if calls[0] == 2: + return [other] # the touched dongle is rebooting + return [ + other, + MagicMock( + device="/dev/ttyACM1", + vid=OPEN_DFU_USB_ID[0], + pid=OPEN_DFU_USB_ID[1], + ), + ] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + assert upload_program(config={}, args=None, host=host) is True + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("-p") + 1] == "/dev/ttyACM1" + + def test_open_dfu_bootloader_that_never_appears_gets_the_reset_hint( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Without ``dfu:`` the touch does nothing; the hint names the RESET button.""" + from esphome.components.nrf52 import upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + clock = [0.0] + + def _monotonic() -> float: + clock[0] += 1.0 + return clock[0] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context(patch("time.monotonic", side_effect=_monotonic)) + with pytest.raises(EsphomeError, match="press the RESET button"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + + def test_open_dfu_bootloader_needs_nordic_nrfutil( + self, setup_core: Path, tmp_path: Path + ) -> None: + """When Nordic's nrfutil cannot be fetched the upload fails before trying adafruit-nrfutil.""" + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=OPEN_DFU_USB_ID + ) + stack.enter_context( + patch( + "esphome.components.nrf52.ensure_nordic_nrfutil", + side_effect=EsphomeError("Failed to download Nordic's nrfutil"), + ) + ) + with pytest.raises(EsphomeError, match="download Nordic's nrfutil"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: """A failed nordicsemi DFU must raise EsphomeError.""" from esphome.components.nrf52 import upload_program - _setup_nrf52_core(build_path=tmp_path / "build") - CORE.config_path = tmp_path / "test.yaml" - paths = _make_paths(tmp_path) - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - dfu_package.parent.mkdir(parents=True, exist_ok=True) - dfu_package.touch() + paths, _ = _prepare_serial_dfu(tmp_path) host = "/dev/ttyACM0" with ExitStack() as stack: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a1ae7f9ec77..67acfa6a189 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -22,7 +22,9 @@ from esphome.const import ( PLATFORM_RP2, PLATFORM_RTL87XX, ) -from esphome.core import EsphomeError +from esphome.core import DocumentLocation, EsphomeError, line_directive +from esphome.cpp_generator import LambdaExpression +from esphome.helpers import indent from esphome.storage_json import StorageJSON from esphome.writer import ( CPP_AUTO_GENERATE_BEGIN, @@ -965,6 +967,32 @@ def test_write_cpp_creates_new_file( assert "App.loop();" in written_content +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.write_file_if_changed") +@patch("esphome.writer.CORE") +def test_write_cpp_resets_line_after_lambda( + mock_core: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """The code after a YAML lambda is reported against main.cpp again.""" + main_cpp = tmp_path / "main.cpp" + mock_core.relative_src_path.return_value = main_cpp + mock_core.cpp_global_section = "" + lambda_ = LambdaExpression( + ("foo();",), (), source=DocumentLocation("test.yaml", 4, 0) + ) + + write_cpp(indent(f"bar({lambda_});\nbaz();") + "\n") + + lines = mock_write_file.call_args[0][1].split("\n") + closing = lines.index(" });") + assert lines[closing - 3].strip() == '#line 5 "test.yaml"' + # The directive names the line after it, which is closing + 1 (1-based) + assert lines[closing - 1].strip() == line_directive(str(main_cpp), closing + 1) + assert lines[closing + 1] == " baz();" + + @pytest.mark.usefixtures("mock_copy_src_tree") @patch("esphome.writer.CORE") def test_write_cpp_with_missing_end_marker( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c3276823ecc..8ce554f0a52 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -431,12 +431,32 @@ def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None: def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: """Listener is removed even if the body raises.""" - before = len(yaml_util._load_listeners) + before = len(yaml_util.load_listeners) with pytest.raises(RuntimeError), yaml_util.track_yaml_loads(): raise RuntimeError("boom") - assert len(yaml_util._load_listeners) == before + assert len(yaml_util.load_listeners) == before + + +def test_load_listener_removing_itself_does_not_skip_the_next( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A listener that removes itself while notified does not skip the one after it.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + listeners: list[Callable[[Path], None]] = [] + monkeypatch.setattr(yaml_util, "load_listeners", listeners) + seen: list[Path] = [] + + def first(fname: Path) -> None: + listeners.remove(first) + + listeners.extend((first, seen.append)) + yaml_util.load_yaml(yaml_file) + + assert seen == [yaml_file.resolve()] + assert listeners == [seen.append] def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure( @@ -954,6 +974,23 @@ def test_yaml_merge_chain_include_resolves() -> None: assert config["base"]["existing"] == "value" +def test_parse_yaml_custom_loader_resolves_root_include() -> None: + """A document that is only `!include` is resolved for a custom loader too.""" + parent = Path("/fake/main.yaml") + loader_calls: list[Path] = [] + + def custom_loader(fname: Path) -> dict[str, int]: + loader_calls.append(fname) + return {"x": 1} + + config = yaml_util.parse_yaml( + parent, io.StringIO("!include inner.yaml\n"), custom_loader + ) + + assert config == {"x": 1} + assert loader_calls == [Path("/fake/inner.yaml")] + + def test_yaml_merge_chain_include_depth_exceeded() -> None: """Chain includes in merge keys exceeding depth limit raise a clear error.""" parent = Path("/fake/main.yaml") @@ -2300,3 +2337,21 @@ def test_find_secret_references_in_flow_collections() -> None: """Flow mapping and sequence punctuation is not part of the secret name.""" text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n" assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"} + + +@pytest.mark.parametrize( + ("yaml_text", "expected_offset"), + [ + ("value: return 42;\n", 0), + ('value: "return 42;"\n', 0), + ("value: |-\n return 42;\n", 1), + ("value: >-\n return 42;\n", 1), + ], +) +def test_content_offset_only_for_block_scalars( + yaml_text: str, expected_offset: int +) -> None: + """A one-line lambda starts on its own line; only | and > move it to the next.""" + doc = yaml_util.parse_yaml(Path("test.yaml"), io.StringIO(yaml_text)) + assert doc["value"].content_offset == expected_offset + assert doc["value"].content_start.line == expected_offset