diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index 8ad7cd5d847..d5e994a82f0 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -15,19 +15,26 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul 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. + the shell that runs it. When more than one agent session shares the bench, run a copy of the script + under a session-specific name (`cap.py`) so another session's `pkill` cannot take your capture + down. 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. + `!secret`. A copy of a device's own config keeps using the secrets file next to that config: two + checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. 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. + the build's `src/` for the change before trusting any number. Tool calls sent together share one + shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=` on + every compile and upload command and print `esphome.__file__` into the compile log. 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. + Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must + start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one + fixed key shared by every such config on the board, so a new name does not help there: wipe once at + boot with an `on_boot` lambda that resets the preferences and saves a marker key + (`templates/dut-ap-only-wipe-once-bk72xx.yaml`). 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. @@ -73,7 +80,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul | 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 | +| 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. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner | | 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 | @@ -91,7 +98,8 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA 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. +- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now + and then; retry up to three times. - `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), @@ -100,6 +108,13 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA (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. +Per-topic scripts, each described in the matching `reference/recipes/.md`: `online_image/` +(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/` +(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, +API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the +Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and +capture decoder). + ## Templates in `$SKILL/templates` - `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and @@ -107,17 +122,25 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA - `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. +- Per-topic device configs, each described in `reference/recipes/.md`: `online-image-dut.yaml`, + `usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`, + `waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`, + `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`. ## 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). +- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse + too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below + the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first. +- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash + preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does. - 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 +`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` 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 index fd2cd448785..805643b59f1 100644 --- a/.agents/skills/hardware-test/reference/boards.md +++ b/.agents/skills/hardware-test/reference/boards.md @@ -88,8 +88,28 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever 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. +- Opening the port may reset the chip (bootloader banner on open) or may not, depending on the adapter and + what pyserial does with DTR and RTS; `serial_capture.py --reset` pulses RTS on CEN and always does, and + `lt_get_reboot_reason()` reports that as Power-On. Check for the banner at the start of a capture before + assuming either. +- `flash_bk72xx.py` misses the link now and then (`Timed out attempting to link with chip`); retry up to 3 + times before suspecting the board. +- Crashes: `__builtin_trap()` and a call into unmapped memory both end as an undefined-instruction trap + (`bk_misc_get_start_type()` 6, `lt_get_reboot_reason()` Crash); reads, unaligned loads and stores to + unmapped addresses never fault on the ARM968. Without Wi-Fi the SDK trap handler spins with interrupts off + for 10 s and the hardware watchdog resets the chip. With Wi-Fi connected a dev build hangs forever after a + crash, and never resets from a plain `portDISABLE_INTERRUPTS(); while (1) {}` either: the boot-time + watchdog is dead once Wi-Fi is up, for any lockup. The crash handler fix (#21012) re-arms the watchdog + from its wrapper, after which the reboot comes within a second (BK7238 and BK7231N). +- The SDK prints nothing on UART1 under `sdk_silent: all`; the bootloader then prints the banked + `cpsr/spsr/r13/r14` of SVC, IRQ, FIQ and SYS plus `ST:` (the retention register) before `go os_addr`. + A CEN or power reset wipes ITCM (reads `0xaaaaaaaa`), so only a watchdog reset keeps a record there. What + survives that reboot on the BK7238: ITCM (`0x3F0000..0x3F4000`) and the top 96 KB of SRAM + (`0x428000..0x440000`, the heap); the bootloader zeroes SRAM up to about `0x420000` and rewrites + `0x400024..0x4000FF` with its own register dump every boot, so neither a `.noinit` after `.bss` nor the + SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception + handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit` + placed there after `.itcm.code` works for a crash record. - 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. @@ -123,6 +143,38 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever - 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. +- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an + `ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC + port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the + 1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader + serial port can fail, copying `.pioenvs//zephyr/zephyr.uf2` to the volume works (the copy reports + an I/O error as the board reboots, which is normal). Reboot a running `dfu: true` app with a 2001 bps touch. +- The PCA10059 board DTS already defines a `cdc_acm_uart` node, so every ESPHome CDC port is one USB port + later than its number (log on `cdc_acm_uart0` is the second port). A third CDC instance needs + `CONFIG_USB_MAX_NUM_TRANSFERS` above 4 or it never moves data (see `recipes/usb_cdc_acm.md`). +- USB CDC boot output: the Zephyr legacy stack's `cdc_acm` TX ring is 1 KB and `uart_poll_out` drops when it + is full, so anything printed in one burst beyond about 1 KB (a coredump, a long table) is lost even with + the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead. +- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs + a never-saved value. + +## Waveshare ESP32-S3-ETH with the OV5640 camera module + +- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11, + MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21. +- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45, + 46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or + power-down GPIO exists. +- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so + the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is + not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot: + priority: 700` with `delay(1200)` and `esp32_camera: setup_priority: 200` + (`templates/rtsp-waveshare-s3-eth-dut.yaml`). `esphome config` rejects a config that uses GPIO8 for both the switch + and `power_down_pin` (pin reuse), so it is one or the other. +- The sensor at SCCB 0x3C answers only with XCLK running, so the ESPHome i2c scan at boot (before XCLK) + reporting no devices on 48/47 is expected. If the SCCB ID register (0x3100 = 0x78) reads fine but every + 0x30xx register reads 0x00 (`Detected camera not supported`, 0x106), reseat the ribbon before blaming + software; `templates/waveshare-s3-eth-camdiag.yaml` runs that probe with LEDC XCLK and raw SCCB reads. ## ESP Thread Border Router board v1.2 (S3 host + H2 RCP) diff --git a/.agents/skills/hardware-test/reference/recipes.md b/.agents/skills/hardware-test/reference/recipes.md deleted file mode 100644 index 32944ce75c4..00000000000 --- a/.agents/skills/hardware-test/reference/recipes.md +++ /dev/null @@ -1,117 +0,0 @@ -# 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/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md new file mode 100644 index 00000000000..7c7871f45ce --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -0,0 +1,26 @@ +# Proven test flows + +Each flow names the pull requests it was used on, so their result tables show what to post. + +One file per component, named after it: + +- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266 +- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer +- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API +- [`espnow.md`](espnow.md): ESP8266 broadcast receive +- [`hdmi_cec.md`](hdmi_cec.md): two-board wire +- [`ld2450.md`](ld2450.md): polygon zones +- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot +- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT +- [`nrf52.md`](nrf52.md): dongle board support +- [`online_image.md`](online_image.md): stalled and malformed downloads +- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP +- [`ota.md`](ota.md): overlap and lock, timeouts, deflate +- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH +- [`scheduler.md`](scheduler.md): loop wake A/B +- [`udp.md`](udp.md): datagrams, multicast, sockets +- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver +- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP) +- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle +- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards +- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md new file mode 100644 index 00000000000..fa6733a72c6 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -0,0 +1,7 @@ +# api + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/captive_portal.md b/.agents/skills/hardware-test/reference/recipes/captive_portal.md new file mode 100644 index 00000000000..6b9710e531b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/captive_portal.md @@ -0,0 +1,21 @@ +# captive_portal + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/cse7766.md b/.agents/skills/hardware-test/reference/recipes/cse7766.md new file mode 100644 index 00000000000..369b3636941 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/cse7766.md @@ -0,0 +1,11 @@ +# cse7766 + +## cse7766 on a Sonoff S31 (#20948) + +`templates/s31-cse7766-dut.yaml`: `logger: baud_rate: 0` because the CSE7766 owns the UART, VERBOSE logger +with everything but cse7766 quieted, 2 s throttles, and a `globals` frame counter fed by the voltage sensor's +`on_raw_value` and logged every 10 s (about 96 frames per 10 s). OTA only, every reflash reboots the relay. +`scripts/cse7766/run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]` subscribes to states and logs over the +API with a concurrent ping and prints the frame counter, API gaps and warnings. API state gaps of 10 to 20 s +can appear on a weak Wi-Fi path while the device counter stays flat, so judge a UART parser by the +device-side counter, not by the API cadence. With nothing plugged in, current and power read 0. diff --git a/.agents/skills/hardware-test/reference/recipes/espnow.md b/.agents/skills/hardware-test/reference/recipes/espnow.md new file mode 100644 index 00000000000..3fee12981f9 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/espnow.md @@ -0,0 +1,7 @@ +# espnow + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md b/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md new file mode 100644 index 00000000000..6582626167e --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md @@ -0,0 +1,9 @@ +# hdmi_cec + +## 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). diff --git a/.agents/skills/hardware-test/reference/recipes/ld2450.md b/.agents/skills/hardware-test/reference/recipes/ld2450.md new file mode 100644 index 00000000000..9d1a8d8f32b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ld2450.md @@ -0,0 +1,7 @@ +# ld2450 + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/libretiny.md b/.agents/skills/hardware-test/reference/recipes/libretiny.md new file mode 100644 index 00000000000..fb784fbc620 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/libretiny.md @@ -0,0 +1,58 @@ +# libretiny + +## LibreTiny rtc preference emulation (#21007) + +The PR keeps `storage: rtc` preferences in FlashDB and deletes them on the first load or save after a boot +whose `lt_get_reboot_reason()` is not software, watchdog, crash or sleep. Only the families listed in +`_libretiny_rtc_supported()` accept the config keys; on others force the backend in with +`platformio_options: build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]` after confirming the rejection. + +- Harness: `scripts/libretiny_prefs/ltprefs.hpp` plus `templates/bk72xx-rtc-prefs-dut.yaml`. `ltprefs_boot()` + loads one rtc and one flash preference, a second pair that shares one key as rtc and as flash, and a third + rtc entry (so a hard reset drops three entries in one pass), logs the reason and both results, saves `100 + step`, syncs; after + a templated delay `ltprefs_exit()` bumps the flash-backed step and runs the step's action (`lt_reboot()`, + `__builtin_trap()`, `lt_reboot_wdt()` or idle). The last 8 boot reports are kept in a flash preference and + repeated in the 10 s alive line, so a boot missed by the capture (a power cut, an OTA-only board) is still + readable. Bump `run:` for fresh keys. +- A store to an unmapped address does not fault on the ARM968, the board carries on: use `__builtin_trap()` + for the crash row. The SDK crash handler reboots about 10 s later. Works on BK7238 and BK7231N. +- Hard resets: `serial_capture.py ... --reset` pulses RTS on CEN (reported as Power-On). A real power cut + needs a hand or a smart plug; the history in the alive line covers the boot the host cannot see. On the + Beken SDK a chip-enable reset and a power cut both report Power-On, there is no separate reset-pin source. +- Step 0 and step 8 wait 30 s before acting so the host can open the port (and pulse a reset) after a flash + without losing the first boot; the flash tool's own reset starts the sequence immediately otherwise. +- OTA-only board: keep the device's wifi, api and ota blocks verbatim, keep safe_mode, read logs with + `esphome logs --device .local` (the API attaches late, hence the history), and restore the + device's own config afterwards. +- Plain-build comparison: `raw_firmware.bin` differs by the timestamp and by `__LINE__` in log calls of + functions below the added code; map offsets through `objdump -h` load addresses and `addr2line` before + calling a byte difference a code change. + +## BK72xx crash report on the next boot (#21012) + +Harness: `scripts/libretiny_crash/bkprobe.hpp` plus `templates/bk72xx-crash-probe-dut.yaml`. A flash +preference holds the step; `on_boot` (priority -100) logs the reset reason, the SDK start type, two canaries +and hex dumps of the low SRAM, a set of marker words and the record area after `.bss`, waits 30 s on step 0 +(so a capture can be opened after the flash) or 5 s, saves `step + 1`, syncs, then acts: 0 `__builtin_trap()`, +1 call into unmapped memory, 2 unmapped and unaligned reads and a store (no fault on the ARM968), 3 +`lt_reboot()`. A `globals: restore_value` step counter does not work here: it is only saved from the global's +loop, which a crash 50 ms later never reaches; save an `ESPPreferenceObject` and `global_preferences->sync()` +first. Bump `run:` for fresh keys. Keep the `wifi:` block: the bug the fix addresses only shows with Wi-Fi up. + +Flow: `flash_bk72xx.py` (retry up to three times), one `serial_capture.py ... --reset` capture of 150 to 230 s, +then `grep -a "libretiny.crash\|probe"`. Decode with +`arm-none-eabi-addr2line -pfiaC -e .pioenvs//raw_firmware.elf ` (the ELF is `raw_firmware.elf` +on LibreTiny), or run `scripts/libretiny_crash/decode_capture.py ` from the config directory +to feed the capture through the CLI's `LogLineProcessor`. + +Expected: `*** CRASH DETECTED ON PREVIOUS BOOT ***`, `Reason: Undefined instruction`, PC on the `udf`, +LR = PC + 1, BT0..BT7 walking the lambda, `StatelessLambdaAction`, `Action::play_complex`, `DelayAction`, +`Scheduler::call`, `Application::loop`. The wild call reports where the core ended up (it executes zeros as +Thumb NOPs until it wraps into real code), not the called address. Without the fix a dev build with Wi-Fi +connected never comes back from the crash; see the BK72xx section in `boards.md`. + +OTA-only board: copy the device's own config, add the probe include and the step machine (step 0 crashes +45 s after boot, later steps idle), keep its wifi, api, ota and safe_mode blocks and use the secrets file +next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the +upload in a short timeout. Read the replayed report with `esphome logs ... --device .local` (an API +client gets it on subscribe) or on the board's serial log port if it has one. diff --git a/.agents/skills/hardware-test/reference/recipes/mqtt.md b/.agents/skills/hardware-test/reference/recipes/mqtt.md new file mode 100644 index 00000000000..6298f769aa9 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/mqtt.md @@ -0,0 +1,12 @@ +# mqtt + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/nrf52.md b/.agents/skills/hardware-test/reference/recipes/nrf52.md new file mode 100644 index 00000000000..a9ae66e1b15 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/nrf52.md @@ -0,0 +1,6 @@ +# nrf52 + +## 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/reference/recipes/online_image.md b/.agents/skills/hardware-test/reference/recipes/online_image.md new file mode 100644 index 00000000000..da81c1eb969 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/online_image.md @@ -0,0 +1,20 @@ +# online_image + +## online_image stalled downloads (#20594) + +One classic ESP32, both frameworks (the ESP32 Arduino build also uses `http_request_idf.cpp`). The host +serves the images: `scripts/online_image/make_images.py ` writes 16x16 BMP/PNG/JPEG/QOI plus an 8 bit +palette BMP with a 1078 byte header; `scripts/online_image/oi_server.py 8765 ` answers one +misbehaviour per path name (`ok`, `truncated`, `large_header`, `empty`, `stall` holds the socket 40 s, +`closed_early`, `slow` pauses 2.5 s, `chunked`, `chunked_cut`, `noclen`, `huge` claims 20 MB) with an ETag +and a 304 on If-None-Match. `templates/online-image-dut.yaml` has two AUTO-format images (default and +256 byte buffer), API actions `fetch`/`fetch_small` (set_url with `update: false`, then component.update) +and `refetch`/`refetch_small` (component.update only, keeps the cache headers), and a restart button. +`scripts/online_image/drive.py http://:8765 pr|base` runs the +case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server +ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with +`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete +(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG +and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs +a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list. +A case expected as `any` passes on `finished` or `error`, never on no outcome. diff --git a/.agents/skills/hardware-test/reference/recipes/openthread.md b/.agents/skills/hardware-test/reference/recipes/openthread.md new file mode 100644 index 00000000000..e126eb6e235 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/openthread.md @@ -0,0 +1,5 @@ +# openthread + +## Thread border router (#18104) + +See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. diff --git a/.agents/skills/hardware-test/reference/recipes/ota.md b/.agents/skills/hardware-test/reference/recipes/ota.md new file mode 100644 index 00000000000..1c9d6642bb0 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ota.md @@ -0,0 +1,8 @@ +# ota + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/rtsp_server.md b/.agents/skills/hardware-test/reference/recipes/rtsp_server.md new file mode 100644 index 00000000000..10438ccd0f4 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/rtsp_server.md @@ -0,0 +1,18 @@ +# rtsp_server + +## RTSP camera streaming on the Waveshare ESP32-S3-ETH (#17990) + +`templates/rtsp-waveshare-s3-eth-dut.yaml` (OV5640 pins, GPIO8 camera power switch with the 1.2 s boot delay, +`rtsp_server`, logger VERBOSE for `rtsp_server`); swap the `wifi:` block for the W5500 `ethernet:` block when +the cable is in, and add `network: enable_high_performance: true` for the big TCP buffers. Flash over native +USB, capture the log with `serial_capture.py`, take the IP from the last `IP Address` line that is not +0.0.0.0 (or `dns-sd -Q .local`). `scripts/rtsp_server/rtsp_client.py IP [cases]` covers play, udp, +pipeline, badreq, replay, getparam, teardown, third, stall, timeout; `rawrate2.py IP quiet|chatty|rtcp|bigbuf` +measures the raw receive rate; `measure.sh IP LABEL LOGFILE [OUTDIR]` runs the fixed set (ping, ffmpeg 12 s +twice, two ffmpeg clients, raw reader) and appends to `results.txt`; `cam_api.py IP` pulls one image over the +API; `ffmpeg -rtsp_transport tcp -i rtsp://IP:554/ -frames:v 30 -f image2 f%03d.jpg` plus Pillow checks +decoding. The 5760 byte default TCP send buffer makes the stream stall over Wi-Fi (about 1 fps, dozens of +stall drops per run); `enable_high_performance` gives several fps on Wi-Fi and removes the stalls on Ethernet. +Frame counts vary a lot between runs (auto exposure changes the JPEG size), so always measure twice. Home +Assistant's stream pipeline does not accept MJPEG over RTSP; its ffmpeg camera opens one RTSP session per +snapshot, so `max_connections` needs headroom, and go2rtc transcoding to H.264 is the way to a live view. diff --git a/.agents/skills/hardware-test/reference/recipes/scheduler.md b/.agents/skills/hardware-test/reference/recipes/scheduler.md new file mode 100644 index 00000000000..d3210f105be --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/scheduler.md @@ -0,0 +1,8 @@ +# scheduler + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/udp.md b/.agents/skills/hardware-test/reference/recipes/udp.md new file mode 100644 index 00000000000..b029e42ff27 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/udp.md @@ -0,0 +1,11 @@ +# udp + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md b/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md new file mode 100644 index 00000000000..688a415f3ad --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md @@ -0,0 +1,20 @@ +# usb_cdc_acm + +## usb_cdc_acm on the nRF52840 dongle (#11634) + +`templates/usb-cdc-acm-dongle-dut.yaml`: logger on USB_CDC port 0, `usb_cdc_acm` interface 0 disabled, +interface 1 echoing from a 20 ms lambda, 0x01-prefixed commands for burst, stream, flush, peek, line coding +and counters. Build and flash with `dfu: true` through the app port; the dongle then shows three CDC ports: +the board DTS node `cdc_acm_uart` (unused), `cdc_acm_uart0` (log) and `cdc_acm_uart1` (data), in that order. +Capture the log port with `serial_capture.py`, drive the data port with `scripts/usb_cdc_acm/drive.py +DATA_PORT` (11 cases, prints PASS/FAIL and a summary) and grep the log for `Opened`, `Closed`, `buffer full`, +`flush result`, `line coding`. Boot lines are held in the driver's 1 KB ring until the host opens the port, so +a capture started later still shows the first 1 KB of the boot and then a truncated line. + +With three CDC instances the Zephyr legacy stack's `CONFIG_USB_MAX_NUM_TRANSFERS=4` starves the last instance +(each instance holds an OUT read and an IN ZLP), so interface 1 shows DTR changes but moves no data. +`scripts/usb_cdc_acm/cdc_probe.hpp` (include it, call `cdc_probe()` from an interval) reads the Zephyr driver +directly to tell a dead endpoint from a component bug. Raising the transfer pool to 8, or disabling the +board's `cdc_acm_uart` node, fixes it. Design limit of the driver: `fifo_fill` holds `tx_ready` until the +write callback and `write_array` drops when the ring is full, so a 64 KiB full-duplex echo loses one 64 byte +tail on every buffer size. diff --git a/.agents/skills/hardware-test/reference/recipes/wifi.md b/.agents/skills/hardware-test/reference/recipes/wifi.md new file mode 100644 index 00000000000..055fe267084 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/wifi.md @@ -0,0 +1,25 @@ +# wifi + +## `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. + +## 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`. + +## 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. diff --git a/.agents/skills/hardware-test/reference/recipes/zephyr.md b/.agents/skills/hardware-test/reference/recipes/zephyr.md new file mode 100644 index 00000000000..e10b2010230 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zephyr.md @@ -0,0 +1,10 @@ +# zephyr + +## Zephyr preferences on the nRF52840 dongle (#20810) + +`templates/nrf52-preferences-dut.yaml`: logger on USB_CDC at VERBOSE, `flash_write_interval: 5s`, one +`globals` with `restore_value` incremented every 15 s. Give each build its own global id so each first boot +sees a never-saved key (settings survive a reflash). Flow per build: flash through the log port, 40 s capture +of the first boot, then start a second capture and reboot with a 2001 baud touch on the dongle's other CDC +port (the dfu rate callback is global, and the capture holds the log port); `serial_capture.py` reopens the +log port after the USB re-enumeration and gets the buffered boot lines (`load setting`, `Loaded N settings`). diff --git a/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md b/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md new file mode 100644 index 00000000000..80f2db8574d --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md @@ -0,0 +1,17 @@ +# zephyr_coredump + +## zephyr_coredump crash and decode on nRF52 boards (#13122) + +`templates/nrf52-coredump-crash-dut.yaml` (dongle; change `board:` to `adafruit_itsybitsy_nrf52840` for the +ItsyBitsy): `zephyr_coredump:`, `dfu: true`, a `restore_value` global keyed by the `run` substitution +(settings survive a reflash, so bump `run` for every new crash), and an `on_boot` sequence that sets the +flag, yields 3 s so the globals loop saves it (a blocking lambda never gets there and the board crash-loops), +syncs preferences, then writes to 0xDEADBEEF. Flash, keep every port closed for 80 s, then +`scripts/zephyr_coredump/flow.sh LABEL PORT ELF [OUTDIR]` does the three opens (3 s cut, 100 s full print, +20 s reopen) and calls `decode.sh`, which keeps the first complete `#CD:BEGIN#..END#` block, runs Zephyr's +`coredump_serial_log_parser.py` and `coredump_gdbserver.py` from the sdk-nrf ESPHome installed and prints +`bt` and `info threads` with its `arm-zephyr-eabi-gdb`. The ELF is `.pioenvs//zephyr/zephyr/zephyr.elf`. +Expect about 9 KB dumps, 140 to 150 `#CD:` lines, a 673 ms loop warning for the erase, and `Has stored +dump: 0` on the reopen. Check `compiled on` in every capture: another session flashing the same board +mid-test looks exactly like a failed test otherwise. Boards without a network cannot exercise the API and +MQTT delivery paths of the component; say so in the results. diff --git a/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md b/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md new file mode 100644 index 00000000000..b9e4ca99018 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md @@ -0,0 +1,23 @@ +# zwave_proxy + +## zwave_proxy frame checksums on a live proxy (#20922) + +A Z-Wave proxy (ESP32-S3 with the module on `usb_uart`) is a live device: flash it over OTA and read logs +over the API, there is no serial console because the native USB is the host port for the module, so the +boot-time `GET_NETWORK_IDS` exchange is never captured. Home Assistant's Z-Wave JS holds the single API +subscription (it polls `0x3B` every 30 s and resubscribes after every reboot), so do not subscribe from a +script, and keep the device `name:` so it stays on Z-Wave JS; give each build its own scratch directory +instead. + +1. `logger: level: VERY_VERBOSE` with `logs: zwave_proxy: VERY_VERBOSE` and DEBUG for the rest: the + `Sending:`, `Received frame:` and `CHECKSUM Received ... Calculated` lines are `ESP_LOGVV`, which needs + the compile-time level. +2. `md5` the two `firmware.ota.bin` files to be sure the A and B builds differ, then `esphome upload + --device .local` and a `timeout 330 esphome logs --device .local` capture started right after + the OTA to catch the resubscribe traffic. +3. `scripts/zwave_proxy/verify_checksums.py ` recomputes the XOR (init 0xFF, LENGTH..payload) over + every logged frame and counts bad checksums and ACK/NAK sent and received; `scripts/zwave_proxy/devinfo.py + .local` prints the firmware version, compile time and the Home ID from the API (a Home ID proves the + device-built `GET_NETWORK_IDS` frame was accepted at boot). +4. Pre-existing: one `zwave_proxy took a long time for an operation` warning on the first frame after the + log client connects, caused by VERY_VERBOSE logging; it appears on dev too. diff --git a/.agents/skills/hardware-test/scripts/cse7766/observe.py b/.agents/skills/hardware-test/scripts/cse7766/observe.py new file mode 100644 index 00000000000..e88e1da1b2e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/cse7766/observe.py @@ -0,0 +1,71 @@ +"""Subscribe to states and logs of a device over the API for N seconds and summarize its float sensors. + +usage: observe.py IP SECONDS OUTFILE [NAME_FILTER] + +Every float sensor state whose entity name contains NAME_FILTER (default: all) is appended to OUTFILE +with a relative timestamp, log lines are appended with a LOG prefix, and a one-line summary per sensor +(count, min, max, last) plus the warning and error count is printed at the end. +""" + +import asyncio +import pathlib +import re +import sys +import time + +from aioesphomeapi import APIClient, LogLevel + +IP, SECS, OUT = sys.argv[1], float(sys.argv[2]), sys.argv[3] +FILTER = sys.argv[4] if len(sys.argv) > 4 else "" +# ESPHome LOG_SECRET span; one cut off before its end marker is secret to the end of the line +SECRET = re.compile(r"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL) +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +async def main() -> None: + c = APIClient(IP, 6053, None) + await c.connect(login=True) + di = await c.device_info() + ents, _ = await c.list_entities_services() + names = {e.key: e.name for e in ents} + readings: dict[str, list[float]] = {} + warnings: list[str] = [] + t0 = time.monotonic() + with pathlib.Path(OUT).open("w", encoding="utf-8") as out: + out.write( + f"device {di.name} version {di.esphome_version} compiled {di.compilation_time}\n" + ) + + def on_state(st) -> None: + n = names.get(st.key, str(st.key)) + if FILTER in n and hasattr(st, "state") and isinstance(st.state, float): + readings.setdefault(n, []).append(st.state) + out.write(f"[{time.monotonic() - t0:6.1f}] {n}: {st.state}\n") + out.flush() + + def on_log(msg) -> None: + line = ( + msg.message.decode(errors="replace") + if isinstance(msg.message, bytes) + else msg.message + ) + line = ANSI.sub("", SECRET.sub("", line)) + out.write(f"[{time.monotonic() - t0:6.1f}] LOG {line}\n") + out.flush() + if "[W]" in line or "[E]" in line or "Invalid" in line: + warnings.append(line) + + c.subscribe_states(on_state) + c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE) + await asyncio.sleep(SECS) + await c.disconnect() + summary = "; ".join( + f"{k.split()[-1]} n={len(v)} min={min(v):.3f} max={max(v):.3f} last={v[-1]:.3f}" + for k, v in sorted(readings.items()) + ) + print(f"{di.compilation_time}: {summary}; warnings/errors {len(warnings)}") + for w in warnings[:5]: + print(" ", w) + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh b/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh new file mode 100755 index 00000000000..e9e9083f978 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# usage: run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER] +# Observes states and logs over the API with a concurrent ping and prints a summary: the device-side +# "frames parsed" counter per 10 s, gaps over 5 s between voltage updates seen by the API, and warnings. +# Needs a python with aioesphomeapi (PYTHON, default python3). +D=${0:A:h} +IP=$1; SECS=$2; L=$3; S=${4:-.}; F=${5:-} +PY=${PYTHON:-python3} +setopt pipefail +ping -i 1 -c $SECS $IP > $S/ping-$L.txt 2>&1 & +P=$! +if ! timeout $((SECS + 40)) $PY $D/observe.py $IP $SECS $S/obs-$L.txt "$F" 2>&1 | sed "s/$IP//g"; then + echo "observe.py failed or timed out, no summary"; wait $P; exit 1 +fi +wait $P +echo "ping: $(tail -2 $S/ping-$L.txt | head -1 | sed "s/$IP//g") $(tail -1 $S/ping-$L.txt)" +python3 - "$S/obs-$L.txt" <<'PY' +import re, sys +txt = open(sys.argv[1]).read() +fr = [int(m.group(1)) for m in re.finditer(r"frames parsed: (\d+) in 10 s", txt)] +print(f"device frame counter per 10 s: n={len(fr)} min={min(fr) if fr else None} max={max(fr) if fr else None} mean={sum(fr)/len(fr) if fr else 0:.1f}") +ts = [float(m.group(1)) for m in re.finditer(r"^\[\s*([0-9.]+)\] .*Voltage: ", txt, re.M)] +gaps = [round(b - a, 1) for a, b in zip(ts, ts[1:]) if b - a > 5] +print(f"API voltage updates {len(ts)}, gaps over 5 s: {gaps}") +warn = [l for l in txt.splitlines() if "LOG" in l and ("[W]" in l or "[E]" in l or "Invalid" in l)] +print(f"warnings/errors in log: {len(warn)}", *warn[:3], sep="\n ") +PY diff --git a/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp b/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp new file mode 100644 index 00000000000..b36739c362b --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp @@ -0,0 +1,116 @@ +// BK72xx crash probe: forces one crash per step and shows what the SDK and the bootloader leave behind. +// Steps: 0 undefined instruction, 1 call into unmapped memory, 2 unmapped and unaligned accesses (no fault), +// 3 lt_reboot(). The step lives in a flash preference saved and synced before the crash. +#pragma once +#include +#include +#include +#include +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +extern "C" unsigned bk_misc_get_start_type(void); +extern "C" char _bss_end[]; + +static constexpr uint32_t PROBE_CANARY_LOW = 0x004000F8; // free word in the SDK-reserved low SRAM +static constexpr uint32_t PROBE_CANARY_ITCM = 0x003F3FF0; // top of ITCM +static char bkprobe_report[112]; +static constexpr uint32_t PROBE_MARKS[] = {0x40A1B0, 0x40B000, 0x40C000, 0x40E000, 0x410000, 0x414000, 0x418000, + 0x41C000, 0x420000, 0x428000, 0x430000, 0x438000, 0x43C000, 0x43E000, + 0x43F000, 0x43F800, 0x43FC00, 0x43FE00, 0x43FFF0}; +static constexpr size_t PROBE_MARK_COUNT = sizeof(PROBE_MARKS) / sizeof(PROBE_MARKS[0]); + +static inline uint32_t rd(uint32_t a) { return *(volatile uint32_t *) a; } + +inline esphome::ESPPreferenceObject bkprobe_pref(uint32_t run) { + return esphome::global_preferences->make_preference(0xBC000000u + run); +} + +inline int bkprobe_boot(uint32_t run) { + using namespace esphome; + int step = 0; + bkprobe_pref(run).load(&step); + unsigned st = bk_misc_get_start_type(); + snprintf(bkprobe_report, sizeof(bkprobe_report), "step=%d reason=%s(%d) start_type=%u low=%08lx itcm=%08lx top=%08lx", + step, lt_get_reboot_reason_name(lt_get_reboot_reason()), (int) lt_get_reboot_reason(), st, + (unsigned long) rd(PROBE_CANARY_LOW), (unsigned long) rd(PROBE_CANARY_ITCM), + (unsigned long) rd(0x0043FFF0u)); + ESP_LOGI("probe", "%s", bkprobe_report); + for (uint32_t a = 0x400000; a < 0x400100; a += 32) { + ESP_LOGI("probe", "%06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a, + (unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12), + (unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24), + (unsigned long) rd(a + 28)); + } + for (size_t i = 0; i < PROBE_MARK_COUNT; i += 7) { + char line[120]; + int n = 0; + for (size_t j = i; j < i + 7 && j < PROBE_MARK_COUNT; j++) + n += snprintf(line + n, sizeof(line) - n, " %06lx=%08lx", (unsigned long) PROBE_MARKS[j], + (unsigned long) rd(PROBE_MARKS[j])); + ESP_LOGI("probe", "marks%s", line); + } + uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u; + for (uint32_t a = rec; a < rec + 64; a += 32) { + ESP_LOGI("probe", "rec %06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a, + (unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12), + (unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24), + (unsigned long) rd(a + 28)); + } + *(volatile uint32_t *) PROBE_CANARY_LOW = 0xC0DE0000u | (uint32_t) step; + *(volatile uint32_t *) PROBE_CANARY_ITCM = 0x17C40000u | (uint32_t) step; + return step; +} + +__attribute__((noinline)) void bkprobe_data_abort() { + using namespace esphome; + volatile uint32_t v = rd(0xFFFFFFF0); + ESP_LOGI("probe", "read 0xFFFFFFF0 -> %08lx (no fault)", (unsigned long) v); + uint32_t r; + __asm__ volatile("ldr %0, [%1]" : "=r"(r) : "r"(0x00400101) : "memory"); + ESP_LOGI("probe", "unaligned ldr 0x400101 -> %08lx (no fault)", (unsigned long) r); + *(volatile uint32_t *) 0xFFFFFFF0 = 1; + ESP_LOGI("probe", "store 0xFFFFFFF0 (no fault)"); +} + +// act is the step number before it was bumped +inline void bkprobe_act(uint32_t run, int act) { + using namespace esphome; + int next = act + 1; + bkprobe_pref(run).save(&next); + global_preferences->sync(); + ESP_LOGI("probe", "acting on step %d (saved %d)", act, next); + // mark candidate storage areas so the next boot shows what survives + uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u; + for (uint32_t i = 0; i < 8; i++) + *(volatile uint32_t *) (rec + 32 + i * 4) = 0xA5A50000u | i; // second half of the record area + for (size_t i = 0; i < PROBE_MARK_COUNT; i++) + *(volatile uint32_t *) PROBE_MARKS[i] = 0x70700000u | (uint32_t) i; + ESP_LOGI("probe", "ramtop before: %08lx", (unsigned long) rd(0x0043FFF0u)); + vTaskDelay(pdMS_TO_TICKS(50)); + switch (act) { + case 0: + __builtin_trap(); // undefined instruction + break; + case 1: { + void (*fn)() = (void (*)()) 0xFFFFFFF1; // prefetch abort (unmapped, Thumb) + fn(); + ESP_LOGI("probe", "call 0xFFFFFFF1 returned (no fault)"); + break; + } + case 2: + bkprobe_data_abort(); + break; + case 3: + lt_reboot(); + break; + default: + break; + } +} + +inline void bkprobe_alive(int step) { + using namespace esphome; + ESP_LOGI("probe", "alive step=%d uptime=%lus boot: %s", step, (unsigned long) (millis() / 1000), bkprobe_report); +} diff --git a/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py b/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py new file mode 100644 index 00000000000..366dbe65d47 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py @@ -0,0 +1,25 @@ +"""Feed a serial capture through the CLI's stacktrace decoder for a build. + +usage: decode_capture.py CONFIG_YAML CAPTURE + +Run from the directory that holds the config (its .esphome build is used), with the esphome checkout that +built it on PYTHONPATH. Lines of the crash report are echoed, decoded frames follow them. +""" + +import logging +from pathlib import Path +import sys + +from esphome.config import read_config +from esphome.core import CORE +from esphome.stacktrace import LogLineProcessor + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +yaml, capture = Path(sys.argv[1]), Path(sys.argv[2]) +CORE.config_path = yaml +config = read_config({}) +proc = LogLineProcessor(config, CORE.target_platform) +for line in capture.read_text(encoding="utf-8", errors="replace").splitlines(): + if "libretiny.crash" in line: + print(line[:120]) + proc.process_line(line) diff --git a/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp b/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp new file mode 100644 index 00000000000..295ba7a9814 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp @@ -0,0 +1,155 @@ +#pragma once +// BK7238 bench harness for esphome#21007: one rtc and one flash preference, a step counter in flash, +// and an exit action per step so the board drives its own soft reboot, crash and watchdog reboot. +#include +#include +#include +#include +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +using namespace esphome; + +// Boot report kept for the alive line, so a boot the host could not capture is still visible +static char ltprefs_report[128]; +// Last boots' reports, kept in a flash preference so boots the host never saw stay readable +struct LtprefsHistory { + char line[8][128]; +}; +static LtprefsHistory ltprefs_hist; + +inline ESPPreferenceObject ltprefs_step_pref(uint32_t run) { + return global_preferences->make_preference(0x3B210070u ^ run, true); +} + +// Loads the step and both preferences, logs them, saves this step's value. +// Returns the exit action: 0 idle, 1 lt_reboot, 2 crash, 3 watchdog. +inline int ltprefs_boot(int &step, uint32_t run) { + auto reason = lt_get_reboot_reason(); + step = 0; + ltprefs_step_pref(run).load(&step); + auto rtcp = global_preferences->make_preference(0x1B210070u ^ run, false); + auto flp = global_preferences->make_preference(0x2B210070u ^ run, true); + uint32_t rv = 0, fv = 0; + // a second pair sharing one key as rtc and as flash, and a third rtc entry, so a hard reset drops three + auto rtcp2 = global_preferences->make_preference(0x5B210070u ^ run, false); + auto flp2 = global_preferences->make_preference(0x5B210070u ^ run, true); + auto rtcp3 = global_preferences->make_preference(0x6B210070u ^ run, false); + uint32_t rv2 = 0, fv2 = 0, rv3 = 0; + bool rok = rtcp.load(&rv); + bool fok = flp.load(&fv); + bool rok2 = rtcp2.load(&rv2); + bool fok2 = flp2.load(&fv2); + bool rok3 = rtcp3.load(&rv3); + snprintf(ltprefs_report, sizeof(ltprefs_report), + "step=%d reason=%s(%d) rtc=%s%u flash=%s%u sharedkey rtc=%s%u flash=%s%u rtc3=%s%u", step, + lt_get_reboot_reason_name(reason), (int) reason, rok ? "" : "none ", rv, fok ? "" : "none ", fv, + rok2 ? "" : "none ", rv2, fok2 ? "" : "none ", fv2, rok3 ? "" : "none ", rv3); + ESP_LOGI("lttest", "%s", ltprefs_report); + auto histp = global_preferences->make_preference(0x4B210070u ^ run, true); + if (!histp.load(<prefs_hist)) + memset(<prefs_hist, 0, sizeof(ltprefs_hist)); + memmove(<prefs_hist.line[1], <prefs_hist.line[0], sizeof(ltprefs_hist.line) - sizeof(ltprefs_hist.line[0])); + strncpy(ltprefs_hist.line[0], ltprefs_report, sizeof(ltprefs_hist.line[0]) - 1); + ltprefs_hist.line[0][sizeof(ltprefs_hist.line[0]) - 1] = 0; + if (!histp.save(<prefs_hist)) { + ESP_LOGW("lttest", "history save failed"); + } + int act = 0; + bool save = true; + switch (step) { + case 0: + act = 1; + break; // soft reboot + case 1: + act = 2; + break; // crash + case 2: + act = 3; + break; // watchdog + case 3: + act = 0; + break; // idle: chip-enable reset from the host + case 4: + act = 1; + break; // soft reboot after the hard reset + case 5: + act = 0; + break; // idle: chip-enable reset from the host + case 6: + act = 1; + save = false; + break; // no save, soft reboot: deleted, not skipped? + case 7: + act = 0; + break; // idle + case 8: + act = 2; + break; // crash + case 9: + act = 0; + break; // idle + case 10: + act = 0; + break; // idle: power cut by the user + default: + act = 0; + save = false; + break; + } + if (save) { + uint32_t v = 100 + step, v2r = 200 + step, v2f = 300 + step, v3 = 400 + step; + bool srtc = rtcp.save(&v); + bool sfl = flp.save(&v); + bool srtc2 = rtcp2.save(&v2r); + bool sfl2 = flp2.save(&v2f); + bool srtc3 = rtcp3.save(&v3); + bool synced = global_preferences->sync(); + ESP_LOGI("lttest", "save %u/%u/%u/%u: rtc=%d flash=%d sharedkey rtc=%d flash=%d rtc3=%d sync=%d", v, v2r, v2f, v3, + srtc, sfl, srtc2, sfl2, srtc3, synced); + } + ESP_LOGI("lttest", "exit action=%d", act); + return act; +} + +inline void ltprefs_exit(int &step, uint32_t run, int act) { + step++; + bool saved = ltprefs_step_pref(run).save(&step); + bool synced = global_preferences->sync(); + ESP_LOGI("lttest", "step %d save=%d sync=%d", step, saved, synced); + if (!saved || !synced) { + ESP_LOGE("lttest", "step not stored, staying idle instead of repeating the step"); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + switch (act) { + case 1: + ESP_LOGW("lttest", "soft reboot now"); + vTaskDelay(pdMS_TO_TICKS(50)); + lt_reboot(); + break; + case 2: + ESP_LOGW("lttest", "crashing now"); + vTaskDelay(pdMS_TO_TICKS(50)); + __builtin_trap(); // undefined instruction: a plain bad store does not fault on the ARM968 + break; + case 3: + ESP_LOGW("lttest", "watchdog reboot now"); + vTaskDelay(pdMS_TO_TICKS(50)); + lt_reboot_wdt(); + break; + default: + ESP_LOGW("lttest", "idle, waiting for an external reset"); + break; + } +} + +inline void ltprefs_alive(int step, int act) { + ESP_LOGI("lttest", "alive step=%d act=%d boot: %s", step, act, ltprefs_report); + for (int i = 7; i >= 0; i--) { + if (ltprefs_hist.line[i][0] != 0) { + ESP_LOGI("lttest", " history[-%d]: %s", i, ltprefs_hist.line[i]); + } + } +} diff --git a/.agents/skills/hardware-test/scripts/online_image/drive.py b/.agents/skills/hardware-test/scripts/online_image/drive.py new file mode 100644 index 00000000000..2fbf3c47b56 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/online_image/drive.py @@ -0,0 +1,179 @@ +"""Drive the online_image cases over the native API and judge each from the serial capture.""" + +import asyncio +import pathlib +import re +import sys +import time + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, +) + +HOST, KEY, CAPTURE, BASE, MODE = sys.argv[1:6] +CAP_S = 45 + +CASES = [ + # (label, action, path, expected outcome) + ("ok bmp", "fetch", "ok.bmp", "finished"), + ("ok png", "fetch", "ok.png", "finished"), + ("ok jpg", "fetch", "ok.jpg", "finished"), + ("ok qoi", "fetch", "ok.qoi", "finished"), + ("truncated bmp", "fetch", "truncated.bmp", "error"), + ("ok bmp after error", "fetch", "ok.bmp", "finished"), + ("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"), + ("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"), + ("empty body", "fetch", "empty.bmp", "error"), + ("stalled connection", "fetch", "stall.bmp", "error"), + ("closed early", "fetch", "closed_early.bmp", "error"), + ("slow but alive", "fetch", "slow.bmp", "finished"), + ("chunked bmp", "fetch", "chunked.bmp", "finished"), + ("chunked png", "fetch", "chunked.png", "finished"), + ("chunked qoi", "fetch", "chunked.qoi", "finished"), + ("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"), + ("chunked png cut", "fetch", "chunked_cut.png", "any"), + ("no content-length", "fetch", "noclen.bmp", "any"), + ("huge jpeg claim", "fetch", "huge.jpg", "error"), + ("ok jpg after huge", "fetch", "ok.jpg", "finished"), + ("ok bmp again", "fetch", "ok.bmp", "finished"), + ("ok bmp refetch: 304", "refetch", "", "finished"), + ("ok png", "fetch", "ok.png", "finished"), + ("ok png refetch: 304", "refetch", "", "finished"), + ("chunked bmp", "fetch", "chunked.bmp", "finished"), + ("chunked bmp refetch: 304", "refetch", "", "finished"), + ("chunked png", "fetch", "chunked.png", "finished"), + ("chunked png refetch: not cached", "refetch", "", "finished"), + ("chunked png cut", "fetch", "chunked_cut.png", "any"), + ("chunked png cut refetch: not cached", "refetch", "", "any"), +] +if MODE == "base": + # Each stuck case is followed by a fetch that shows the symptom, then a reboot. + CASES = [ + ("ok bmp", "fetch", "ok.bmp", "finished"), + ("truncated bmp", "fetch", "truncated.bmp", "error"), + ("ok bmp after truncated", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"), + ("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("empty body", "fetch", "empty.bmp", "error"), + ("ok bmp after empty", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("stalled connection", "fetch", "stall.bmp", "error"), + ("ok bmp after stall", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("closed early", "fetch", "closed_early.bmp", "error"), + ("ok bmp after closed early", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("chunked png", "fetch", "chunked.png", "finished"), + ("ok bmp after chunked png", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"), + ("ok bmp after chunked cut", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("huge jpeg claim", "fetch", "huge.jpg", "error"), + ("ok jpg after huge", "fetch", "ok.jpg", "finished"), + ] + +OUTCOME = re.compile(r"download (finished|error): (img_small|img)") +INTEREST = re.compile( + r"\[(E|W)\]\[online_image|Image already being updated|download (finished|error)|Downloading image|Updating image|Image fully downloaded|HTTP 304|allocat|Panic|abort\(\)|Guru|Backtrace|rst:0x", + re.IGNORECASE, +) + + +def tail(path: str, offset: int) -> tuple[list[str], int]: + with pathlib.Path(path).open("rb") as f: + f.seek(offset) + data = f.read() + return data.decode("utf-8", "replace").splitlines(), offset + len(data) + + +async def connect() -> tuple[APIClient, dict, int]: + for _ in range(60): + try: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, services = await c.list_entities_services() + except (InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError) as exc: + raise SystemExit(f"API key problem, not retrying: {exc}") from exc + except (APIConnectionError, OSError) as exc: + # only connection trouble is retried; a wrong key or a missing button must fail at once + print(f"connect: {exc}", flush=True) + await asyncio.sleep(3) + continue + btn = next( + (e.key for e in ents if getattr(e, "object_id", "") == "reboot"), None + ) + if btn is None: + raise SystemExit("the config has no reboot button") + return c, {s.name: s for s in services}, btn + raise SystemExit("API never connected") + + +async def main() -> None: + client, actions, btn = await connect() + with pathlib.Path(CAPTURE).open("rb") as f: + f.seek(0, 2) + offset = f.tell() + rows = [] + for label, action, path, expect in CASES: + if label == "REBOOT": + client.button_command(btn) + await asyncio.sleep(1) + await client.disconnect() + await asyncio.sleep(8) + client, actions, btn = await connect() + _, offset = tail(CAPTURE, offset) + print("--- rebooted", flush=True) + continue + img = "img_small" if action.endswith("_small") else "img" + t0 = time.monotonic() + args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"} + await client.execute_service(actions[action], args) + outcome, notes = "no outcome", [] + while time.monotonic() - t0 < CAP_S: + await asyncio.sleep(0.5) + lines, offset = tail(CAPTURE, offset) + done = False + for line in lines: + if INTEREST.search(line): + notes.append(line.strip()) + m = OUTCOME.search(line) + if m and m.group(2) == img: + outcome = m.group(1) + done = True + if done: + break + took = time.monotonic() - t0 + if outcome == "no outcome": + took = CAP_S + # drain a little more so the "already being updated" line lands + await asyncio.sleep(1) + lines, offset = tail(CAPTURE, offset) + notes += [line.strip() for line in lines if INTEREST.search(line)] + ok = outcome == expect or (expect == "any" and outcome != "no outcome") + rows.append((label, path, expect, outcome, took, ok, notes)) + print( + f"{'PASS' if ok else 'FAIL'} {label:32s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s", + flush=True, + ) + for n in notes: + print(f" {n}", flush=True) + await asyncio.sleep(1.5) + await client.disconnect() + print( + "\nSUMMARY", + "all expectations met" + if all(r[5] for r in rows) + else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[5]), + flush=True, + ) + if not all(r[5] for r in rows): + sys.exit(1) + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/online_image/make_images.py b/.agents/skills/hardware-test/scripts/online_image/make_images.py new file mode 100644 index 00000000000..a7e3451d291 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/online_image/make_images.py @@ -0,0 +1,38 @@ +"""Build the small test images the HTTP server serves.""" + +from pathlib import Path +import struct +import sys + +from PIL import Image + +out = Path(sys.argv[1]) +out.mkdir(exist_ok=True) +w = h = 16 +im = Image.new("RGB", (w, h)) +px = im.load() +for y in range(h): + for x in range(w): + px[x, y] = (x * 16, y * 16, (x + y) * 8) +im.save(out / "ok.bmp", "BMP") +im.save(out / "ok.png", "PNG") +im.save(out / "ok.jpg", "JPEG", quality=90) + +# QOI with only QOI_OP_RGB codes: valid and trivially encoded. +qoi = b"qoif" + struct.pack(">IIBB", w, h, 3, 0) +for y in range(h): + for x in range(w): + qoi += bytes([0xFE, *px[x, y]]) +qoi += bytes(7) + b"\x01" +(out / "ok.qoi").write_bytes(qoi) + +# 8x8 8-bit palette BMP: 1078 byte header, pixels start beyond a 256 byte buffer. +pw = ph = 8 +data_offset = 14 + 40 + 256 * 4 +file_size = data_offset + pw * ph +hdr = b"BM" + struct.pack(" None: + LOG.write(f"{time.strftime('%H:%M:%S')} {msg}\n") + + +def headers(ctype: str, extra: list[bytes]) -> bytes: + lines = [ + b"HTTP/1.1 200 OK", + b"Content-Type: " + ctype.encode(), + b"Connection: close", + *extra, + b"", + b"", + ] + return b"\r\n".join(lines) + + +def etag(path: str) -> bytes: + return b'"' + path.strip("/").encode() + b'-v1"' + + +def chunked(body: bytes, size: int) -> bytes: + out = b"" + for i in range(0, len(body), size): + part = body[i : i + size] + out += b"%x\r\n" % len(part) + part + b"\r\n" + return out + b"0\r\n\r\n" + + +async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str: + name, _, ext = path.lstrip("/").rpartition(".") + ctype = MIME.get(ext, "application/octet-stream") + body = IMG.get(ext, BMP) + tag = [b"ETag: " + etag(path)] + if b"if-none-match: " + etag(path).lower() in req.lower(): + w.write( + b"HTTP/1.1 304 Not Modified\r\nETag: " + + etag(path) + + b"\r\nConnection: close\r\n\r\n" + ) + return "304 not modified" + if name == "ok": + w.write(headers(ctype, [b"Content-Length: %d" % len(body), *tag]) + body) + return f"full {len(body)} bytes" + if name == "truncated": # cut one byte into the sixth pixel, Content-Length matches + cut = BMP[: BMP_HDR + 5 * 3 + 1] + w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut) + return f"truncated {len(cut)} bytes" + if name == "large_header": + w.write( + headers(ctype, [b"Content-Length: %d" % len(LARGE_HEADER)]) + LARGE_HEADER + ) + return f"large header {len(LARGE_HEADER)} bytes" + if name == "empty": + w.write(headers(ctype, [b"Content-Length: 0"])) + return "empty body" + if ( + name == "stall" + ): # claims the full length, sends part, then holds the socket open + part = body[: BMP_HDR + 15] + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part) + await w.drain() + await asyncio.sleep(40) + return f"stalled after {len(part)} bytes, closed after 40 s" + if name == "closed_early": # claims the full length, sends part, closes at once + part = body[: BMP_HDR + 15] + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part) + return f"closed after {len(part)} of {len(body)} bytes" + if name == "slow": # pauses under the timeout, then completes + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + body[:BMP_HDR]) + await w.drain() + await asyncio.sleep(2.5) + w.write(body[BMP_HDR:]) + return f"full {len(body)} bytes with a 2.5 s pause" + if name == "chunked": + w.write( + headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(body, 64) + ) + return f"chunked full {len(body)} bytes" + if ( + name == "chunked_cut" + ): # chunked, ends cleanly at a pixel boundary (or half a png) + cut = body[: BMP_HDR + 5 * 3] if ext == "bmp" else body[: len(body) // 2] + w.write( + headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(cut, 64) + ) + return f"chunked cut {len(cut)} of {len(body)} bytes" + if name == "noclen": # body delimited by connection close + w.write(headers(ctype, []) + body) + return f"no content-length, full {len(body)} bytes" + if name == "huge": # claims 20 MB so the JPEG buffer resize fails + w.write(headers(ctype, [b"Content-Length: 20000000"]) + body) + return f"claims 20000000, sent {len(body)} bytes" + w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + return "404" + + +async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None: + peer = w.get_extra_info("peername") + try: + async with asyncio.timeout(5): + line = await r.readuntil(b"\r\n") + req = await r.readuntil(b"\r\n\r\n") + path = line.split(b" ")[1].decode() + log( + f"GET {path} from {peer}" + + (" (If-None-Match)" if b"if-none-match" in req.lower() else "") + ) + what = await respond(path, req, w) + await w.drain() + log(f" -> {path}: {what}") + except Exception as exc: # noqa: BLE001 + log(f" !! {peer}: {exc!r}") + finally: + w.close() + + +async def main() -> None: + server = await asyncio.start_server(handle, "0.0.0.0", PORT) + log(f"listening on {PORT}") + async with server: + await server.serve_forever() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py b/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py new file mode 100644 index 00000000000..53c9996b3ba --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py @@ -0,0 +1,33 @@ +"""Request one camera image over the native API and report its size (direct esp32_camera check).""" + +import asyncio +import sys + +from aioesphomeapi import APIClient + + +async def main(): + c = APIClient(sys.argv[1], 6053, None) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + cams = [e for e in ents if type(e).__name__ == "CameraInfo"] + print("camera entities:", [e.name for e in cams]) + got = asyncio.get_event_loop().create_future() + + def on_state(state): + if type(state).__name__ == "CameraState" and not got.done(): + got.set_result(len(state.data)) + + c.subscribe_states(on_state) + c.request_single_image() + try: + n = await asyncio.wait_for(got, 10) + print("single image bytes:", n) + except TimeoutError: + print("no image within 10 s") + await c.disconnect() + sys.exit(1) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh b/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh new file mode 100755 index 00000000000..b8b4d30326f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh @@ -0,0 +1,32 @@ +#!/bin/zsh +# usage: measure.sh IP LABEL LOGFILE [OUTDIR] +# Runs the fixed RTSP measurement set against one build and appends a result line to OUTDIR/results.txt: +# ping RTT, two 12 s ffmpeg runs, two concurrent ffmpeg clients, and the raw receive rate with the default +# and a 1 MiB socket buffer. LOGFILE is the device capture, used to count "stalled" drops during the run. +# A measurement that produced nothing is written as ERR and the script exits 1, so a broken run never looks +# like a measured one. Needs ffmpeg and a python with the rtsp scripts' dependencies (PYTHON, default python3). +D=${0:A:h} +IP=$1; LABEL=$2; LOG=$3; S=${4:-.} +PY=${PYTHON:-python3} +[ -f "$LOG" ] || { echo "device capture not found: $LOG"; exit 1; } +rtt=$(ping -c 10 -i 0.2 $IP 2>&1 | tail -1 | awk -F/ '{print $5}') +stall0=$(grep -a -c "stalled" $LOG) +frames() { # OUTFILE RC: the frame count of a run that finished on its own, nothing for a failed or killed one + [ "$2" = 0 ] && grep -o 'frame= *[0-9]*' $1 | tail -1 | tr -dc 0-9 +} +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f1.txt 2>&1; f1=$(frames $S/m-f1.txt $?); sleep 2 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f2.txt 2>&1; f2=$(frames $S/m-f2.txt $?); sleep 2 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoA.txt 2>&1 & A=$!; sleep 1 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoB.txt 2>&1 & B=$! +wait $A; ra=$?; wait $B; rb=$? +fa=$(frames $S/m-twoA.txt $ra); fb=$(frames $S/m-twoB.txt $rb); sleep 2 +timeout 40 $PY $D/rawrate2.py $IP quiet > $S/m-raw.txt 2>&1 && raw=$(grep -o "[0-9.]* KiB/s" $S/m-raw.txt | head -1); sleep 2 +timeout 40 $PY $D/rawrate2.py $IP bigbuf > $S/m-raw2.txt 2>&1 && raw2=$(grep -o "[0-9.]* KiB/s" $S/m-raw2.txt | head -1); sleep 1 +stall1=$(grep -a -c "stalled" $LOG) +bad=0 +for v in rtt f1 f2 fa fb raw raw2; do + if [ -z "${(P)v}" ]; then eval "$v=ERR"; bad=1; fi +done +line="$LABEL | rtt avg ${rtt} ms | ffmpeg single 12 s: ${f1}, ${f2} frames | two clients: ${fa} + ${fb} | python raw: ${raw} (1 MiB rcvbuf ${raw2}) | stalls logged: $((stall1 - stall0))" +echo "$line" | tee -a $S/results.txt +if [ $bad = 1 ]; then echo "one or more measurements failed (ERR above)"; exit 1; fi diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py b/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py new file mode 100644 index 00000000000..f65329c4025 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py @@ -0,0 +1,83 @@ +import contextlib +import socket +import sys +import time + +IP = sys.argv[1] +mode = sys.argv[2] +secs = 8 +s = socket.create_connection((IP, 554), timeout=5) +if mode == "bigbuf": + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20) +cseq = 0 + + +buf = b"" + + +def read_response() -> bytes: + """One complete RTSP response: headers through the blank line plus a Content-Length body.""" + global buf # noqa: PLW0603 + while (end := buf.find(b"\r\n\r\n")) < 0: + chunk = s.recv(4096) + if not chunk: + raise ConnectionError("closed") + buf += chunk + head = buf[:end].decode(errors="replace") + body_len = 0 + for line in head.split("\r\n"): + if line.lower().startswith("content-length:"): + body_len = int(line.split(":", 1)[1]) + while len(buf) < end + 4 + body_len: + chunk = s.recv(4096) + if not chunk: + raise ConnectionError("closed") + buf += chunk + resp, buf = buf[: end + 4 + body_len], buf[end + 4 + body_len :] + return resp + + +def req(m, extra="", wait=True): + global cseq # noqa: PLW0603 + cseq += 1 + url = f"rtsp://{IP}:554/trackID=0" if m == "SETUP" else f"rtsp://{IP}:554/" + s.sendall(f"{m} {url} RTSP/1.0\r\nCSeq: {cseq}\r\n{extra}\r\n".encode()) + return read_response() if wait else b"" + + +req("OPTIONS") +req("DESCRIBE", "Accept: application/sdp\r\n") +r = req("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n") +sess = ( + [x for x in r.decode(errors="replace").split("\r\n") if x.startswith("Session")][0] + .split()[1] + .split(";")[0] +) +req("PLAY", f"Session: {sess}\r\n") +t0 = time.monotonic() +total = 0 +chunks = 0 +last_chat = t0 +s.settimeout(2) +while time.monotonic() - t0 < secs: + now = time.monotonic() + if mode == "chatty" and now - last_chat > 0.2: + req("GET_PARAMETER", f"Session: {sess}\r\n", wait=False) + last_chat = now + if mode == "rtcp" and now - last_chat > 0.2: + s.sendall(b"$\x01\x00\x08" + b"\x80\xc9\x00\x01\x00\x00\x00\x01") + last_chat = now + try: + d = s.recv(65536) + except TimeoutError: + continue + if not d: + break + total += len(d) + chunks += 1 +print( + f"{mode:7s}: {total / 1024 / secs:5.1f} KiB/s, {chunks} recvs, avg {total // max(chunks, 1)} B/recv" +) +with contextlib.suppress(OSError): + req("TEARDOWN", f"Session: {sess}\r\n") +s.close() diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py b/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py new file mode 100644 index 00000000000..6cec93b3a4e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py @@ -0,0 +1,345 @@ +"""Raw RTSP/TCP client for the esphome rtsp_server hardware test. + +usage: rtsp_client.py HOST [case ...] +cases: play, udp, pipeline, third, stall, teardown, getparam, badreq, replay, timeout +""" + +import socket +import struct +import sys +import time + +HOST = sys.argv[1] +PORT = 554 +cases = sys.argv[2:] or [ + "play", + "udp", + "pipeline", + "badreq", + "replay", + "getparam", + "teardown", + "third", + "stall", +] +results = [] + + +def say(case, ok, detail): + results.append((case, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True) + + +class Client: + def __init__(self, timeout=5): + self.s = socket.create_connection((HOST, PORT), timeout=timeout) + self.cseq = 0 + self.session = None + self.buf = b"" + + def send(self, method, extra="", url=None): + self.cseq += 1 + url = ( + url or f"rtsp://{HOST}:{PORT}/trackID=0" + if method == "SETUP" + else (url or f"rtsp://{HOST}:{PORT}/") + ) + req = f"{method} {url} RTSP/1.0\r\nCSeq: {self.cseq}\r\n" + if self.session: + req += f"Session: {self.session}\r\n" + req += extra + "\r\n" + self.s.sendall(req.encode()) + return self.cseq + + def _recv(self): + chunk = self.s.recv(4096) + if not chunk: + raise ConnectionError("closed") + self.buf += chunk + + def read_response(self): + # Read until a full RTSP response (headers + optional body); skip any interleaved data in between + while True: + if self.buf.startswith(b"$"): + if len(self.buf) < 4: + self._recv() + continue + ln = struct.unpack(">H", self.buf[2:4])[0] + if len(self.buf) < 4 + ln: + self._recv() + continue + self.buf = self.buf[4 + ln :] + continue + end = self.buf.find(b"\r\n\r\n") + if end < 0: + self._recv() + continue + head = self.buf[:end].decode(errors="replace") + body_len = 0 + for line in head.split("\r\n"): + if line.lower().startswith("content-length:"): + body_len = int(line.split(":", 1)[1]) + while len(self.buf) < end + 4 + body_len: + self._recv() + body = self.buf[end + 4 : end + 4 + body_len] + self.buf = self.buf[end + 4 + body_len :] + status = int(head.split(" ", 2)[1]) + for line in head.split("\r\n"): + if line.lower().startswith("session:"): + self.session = line.split(":", 1)[1].strip().split(";")[0] + return status, head, body + + def read_frames(self, seconds): + """Collect interleaved RTP packets on channel 0 for `seconds`; return (frames, packets, seq_gaps, sizes).""" + deadline = time.monotonic() + seconds + frames = 0 + packets = 0 + gaps = 0 + last_seq = None + cur = 0 + sizes = [] + self.s.settimeout(1.0) + while time.monotonic() < deadline: + try: + chunk = self.s.recv(65536) + except TimeoutError: + continue + if not chunk: + break + self.buf += chunk + while True: + if len(self.buf) < 4 or self.buf[0:1] != b"$": + if self.buf and self.buf[0:1] != b"$": + end = self.buf.find(b"\r\n\r\n") + if end < 0: + break + self.buf = self.buf[end + 4 :] + continue + break + ch = self.buf[1] + ln = struct.unpack(">H", self.buf[2:4])[0] + if len(self.buf) < 4 + ln: + break + pkt = self.buf[4 : 4 + ln] + self.buf = self.buf[4 + ln :] + if ch != 0 or len(pkt) < 12: + continue + packets += 1 + seq = struct.unpack(">H", pkt[2:4])[0] + marker = pkt[1] & 0x80 + if last_seq is not None and seq != ((last_seq + 1) & 0xFFFF): + gaps += 1 + last_seq = seq + cur += len(pkt) - 12 + if marker: + frames += 1 + sizes.append(cur) + cur = 0 + return frames, packets, gaps, sizes + + def close(self): + self.s.close() + + +def setup_play(c): + c.send("OPTIONS") + st, head, _ = c.read_response() + assert st == 200, head + c.send("DESCRIBE", "Accept: application/sdp\r\n") + st, head, body = c.read_response() + assert st == 200, head + c.send("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n") + st, head, _ = c.read_response() + assert st == 200, head + c.send("PLAY") + st, head, _ = c.read_response() + assert st == 200, head + return body + + +for case in cases: + try: + if case == "play": + c = Client() + sdp = setup_play(c) + frames, packets, gaps, sizes = c.read_frames(10) + c.send("TEARDOWN") + c.read_response() + c.close() + ok = frames >= 50 and gaps == 0 + say( + case, + ok, + f"{frames} frames in 10 s ({frames / 10:.1f} fps), {packets} RTP packets, {gaps} seq gaps, frame size min {min(sizes) if sizes else 0} max {max(sizes) if sizes else 0}; sdp has m=video {'m=video' in sdp.decode()}", + ) + elif case == "udp": + c = Client() + c.send("OPTIONS") + c.read_response() + c.send("SETUP", "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n") + st, head, _ = c.read_response() + c.close() + say(case, st == 461, f"UDP SETUP answered {st}") + elif case == "pipeline": + c = Client() + c.s.sendall( + f"OPTIONS rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 1\r\n\r\nDESCRIBE rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n".encode() + ) + c.cseq = 2 + st1, h1, _ = c.read_response() + st2, h2, b2 = c.read_response() + c.close() + cs = [x for x in (h1 + h2).split("\r\n") if x.startswith("CSeq")] + say( + case, + st1 == 200 and st2 == 200 and cs == ["CSeq: 1", "CSeq: 2"], + f"two pipelined requests answered {st1}, {st2} with {cs}", + ) + elif case == "badreq": + c = Client() + c.s.sendall(b"BOGUS rtsp://x/ RTSP/1.0\r\nCSeq: 7\r\n\r\n") + st, head, _ = c.read_response() + c.close() + say( + case, + st in (400, 405, 501) and "CSeq: 7" in head, + f"unknown method answered {st}, CSeq echoed {'CSeq: 7' in head}", + ) + elif case == "replay": + c = Client() + setup_play(c) + f1, _, _, _ = c.read_frames(3) + c.send("PLAY") + st, head, _ = c.read_response() + f2, _, gaps, _ = c.read_frames(3) + c.send("TEARDOWN") + c.read_response() + c.close() + say( + case, + st == 200 and f2 >= 10 and gaps == 0, + f"second PLAY answered {st}, {f1} then {f2} frames, {gaps} seq gaps after repeat PLAY", + ) + elif case == "getparam": + c = Client() + setup_play(c) + c.read_frames(1) + c.send("GET_PARAMETER") + st, head, _ = c.read_response() + f, _, _, _ = c.read_frames(2) + c.send("TEARDOWN") + c.read_response() + c.close() + say( + case, + st == 200 and f > 5, + f"GET_PARAMETER while playing answered {st}, stream continued with {f} frames in 2 s", + ) + elif case == "teardown": + c = Client() + setup_play(c) + c.read_frames(2) + c.send("TEARDOWN") + st, head, _ = c.read_response() + c.s.settimeout(3) + time.sleep(1.5) + try: + rest = c.s.recv(65536) + closed = rest == b"" + except TimeoutError: + rest = b"x" + closed = False + except ConnectionError: + closed = True + c.close() + say( + case, + st == 200, + f"TEARDOWN answered {st}; connection closed by server afterwards: {closed}", + ) + elif case == "third": + a = Client() + setup_play(a) + b = Client() + setup_play(b) + fa, _, ga, _ = a.read_frames(4) + fb, _, gb, _ = b.read_frames(4) + try: + d = Client(timeout=3) + d.send("OPTIONS") + st, _, _ = d.read_response() + third = f"third got {st}" + d.close() + except ConnectionError as e: + # refused, reset or closed by the server; a timeout or a bug propagates and fails the case + third = f"third rejected ({type(e).__name__})" + a.send("TEARDOWN") + a.read_response() + a.close() + b.send("TEARDOWN") + b.read_response() + b.close() + say( + case, + fa > 10 and fb > 10 and ga == 0 and gb == 0 and "rejected" in third, + f"two clients: {fa} and {fb} frames in 4 s each, gaps {ga}/{gb}; {third}", + ) + elif case == "stall": + a = Client() + setup_play(a) + b = Client() + setup_play(b) + # stop reading on a for 4 s while b keeps playing + a.s.settimeout(None) + fb1, _, gb1, _ = b.read_frames(4) + a.s.settimeout(1.0) + fa, _, ga, _ = a.read_frames(4) + fb2, _, gb2, _ = b.read_frames(3) + a.send("TEARDOWN") + a.read_response() + a.close() + b.send("TEARDOWN") + b.read_response() + b.close() + say( + case, + fb1 > 10 and fb2 > 10 and fa > 5, + f"b kept {fb1} then {fb2} frames while a stalled 4 s; a recovered {fa} frames after resuming (gaps {ga}); b gaps {gb1}/{gb2}; device log must show stalled/dropping frame", + ) + elif case == "timeout": + c = Client(timeout=70) + setup_play(c) + c.s.settimeout( + 2 + ) # a server that stops sending but never closes must not hang the run + t0 = time.monotonic() + dead = False + try: + while time.monotonic() - t0 < 68: + try: + d = c.s.recv(65536) + except TimeoutError: + continue + if not d: + dead = True + break + except ConnectionError: + dead = True + elapsed = time.monotonic() - t0 + say( + case, + dead and 50 <= elapsed <= 68, + f"connection closed by server after idle: {dead} at {elapsed:.0f} s (expect the 60 s session timeout when the client sends nothing)", + ) + except Exception as e: # noqa: BLE001 + say(case, False, f"exception {type(e).__name__}: {e}") + time.sleep(1) + +print( + "SUMMARY", + sum(1 for r in results if r[1]), + "pass", + sum(1 for r in results if not r[1]), + "fail", +) +sys.exit(1 if any(not r[1] for r in results) else 0) diff --git a/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp b/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp new file mode 100644 index 00000000000..3fa93631a0f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include "esphome/core/log.h" + +// Direct look at the Zephyr cdc_acm_uart1 driver state, bypassing the usb_cdc_acm component +inline void cdc_probe() { + const device *d = DEVICE_DT_GET(DT_NODELABEL(cdc_acm_uart1)); + uint8_t tmp[32]; + int rr = uart_irq_rx_ready(d); + int n = uart_fifo_read(d, tmp, sizeof(tmp)); + ESP_LOGI("probe", "driver rx_ready=%d fifo_read=%d first=0x%02X", rr, n, n > 0 ? tmp[0] : 0); +} diff --git a/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py b/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py new file mode 100644 index 00000000000..dbd8e4f9753 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py @@ -0,0 +1,203 @@ +"""Drive the usb_cdc_acm echo firmware on the nRF52840 dongle from the Mac. + +usage: drive.py DATA_PORT [case ...] +Cases: short, bulk1k, bulk16k, bulk64k, burst, flush, peek, linecoding, dtr, stream, counters +""" + +import random +import sys +import time + +import serial + +port = sys.argv[1] +cases = sys.argv[2:] or [ + "short", + "bulk1k", + "bulk16k", + "bulk64k", + "burst", + "flush", + "peek", + "linecoding", + "dtr", + "stream", + "counters", +] +results = [] + + +def say(case, ok, detail): + results.append((case, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True) + + +def info(case, detail): + """A case whose result lives in the device log; printed, not counted.""" + print(f"INFO {case}: {detail}", flush=True) + + +def open_port(baud=115200): + s = serial.Serial(port, baudrate=baud, timeout=1) + time.sleep(0.3) # let the 100 ms line-state poll see DTR and the echo loop settle + return s + + +def read_exact(s, n, timeout): + buf = bytearray() + deadline = time.monotonic() + timeout + while len(buf) < n and time.monotonic() < deadline: + chunk = s.read(min(4096, n - len(buf))) + if chunk: + buf += chunk + return bytes(buf) + + +def payload(n): + # No 0x01 anywhere: the device treats a chunk starting with 0x01 as a command + rnd = random.Random(n) + return bytes(rnd.choice(range(2, 256)) for _ in range(n)) + + +def cmd(s, c, extra=b""): + s.write(b"\x01" + c + extra) + s.flush() + + +def echo_case(name, n, chunk): + s = open_port() + s.reset_input_buffer() + data = payload(n) + t0 = time.monotonic() + for i in range(0, n, chunk): + s.write(data[i : i + chunk]) + got = read_exact(s, n, timeout=max(5, n / 2000)) + dt = time.monotonic() - t0 + s.close() + ok = got == data + first_bad = next((i for i in range(min(len(got), n)) if got[i] != data[i]), None) + say( + name, + ok, + f"{len(got)}/{n} bytes echoed in {dt:.2f}s ({n / dt / 1024:.1f} KiB/s), first mismatch {first_bad}", + ) + + +for case in cases: + if case == "short": + s = open_port() + s.reset_input_buffer() + ok = True + lat = [] + for i in range(20): + msg = f"hello {i}\n".encode() + t0 = time.monotonic() + s.write(msg) + got = read_exact(s, len(msg), 2) + lat.append(time.monotonic() - t0) + ok = ok and got == msg + s.close() + say( + case, + ok, + f"20 lines, latency min {min(lat) * 1000:.0f} ms max {max(lat) * 1000:.0f} ms", + ) + elif case == "bulk1k": + echo_case(case, 1024, 1024) + elif case == "bulk16k": + echo_case(case, 16 * 1024, 4096) + elif case == "bulk64k": + echo_case(case, 64 * 1024, 16384) + elif case == "burst": + s = open_port() + s.reset_input_buffer() + cmd(s, b"B") + got = read_exact(s, 1000, 3) + s.close() + expect = bytes((ord("A") + (i % 26)) for i in range(1000)) + prefix_ok = got == expect[: len(got)] + say( + case, + prefix_ok and 0 < len(got) < 1000, + f"{len(got)} of 1000 bytes arrived (256 byte TX ring, rest must be dropped and warned), prefix intact {prefix_ok}", + ) + elif case == "flush": + s = open_port() + s.reset_input_buffer() + cmd(s, b"F") + got = read_exact(s, 200, 3) + s.close() + expect = bytes((ord("a") + (i % 26)) for i in range(200)) + say(case, got == expect, f"{len(got)}/200 bytes, flush result in device log") + time.sleep(0.5) + elif case == "peek": + s = open_port() + s.reset_input_buffer() + cmd(s, b"K", b"Z") + time.sleep(0.5) + s.close() + info( + case, + "peek/read result is in the device log: expect `peek ok=1 0x5A read ok=1 0x5A`", + ) + elif case == "linecoding": + for baud in (9600, 115200, 921600): + s = open_port(baud) + s.reset_input_buffer() + cmd(s, b"L") + time.sleep(0.4) + s.close() + time.sleep(0.3) + info(case, "opened at 9600, 115200, 921600; the device log must show each baud") + elif case == "dtr": + for _i in range(5): + s = open_port() + time.sleep(0.3) + s.close() + time.sleep(0.3) + info( + case, "5 open/close cycles; the device log must show 5 Opened and 5 Closed" + ) + elif case == "stream": + # device streams 100 x 100 byte lines over 10 s; close the port for 3 s in the middle + s = open_port() + s.reset_input_buffer() + cmd(s, b"S") + part1 = bytearray() + t0 = time.monotonic() + while time.monotonic() - t0 < 2.5: + part1 += s.read(4096) + s.close() + time.sleep(3.0) + s = open_port() + part2 = bytearray() + t0 = time.monotonic() + while time.monotonic() - t0 < 6.0: + part2 += s.read(4096) + s.close() + lines1 = [x for x in bytes(part1).split(b"\n") if x.startswith(b"STREAM")] + lines2 = [x for x in bytes(part2).split(b"\n") if x.startswith(b"STREAM")] + n1 = [int(x[7:10]) for x in lines1] + n2 = [int(x[7:10]) for x in lines2] + resumed = bool(n2) and max(n2) >= 90 + say( + case, + resumed, + f"before close: lines {min(n1) if n1 else None}-{max(n1) if n1 else None} ({len(n1)}), after reopen: {min(n2) if n2 else None}-{max(n2) if n2 else None} ({len(n2)}); streaming resumed after reopen {resumed}", + ) + elif case == "counters": + s = open_port() + cmd(s, b"C") + time.sleep(0.3) + s.close() + info(case, "counters are in the device log") + time.sleep(0.5) + +print( + "SUMMARY", + sum(1 for r in results if r[1]), + "pass", + sum(1 for r in results if not r[1]), + "fail", +) +sys.exit(1 if any(not r[1] for r in results) else 0) diff --git a/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh b/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh new file mode 100755 index 00000000000..11476fcddf9 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +# usage: decode.sh CAPTURE ELF OUTPREFIX +# Parses the #CD: lines of a serial capture with Zephyr's coredump tools from the sdk-nrf that ESPHome +# installed (SDK_NRF, default: the sdk-nrf directory under the ESPHome cache), serves the dump to GDB on a +# random port and prints the backtrace and the thread list. The ELF is .pioenvs//zephyr/zephyr/zephyr.elf. +# Every step is checked: a missing tool, a capture without a complete dump, an empty .bin or a gdbserver +# that died all stop the script with exit 1, and the exit status is GDB's otherwise. +CAP=$1; ELF=$2; OUT=$3 +SDK_NRF=${SDK_NRF:-$(ls -d ~/Library/Caches/esphome/sdk-nrf ~/.cache/esphome/sdk-nrf 2>/dev/null | head -1)} +[ -n "$SDK_NRF" ] || { echo "sdk-nrf not found, set SDK_NRF"; exit 1; } +F=$(ls -d $SDK_NRF/frameworks/v*/zephyr/scripts/coredump 2>/dev/null | tail -1) +PY=${PYTHON:-$(ls $SDK_NRF/penvs/*/bin/python 2>/dev/null | head -1)} +# SDK 0.x keeps the tools under arm-zephyr-eabi/, SDK 1.0+ under gnu/arm-zephyr-eabi/ +GDB=$(ls $SDK_NRF/toolchains/*/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb $SDK_NRF/toolchains/*/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb 2>/dev/null | tail -1) +for v in F PY GDB; do + [ -n "${(P)v}" ] || { echo "$v not found under $SDK_NRF"; exit 1; } +done +[ -f "$ELF" ] || { echo "ELF not found: $ELF"; exit 1; } +# strip capture timestamps and ANSI so the parser sees the raw log lines +sed -E 's/^\[[0-9:.]+\]//; s/\x1b\[[0-9;]*m//g' "$CAP" > $OUT-all.log +# keep only the first complete #CD:BEGIN# .. #CD:END# block +awk '/#CD:BEGIN#/{if(!done){p=1;buf=""}} p{buf=buf $0 "\n"} /#CD:END#/{if(p){printf "%s", buf; done=1; p=0}}' $OUT-all.log > $OUT-raw.log +echo "CD lines in capture: $(grep -c '#CD:' $OUT-all.log) (BEGIN $(grep -c '#CD:BEGIN#' $OUT-all.log), END $(grep -c '#CD:END#' $OUT-all.log)); first complete block: $(grep -c '#CD:' $OUT-raw.log) lines" +[ -s $OUT-raw.log ] || { echo "no complete #CD:BEGIN# .. #CD:END# block in the capture"; exit 1; } +if ! $PY $F/coredump_serial_log_parser.py $OUT-raw.log $OUT.bin > $OUT-parser.log 2>&1; then + echo "parser failed:"; tail -5 $OUT-parser.log; exit 1 +fi +tail -3 $OUT-parser.log +[ -s $OUT.bin ] || { echo "parser wrote an empty $OUT.bin"; exit 1; } +ls -la $OUT.bin | awk '{print "bin bytes", $5}' +PORT=$((20000 + RANDOM % 10000)) +$PY $F/coredump_gdbserver.py --port $PORT "$ELF" $OUT.bin > $OUT-gdbserver.log 2>&1 & +G=$! +sleep 3 +if ! kill -0 $G 2>/dev/null; then echo "gdbserver exited:"; tail -5 $OUT-gdbserver.log; exit 1; fi +$GDB -q -batch -ex "target remote :$PORT" -ex "bt" -ex "info threads" "$ELF" 2>&1 | grep -v "^warning\|Reading symbols\|^$" | head -40 +rc=$pipestatus[1] +kill $G 2>/dev/null +exit $rc diff --git a/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh b/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh new file mode 100755 index 00000000000..262951ba5ff --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh @@ -0,0 +1,29 @@ +#!/bin/zsh +# usage: flow.sh LABEL PORT ELF [OUTDIR] +# Run at least 60 s after the board rebooted from its crash, with the port closed. Proves the stored +# coredump waits for a reader and survives a reader that goes away: +# 1) open the port for 3 s and close it (a reader that leaves mid-print) +# 2) open it for 100 s: the whole dump must print again, then be erased +# 3) open it again for 20 s: dump_config must report no stored dump +# then decode.sh parses the first complete dump and prints the GDB backtrace. +# Needs a python with pyserial (PYTHON, default python3). +D=${0:A:h} +L=$1; P=$2; ELF=$3; S=${4:-.} +PY=${PYTHON:-python3} +CAP=$D/../serial_capture.py +capture() { # SECONDS OUTFILE: stop the flow when the capture fails (busy or missing port) + if ! $PY $CAP $P $1 $2 > $2.err 2>&1; then + echo "capture of $2 failed:"; cat $2.err; exit 1 + fi +} +capture 3 $S/$L-brief.txt +echo "$L brief 3 s open: $(wc -l < $S/$L-brief.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-brief.txt), END $(grep -a -c '#CD:END#' $S/$L-brief.txt)" +sleep 5 +capture 100 $S/$L-full.txt +echo "$L full 100 s open: $(wc -l < $S/$L-full.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-full.txt), BEGIN $(grep -a -c '#CD:BEGIN#' $S/$L-full.txt), END $(grep -a -c '#CD:END#' $S/$L-full.txt)" +grep -a "crashtest\|zephyr_coredump\|Reset reason\|Has stored\| Size:\| Error:\|took a long\|compiled on" $S/$L-full.txt | grep -a -v "#CD:[0-9A-F]" | head -16 +sleep 3 +capture 20 $S/$L-after.txt +echo "$L reopen 20 s: CD lines $(grep -a -c '#CD:' $S/$L-after.txt); $(grep -a 'Has stored\| Size:' $S/$L-after.txt | sed -E 's/^\[[^]]*\]//' | tr '\n' ' ')" +echo "== decode" +$D/decode.sh $S/$L-full.txt $ELF $S/$L diff --git a/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py b/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py new file mode 100644 index 00000000000..e6d73ee60cb --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py @@ -0,0 +1,24 @@ +import asyncio +import sys + +from aioesphomeapi import APIClient + + +async def main(): + c = APIClient(sys.argv[1], 6053, None) + await c.connect(login=True) + di = await c.device_info() + print( + "version", + di.esphome_version, + "compiled", + di.compilation_time, + "home_id", + hex(di.zwave_home_id), + "flags", + di.zwave_proxy_feature_flags, + ) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py b/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py new file mode 100644 index 00000000000..6b12146dd1e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py @@ -0,0 +1,45 @@ +"""Recompute the Z-Wave checksum (XOR of LENGTH..payload, initial 0xFF) over every frame the proxy logged.""" + +from functools import reduce +import pathlib +import re +import sys + + +def zw_checksum(frame: bytes) -> int: + return reduce(lambda a, b: a ^ b, frame[1:-1], 0xFF) + + +def complete(frame: bytes) -> bool: + """A data frame is SOF, LENGTH, then LENGTH bytes; the proxy logs at most 168 bytes, so longer ones are cut.""" + return len(frame) >= 3 and frame[0] == 0x01 and len(frame) == frame[1] + 2 + + +for path in sys.argv[1:]: + text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace") + sent = re.findall(r"Sending: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text) + recv = re.findall(r"Received frame: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text) + calc = re.findall( + r"CHECKSUM Received: 0x([0-9A-F]{2}) - Calculated: 0x([0-9A-F]{2})", text + ) + bad = len(re.findall(r"Bad checksum", text)) + acks_sent = len(re.findall(r"Sending ACK", text)) + naks_sent = len(re.findall(r"Sending NAK/CAN", text)) + acks_recv = len(re.findall(r"Received ACK", text)) + naks_recv = len(re.findall(r"Received NAK", text)) + reboots = len(re.findall(r"Boot count|Reset reason|rst:0x", text)) + long_ops = len(re.findall(r"zwave_proxy took a long time", text)) + rx_frames = [bytes.fromhex(f.replace(":", "")) for f in recv] + tx_frames = [bytes.fromhex(f.replace(":", "")) for f in sent] + rx_cut = sum(1 for b in rx_frames if not complete(b)) + tx_cut = sum(1 for b in tx_frames if len(b) > 1 and not complete(b)) + rx_ok = sum(1 for b in rx_frames if complete(b) and zw_checksum(b) == b[-1]) + tx_ok = sum(1 for b in tx_frames if complete(b) and zw_checksum(b) == b[-1]) + calc_ok = sum(1 for r, c in calc if r == c) + cmds = sorted({f.split(":")[3] for f in recv}) + print( + f"{path.rsplit('/', 1)[-1]}: tx frames {len(sent)} (checksum ok {tx_ok}, truncated in log {tx_cut}), " + f"rx frames {len(recv)} (recomputed ok {rx_ok}, truncated in log {rx_cut}), " + f"device calc==received {calc_ok}/{len(calc)}, bad checksum warnings {bad}, ACK sent {acks_sent}, NAK sent {naks_sent}, " + f"ACK received {acks_recv}, NAK received {naks_recv}, long ops {long_ops}, rx cmd ids {cmds}" + ) diff --git a/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml b/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml new file mode 100644 index 00000000000..b43b850c99a --- /dev/null +++ b/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml @@ -0,0 +1,44 @@ +substitutions: + run: "1" + +esphome: + name: bk72xx-crash-probe + includes: [../scripts/libretiny_crash/bkprobe.hpp] + # step 0 waits 30 s so a capture can be opened (and a reset pulsed) after the flash; later steps 5 s + on_boot: + priority: -100 + then: + - lambda: id(step) = bkprobe_boot(${run}); + - delay: !lambda 'return id(step) == 0 ? 30000 : 5000;' + - lambda: bkprobe_act(${run}, id(step)); + +bk72xx: + board: generic-bk7238-tuya + +logger: + hardware_uart: UART1 + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: step + type: int + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: bkprobe_alive(id(step)); + +# Wi-Fi matters: a crashed BK72xx with Wi-Fi up never reboots on its own without the crash handler fix +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + reboot_timeout: 0s + +ota: + - platform: esphome diff --git a/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml b/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml new file mode 100644 index 00000000000..0e8bacdc48b --- /dev/null +++ b/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml @@ -0,0 +1,38 @@ +substitutions: + run: "1" + +esphome: + name: lt21007e + includes: [../scripts/libretiny_prefs/ltprefs.hpp] + platformio_options: + build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"] + on_boot: + priority: -100 + then: + - lambda: id(act) = ltprefs_boot(id(step), ${run}); + # the first boot waits so the host can open the port and pulse a reset before the sequence starts + - delay: !lambda 'return (id(step) == 0 || id(step) == 8) ? 30000 : 3000;' + - lambda: ltprefs_exit(id(step), ${run}, id(act)); + +bk72xx: + board: generic-bk7238-tuya + +logger: + hardware_uart: UART1 + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: step + type: int + initial_value: "0" + - id: act + type: int + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: ltprefs_alive(id(step), id(act)); diff --git a/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml b/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml new file mode 100644 index 00000000000..c4e42ce4c2f --- /dev/null +++ b/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml @@ -0,0 +1,34 @@ +esphome: + name: i19430-dut3 + on_boot: + priority: 800 + then: + - lambda: |- + auto marker = global_preferences->make_preference(0x19430001, true); + uint32_t v = 0; + if (!marker.load(&v)) { + ESP_LOGW("test", "first boot: wiping preferences"); + global_preferences->reset(); + static_cast(global_preferences)->open(); + v = 1; + marker.save(&v); + global_preferences->sync(); + } + +bk72xx: + board: generic-bk7238 + +logger: + hardware_uart: UART1 + level: VERBOSE + +wifi: + ap: + ssid: i19430-ap + +captive_portal: + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml b/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml new file mode 100644 index 00000000000..7e163db752d --- /dev/null +++ b/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml @@ -0,0 +1,50 @@ +substitutions: + run: "3" + +esphome: + name: cd13122d + on_boot: + priority: -100 + then: + - lambda: |- + ESP_LOGI("crashtest", "boot, crashed_before=%d run=%s", id(crashed_${run}), "${run}"); + - delay: 20s + - if: + condition: + lambda: 'return id(crashed_${run}) == 0;' + then: + - lambda: |- + id(crashed_${run}) = 1; + ESP_LOGW("crashtest", "flag set, letting the loop save it"); + - delay: 3s + - lambda: |- + global_preferences->sync(); + delay(500); + ESP_LOGW("crashtest", "writing to 0xDEADBEEF now"); + delay(100); + *reinterpret_cast(0xDEADBEEF) = 1; + else: + - lambda: |- + ESP_LOGI("crashtest", "already crashed once, running normally"); + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: DEBUG + +zephyr_coredump: + +globals: + - id: crashed_${run} + type: int + restore_value: true + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("crashtest", "alive"); diff --git a/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml b/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml new file mode 100644 index 00000000000..f7f588f5767 --- /dev/null +++ b/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml @@ -0,0 +1,31 @@ +esphome: + name: pref20810 + on_boot: + priority: -100 + then: + - lambda: |- + ESP_LOGI("test", "boot: restored counter_pr=%d", id(counter_pr)); + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: VERBOSE + +preferences: + flash_write_interval: 5s + +globals: + - id: counter_pr + type: int + restore_value: true + initial_value: "0" + +interval: + - interval: 15s + then: + - lambda: |- + id(counter_pr) += 1; + ESP_LOGI("test", "counter_pr now %d", id(counter_pr)); diff --git a/.agents/skills/hardware-test/templates/online-image-dut.yaml b/.agents/skills/hardware-test/templates/online-image-dut.yaml new file mode 100644 index 00000000000..f95c6ab7de6 --- /dev/null +++ b/.agents/skills/hardware-test/templates/online-image-dut.yaml @@ -0,0 +1,84 @@ +esphome: + name: online-image-dut + friendly_name: online-image-dut + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + actions: + - action: fetch + variables: + url: string + then: + - online_image.set_url: + id: img + url: !lambda 'return url;' + update: false + - component.update: img + - action: refetch + then: + - component.update: img + - action: fetch_small + variables: + url: string + then: + - online_image.set_url: + id: img_small + url: !lambda 'return url;' + update: false + - component.update: img_small + - action: refetch_small + then: + - component.update: img_small + +http_request: + timeout: 4.5s + verify_ssl: false + +display: + +image: + - platform: online_image + id: img + url: http://127.0.0.1/none.bmp + format: AUTO + type: RGB565 + update_interval: never + on_download_finished: + logger.log: + format: "download finished: img cached=%d" + args: [cached] + on_error: + logger.log: "download error: img" + - platform: online_image + id: img_small + url: http://127.0.0.1/none.bmp + format: AUTO + type: RGB565 + buffer_size: 256 + update_interval: never + on_download_finished: + logger.log: + format: "download finished: img_small cached=%d" + args: [cached] + on_error: + logger.log: "download error: img_small" + +button: + - platform: restart + id: reboot_btn + name: Reboot diff --git a/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml new file mode 100644 index 00000000000..8bab3df9abe --- /dev/null +++ b/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml @@ -0,0 +1,105 @@ +esphome: + name: rtsp17990 + on_boot: + # GPIO8 low enables the camera's power supply on the Waveshare ESP32-S3-ETH; give the sensor + # time to come up before esp32_camera (setup_priority 200) probes it + priority: 700 + then: + - switch.turn_off: cam_power + - lambda: |- + delay(1200); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: VERBOSE + hardware_uart: USB_SERIAL_JTAG + logs: + rtsp_server: VERBOSE + esp32_camera: DEBUG + camera: DEBUG + wifi: DEBUG + api: DEBUG + api.connection: DEBUG + api.socket: DEBUG + socket: DEBUG + scheduler: DEBUG + component: DEBUG + app: DEBUG + mdns: DEBUG + ota: DEBUG + logger: DEBUG + psram: DEBUG + esp32: DEBUG + network: DEBUG + i2c: DEBUG + i2c.idf: DEBUG + preferences: DEBUG + debug: DEBUG + sensor: DEBUG + wifi_esp32: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + reboot_timeout: 0s + +ota: + - platform: esphome + +debug: + update_interval: 10s + +sensor: + - platform: debug + free: + name: "Heap Free" + block: + name: "Heap Largest Block" + psram: + name: "PSRAM Free" + +# Waveshare ESP32-S3-ETH camera header (Waveshare wiki, WIFI_Web_CAM table) +i2c: + - id: cam_i2c + sda: GPIO48 + scl: GPIO47 + frequency: 400kHz + +switch: + - platform: gpio + id: cam_power + name: Camera Power + pin: GPIO8 + restore_mode: ALWAYS_OFF + setup_priority: 800 + +esp32_camera: + name: cam + setup_priority: 200 + data_pins: [GPIO41, GPIO45, GPIO46, GPIO42, GPIO40, GPIO38, GPIO15, GPIO18] + vsync_pin: GPIO1 + href_pin: GPIO2 + pixel_clock_pin: GPIO39 + external_clock: + pin: GPIO3 + frequency: 20MHz + i2c_id: cam_i2c + resolution: 640X480 + max_framerate: 10fps + +rtsp_server: + port: 554 + max_connections: 2 diff --git a/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml b/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml new file mode 100644 index 00000000000..bcbe505c71d --- /dev/null +++ b/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml @@ -0,0 +1,107 @@ +# Basic Config + +esphome: + name: s31-cse7766 + +esp8266: + board: esp01_1m + toolchain: arduino + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +logger: + baud_rate: 0 # (UART logging interferes with cse7766) + level: VERBOSE + logs: + cse7766: VERBOSE + api: DEBUG + api.connection: DEBUG + api.socket: DEBUG + socket: DEBUG + scheduler: DEBUG + component: DEBUG + app: DEBUG + wifi: DEBUG + mdns: DEBUG + ota: DEBUG + logger: DEBUG + sensor: DEBUG + switch: DEBUG + binary_sensor: DEBUG + status_led: DEBUG + uart: DEBUG + gpio: DEBUG + preferences: DEBUG + esp8266: DEBUG + wifi_esp8266: DEBUG +api: +ota: + platform: esphome + +# Device Specific Config + +uart: + rx_pin: RX + baud_rate: 4800 + parity: EVEN + +binary_sensor: + - platform: gpio + pin: + number: GPIO0 + mode: INPUT_PULLUP + inverted: True + name: "S31 Button" + on_press: + - switch.toggle: relay + - platform: status + name: "S31 Status" + +sensor: + - platform: wifi_signal + name: "S31 WiFi Signal" + update_interval: 60s + - platform: cse7766 + current: + accuracy_decimals: 3 + name: "S31 Current" + filters: + - throttle: 2s + voltage: + accuracy_decimals: 1 + name: "S31 Voltage" + filters: + - throttle: 2s + on_raw_value: + then: + - lambda: |- + id(frames) += 1; + power: + accuracy_decimals: 1 + name: "S31 Power" + filters: + - throttle: 2s +switch: + - platform: gpio + name: "S31 Relay" + pin: GPIO12 + id: relay + restore_mode: RESTORE_DEFAULT_ON + +status_led: + pin: GPIO13 + +globals: + - id: frames + type: uint32_t + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: |- + static uint32_t last = 0; + ESP_LOGI("frames", "cse7766 frames parsed: %u in 10 s (total %u)", (unsigned) (id(frames) - last), (unsigned) id(frames)); + last = id(frames); diff --git a/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml b/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml new file mode 100644 index 00000000000..0dc9fa4a96a --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml @@ -0,0 +1,112 @@ +esphome: + name: cdc11634 + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: VERBOSE + logs: + usb_cdc_acm: VERBOSE + scheduler: DEBUG + component: DEBUG + app: DEBUG + preferences: DEBUG + logger: DEBUG + nrf52: DEBUG + dfu: DEBUG + +usb_cdc_acm: + interfaces: + - disabled: true + - id: cdc1 + +globals: + - id: rx_total + type: uint32_t + initial_value: "0" + - id: tx_attempted + type: uint32_t + initial_value: "0" + - id: stream_left + type: int32_t + initial_value: "0" + +interval: + - interval: 20ms + then: + - lambda: |- + uint8_t buf[64]; + while (id(cdc1).available() > 0) { + // a command is exactly two bytes, so what the host sends after it stays in the driver for peek/read + uint8_t first = 0; + id(cdc1).peek_byte(&first); + size_t n = std::min(id(cdc1).available(), sizeof(buf)); + if (first == 0x01 && n >= 2) + n = 2; + if (!id(cdc1).read_array(buf, n)) { + ESP_LOGE("test", "read_array(%u) failed", (unsigned) n); + break; + } + id(rx_total) += n; + if (buf[0] == 0x01 && n >= 2) { + const char cmd = buf[1]; + if (cmd == 'B') { + // one oversized write: the 256 byte TX ring must drop the rest and warn once + static uint8_t big[1000]; + for (size_t i = 0; i < sizeof(big); i++) big[i] = 'A' + (i % 26); + id(cdc1).write_array(big, sizeof(big)); + id(tx_attempted) += sizeof(big); + ESP_LOGI("test", "burst 1000 written"); + } else if (cmd == 'S') { + id(stream_left) = 100; + ESP_LOGI("test", "stream started"); + } else if (cmd == 'L') { + ESP_LOGI("test", "line coding baud=%u data=%u stop=%u parity=%u", (unsigned) id(cdc1).get_baud_rate(), + id(cdc1).get_data_bits(), id(cdc1).get_stop_bits(), (unsigned) id(cdc1).get_parity()); + } else if (cmd == 'K') { + // the host sends "\x01KZ" in one write; the Z must still be in the driver here + uint8_t pb = 0, rb = 0; + bool okp = id(cdc1).peek_byte(&pb); + bool okr = id(cdc1).read_byte(&rb); + ESP_LOGI("test", "peek ok=%d 0x%02X read ok=%d 0x%02X (expect ok=1 0x5A for both)", okp, pb, okr, rb); + if (okr) id(rx_total) += 1; + } else if (cmd == 'F') { + static uint8_t pat[200]; + for (size_t i = 0; i < sizeof(pat); i++) pat[i] = 'a' + (i % 26); + id(cdc1).write_array(pat, sizeof(pat)); + id(tx_attempted) += sizeof(pat); + const uint32_t t0 = millis(); + auto res = id(cdc1).flush(); + ESP_LOGI("test", "flush result=%d took %u ms", (int) res, (unsigned) (millis() - t0)); + } else if (cmd == 'C') { + ESP_LOGI("test", "counters rx=%u tx_attempted=%u (write_array drops silently when the ring is full)", (unsigned) id(rx_total), (unsigned) id(tx_attempted)); + } else { + ESP_LOGW("test", "unknown cmd 0x%02X", cmd); + } + continue; + } + id(cdc1).write_array(buf, n); + id(tx_attempted) += n; + } + - interval: 100ms + then: + - lambda: |- + if (id(stream_left) > 0) { + char line[101]; + int len = snprintf(line, sizeof(line), "STREAM %03d ", 100 - id(stream_left)); + for (int i = len; i < 99; i++) line[i] = 'x'; + line[99] = '\n'; + id(cdc1).write_array(reinterpret_cast(line), 100); + id(tx_attempted) += 100; + id(stream_left) -= 1; + if (id(stream_left) == 0) { + ESP_LOGI("test", "stream done"); + } + } + - interval: 10s + then: + - lambda: |- + ESP_LOGI("test", "alive rx=%u tx_attempted=%u", (unsigned) id(rx_total), (unsigned) id(tx_attempted)); diff --git a/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml b/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml new file mode 100644 index 00000000000..b7c8fc2a16e --- /dev/null +++ b/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml @@ -0,0 +1,83 @@ +esphome: + name: camdiag + on_boot: + priority: -100 + then: + - output.set_level: + id: xclk + level: 50% + - delay: 200ms + - lambda: |- + auto *bus = id(cam_i2c); + auto scan = [&](const char *what) { + int found = 0; + for (uint8_t a = 0x08; a < 0x78; a++) { + if (bus->write(a, nullptr, 0) == i2c::ERROR_OK) { + ESP_LOGI("camdiag", " ACK at 0x%02X", a); + found++; + } + } + ESP_LOGI("camdiag", "scan %s: %d devices", what, found); + }; + auto rd16 = [&](uint16_t reg) { + uint8_t r[2] = {(uint8_t) (reg >> 8), (uint8_t) reg}; + uint8_t v = 0xEE; + auto err = bus->write_readv(0x3C, r, 2, &v, 1); + ESP_LOGI("camdiag", " 0x3C [0x%04X] err=%d -> 0x%02X", reg, (int) err, v); + return v; + }; + auto rd8 = [&](uint8_t addr, uint8_t reg) { + uint8_t v = 0xEE; + auto err = bus->write_readv(addr, ®, 1, &v, 1); + ESP_LOGI("camdiag", " 0x%02X [0x%02X] (8 bit) err=%d -> 0x%02X", addr, reg, (int) err, v); + }; + id(g8).turn_off(); delay(1500); + id(xclk).set_level(0.0f); delay(300); + scan("GPIO8 LOW, XCLK OFF"); + id(xclk).set_level(0.5f); delay(300); + scan("GPIO8 LOW, XCLK ON"); + ESP_LOGI("camdiag", "OV5640 style 16 bit reads (expect 300A=0x56 300B=0x40, 302A=0xB0..B2):"); + rd16(0x300A); rd16(0x300B); rd16(0x302A); rd16(0x3008); rd16(0x3100); rd16(0x3103); rd16(0x4300); + ESP_LOGI("camdiag", "8 bit reads at 0x3C (GC2145 expects F0=0x21 F1=0x45):"); + rd8(0x3C, 0xF0); rd8(0x3C, 0xF1); rd8(0x3C, 0x0A); rd8(0x3C, 0x0B); + ESP_LOGI("camdiag", "write 0x3103=0x11 then read back, then soft reset 0x3008=0x82 and re-read IDs:"); + uint8_t w[3] = {0x31, 0x03, 0x11}; + auto werr = bus->write(0x3C, w, 3); + ESP_LOGI("camdiag", " write err=%d", (int) werr); + rd16(0x3103); + uint8_t rst[3] = {0x30, 0x08, 0x82}; + bus->write(0x3C, rst, 3); delay(100); + rd16(0x300A); rd16(0x300B); + id(g8).turn_on(); delay(500); scan("GPIO8 HIGH (camera off)"); + id(g8).turn_off(); delay(1500); scan("GPIO8 LOW again"); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: DEBUG + hardware_uart: USB_SERIAL_JTAG + +i2c: + - id: cam_i2c + sda: GPIO48 + scl: GPIO47 + frequency: 100kHz + scan: true + +output: + - platform: gpio + id: g8 + pin: GPIO8 + - platform: ledc + pin: GPIO3 + id: xclk + frequency: 20000000Hz diff --git a/CODEOWNERS b/CODEOWNERS index 1f1375ba968..3bb01931381 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -279,6 +279,7 @@ esphome/components/hmac_sha256/* @dwmw2 esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/button/* @jesserockz +esphome/components/homeassistant/light/* @egormanga esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/select/* @jesserockz esphome/components/homeassistant/switch/* @Links2004 @@ -683,6 +684,7 @@ esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/unicode/* @esphome/core esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_audio/* @ab-tools esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_host/* @clydebarrow esphome/components/usb_uart/* @clydebarrow diff --git a/esphome/__main__.py b/esphome/__main__.py index 82ef585e1a9..39451849bc2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -791,6 +791,7 @@ def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() + writer.check_build_tree_not_tracked() generate_cpp_contents(config) return write_cpp_file() diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 8c97169cbf1..84f853c265a 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -9,15 +9,13 @@ namespace esphome::a01nyub { ESPHOME_LOG_TAG(TAG, "a01nyub.sensor"); void A01nyubComponent::loop() { - uint8_t data; - while (this->available() > 0) { - this->read_byte(&data); + this->read_available([this](uint8_t data) { if (this->buffer_.empty() && (data != 0xff)) - continue; + return; buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void A01nyubComponent::check_buffer_() { diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp index 20e8f6c7b56..6b809ad396c 100644 --- a/esphome/components/a02yyuw/a02yyuw.cpp +++ b/esphome/components/a02yyuw/a02yyuw.cpp @@ -9,15 +9,13 @@ namespace esphome::a02yyuw { ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor"); void A02yyuwComponent::loop() { - uint8_t data; - while (this->available() > 0) { - this->read_byte(&data); + this->read_available([this](uint8_t data) { if (this->buffer_.empty() && (data != 0xff)) - continue; + return; buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void A02yyuwComponent::check_buffer_() { diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index f11ba5af990..10ad57a8828 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -44,10 +44,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(temperature_sensor)) - - humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(humidity_sensor)) + await cg.add_variable(var.set_temperature_sensor, config[CONF_TEMPERATURE]) + await cg.add_variable(var.set_humidity_sensor, config[CONF_HUMIDITY]) cg.add(var.set_equation(config[CONF_EQUATION])) diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index e3510d380eb..7e7dca0f2fd 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -68,32 +68,32 @@ void AdalightLightEffect::apply(light::AddressableLight &it, const Color ¤ blank_all_leds_(it); } - if (this->available() > 0) { - ESP_LOGV(TAG, "Frame: Available (size=%d).", this->available()); + const size_t avail = this->available(); + if (avail > 0) { + ESP_LOGV(TAG, "Frame: Available (size=%zu).", avail); } - while (this->available() != 0) { - uint8_t data; - if (!this->read_byte(&data)) - break; - this->frame_.push_back(data); - this->last_byte_ = now; + this->read_available( + [this, &it, now](uint8_t data) { + this->frame_.push_back(data); + this->last_byte_ = now; - switch (this->parse_frame_(it)) { - case INVALID: - ESP_LOGD(TAG, "Frame: Invalid (size=%zu, first=%d).", this->frame_.size(), this->frame_[0]); - reset_frame_(it); - break; + switch (this->parse_frame_(it)) { + case INVALID: + ESP_LOGD(TAG, "Frame: Invalid (size=%zu, first=%d).", this->frame_.size(), this->frame_[0]); + reset_frame_(it); + break; - case PARTIAL: - break; + case PARTIAL: + break; - case CONSUMED: - ESP_LOGV(TAG, "Frame: Consumed (size=%zu).", this->frame_.size()); - reset_frame_(it); - break; - } - } + case CONSUMED: + ESP_LOGV(TAG, "Frame: Consumed (size=%zu).", this->frame_.size()); + reset_frame_(it); + break; + } + }, + avail); } AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableLight &it) { diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 5f0e2eb3ecf..c0762cb4c83 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -1,4 +1,5 @@ #include "airthings_listener.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -12,10 +13,7 @@ bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) if (it.data.size() < 4) continue; - uint32_t sn = it.data[0]; - sn |= ((uint32_t) it.data[1] << 8); - sn |= ((uint32_t) it.data[2] << 16); - sn |= ((uint32_t) it.data[3] << 24); + uint32_t sn = encode_value_le(it.data.data()); char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD(TAG, "Found AirThings device Serial:%" PRIu32 " (MAC: %s)", sn, device.address_str_to(addr_buf)); diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py index fdfef45aaa4..bde8eca0c04 100644 --- a/esphome/components/airton/climate.py +++ b/esphome/components/airton/climate.py @@ -16,15 +16,16 @@ CONF_VERTICAL_DIRECTION = "vertical_direction" VerticalDirection = airton_ns.enum("VerticalDirection", is_class=True) # In the order of the enum, so the option index is the direction value -VERTICAL_DIRECTIONS = { - "off": VerticalDirection.VERTICAL_DIRECTION_OFF, - "swing": VerticalDirection.VERTICAL_DIRECTION_SWING, - "up": VerticalDirection.VERTICAL_DIRECTION_UP, - "middle-up": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_UP, - "middle": VerticalDirection.VERTICAL_DIRECTION_MIDDLE, - "middle-down": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_DOWN, - "down": VerticalDirection.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirection.options( + "off", + "swing", + "up", + "middle-up", + "middle", + "middle-down", + "down", + prefix="VERTICAL_DIRECTION_", +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(AirtonClimate).extend( { diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index d8d59242fac..ada426ca095 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FLOW, CONF_HEAD, - CONF_ID, CONF_POWER, CONF_SPEED, CONF_VOLTAGE, @@ -70,9 +69,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + var = await ble_client.new_ble_node_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FLOW, var.set_flow_sensor) diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 1b3761fdac5..eccab7acfb5 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -69,12 +69,7 @@ bool AM2320Component::read_data_(uint8_t *data) { return false; } - uint16_t checksum; - - checksum = data[7] << 8; - checksum += data[6]; - - if (crc16(data, 6) != checksum) { + if (crc16(data, 6) != encode_value_le(&data[6])) { ESP_LOGW(TAG, "AM2320 Checksum invalid!"); return false; } diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index ef746f3e52f..d988c6dbec6 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -3,7 +3,6 @@ from esphome.components import ble_client, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, - CONF_ID, CONF_ILLUMINANCE, DEVICE_CLASS_BATTERY, ENTITY_CATEGORY_DIAGNOSTIC, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + var = await ble_client.new_ble_node_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_LEVEL, var.set_battery) diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index b2de1d6184c..f21efb09055 100644 --- a/esphome/components/analog_threshold/binary_sensor.py +++ b/esphome/components/analog_threshold/binary_sensor.py @@ -37,8 +37,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR_ID]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR_ID]) if isinstance(config[CONF_THRESHOLD], dict): lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_) diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp index dee4bd8d73b..b94f9c52021 100644 --- a/esphome/components/apc1/apc1.cpp +++ b/esphome/components/apc1/apc1.cpp @@ -104,16 +104,14 @@ void APC1Component::loop() { this->rx_index_ = 0; } - while (this->available()) { + this->read_available([this, now](uint8_t byte) { this->last_transmission_ = now; - uint8_t byte; - this->read_byte(&byte); if (this->rx_index_ == 0) { if (byte == APC1_HEADER_1) { this->rx_buffer_[this->rx_index_++] = byte; } - continue; + return; } if (this->rx_index_ == 1) { @@ -122,12 +120,12 @@ void APC1Component::loop() { } else if (byte != APC1_HEADER_1) { this->rx_index_ = 0; } - continue; + return; } this->rx_buffer_[this->rx_index_++] = byte; if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { - continue; + return; } // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a @@ -136,14 +134,14 @@ void APC1Component::loop() { if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) { ESP_LOGW(TAG, "Invalid frame length: %u", payload_len); this->rx_index_ = 0; - continue; + return; } const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE; if (this->rx_index_ == total_len) { this->parse_frame_(total_len); this->rx_index_ = 0; } - } + }); } void APC1Component::parse_frame_(uint16_t total_len) { diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp index c17773da29c..e781cef67d5 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.cpp +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -27,11 +27,7 @@ void APCProteousCover::setup() { } void APCProteousCover::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) { - continue; - } + this->read_available([this](uint8_t data) { const char *line = this->line_reader_.feed(data); if (this->line_reader_.overflowed()) { // Start over from this byte, so a frame right after unterminated noise is still read @@ -41,7 +37,7 @@ void APCProteousCover::loop() { ESP_LOGVV(TAG, "rx: '%s'", line); this->parse_response_(line); } - } + }); } void APCProteousCover::parse_response_(const char *line) { diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index eec741d5938..3df24a47537 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -104,11 +104,9 @@ void APDS9930Component::dump_config() { void APDS9930Component::update() { // STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H uint8_t raw[7]; - if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) { - this->status_set_warning(LOG_STR("Reading data failed")); + if (!this->status_comm_result(this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw)), + LOG_STR("Reading data failed"))) return; - } - this->status_clear_warning(); const uint8_t status = raw[0]; if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index c003e7a8bc9..dc7f8c8bffd 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -162,13 +162,13 @@ void APDS9960::read_color_data_(uint8_t status) { return; } - uint8_t raw[8]; - APDS9960_WARNING_CHECK(this->read_bytes(0x94, raw, 8), "Reading color values failed."); + uint16_t raw[4]; + APDS9960_WARNING_CHECK(this->read_bytes_16_le(0x94, raw, 4), "Reading color values failed."); - uint16_t uint_clear = (uint16_t(raw[1]) << 8) | raw[0]; - uint16_t uint_red = (uint16_t(raw[3]) << 8) | raw[2]; - uint16_t uint_green = (uint16_t(raw[5]) << 8) | raw[4]; - uint16_t uint_blue = (uint16_t(raw[7]) << 8) | raw[6]; + uint16_t uint_clear = raw[0]; + uint16_t uint_red = raw[1]; + uint16_t uint_green = raw[2]; + uint16_t uint_blue = raw[3]; float clear_perc = (uint_clear / float(UINT16_MAX)) * 100.0f; float red_perc = (uint_red / float(UINT16_MAX)) * 100.0f; diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 44d8b443676..dcca7ed1494 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -4,7 +4,6 @@ import re from typing import Any from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_DESCRIPTION, CONF_HOST from esphome.components.logger import request_log_listener @@ -99,7 +98,6 @@ ActionResponse = api_ns.class_("ActionResponse") HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger ) -APIConnectedCondition = api_ns.class_("APIConnectedCondition", Condition) APIRespondAction = api_ns.class_("APIRespondAction", automation.Action) APIUnregisterServiceCallAction = api_ns.class_( "APIUnregisterServiceCallAction", automation.Action @@ -1057,19 +1055,13 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( ) -@automation.register_condition( - "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA +automation.register_apply_condition( + "api.connected", + API_CONNECTED_CONDITION_SCHEMA, + automation.ApplyCall( + "is_connected({})", ((CONF_STATE_SUBSCRIPTION_ONLY, cg.bool_),) + ), ) -async def api_connected_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) - cg.add(var.set_state_subscription_only(templ)) - return var # user_services.cpp is only needed when user defined actions exist; the diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index b38976e8cd5..491a9c27a4f 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { if (!network::is_connected()) { - // Flips within seconds of boot; recheck fast so a deep sleep wake - // window is not spent waiting - this->schedule_wait_(now, NETWORK_RETRY_MS); - return; + return; // still WAITING with the wait elapsed, so the next loop rechecks } struct sockaddr_storage addr; socklen_t addr_len = this->target_sockaddr_(&addr); @@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { } this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ts_ = now; - this->last_poll_ = now; } void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { @@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } - if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { - return; - } - this->last_poll_ = now; int err = 0; switch (socket::poll_connect(*this->dial_socket_, err)) { case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 16d50c34255..fa222e29ccf 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -59,8 +59,6 @@ class OutgoingConnectionManager { static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those // builds dial out as soon as the target is gone @@ -104,7 +102,6 @@ class OutgoingConnectionManager { uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t wait_{BOOT_WAIT_MS}; uint32_t state_ts_{0}; - uint32_t last_poll_{0}; // Byte-aligned types last #ifndef API_OUTGOING_CONNECTION_HOST diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 09b08de4e3f..52310747fcf 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -213,6 +213,9 @@ class APIServer final : public Component bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + bool is_connected(bool state_subscription_only) const { + return state_subscription_only ? this->is_connected_with_state_subscription() : this->is_connected(); + } // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the @@ -407,16 +410,5 @@ class APIServer final : public Component extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition final : public Condition { - TEMPLATABLE_VALUE(bool, state_subscription_only) - public: - bool check(const Ts &...x) override { - if (this->state_subscription_only_.value(x...)) { - return global_api_server->is_connected_with_state_subscription(); - } - return global_api_server->is_connected(); - } -}; - } // namespace esphome::api #endif diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 8e5c808e24c..200c4f9f120 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -208,6 +208,7 @@ WIZARD_SCHEMA = cv.All( WIZARD_INPUT_DOMAINS = ( "binary_sensor", "button", + "light", "number", "select", "sensor", @@ -216,7 +217,14 @@ WIZARD_INPUT_DOMAINS = ( "text_sensor", ) # Platforms that act on one family of Home Assistant domains, which the input's filters must stay within -WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text") +WIZARD_DOMAIN_LIMITED_PLATFORMS = ( + "button", + "light", + "number", + "select", + "switch", + "text", +) def wizard_input_ids(api_config: ConfigType) -> set[str]: diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index a98d977227c..6e0a3e74963 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -53,11 +53,8 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pm_2_5_sensor = await cg.get_variable(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(pm_2_5_sensor)) - - pm_10_0_sensor = await cg.get_variable(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) + await cg.add_variable(var.set_pm_2_5_sensor, config[CONF_PM_2_5]) + await cg.add_variable(var.set_pm_10_0_sensor, config[CONF_PM_10_0]) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py index f22d242f9cc..c31325df933 100644 --- a/esphome/components/aranet/sensor.py +++ b/esphome/components/aranet/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_PRESSURE, CONF_RADON, @@ -131,9 +130,9 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex + ) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 95b482c8404..a495cb9dd2e 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,4 +1,5 @@ #include "atc_mithermometer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::atc_mithermometer { @@ -89,7 +90,7 @@ bool ATCMiThermometer::parse_message_(const std::vector &message, Parse } // temperature, 2 bytes, 16-bit signed integer (LE), 0.1 °C - const int16_t temperature = uint16_t(data[7]) | (uint16_t(data[6]) << 8); + const int16_t temperature = encode_value(&data[6]); result.temperature = temperature / 10.0f; // humidity, 1 byte, 8-bit unsigned integer, 1.0 % @@ -99,7 +100,7 @@ bool ATCMiThermometer::parse_message_(const std::vector &message, Parse result.battery_level = data[9]; // battery, 2 bytes, 16-bit unsigned integer, 0.001 V - const int16_t battery_voltage = uint16_t(data[11]) | (uint16_t(data[10]) << 8); + const int16_t battery_voltage = encode_value(&data[10]); result.battery_voltage = battery_voltage / 1.0e3f; return true; diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index cde1a41f657..bacb6e46b87 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 0cdf9918db9..9fb32e2f021 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -117,12 +117,9 @@ void ATM90E32Component::loop() { } void ATM90E32Component::update() { - if (this->read16_(ATM90E32_REGISTER_METEREN) != 1) { - this->status_set_warning(); + if (!this->status_comm_result(this->read16_(ATM90E32_REGISTER_METEREN) == 1, nullptr)) return; - } this->set_publish_interval_flag_(true); - this->status_clear_warning(); #ifdef USE_TEXT_SENSOR this->check_phase_status(); diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index 402e741059b..61163b820d8 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -23,14 +23,15 @@ AudioStreamInfo::AudioStreamInfo(uint8_t bits_per_sample, uint8_t channels, uint } uint32_t AudioStreamInfo::frames_to_microseconds(uint32_t frames) const { - return (frames * 1000000 + (this->sample_rate_ >> 1)) / this->sample_rate_; + // In 64 bits: frames * 1000000 overflows 32 bits above 4294 frames, about 90 ms at 48 kHz. + return (static_cast(frames) * 1000000 + (this->sample_rate_ >> 1)) / this->sample_rate_; } uint32_t AudioStreamInfo::frames_to_milliseconds_with_remainder(uint32_t *total_frames) const { uint32_t unprocessable_frames = *total_frames % (this->sample_rate_ / this->ms_sample_rate_gcd_); uint32_t frames_for_ms_calculation = *total_frames - unprocessable_frames; - uint32_t playback_ms = (frames_for_ms_calculation * 1000) / this->sample_rate_; + uint32_t playback_ms = (static_cast(frames_for_ms_calculation) * 1000) / this->sample_rate_; *total_frames = unprocessable_frames; return playback_ms; } diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 36780a3055b..c03c42a5a3f 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -34,7 +34,7 @@ class AudioStreamInfo { /// @return Duration in milliseconds that will store `bytes` bytes of audio. May round down for certain sample rates /// or values of `bytes`. uint32_t bytes_to_ms(size_t bytes) const { - return bytes * 1000 / (this->sample_rate_ * this->bytes_per_sample_ * this->channels_); + return static_cast(bytes) * 1000 / (this->sample_rate_ * this->bytes_per_sample_ * this->channels_); } /// @brief Convert bytes to frames. @@ -60,18 +60,20 @@ class AudioStreamInfo { /// @brief Converts duration to frames. /// @param ms Duration in milliseconds /// @return Audio frames that will store `ms` milliseconds of audio. May round down for certain sample rates. - uint32_t ms_to_frames(uint32_t ms) const { return (ms * this->sample_rate_) / 1000; } + uint32_t ms_to_frames(uint32_t ms) const { return (static_cast(ms) * this->sample_rate_) / 1000; } /// @brief Converts duration to samples. /// @param ms Duration in milliseconds /// @return Audio samples that will store `ms` milliseconds of audio. May round down for certain sample rates. - uint32_t ms_to_samples(uint32_t ms) const { return (ms * this->channels_ * this->sample_rate_) / 1000; } + uint32_t ms_to_samples(uint32_t ms) const { + return (static_cast(ms) * this->channels_ * this->sample_rate_) / 1000; + } /// @brief Converts duration to bytes. May round down for certain sample rates. /// @param ms Duration in milliseconds /// @return Bytes that will store `ms` milliseconds of audio. May round down for certain sample rates. size_t ms_to_bytes(uint32_t ms) const { - return (ms * this->bytes_per_sample_ * this->channels_ * this->sample_rate_) / 1000; + return (static_cast(ms) * this->bytes_per_sample_ * this->channels_ * this->sample_rate_) / 1000; } /// @brief Computes the duration, in microseconds, the given amount of frames represents. diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 8bed59b3dbc..972d938c47b 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,4 +1,5 @@ #include "b_parasite.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::b_parasite { @@ -62,29 +63,29 @@ bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { } // Battery voltage in millivolts. - uint16_t battery_millivolt = data[2] << 8 | data[3]; + uint16_t battery_millivolt = encode_value(&data[2]); float battery_voltage = battery_millivolt / 1000.0f; // Temperature in 1000 * Celsius (protocol v1) or 100 * Celsius (protocol v2). float temp_celsius; if (protocol_version == 1) { - uint16_t temp_millicelsius = data[4] << 8 | data[5]; + uint16_t temp_millicelsius = encode_value(&data[4]); temp_celsius = temp_millicelsius / 1000.0f; } else { - int16_t temp_centicelsius = data[4] << 8 | data[5]; + int16_t temp_centicelsius = encode_value(&data[4]); temp_celsius = temp_centicelsius / 100.0f; } // Relative air humidity in the range [0, 2^16). - uint16_t humidity = data[6] << 8 | data[7]; + uint16_t humidity = encode_value(&data[6]); float humidity_percent = (100.0f * humidity) / (1 << 16); // Relative soil moisture in [0 - 2^16). - uint16_t soil_moisture = data[8] << 8 | data[9]; + uint16_t soil_moisture = encode_value(&data[8]); float moisture_percent = (100.0f * soil_moisture) / (1 << 16); // Ambient light in lux. - float illuminance = has_illuminance ? data[16] << 8 | data[17] : 0.0f; + float illuminance = has_illuminance ? encode_value(&data[16]) : 0.0f; if (battery_voltage_ != nullptr) { battery_voltage_->publish_state(battery_voltage); diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index cb8f569c0d6..74573fee262 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -76,9 +75,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/bang_bang/climate.py b/esphome/components/bang_bang/climate.py index 65c5eaed187..e09b187ae3d 100644 --- a/esphome/components/bang_bang/climate.py +++ b/esphome/components/bang_bang/climate.py @@ -46,12 +46,8 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) normal_config = BangBangClimateTargetTempConfig( config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 61dc73c73d5..2bcbf557649 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.const import CONF_RECEIVE_TIMEOUT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -41,11 +41,7 @@ async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) - if time_id := config.get(CONF_TIME_ID): - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + var = await ble_client.new_ble_node_component(config) + await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID)) if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None: cg.add(var.set_status_timeout(receive_timeout)) diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index c34c2493310..bc89a17351e 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -134,11 +134,8 @@ void BH1745Component::update() { return; uint16_t buffer[BH1745_CHANNELS]; - if (!this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->status_comm_result(this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS))) return; - } - this->status_clear_warning(); const uint16_t red = buffer[0]; const uint16_t green = buffer[1]; diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 6b7f1a35863..dc1b930675c 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "bh1900nux.h" @@ -36,7 +37,7 @@ void BH1900NUXSensor::update() { // Combined raw value, unsigned and unaligned 16 bit // Temperature is represented in just 12 bits, shift needed - int16_t raw_temperature_register_value = encode_uint16(temperature_raw[0], temperature_raw[1]); + int16_t raw_temperature_register_value = encode_value(temperature_raw); raw_temperature_register_value >>= 4; float temperature_value = raw_temperature_register_value * SENSOR_RESOLUTION; // Apply sensor resolution diff --git a/esphome/components/binary/fan/__init__.py b/esphome/components/binary/fan/__init__.py index 03a03ec7cac..a97920b408a 100644 --- a/esphome/components/binary/fan/__init__.py +++ b/esphome/components/binary/fan/__init__.py @@ -25,13 +25,6 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) - - if oscillation_output_id := config.get(CONF_OSCILLATION_OUTPUT): - oscillation_output = await cg.get_variable(oscillation_output_id) - cg.add(var.set_oscillating(oscillation_output)) - - if direction_output_id := config.get(CONF_DIRECTION_OUTPUT): - direction_output = await cg.get_variable(direction_output_id) - cg.add(var.set_direction(direction_output)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) + await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) + await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) diff --git a/esphome/components/binary/light/__init__.py b/esphome/components/binary/light/__init__.py index b6eddac3410..3883394050c 100644 --- a/esphome/components/binary/light/__init__.py +++ b/esphome/components/binary/light/__init__.py @@ -20,5 +20,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index abef3c7752b..5a03fdd293c 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -144,8 +144,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BK72XX_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. bk72xx_ble.request_scan_listener_slot() diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 681c7ad15eb..12ea9f40faa 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,8 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) } void BL0906::loop() { - while (this->available()) - this->read(); + this->discard_available(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,8 +91,7 @@ void BL0906::advance_stage_() { } void BL0906::setup() { - while (this->available()) - this->read(); + this->discard_available(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index f7fa488aac7..1cb44c8ace7 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -493,13 +493,13 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x19: // Appearance if (ad_data_len >= 2) - this->appearance_ = static_cast(ad_data[0]) | (static_cast(ad_data[1]) << 8); + this->appearance_ = encode_value_le(ad_data); break; case 0x02: // Incomplete List of 16-bit Service UUIDs case 0x03: // Complete List of 16-bit Service UUIDs for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) { - uint16_t uuid = (static_cast(ad_data[i + 1]) << 8) | ad_data[i]; + uint16_t uuid = encode_value_le(&ad_data[i]); this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid)); } break; @@ -507,9 +507,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x04: // Incomplete List of 32-bit Service UUIDs case 0x05: // Complete List of 32-bit Service UUIDs for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) { - uint32_t uuid = (static_cast(ad_data[i + 3]) << 24) | - (static_cast(ad_data[i + 2]) << 16) | (static_cast(ad_data[i + 1]) << 8) | - ad_data[i]; + uint32_t uuid = encode_value_le(&ad_data[i]); this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid)); } break; @@ -522,7 +520,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0xFF: // Manufacturer Specific Data if (ad_data_len >= 2) { - uint16_t company_id = (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint16_t company_id = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint16(company_id); sd.data.assign(ad_data + 2, ad_data + ad_data_len); @@ -532,7 +530,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x16: // Service Data — 16-bit UUID if (ad_data_len >= 2) { - uint16_t uuid = (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint16_t uuid = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint16(uuid); sd.data.assign(ad_data + 2, ad_data + ad_data_len); @@ -542,8 +540,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x20: // Service Data — 32-bit UUID if (ad_data_len >= 4) { - uint32_t uuid = (static_cast(ad_data[3]) << 24) | (static_cast(ad_data[2]) << 16) | - (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint32_t uuid = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint32(uuid); sd.data.assign(ad_data + 4, ad_data + ad_data_len); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index b21d997b4fc..176a73dc33d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -4,6 +4,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/ble_device_base/ble_client_state.h" #include "esphome/components/ble_device_base/ble_device.h" @@ -124,10 +125,8 @@ inline void fill_128bit_uuid_array(std::array &out, const ble_devic if (uuid.type() == ESPBTUUID::Type::UUID128) { const uint8_t *u = uuid.uuid128(); // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) - out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | - ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); - out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | - ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + out[0] = encode_value_le(&u[8]); + out[1] = encode_value_le(u); return; } // 16/32-bit UUID inserted into the Bluetooth base UUID: diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 6332ea6dc12..5c7dff0cd17 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -372,8 +372,7 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) - cg.add(var.set_ble_hub(tracker)) + await cg.add_variable(var.set_ble_hub, config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) # Compiles the scanner-state push slot into the tracker and the matching # registration into the proxy; the other hubs are polled instead. @@ -390,8 +389,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) - cg.add(var.set_ble_hub(hub)) + await cg.add_variable(var.set_ble_hub, config[ble_device_base.CONF_BLE_HUB_ID]) await _connections_to_code(var, config) diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index f83b98af115..9e8db2dd6ec 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -2,6 +2,7 @@ #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bm8563 { @@ -67,28 +68,17 @@ void BM8563::read_time() { time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); } -uint8_t BM8563::bcd2_to_byte_(uint8_t value) { - const uint8_t tmp = ((value & 0xF0) >> 0x4) * 10; - return tmp + (value & 0x0F); -} - -uint8_t BM8563::byte_to_bcd2_(uint8_t value) { - const uint8_t bcdhigh = value / 10; - value -= bcdhigh * 10; - return (bcdhigh << 4) | value; -} - void BM8563::get_time_(ESPTime &time) { uint8_t buf[3] = {0}; this->read_register(TIME_FIRST_REG, buf, 3); - time.second = this->bcd2_to_byte_(buf[0] & 0x7f); - time.minute = this->bcd2_to_byte_(buf[1] & 0x7f); - time.hour = this->bcd2_to_byte_(buf[2] & 0x3f); + time.second = bcd_to_uint8(buf[0] & 0x7f); + time.minute = bcd_to_uint8(buf[1] & 0x7f); + time.hour = bcd_to_uint8(buf[2] & 0x3f); } void BM8563::set_time_(const ESPTime &time) { - uint8_t buf[3] = {this->byte_to_bcd2_(time.second), this->byte_to_bcd2_(time.minute), this->byte_to_bcd2_(time.hour)}; + uint8_t buf[3] = {uint8_to_bcd(time.second), uint8_to_bcd(time.minute), uint8_to_bcd(time.hour)}; this->write_register_(TIME_FIRST_REG, buf, 3); } @@ -96,12 +86,12 @@ void BM8563::get_date_(ESPTime &time) { uint8_t buf[4] = {0}; this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); - time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); + time.day_of_month = bcd_to_uint8(buf[0] & 0x3f); // 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); + time.month = bcd_to_uint8(buf[2] & 0x1f); - uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); + uint8_t year_byte = bcd_to_uint8(buf[3] & 0xff); if (buf[2] & 0x80) { time.year = 1900 + year_byte; @@ -112,10 +102,10 @@ 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), + uint8_to_bcd(time.day_of_month), static_cast(time.day_of_week - 1), - this->byte_to_bcd2_(time.month), - this->byte_to_bcd2_(time.year % 100), + uint8_to_bcd(time.month), + uint8_to_bcd(time.year % 100), }; if (time.year < 2000) { diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index b9b7a2c3276..db6d6c5082a 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -29,9 +29,6 @@ class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { void write_byte_(uint8_t reg, uint8_t value); void write_register_(uint8_t reg, const uint8_t *data, size_t len); optional read_register_(uint8_t reg); - - uint8_t bcd2_to_byte_(uint8_t value); - uint8_t byte_to_bcd2_(uint8_t value); }; } // namespace esphome::bm8563 diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index f922b1942b4..00e6f9edd25 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -236,7 +236,7 @@ void BME280Component::update() { }); } float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { - int32_t adc = ((data[3] & 0xFF) << 16) | ((data[4] & 0xFF) << 8) | (data[5] & 0xFF); + int32_t adc = encode_uint24(data[3], data[4], data[5]); adc >>= 4; if (adc == 0x80000) { // temperature was disabled @@ -256,7 +256,7 @@ float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { } float BME280Component::read_pressure_(const uint8_t *data, int32_t t_fine) { - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // pressure was disabled diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 3bc1f3ec1e2..ac5ce3287d8 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -1,5 +1,6 @@ #include "bme680.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bme680 { @@ -92,8 +93,8 @@ bool BME680Component::setup_registers_() { return false; } - this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; - this->calibration_.t2 = coeff1[2] << 8 | coeff1[1]; + this->calibration_.t1 = encode_value_le(&coeff2[8]); + this->calibration_.t2 = encode_value_le(&coeff1[1]); this->calibration_.t3 = coeff1[3]; this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F); @@ -104,19 +105,19 @@ bool BME680Component::setup_registers_() { this->calibration_.h6 = coeff2[6]; this->calibration_.h7 = coeff2[7]; - this->calibration_.p1 = coeff1[6] << 8 | coeff1[5]; - this->calibration_.p2 = coeff1[8] << 8 | coeff1[7]; + this->calibration_.p1 = encode_value_le(&coeff1[5]); + this->calibration_.p2 = encode_value_le(&coeff1[7]); this->calibration_.p3 = coeff1[9]; - this->calibration_.p4 = coeff1[12] << 8 | coeff1[11]; - this->calibration_.p5 = coeff1[14] << 8 | coeff1[13]; + this->calibration_.p4 = encode_value_le(&coeff1[11]); + this->calibration_.p5 = encode_value_le(&coeff1[13]); this->calibration_.p6 = coeff1[16]; this->calibration_.p7 = coeff1[15]; - this->calibration_.p8 = coeff1[20] << 8 | coeff1[19]; - this->calibration_.p9 = coeff1[22] << 8 | coeff1[21]; + this->calibration_.p8 = encode_value_le(&coeff1[19]); + this->calibration_.p9 = encode_value_le(&coeff1[21]); this->calibration_.p10 = coeff1[23]; this->calibration_.gh1 = coeff2[14]; - this->calibration_.gh2 = coeff2[12] << 8 | coeff2[13]; + this->calibration_.gh2 = encode_value(&coeff2[12]); this->calibration_.gh3 = coeff2[15]; uint8_t temp_var = 0; @@ -283,7 +284,7 @@ uint8_t BME680Component::calc_heater_duration_(uint16_t duration) { } void BME680Component::read_data_() { uint8_t data[15]; - if (!this->read_bytes(BME680_REGISTER_FIELD0, data, 15)) { + if (!this->status_comm_result(this->read_bytes(BME680_REGISTER_FIELD0, data, 15))) { if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(NAN); if (this->pressure_sensor_ != nullptr) @@ -293,10 +294,8 @@ void BME680Component::read_data_() { if (this->gas_resistance_sensor_ != nullptr) this->gas_resistance_sensor_->publish_state(NAN); ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); return; } - this->status_clear_warning(); uint32_t raw_temperature = (uint32_t(data[5]) << 12) | (uint32_t(data[6]) << 4) | (uint32_t(data[7]) >> 4); uint32_t raw_pressure = (uint32_t(data[2]) << 12) | (uint32_t(data[3]) << 4) | (uint32_t(data[4]) >> 4); diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 3f0082e1af7..fe24ba88754 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -191,11 +191,7 @@ void BME680BSECComponent::loop() { } else { this->status_clear_error(); } - if (this->bsec_status_ > BSEC_OK || this->bme680_status_ > BME680_OK) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(this->bsec_status_ <= BSEC_OK && this->bme680_status_ <= BME680_OK, nullptr); // Process a single action from the queue. These are primarily sensor state publishes // that in totality take too long to send in a single call. diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f5a674d6b92..1be5c3835e4 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -16,6 +17,7 @@ namespace esphome::bme68x_bsec2 { ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); static constexpr uint32_t READ_TIMEOUT_ID = 0; +static constexpr uint32_t RUN_TIMEOUT_ID = 1; static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; @@ -61,7 +63,9 @@ void BME68xBSEC2Component::setup() { return; } + this->disable_loop(); this->load_state_(); + this->run_(); } void BME68xBSEC2Component::dump_config() { @@ -115,26 +119,25 @@ void BME68xBSEC2Component::dump_config() { #endif } -void BME68xBSEC2Component::loop() { - this->run_(); - +void BME68xBSEC2Component::check_status_() { if (this->bsec_status_ < BSEC_OK || this->bme68x_status_ < BME68X_OK) { this->status_set_error(); } else { this->status_clear_error(); } const bool has_bme68x_warning = this->bme68x_status_ > BME68X_OK && !is_no_new_data_warning(this->bme68x_status_); - if (this->bsec_status_ > BSEC_OK || has_bme68x_warning) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(this->bsec_status_ <= BSEC_OK && !has_bme68x_warning, nullptr); +} + +void BME68xBSEC2Component::loop() { // Process a single action from the queue. These are primarily sensor state publishes // that in totality take too long to send in a single call. if (this->queue_.size()) { auto action = std::move(this->queue_.front()); this->queue_.pop(); action(); + } else { + this->disable_loop(); } } @@ -219,15 +222,15 @@ void BME68xBSEC2Component::update_subscription_() { void BME68xBSEC2Component::run_() { this->op_mode_ = this->bsec_settings_.op_mode; int64_t curr_time_ns = this->get_time_ns_(); - if (curr_time_ns < this->bsec_settings_.next_call) { - return; - } ESP_LOGV(TAG, "Performing sensor run"); struct bme68x_conf bme68x_conf; this->bsec_status_ = bsec_sensor_control_m(&this->bsec_instance_, curr_time_ns, &this->bsec_settings_); if (this->bsec_status_ < BSEC_OK) { ESP_LOGW(TAG, "Fetching control settings failed (BSEC2 error code %d)", this->bsec_status_); + this->check_status_(); + // schedule next run after short delay + this->set_timeout(RUN_TIMEOUT_ID, 50, [this]() { this->run_(); }); return; } @@ -287,12 +290,25 @@ void BME68xBSEC2Component::run_() { uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { + this->read_(this->trigger_time_ns_); + this->check_status_(); + }); } else { ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { + this->read_(this->trigger_time_ns_); + this->check_status_(); + }); } + + // schedule next run + int64_t delay_ns = this->bsec_settings_.next_call - this->get_time_ns_(); + uint32_t delay_ms = delay_ns > 0 ? static_cast((delay_ns + 999999) / 1000000) : 1; + ESP_LOGV(TAG, "Next run in: %" PRIu32 "ms", delay_ms); + this->set_timeout(RUN_TIMEOUT_ID, delay_ms, [this]() { this->run_(); }); + this->check_status_(); } void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { @@ -454,6 +470,7 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou // Queue up an opportunity to save state this->queue_push_([this, max_accuracy]() { this->save_state_(max_accuracy); }); } + this->enable_loop(); } int64_t BME68xBSEC2Component::get_time_ns_() { @@ -496,13 +513,15 @@ void BME68xBSEC2Component::load_state_() { bsec_set_state_m(&this->bsec_instance_, state, BSEC_MAX_STATE_BLOB_SIZE, work_buffer, sizeof(work_buffer)); if (this->bsec_status_ != BSEC_OK) { ESP_LOGW(TAG, "Failed to load state (BSEC2 error code %d)", this->bsec_status_); + // no need to set error here } ESP_LOGI(TAG, "Loaded state"); } } void BME68xBSEC2Component::save_state_(uint8_t accuracy) { - if (accuracy < 3 || (millis() - this->last_state_save_ms_ < this->state_save_interval_ms_)) { + if (accuracy < 3 || + (App.get_loop_component_start_time() - this->last_state_save_ms_ < this->state_save_interval_ms_)) { return; } @@ -516,6 +535,7 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { BSEC_MAX_STATE_BLOB_SIZE, &num_serialized_state); if (this->bsec_status_ != BSEC_OK) { ESP_LOGW(TAG, "Failed fetch state for save (BSEC2 error code %d)", this->bsec_status_); + this->check_status_(); return; } @@ -523,7 +543,7 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { ESP_LOGW(TAG, "Failed to save state"); return; } - this->last_state_save_ms_ = millis(); + this->last_state_save_ms_ = App.get_loop_component_start_time(); ESP_LOGI(TAG, "Saved state"); } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index 9317229a1fb..9e1e46c8043 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -91,6 +91,7 @@ class BME68xBSEC2Component : public Component { void read_(int64_t trigger_time_ns); void publish_(const bsec_output_t *outputs, uint8_t num_outputs); int64_t get_time_ns_(); + void check_status_(); #ifdef USE_SENSOR void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false); diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 0e010b03118..cc233f4978b 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -314,9 +314,9 @@ bool BMI270Component::update_data(motion::MotionData &data) { }; float scale = ACCEL_SCALE[this->accel_range_]; - data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; - data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; - data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + data.acceleration[motion::X_AXIS] = encode_value_le(raw_data) * scale; + data.acceleration[motion::Y_AXIS] = encode_value_le(&raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = encode_value_le(&raw_data[4]) * scale; // Gyroscope: registers 0x12–0x17 (6 bytes) // Scale: full-scale range / 2^15 @@ -326,15 +326,15 @@ bool BMI270Component::update_data(motion::MotionData &data) { static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; scale = GYRO_SCALE[this->gyro_range_]; - data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; - data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; - data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; + data.angular_rate[motion::X_AXIS] = encode_value_le(&raw_data[GYR_OFFS]) * scale; + data.angular_rate[motion::Y_AXIS] = encode_value_le(&raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = encode_value_le(&raw_data[GYR_OFFS + 4]) * scale; if (!this->temperature_callback_.empty()) { // Temperature: registers 0x22–0x23 // Formula from datasheet: T[°C] = raw / 512 + 23 static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + int16_t raw_t = encode_value_le(&raw_data[TEMP_OFFS]); float temperature = (raw_t / 512.0f) + 23.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 10be0585210..69c4ca5e931 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -1,5 +1,6 @@ #include "bmp280_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bmp280_base { @@ -176,7 +177,7 @@ float BMP280Component::read_temperature_(int32_t *t_fine) { if (!this->bmp_read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) return NAN; ESP_LOGV(TAG, "Read temperature data, raw: %02X %02X %02X", data[0], data[1], data[2]); - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // temperature was disabled @@ -199,7 +200,7 @@ float BMP280Component::read_pressure_(int32_t t_fine) { uint8_t data[3]; if (!this->bmp_read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) return NAN; - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // pressure was disabled diff --git a/esphome/components/bp1658cj/output.py b/esphome/components/bp1658cj/output.py index 93e3c75daf9..ca9c94f733b 100644 --- a/esphome/components/bp1658cj/output.py +++ b/esphome/components/bp1658cj/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_BP1658CJ_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BP1658CJ_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/bp5758d/output.py b/esphome/components/bp5758d/output.py index bbca7c18cc5..24fe8a04e50 100644 --- a/esphome/components/bp5758d/output.py +++ b/esphome/components/bp5758d/output.py @@ -25,7 +25,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_BP5758D_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BP5758D_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) cg.add(var.set_current(config[CONF_CURRENT])) diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index 3c78893437d..a7caca62233 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -57,11 +57,7 @@ void BQ27220Component::update() { [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); - } + this->status_comm_result(ok, LOG_STR("Failed to read one or more registers from BQ27220")); } void BQ27220Component::dump_config() { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index 59d18878370..c27b56dc50d 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -292,11 +292,7 @@ void BQ76972Component::update() { ok = false; } } - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - } + this->status_comm_result(ok); } #else void BQ76972Component::update() {} diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index c055c430413..b37d2782d56 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -408,7 +408,7 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } case 0x02: { // temperature if (this->temperature_ != nullptr) { - const int16_t raw = encode_uint16(value[1], value[0]); + const int16_t raw = encode_value_le(value); this->temperature_->publish_state(raw * 0.01f); reported = true; } @@ -424,7 +424,7 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T if (this->temperature_ != nullptr) { - const int16_t raw = encode_uint16(value[1], value[0]); + const int16_t raw = encode_value_le(value); this->temperature_->publish_state(raw * 0.1f); reported = true; } diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 80948a6b433..703ae619c5d 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -72,9 +72,5 @@ async def to_code(config: ConfigType) -> None: if (baseline := config.get(CONF_BASELINE)) is not None: cg.add(var.set_baseline(baseline)) - if temperature_id := config.get(CONF_TEMPERATURE): - sens = await cg.get_variable(temperature_id) - cg.add(var.set_temperature(sens)) - if humidity_id := config.get(CONF_HUMIDITY): - sens = await cg.get_variable(humidity_id) - cg.add(var.set_humidity(sens)) + await cg.add_variable(var.set_temperature, config.get(CONF_TEMPERATURE)) + await cg.add_variable(var.set_humidity, config.get(CONF_HUMIDITY)) diff --git a/esphome/components/cd74hc4067/sensor.py b/esphome/components/cd74hc4067/sensor.py index 670b0502715..aac921178e5 100644 --- a/esphome/components/cd74hc4067/sensor.py +++ b/esphome/components/cd74hc4067/sensor.py @@ -53,5 +53,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_pin(config[CONF_NUMBER])) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_source(sens)) + await cg.add_variable(var.set_source, config[CONF_SENSOR]) diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 5110503bf84..107a7bb871d 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_I2C_ID, CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OPEN_DRAIN, @@ -18,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesterret", "@clydebarrow"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] DOMAIN = "ch422g" MULTI_CONF = True ch422g_ns = cg.esphome_ns.namespace("ch422g") @@ -42,8 +42,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) # This is used as a final validation step so that modes have been fully transformed. @@ -67,13 +66,10 @@ CH422G_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH422G, CH422G_PIN_SCHEMA, pin_mode_check) async def ch422g_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = await pins.new_parented_pin(config, CONF_CH422G) + # The parent collects every pin's mode before its setup() writes the mode register parent = await cg.get_variable(config[CONF_CH422G]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + cg.add( + parent.pin_mode(config[CONF_NUMBER], pins.gpio_flags_expr(config[CONF_MODE])) + ) return var diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index e76801ff77a..b6496ffa2c7 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -120,16 +120,6 @@ bool CH422GComponent::write_outputs_() { float CH422GComponent::get_setup_priority() const { return setup_priority::IO; } -void CH422GGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void CH422GGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -size_t CH422GGPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "EXIO%u via CH422G", this->pin_); -} -void CH422GGPIOPin::set_flags(gpio::Flags flags) { - flags_ = flags; - this->parent_->pin_mode(this->pin_, flags); -} - } // namespace esphome::ch422g + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::ch422g::CH422GComponent, "EXIO%u via CH422G") diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index a8729225e80..53b0d035e1a 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" @@ -42,26 +43,8 @@ class CH422GComponent final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin final : public GPIOPin { - 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_parent(CH422GComponent *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - CH422GComponent *parent_{}; - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using CH422GGPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::ch422g + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::ch422g::CH422GComponent); diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 3997f9581f0..2e51d18d075 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_I2C_ID, CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OPEN_DRAIN, @@ -19,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@dwmw2"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] DOMAIN = "ch423" MULTI_CONF = True ch423_ns = cg.esphome_ns.namespace("ch423") @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) # This is used as a final validation step so that modes have been fully transformed. @@ -94,13 +93,10 @@ CH423_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH423, CH423_PIN_SCHEMA, pin_mode_check) async def ch423_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = await pins.new_parented_pin(config, CONF_CH423) + # The parent collects every pin's mode before its setup() writes the mode register parent = await cg.get_variable(config[CONF_CH423]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + cg.add( + parent.pin_mode(config[CONF_NUMBER], pins.gpio_flags_expr(config[CONF_MODE])) + ) return var diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 3a57f646d4e..2ad859f9b37 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -123,16 +123,6 @@ bool CH423Component::write_outputs_() { float CH423Component::get_setup_priority() const { return setup_priority::IO; } -void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void CH423GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -size_t CH423GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "EXIO%u via CH423", this->pin_); -} -void CH423GPIOPin::set_flags(gpio::Flags flags) { - flags_ = flags; - this->parent_->pin_mode(this->pin_, flags); -} - } // namespace esphome::ch423 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::ch423::CH423Component, "EXIO%u via CH423") diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index fbfffb521d2..38fcb1ed495 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" @@ -41,26 +42,8 @@ class CH423Component final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin final : public GPIOPin { - 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_parent(CH423Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - CH423Component *parent_{}; - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using CH423GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::ch423 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::ch423::CH423Component); diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index f4b43e3a523..27c24464595 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -74,12 +74,8 @@ async def register_climate_ir(var: MockObj, config: ConfigType) -> None: cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) - if sensor_id := config.get(CONF_SENSOR): - sens = await cg.get_variable(sensor_id) - cg.add(var.set_sensor(sens)) - if sensor_id := config.get(CONF_HUMIDITY_SENSOR): - sens = await cg.get_variable(sensor_id) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) async def new_climate_ir(config: ConfigType, *args: SafeExpType) -> MockObj: diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 8739e6c0805..1b51030b162 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -101,15 +101,13 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co uint8_t response[4] = {0}; if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { ESP_LOGW(TAG, "Command %02X failed", command[2]); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } - if (memcmp(response, ack, sizeof(response)) != 0) { + if (!this->status_comm_result(memcmp(response, ack, sizeof(response)) == 0)) { ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); - this->status_set_warning(); return false; } - this->status_clear_warning(); return true; } diff --git a/esphome/components/color_temperature/light.py b/esphome/components/color_temperature/light.py index 7686ede1558..f66b51eff4e 100644 --- a/esphome/components/color_temperature/light.py +++ b/esphome/components/color_temperature/light.py @@ -33,11 +33,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - color_temperature = await cg.get_variable(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature(color_temperature)) - - brightness = await cg.get_variable(config[CONF_BRIGHTNESS]) - cg.add(var.set_brightness(brightness)) + await cg.add_variable(var.set_color_temperature, config[CONF_COLOR_TEMPERATURE]) + await cg.add_variable(var.set_brightness, config[CONF_BRIGHTNESS]) cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 966db92c6fd..a15df707e60 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -47,6 +47,7 @@ CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_MANUFACTURER = "manufacturer" +CONF_MAX_PACKET_SIZE = "max_packet_size" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" @@ -55,10 +56,12 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" +CONF_PROVIDER = "provider" CONF_QUERY_PARAMS = "query_params" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_RED_CHANNEL = "red_channel" +CONF_REMOTE_ID = "remote_id" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" @@ -70,6 +73,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VALUE_TYPE = "value_type" CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index cc8492f21e0..e21743ef296 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 768131bbe58..17155b2f744 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: await button.register_button(var, config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/cover/__init__.py b/esphome/components/copy/cover/__init__.py index d23602fa741..7d01cd93ac2 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -36,5 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/fan/__init__.py b/esphome/components/copy/fan/__init__.py index ffa414c5f21..44c0d654073 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 8d9c4b6eca7..5f2f8f2eb1e 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index 9659a605f91..c0370afc18c 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index 97776b1edd7..e6a4091be8f 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: await select.register_select(var, config, options=[]) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 54687980475..746a48e19ce 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -42,5 +42,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index 0e714540f96..6bcd10ff200 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -36,5 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py index 59fdce6c96e..44d61a88506 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 146beae5eaa..9c5495938e0 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py index 0f36b1721bf..0c3766cbe7c 100644 --- a/esphome/components/counter/sensor.py +++ b/esphome/components/counter/sensor.py @@ -55,10 +55,8 @@ async def to_code(config): ) await cg.register_component(var, config) await sensor.register_sensor(var, config) - if (source := config.get(CONF_SENSOR)) is not None: - cg.add(var.count_updates_from(await cg.get_variable(source))) - if (source := config.get(CONF_BINARY_SENSOR)) is not None: - cg.add(var.count_true_from(await cg.get_variable(source))) + await cg.add_variable(var.count_updates_from, config.get(CONF_SENSOR)) + await cg.add_variable(var.count_true_from, config.get(CONF_BINARY_SENSOR)) automation.register_apply_action( diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp index 849cff4a49e..7f98051d2bc 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp @@ -20,12 +20,10 @@ void CST226Touchscreen::setup() { void CST226Touchscreen::update_touches() { uint8_t data[28]; - if (!this->read_bytes(CST226_REG_STATUS, data, sizeof data)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes(CST226_REG_STATUS, data, sizeof data))) { this->skip_update_ = true; return; } - this->status_clear_warning(); if (data[0] == 0x83 && data[1] == 0x17 && data[5] == 0x80) { this->update_button_state_(true); return; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 72803804335..6d869ec5ea7 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -87,12 +87,10 @@ void CST9220Touchscreen::update_touches() { // Only an actual I2C failure should skip the update; a successful read with no // touches is a real "all fingers lifted" state that must flow through so the // base class can generate the release event. - if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) { this->skip_update_ = true; return; } - this->status_clear_warning(); // Acknowledge the report so the controller can prepare the next one. uint8_t ack = TOUCH_ACK; diff --git a/esphome/components/ct_clamp/sensor.py b/esphome/components/ct_clamp/sensor.py index 8ef211cb247..b9959d65f72 100644 --- a/esphome/components/ct_clamp/sensor.py +++ b/esphome/components/ct_clamp/sensor.py @@ -41,6 +41,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_source(sens)) + await cg.add_variable(var.set_source, config[CONF_SENSOR]) cg.add(var.set_sample_duration(config[CONF_SAMPLE_DURATION])) diff --git a/esphome/components/current_based/cover.py b/esphome/components/current_based/cover.py index a552956082a..4edcb98a1d9 100644 --- a/esphome/components/current_based/cover.py +++ b/esphome/components/current_based/cover.py @@ -77,8 +77,7 @@ async def to_code(config: ConfigType) -> None: ) # OPEN - bin = await cg.get_variable(config[CONF_OPEN_SENSOR]) - cg.add(var.set_open_sensor(bin)) + await cg.add_variable(var.set_open_sensor, config[CONF_OPEN_SENSOR]) cg.add( var.set_open_moving_current_threshold( config[CONF_OPEN_MOVING_CURRENT_THRESHOLD] @@ -97,8 +96,7 @@ async def to_code(config: ConfigType) -> None: ) # CLOSE - bin = await cg.get_variable(config[CONF_CLOSE_SENSOR]) - cg.add(var.set_close_sensor(bin)) + await cg.add_variable(var.set_close_sensor, config[CONF_CLOSE_SENSOR]) cg.add( var.set_close_moving_current_threshold( config[CONF_CLOSE_MOVING_CURRENT_THRESHOLD] diff --git a/esphome/components/cwww/light.py b/esphome/components/cwww/light.py index 90fe6d0bad8..3ea6a85923a 100644 --- a/esphome/components/cwww/light.py +++ b/esphome/components/cwww/light.py @@ -36,13 +36,11 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - cwhite = await cg.get_variable(config[CONF_COLD_WHITE]) - cg.add(var.set_cold_white(cwhite)) + await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) if cold_white_color_temperature := config.get(CONF_COLD_WHITE_COLOR_TEMPERATURE): cg.add(var.set_cold_white_temperature(cold_white_color_temperature)) - wwhite = await cg.get_variable(config[CONF_WARM_WHITE]) - cg.add(var.set_warm_white(wwhite)) + await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) if warm_white_color_temperature := config.get(CONF_WARM_WHITE_COLOR_TEMPERATURE): cg.add(var.set_warm_white_temperature(warm_white_color_temperature)) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 45101e4981f..4514624c75c 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -1,4 +1,5 @@ #include "dallas_temp.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::dallas_temp { @@ -136,7 +137,7 @@ bool DallasTemperatureSensor::check_scratch_pad_() { } float DallasTemperatureSensor::get_temp_c_() { - int16_t temp = (this->scratch_pad_[1] << 8) | this->scratch_pad_[0]; + int16_t temp = encode_value_le(this->scratch_pad_); if ((this->address_ & 0xff) == DALLAS_MODEL_DS18S20) { if (this->scratch_pad_[7] == 0) { return NAN; diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 04ef798b69e..422f6f1350b 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -42,25 +42,26 @@ void DalyBmsComponent::loop() { this->last_transmission_ = now; this->trigger_next_ = true; } - if (available()) + const size_t avail = this->available(); + if (avail > 0) this->last_transmission_ = now; - while (available()) { - uint8_t c; - read_byte(&c); - if (!this->receiving_) { - if (c != 0xa5) - continue; - this->receiving_ = true; - } - this->data_.push_back(c); - if (this->data_.size() == 4) - this->data_count_ = c; - if ((this->data_.size() > 4) and (data_.size() == this->data_count_ + 5)) { - this->decode_data_(this->data_); - this->data_.clear(); - this->receiving_ = false; - } - } + this->read_available( + [this](uint8_t c) { + if (!this->receiving_) { + if (c != 0xa5) + return; + this->receiving_ = true; + } + this->data_.push_back(c); + if (this->data_.size() == 4) + this->data_count_ = c; + if ((this->data_.size() > 4) and (data_.size() == this->data_count_ + 5)) { + this->decode_data_(this->data_); + this->data_.clear(); + this->receiving_ = false; + } + }, + avail); if (this->trigger_next_) { this->trigger_next_ = false; diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 20fd258cb00..e9ea05dcdab 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -148,9 +148,7 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: forward=[automation.parent_ref(var).state_as_esptime()], ) - if CONF_TIME_ID in config: - rtc = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_rtc(rtc)) + await cg.add_variable(var.set_rtc, config.get(CONF_TIME_ID)) for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 7230b1e8e9c..69aa1120ba1 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -491,8 +491,7 @@ async def deep_sleep_enter_to_code( until = config[CONF_UNTIL] cg.add(var.set_until(until[CONF_HOUR], until[CONF_MINUTE], until[CONF_SECOND])) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) return var diff --git a/esphome/components/dew_point/sensor.py b/esphome/components/dew_point/sensor.py index 555fdef289e..e7361c8dbfe 100644 --- a/esphome/components/dew_point/sensor.py +++ b/esphome/components/dew_point/sensor.py @@ -40,8 +40,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(temperature_sensor)) - - humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(humidity_sensor)) + await cg.add_variable(var.set_temperature_sensor, config[CONF_TEMPERATURE]) + await cg.add_variable(var.set_humidity_sensor, config[CONF_HUMIDITY]) diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp index 78267d5ff9e..5fffd71fe91 100644 --- a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp @@ -255,11 +255,7 @@ void DFRobotSEN0465Component::handle_data_(const uint8_t *frame) { if (this->concentration_sensor_ != nullptr) this->concentration_sensor_->publish_state(concentration); - if (std::isnan(concentration) || std::isnan(temperature) || this->setup_incomplete_) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(!std::isnan(concentration) && !std::isnan(temperature) && !this->setup_incomplete_, nullptr); } #ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 791242cda12..ba51bdccaae 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -280,15 +280,12 @@ async def menu_item_to_code(menu, config, parent): if CONF_IMMEDIATE_EDIT in config: cg.add(item.set_immediate_edit(config[CONF_IMMEDIATE_EDIT])) if config[CONF_TYPE] == CONF_SELECT: - var = await cg.get_variable(config[CONF_SELECT]) - cg.add(item.set_select_variable(var)) + await cg.add_variable(item.set_select_variable, config[CONF_SELECT]) if config[CONF_TYPE] == CONF_NUMBER: - var = await cg.get_variable(config[CONF_NUMBER]) - cg.add(item.set_number_variable(var)) + await cg.add_variable(item.set_number_variable, config[CONF_NUMBER]) cg.add(item.set_format(config[CONF_FORMAT])) if config[CONF_TYPE] == CONF_SWITCH: - var = await cg.get_variable(config[CONF_SWITCH]) - cg.add(item.set_switch_variable(var)) + await cg.add_variable(item.set_switch_variable, config[CONF_SWITCH]) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) await _build_item_automations(item, config) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index eed702b6f1a..4520d4e212d 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -3,14 +3,9 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart +from esphome.components.const import CONF_PROVIDER import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_NAME, - CONF_PATTERN, - CONF_PRIORITY, - CONF_RECEIVE_TIMEOUT, -) +from esphome.const import CONF_NAME, CONF_PATTERN, CONF_PRIORITY, CONF_RECEIVE_TIMEOUT from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -26,7 +21,6 @@ CONF_OBIS_CODE = "obis_code" CONF_CUSTOM_PATTERNS = "custom_patterns" CONF_SKIP_CRC = "skip_crc" CONF_DEFAULT_OBIS = "default_obis" -CONF_PROVIDER = "provider" DLMS_PARSER_VERSION = "2.2.0" @@ -146,8 +140,8 @@ async def to_code(config: ConfigType) -> None: for pattern in config.get(CONF_CUSTOM_PATTERNS, []) ] - var = cg.new_Pvariable( - config[CONF_ID], + await uart.new_uart_component( + config, config[CONF_RECEIVE_TIMEOUT], config[CONF_SKIP_CRC], config.get(CONF_DECRYPTION_KEY, cg.nullptr), @@ -155,9 +149,6 @@ async def to_code(config: ConfigType) -> None: cg.ArrayInitializer(*custom_patterns, multiline=True), ) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - if CORE.is_esp32: esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION) else: diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 5b3272b1d5e..9241a825549 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -58,26 +58,16 @@ bool DPS310Component::setup_registers_() { return false; } - this->c0_ = // we only ever use c0/2, so just divide by 2 here to save time later - DPS310Component::twos_complement( - int16_t(((uint16_t) coef_data_raw[0] << 4) | (((uint16_t) coef_data_raw[1] >> 4) & 0x0F)), 12) / - 2; - - this->c1_ = - DPS310Component::twos_complement(int16_t((((uint16_t) coef_data_raw[1] & 0x0F) << 8) | coef_data_raw[2]), 12); - - this->c00_ = ((uint32_t) coef_data_raw[3] << 12) | ((uint32_t) coef_data_raw[4] << 4) | - (((uint32_t) coef_data_raw[5] >> 4) & 0x0F); - this->c00_ = DPS310Component::twos_complement(c00_, 20); - - this->c10_ = encode_uint24(coef_data_raw[5] & 0x0F, coef_data_raw[6], coef_data_raw[7]); - this->c10_ = DPS310Component::twos_complement(c10_, 20); - - this->c01_ = int16_t(((uint16_t) coef_data_raw[8] << 8) | (uint16_t) coef_data_raw[9]); - this->c11_ = int16_t(((uint16_t) coef_data_raw[10] << 8) | (uint16_t) coef_data_raw[11]); - this->c20_ = int16_t(((uint16_t) coef_data_raw[12] << 8) | (uint16_t) coef_data_raw[13]); - this->c21_ = int16_t(((uint16_t) coef_data_raw[14] << 8) | (uint16_t) coef_data_raw[15]); - this->c30_ = int16_t(((uint16_t) coef_data_raw[16] << 8) | (uint16_t) coef_data_raw[17]); + // we only ever use c0/2, so just divide by 2 here to save time later + this->c0_ = sign_extend<12>(encode_uint16(coef_data_raw[0], coef_data_raw[1]) >> 4) / 2; + this->c1_ = sign_extend<12>(encode_uint16(coef_data_raw[1], coef_data_raw[2])); + this->c00_ = sign_extend<20>(encode_uint24(coef_data_raw[3], coef_data_raw[4], coef_data_raw[5]) >> 4); + this->c10_ = sign_extend<20>(encode_uint24(coef_data_raw[5], coef_data_raw[6], coef_data_raw[7])); + this->c01_ = encode_value(&coef_data_raw[8]); + this->c11_ = encode_value(&coef_data_raw[10]); + this->c20_ = encode_value(&coef_data_raw[12]); + this->c21_ = encode_value(&coef_data_raw[14]); + this->c30_ = encode_value(&coef_data_raw[16]); return true; } @@ -133,7 +123,7 @@ void DPS310Component::read_pressure_() { return; } this->got_pres_ = true; - this->raw_pressure_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); + this->raw_pressure_ = sign_extend<24>(encode_uint24(bytes[0], bytes[1], bytes[2])); } void DPS310Component::read_temperature_() { @@ -143,7 +133,7 @@ void DPS310Component::read_temperature_() { return; } this->got_temp_ = true; - this->raw_temperature_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); + this->raw_temperature_ = sign_extend<24>(encode_uint24(bytes[0], bytes[1], bytes[2])); } // Calculations are taken from the datasheet which can be found here: @@ -168,11 +158,4 @@ void DPS310Component::calculate_values_(int32_t raw_temperature, int32_t raw_pre } } -int32_t DPS310Component::twos_complement(int32_t val, uint8_t bits) { - if (val & ((uint32_t) 1 << (bits - 1))) { - val -= (uint32_t) 1 << bits; - } - return val; -} - } // namespace esphome::dps310 diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index d761940ca99..a05a5ec056c 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -51,7 +51,6 @@ class DPS310Component final : public PollingComponent, public i2c::I2CDevice { void read_pressure_(); void read_temperature_(); void calculate_values_(int32_t raw_temperature, int32_t raw_pressure); - static int32_t twos_complement(int32_t val, uint8_t bits); sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 7a15283f3b7..962bd3605fb 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -2,7 +2,6 @@ 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 = ["@badbadc0ffee"] @@ -40,8 +39,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index b0289581329..1b142b41690 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -52,6 +52,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_DS248X_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_DS248X_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp index 72cae0e3832..1033ddfb454 100644 --- a/esphome/components/ds3231/ds3231.cpp +++ b/esphome/components/ds3231/ds3231.cpp @@ -1,4 +1,5 @@ #include "ds3231.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" // Datasheet: @@ -37,7 +38,7 @@ bool DS3231Component::read_temperature(float &out) { return false; } // 10-bit two's complement, 0.25 degC per LSB. - int16_t value = static_cast((raw[0] << 8) | raw[1]) >> 6; + int16_t value = encode_value(raw) >> 6; out = value * 0.25f; return true; } diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp index 8d7e1156be4..569f24cab5e 100644 --- a/esphome/components/ds3231/sensor/ds3231_temperature.cpp +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -7,11 +7,8 @@ ESPHOME_LOG_TAG(TAG, "ds3231.sensor"); void DS3231TemperatureSensor::update() { float temperature; - if (!this->parent_->read_temperature(temperature)) { - this->status_set_warning(); + if (!this->status_comm_result(this->parent_->read_temperature(temperature))) return; - } - this->status_clear_warning(); this->publish_state(temperature); } diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 6b035d5c982..1f733fc6bb1 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -57,9 +57,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) - if CONF_SENSOR in config: - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_) cg.add(var.set_lambda(lambda_)) diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index 8a0e1654188..9cb9bc0e970 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -6,7 +6,7 @@ from esphome.components.esp32_ble import ( CONF_MAX_KEY_SIZE, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TIME_ID +from esphome.const import CONF_TIME_ID from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -50,11 +50,8 @@ async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) - if (time_id := config.get(CONF_TIME_ID)) is not None: - cg.add(var.set_time(await cg.get_variable(time_id))) + var = await ble_client.new_ble_node_component(config) + await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) def _final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 223648e3276..dcabf6c75c5 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -235,7 +235,7 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); return; } - const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; + const float temperature = encode_value(&data[3]) / 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: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 888b12f4bf4..1992f36d328 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -111,8 +111,7 @@ void EmonTx::loop() { // Drop what arrived during the pause and resync at the next line start this->was_paused_ = false; uint8_t dropped = 0; - while (this->available() > 0 && this->read_byte(&dropped)) { - } + this->read_available([&dropped](uint8_t byte) { dropped = byte; }); if (dropped == '\n') { this->line_reader_.reset(); } else { @@ -126,16 +125,12 @@ void EmonTx::loop() { } // Read all available data to prevent UART buffer overflow - while (this->available() > 0) { - uint8_t received; - if (!this->read_byte(&received)) - break; - + this->read_available([this](uint8_t received) { size_t len; char *line = this->line_reader_.feed(received, len); this->line_reader_.log_if_overflowed(TAG); if (line == nullptr || len == 0) { - continue; + return; } ESP_LOGD(TAG, "Received line: %s", line); this->data_callbacks_.call(StringRef(line, len)); @@ -149,7 +144,7 @@ void EmonTx::loop() { this->parse_version_line_(line, len); } #endif - } + }); } void EmonTx::parse_json_(const char *data, size_t len) { diff --git a/esphome/components/endstop/cover.py b/esphome/components/endstop/cover.py index 0e27189500c..066326e9d46 100644 --- a/esphome/components/endstop/cover.py +++ b/esphome/components/endstop/cover.py @@ -43,15 +43,13 @@ async def to_code(config: ConfigType) -> None: var.get_stop_trigger(), [], config[CONF_STOP_ACTION] ) - bin = await cg.get_variable(config[CONF_OPEN_ENDSTOP]) - cg.add(var.set_open_endstop(bin)) + await cg.add_variable(var.set_open_endstop, config[CONF_OPEN_ENDSTOP]) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) await automation.build_automation( var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) - bin = await cg.get_variable(config[CONF_CLOSE_ENDSTOP]) - cg.add(var.set_close_endstop(bin)) + await cg.add_variable(var.set_close_endstop, config[CONF_CLOSE_ENDSTOP]) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 8ef5971f17a..61342cbe5c0 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -70,9 +70,9 @@ async def to_code_base(config: ConfigType) -> MockObj: await sensors(CONF_AQI, var.set_aqi) if compensation_config := config.get(CONF_COMPENSATION): - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) + await cg.add_variable( + var.set_temperature, compensation_config[CONF_TEMPERATURE] + ) + await cg.add_variable(var.set_humidity, compensation_config[CONF_HUMIDITY]) return var diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 75a3426f162..35bb99ed872 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -245,11 +245,7 @@ async def to_code(config): await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index fccb5325a7f..488a068b0ff 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -397,7 +397,7 @@ void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_widt const Rgb565ToMono &to_mono) { const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; for (; count != 0; count--, source += 2) { - const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + const uint16_t value = BigEndianSource ? encode_uint16(source[0], source[1]) : encode_value_le(source); if (to_mono(value)) { *byte |= mask; } else { diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp index 004597b72bd..eecb1882613 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -20,7 +20,7 @@ static BwrState color_to_bwr(Color color) { return BwrState::BWR_BLACK; } -// UC8179 3-color display buffer layout: +// 3-color display buffer layout (UC8179 and UC8151D): // - 1 bit per pixel, 8 pixels per byte // - Buffer first half: Black/White plane (1=black, 0=white) // - Buffer second half: Red plane (1=red, 0=white) @@ -79,7 +79,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) - // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + // DTM1 (0x10): inverted to get 0=black, 1=white if (this->current_data_index_ < half_buffer) { if (this->current_data_index_ == 0) { this->command(0x10); // DATA START TRANSMISSION 1 (black channel) @@ -101,7 +101,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { } // Phase 2: send Red plane (second half) via command 0x13 (DTM2) - // UC8179 DTM2 (0x13): 1=red, 0=white + // DTM2 (0x13): 1=red, 0=white; on the UC8151D this relies on the 0x50 value in uc8151.py if (this->current_data_index_ < buffer_length) { if (this->current_data_index_ == half_buffer) { this->command(0x13); // DATA START TRANSMISSION 2 (red channel) @@ -127,8 +127,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { } void EPaperWaveshareBWR::power_on() { - this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING - this->command(0x04); // POWER ON + this->command(0x04); // POWER ON } void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index d34f05cbb2b..f5cbb5fde47 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -5,8 +5,8 @@ namespace esphome::epaper_spi { /** - * Waveshare 3-color e-paper displays (UC8179 controller). - * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * Waveshare 3-color e-paper displays (UC8179 and UC8151D controllers). + * Supports: 7.5" V2 BWR (EPD_7in5b_V2), 800x480 pixels; 2.13" B V3 (EPD_2in13b_V3), 104x212 pixels. * * Color scheme: Black, White, Red (BWR) * Buffer layout: 1 bit per pixel, separate planes @@ -14,8 +14,8 @@ namespace esphome::epaper_spi { * - Buffer second half: Red plane (1=red, 0=no red) * - Total buffer: width * height / 4 bytes (2 * width * height / 8) * - * The init sequence (INITIALISE state) sends panel configuration only. - * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * The init sequence (INITIALISE state) sends the power and panel configuration. + * Power-on (0x04) is sent in the POWER_ON state after data transfer; * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). */ class EPaperWaveshareBWR : public EPaperBase { diff --git a/esphome/components/epaper_spi/models/uc8151.py b/esphome/components/epaper_spi/models/uc8151.py new file mode 100644 index 00000000000..49d0d6818b4 --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8151.py @@ -0,0 +1,60 @@ +"""Black/white/red e-paper displays using the UltraChip UC8151D controller. + +Supported models: +- uc8151: the controller on its own; dimensions and pins must be configured +- waveshare-2.13in-bv3: 104x212 pixels (2.13" B V3, EPD_2in13b_V3) + +The busy pin of these displays is low while busy, so configure it with ``inverted: true``. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS +from esphome.types import ConfigType + +from . import EpaperModel + +MAX_SOURCES = 160 +MAX_GATES = 296 + + +class UC8151(EpaperModel): + """EpaperModel class for black/white/red displays using the UC8151D controller.""" + + def __init__(self, name: str, **defaults: Any) -> None: + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + width, height = self.get_dimensions(config) + if width > MAX_SOURCES or height > MAX_GATES: + raise cv.Invalid( + f"{self.name} supports at most {MAX_SOURCES}x{MAX_GATES} pixels (width x height)", + path=[CONF_DIMENSIONS], + ) + return config + + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (black/white/red mode, LUT from OTP) + (0x00, 0x0F, 0x89), + # RESOLUTION SETTING: sources, then gates + (0x61, width, height >> 8, height & 0xFF), + # VCOM AND DATA INTERVAL SETTING; 0x57, not Waveshare's 0x77, so that 1 in DTM2 is red + (0x50, 0x57), + ) + + +uc8151 = UC8151( + "uc8151", + data_rate="10MHz", + minimum_update_interval="15s", + width_multiple=8, +) + +uc8151.extend( + "waveshare-2.13in-bv3", + width=104, + height=212, +) diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py index e124ea7083b..18c673fbeee 100644 --- a/esphome/components/epaper_spi/models/waveshare_bwr.py +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -1,10 +1,10 @@ """Waveshare Black/White/Red e-paper displays using UC8179 controller. Supported models: -- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EPD_7in5b_V2) -These displays use the UC8179 controller. Panel configuration is sent during -the INITIALISE state. Power-on is handled in the POWER_ON state, after data +These displays use the UC8179 controller. Power and panel configuration are sent +during the INITIALISE state. Power-on is handled in the POWER_ON state, after data transfer, so the state machine's built-in busy wait covers the power-on delay. """ @@ -20,8 +20,8 @@ class WaveshareBWR(EpaperModel): def get_init_sequence(self, config): """Generate initialization sequence for UC8179 BWR displays. - Panel configuration only — power-on is handled separately in power_on() - after data transfer, with the state machine busy-waiting before refresh. + Power and panel configuration only — power-on is handled separately in + power_on() after data transfer, with the state machine busy-waiting before refresh. """ width, height = self.get_dimensions(config) return ( @@ -43,10 +43,12 @@ class WaveshareBWR(EpaperModel): (0x60, 0x22), # RESOLUTION GATE SETTING (0x65, 0x00, 0x00, 0x00, 0x00), + # POWER SETTING + (0x01, 0x07, 0x17, 0x3F, 0x3F), ) -# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +# Model: Waveshare 7.5" V2 BWR (EPD_7in5b_V2) — 800x480, UC8179 controller WaveshareBWR( "waveshare-7.5in-bv2-bwr", width=800, diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index e3cd3820d7e..3eb1ad27253 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -119,14 +119,7 @@ bool ES9038Q2MKatana::write_volume_() { } // 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::check_write_(bool ok) { return this->status_comm_result(ok); } bool ES9038Q2MKatana::set_volume(float volume) { if (this->is_failed()) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 6905ef82228..e627d58fc81 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -680,18 +680,17 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { case 0xD: // int12. case 0xE: // int16. if (length > 2) { - return (float) ((int16_t) (value[1] << 8) + (int16_t) value[2]); + return (float) encode_value(&value[1]); } [[fallthrough]]; case 0xF: // int24. if (length > 3) { - return (float) ((int32_t) (value[1] << 16) + (int32_t) (value[2] << 8) + (int32_t) (value[3])); + return (float) encode_uint24(value[1], value[2], value[3]); } [[fallthrough]]; case 0x10: // int32. if (length > 4) { - return (float) ((int32_t) (value[1] << 24) + (int32_t) (value[2] << 16) + (int32_t) (value[3] << 8) + - (int32_t) (value[4])); + return (float) encode_value(&value[1]); } } ESP_LOGW(TAG, "[%d] [%s] Cannot parse characteristic value of type 0x%x length %d", this->connection_index_, diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 0014d38a5c4..a83a4817b1a 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -376,8 +376,7 @@ async def to_code(config: ConfigType) -> None: extclk = config[CONF_EXTERNAL_CLOCK] cg.add(var.set_external_clock(extclk[CONF_PIN], extclk[CONF_FREQUENCY])) if i2c_id := config.get(CONF_I2C_ID): - i2c_hub = await cg.get_variable(i2c_id) - cg.add(var.set_i2c_id(i2c_hub)) + await cg.add_variable(var.set_i2c_id, i2c_id) else: i2c_pins = config[CONF_I2C_PINS] cg.add(var.set_i2c_pins(i2c_pins[CONF_SDA], i2c_pins[CONF_SCL])) diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 8e85cce75a5..4ccd6a12f8b 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -104,8 +104,7 @@ async def to_code(config: dict[str, Any]) -> None: cg.add(var.set_firmware_data(prog_arr)) cg.add(var.set_firmware_size(len(firmware_data))) else: - http_request_var = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_http_request_parent(http_request_var)) + await cg.add_variable(var.set_http_request_parent, config[CONF_HTTP_REQUEST_ID]) cg.add(var.set_source_url(config[CONF_SOURCE])) cg.add_define("USE_ESP32_HOSTED_HTTP_UPDATE") diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 420ff64ddc6..24f3a77a7ce 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -212,12 +212,9 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { #else error = rmt_transmit(this->channel_, this->encoder_, this->rmt_buf_, len * sizeof(rmt_symbol_word_t), &config); #endif - if (error != ESP_OK) { + if (!this->status_comm_result(error == ESP_OK, nullptr)) { ESP_LOGE(TAG, "RMT TX error"); - this->status_set_warning(); - return; } - this->status_clear_warning(); } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3fa71632f02..a4b18942026 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from esphome import automation, pins -from esphome.automation import Condition import esphome.codegen as cg from esphome.components import spi from esphome.components.network import ( @@ -238,10 +237,6 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") -EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) -EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) -EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) -EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -715,8 +710,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: if config[CONF_TYPE] in SPI_ETHERNET_TYPES: if (spi_id := config.get(CONF_SPI_ID)) is not None: # Pins and host come from the shared spi bus. - spi_parent = await cg.get_variable(spi_id) - cg.add(var.set_spi_parent(spi_parent)) + await cg.add_variable(var.set_spi_parent, spi_id) else: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) @@ -933,15 +927,22 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -automation.register_bare_condition( - "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) -) -automation.register_bare_condition( - "ethernet.enabled", EthernetEnabledCondition, cv.Schema({}) -) -automation.register_bare_action( - "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True -) -automation.register_bare_action( - "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +ETHERNET_AUTOMATION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(EthernetComponent), + } ) + +for _name, _check in ( + ("ethernet.connected", "is_connected()"), + ("ethernet.enabled", "is_enabled()"), +): + automation.register_apply_condition(_name, ETHERNET_AUTOMATION_SCHEMA, _check) + +for _name, _call in ( + ("ethernet.enable", "enable()"), + ("ethernet.disable", "disable()"), +): + automation.register_apply_action( + _name, ETHERNET_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h deleted file mode 100644 index f975f52bffd..00000000000 --- a/esphome/components/ethernet/automation.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_ETHERNET -#include "ethernet_component.h" - -namespace esphome::ethernet { - -template class EthernetConnectedCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_eth_component->is_connected(); } -}; - -template class EthernetEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } -}; - -template class EthernetEnableAction final : public Action { - public: - void play(const Ts &...x) override { global_eth_component->enable(); } -}; - -template class EthernetDisableAction final : public Action { - public: - void play(const Ts &...x) override { global_eth_component->disable(); } -}; - -} // namespace esphome::ethernet -#endif diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 9fbda9a9737..f203e8d5186 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -129,30 +129,22 @@ async def to_code(config: ConfigType) -> None: var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) - if CONF_OPEN_ENDSTOP in config: - bin = await cg.get_variable(config[CONF_OPEN_ENDSTOP]) - cg.add(var.set_open_endstop(bin)) - if CONF_OPEN_SENSOR in config: - bin = await cg.get_variable(config[CONF_OPEN_SENSOR]) - cg.add(var.set_open_sensor(bin)) - if CONF_OPEN_OBSTACLE_SENSOR in config: - bin = await cg.get_variable(config[CONF_OPEN_OBSTACLE_SENSOR]) - cg.add(var.set_open_obstacle_sensor(bin)) + await cg.add_variable(var.set_open_endstop, config.get(CONF_OPEN_ENDSTOP)) + await cg.add_variable(var.set_open_sensor, config.get(CONF_OPEN_SENSOR)) + await cg.add_variable( + var.set_open_obstacle_sensor, config.get(CONF_OPEN_OBSTACLE_SENSOR) + ) # CLOSE await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] ) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) - if CONF_CLOSE_ENDSTOP in config: - bin = await cg.get_variable(config[CONF_CLOSE_ENDSTOP]) - cg.add(var.set_close_endstop(bin)) - if CONF_CLOSE_SENSOR in config: - bin = await cg.get_variable(config[CONF_CLOSE_SENSOR]) - cg.add(var.set_close_sensor(bin)) - if CONF_CLOSE_OBSTACLE_SENSOR in config: - bin = await cg.get_variable(config[CONF_CLOSE_OBSTACLE_SENSOR]) - cg.add(var.set_close_obstacle_sensor(bin)) + await cg.add_variable(var.set_close_endstop, config.get(CONF_CLOSE_ENDSTOP)) + await cg.add_variable(var.set_close_sensor, config.get(CONF_CLOSE_SENSOR)) + await cg.add_variable( + var.set_close_obstacle_sensor, config.get(CONF_CLOSE_OBSTACLE_SENSOR) + ) # OTHER if CONF_MAX_DURATION in config: diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py index 58af033f329..549f5a20a44 100644 --- a/esphome/components/filter_lifetime/sensor.py +++ b/esphome/components/filter_lifetime/sensor.py @@ -72,15 +72,15 @@ async def to_code(config: ConfigType) -> None: if (is_on := config.get(CONF_IS_ON)) is not None: cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) - elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: - cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + await cg.add_variable(var.set_is_on_sensor, config.get(CONF_IS_ON_SENSOR)) if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: cg.add( var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) ) - elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: - cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + await cg.add_variable( + var.set_current_speed_sensor, config.get(CONF_CURRENT_SPEED_SENSOR) + ) sensors = sensor.sub_sensors(config) await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index fcb9e832ea8..3a00af069ea 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -20,55 +20,53 @@ void GCJA5Component::loop() { this->rx_message_.clear(); } - if (this->available() == 0) { + const size_t avail = this->available(); + if (avail == 0) { return; } // There must now be data waiting this->last_transmission_ = now; - uint8_t val; - while (this->available() != 0) { - this->read_byte(&val); - this->rx_message_.push_back(val); + this->read_available( + [this](uint8_t val) { + this->rx_message_.push_back(val); - // check if rx_message_ has 32 bytes of data - if (this->rx_message_.size() == 32) { - this->parse_data_(); + // check if rx_message_ has 32 bytes of data + if (this->rx_message_.size() == 32) { + this->parse_data_(); - if (this->have_good_data_) { - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(get_32_bit_uint_(1)); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(get_32_bit_uint_(5)); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(get_32_bit_uint_(9)); - if (this->pmc_0_3_sensor_ != nullptr) - this->pmc_0_3_sensor_->publish_state(get_16_bit_uint_(13)); - if (this->pmc_0_5_sensor_ != nullptr) - this->pmc_0_5_sensor_->publish_state(get_16_bit_uint_(15)); - if (this->pmc_1_0_sensor_ != nullptr) - this->pmc_1_0_sensor_->publish_state(get_16_bit_uint_(17)); - if (this->pmc_2_5_sensor_ != nullptr) - this->pmc_2_5_sensor_->publish_state(get_16_bit_uint_(21)); - if (this->pmc_5_0_sensor_ != nullptr) - this->pmc_5_0_sensor_->publish_state(get_16_bit_uint_(23)); - if (this->pmc_10_0_sensor_ != nullptr) - this->pmc_10_0_sensor_->publish_state(get_16_bit_uint_(25)); - } else { - this->status_set_warning(); - ESP_LOGV(TAG, "Have 32 bytes but not good data. Skipping."); - } + if (this->have_good_data_) { + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(get_32_bit_uint_(1)); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(get_32_bit_uint_(5)); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(get_32_bit_uint_(9)); + if (this->pmc_0_3_sensor_ != nullptr) + this->pmc_0_3_sensor_->publish_state(get_16_bit_uint_(13)); + if (this->pmc_0_5_sensor_ != nullptr) + this->pmc_0_5_sensor_->publish_state(get_16_bit_uint_(15)); + if (this->pmc_1_0_sensor_ != nullptr) + this->pmc_1_0_sensor_->publish_state(get_16_bit_uint_(17)); + if (this->pmc_2_5_sensor_ != nullptr) + this->pmc_2_5_sensor_->publish_state(get_16_bit_uint_(21)); + if (this->pmc_5_0_sensor_ != nullptr) + this->pmc_5_0_sensor_->publish_state(get_16_bit_uint_(23)); + if (this->pmc_10_0_sensor_ != nullptr) + this->pmc_10_0_sensor_->publish_state(get_16_bit_uint_(25)); + } else { + this->status_set_warning(); + ESP_LOGV(TAG, "Have 32 bytes but not good data. Skipping."); + } - this->rx_message_.clear(); - } - } + this->rx_message_.clear(); + } + }, + avail); } bool GCJA5Component::calculate_checksum_() { - uint8_t crc = 0; - - for (uint8_t i = 1; i < 30; i++) - crc = crc ^ this->rx_message_[i]; + uint8_t crc = xor_bytes(&this->rx_message_[1], 29); ESP_LOGVV(TAG, "Checksum packet was (0x%02X), calculated checksum was (0x%02X)", this->rx_message_[30], crc); diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index a84d5ba06fb..222ae5b349d 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -30,11 +30,7 @@ void GDK101Component::update() { return; } - if (!this->read_measurement_duration_(data)) { - this->status_set_warning(LOG_STR("Failed to read measurement duration")); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->read_measurement_duration_(data), LOG_STR("Failed to read measurement duration")); } void GDK101Component::setup() { diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index a35e2280455..c699c933ba1 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -1,10 +1,11 @@ import hashlib -from esphome import automation, codegen as cg, config_validation as cv +from esphome import automation, codegen as cg, config_validation as cv, preferences from esphome.const import ( CONF_ID, CONF_INITIAL_VALUE, CONF_RESTORE_VALUE, + CONF_STORAGE, CONF_TYPE, CONF_VALUE, ) @@ -21,6 +22,7 @@ RestoringGlobalsComponent = globals_ns.class_( RestoringGlobalStringComponent = globals_ns.class_( "RestoringGlobalStringComponent", cg.PollingComponent ) +Storage = globals_ns.enum("Storage", is_class=True) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" @@ -37,6 +39,9 @@ _NON_RESTORING_SCHEMA = cv.Schema( { **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, + cv.Optional(CONF_STORAGE): cv.invalid( + f"'{CONF_STORAGE}' needs '{CONF_RESTORE_VALUE}: true'" + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -45,6 +50,9 @@ _RESTORING_SCHEMA = cv.Schema( { **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean, + # No default: when omitted the global keeps the platform's own default backend, + # which on ESP8266 follows restore_from_flash + cv.Optional(CONF_STORAGE): preferences.validate_storage, } ).extend(cv.polling_component_schema("1s")) @@ -68,13 +76,17 @@ async def to_code(config: ConfigType) -> None: # Special casing the strings to their own class with a different save/restore mechanism if str(type_) == "std::string" and restore: - template_args = cg.TemplateArguments( - type_, config.get(CONF_MAX_RESTORE_DATA_LENGTH, 63) + 1 - ) + args = [type_, config.get(CONF_MAX_RESTORE_DATA_LENGTH, 63) + 1] type = RestoringGlobalStringComponent else: - template_args = cg.TemplateArguments(type_) + args = [type_] type = RestoringGlobalsComponent if restore else GlobalsComponent + in_flash = None + if (storage := config.get(CONF_STORAGE)) is not None: + # is_in_flash() also requests the RTC backend when rtc is chosen + in_flash = preferences.is_in_flash(storage) + args.append(Storage.STORAGE_FLASH if in_flash else Storage.STORAGE_RTC) + template_args = cg.TemplateArguments(*args) res_type = type.template(template_args) initial_value = None @@ -91,6 +103,10 @@ async def to_code(config: ConfigType) -> None: value = value.encode() hash_ = int(hashlib.md5(value).hexdigest()[:8], 16) cg.add(glob.set_name_hash(hash_)) + if type is RestoringGlobalStringComponent: + # The ESP8266 adds up every saved value to check that they fit its store; only the + # string's size is known here + preferences.reserve_preferences(1, args[1], in_flash) # A lambda returns the global's own type so `return 42;` reduces to a cast; the value diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index b7acb065695..1f9503c2c3c 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -25,7 +25,11 @@ template class GlobalsComponent final : public Component { T value_{}; }; -template class RestoringGlobalsComponent : public PollingComponent { +// Where a restoring global saves its value; STORAGE_PLATFORM keeps the platform default +enum class Storage : uint8_t { STORAGE_PLATFORM, STORAGE_FLASH, STORAGE_RTC }; + +template +class RestoringGlobalsComponent : public PollingComponent { public: using value_type = T; explicit RestoringGlobalsComponent() : PollingComponent(1000) {} @@ -39,7 +43,12 @@ template class RestoringGlobalsComponent : public PollingComponent { T &value() { return this->value_; } void setup() override { - this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + if constexpr (STORAGE == Storage::STORAGE_PLATFORM) { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + } else { + this->rtc_ = + global_preferences->make_preference(1944399030U ^ this->name_hash_, STORAGE == Storage::STORAGE_FLASH); + } this->rtc_.load(&this->value_); memcpy(&this->last_checked_value_, &this->value_, sizeof(T)); } @@ -68,7 +77,8 @@ template class RestoringGlobalsComponent : public PollingComponent { }; // Use with string or subclasses of strings -template class RestoringGlobalStringComponent : public PollingComponent { +template +class RestoringGlobalStringComponent : public PollingComponent { public: using value_type = T; explicit RestoringGlobalStringComponent() : PollingComponent(1000) {} @@ -83,7 +93,12 @@ template class RestoringGlobalStringComponent : public P void setup() override { char temp[SZ]; - this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + if constexpr (STORAGE == Storage::STORAGE_PLATFORM) { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + } else { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_, + STORAGE == Storage::STORAGE_FLASH); + } bool hasdata = this->rtc_.load(&temp); if (hasdata) { this->value_.assign(temp + 1, static_cast(temp[0])); @@ -130,7 +145,9 @@ template class RestoringGlobalStringComponent : public P }; template T &id(GlobalsComponent *value) { return value->value(); } -template T &id(RestoringGlobalsComponent *value) { return value->value(); } -template T &id(RestoringGlobalStringComponent *value) { return value->value(); } +template T &id(RestoringGlobalsComponent *value) { return value->value(); } +template T &id(RestoringGlobalStringComponent *value) { + return value->value(); +} } // namespace esphome::globals diff --git a/esphome/components/gp2y1010au0f/sensor.py b/esphome/components/gp2y1010au0f/sensor.py index 3121aa1de50..9d541a3d88a 100644 --- a/esphome/components/gp2y1010au0f/sensor.py +++ b/esphome/components/gp2y1010au0f/sensor.py @@ -49,8 +49,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # the ADC sensor to read voltage from - adc_sensor = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_adc_source(adc_sensor)) + await cg.add_variable(var.set_adc_source, config[CONF_SENSOR]) cg.add( var.set_voltage_refs( config[CONF_ADC_VOLTAGE_OFFSET], config[CONF_ADC_VOLTAGE_MULTIPLIER] @@ -58,5 +57,4 @@ async def to_code(config: ConfigType) -> None: ) # the binary output to control the module's internal IR LED - led_output = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_led_output(led_output)) + await cg.add_variable(var.set_led_output, config[CONF_OUTPUT]) diff --git a/esphome/components/gpio_expander/expander_gpio_pin.h b/esphome/components/gpio_expander/expander_gpio_pin.h new file mode 100644 index 00000000000..5dbb2883551 --- /dev/null +++ b/esphome/components/gpio_expander/expander_gpio_pin.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +namespace esphome::gpio_expander { + +/// Pin of a GPIO expander. Parent provides pin_mode(pin, flags), digital_read(pin) and digital_write(pin, value). +/// dump_summary() is specialized per expander with the macros below, outside the template so that its format +/// string stays in flash on ESP8266; a missing macro is an undefined reference to +/// ExpanderGPIOPin::dump_summary. Built by pins.new_parented_pin(). +template class ExpanderGPIOPin final : public GPIOPin, public Parented { + public: + // User provided, not "= default": `new(p) ExpanderGPIOPin()` would zero-fill .bss that is already zero. + ExpanderGPIOPin() {} + + void setup() override { this->pin_mode(this->flags_); } + void pin_mode(gpio::Flags flags) override { this->parent_->pin_mode(this->pin_, flags); } + bool digital_read() override { return this->parent_->digital_read(this->pin_) != this->inverted_; } + void digital_write(bool value) override { this->parent_->digital_write(this->pin_, value != this->inverted_); } + 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::gpio_expander + +/// In the expander's header, at file scope: declares the pin summary its .cpp defines. +#define ESPHOME_EXPANDER_PIN_SUMMARY_DECL(Parent) \ + template<> size_t esphome::gpio_expander::ExpanderGPIOPin::dump_summary(char *buffer, size_t len) const + +/// In the expander's .cpp, at file scope: the pin summary, a printf format taking the pin number. +#define ESPHOME_EXPANDER_PIN_SUMMARY(Parent, format) \ + ESPHOME_EXPANDER_PIN_SUMMARY_DECL(Parent) { return buf_append_printf(buffer, len, 0, format, this->pin_); } diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index ce9035655c3..9dfa8df4cc8 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -169,8 +169,7 @@ async def to_code(config: ConfigType) -> None: # Trace options for trace in config[CONF_TRACES]: tr = cg.new_Pvariable(trace[CONF_ID], GraphTrace()) - sens = await cg.get_variable(trace[CONF_SENSOR]) - cg.add(tr.set_sensor(sens)) + await cg.add_variable(tr.set_sensor, trace[CONF_SENSOR]) if CONF_NAME in trace: cg.add(tr.set_name(trace[CONF_NAME])) else: @@ -179,9 +178,7 @@ async def to_code(config: ConfigType) -> None: cg.add(tr.set_line_thickness(trace[CONF_LINE_THICKNESS])) if CONF_LINE_TYPE in trace: cg.add(tr.set_line_type(trace[CONF_LINE_TYPE])) - if CONF_COLOR in trace: - c = await cg.get_variable(trace[CONF_COLOR]) - cg.add(tr.set_line_color(c)) + await cg.add_variable(tr.set_line_color, trace.get(CONF_COLOR)) if CONF_CONTINUOUS in trace: cg.add(tr.set_continuous(trace[CONF_CONTINUOUS])) cg.add(var.add_trace(tr)) @@ -189,12 +186,8 @@ async def to_code(config: ConfigType) -> None: if CONF_LEGEND in config: lgd = config[CONF_LEGEND] legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend()) - if CONF_NAME_FONT in lgd: - font = await cg.get_variable(lgd[CONF_NAME_FONT]) - cg.add(legend.set_name_font(font)) - if CONF_VALUE_FONT in lgd: - font = await cg.get_variable(lgd[CONF_VALUE_FONT]) - cg.add(legend.set_value_font(font)) + await cg.add_variable(legend.set_name_font, lgd.get(CONF_NAME_FONT)) + await cg.add_variable(legend.set_value_font, lgd.get(CONF_VALUE_FONT)) if CONF_WIDTH in lgd: cg.add(legend.set_width(lgd[CONF_WIDTH])) if CONF_HEIGHT in lgd: diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index a398aea6192..5534321dd7f 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -55,12 +55,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if display_config := config.get(CONF_DISPLAY): - drawing_display = await cg.get_variable(display_config) - cg.add(var.set_display(drawing_display)) - - menu_font = await cg.get_variable(config[CONF_FONT]) - cg.add(var.set_font(menu_font)) + await cg.add_variable(var.set_display, config.get(CONF_DISPLAY)) + await cg.add_variable(var.set_font, config[CONF_FONT]) if (menu_item_value_config := config.get(CONF_MENU_ITEM_VALUE, None)) is not None: if isinstance(menu_item_value_config, core.Lambda): @@ -73,13 +69,8 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_menu_item_value(menu_item_value_config)) - if foreground_color_config := config.get(CONF_FOREGROUND_COLOR): - foreground_color = await cg.get_variable(foreground_color_config) - cg.add(var.set_foreground_color(foreground_color)) - - if background_color_config := config.get(CONF_BACKGROUND_COLOR): - background_color = await cg.get_variable(background_color_config) - cg.add(var.set_background_color(background_color)) + await cg.add_variable(var.set_foreground_color, config.get(CONF_FOREGROUND_COLOR)) + await cg.add_variable(var.set_background_color, config.get(CONF_BACKGROUND_COLOR)) for conf in config.get(CONF_ON_REDRAW, []): await automation.build_callback_automation( diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 0f49a30e6da..2f277fbbebf 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_ACTIVE_POWER, CONF_CURRENT, CONF_FREQUENCY, - CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C, @@ -168,9 +167,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d2db05381d8..20047f9ea81 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_ACTIVE_POWER, CONF_CURRENT, CONF_FREQUENCY, - CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C, @@ -225,9 +224,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index f6d935c300d..01713ba34f4 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -56,14 +56,9 @@ async def to_code(config: ConfigType) -> None: config[CONF_DECAY_MODE], ) await cg.register_component(var, config) - pin_a_ = await cg.get_variable(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a_)) - pin_b_ = await cg.get_variable(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b_)) - - if CONF_ENABLE_PIN in config: - enable_pin = await cg.get_variable(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_variable(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_variable(var.set_pin_b, config[CONF_PIN_B]) + await cg.add_variable(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if CONF_PRESET_MODES in config: cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f7866cb9907..95cf9dc6dc8 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -28,7 +28,5 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await light.register_light(var, config) - hside = await cg.get_variable(config[CONF_PIN_A]) - cg.add(var.set_pina_pin(hside)) - lside = await cg.get_variable(config[CONF_PIN_B]) - cg.add(var.set_pinb_pin(lside)) + await cg.add_variable(var.set_pina_pin, config[CONF_PIN_A]) + await cg.add_variable(var.set_pinb_pin, config[CONF_PIN_B]) diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 2197a086c36..734e133336f 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -55,14 +55,11 @@ void HC8Component::update() { return; } - if (crc16(response.data(), 12) != encode_uint16(response[13], response[12])) { + if (!this->status_comm_result(crc16(response.data(), 12) == encode_uint16(response[13], response[12]))) { ESP_LOGW(TAG, "HC8 Checksum mismatch"); - this->status_set_warning(); return; } - this->status_clear_warning(); - const uint16_t ppm = encode_uint16(response[5], response[4]); ESP_LOGD(TAG, "HC8 Received COâ‚‚=%uppm", ppm); if (this->co2_sensor_ != nullptr) diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index 8672dc497f5..3386fe4d540 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -44,17 +44,14 @@ void HDC2080Component::update() { data = MEAS_TRIG | MEAS_CONF_HUM; } if (this->write_register(REG_MEASUREMENT_CONFIGURATION, &data, 1) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // wait for conversion to complete 2ms should be enough, more is fine this->set_timeout(5, [this]() { uint8_t raw_data[4]; - if (this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + if (!this->status_comm_result(this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) == i2c::ERROR_OK)) return; - } - this->status_clear_warning(); if (this->temperature_sensor_ != nullptr) { float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; this->temperature_sensor_->publish_state(temp); diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index d5428486579..ffe03190b3e 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -133,12 +133,9 @@ void HDC302XComponent::read_data_() { } // Check checksums - if (crc8(buf, 2, 0xff, 0x31, true) != buf[2] || crc8(buf + 3, 2, 0xff, 0x31, true) != buf[5]) { - this->status_set_warning(LOG_STR("Read data: invalid CRC")); + const bool crc_ok = crc8(buf, 2, 0xff, 0x31, true) == buf[2] && crc8(buf + 3, 2, 0xff, 0x31, true) == buf[5]; + if (!this->status_comm_result(crc_ok, LOG_STR("Read data: invalid CRC"))) return; - } - - this->status_clear_warning(); if (this->temp_sensor_ != nullptr) { uint16_t raw_t = encode_uint16(buf[0], buf[1]); diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index 7c67c5ffd3f..a15551f4c1a 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -152,13 +152,7 @@ void HE60rCover::update_() { } void HE60rCover::loop() { - uint8_t data; - - while (this->available() > 0) { - if (this->read_byte(&data)) { - this->process_rx_(data); - } - } + this->read_available([this](uint8_t data) { this->process_rx_(data); }); } void HE60rCover::control(const CoverCall &call) { diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 43311f3d968..caba0be9792 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -192,7 +192,7 @@ void HlkFm22xComponent::handle_note_(const uint8_t *data, size_t length) { int16_t info[8]; uint8_t offset = 1; for (int16_t &i : info) { - i = ((int16_t) data[offset + 1] << 8) | data[offset]; + i = encode_value_le(&data[offset]); offset += 2; } ESP_LOGV(TAG, "Face state: status: %d, left: %d, top: %d, right: %d, bottom: %d, yaw: %d, pitch: %d, roll: %d", @@ -259,7 +259,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { ESP_LOGE(TAG, "VERIFY response too short: %zu bytes", length); break; } - int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + int16_t face_id = encode_value(&data[2]); const char *name_ptr = reinterpret_cast(data + 4); ESP_LOGD(TAG, "Face verified. ID: %d, name: %.*s", face_id, (int) HLK_FM22X_NAME_SIZE, name_ptr); if (this->last_face_id_sensor_ != nullptr) { @@ -272,7 +272,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { break; } case HlkFm22xCommand::ENROLL: { - int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + int16_t face_id = encode_value(&data[2]); HlkFm22xFaceDirection direction = (HlkFm22xFaceDirection) data[4]; ESP_LOGI(TAG, "Face enrolled. ID: %d, Direction: 0x%.2X", face_id, direction); this->enrollment_done_callback_.call(face_id, (uint8_t) direction); diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp index cbb6b6c628d..eb0a15d6f2f 100644 --- a/esphome/components/hlw8032/hlw8032.cpp +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -30,8 +30,7 @@ static constexpr uint8_t STATE_REG_OVERFLOW_MASK = 0xF; static constexpr uint8_t PACKET_LENGTH = 24; void HLW8032Component::loop() { - while (this->available()) { - uint8_t data = this->read(); + this->read_available([this](uint8_t data) { if (!this->header_found_) { if ((data == STATE_REG_CORRECTION_FUNC_NORMAL) || (data == STATE_REG_CORRECTION_FUNC_FAIL) || (data & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { @@ -57,7 +56,7 @@ void HLW8032Component::loop() { memset(this->raw_data_, 0, PACKET_LENGTH); } } - } + }); } uint32_t HLW8032Component::read_uint24_(uint8_t offset) { diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp index ab2afc74d93..d4fc2e9b689 100644 --- a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -39,8 +39,7 @@ void HoermannHcpCover::control(const cover::CoverCall &call) { } void HoermannHcpCover::update_from_state_() { - if (!this->parent_->is_valid()) { - this->status_set_warning(); + if (!this->status_comm_result(this->parent_->is_valid(), LOG_STR("bus controller not responding"))) { // The door can now move unheard, so drop the baseline a direction would be inferred from and stop // reporting motion instead of leaving the cover travelling until the controller returns. this->previous_position_ = NAN; @@ -50,7 +49,6 @@ void HoermannHcpCover::update_from_state_() { } return; } - this->status_clear_warning(); const auto previous_operation = this->current_operation; const float current_position = this->parent_->get_current_position(); diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp index 6195c5d63d8..6a3028ad2fe 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -53,12 +53,9 @@ void HoermannHcpLight::update_from_state_() { this->status_set_warning(LOG_STR("bus controller not responding")); return; } - if (!this->parent_->is_light_known()) { - // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. - this->status_set_warning(LOG_STR("door has not reported the lamp")); + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + if (!this->status_comm_result(this->parent_->is_light_known(), LOG_STR("door has not reported the lamp"))) return; - } - this->status_clear_warning(); const bool heading_on = this->parent_->is_light_heading_on(); if (this->light_state_->remote_values.is_on() != heading_on) this->publish_lamp_state_(heading_on); diff --git a/esphome/components/homeassistant/homeassistant.cpp b/esphome/components/homeassistant/homeassistant.cpp new file mode 100644 index 00000000000..5d6a41c677b --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.cpp @@ -0,0 +1,11 @@ +#include "homeassistant.h" + +#include "esphome/core/progmem.h" + +namespace esphome::homeassistant { + +PROGMEM_STRING_TABLE(UnsetValues, "None", "unavailable", "unknown"); + +bool is_unset(StringRef value) { return progmem_string_table_find(value.c_str(), value.size()) >= 0; } + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h index 65eb6ef5fd2..580c1d89a90 100644 --- a/esphome/components/homeassistant/homeassistant.h +++ b/esphome/components/homeassistant/homeassistant.h @@ -7,6 +7,10 @@ namespace esphome::homeassistant { +/// True for what Home Assistant reports when there is no value: "None" for an attribute that is not +/// set, "unavailable" and "unknown" for the state of an entity that is not ready +bool is_unset(StringRef value); + /// 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) diff --git a/esphome/components/homeassistant/light/__init__.py b/esphome/components/homeassistant/light/__init__.py new file mode 100644 index 00000000000..abb6b7289f7 --- /dev/null +++ b/esphome/components/homeassistant/light/__init__.py @@ -0,0 +1,81 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEFAULT_TRANSITION_LENGTH, + CONF_EFFECTS, + CONF_FLASH_TRANSITION_LENGTH, + CONF_GAMMA_CORRECT, + CONF_INITIAL_STATE, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, +) +from esphome.core import TimePeriod +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@egormanga"] +DEPENDENCIES = ["api"] +AUTO_LOAD = ["homeassistant"] + +CONF_REPORT_TIMEOUT = "report_timeout" +SUPPORTED_DOMAINS = ["light"] + +HomeassistantLight = homeassistant_ns.class_( + "HomeassistantLight", light.LightOutput, cg.Component +) + +_RENDERED_BY_HOME_ASSISTANT = cv.invalid( + "Home Assistant renders this light and keeps its state; this option has no effect here" +) + +CONFIG_SCHEMA = cv.All( + light.light_schema(HomeassistantLight, light.LightType.RGB) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend( + { + # A transition would be stepped over the API; the target is sent once instead + cv.Optional( + CONF_DEFAULT_TRANSITION_LENGTH, default="0s" + ): cv.positive_time_period_milliseconds, + # Stored in a byte of seconds; a light polled by Home Assistant can take a minute + cv.Optional(CONF_REPORT_TIMEOUT, default="10s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=255)), + ), + **{ + cv.Optional(key): _RENDERED_BY_HOME_ASSISTANT + for key in ( + CONF_GAMMA_CORRECT, + CONF_EFFECTS, + CONF_FLASH_TRANSITION_LENGTH, + light.CONF_TRANSITION_STATE_PUBLISH_INTERVAL, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + CONF_INITIAL_STATE, + ) + }, + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("light", SUPPORTED_DOMAINS), +) + +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await light.new_light(config) + await cg.register_component(var, config) + cg.add(var.set_report_timeout(config[CONF_REPORT_TIMEOUT].total_seconds)) + # 1 state + supported_color_modes, min/max_color_temp_kelvin, color_mode, brightness, + # color_temp_kelvin and rgb_color in HomeassistantLight::setup() + setup_home_assistant_entity(var, config, state_subscription_count=8) diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp new file mode 100644 index 00000000000..6ebe0cadbfb --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -0,0 +1,477 @@ +#include "homeassistant_light.h" + +#include +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "../homeassistant.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.light"); + +static constexpr uint32_t ECHO_TIMEOUT_ID = 0; + +using namespace esphome::light; + +namespace { + +// Home Assistant's color mode names, in flash on the ESP8266. The names from "hs" on are every +// color mode, all handled as RGB: Home Assistant reports rgb_color for all of them and derives +// the white channels from rgb_color itself. +PROGMEM_STRING_TABLE(ColorModeNames, "onoff", "brightness", "white", "color_temp", "hs", "xy", "rgb", "rgbw", "rgbww"); +const uint8_t COLOR_MODES[] PROGMEM = { + static_cast(ColorMode::ON_OFF), + static_cast(ColorMode::BRIGHTNESS), + static_cast(ColorMode::WHITE), + static_cast(ColorMode::COLOR_TEMPERATURE), +}; + +optional parse_color_mode(StringRef name) { + const int16_t i = progmem_string_table_find(name.c_str(), name.size()); + if (i < 0) { + return nullopt; + } + return i < sizeof(COLOR_MODES) ? static_cast(progmem_read_byte(&COLOR_MODES[i])) : ColorMode::RGB; +} + +/// Reads a Python tuple of three integers such as "(255, 0, 0)"; false for anything else +bool parse_rgb(const char *text, std::array &values) { + for (auto &value : values) { + while (*text == ' ' || *text == ',' || *text == '(') { + text++; + } + if (*text < '0' || *text > '9') { + return false; + } + uint16_t number = 0; + while (*text >= '0' && *text <= '9') { + number = number * 10 + (*text++ - '0'); + if (number > 255) { + return false; + } + } + value = static_cast(number); + } + while (*text == ' ' || *text == ',' || *text == ')') { + text++; + } + return *text == '\0'; +} + +} // namespace + +void HomeassistantLight::setup() { + auto *server = api::global_api_server; + // Home Assistant sends these on subscribe and when they change, so a light that comes up late gets them + server->subscribe_home_assistant_state(this->entity_id_, "supported_color_modes", [this](StringRef value) { + if (is_unset(value)) { + return; // the light is unavailable; its traits stay + } + // A Python list of the mode names, as strings or as the enum members + ColorModeMask modes; + char name[ColorModeNames::MAX_LENGTH + 1]; + size_t length = 0; + const bool valid = parse_python_list( + value, + [&](char c) { + if (length < sizeof(name)) { + name[length] = c; + } + length++; + }, + [&]() { + auto mode = length <= ColorModeNames::MAX_LENGTH ? parse_color_mode(StringRef(name, length)) : nullopt; + if (mode.has_value()) { + modes.insert(*mode); + } else { + ESP_LOGW(TAG, "'%s': Unknown color mode in '%s'", this->entity_id_, value.c_str()); + } + length = 0; + }); + if (!valid) { + ESP_LOGW(TAG, "'%s': Can't parse 'supported_color_modes' value '%s'", this->entity_id_, value.c_str()); + return; + } + if (modes.empty()) { + ESP_LOGW(TAG, "'%s': No color mode found in '%s', keeping the current ones", this->entity_id_, value.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Supported color modes retrieved: %s", this->entity_id_, value.c_str()); + this->traits_.set_supported_color_modes(modes); + // Attributes that arrived before the traits were validated against empty ones; apply them again + this->schedule_apply_(); + }); + // Home Assistant reports the temperature range in Kelvin, the light core works in mireds + server->subscribe_home_assistant_state(this->entity_id_, "min_color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("min_color_temp_kelvin", value, UINT16_MAX)) { + this->traits_.set_max_mireds(1e6f / *kelvin); + this->schedule_apply_(); + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "max_color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("max_color_temp_kelvin", value, UINT16_MAX)) { + this->traits_.set_min_mireds(1e6f / *kelvin); + this->schedule_apply_(); + } + }); + + // An attribute is "None" while the light is off or does not report it; the last value stays + server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](StringRef value) { + if (is_unset(value)) { + return; // normal while Home Assistant or the light is starting up or offline + } + auto on_off = parse_on_off(value.c_str()); + if (on_off != PARSE_ON && on_off != PARSE_OFF) { + ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str()); + return; + } + const bool state = on_off == PARSE_ON; + this->on_report_(this->imported_.state != state); + this->imported_.state = state; + }); + server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) { + if (is_unset(value)) { + return; + } + auto mode = parse_color_mode(value); + if (!mode.has_value()) { + ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str()); + return; + } + this->on_report_(this->imported_.color_mode != *mode); + this->imported_.color_mode = *mode; + }); + server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) { + if (auto brightness = this->parse_integer_("brightness", value, 255)) { + this->on_report_(this->imported_.brightness != *brightness); + this->imported_.brightness = *brightness; + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) { + this->on_report_(this->imported_.color_temp_kelvin != *kelvin); + this->imported_.color_temp_kelvin = *kelvin; + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) { + if (is_unset(value)) { + return; + } + std::array rgb; + if (!parse_rgb(value.c_str(), rgb)) { + ESP_LOGE(TAG, "'%s': Can't parse 'rgb_color' value '%s'!", this->entity_id_, value.c_str()); + return; + } + this->on_report_(!this->imported_.has_rgb || this->imported_.rgb != rgb); + this->imported_.rgb = rgb; + this->imported_.has_rgb = true; + }); +} + +void HomeassistantLight::dump_config() { + ESP_LOGCONFIG(TAG, + "Homeassistant Light:\n" + " Entity ID: '%s'", + this->entity_id_); +} + +optional HomeassistantLight::parse_integer_(const char *name, StringRef value, uint16_t max) { + if (is_unset(value)) { + return nullopt; + } + auto number = parse_number(value.c_str()); + if (!number.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert '%s' value '%s' to number!", this->entity_id_, name, value.c_str()); + return nullopt; + } + // Written so that NaN fails too + const bool in_range = *number >= 1.0f && *number <= max; + if (!in_range) { + ESP_LOGW(TAG, "'%s': '%s' value %.0f is out of range!", this->entity_id_, name, *number); + return nullopt; + } + return static_cast(*number); +} + +void HomeassistantLight::on_report_(bool changed) { + // A report that changed an attribute answers the change that armed the echo timeout; a repeated + // value (or a trait) does not, so a refused change still gets its warning + if (changed && this->echo_pending_) { + this->echo_pending_ = false; + this->cancel_timeout(ECHO_TIMEOUT_ID); + } + this->report_changed_ |= changed; + this->schedule_apply_(); +} + +void HomeassistantLight::schedule_apply_() { + // One update changes several attributes, each arriving as its own message; apply them as one call + if (!this->apply_scheduled_) { + this->apply_scheduled_ = true; + this->defer([this]() { this->apply_imported_(); }); + } +} + +void HomeassistantLight::apply_imported_() { + this->apply_scheduled_ = false; + // Nothing to apply before the first state; while a sent change waits for its report the reported + // state is the old one, so a trait that arrives then does not bring it back + if (!this->imported_.state.has_value() || this->echo_pending_) { + return; + } + // Attributes Home Assistant has not reported keep what the light has. An "off" report carries + // no levels, so the ones reported last stay with the light: a turn_on without a brightness + // then sends what Home Assistant last had, not a level that never reached it + const Attributes &imported = this->imported_; + LightColorValues values = this->state_->remote_values; + values.set_state(*imported.state); + if (imported.color_mode != ColorMode::UNKNOWN) { + values.set_color_mode(imported.color_mode); + } + if (imported.brightness != 0) { + values.set_brightness(imported.brightness / 255.0f); + } + // Home Assistant reports rgb_color in every mode and keeps the attributes of the other + // modes, so only the ones of the mode it reports are taken over + const ColorMode mode = values.get_color_mode(); + if (mode == ColorMode::COLOR_TEMPERATURE) { + values.set_color_temperature_kelvin(imported.color_temp_kelvin); // ignores an unreported 0 + } else if (mode == ColorMode::RGB) { + if (imported.has_rgb) { + values.set_red(imported.rgb[0] / 255.0f); + values.set_green(imported.rgb[1] / 255.0f); + values.set_blue(imported.rgb[2] / 255.0f); + } + values.set_color_brightness(1.0f); // brightness is the only level Home Assistant has + } else if (mode == ColorMode::WHITE) { + values.set_white(1.0f); + } + if (!this->state_->set_external_state(values)) { + // A report that changed something during a transition or flash is applied once it ends, in + // place of the values it ends at; a trait or a repeated report has nothing newer to say + if (this->state_->is_transitioning() && this->report_changed_) { + this->report_held_ = true; + this->held_hash_ = state_hash(this->state_->remote_values); + return; + } + // A local change still waits for LightState::loop() to send it. Home Assistant reports again + // once that change reaches it; when it does not, the echo timeout write_state() arms brings the + // light back to what was reported last + ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_); + return; + } + this->report_changed_ = false; + ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_); +} + +uint32_t HomeassistantLight::state_hash(const LightColorValues &values) { + uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast(values.get_color_mode())); + for (const float channel : + {values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(), + values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) { + hash = fnv1a_hash_extend(hash, to_uint8_scale(channel)); + } + return fnv1a_hash_extend(hash, static_cast(values.get_color_temperature())); // mireds exceed a byte +} + +void HomeassistantLight::update_state(LightState *state) { + // The light core's setup performs one call for the initial state; a second one before the first + // write (an on_boot automation) is a local change merged into that write, so it must be sent + if (this->initial_write_) { + if (this->initial_seen_) { + this->initial_write_ = false; + } + this->initial_seen_ = true; + } + // A call without a transition replaces whatever ran, so a report held for it is older than the call + if (!state->is_transitioning()) { + this->report_held_ = false; + } +} + +bool HomeassistantLight::is_imported_(const Attributes &outgoing) const { + const Attributes &imported = this->imported_; + if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) { + return false; + } + if (!*outgoing.state) { + return true; + } + if (imported.color_mode != outgoing.color_mode || imported.brightness != outgoing.brightness) { + return false; + } + // rgb_color stays reported in the other modes, so only the attributes of the mode count + switch (outgoing.color_mode) { + case ColorMode::COLOR_TEMPERATURE: + return imported.color_temp_kelvin == outgoing.color_temp_kelvin; + case ColorMode::RGB: + return imported.has_rgb && imported.rgb == outgoing.rgb; + default: + return true; + } +} + +void HomeassistantLight::fall_back_() { + // Back to the state Home Assistant reported, which the light still has + if (!this->imported_.state.has_value()) { + ESP_LOGW(TAG, "'%s': Nothing reported yet to fall back to, the light keeps the unsent change", this->entity_id_); + return; + } + this->schedule_apply_(); +} + +void HomeassistantLight::write_state(LightState *state) { + // A reported state is taken over without a write, so every write is a local change, except + // the very first: the light core writes its initial state, which Home Assistant does not need + if (this->initial_write_) { + this->initial_write_ = false; + return; + } + if (state->is_transitioning()) { + // A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that + // replaces the transition a report was held for retargets it and is newer than the report + if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) { + this->report_held_ = false; + } + this->transitioned_ = true; + return; + } + const bool held = this->report_held_; + const bool transitioned = this->transitioned_; + this->report_held_ = false; + this->transitioned_ = false; + this->report_changed_ = false; // this change is newer than any report so far, held or dropped + // Home Assistant changed meanwhile; its report is newer than what the transition ends at or + // what the flash goes back to + if (held) { + ESP_LOGD(TAG, "'%s': Applying the report that arrived during the transition", this->entity_id_); + this->schedule_apply_(); + return; + } + // What Home Assistant holds after the change. It has one level, so the channel brightness of the + // mode folds into it + const LightColorValues &values = state->current_values; + Attributes outgoing; + outgoing.state = values.is_on(); + if (values.is_on()) { + outgoing.color_mode = values.get_color_mode(); + switch (outgoing.color_mode) { + case ColorMode::ON_OFF: + break; + case ColorMode::WHITE: + outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_white()); + break; + case ColorMode::COLOR_TEMPERATURE: + outgoing.brightness = to_uint8_scale(values.get_brightness()); + if (values.get_color_temperature() > 0.0f) { + // A mired value below the reported range could give more than 65535 K + outgoing.color_temp_kelvin = lroundf(std::min(values.get_color_temperature_kelvin(), 65535.0f)); + } + break; + case ColorMode::RGB: + outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_color_brightness()); + outgoing.rgb = {to_uint8_scale(values.get_red()), to_uint8_scale(values.get_green()), + to_uint8_scale(values.get_blue())}; + outgoing.has_rgb = true; + break; + default: + outgoing.brightness = to_uint8_scale(values.get_brightness()); + break; + } + } + // Home Assistant reports only what changed, so a change that leaves it as it is gets no report: + // nothing to send and no echo to wait for. A change still on its way may alter it first, though + if (!this->echo_pending_ && this->is_imported_(outgoing)) { + if (transitioned) { + // A flash ends where it began, and Home Assistant only ever gets the end + ESP_LOGW(TAG, "'%s': Ended at the state Home Assistant already has, nothing to send (a flash never is)", + this->entity_id_); + } else { + ESP_LOGD(TAG, "'%s': Home Assistant already has this state", this->entity_id_); + } + return; + } + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "'%s': No clients connected to API server, change lost", this->entity_id_); + this->fall_back_(); + return; + } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", state->get_name().c_str()); + this->fall_back_(); + return; + } +#endif + + // In flash on the ESP8266 and encoded from there + static const char SERVICE_TURN_ON[] PROGMEM = "light.turn_on"; + static const char SERVICE_TURN_OFF[] PROGMEM = "light.turn_off"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char BRIGHTNESS_KEY[] PROGMEM = "brightness"; + static const char WHITE_KEY[] PROGMEM = "white"; + static const char COLOR_TEMP_KELVIN_KEY[] PROGMEM = "color_temp_kelvin"; + static const char RGB_COLOR_KEY[] PROGMEM = "rgb_color"; + + // The request refers to these buffers until it is sent below + char level_buf[4]; // up to 255 + char color_temp_buf[6]; // up to 65535 + char color_buf[16]; + + api::HomeassistantActionRequest resp; + resp.data.init(3); + auto &entity_id = resp.data.emplace_back(); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); + + auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) { + auto &entry = resp.data.emplace_back(); + entry.key = key; + entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number))); + }; + if (!*outgoing.state) { + resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF); + } else { + resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_ON); + if (outgoing.color_mode != ColorMode::ON_OFF) { + // The level goes under "white" in that mode, which also selects the mode + add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY) + : ProgmemStringRef::from_lit(BRIGHTNESS_KEY), + level_buf, outgoing.brightness); + } + // Home Assistant converts the color to the light's own channels, which loses the white balance + // of a cold/warm white light, so the color goes only when it or the mode changed (or may have, + // while a change still waits for its report) + const Attributes &imported = this->imported_; + const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode; + if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) { + add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin); + } + if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) { + // A list, which only a template renders as one + resp.data_template.init(1); + auto &color = resp.data_template.emplace_back(); + color.key = ProgmemStringRef::from_lit(RGB_COLOR_KEY); + color.value = + ProgmemStringRef(StringRef(color_buf, buf_append_printf(color_buf, sizeof(color_buf), 0, "[%u,%u,%u]", + outgoing.rgb[0], outgoing.rgb[1], outgoing.rgb[2]))); + } + } + api::global_api_server->send_homeassistant_action(resp); + // Home Assistant reports the change back in a moment, which cancels this; when it does not, the + // change got lost on the way (no subscriber yet, the entity unavailable, the action refused) + this->echo_pending_ = true; + this->set_timeout(ECHO_TIMEOUT_ID, this->report_timeout_s_ * 1000u, [this]() { + this->echo_pending_ = false; + ESP_LOGW(TAG, "'%s': Home Assistant did not report the change back", this->entity_id_); + this->fall_back_(); + }); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h new file mode 100644 index 00000000000..a7f316a22f8 --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/// A light in Home Assistant, driven from here: its state and attributes are imported over the +/// native API, and every change made on this side is sent back as a light.turn_on or +/// light.turn_off action. +class HomeassistantLight final : public light::LightOutput, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + /// How long a sent change may take to come back as a report before the light returns to the reported state + void set_report_timeout(uint8_t seconds) { this->report_timeout_s_ = seconds; } + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + light::LightTraits get_traits() override { return this->traits_; } + void setup_state(light::LightState *state) override { this->state_ = state; } + void update_state(light::LightState *state) override; + void write_state(light::LightState *state) override; + + protected: + /// What Home Assistant reported last, in its own units. Each attribute arrives as its own + /// message and stays until the next report of it, so an update may change any subset; zero, + /// ColorMode::UNKNOWN and has_rgb say an attribute was never reported. + struct Attributes { + uint16_t color_temp_kelvin{0}; + uint8_t brightness{0}; + std::array rgb{}; + light::ColorMode color_mode{light::ColorMode::UNKNOWN}; + bool has_rgb{false}; + optional state; + }; + + /// Parses a Home Assistant attribute as an integer from 1 to max, logging anything else + optional parse_integer_(const char *name, StringRef value, uint16_t max); + /// A state or attribute report: answers a pending echo when it changed something, and schedules the apply + void on_report_(bool changed); + /// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call + static uint32_t state_hash(const light::LightColorValues &values); + /// Schedules apply_imported_() once for the attributes of one update + void schedule_apply_(); + void apply_imported_(); + /// True when Home Assistant reported exactly what a change would make of it + bool is_imported_(const Attributes &outgoing) const; + /// Returns the light to the reported state after a change that did not reach Home Assistant + void fall_back_(); + + const char *entity_id_{nullptr}; + light::LightState *state_{nullptr}; + light::LightTraits traits_{}; + Attributes imported_{}; + uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at + bool apply_scheduled_ : 1 {false}; + bool initial_write_ : 1 {true}; // the light core's own first write is not a local change + bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state + bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back + bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends + bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent + bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute + uint8_t report_timeout_s_{10}; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 5415b7af74f..7317caf6c0d 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -20,10 +20,7 @@ ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); * The 123 means 123cm */ void HrxlMaxsonarWrComponent::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) - continue; + this->read_available([this](uint8_t data) { size_t len; const char *line = this->line_reader_.feed(data, len); if (line != nullptr) { @@ -34,7 +31,7 @@ void HrxlMaxsonarWrComponent::loop() { ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); this->line_reader_.reset(); } - } + }); } void HrxlMaxsonarWrComponent::handle_line_(const char *line, size_t len) { diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index 4bdc30e4cf3..e22270f1f2e 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -32,10 +32,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await update.new_update(config) - ota_parent = await cg.get_variable(config[CONF_OTA_ID]) - cg.add(var.set_ota_parent(ota_parent)) - request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_request_parent(request_parent)) + await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID]) + await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID]) cg.add(var.set_source_url(config[CONF_SOURCE])) diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp index d0f9973b2a4..3d39b948b38 100644 --- a/esphome/components/husb238/husb238.cpp +++ b/esphome/components/husb238/husb238.cpp @@ -14,11 +14,8 @@ void Husb238Component::setup() { void Husb238Component::update() { uint8_t status; - if (!this->read_byte(REG_PD_STATUS1, &status)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_byte(REG_PD_STATUS1, &status))) return; - } - this->status_clear_warning(); #ifdef USE_BINARY_SENSOR if (this->attached_binary_sensor_ != nullptr) { diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index 9a81befcc45..c6bf03b72e1 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -59,14 +59,11 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { final_dout = this->dout_pin_->digital_read(); } - if (!final_dout) { + if (!this->status_comm_result(final_dout)) { ESP_LOGW(TAG, "HX711 DOUT pin not high after reading (data 0x%" PRIx32 ")!", data); - this->status_set_warning(); return false; } - this->status_clear_warning(); - data = static_cast(sign_extend<24>(data)); if (result != nullptr) diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 4a479874f53..c96e2a41ecb 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -106,18 +106,14 @@ void HydreonRGxxComponent::update() { } void HydreonRGxxComponent::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) { - continue; - } + this->read_available([this](uint8_t data) { size_t len; const char *line = this->line_reader_.feed(data, len); this->line_reader_.log_if_overflowed(TAG); if (line != nullptr) { this->process_line_(StringRef(line, len)); } - } + }); } /** diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 84f6d0c2e66..8598998f563 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -390,8 +390,7 @@ async def register_i2c_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) cg.add(var.set_i2c_address(config[CONF_ADDRESS])) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 3d5ec97a633..4abc807ee3d 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines @@ -98,6 +99,7 @@ I2S_PORTS = { VARIANT_ESP32P4: 3, VARIANT_ESP32S2: 1, VARIANT_ESP32S3: 2, + VARIANT_ESP32S31: 2, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index c2173172372..2ff709dbd1c 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -38,7 +38,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_( ) INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] -PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] +PDM_VARIANTS = [ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32S31, +] i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") I2S_PDM_DSR = { diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index e7d4be8cc3d..dbc6542ec81 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -135,13 +135,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_wifi_timeout(config[CONF_WIFI_TIMEOUT])) - if CONF_AUTHORIZER in config and config[CONF_AUTHORIZER] is not None: - activator = await cg.get_variable(config[CONF_AUTHORIZER]) - cg.add(var.set_authorizer(activator)) - - if CONF_STATUS_INDICATOR in config: - status_indicator = await cg.get_variable(config[CONF_STATUS_INDICATOR]) - cg.add(var.set_status_indicator(status_indicator)) + await cg.add_variable(var.set_authorizer, config.get(CONF_AUTHORIZER)) + await cg.add_variable(var.set_status_indicator, config.get(CONF_STATUS_INDICATOR)) if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 3e1aef21bb6..6bb5725b416 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -68,5 +68,5 @@ async def to_code(config: ConfigType) -> None: await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: - cg.add(var.set_uart(await cg.get_variable(uart_id))) + await cg.add_variable(var.set_uart, uart_id) cg.add_define("USE_IMPROV_SERIAL_UART") diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 987ce25d60b..2548f6312e7 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -1,4 +1,5 @@ #include "ina226.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -109,7 +110,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value (though always positive) - float bus_voltage_v = this->twos_complement_(raw_bus_voltage, 16); + float bus_voltage_v = sign_extend<16>(raw_bus_voltage); bus_voltage_v *= 0.00125f; this->bus_voltage_sensor_->publish_state(bus_voltage_v); } @@ -121,7 +122,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value - float shunt_voltage_v = this->twos_complement_(raw_shunt_voltage, 16); + float shunt_voltage_v = sign_extend<16>(raw_shunt_voltage); shunt_voltage_v *= 0.0000025f; this->shunt_voltage_sensor_->publish_state(shunt_voltage_v); } @@ -133,7 +134,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value - float current_ma = this->twos_complement_(raw_current, 16); + float current_ma = sign_extend<16>(raw_current); current_ma *= (this->calibration_lsb_ / 1000.0f); this->current_sensor_->publish_state(current_ma / 1000.0f); } @@ -151,11 +152,4 @@ void INA226Component::update() { this->status_clear_warning(); } -int32_t INA226Component::twos_complement_(int32_t val, uint8_t bits) { - if (val & ((uint32_t) 1 << (bits - 1))) { - val -= (uint32_t) 1 << bits; - } - return val; -} - } // namespace esphome::ina226 diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 00d62fad760..6fc7c20dd73 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -68,8 +68,6 @@ class INA226Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *shunt_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; - - int32_t twos_complement_(int32_t val, uint8_t bits); }; } // namespace esphome::ina226 diff --git a/esphome/components/ina260/sensor.py b/esphome/components/ina260/sensor.py index 20cf1fee45f..985defc06a8 100644 --- a/esphome/components/ina260/sensor.py +++ b/esphome/components/ina260/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, @@ -54,10 +53,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 4c36870b680..97f7f211370 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -172,11 +172,7 @@ void INA2XX::loop() { break; case State::DATA_COLLECTION_8: - if (this->full_loop_is_okay_) { - this->status_clear_warning(); - } else { - this->status_set_warning(); - } + this->status_comm_result(this->full_loop_is_okay_); this->state_ = State::IDLE; break; @@ -385,10 +381,10 @@ bool INA2XX::read_shunt_voltage_mv_(float &volt_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_VSHUNT, 3, raw); raw >>= 4; - volt_reading = this->two_complement_(raw, 20); + volt_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_VSHUNT, 2, raw); - volt_reading = this->two_complement_(raw, 16); + volt_reading = sign_extend<16>(raw); } if (ret) { @@ -412,10 +408,10 @@ bool INA2XX::read_bus_voltage_(float &volt_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_VBUS, 3, raw); raw >>= 4; - volt_reading = this->two_complement_(raw, 20); + volt_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_VBUS, 2, raw); - volt_reading = this->two_complement_(raw, 16); + volt_reading = sign_extend<16>(raw); } if (ret) { volt_out = this->cfg_.vbus_lsb * (float) volt_reading; @@ -436,11 +432,11 @@ bool INA2XX::read_die_temp_c_(float &temp_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_DIETEMP, 2, raw); - temp_reading = this->two_complement_(raw, 16); + temp_reading = sign_extend<16>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_DIETEMP, 2, raw); raw >>= 4; - temp_reading = this->two_complement_(raw, 12); + temp_reading = sign_extend<12>(raw); } if (ret) { temp_out = this->cfg_.die_temp_lsb * (float) temp_reading; @@ -461,10 +457,10 @@ bool INA2XX::read_current_a_(float &s_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_CURRENT, 3, raw); raw >>= 4; - amps_reading = this->two_complement_(raw, 20); + amps_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_CURRENT, 2, raw); - amps_reading = this->two_complement_(raw, 16); + amps_reading = sign_extend<16>(raw); } ESP_LOGV(TAG, "read_current_a_ ret=%s. current_lsb=%f. reading_lsb=%f", OKFAILED(ret), this->current_lsb_, diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index f6524021c08..03543cba3b3 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_EXTERNAL_TEMPERATURE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index d8e2c82cf6f..6a7dc63dbd5 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -93,8 +93,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await sensor.register_sensor(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) cg.add(var.set_time(config[CONF_TIME_UNIT])) cg.add(var.set_method(config[CONF_INTEGRATION_METHOD])) cg.add(var.set_restore(config[CONF_RESTORE])) diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index e0d6f683f0d..276f91eed59 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -982,8 +982,7 @@ void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, in switch (bitness) { case COLOR_BITNESS_565: { const size_t i = source_idx * 2; - color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] - : ptr[i] | (static_cast(ptr[i + 1]) << 8); + color_value = big_endian ? encode_uint16(ptr[i], ptr[i + 1]) : encode_value_le(&ptr[i]); break; } case COLOR_BITNESS_888: { diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index 0c7dd277381..502874a0022 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -14,19 +14,16 @@ void Jsnsr04tComponent::update() { } void Jsnsr04tComponent::loop() { - while (this->available() > 0) { - uint8_t data; - this->read_byte(&data); - + this->read_available([this](uint8_t data) { ESP_LOGV(TAG, "Read byte from sensor: %x", data); if (this->buffer_.empty() && data != 0xFF) - continue; + return; this->buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void Jsnsr04tComponent::check_buffer_() { diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 396f4514cfe..f69bafcaf16 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -77,7 +77,7 @@ void KamstrupKMPComponent::send_command_(uint16_t command) { msg[3] = command >> 8; msg[4] = command & 0xFF; - this->clear_uart_rx_buffer_(); + this->discard_available(); this->send_message_(msg, msg_len); this->read_command_(command); } @@ -122,13 +122,6 @@ void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) { this->write_array(tx_msg, tx_msg_len); } -void KamstrupKMPComponent::clear_uart_rx_buffer_() { - uint8_t tmp; - while (this->available()) { - this->read_byte(&tmp); - } -} - void KamstrupKMPComponent::read_command_(uint16_t command) { uint8_t buffer[20] = {0}; size_t buffer_len = 0; diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index 57a89f77a1c..49a80d73157 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -114,7 +114,6 @@ class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDev // Sends a message to the meter. A prefix/suffix and CRC are added void send_message_(const uint8_t *msg, int msg_len); // Clears and data that might be in the UART Rx buffer - void clear_uart_rx_buffer_(); // Reads and validates the response to a send command void read_command_(uint16_t command); // Parses a received message diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 8dafb5da070..dc50f8cdc0d 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -93,8 +93,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): - source = await cg.get_variable(source_conf) - cg.add(var.add_provider(source)) + await cg.add_variable(var.add_provider, source_conf) if CONF_MIN_LENGTH in config: cg.add(var.set_min_length(config[CONF_MIN_LENGTH])) if CONF_MAX_LENGTH in config: diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 40e7447d669..0278bb93e68 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -3,7 +3,6 @@ from esphome.components import modbus, sensor import esphome.config_validation as cv from esphome.const import ( CONF_EC, - CONF_ID, CONF_PH, CONF_TEMPERATURE, DEVICE_CLASS_EMPTY, @@ -97,9 +96,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PH, var.set_ph_sensor) diff --git a/esphome/components/lcd_menu/__init__.py b/esphome/components/lcd_menu/__init__.py index 483a36491db..b314a0ae02d 100644 --- a/esphome/components/lcd_menu/__init__.py +++ b/esphome/components/lcd_menu/__init__.py @@ -61,8 +61,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - disp = await cg.get_variable(config[CONF_DISPLAY_ID]) - cg.add(var.set_display(disp)) + await cg.add_variable(var.set_display, config[CONF_DISPLAY_ID]) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) await display_menu_to_code(var, config) cg.add(var.set_mark_selected(config[CONF_MARK_SELECTED])) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 52327d309e3..e14be5ac968 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -164,15 +164,6 @@ static inline int16_t decode_speed(uint8_t low_byte, uint8_t high_byte) { return speed * 10; // mm/s } -static inline int16_t hex_to_signed_int(const uint8_t *buffer, uint8_t offset) { - uint16_t hex_val = (buffer[offset + 1] << 8) | buffer[offset]; - int16_t dec_val = static_cast(hex_val); - if (dec_val & 0x8000) { - dec_val -= 65536; - } - return dec_val; -} - static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; } @@ -389,10 +380,10 @@ void LD2450Component::process_zone_() { uint8_t index, start; for (index = 0; index < MAX_ZONES; index++) { start = 12 + index * 8; - this->zone_config_[index].x1 = ld2450::hex_to_signed_int(this->buffer_data_, start); - this->zone_config_[index].y1 = ld2450::hex_to_signed_int(this->buffer_data_, start + 2); - this->zone_config_[index].x2 = ld2450::hex_to_signed_int(this->buffer_data_, start + 4); - this->zone_config_[index].y2 = ld2450::hex_to_signed_int(this->buffer_data_, start + 6); + this->zone_config_[index].x1 = encode_value_le(&this->buffer_data_[start]); + this->zone_config_[index].y1 = encode_value_le(&this->buffer_data_[start + 2]); + this->zone_config_[index].x2 = encode_value_le(&this->buffer_data_[start + 4]); + this->zone_config_[index].y2 = encode_value_le(&this->buffer_data_[start + 6]); #ifdef USE_NUMBER // only one null check as all coordinates are required for a single zone if (this->zone_numbers_[index].x1 != nullptr) { @@ -505,7 +496,7 @@ void LD2450Component::handle_periodic_data_() { #ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; - res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; + res = encode_value_le(&this->buffer_data_[start]); #endif // SPEED start = TARGET_SPEED + index * 8; diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index abeb6779cf4..c2d469575e7 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -1,6 +1,7 @@ #include "ld2460.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -117,8 +118,8 @@ void LD2460Component::handle_periodic_data_() { continue; } const uint8_t *p = &data[7 + i * 4]; - auto raw_x = static_cast(encode_uint16(p[1], p[0])); - auto raw_y = static_cast(encode_uint16(p[3], p[2])); + auto raw_x = encode_value_le(p); + auto raw_y = encode_value_le(&p[2]); if (raw_x == t.last_x && raw_y == t.last_y) { continue; } diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index deac04e86f9..b7dddb92fad 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -7,14 +7,7 @@ #include #ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" - -#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ - protected: \ - ld24xx::SensorWithDedup name##_sensor_{}; \ -\ - public: \ - void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } +#include "esphome/components/sensor/sensor_with_dedup.h" #endif #define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \ @@ -61,33 +54,6 @@ inline void format_version_str(const uint8_t *version, std::span buffe } #ifdef USE_SENSOR -/// Sensor with deduplication — sensor may be null, null check is internal. -/// Stored inline, no heap allocation. Does nothing when no sensor is set. -template class SensorWithDedup { - public: - void set_sensor(sensor::Sensor *sens) { - this->sens_ = sens; - this->dedup_ = {}; - } - - void publish_state_if_not_dup(T state) { - if (this->sens_ != nullptr && this->dedup_.next(state)) { - this->sens_->publish_state(static_cast(state)); - } - } - - void publish_state_unknown() { - if (this->sens_ != nullptr && this->dedup_.next_unknown()) { - this->sens_->publish_state(NAN); - } - } - - bool has_sensor() const { return this->sens_ != nullptr; } - sensor::Sensor *get_sensor() const { return this->sens_; } - - protected: - sensor::Sensor *sens_{nullptr}; - Deduplicator dedup_; -}; +template using SensorWithDedup = sensor::SensorWithDedup; #endif } // namespace esphome::ld24xx diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 48017a8f00e..5dccf0d725f 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -64,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) - if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): - pin = await cg.gpio_pin_expression(wakeup_pin_config) - cg.add(var.set_wakeup_pin(pin)) + await cg.add_pin(var.set_wakeup_pin, config.get(CONF_WAKEUP_PIN)) cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 42461228cef..51682358e7c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -203,22 +203,8 @@ static bool is_expected_control_report(uint32_t command, uint16_t type) { } #endif -uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } - -uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { - return static_cast(data[0]) | (static_cast(data[1]) << 8) | - (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); -} - -int32_t LD6002BComponent::read_int32_le(const uint8_t *data) { - uint32_t raw = read_u32_le(data); - int32_t value; - std::memcpy(&value, &raw, sizeof(value)); - return value; -} - float LD6002BComponent::read_f32_le(const uint8_t *data) { - uint32_t raw = read_u32_le(data); + uint32_t raw = encode_value_le(data); float value; std::memcpy(&value, &raw, sizeof(value)); return value; @@ -497,10 +483,7 @@ void LD6002BComponent::dump_config() { } void LD6002BComponent::loop() { - while (this->available()) { - uint8_t byte = this->read(); - this->parse_byte_(byte); - } + this->read_available([this](uint8_t byte) { this->parse_byte_(byte); }); this->process_command_queue_(); } @@ -540,9 +523,9 @@ void LD6002BComponent::parse_byte_(uint8_t byte) { this->header_xor_ ^= byte; this->header_pos_++; if (this->header_pos_ == 6) { - this->frame_id_ = read_u16_be(this->data_buf_); - this->data_len_ = read_u16_be(this->data_buf_ + 2); - this->frame_type_ = read_u16_be(this->data_buf_ + 4); + this->frame_id_ = encode_value(this->data_buf_); + this->data_len_ = encode_value(this->data_buf_ + 2); + this->frame_type_ = encode_value(this->data_buf_ + 4); // The length is only trustworthy once the header checksum has been verified, so just // remember that the frame is oversized and let the HCK state act on it. this->frame_oversize_ = this->data_len_ > this->max_data_len_; @@ -628,7 +611,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ #ifdef ESPHOME_LOG_HAS_VERBOSE const uint32_t active_control_command = (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? read_u32_le(this->active_command_.data.data()) + ? encode_value_le(this->active_command_.data.data()) : 0; if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, @@ -694,7 +677,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) if (len < 4) return; - uint32_t target_num = read_u32_le(data); + uint32_t target_num = encode_value_le(data); uint16_t available = (len - 4) / TARGET_DATA_LEN; // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". const uint32_t reported = std::min(target_num, available); @@ -707,7 +690,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) std::array slot_wire{}; for (uint8_t i = 0; i < count; i++) { uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; - wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); + wire_cluster[i] = encode_value_le(data + cluster_offset); } for (uint8_t i = 0; i < count; i++) { for (uint8_t s = 0; s < MAX_TARGETS; s++) { @@ -764,7 +747,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) float x = read_f32_le(data + offset + 0); float y = read_f32_le(data + offset + 4); float z = read_f32_le(data + offset + 8); - int32_t dop_idx = read_int32_le(data + offset + 12); + int32_t dop_idx = encode_value_le(data + offset + 12); int32_t cluster_id = this->slot_cluster_[i]; TargetSensors &target = this->targets_[i]; if (target.x != nullptr) { @@ -814,7 +797,7 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { return; #ifdef USE_SENSOR - uint32_t point_num = read_u32_le(data); + uint32_t point_num = encode_value_le(data); if (this->point_count_sensor_ != nullptr) { if (point_num != this->last_point_count_) { this->point_count_sensor_->publish_state(point_num); @@ -835,7 +818,7 @@ void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) this->area_presence_any_ = false; for (uint8_t i = 0; i < AREA_COUNT; i++) { - uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + uint32_t state = encode_value_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); bool present = state != 0; this->area_presence_any_ = this->area_presence_any_ || present; #ifdef USE_BINARY_SENSOR @@ -906,7 +889,7 @@ void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; #ifdef USE_NUMBER - uint32_t delay = read_u32_le(data); + uint32_t delay = encode_value_le(data); this->publish_number_clamped_(this->hold_delay_number_, delay); #endif } @@ -981,7 +964,7 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 if (len < 4) return; #ifdef USE_NUMBER - uint32_t sleep_ms = read_u32_le(data); + uint32_t sleep_ms = encode_value_le(data); this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); #endif } @@ -1118,7 +1101,7 @@ void LD6002BComponent::process_command_queue_() { if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { const uint32_t active_control_command = (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? read_u32_le(this->active_command_.data.data()) + ? encode_value_le(this->active_command_.data.data()) : 0; if (this->retries_left_ > 0) { #ifdef ESPHOME_LOG_HAS_VERBOSE diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index c52f770d6e9..663c73363ef 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -329,9 +329,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void apply_area_config_(); void wake_(); - static uint16_t read_u16_be(const uint8_t *data); - static uint32_t read_u32_le(const uint8_t *data); - static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); static void write_int32_le(uint8_t *data, int32_t value); diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index c490300b58c..dcd88a80ea4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -90,6 +90,25 @@ DOMAIN = "light" CONF_GAMMA_TABLE_ID = "gamma_table_id" CONF_RESUME_EFFECT = "resume_effect" +CONF_POWER_ESTIMATION = "power_estimation" +CONF_MA_PER_LED_RED = "ma_per_led_red" +CONF_MA_PER_LED_GREEN = "ma_per_led_green" +CONF_MA_PER_LED_BLUE = "ma_per_led_blue" +CONF_MA_PER_LED_WHITE = "ma_per_led_white" +CONF_IDLE_MA_PER_LED = "idle_ma_per_led" +CONF_MAX_CURRENT_MA = "max_current_ma" + +POWER_ESTIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MA_PER_LED_RED, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_GREEN, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_BLUE, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_WHITE, default=20.0): cv.positive_float, + cv.Optional(CONF_IDLE_MA_PER_LED, default=1.0): cv.positive_float, + cv.Optional(CONF_MAX_CURRENT_MA): cv.positive_not_null_float, + } +) + @dataclass class EffectRef: @@ -446,6 +465,7 @@ ADDRESSABLE_LIGHT_SCHEMA = RGB_LIGHT_SCHEMA.extend( [cv.percentage], cv.Length(min=3, max=4) ), cv.Optional(CONF_POWER_SUPPLY): cv.use_id(power_supply.PowerSupply), + cv.Optional(CONF_POWER_ESTIMATION): POWER_ESTIMATION_SCHEMA, } ) @@ -613,9 +633,21 @@ async def setup_light_core_(light_var, config, output_var): if (color_correct := config.get(CONF_COLOR_CORRECT)) is not None: cg.add(output_var.set_correction(*color_correct)) - if (power_supply_id := config.get(CONF_POWER_SUPPLY)) is not None: - var_ = await cg.get_variable(power_supply_id) - cg.add(output_var.set_power_supply(var_)) + await cg.add_variable(output_var.set_power_supply, config.get(CONF_POWER_SUPPLY)) + + if (power_estimation := config.get(CONF_POWER_ESTIMATION)) is not None: + cg.add_define("USE_LIGHT_POWER_ESTIMATION") + cg.add( + output_var.set_power_estimation( + power_estimation[CONF_MA_PER_LED_RED], + power_estimation[CONF_MA_PER_LED_GREEN], + power_estimation[CONF_MA_PER_LED_BLUE], + power_estimation[CONF_MA_PER_LED_WHITE], + power_estimation[CONF_IDLE_MA_PER_LED], + ) + ) + if (max_current_ma := power_estimation.get(CONF_MAX_CURRENT_MA)) is not None: + cg.add(output_var.set_max_current_ma(max_current_ma)) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, light_var) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index a6440700fa9..5292db13ac0 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -1,12 +1,134 @@ #include "addressable_light.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::light { ESPHOME_LOG_TAG(TAG, "light.addressable"); +#ifdef USE_LIGHT_POWER_ESTIMATION +float AddressableLightState::get_estimated_current_ma() const { + return static_cast(this->output_)->get_estimated_current_ma(); +} + +void AddressableLightState::loop() { + this->apply_effect_and_transformer_(); + if (this->next_write_) + static_cast(this->output_)->limit_power_(); + this->write_if_pending_(); +} + +float AddressableLight::get_estimated_current_ma() const { + return this->power_estimation_ == nullptr ? NAN : this->frame_ma_(); +} + +// Idle draw plus the weighted levels in the buffer +float AddressableLight::frame_ma_(float *slack_ma) const { + uint32_t sums[4]{}; + uint32_t lit[4]{}; + const int32_t count = this->size(); + for (int32_t i = 0; i < count; i++) { + const auto view = this->get_view_internal(i); + const uint8_t levels[4] = {view.get_red_raw(), view.get_green_raw(), view.get_blue_raw(), view.get_white_raw()}; + for (size_t channel = 0; channel < 4; channel++) { + sums[channel] += levels[channel]; + lit[channel] += levels[channel] != 0; + } + } + if (slack_ma != nullptr) { + const auto &power = *this->power_estimation_; + *slack_ma = 0.0f; + for (size_t channel = 0; channel < 4; channel++) + *slack_ma += lit[channel] * power.ma_per_level[channel]; + } + return this->draw_ma_(sums); +} + +float AddressableLight::draw_ma_(const uint32_t sums[4]) const { + const auto &power = *this->power_estimation_; + float ma = power.idle_ma; + for (size_t channel = 0; channel < 4; channel++) + ma += sums[channel] * power.ma_per_level[channel]; + return ma; +} + +void AddressableLight::setup_power_budget_() { + auto &power = *this->power_estimation_; + power.idle_ma = this->size() * power.idle_ma_per_led; + power.last_frame_ma = NAN; + if (std::isnan(power.max_current_ma)) + return; + power.budget_ma = power.max_current_ma - power.idle_ma; + if (power.budget_ma <= 0.0f) { + ESP_LOGW(TAG, "max_current_ma %.0f leaves nothing for the pixels after the idle draw of %.0f mA", + power.max_current_ma, power.idle_ma); + } +} + +// Rounded up on the way down so a lit channel stays lit, down on the way up so a level does not creep +static uint8_t rescale_level(uint8_t level, uint8_t scale, uint8_t previous) { + if (level == 0) + return 0; + const uint32_t scaled = static_cast(level) * scale; + const uint32_t moved = scale < previous ? (scaled + previous - 1) / previous : scaled / previous; + // A level written past the scale in force (set_raw, a partition over another strip) must not wrap + return std::clamp(moved, 1, 255); +} + +// The frame is moved to the new scale before it goes out, so every write path is limited +void AddressableLight::limit_power_() { + auto *power = this->power_estimation_; + if (power == nullptr || std::isnan(power->budget_ma)) + return; + // A frame the last pass already left in the buffer, as a driver retry sends, is not limited again: its + // levels were rounded up on the way down and read as a hair over budget, which would walk the scale down + float slack_ma; + const float frame_ma = this->frame_ma_(&slack_ma); + if (frame_ma == power->last_frame_ma) + return; + power->last_frame_ma = frame_ma; + // The stored levels already carry the limit in force; the scale comes from the draw the frame asked for + const uint8_t previous = this->correction_.get_power_brightness(); + const float intended_ma = (frame_ma - power->idle_ma) * 255.0f / previous; + // Never 0, which would black the strip out and swing the next estimate back to full + const uint8_t scale = intended_ma <= power->budget_ma + ? 255 + : static_cast(std::max(power->budget_ma / intended_ma * 255.0f, 1.0f)); + if (scale == previous) + return; + // Below full scale the levels were rounded up on the way down, so a frame can read as over budget by up + // to one level per lit channel. A drop the frame does not need once that is taken off would only move the + // scale under levels that stay where they are, and inflate what they read back as + if (previous < 255 && scale < previous && frame_ma - power->idle_ma - slack_ma <= power->budget_ma) + return; + this->correction_.set_power_brightness(scale); + // One divide per level instead of one per channel; ESP8266 has no hardware divide + uint8_t map[256]; + for (size_t level = 0; level < 256; level++) + map[level] = rescale_level(level, scale, previous); + uint32_t sums[4]{}; + for (auto view : *this) { + const uint8_t r = map[view.get_red_raw()], g = map[view.get_green_raw()], b = map[view.get_blue_raw()], + w = map[view.get_white_raw()]; + view.set_raw(r, g, b, w); + sums[0] += r; + sums[1] += g; + sums[2] += b; + sums[3] += w; + } + power->last_frame_ma = this->draw_ma_(sums); +} + +#endif + void AddressableLight::call_setup() { this->setup(); +#ifdef USE_LIGHT_POWER_ESTIMATION + if (this->power_estimation_ != nullptr) + this->setup_power_budget_(); +#endif #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE this->set_interval(5000, [this]() { diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 57e9caf289c..b6446f2b9d3 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -10,6 +10,8 @@ #include "light_state.h" #include "light_transformer.h" +#include + #ifdef USE_POWER_SUPPLY #include "esphome/components/power_supply/power_supply.h" #endif @@ -23,8 +25,28 @@ Color color_from_light_color_values(LightColorValues val); /// non-addressable lights. class AddressableLightState final : public LightState { using LightState::LightState; + + public: +#ifdef USE_LIGHT_POWER_ESTIMATION + float get_estimated_current_ma() const; + void loop() override; +#endif }; +#ifdef USE_LIGHT_POWER_ESTIMATION +// Allocated only for a strip with power_estimation configured +struct PowerEstimation { + float ma_per_level[4]; + float idle_ma_per_led; + float max_current_ma{NAN}; + // Set by call_setup() once size() is known; the budget is what is left for the pixels, NAN for no limit + float idle_ma{0.0f}; + float budget_ma{NAN}; + // The draw the last pass left in the buffer; a pass that sees it again has nothing new to limit + float last_frame_ma{NAN}; +}; +#endif + class AddressableLight : public LightOutput, public Component { public: virtual int32_t size() const = 0; @@ -65,6 +87,23 @@ class AddressableLight : public LightOutput, public Component { this->correction_.set_max_brightness( Color(to_uint8_scale(red), to_uint8_scale(green), to_uint8_scale(blue), to_uint8_scale(white))); } +#ifdef USE_LIGHT_POWER_ESTIMATION + // Per-channel draw at full level and idle draw per LED + void set_power_estimation(float ma_red, float ma_green, float ma_blue, float ma_white, float idle_ma_per_led) { + if (this->power_estimation_ == nullptr) + this->power_estimation_ = new PowerEstimation(); // NOLINT(cppcoreguidelines-owning-memory) + auto *power = this->power_estimation_; + power->ma_per_level[0] = ma_red / 255.0f; + power->ma_per_level[1] = ma_green / 255.0f; + power->ma_per_level[2] = ma_blue / 255.0f; + power->ma_per_level[3] = ma_white / 255.0f; + power->idle_ma_per_led = idle_ma_per_led; + } + // Called after set_power_estimation() + void set_max_current_ma(float max_current_ma) { this->power_estimation_->max_current_ma = max_current_ma; } + // The draw of the frame in the buffer, NAN without power_estimation + float get_estimated_current_ma() const; +#endif void setup_state(LightState *state) override { #ifdef USE_LIGHT_GAMMA_LUT this->correction_.set_gamma_table(state->get_gamma_table()); @@ -96,8 +135,22 @@ class AddressableLight : public LightOutput, public Component { } virtual ESPColorView get_view_internal(int32_t index) const = 0; +#ifdef USE_LIGHT_POWER_ESTIMATION + friend class AddressableLightState; + // Run by the light state before each frame goes out + void limit_power_(); + void setup_power_budget_(); + // slack_ma, when asked for, is one level's draw for every lit channel: the most the round-up on the way + // down can have added to the frame + float frame_ma_(float *slack_ma = nullptr) const; + float draw_ma_(const uint32_t sums[4]) const; +#endif + ESPColorCorrection correction_{}; LightState *state_parent_{nullptr}; +#ifdef USE_LIGHT_POWER_ESTIMATION + PowerEstimation *power_estimation_{nullptr}; +#endif #ifdef USE_POWER_SUPPLY power_supply::PowerSupplyRequester power_; #endif diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2e963479f76..fb4a156f7be 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,29 +8,6 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction final : public Action { - public: - explicit ToggleAction(LightState *state) : state_(state) {} - - template void set_transition_length(V value) requires(HasTransitionLength) { - this->transition_length_ = value; - } - - void play(const Ts &...x) override { - auto call = this->state_->toggle(); - if constexpr (HasTransitionLength) { - call.set_transition_length(this->transition_length_.optional_value(x...)); - } - call.perform(); - } - - protected: - LightState *state_; - struct NoTransition {}; - [[no_unique_address]] std::conditional_t, NoTransition> - transition_length_{}; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index a616d26571d..df2c4c16445 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -46,7 +46,6 @@ from .types import ( DimRelativeAction, LightEffectCycleAction, LightState, - ToggleAction, ) _LOGGER = logging.getLogger(__name__) @@ -76,9 +75,8 @@ def validate_light_state(value: Any) -> Any: ) from err -@automation.register_action( +automation.register_apply_action( "light.toggle", - ToggleAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(LightState), @@ -87,19 +85,9 @@ def validate_light_state(value: Any) -> Any: ), } ), - synchronous=True, + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="toggle", ) -async def light_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - has_transition_length = CONF_TRANSITION_LENGTH in config - toggle_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) - var = cg.new_Pvariable(action_id, toggle_template_arg, paren) - if has_transition_length: - template_ = await cg.templatable( - config[CONF_TRANSITION_LENGTH], args, cg.uint32 - ) - cg.add(var.set_transition_length(template_)) - return var class LightStateField(NamedTuple): diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 12eb6a30089..567d1c3c0b3 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -32,12 +32,34 @@ Color ESPColorCorrection::color_uncorrect(Color color) const { this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); } +// Under the limit, gamma and the limit are undone together: the largest level that encodes to at most the +// stored one, so a read, change and write of a pixel re-encodes to what was stored +uint8_t ESPColorCorrection::uncorrect_level_(uint8_t value) const { +#ifdef USE_LIGHT_POWER_ESTIMATION + if (this->power_brightness_ == 255) + return this->gamma_uncorrect_(value); + uint8_t lo = 0; + uint8_t hi = 255; + while (lo < hi) { + const uint8_t mid = (lo + hi + 1) / 2; + if (this->limit_(this->gamma_correct_(mid)) <= value) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; +#else + return this->gamma_uncorrect_(value); +#endif +} + uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { if (max_brightness == 0 || this->local_brightness_ == 0) return 0; // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. - uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint32_t uncorrected = this->uncorrect_level_(value) * 255UL; uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; return static_cast(std::min(res, uint32_t(255))); } diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 0295fb36b8c..46b8e9db5da 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -1,8 +1,11 @@ #pragma once #include "esphome/core/color.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include + namespace esphome::light { /// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from @@ -30,6 +33,11 @@ class ESPColorCorrection { public: void set_max_brightness(const Color &max_brightness) { this->max_brightness_ = max_brightness; } void set_local_brightness(uint8_t local_brightness) { this->local_brightness_ = local_brightness; } +#ifdef USE_LIGHT_POWER_ESTIMATION + // The current limit's factor, applied after gamma + void set_power_brightness(uint8_t power_brightness) { this->power_brightness_ = power_brightness; } + uint8_t get_power_brightness() const { return this->power_brightness_; } +#endif void set_gamma_table(const uint16_t *table) { this->gamma_table_ = table; } inline Color color_correct(Color color) const ESPHOME_ALWAYS_INLINE { // corrected = (uncorrected * max_brightness * local_brightness) ^ gamma @@ -37,20 +45,16 @@ class ESPColorCorrection { this->color_correct_blue(color.blue), this->color_correct_white(color.white)); } inline uint8_t color_correct_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(red, this->max_brightness_.red, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(red, this->max_brightness_.red); } inline uint8_t color_correct_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(green, this->max_brightness_.green, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(green, this->max_brightness_.green); } inline uint8_t color_correct_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(blue, this->max_brightness_.blue, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(blue, this->max_brightness_.blue); } inline uint8_t color_correct_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(white, this->max_brightness_.white); } Color color_uncorrect(Color color) const; inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { @@ -67,8 +71,21 @@ class ESPColorCorrection { } protected: + inline uint8_t color_correct_channel_(uint8_t value, uint8_t max_brightness) const ESPHOME_ALWAYS_INLINE { + return this->limit_(this->gamma_correct_(esp_scale8_twice(value, max_brightness, this->local_brightness_))); + } + // The limit on a gamma-corrected level; rounded up, so a lit channel stays lit + inline uint8_t limit_(uint8_t res) const ESPHOME_ALWAYS_INLINE { +#ifdef USE_LIGHT_POWER_ESTIMATION + if (res != 0) + res = std::max(esp_scale8(res, this->power_brightness_), 1); +#endif + return res; + } /// Forward gamma: read uint16 PROGMEM table, convert to uint8 uint8_t gamma_correct_(uint8_t value) const; + /// Reverse gamma and limit for one stored level + uint8_t uncorrect_level_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line @@ -78,6 +95,9 @@ class ESPColorCorrection { const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; uint8_t local_brightness_{255}; +#ifdef USE_LIGHT_POWER_ESTIMATION + uint8_t power_brightness_{255}; +#endif }; } // namespace esphome::light diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 018e30a9a81..f764f7667da 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -89,9 +89,18 @@ class ESPColorView : public ESPColorSettable { return 0; return *this->effect_data_; } + const ESPColorCorrection *get_color_correction() const { return this->color_correction_; } void raw_set_color_correction(const ESPColorCorrection *color_correction) { this->color_correction_ = color_correction; } + /// Store hardware levels as they are, without the correction + void set_raw(uint8_t red, uint8_t green, uint8_t blue, uint8_t white) { + *this->red_ = red; + *this->green_ = green; + *this->blue_ = blue; + if (this->white_ != nullptr) + *this->white_ = white; + } protected: uint8_t *const red_; diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 5d372983d90..fe7c4a086eb 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -72,15 +72,18 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT if (rhs.begin_ == this->begin_) return *this; + // The source is read before the destination view is fetched: a partition's views share one correction if (rhs.begin_ > this->begin_) { // Copy from left for (int32_t i = 0; i < this->size(); i++) { - (*this)[i].set(rhs[i].get()); + const Color color = rhs[i].get(); + (*this)[i].set(color); } } else { // Copy from right for (int32_t i = this->size() - 1; i >= 0; i--) { - (*this)[i].set(rhs[i].get()); + const Color color = rhs[i].get(); + (*this)[i].set(color); } } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index d8293338897..84db3653a36 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -121,6 +121,11 @@ void LightState::dump_config() { } } void LightState::loop() { + this->apply_effect_and_transformer_(); + this->write_if_pending_(); +} + +void LightState::apply_effect_and_transformer_() { // Apply effect (if any) auto *effect = this->get_active_effect_(); if (effect != nullptr) { @@ -171,8 +176,9 @@ void LightState::loop() { this->disable_loop_if_idle_(); } } +} - // Write state to the light +void LightState::write_if_pending_() { if (this->next_write_) { this->next_write_ = false; this->output_->write_state(this); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 946b4efae58..86a132c1434 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -313,6 +313,9 @@ class LightState : public EntityBase, public Component { bool is_transformer_active() const { return this->is_transformer_active_; } protected: + // The two halves of loop() + void apply_effect_and_transformer_(); + void write_if_pending_(); friend LightOutput; friend LightCall; friend class AddressableLight; diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 23d1d7e00ea..a6cc2c7efed 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -38,7 +38,6 @@ LimitMode = light_ns.enum("LimitMode", is_class=True) LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING") # Actions -ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 7af60be87a4..949ebe14509 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -291,11 +291,8 @@ void LIS2DW12Component::loop() { // SIXD_SRC and ALL_INT_SRC are adjacent, so one read captures both before any latch clears uint8_t regs[2]; - if (!this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs))) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs)))) return; - } - this->status_clear_warning(); RegSixdSrc sixd; sixd.raw = regs[0]; RegAllIntSrc src; @@ -315,11 +312,9 @@ void LIS2DW12Component::loop() { } void LIS2DW12Component::update() { - float x, y, z; - if (!this->read_acceleration_data_(x, y, z)) { - this->status_set_warning(); + float x{}, y{}, z{}; + if (!this->status_comm_result(this->read_acceleration_data_(x, y, z))) return; - } ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); @@ -331,8 +326,6 @@ void LIS2DW12Component::update() { if (this->acceleration_z_sensor_ != nullptr) this->acceleration_z_sensor_->publish_state(z); #endif - - this->status_clear_warning(); } void LIS2DW12Component::set_offset(float offset_x, float offset_y, float offset_z) { diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 388f5dfd93a..17135652088 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -1,4 +1,5 @@ #include "lm75b.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -21,7 +22,7 @@ void LM75BComponent::update() { return; } // Obtain combined 16-bit value - int16_t raw_temperature = (buff[0] << 8) | buff[1]; + int16_t raw_temperature = encode_value(buff); // Read the 11-bit raw temperature value raw_temperature >>= 5; // Publish the temperature in °C diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 0d2b3a51804..607ea4448fd 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -126,8 +126,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_LN882H_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_LN882H_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. ln882h_ble.request_scan_listener_slot() diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index da392ea5c04..2010f93f0c3 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -73,11 +73,7 @@ void LP5562Output::dump_config() { void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { if (this->is_failed()) return; - if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { - this->status_set_warning(); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)); } void LP5562Channel::write_state(float state) { diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp index 680b3bcd9e7..0eda7d9864d 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp @@ -4,12 +4,9 @@ namespace esphome::m5stack_8angle { void M5Stack8AngleSwitchBinarySensor::update() { int8_t out = this->parent_->read_switch(); - if (out == -1) { - this->status_set_warning(LOG_STR("Could not read binary sensor state from M5Stack 8Angle.")); + if (!this->status_comm_result(out != -1, LOG_STR("Could not read binary sensor state from M5Stack 8Angle."))) return; - } this->publish_state(out != 0); - this->status_clear_warning(); } } // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp index b05e1e68163..24a40dd9d4d 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp @@ -5,17 +5,14 @@ namespace esphome::m5stack_8angle { void M5Stack8AngleKnobSensor::update() { if (this->parent_ != nullptr) { int32_t raw_pos = this->parent_->read_knob_pos_raw(this->channel_, this->bits_); - if (raw_pos == -1) { - this->status_set_warning(LOG_STR("Could not read knob position from M5Stack 8Angle.")); + if (!this->status_comm_result(raw_pos != -1, LOG_STR("Could not read knob position from M5Stack 8Angle."))) return; - } if (this->raw_) { this->publish_state(raw_pos); } else { float knob_pos = (float) raw_pos / ((1 << this->bits_) - 1); this->publish_state(knob_pos); } - this->status_clear_warning(); }; } diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp index 1caf8ab308b..6160506484d 100644 --- a/esphome/components/max17048/max17048.cpp +++ b/esphome/components/max17048/max17048.cpp @@ -55,11 +55,7 @@ void MAX17048Component::update() { ok = false; } } - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR("Register read failed")); - } + this->status_comm_result(ok, LOG_STR("Register read failed")); } void MAX17048Component::dump_config() { diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp index 5ff8e6f1ef0..f8787e71d50 100644 --- a/esphome/components/max31888/max31888.cpp +++ b/esphome/components/max31888/max31888.cpp @@ -88,7 +88,7 @@ bool MAX31888Sensor::read_temperature_(int16_t &raw) { this->status_set_warning(LOG_STR("checksum invalid")); return false; } - raw = static_cast(encode_uint16(frame[3], frame[4])); + raw = encode_value(&frame[3]); return true; } diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index 2fc8dbb73d7..14eaca89164 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -31,17 +31,15 @@ void MAX6675Sensor::read_data_() { uint16_t val = data[1] | (uint16_t(data[0]) << 8); this->disable(); - if ((val & 0x04) != 0) { - // Thermocouple open + // Bit 2 set means the thermocouple is open + if (!this->status_comm_result((val & 0x04) == 0, nullptr)) { ESP_LOGW(TAG, "Got invalid value from MAX6675Sensor (0x%04X)", val); - this->status_set_warning(); return; } float temperature = float(val >> 3) / 4.0f; ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", this->name_.c_str(), temperature); this->publish_state(temperature); - this->status_clear_warning(); } } // namespace esphome::max6675 diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 6dd9dc0b09f..c2e5f71d4f5 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -2,21 +2,14 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, - CONF_PULLUP, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT, CONF_PULLUP from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] DOMAIN = "max6956" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -56,23 +49,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -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") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - CONF_MAX6956 = "max6956" MAX6956_PIN_SCHEMA = pins.gpio_base_schema( MAX6956GPIOPin, cv.int_range(min=4, max=31), modes=[CONF_INPUT, CONF_PULLUP, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output_pullup, ).extend( { cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956), @@ -82,16 +65,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) async def max6956_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MAX6956]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MAX6956) automation.register_apply_action( diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index 7060a8c76b7..05814dcc46d 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -155,15 +155,6 @@ void MAX6956::dump_config() { } } -/************************************** - * MAX6956GPIOPin * - **************************************/ -void MAX6956GPIOPin::setup() { pin_mode(flags_); } -void MAX6956GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MAX6956GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MAX6956GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t MAX6956GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via Max6956", this->pin_); -} - } // namespace esphome::max6956 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::max6956::MAX6956, "%u via Max6956") diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 4dbee165284..956a2bdbcfc 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::max6956 { @@ -69,26 +70,8 @@ class MAX6956 final : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin final : public GPIOPin { - 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_parent(MAX6956 *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MAX6956 *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MAX6956GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::max6956 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::max6956::MAX6956); diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index ab77bc5a5b3..d33b1e43ae4 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -2,15 +2,7 @@ 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.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -38,16 +30,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(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 + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) CONF_MCP23016 = "mcp23016" @@ -55,7 +38,7 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( MCP23016GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -66,13 +49,4 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23016, MCP23016_PIN_SCHEMA) async def mcp23016_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MCP23016]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MCP23016) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 13d930fd470..d5a16f9964c 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -92,12 +92,6 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) { } } -void MCP23016GPIOPin::setup() { pin_mode(flags_); } -void MCP23016GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MCP23016GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MCP23016GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t MCP23016GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via MCP23016", this->pin_); -} - } // namespace esphome::mcp23016 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::mcp23016::MCP23016, "%u via MCP23016") diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 14c0c9a2fc4..a55d4dc6187 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::mcp23016 { @@ -56,26 +57,8 @@ class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expa InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin final : public GPIOPin { - 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_parent(MCP23016 *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MCP23016 *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MCP23016GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::mcp23016 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::mcp23016::MCP23016); diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 942a975813a..5f018412b66 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -7,9 +7,6 @@ from esphome.const import ( CONF_INPUT, CONF_INTERRUPT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OPEN_DRAIN_INTERRUPT, CONF_OUTPUT, CONF_PULLUP, @@ -47,28 +44,17 @@ async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: await cg.register_component(var, config) CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) return var -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") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - CONF_MCP23XXX = "mcp23xxx" MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( MCP23XXXGPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT, CONF_PULLUP], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output_pullup, invertible=True, ).extend( { @@ -83,16 +69,9 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] - parent = await cg.get_variable(parent_id) - + # Wait for the hub so its to_code has recorded the pin count + await cg.get_variable(parent_id) num_pins = cg.TemplateArguments(CORE.data[CONF_MCP23XXX][parent_id.id]) - - var = cg.new_Pvariable(config[CONF_ID], num_pins) - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + var = await pins.new_parented_pin(config, CONF_MCP23XXX, num_pins) cg.add(var.set_interrupt_mode(config[CONF_INTERRUPT])) return var diff --git a/esphome/components/mcp3221/mcp3221_sensor.cpp b/esphome/components/mcp3221/mcp3221_sensor.cpp index d38883c3421..94e7174baa7 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.cpp +++ b/esphome/components/mcp3221/mcp3221_sensor.cpp @@ -8,12 +8,10 @@ ESPHOME_LOG_TAG(TAG, "mcp3221"); float MCP3221Sensor::sample() { uint8_t data[2]; - if (this->read(data, 2) != i2c::ERROR_OK) { + if (!this->status_comm_result(this->read(data, 2) == i2c::ERROR_OK)) { ESP_LOGW(TAG, "Read failed"); - this->status_set_warning(); return NAN; } - this->status_clear_warning(); uint16_t value = encode_uint16(data[0], data[1]); float voltage = value * this->reference_voltage_ / 4096.0f; diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 9a5afe64c28..9953f450145 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_THERMOCOUPLE_TYPE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -57,10 +56,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema( 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) + var = await i2c.new_i2c_component(config) cg.add(var.set_thermocouple_type(config[CONF_THERMOCOUPLE_TYPE])) diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 309651970ca..2c96b4119d3 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -68,14 +68,11 @@ void MCP9808Sensor::update() { temp = (uint16_t) (msb) *16 + lsb / 16.0f; } - if (std::isnan(temp)) { - this->status_set_warning(); + if (!this->status_comm_result(!std::isnan(temp), nullptr)) return; - } ESP_LOGD(TAG, "%s: Got temperature=%.4f°C", this->name_.c_str(), temp); this->publish_state(temp); - this->status_clear_warning(); } } // namespace esphome::mcp9808 diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index eda4b4d8e78..3eb71c1adad 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -66,13 +66,11 @@ void MHZ19Component::update() { } uint8_t checksum = mhz19_checksum(response); - if (response[8] != checksum) { + if (!this->status_comm_result(response[8] == checksum)) { ESP_LOGW(TAG, "MHZ19 Checksum doesn't match: 0x%02X!=0x%02X", response[8], checksum); - this->status_set_warning(); return; } - this->status_clear_warning(); const uint16_t ppm = (uint16_t(response[2]) << 8) | response[3]; const int temp = int(response[4]) - 40; const uint8_t status = response[5]; diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index 957cdf90ef2..c42ca7ab465 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -43,11 +43,8 @@ void MICS4514Component::update() { return; } uint8_t data[6]; - if (this->read_register(SENSOR_REGISTER, data, 6) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register(SENSOR_REGISTER, data, 6) == i2c::ERROR_OK)) return; - } - this->status_clear_warning(); ESP_LOGV(TAG, "Got data: %02X %02X %02X %02X %02X %02X", data[0], data[1], data[2], data[3], data[4], data[5]); uint16_t ox = encode_uint16(data[0], data[1]); uint16_t red = encode_uint16(data[2], data[3]); diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index cecbf9836ef..f5c1ed71b84 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -221,8 +221,7 @@ async def to_code(config): if CONF_TRANSMITTER_ID in config: remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter_)) + await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) 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])) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 380c3a4dfd6..fffd1aacf44 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -227,11 +226,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( - { - model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( - 0, 0xFF, min_included=True, max_included=True - ), - } + {model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.uint8_t} ) if bus_mode != TYPE_SINGLE: return cv.All(schema, cv.only_on_esp32) @@ -413,10 +408,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py index aec5d9b4fdf..2fe4c872dfe 100644 --- a/esphome/components/mipi_spi/light/__init__.py +++ b/esphome/components/mipi_spi/light/__init__.py @@ -32,8 +32,8 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, - cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), - cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.uint8_t, + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.uint8_t, } ), _validate_brightness_range, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index f62bee5f602..4846635ac85 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -55,6 +55,8 @@ ILI9341.extend( GC9107 = ST7789V.extend( "GC9107", + native_width=132, + native_height=130, width=128, height=128, offset_width=2, @@ -73,6 +75,15 @@ GC9107.extend( requires={"psram"}, ) +GC9107.extend( + "M5STACK-ATOMS3", + data_rate="40MHz", + invert_colors=True, + reset_pin=34, + dc_pin=33, + cs_pin=15, +) + # M5Stack AtomS3R (ST7735 revision - May 14, 2026+) ST7735.extend( "M5STACK-ATOMS3R-ST7735", diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a1f15aab452..258ecd51bac 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -9,7 +9,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ROLE import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType, TemplateArgsType @@ -301,10 +301,7 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: cg.add_global(modbus_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 67c3cd75bdc..25f4b230dbb 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -186,8 +186,7 @@ async def register_client_action( response_args are the on_response handler's arguments, which differ per action. """ - parent = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[modbus.CONF_MODBUS_ID]) cg.add( var.set_target_address( await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index 364a0a510e8..333196aaa0d 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -1,3 +1,5 @@ +from esphome.components.const import CONF_VALUE_TYPE # noqa: F401 # pylint: disable=unused-import + CONF_ALLOW_DUPLICATE_COMMANDS = "allow_duplicate_commands" CONF_BITMASK = "bitmask" CONF_BYTE_OFFSET = "byte_offset" @@ -23,5 +25,4 @@ CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" CONF_USE_WRITE_MULTIPLE = "use_write_multiple" -CONF_VALUE_TYPE = "value_type" CONF_WRITE_LAMBDA = "write_lambda" diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index 86366c7ce02..91293818fca 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -1,6 +1,7 @@ +from esphome.components.const import CONF_VALUE_TYPE # noqa: F401 # pylint: disable=unused-import + CONF_REGISTER_LAST_ADDRESS = "register_last_address" CONF_REGISTER_VALUE = "register_value" -CONF_VALUE_TYPE = "value_type" CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 761e5831c11..8f4147d0188 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_DISTANCE, - CONF_ID, CONF_LEVEL, CONF_MAC_ADDRESS, CONF_TEMPERATURE, @@ -132,9 +131,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index b3e55766d8a..644684afa67 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_DISTANCE, - CONF_ID, CONF_LEVEL, CONF_MAC_ADDRESS, CONF_TEMPERATURE, @@ -103,9 +102,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 58f346ab77a..2e0dd7bd2bc 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -61,7 +61,7 @@ void MPL3115A2Component::update() { if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } - int16_t t = encode_uint16(buffer[3], buffer[4]); + int16_t t = encode_value(&buffer[3]); float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index 1764ff9c054..3d75451ef82 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -7,8 +7,6 @@ from esphome.const import ( CONF_CHANNEL, CONF_ID, CONF_INPUT, - CONF_INVERTED, - CONF_MODE, CONF_NUMBER, CONF_OUTPUT, ) @@ -24,12 +22,15 @@ CONF_MAX_TOUCH_CHANNEL = "max_touch_channel" CONF_MPR121 = "mpr121" CONF_MPR121_ID = "mpr121_id" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] DOMAIN = "mpr121" mpr121_ns = cg.esphome_ns.namespace("mpr121") MPR121Component = mpr121_ns.class_("MPR121Component", cg.Component, i2c.I2CDevice) -MPR121GPIOPin = mpr121_ns.class_("MPR121GPIOPin", cg.GPIOPin) +MPR121GPIOPin = mpr121_ns.class_( + "MPR121GPIOPin", cg.GPIOPin, cg.Parented.template(MPR121Component) +) MULTI_CONF = True CONFIG_SCHEMA = ( @@ -85,12 +86,6 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) -def validate_mode(value: ConfigType) -> ConfigType: - if bool(value[CONF_INPUT]) == bool(value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - return value - - # https://www.nxp.com/docs/en/data-sheet/MPR121.pdf, page 4 # # Among the 12 electrode inputs, 8 inputs are designed as multifunctional pins. When these pins are @@ -100,7 +95,7 @@ MPR121_GPIO_PIN_SCHEMA = pins.gpio_base_schema( MPR121GPIOPin, cv.int_range(min=4, max=11), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_MPR121): cv.use_id(MPR121Component), @@ -121,13 +116,4 @@ def mpr121_pin_final_validate( CONF_MPR121, MPR121_GPIO_PIN_SCHEMA, mpr121_pin_final_validate ) async def mpr121_gpio_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MPR121]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MPR121) diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index bbcee223e8c..2dd8614a323 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -80,9 +80,10 @@ void MPR121Component::loop() { this->read_byte(MPR121_GPIODATA, &this->gpio_input_); } -bool MPR121Component::digital_read(uint8_t ionum) { return (this->gpio_input_ & (1 << ionum)) != 0; } +bool MPR121Component::digital_read(uint8_t pin) { return (this->gpio_input_ & (1 << (pin - 4))) != 0; } -void MPR121Component::digital_write(uint8_t ionum, bool value) { +void MPR121Component::digital_write(uint8_t pin, bool value) { + const uint8_t ionum = pin - 4; if (value) { this->gpio_output_ |= (1 << ionum); } else { @@ -91,7 +92,8 @@ void MPR121Component::digital_write(uint8_t ionum, bool value) { this->flush_gpio_(); } -void MPR121Component::pin_mode(uint8_t ionum, gpio::Flags flags) { +void MPR121Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t ionum = pin - 4; this->gpio_enable_ |= (1 << ionum); if (flags & gpio::FLAG_INPUT) { this->gpio_direction_ &= ~(1 << ionum); @@ -112,34 +114,9 @@ bool MPR121Component::flush_gpio_() { this->write_byte(MPR121_GPIOEN, this->gpio_enable_); this->write_byte(MPR121_GPIODIR, this->gpio_direction_); - if (!this->write_byte(MPR121_GPIODATA, this->gpio_output_)) { - this->status_set_warning(); - return false; - } - - this->status_clear_warning(); - return true; -} - -void MPR121GPIOPin::setup() { this->pin_mode(this->flags_); } - -void MPR121GPIOPin::pin_mode(gpio::Flags flags) { - assert(this->pin_ >= 4); - this->parent_->pin_mode(this->pin_ - 4, flags); -} - -bool MPR121GPIOPin::digital_read() { - assert(this->pin_ >= 4); - return this->parent_->digital_read(this->pin_ - 4) != this->inverted_; -} - -void MPR121GPIOPin::digital_write(bool value) { - assert(this->pin_ >= 4); - this->parent_->digital_write(this->pin_ - 4, value != this->inverted_); -} - -size_t MPR121GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "ELE%u on MPR121", this->pin_); + return this->status_comm_result(this->write_byte(MPR121_GPIODATA, this->gpio_output_)); } } // namespace esphome::mpr121 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::mpr121::MPR121Component, "ELE%u on MPR121") diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 4d945ded872..f998f015831 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include @@ -73,10 +74,10 @@ class MPR121Component final : public Component, public i2c::I2CDevice { void set_max_touch_channel(uint8_t max_touch_channel) { this->max_touch_channel_ = max_touch_channel; } - // GPIO helper functions. - bool digital_read(uint8_t ionum); - void digital_write(uint8_t ionum, bool value); - void pin_mode(uint8_t ionum, gpio::Flags flags); + // GPIO helper functions; `pin` is the electrode number, 4 to 11, of the shared GPIO pins. + bool digital_read(uint8_t pin); + void digital_write(uint8_t pin, bool value); + void pin_mode(uint8_t pin, gpio::Flags flags); protected: std::vector channels_{}; @@ -98,26 +99,8 @@ class MPR121Component final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin final : public GPIOPin { - 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_parent(MPR121Component *parent) { this->parent_ = parent; } - 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: - MPR121Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MPR121GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::mpr121 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::mpr121::MPR121Component); diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py index 17734e00459..79c73eebe46 100644 --- a/esphome/components/mt6701_i2c/__init__.py +++ b/esphome/components/mt6701_i2c/__init__.py @@ -155,9 +155,7 @@ CONFIG_SCHEMA = ( 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) + var = await i2c.new_i2c_component(config) if (direction := config.get(CONF_DIRECTION)) is not None: cg.add(var.set_direction(direction)) diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py index 6f45e347b7d..3498dde0e8a 100644 --- a/esphome/components/mt6701_spi/__init__.py +++ b/esphome/components/mt6701_spi/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType from ..mt6701 import MT6701Component @@ -42,6 +41,4 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index cc24353782e..813321fac9e 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -1,5 +1,6 @@ #include "nau7802.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -226,11 +227,7 @@ int32_t NAU7802Sensor::read_value_(uint8_t start_reg, size_t size) { for (size_t i = 0; i < size; i++) { result |= data[i] << (size - 1 - i) * 8; } - // extend sign bit - if (result & 0x800000 && size == 3) { - result |= 0xFF000000; - } - return result; + return size == 3 ? sign_extend<24>(result) : result; } bool NAU7802Sensor::calibrate_(enum NAU7802CalibrationModes mode) { @@ -292,14 +289,11 @@ void NAU7802Sensor::loop() { } void NAU7802Sensor::update() { - if (!this->is_data_ready_()) { + if (!this->status_comm_result(this->is_data_ready_(), nullptr)) { ESP_LOGW(TAG, "No measurements ready!"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Get the most recent sample to publish int32_t result = this->read_value_(ADCO_B2_REG, 3); diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 969a6c8e8c9..92255a6eda2 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BRIGHTNESS, CONF_ID, - CONF_LAMBDA, CONF_ON_TOUCH, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -232,11 +231,7 @@ async def to_code(config): if CONF_BRIGHTNESS in config: cg.add(var.set_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(nextion_ref, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, nextion_ref) if CONF_TFT_URL in config: cg.add_define("USE_NEXTION_TFT_UPLOAD") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b9194b3f31c..a5ac2325369 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -180,11 +180,7 @@ void Nextion::release_queue_entry_(NextionQueue *nb) { } void Nextion::reset_(bool reset_nextion) { - uint8_t d; - - while (this->available()) { // Clear receive buffer - this->read_byte(&d); - } + this->discard_available(); for (auto *entry : this->nextion_queue_) { this->release_queue_entry_(entry); } diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 374fd707561..caf7decde94 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -104,13 +104,11 @@ void OPT3001Sensor::update() { this->read_lx_([this](float val) { this->updating_ = false; - if (std::isnan(val)) { - this->status_set_warning(); + if (!this->status_comm_result(!std::isnan(val))) { this->publish_state(NAN); return; } ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), val); - this->status_clear_warning(); this->publish_state(val); }); } diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index c36d421a355..e63782c26e4 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -7,6 +7,7 @@ from typing import Any import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID from esphome.components.sensor import Sensor import esphome.config_validation as cv from esphome.const import ( @@ -34,9 +35,7 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "packet_transport" CONF_BROADCAST = "broadcast" CONF_BROADCAST_ID = "broadcast_id" -CONF_PROVIDER = "provider" CONF_PROVIDERS = "providers" -CONF_REMOTE_ID = "remote_id" CONF_PING_PONG_ENABLE = "ping_pong_enable" CONF_PING_PONG_RECYCLE_TIME = "ping_pong_recycle_time" CONF_ROLLING_CODE_ENABLE = "rolling_code_enable" diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 37c46882423..c670190af05 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID import esphome.config_validation as cv from esphome.const import ( CONF_DATA, @@ -16,9 +17,7 @@ from esphome.types import ConfigType from . import ( CONF_ENCRYPTION, CONF_PING_PONG_ENABLE, - CONF_PROVIDER, CONF_PROVIDERS, - CONF_REMOTE_ID, CONF_TRANSPORT_ID, PacketTransport, packet_transport_sensor_schema, diff --git a/esphome/components/packet_transport/sensor.py b/esphome/components/packet_transport/sensor.py index 018f1c3a9b0..7eb48f6214b 100644 --- a/esphome/components/packet_transport/sensor.py +++ b/esphome/components/packet_transport/sensor.py @@ -1,14 +1,10 @@ import esphome.codegen as cg +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID from esphome.components.sensor import new_sensor, sensor_schema from esphome.const import CONF_ID from esphome.types import ConfigType -from . import ( - CONF_PROVIDER, - CONF_REMOTE_ID, - CONF_TRANSPORT_ID, - packet_transport_sensor_schema, -) +from . import CONF_TRANSPORT_ID, packet_transport_sensor_schema CONFIG_SCHEMA = packet_transport_sensor_schema(sensor_schema()) diff --git a/esphome/components/partition/light.py b/esphome/components/partition/light.py index b2341f95b7f..eab2e1ac660 100644 --- a/esphome/components/partition/light.py +++ b/esphome/components/partition/light.py @@ -84,6 +84,10 @@ CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( ), cv.Length(min=1), ), + cv.Optional(light.CONF_POWER_ESTIMATION): cv.invalid( + "power_estimation belongs on the strip a partition draws from; " + "the strip's limit covers every partition on it" + ), } ) diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index adadde068cb..c843474ab4c 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -85,6 +85,12 @@ class PartitionLightOutput final : public light::AddressableLight { } auto view = (*seg.get_src())[src_off]; +#ifdef USE_LIGHT_POWER_ESTIMATION + // Writes go through the strip's power factor; a partition has no limit of its own. A view kept alive + // across two strips with different limits carries the factor of the strip fetched last. + const_cast(this->correction_) + .set_power_brightness(view.get_color_correction()->get_power_brightness()); +#endif view.raw_set_color_correction(&this->correction_); return view; } diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 33f8af9d528..92fb8f8eeed 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -6,9 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OUTPUT, CONF_PULLUP, ) @@ -42,47 +39,21 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_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") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - -PCA6416A_PIN_SCHEMA = cv.All( +PCA6416A_PIN_SCHEMA = pins.gpio_base_schema( + PCA6416AGPIOPin, + cv.int_range(min=0, max=15), + modes=[CONF_INPUT, CONF_PULLUP, CONF_OUTPUT], + mode_validator=pins.gpio_validate_input_xor_output_pullup, +).extend( { - cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_PULLUP, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA6416A, PCA6416A_PIN_SCHEMA) async def pca6416a_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCA6416A]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA6416A) diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 0825d6eac98..1b643c6241d 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -138,13 +138,10 @@ bool PCA6416AComponent::read_register_(uint8_t reg, uint8_t *value) { } this->last_error_ = this->read_register(reg, value, 1); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } @@ -155,13 +152,10 @@ bool PCA6416AComponent::write_register_(uint8_t reg, uint8_t value) { } this->last_error_ = this->write_register(reg, &value, 1); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } @@ -193,12 +187,6 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re float PCA6416AComponent::get_setup_priority() const { return setup_priority::IO; } -void PCA6416AGPIOPin::setup() { pin_mode(flags_); } -void PCA6416AGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCA6416AGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCA6416AGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCA6416AGPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA6416A", this->pin_); -} - } // namespace esphome::pca6416a + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca6416a::PCA6416AComponent, "%u via PCA6416A") diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 39011d53ab7..61b65501cfa 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca6416a { @@ -48,27 +49,8 @@ class PCA6416AComponent final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin final : public GPIOPin { - 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_parent(PCA6416AComponent *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCA6416AComponent *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using PCA6416AGPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca6416a + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca6416a::PCA6416AComponent); diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 4e3acb94e6e..f1c34b1eaad 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -2,15 +2,7 @@ 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.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -42,23 +34,14 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(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 + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) PCA9505_PIN_SCHEMA = pins.gpio_base_schema( PCA9505GPIOPin, cv.int_range(min=0, max=39), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), @@ -68,10 +51,4 @@ PCA9505_PIN_SCHEMA = pins.gpio_base_schema( @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 + return await pins.new_parented_pin(config, CONF_PCA9505) diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 6ac50d3b1bc..567b480b1b4 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -109,24 +109,16 @@ 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; + return this->status_comm_result(this->read_register(INPUT_REG + bank, &this->input_[bank], 1) == i2c::ERROR_OK, + LOG_STR("Failed to read input register")); } 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; + return this->status_comm_result(this->write_register(reg + bank, &value, 1) == i2c::ERROR_OK, + LOG_STR("Failed to write register")); } bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { @@ -137,26 +129,12 @@ bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { 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; + return this->status_comm_result(this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) == i2c::ERROR_OK, + LOG_STR("Failed to write register")); } 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 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9505::PCA9505Component, "%u via PCA9505") diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index 29c4f18361c..a8ce6f1a324 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" namespace esphome::pca9505 { @@ -49,24 +50,8 @@ class PCA9505Component final : public Component, 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_{}; -}; +using PCA9505GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca9505 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca9505::PCA9505Component); diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index cba5656e88d..4e30c47bdae 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -6,8 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, CONF_NUMBER, CONF_OUTPUT, ) @@ -48,23 +46,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) 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 + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) PCA9554_PIN_SCHEMA = pins.gpio_base_schema( PCA9554GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), @@ -84,13 +73,4 @@ def pca9554_pin_final_validate( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) async def pca9554_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCA9554]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA9554) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index a12d291f6b2..add9c8d0109 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -110,12 +110,10 @@ bool PCA9554Component::read_inputs_() { } this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - this->status_clear_warning(); this->input_mask_ = inputs[0]; if (this->reg_width_ == 2) { this->input_mask_ |= inputs[1] << 8; @@ -128,24 +126,15 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { outputs[0] = (uint8_t) value; outputs[1] = (uint8_t) (value >> 8); this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -void PCA9554GPIOPin::setup() { pin_mode(flags_); } -void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCA9554GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCA9554GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA9554", this->pin_); -} - } // namespace esphome::pca9554 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9554::PCA9554Component, "%u via PCA9554") diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index cc95f147ac5..b8d34e328eb 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca9554 { @@ -52,30 +53,8 @@ class PCA9554Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin final : public GPIOPin { - public: - // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. - PCA9554GPIOPin() {} - - 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_parent(PCA9554Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCA9554Component *parent_{nullptr}; - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCA9554GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca9554 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca9554::PCA9554Component); diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 3f993c17e19..e3e7daeb49a 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -2,7 +2,6 @@ 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 = ["@brogon"] @@ -42,8 +41,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 3f677280c5b..7f78549ba8e 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -2,7 +2,6 @@ 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 = ["@KoenBreeman"] @@ -38,8 +37,5 @@ automation.register_apply_action( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index f1db7c29748..21e9e7b007e 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, @@ -23,7 +22,9 @@ MULTI_CONF = True pcf8574_ns = cg.esphome_ns.namespace("pcf8574") PCF8574Component = pcf8574_ns.class_("PCF8574Component", cg.Component, i2c.I2CDevice) -PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) +PCF8574GPIOPin = pcf8574_ns.class_( + "PCF8574GPIOPin", cg.GPIOPin, cg.Parented.template(PCF8574Component) +) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" @@ -48,23 +49,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pcf8575(config[CONF_PCF8575])) if (reset := config[CONF_RESET]) != DEFAULT_RESET: cg.add(var.set_reset(reset)) - 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 + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) PCF8574_PIN_SCHEMA = pins.gpio_base_schema( PCF8574GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -75,13 +67,8 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCF8574, PCF8574_PIN_SCHEMA) async def pcf8574_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCF8574]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + var = await pins.new_parented_pin(config, CONF_PCF8574) + if config[CONF_MODE][CONF_INPUT]: + parent = await cg.get_variable(config[CONF_PCF8574]) + cg.add(parent.register_input_pin(config[CONF_NUMBER])) return var diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 15ed2f8650e..ddf1985db45 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -1,4 +1,5 @@ #include "pcf8574.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pcf8574 { @@ -86,12 +87,9 @@ bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. - if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1))) return false; - } - this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; - this->status_clear_warning(); + this->input_mask_ = encode_value_le(data); return true; } bool PCF8574Component::write_gpio_() { @@ -107,27 +105,10 @@ bool PCF8574Component::write_gpio_() { uint8_t data[2]; data[0] = value; data[1] = value >> 8; - if (this->write(data, this->pcf8575_ ? 2 : 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return false; - } - - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write(data, this->pcf8575_ ? 2 : 1) == i2c::ERROR_OK); } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } -void PCF8574GPIOPin::setup() { pin_mode(flags_); } -void PCF8574GPIOPin::set_flags(gpio::Flags flags) { - this->flags_ = flags; - if (flags & gpio::FLAG_INPUT) - this->parent_->register_input_pin(this->pin_); -} -void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCF8574GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCF8574", this->pin_); -} - } // namespace esphome::pcf8574 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pcf8574::PCF8574Component, "%u via PCF8574") diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 465bbd7a918..4bc19d204fa 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pcf8574 { @@ -56,31 +57,8 @@ class PCF8574Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin final : public GPIOPin { - public: - // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. - PCF8574GPIOPin() {} - - 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_parent(PCF8574Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - // Call after set_parent() and set_pin(): input flags register the pin with the parent. - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCF8574Component *parent_{nullptr}; - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCF8574GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pcf8574 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pcf8574::PCF8574Component); diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index cb50b101c13..6b7bfd47c3a 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -6,9 +6,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, CONF_ENABLE_PIN, - CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, @@ -92,14 +90,6 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_pin_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, not both") - return value - - def _validate_pin(value: ConfigType) -> ConfigType: if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") @@ -111,7 +101,7 @@ PIN_SCHEMA = cv.All( PCM5122GPIOPin, cv.int_range(min=3, max=6), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=_validate_pin_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCM5122): cv.use_id(PCM5122), @@ -123,13 +113,7 @@ PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PCM5122]) - - 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 + return await pins.new_parented_pin(config, CONF_PCM5122) async def to_code(config: ConfigType) -> None: @@ -140,6 +124,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) - if enable_pin_config := config.get(CONF_ENABLE_PIN): - enable_pin = await cg.gpio_pin_expression(enable_pin_config) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 4128114b3cc..d9d1fd31f51 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -6,9 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OUTPUT, CONF_PULLDOWN, CONF_PULLUP, @@ -49,16 +46,7 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) cg.add(var.set_reset(config[CONF_RESET])) - 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 + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( @@ -70,7 +58,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( CONF_PULLUP, CONF_PULLDOWN, ], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component), @@ -80,10 +68,4 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PI4IOE5V6408, PI4IOE5V6408_PIN_SCHEMA) async def pi4ioe5v6408_pin_schema(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PI4IOE5V6408]) - - 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 + return await pins.new_parented_pin(config, CONF_PI4IOE5V6408) diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 0b5af94e696..63fa1c249b4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -90,13 +90,11 @@ bool PI4IOE5V6408Component::read_gpio_outputs_() { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data)) { - this->status_set_warning(LOG_STR("Failed to read output register")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data), + LOG_STR("Failed to read output register"))) return false; - } this->output_mask_ = data; - this->status_clear_warning(); return true; } @@ -104,16 +102,14 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data)) { - this->status_set_warning(LOG_STR("Failed to read GPIO modes")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data), + LOG_STR("Failed to read GPIO modes"))) return false; - } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Read GPIO modes: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(data)); #endif this->mode_mask_ = data; - this->status_clear_warning(); return true; } @@ -121,13 +117,11 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data)) { - this->status_set_warning(LOG_STR("Failed to read GPIO state")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data), + LOG_STR("Failed to read GPIO state"))) return false; - } this->input_mask_ = data; - this->status_clear_warning(); return true; } @@ -140,14 +134,12 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) { } else { this->output_mask_ &= ~(1 << pin); } - if (!this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); + if (!this->status_comm_result(this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_), + LOG_STR("Failed to write output register"))) return; - } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Wrote GPIO output: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(this->output_mask_)); #endif - this->status_clear_warning(); } bool PI4IOE5V6408Component::write_gpio_modes_() { @@ -190,14 +182,6 @@ bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->inpu float PI4IOE5V6408Component::get_setup_priority() const { return setup_priority::IO; } -void PI4IOE5V6408GPIOPin::setup() { this->pin_mode(this->flags_); } -void PI4IOE5V6408GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PI4IOE5V6408GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PI4IOE5V6408GPIOPin::digital_write(bool value) { - this->parent_->digital_write(this->pin_, value != this->inverted_); -} -size_t PI4IOE5V6408GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PI4IOE5V6408", this->pin_); -} - } // namespace esphome::pi4ioe5v6408 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pi4ioe5v6408::PI4IOE5V6408Component, "%u via PI4IOE5V6408") diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 9909dc2217b..556404a3743 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -49,24 +50,8 @@ class PI4IOE5V6408Component final : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin 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_; - bool inverted_; - gpio::Flags flags_; -}; +using PI4IOE5V6408GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pi4ioe5v6408 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pi4ioe5v6408::PI4IOE5V6408Component); diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ea7ebaed0ae..b5d17cf3c15 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -11,20 +11,10 @@ void Pipsolar::setup() { this->command_start_millis_ = 0; } -void Pipsolar::empty_uart_buffer_() { - uint8_t buf[64]; - size_t avail; - while ((avail = this->available()) > 0) { - if (!this->read_array(buf, std::min(avail, sizeof(buf)))) { - break; - } - } -} - void Pipsolar::loop() { // Read message if (this->state_ == STATE_IDLE) { - this->empty_uart_buffer_(); + this->discard_available(); if (this->send_next_command_()) { // command sent @@ -111,7 +101,7 @@ void Pipsolar::loop() { // make sure data and null terminator fit in buffer if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { this->read_pos_ = 0; - this->empty_uart_buffer_(); + this->discard_available(); ESP_LOGW(TAG, "response data too long, discarding."); done = true; break; @@ -122,7 +112,7 @@ void Pipsolar::loop() { // end of answer if (byte == 0x0D) { this->read_buffer_[this->read_pos_] = 0; - this->empty_uart_buffer_(); + this->discard_available(); if (this->state_ == STATE_POLL) { this->state_ = STATE_POLL_COMPLETE; } @@ -202,7 +192,7 @@ bool Pipsolar::send_next_command_() { } this->state_ = STATE_COMMAND; this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); + this->discard_available(); this->read_pos_ = 0; crc16 = this->pipsolar_crc_(byte_command, length); this->write_str(command); @@ -231,7 +221,7 @@ bool Pipsolar::send_next_poll_() { } this->state_ = STATE_POLL; this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); + this->discard_available(); this->read_pos_ = 0; crc16 = this->pipsolar_crc_(this->enabled_polling_commands_[this->last_polling_command_].command, this->enabled_polling_commands_[this->last_polling_command_].length); diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 4695e39829a..80ca0156a36 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -206,7 +206,6 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { static const size_t COMMAND_TIMEOUT = 5000; static const size_t POLLING_COMMANDS_MAX = 15; void add_polling_command_(const char *command, ENUMPollingCommand polling_command); - void empty_uart_buffer_(); uint8_t check_incoming_crc_(); uint8_t check_incoming_length_(uint8_t length); uint16_t pipsolar_crc_(uint8_t *msg, uint8_t len); diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index 2f57c028439..9247cf37bf7 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -24,8 +24,8 @@ void PM1006Component::update() { } void PM1006Component::loop() { - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); + this->read_available([this](uint8_t byte) { + this->data_[this->data_index_] = byte; auto check = this->check_byte_(); if (!check.has_value()) { // finished @@ -39,7 +39,7 @@ void PM1006Component::loop() { // next byte this->data_index_++; } - } + }); } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index efc80e8c650..d7e7e0e22bc 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -83,8 +83,7 @@ void PMSX003Component::loop() { if (now - this->fan_on_time_ < STABILISING_MS) return; // consume any command responses that are in the serial buffer - while (this->available()) - this->read_byte(&this->data_[0]); + this->discard_available(); // Trigger a new read this->send_command_(Command::MANUAL_MEASUREMENT, 0); this->state_ = State::WAITING; @@ -100,27 +99,30 @@ void PMSX003Component::loop() { this->data_index_ = 0; } - if (this->available() == 0) + const size_t avail = this->available(); + if (avail == 0) return; this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); - auto check = this->check_byte_(); - if (!check.has_value()) { - if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) { - this->parse_data_(); - this->last_update_ = now; - } - this->data_index_ = 0; - } else if (!*check) { - // wrong data - this->data_index_ = 0; - } else { - // next byte - this->data_index_++; - } - } + this->read_available( + [this, now](uint8_t byte) { + this->data_[this->data_index_] = byte; + auto check = this->check_byte_(); + if (!check.has_value()) { + if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) { + this->parse_data_(); + this->last_update_ = now; + } + this->data_index_ = 0; + } else if (!*check) { + // wrong data + this->data_index_ = 0; + } else { + // next byte + this->data_index_++; + } + }, + avail); } optional PMSX003Component::check_byte_() { diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 60cc4da0e4c..1eac0244469 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -72,32 +72,32 @@ void PMWCS3Component::read_data_() { // Wait for the sensor to be ready. // 80ms empirically determined (conservative). this->set_timeout(80, [this] { - uint8_t data[8]; + uint16_t data[4]; float e25, ec, temperature, vwc; - if (!this->read_bytes(PMWCS3_REG_GET_DATA, (uint8_t *) &data, 8)) { + if (!this->read_bytes_16_le(PMWCS3_REG_GET_DATA, data, 4)) { ESP_LOGVV(TAG, "Reading PMWCS3_REG_GET_DATA failed"); this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->e25_sensor_ != nullptr) { - e25 = ((data[1] << 8) | data[0]) / 100.0; + e25 = data[0] / 100.0; this->e25_sensor_->publish_state(e25); - ESP_LOGVV(TAG, "e25: data[0]=%d, data[1]=%d, result=%f", data[0], data[1], e25); + ESP_LOGVV(TAG, "e25: raw=%u, result=%f", data[0], e25); } if (this->ec_sensor_ != nullptr) { - ec = ((data[3] << 8) | data[2]) / 10.0; + ec = data[1] / 10.0; this->ec_sensor_->publish_state(ec); - ESP_LOGVV(TAG, "ec: data[2]=%d, data[3]=%d, result=%f", data[2], data[3], ec); + ESP_LOGVV(TAG, "ec: raw=%u, result=%f", data[1], ec); } if (this->temperature_sensor_ != nullptr) { - temperature = ((data[5] << 8) | data[4]) / 100.0; + temperature = data[2] / 100.0; this->temperature_sensor_->publish_state(temperature); - ESP_LOGVV(TAG, "temp: data[4]=%d, data[5]=%d, result=%f", data[4], data[5], temperature); + ESP_LOGVV(TAG, "temp: raw=%u, result=%f", data[2], temperature); } if (this->vwc_sensor_ != nullptr) { - vwc = ((data[7] << 8) | data[6]) / 10.0; + vwc = data[3] / 10.0; this->vwc_sensor_->publish_state(vwc); - ESP_LOGVV(TAG, "vwc: data[6]=%d, data[7]=%d, result=%f", data[6], data[7], vwc); + ESP_LOGVV(TAG, "vwc: raw=%u, result=%f", data[3], vwc); } }); } diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index fd38edf136a..c83726949d9 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -106,16 +106,15 @@ void PN532::update() { obj->on_scan_end(); #endif - if (!this->write_command_({ - PN532_COMMAND_INLISTPASSIVETARGET, - 0x01, // max 1 card - 0x00, // baud rate ISO14443A (106 kbit/s) - })) { + const bool ok = this->write_command_({ + PN532_COMMAND_INLISTPASSIVETARGET, + 0x01, // max 1 card + 0x00, // baud rate ISO14443A (106 kbit/s) + }); + if (!this->status_comm_result(ok)) { ESP_LOGW(TAG, "Requesting tag read failed!"); - this->status_set_warning(); return; } - this->status_clear_warning(); this->requested_read_ = true; } diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index 0d7f440f15e..462d64f937d 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -1,4 +1,5 @@ #include "pvvx_mithermometer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pvvx_mithermometer { @@ -96,15 +97,15 @@ bool PVVXMiThermometer::parse_message_(const std::vector &message, Pars } // int16_t temperature; // x 0.01 degree [6,7] - const int16_t temperature = int16_t(data[6]) | (int16_t(data[7]) << 8); + const int16_t temperature = encode_value_le(&data[6]); result.temperature = temperature / 1.0e2f; // uint16_t humidity; // x 0.01 % [8,9] - const int16_t humidity = uint16_t(data[8]) | (uint16_t(data[9]) << 8); + const int16_t humidity = encode_value_le(&data[8]); result.humidity = humidity / 1.0e2f; // uint16_t battery_mv; // mV [10,11] - const int16_t battery_voltage = uint16_t(data[10]) | (uint16_t(data[11]) << 8); + const int16_t battery_voltage = encode_value_le(&data[10]); result.battery_voltage = battery_voltage / 1.0e3f; // uint8_t battery_level; // 0..100 % [12] diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index e4a7781538d..a2add0b88a9 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index 7788caca442..1202f8a15c2 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -184,9 +184,7 @@ FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="cl async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors("voltage_a", var.set_voltage_a_sensor) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 90e0abb14c1..9ed0284cff0 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -99,9 +99,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b3f59720ad3..80008f6b592 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -81,9 +81,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 9dce402fe02..83344de479d 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -288,12 +288,10 @@ bool QMP6988Component::calculate_pressure_() { int32_t t_int, p_int; err = this->read_register(QMP6988_PRESSURE_MSB_REG, a_data_uint8_tr, 6); - if (err != i2c::ERROR_OK) { + if (!this->status_comm_result(err == i2c::ERROR_OK)) { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); - this->status_set_warning(); return false; } - this->status_clear_warning(); p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); p_raw = (int32_t) (p_read - SUBTRACTOR); diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index b01b0f3645e..3d5053ca7e0 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -5,6 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_REMOTE_ID from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -72,7 +73,6 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" -CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index ecd27a95a56..2228f41dc10 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -91,9 +91,9 @@ class ABBWelcomeData { } uint32_t get_destination_address() const { if (this->get_address_length() == 2) { - return (this->data_[4] << 8) + this->data_[5]; + return encode_uint16(this->data_[4], this->data_[5]); } - return (this->data_[4] << 16) + (this->data_[5] << 8) + this->data_[6]; + return encode_uint24(this->data_[4], this->data_[5], this->data_[6]); } void set_source_address(uint32_t address) { if (this->get_address_length() == 2) { @@ -107,9 +107,9 @@ class ABBWelcomeData { } uint32_t get_source_address() const { if (this->get_address_length() == 2) { - return (this->data_[6] << 8) + this->data_[7]; + return encode_uint16(this->data_[6], this->data_[7]); } - return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; + return encode_uint24(this->data_[7], this->data_[8], this->data_[9]); } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } /// A configured id, which finalize() keeps instead of picking one diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 22a63ca4fd3..18ad08ab6d2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -284,14 +284,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->store_.index = offset; esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), this->rmt_temp_.size() * sizeof(rmt_symbol_half_t), &config); - if (error != ESP_OK) { + if (!this->status_comm_result(error == ESP_OK, nullptr)) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); - this->status_set_warning(); // nothing was queued, so there is no frame to wait for this->fire_complete_(false); return; } - this->status_clear_warning(); if (this->non_blocking_) { this->set_timeout(COMPLETE_TIMEOUT_ID, total_duration / 1000, [this]() { this->wait_for_rmt_(); }); diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py index 808f27a67a8..162ba871f9a 100644 --- a/esphome/components/renogy_inverter_ble/__init__.py +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import ble_client import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["sensor"] @@ -35,6 +34,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + await ble_client.new_ble_node_component(config) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 86daaf4b08b..1eb2680e9c8 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -56,10 +56,10 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return true; RFBridgeData data; - data.sync = (raw[2] << 8) | raw[3]; - data.low = (raw[4] << 8) | raw[5]; - data.high = (raw[6] << 8) | raw[7]; - data.code = (raw[8] << 16) | (raw[9] << 8) | raw[10]; + data.sync = encode_uint16(raw[2], raw[3]); + data.low = encode_uint16(raw[4], raw[5]); + data.high = encode_uint16(raw[6], raw[7]); + data.code = encode_uint24(raw[8], raw[9], raw[10]); if (action == RF_CODE_LEARN_OK) { ESP_LOGD(TAG, "Learning success"); diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index c781e282fe7..415885cfe2b 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -1,4 +1,5 @@ #include "ruuvi_ble.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::ruuvi_ble { @@ -19,9 +20,9 @@ bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviPar const float humidity = data[0] * 0.5f; const float pressure = (encode_uint16(data[3], data[4]) + 50000.0f) / 100.0f; - const float acceleration_x = static_cast(encode_uint16(data[5], data[6])) / 1000.0f; - const float acceleration_y = static_cast(encode_uint16(data[7], data[8])) / 1000.0f; - const float acceleration_z = static_cast(encode_uint16(data[9], data[10])) / 1000.0f; + const float acceleration_x = encode_value(&data[5]) / 1000.0f; + const float acceleration_y = encode_value(&data[7]) / 1000.0f; + const float acceleration_z = encode_value(&data[9]) / 1000.0f; const float battery_voltage = encode_uint16(data[11], data[12]) / 1000.0f; result.humidity = humidity; @@ -40,12 +41,12 @@ bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviPar if (adv_data.size() != 24) return false; - const float temperature = static_cast(encode_uint16(data[0], data[1])) * 0.005f; + const float temperature = encode_value(data) * 0.005f; const float humidity = encode_uint16(data[2], data[3]) / 400.0f; const float pressure = (encode_uint16(data[4], data[5]) + 50000.0f) / 100.0f; - const float acceleration_x = static_cast(encode_uint16(data[6], data[7])) / 1000.0f; - const float acceleration_y = static_cast(encode_uint16(data[8], data[9])) / 1000.0f; - const float acceleration_z = static_cast(encode_uint16(data[10], data[11])) / 1000.0f; + const float acceleration_x = encode_value(&data[6]) / 1000.0f; + const float acceleration_y = encode_value(&data[8]) / 1000.0f; + const float acceleration_z = encode_value(&data[10]) / 1000.0f; const uint16_t power_info = encode_uint16(data[12], data[13]); const float battery_voltage = ((power_info >> 5) + 1600.0f) / 1000.0f; diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index abbda5872b2..bad973c6315 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_ACCELERATION_Z, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_MEASUREMENT_SEQUENCE_NUMBER, CONF_MOVEMENT_COUNTER, @@ -123,9 +122,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index c94c64f8611..4d7e9b4e694 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -13,9 +13,6 @@ static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; ESPHOME_LOG_TAG(TAG, "rx8025t"); -constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } -constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } - void RX8025TComponent::setup() { uint8_t flags; if (!this->read_flags_(&flags)) { @@ -52,31 +49,17 @@ void RX8025TComponent::read_time() { } uint8_t date[7]; - if (!this->read_bytes(RX8025T_REG_SEC, date, sizeof(date))) { + if (!this->status_comm_result(this->read_bytes(RX8025T_REG_SEC, date, sizeof(date)))) { ESP_LOGE(TAG, "Can't read I2C data."); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - this->status_clear_warning(); if (flags & RX8025T_FLAG_VLF) { ESP_LOGW(TAG, "VLF flag is set - time data may be invalid, not syncing to system clock."); return; } - ESPTime rtc_time{ - .second = bcd2dec(date[0] & 0x7f), - .minute = bcd2dec(date[1] & 0x7f), - .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), - .day_of_month = bcd2dec(date[4] & 0x3f), - .day_of_year = 1, - .month = bcd2dec(date[5] & 0x1f), - .year = static_cast(bcd2dec(date[6]) + 2000), - .is_dst = false, - .timestamp = 0, - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -94,13 +77,7 @@ void RX8025TComponent::write_time() { } uint8_t buff[7]; - buff[0] = dec2bcd(now.second); - buff[1] = dec2bcd(now.minute); - buff[2] = dec2bcd(now.hour); - buff[3] = 1 << (now.day_of_week - 1); - buff[4] = dec2bcd(now.day_of_month); - buff[5] = dec2bcd(now.month); - buff[6] = dec2bcd(now.year % 100); + time::encode_bcd_calendar(now, buff); if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { ESP_LOGE(TAG, "Can't write I2C data."); this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); @@ -115,11 +92,7 @@ void RX8025TComponent::write_time() { return; } flags &= ~(RX8025T_FLAG_VLF | RX8025T_FLAG_VDET); - if (!this->write_byte(RX8025T_REG_FLAG, flags)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->write_byte(RX8025T_REG_FLAG, flags)); } } // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/time.py b/esphome/components/rx8025t/time.py index e8ffe772ab7..fcc6c0b72fa 100644 --- a/esphome/components/rx8025t/time.py +++ b/esphome/components/rx8025t/time.py @@ -2,7 +2,6 @@ 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 = ["@remcom"] @@ -35,8 +34,5 @@ for _name, _call in ( 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) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index b3f26cb2611..62923482c7e 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -15,9 +15,6 @@ 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(); } @@ -63,31 +60,17 @@ void RX8111Component::read_time() { 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); + ESPTime rtc_time = time::decode_bcd_calendar(buf); 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) { + if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week, + LOG_STR("Invalid RTC time"))) { 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); @@ -105,13 +88,7 @@ void RX8111Component::write_time() { } 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)); + time::encode_bcd_calendar(now, buf); 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); @@ -119,11 +96,8 @@ void RX8111Component::write_time() { 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)); + if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS))) 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); } diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 63153c5db3f..a5b1243c263 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -23,9 +23,6 @@ static const uint8_t RX8130_CLEAR_FLAGS = 0x00; ESPHOME_LOG_TAG(TAG, "rx8130"); -constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } -constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } - void RX8130Component::setup() { // Set digital offset to disabled with no offset if (this->write_register(RX8130_REG_DIG_OFFSET, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { @@ -70,16 +67,7 @@ void RX8130Component::read_time() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - ESPTime rtc_time{ - .second = bcd2dec(date[0] & 0x7f), - .minute = bcd2dec(date[1] & 0x7f), - .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), - .day_of_month = bcd2dec(date[4] & 0x3f), - .month = bcd2dec(date[5] & 0x1f), - .year = static_cast(bcd2dec(date[6]) + 2000), - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -96,13 +84,7 @@ void RX8130Component::write_time() { return; } uint8_t buff[7]; - buff[0] = dec2bcd(now.second); - buff[1] = dec2bcd(now.minute); - buff[2] = dec2bcd(now.hour); - buff[3] = 1 << (now.day_of_week - 1); - buff[4] = dec2bcd(now.day_of_month); - buff[5] = dec2bcd(now.month); - buff[6] = dec2bcd(now.year % 100); + time::encode_bcd_calendar(now, buff); this->stop_(true); if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 4b67319efb1..f1c7beaea8b 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -42,6 +42,15 @@ MEASUREMENT_MODE_OPTIONS = MeasurementMode.options( ) +def pressure_hpa(value: str | float) -> float: + """Pressure in hPa. A bare number is hPa, matching what the C++ sends to the sensor.""" + try: + return cv.float_(value) + except cv.Invalid: + pass + return cv.pressure(value) * 1000 # bar, mbar, mBar + + CONFIG_SCHEMA = ( cv.Schema( { @@ -72,7 +81,9 @@ CONFIG_SCHEMA = ( cv.float_with_unit("altitude", "(m|m a.s.l.|MAMSL|MASL)"), cv.int_range(min=0, max=0xFFFF, max_included=False), ), - cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure, + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.All( + pressure_hpa, cv.Any(cv.float_range(min=700, max=1200), 0) + ), cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta, cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( sensor.Sensor diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 74c56acf120..806b7ee27b5 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_EXPORT_ACTIVE_ENERGY, CONF_EXPORT_REACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, CONF_PHASE_A, @@ -156,9 +155,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 147365251b5..08486e55589 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -83,27 +83,30 @@ void SDS011Component::loop() { this->data_index_ = 0; } - if (this->available() == 0) { + const size_t avail = this->available(); + if (avail == 0) { return; } this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); - auto check = this->check_byte_(); - if (!check.has_value()) { - // finished - this->parse_data_(); - this->data_index_ = 0; - } else if (!*check) { - // wrong data - ESP_LOGV(TAG, "Byte %i of received data frame is invalid.", this->data_index_); - this->data_index_ = 0; - } else { - // next byte - this->data_index_++; - } - } + this->read_available( + [this](uint8_t byte) { + this->data_[this->data_index_] = byte; + auto check = this->check_byte_(); + if (!check.has_value()) { + // finished + this->parse_data_(); + this->data_index_ = 0; + } else if (!*check) { + // wrong data + ESP_LOGV(TAG, "Byte %i of received data frame is invalid.", this->data_index_); + this->data_index_ = 0; + } else { + // next byte + this->data_index_++; + } + }, + avail); } void SDS011Component::sds011_write_command_(const uint8_t *command_data) { diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index 4ec01e46c67..63b5373cffc 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -43,12 +42,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -# The async def keyword is used to define a concurrent function. -# Concurrent functions are special functions designed to work with Python's asyncio library to support asynchronous I/O operations. async def to_code(config: ConfigType) -> None: - # This line of code creates a new Pvariable (a Python object representing a C++ variable) with the variable's ID taken from the configuration. - var = cg.new_Pvariable(config[CONF_ID]) - # This line of code registers the newly created Pvariable as a component so that ESPHome can manage it at runtime. - await cg.register_component(var, config) - # This line of code registers the newly created Pvariable as a device. - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index eec4e7afa6b..199c25d9390 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -48,25 +48,6 @@ void MR60BHA2Component::loop() { } } -/** - * @brief Calculate the checksum for a byte array. - * - * This function calculates the checksum for the provided byte array using an - * XOR-based checksum algorithm. - * - * @param data The byte array to calculate the checksum for. - * @param len The length of the byte array. - * @return The calculated checksum. - */ -static uint8_t calculate_checksum(const uint8_t *data, size_t len) { - uint8_t checksum = 0; - for (size_t i = 0; i < len; i++) { - checksum ^= data[i]; - } - checksum = ~checksum; - return checksum; -} - /** * @brief Validate the checksum of a byte array. * @@ -79,7 +60,7 @@ static uint8_t calculate_checksum(const uint8_t *data, size_t len) { * @return True if the checksum is valid, false otherwise. */ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_checksum) { - return calculate_checksum(data, len) == expected_checksum; + return static_cast(~xor_bytes(data, len)) == expected_checksum; } bool MR60BHA2Component::validate_message_() { diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index 9a895b0e0e9..db9eca8288c 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -133,11 +133,7 @@ void SelecMeter::on_read_input_registers(uint16_t start_address, std::spandecode_em2m_(registers); #endif - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(); - } + this->status_comm_result(ok); break; } #if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 5e6709ec97d..6cf79592eb0 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_EXPORT_ACTIVE_ENERGY, CONF_EXPORT_REACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, CONF_MODEL, @@ -337,9 +336,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) if config[CONF_MODEL] == MODEL_EM4M: diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index d45b247a437..b05e21eaacb 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -15,12 +15,10 @@ void Sen21231Sensor::dump_config() { void Sen21231Sensor::read_data_() { person_sensor_results_t results; - if (!this->read_bytes(PERSON_SENSOR_I2C_ADDRESS, (uint8_t *) &results, sizeof(results))) { + if (!this->status_comm_result(this->read_bytes(PERSON_SENSOR_I2C_ADDRESS, (uint8_t *) &results, sizeof(results)))) { ESP_LOGW(TAG, "Failed to read data from SEN21231"); - this->status_set_warning(); return; } - this->status_clear_warning(); ESP_LOGD(TAG, "SEN21231: %d faces detected", results.num_faces); this->publish_state(results.num_faces); if (results.num_faces == 1) { diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 9f95ed192c0..4e8a79124c6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -678,8 +678,7 @@ void SEN6XComponent::parse_and_publish_number_concentration_() { this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; uint16_t measurements[std::size(sensors)]; - if (!this->read_data(measurements, std::size(measurements))) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(measurements, std::size(measurements)))) { ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); return; } @@ -688,7 +687,6 @@ void SEN6XComponent::parse_and_publish_number_concentration_() { if (sensors[i] != nullptr) sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); } - this->status_clear_warning(); } #endif diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 858510f4277..7b40d0d8409 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -43,14 +43,12 @@ void SenseAirComponent::update() { } uint16_t calc_checksum = crc16(response, 11); - uint16_t resp_checksum = (uint16_t(response[12]) << 8) | response[11]; - if (resp_checksum != calc_checksum) { + uint16_t resp_checksum = encode_value_le(&response[11]); + if (!this->status_comm_result(resp_checksum == calc_checksum)) { ESP_LOGW(TAG, "SenseAir checksum doesn't match: 0x%02X!=0x%02X", resp_checksum, calc_checksum); - this->status_set_warning(); return; } - this->status_clear_warning(); const uint8_t length = response[2]; const uint16_t status = encode_uint16(response[3], response[4]); const uint16_t ppm = encode_uint16(response[length + 1], response[length + 2]); diff --git a/esphome/components/sensor/sensor_with_dedup.h b/esphome/components/sensor/sensor_with_dedup.h new file mode 100644 index 00000000000..6fab40f1e68 --- /dev/null +++ b/esphome/components/sensor/sensor_with_dedup.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::sensor { + +/// Declare an optional child sensor that skips publishing a value equal to the last one. +#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ + protected: \ + sensor::SensorWithDedup name##_sensor_{}; \ +\ + public: \ + void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } + +#define LOG_SENSOR_WITH_DEDUP(prefix, type, s) LOG_SENSOR(prefix, type, (s).get_sensor()) + +/// A sensor pointer that may be null plus the last value it published, stored inline. +/// Publishing does nothing when no sensor is set, and skips a value equal to the last one. +template class SensorWithDedup { + public: + void set_sensor(Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } + + void publish_state_if_not_dup(T state) { + if (this->sens_ != nullptr && this->dedup_.next(state)) { + this->sens_->publish_state(static_cast(state)); + } + } + + void publish_state_unknown() { + if (this->sens_ != nullptr && this->dedup_.next_unknown()) { + this->sens_->publish_state(NAN); + } + } + + bool has_sensor() const { return this->sens_ != nullptr; } + Sensor *get_sensor() const { return this->sens_; } + + protected: + Sensor *sens_{nullptr}; + Deduplicator dedup_; +}; + +} // namespace esphome::sensor diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index d46020b2d9a..5e6b8849dd1 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -87,15 +87,12 @@ void SHT4XComponent::update() { uint16_t buffer[2]; // Read measurement - if (!this->read_data(buffer, 2)) { + if (!this->status_comm_result(this->read_data(buffer, 2))) { // Using ESP_LOGW to force the warning to be printed ESP_LOGW(TAG, "Sensor read failed"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Evaluate and publish measurements if (this->temp_sensor_ != nullptr) { // Temp is contained in the first result word diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 814e09162ec..7ce343a3a9e 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -81,8 +81,7 @@ void SHTCXComponent::update() { float humidity = NAN; uint16_t raw_data[2]; if (!this->read_data(raw_data, 2)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } else { temperature = 175.0f * float(raw_data[0]) / 65536.0f - 45.0f; humidity = 100.0f * float(raw_data[1]) / 65536.0f; diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index ba07bc3d6d3..1c23de43abf 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -422,10 +422,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { void Sim800LComponent::loop() { // Read message - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - + this->read_available([this](uint8_t byte) { ESP_LOGVV(TAG, "Buffer pos: %u %d", this->line_reader_.length(), byte); // NOLINT if (byte >= 0x7F) @@ -441,7 +438,7 @@ void Sim800LComponent::loop() { } else { this->line_reader_.log_if_overflowed(TAG); } - } + }); if (state_ == STATE_INIT && this->registered_ && (this->call_state_ != 6 // A call is in progress || this->send_pending_ || this->dial_pending_ || this->connect_pending_ || this->disconnect_pending_)) { diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index a4bc4373d7c..6924e2fdd9c 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -24,9 +24,7 @@ char Sml::check_start_end_bytes_(uint8_t byte) { } void Sml::loop() { - while (available()) { - const char c = read(); - + this->read_available([this](uint8_t c) { if (this->record_) this->sml_data_.emplace_back(c); @@ -57,7 +55,7 @@ void Sml::loop() { break; }; }; - } + }); } void Sml::process_sml_file_(const BytesView &sml_data) { diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 8a8f82b8504..67b3f4faecf 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -11,32 +11,31 @@ void SMT100Component::update() { } void SMT100Component::loop() { - while (this->available() != 0) { - const int readch = this->read(); - if (readch <= 0) - continue; + this->read_available([this](uint8_t readch) { + if (readch == 0) + return; char *line = this->line_reader_.feed(readch); this->line_reader_.log_if_overflowed(TAG); if (line != nullptr && line[0] != '\0') { char *token = strtok(line, ","); if (!token) - continue; + return; int counts = (int) strtol(token, nullptr, 10); token = strtok(nullptr, ","); if (!token) - continue; + return; float permittivity = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float moisture = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float temperature = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float voltage = (float) strtod(token, nullptr); if (this->counts_sensor_ != nullptr) { @@ -59,7 +58,7 @@ void SMT100Component::loop() { voltage_sensor_->publish_state(voltage); } } - } + }); } void SMT100Component::dump_config() { diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index 55fa177d18e..db0fb1f510b 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -139,19 +139,12 @@ bool SPD2010Touchscreen::read_data_() { return false; } if ((status_high & STATUS_RUNNING) && length == 0) { - if (!this->clear_interrupt_()) { - this->status_set_warning(LOG_STR("Failed to clear interrupt")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->clear_interrupt_(), LOG_STR("Failed to clear interrupt")); } if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { - if ((status_high & STATUS_RUNNING) && (status_low & STATUS_AUX) && !this->clear_interrupt_()) { - this->status_set_warning(LOG_STR("Failed to clear interrupt")); - return false; - } - this->status_clear_warning(); + const bool needs_clear = (status_high & STATUS_RUNNING) && (status_low & STATUS_AUX); + const bool cleared = !needs_clear || this->clear_interrupt_(); + this->status_comm_result(cleared, LOG_STR("Failed to clear interrupt")); return false; } if (length < REPORT_HEADER_LENGTH || length > MAX_REPORT_LENGTH || diff --git a/esphome/components/speed/fan/__init__.py b/esphome/components/speed/fan/__init__.py index 9a9290e78ee..cd94796f081 100644 --- a/esphome/components/speed/fan/__init__.py +++ b/esphome/components/speed/fan/__init__.py @@ -38,16 +38,9 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config, config[CONF_SPEED_COUNT]) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) - - if CONF_OSCILLATION_OUTPUT in config: - oscillation_output = await cg.get_variable(config[CONF_OSCILLATION_OUTPUT]) - cg.add(var.set_oscillating(oscillation_output)) - - if CONF_DIRECTION_OUTPUT in config: - direction_output = await cg.get_variable(config[CONF_DIRECTION_OUTPUT]) - cg.add(var.set_direction(direction_output)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) + await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) + await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) if CONF_PRESET_MODES in config: cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp index 5cd986f6976..2f21de3f6d3 100644 --- a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -73,12 +73,10 @@ void ST7123Touchscreen::update_touches() { // Reading from this register also clears the INT pin so the controller can report the next frame. uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; - if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + if (!this->status_comm_result(this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) == i2c::ERROR_OK)) { this->skip_update_ = true; - this->status_set_warning(); return; } - this->status_clear_warning(); const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); for (uint8_t i = 0; i != this->max_touches_; i++) { diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index fd9569b951d..9e224a024c6 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -50,15 +50,12 @@ void STS3XComponent::update() { this->set_timeout(50, [this]() { uint16_t raw_data[1]; - if (!this->read_data(raw_data, 1)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(raw_data, 1))) return; - } float temperature = 175.0f * float(raw_data[0]) / 65535.0f - 45.0f; ESP_LOGD(TAG, "Got temperature=%.2f°C", temperature); this->publish_state(temperature); - this->status_clear_warning(); }); } diff --git a/esphome/components/sun_gtil2/sun_gtil2.cpp b/esphome/components/sun_gtil2/sun_gtil2.cpp index f89797cc106..edaac470cb7 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.cpp +++ b/esphome/components/sun_gtil2/sun_gtil2.cpp @@ -39,11 +39,7 @@ static_assert(MESSAGE_SIZE == 350, "Expected the message size to be 350 bytes"); void SunGTIL2::setup() { this->rx_message_.reserve(MESSAGE_SIZE); } void SunGTIL2::loop() { - while (this->available()) { - uint8_t c; - this->read_byte(&c); - this->handle_char_(c); - } + this->read_available([this](uint8_t c) { this->handle_char_(c); }); } const char *SunGTIL2::state_to_string_(uint8_t state, std::span buffer) { diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index c44c52a6fc6..c98f94d4b55 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -4,11 +4,7 @@ from esphome.components import i2c, key_provider from esphome.components.const import CONF_KEYS import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_ON_KEY, CONF_OPEN_DRAIN, CONF_OUTPUT, @@ -113,12 +109,7 @@ async def to_code(config: ConfigType) -> None: 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") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") + pins.gpio_validate_input_xor_output_pullup(value) if value[CONF_PULLDOWN] and not value[CONF_INPUT]: raise cv.Invalid("Pulldown only available with input") if value[CONF_PULLUP] and value[CONF_PULLDOWN]: @@ -129,34 +120,18 @@ def validate_mode(value: ConfigType) -> ConfigType: CONF_SX1509 = "sx1509" -SX1509_PIN_SCHEMA = cv.All( +SX1509_PIN_SCHEMA = pins.gpio_base_schema( + SX1509GPIOPin, + cv.int_range(min=0, max=15), + modes=[CONF_INPUT, CONF_PULLUP, CONF_PULLDOWN, CONF_OUTPUT, CONF_OPEN_DRAIN], + mode_validator=validate_mode, +).extend( { - cv.GenerateID(): cv.declare_id(SX1509GPIOPin), cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_PULLUP, default=False): cv.boolean, - cv.Optional(CONF_PULLDOWN, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - cv.Optional(CONF_OPEN_DRAIN, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_SX1509, SX1509_PIN_SCHEMA) async def sx1509_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_SX1509]) - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_SX1509) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index d883982bcd5..bbb38198459 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -312,3 +312,5 @@ void SX1509Component::set_debounce_keypad_(uint8_t time, uint8_t num_rows, uint8 } } // namespace esphome::sx1509 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::sx1509::SX1509Component, "%u via sx1509") diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index ef6ee6ec5e6..5b163cbd6e5 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -5,7 +5,7 @@ #include "esphome/components/gpio_expander/cached_gpio.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "sx1509_gpio_pin.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "sx1509_registers.h" #include @@ -97,4 +97,8 @@ class SX1509Component final : public Component, void clock_(uint8_t osc_source = 2, uint8_t osc_pin_function = 1, uint8_t osc_freq_out = 0, uint8_t osc_divider = 0); }; +using SX1509GPIOPin = gpio_expander::ExpanderGPIOPin; + } // namespace esphome::sx1509 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::sx1509::SX1509Component); diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp deleted file mode 100644 index 41f967b6672..00000000000 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" -#include "sx1509.h" -#include "sx1509_gpio_pin.h" - -namespace esphome::sx1509 { - -ESPHOME_LOG_TAG(TAG, "sx1509_gpio_pin"); - -void SX1509GPIOPin::setup() { pin_mode(flags_); } -void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool SX1509GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void SX1509GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t SX1509GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via sx1509", this->pin_); -} - -} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h deleted file mode 100644 index 3bd3d90bd93..00000000000 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/gpio.h" - -namespace esphome::sx1509 { - -class SX1509Component; - -class SX1509GPIOPin final : public GPIOPin { - 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_parent(SX1509Component *parent) { this->parent_ = parent; } - 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: - SX1509Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; - -} // namespace esphome::sx1509 diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 754cfb14ca8..3efc4d8a3f9 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -123,14 +123,11 @@ void SY6970Component::update() { } // Read all registers in one transaction - if (!this->read_all_registers_()) { + if (!this->status_comm_result(this->read_all_registers_())) { ESP_LOGW(TAG, "Failed to read registers during update"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Notify all listeners with the new data for (auto *listener : this->listeners_) { listener->on_data(this->data_); diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp index 5d9890eca51..19f05e95b77 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::systa_bus { ESPHOME_LOG_TAG(TAG, "systa_bus.sensor"); static int16_t get_i16be(std::span message, size_t start) { - return static_cast(encode_uint16(message[start], message[start + 1])); + return encode_value(&message[start]); } void SystaSolarAquaSensor::dump_config() { diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 6c780d07a92..884ad986b3a 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -13,9 +13,7 @@ void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { - uint8_t c; - while (this->available() && this->read_byte(&c)) - this->handle_byte_(c); + this->read_available([this](uint8_t c) { this->handle_byte_(c); }); } void SystaBus::handle_byte_(uint8_t c) { diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 6fb58345358..e06d3277842 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -44,18 +44,15 @@ void T6615Component::loop() { this->read_array(response_buffer, 5); // Read header - if (response_buffer[0] != T6615_MAGIC || response_buffer[1] != T6615_ADDR_HOST) { + if (!this->status_comm_result(response_buffer[0] == T6615_MAGIC && response_buffer[1] == T6615_ADDR_HOST)) { ESP_LOGW(TAG, "Got bad data from T6615! Magic was %02X and address was %02X", response_buffer[0], response_buffer[1]); this->discard_available(); /* try again to read the sensor */ this->send_ppm_command_(); - this->status_set_warning(); return; } - this->status_clear_warning(); - switch (this->command_) { case T6615Command::GET_PPM: { const uint16_t ppm = encode_uint16(response_buffer[3], response_buffer[4]); diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 3addbfda00d..28d0ce22bdb 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -222,12 +222,10 @@ void TAS58xx::on_audio_started() { } void TAS58xx::update() { - uint8_t power_state; - if (!this->read_faults_() || !this->read_byte(TAS58XX_POWER_STATE, &power_state)) { - this->status_set_warning(LOG_STR("Failed to read status")); + uint8_t power_state{0}; + if (!this->status_comm_result(this->read_faults_() && this->read_byte(TAS58XX_POWER_STATE, &power_state), + LOG_STR("Failed to read status"))) return; - } - this->status_clear_warning(); power_state &= TAS58XX_CTRL_STATE_MASK; if (power_state != this->power_state_) { diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 204bc18c1e5..27e3e0337e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -86,11 +86,8 @@ void TCA8418Component::loop() { return; this->last_poll_ = now; - if (!this->drain_events_(true)) { - this->status_set_warning(); + if (!this->status_comm_result(this->drain_events_(true))) return; - } - this->status_clear_warning(); // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 0554f9a569d..5b7f58253e8 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -2,15 +2,7 @@ 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.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -41,23 +33,14 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(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 + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) TCA9555_PIN_SCHEMA = pins.gpio_base_schema( TCA9555GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -68,10 +51,4 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_TCA9555, TCA9555_PIN_SCHEMA) async def tca9555_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_TCA9555]) - - 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 + return await pins.new_parented_pin(config, CONF_TCA9555) diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 15272e614fb..bfb837e243c 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -65,12 +65,10 @@ bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) return false; uint16_t value; - if (!this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value)) { - this->status_set_warning(LOG_STR("Failed to read output register")); + if (!this->status_comm_result(this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value), + LOG_STR("Failed to read output register"))) return false; - } this->output_mask_ = value; - this->status_clear_warning(); return true; } @@ -78,13 +76,10 @@ bool TCA9555Component::read_gpio_modes_() { if (this->is_failed()) return false; uint16_t value; - if (!this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value)) { - this->status_set_warning(LOG_STR("Failed to read mode register")); + if (!this->status_comm_result(this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value), + LOG_STR("Failed to read mode register"))) return false; - } this->mode_mask_ = value; - - this->status_clear_warning(); return true; } bool TCA9555Component::digital_read_hw(uint8_t pin) { @@ -119,35 +114,21 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } - if (!this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_), + LOG_STR("Failed to write output register")); } bool TCA9555Component::write_gpio_modes_() { if (this->is_failed()) return false; - if (!this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_)) { - this->status_set_warning(LOG_STR("Failed to write mode register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_), + LOG_STR("Failed to write mode register")); } bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } float TCA9555Component::get_setup_priority() const { return setup_priority::IO; } -void TCA9555GPIOPin::setup() { this->pin_mode(this->flags_); } -void TCA9555GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool TCA9555GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void TCA9555GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t TCA9555GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via TCA9555", this->pin_); -} - } // namespace esphome::tca9555 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::tca9555::TCA9555Component, "%u via TCA9555") diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 50037cbe92d..fe4f6c21264 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -46,25 +47,8 @@ class TCA9555Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin 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_; - bool inverted_; - gpio::Flags flags_; -}; +using TCA9555GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::tca9555 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::tca9555::TCA9555Component); diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 30155de28aa..54cd6df45e6 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -295,11 +295,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await climate.register_climate(var, config) - if (sens := config.get(CONF_SENSOR)) is not None: - cg.add(var.set_sensor(await cg.get_variable(sens))) - - if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None: - cg.add(var.set_humidity_sensor(await cg.get_variable(sens))) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) for key, flag in ( (CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION), diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index ed42312a26b..c050ffd94fe 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_EXTERNAL_TEMPERATURE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -76,9 +75,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 38a58137aec..159e36e5554 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,5 +1,6 @@ #include "thermopro_ble.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::thermopro_ble { @@ -113,16 +114,15 @@ void ThermoProBLE::update_device_type_(StringRef device_name) { } static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { - return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8); + return encode_value_le(&data[offset]); } static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { - return static_cast(read_uint16(data, offset)); + return encode_value_le(&data[offset]); } static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { - return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8) | - (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); + return encode_value_le(&data[offset]); } // Battery calculation used with permission from: diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 6ee4c9e3ee6..e050325436c 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -451,12 +451,10 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { this->flush(); // On the single wire bus every byte sent is also received, drop that echo uint8_t echo[DATAGRAM_SIZE]; - if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + if (!this->status_comm_result(this->read_array(echo, DATAGRAM_SIZE) && memcmp(echo, datagram, DATAGRAM_SIZE) == 0)) { ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } - this->status_clear_warning(); if (uint32_t *shadow = this->shadow_register(reg)) { *shadow = value; @@ -483,12 +481,11 @@ optional TMC22XXStepper::read_register(uint8_t reg) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return {}; } - if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + if (!this->status_comm_result(reply[0] == SYNC_BYTE && reply[1] == MASTER_ADDRESS && reply[2] == reg && + reply[7] == datagram_crc(reply, 7))) { ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return {}; } - this->status_clear_warning(); return encode_uint32(reply[3], reply[4], reply[5], reply[6]); } diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 0cb2456cbd3..58858b9b386 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -29,12 +29,10 @@ void TMP1075Sensor::setup() { void TMP1075Sensor::update() { uint16_t regvalue; - if (!read_byte_16(REG_TEMP, ®value)) { + if (!this->status_comm_result(this->read_byte_16(REG_TEMP, ®value), LOG_STR("can't read"))) { ESP_LOGW(TAG, "'%s' - unable to read temperature register", this->name_.c_str()); - this->status_set_warning(LOG_STR("can't read")); return; } - this->status_clear_warning(); const float temp = regvalue2temp(regvalue); this->publish_state(temp); diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index 3943b37150c..76a061cadf7 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -26,8 +26,7 @@ void TOF10120Sensor::setup() {} void TOF10120Sensor::update() { if (!this->write_bytes(TOF10120_DISTANCE_REGISTER, TOF10120_READ_DISTANCE_CMD, sizeof(TOF10120_READ_DISTANCE_CMD))) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -38,8 +37,7 @@ void TOF10120Sensor::update() { } delay(TOF10120_DEFAULT_DELAY); if (this->read(data, 2) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 0ae96c9fd1c..33f33bda104 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -119,15 +119,12 @@ void TSL2561Sensor::read_data_() { return; } // Power off - if (!this->tsl2561_write_byte(TSL2561_REGISTER_CONTROL, 0b00000000)) { - this->status_set_warning(); + if (!this->status_comm_result(this->tsl2561_write_byte(TSL2561_REGISTER_CONTROL, 0b00000000))) return; - } float lx = this->calculate_lx_(data0, data1); ESP_LOGD(TAG, "Got illuminance=%.1flx", lx); this->publish_state(lx); - this->status_clear_warning(); } float TSL2561Sensor::get_integration_time_ms_() { switch (this->integration_time_) { diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index bf5c3f67284..f50e5392915 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -18,12 +18,9 @@ void TTP229LSFComponent::dump_config() { } void TTP229LSFComponent::loop() { uint16_t touched = 0; - if (this->read(reinterpret_cast(&touched), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read(reinterpret_cast(&touched), 2) == i2c::ERROR_OK)) return; - } touched = i2c::i2ctohs(touched); - this->status_clear_warning(); touched = reverse_bits(touched); for (auto *channel : this->channels_) { channel->process(touched); diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index dd6ae47bbfa..c70f0b6c693 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -370,15 +370,9 @@ async def to_code(config): cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) - if CONF_TX_PIN in config: - tx_pin = await cg.gpio_pin_expression(config[CONF_TX_PIN]) - cg.add(var.set_tx_pin(tx_pin)) - if CONF_RX_PIN in config: - rx_pin = await cg.gpio_pin_expression(config[CONF_RX_PIN]) - cg.add(var.set_rx_pin(rx_pin)) - if CONF_FLOW_CONTROL_PIN in config: - flow_control_pin = await cg.gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) - cg.add(var.set_flow_control_pin(flow_control_pin)) + await cg.add_pin(var.set_tx_pin, config.get(CONF_TX_PIN)) + await cg.add_pin(var.set_rx_pin, config.get(CONF_RX_PIN)) + await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) if CONF_PORT in config: cg.add(var.set_name(config[CONF_PORT])) cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) @@ -589,8 +583,7 @@ async def register_uart_device(var, config): This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_uart_parent(parent)) + await cg.add_variable(var.set_uart_parent, config[CONF_UART_ID]) def new_uart_component( diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp index 9bf9e726561..67158c82de1 100644 --- a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -14,10 +14,7 @@ void UARTBinarySensorHub::dump_config() { } void UARTBinarySensorHub::loop() { - while (this->available()) { - uint8_t byte; - if (!this->read_byte(&byte)) - break; + this->read_available([this](uint8_t byte) { this->buffer_.push_overwrite(byte); for (const auto &matcher : this->matchers_) { // The byte just read has to be the pattern's last one before the rest is compared @@ -30,7 +27,7 @@ void UARTBinarySensorHub::loop() { this->buffer_.clear(); break; } - } + }); } // Whether the pattern, apart from its last byte, ends the buffer diff --git a/esphome/components/uart/event/uart_event.cpp b/esphome/components/uart/event/uart_event.cpp index 22bcee9c83f..5cbd7ed7829 100644 --- a/esphome/components/uart/event/uart_event.cpp +++ b/esphome/components/uart/event/uart_event.cpp @@ -20,9 +20,7 @@ void UARTEvent::add_event_matcher(const char *event_name, const uint8_t *match_d } void UARTEvent::read_data_() { - while (this->available()) { - uint8_t data; - this->read_byte(&data); + this->read_available([this](uint8_t data) { this->buffer_.push_back(data); bool match_found = false; @@ -42,7 +40,7 @@ void UARTEvent::read_data_() { if (!match_found && this->max_matcher_len_ > 0 && this->buffer_.size() > this->max_matcher_len_) { this->buffer_.erase(this->buffer_.begin()); } - } + }); } } // namespace esphome::uart diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 47ff2165f8c..2d07fa892ac 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -81,7 +81,7 @@ static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "softwar static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; -static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static float to_float(uint8_t data) { return bcd_to_uint8(data); } static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { bool all_zero = true; @@ -214,10 +214,7 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { } void UFM01Component::flush_rx_() { - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - } + this->discard_available(); this->read_index_ = 0; } diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index fd6eda73a0c..8db3c148958 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -47,17 +47,15 @@ void UponorSmatrixComponent::loop() { } // Read incoming data - while (this->available()) { + this->read_available([this, now](uint8_t byte) { // The controller polls devices every 10 seconds in some units or continuously in others with around 200 ms between // devices. Remember timestamps so we can send our own packets when the bus is expected to be silent. this->last_rx_ = now; - uint8_t byte; - this->read_byte(&byte); if (this->parse_byte_(byte)) { this->rx_buffer_.clear(); } - } + }); // Send packets during bus silence if (this->rx_buffer_.empty() && (now - this->last_rx_ > 50) && (now - this->last_rx_ < 100) && diff --git a/esphome/components/usb_audio/__init__.py b/esphome/components/usb_audio/__init__.py new file mode 100644 index 00000000000..fd1333d46f6 --- /dev/null +++ b/esphome/components/usb_audio/__init__.py @@ -0,0 +1,152 @@ +from collections.abc import Callable + +import esphome.codegen as cg +from esphome.components import audio, esp32, usb_host +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_SIZE, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_PLATFORM, + CONF_SAMPLE_RATE, +) +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@ab-tools"] +DEPENDENCIES = ["esp32"] +# usb_host installs the USB host library, services its events and limits the variants +AUTO_LOAD = ["audio", "usb_host"] +DOMAIN = "usb_audio" + +usb_audio_ns = cg.esphome_ns.namespace("usb_audio") +USBAudioComponent = usb_audio_ns.class_("USBAudioComponent", cg.Component) +AudioStreamInfo = audio.audio_ns.class_("AudioStreamInfo") + +CONF_USB_AUDIO_ID = "usb_audio_id" +BITS_PER_SAMPLE_OPTIONS = [8, 16, 24, 32] +DEFAULT_BUFFER_SIZE = 6400 + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBAudioComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +def stream_schema(default_sample_rate: int, default_num_channels: int) -> cv.Schema: + """Stream format and buffer options shared by the speaker and microphone platforms.""" + return cv.Schema( + { + cv.GenerateID(CONF_USB_AUDIO_ID): cv.use_id(USBAudioComponent), + cv.Optional(CONF_SAMPLE_RATE, default=default_sample_rate): cv.int_range( + min=8000, max=96000 + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), cv.one_of(*BITS_PER_SAMPLE_OPTIONS) + ), + cv.Optional(CONF_NUM_CHANNELS, default=default_num_channels): cv.int_range( + min=1, max=2 + ), + # The default depends on the format; see apply_buffer_size() + cv.Optional(CONF_BUFFER_SIZE): cv.positive_not_null_int, + } + ).extend(cv.COMPONENT_SCHEMA) + + +def set_stream_limits(config: ConfigType) -> ConfigType: + """Pin the audio stream limits to the one configured format.""" + bits_per_sample = int(config[CONF_BITS_PER_SAMPLE]) + channels = config[CONF_NUM_CHANNELS] + sample_rate = config[CONF_SAMPLE_RATE] + # The limits validator edits the config in place and returns None + audio.set_stream_limits( + min_bits_per_sample=bits_per_sample, + max_bits_per_sample=bits_per_sample, + min_channels=channels, + max_channels=channels, + min_sample_rate=sample_rate, + max_sample_rate=sample_rate, + )(config) + return config + + +def bytes_per_frame(config: ConfigType) -> int: + """Bytes in one frame (one sample for every channel) of a platform's configured format.""" + return config[CONF_NUM_CHANNELS] * int(config[CONF_BITS_PER_SAMPLE]) // 8 + + +def apply_buffer_size(config: ConfigType, min_size: int, minimum: str) -> ConfigType: + """Default buffer_size to DEFAULT_BUFFER_SIZE, or the format's minimum when that is larger; reject smaller ones. + + `minimum` describes what min_size holds, for the error message. + """ + if CONF_BUFFER_SIZE not in config: + config[CONF_BUFFER_SIZE] = max(DEFAULT_BUFFER_SIZE, min_size) + elif config[CONF_BUFFER_SIZE] < min_size: + raise cv.Invalid( + f"buffer_size must be at least {min_size} bytes ({minimum}) for this format", + path=[CONF_BUFFER_SIZE], + ) + return config + + +def final_validate_one_per_hub(domain: str) -> Callable[[ConfigType], ConfigType]: + """The hub streams one speaker and one microphone, so reject a second platform of the same kind on a hub.""" + + def validator(config: ConfigType) -> ConfigType: + hub_id = str(config[CONF_USB_AUDIO_ID]) + count = sum( + 1 + for platform_config in fv.full_config.get().get(domain, []) + if platform_config.get(CONF_PLATFORM) == DOMAIN + and str(platform_config.get(CONF_USB_AUDIO_ID)) == hub_id + ) + if count > 1: + raise cv.Invalid( + f"Only one usb_audio {domain} per usb_audio component is supported", + path=[CONF_USB_AUDIO_ID], + ) + return config + + return validator + + +def max_packet_bytes(config: ConfigType) -> int: + """Largest isochronous packet of a platform's format: a 1 ms packet, plus one frame for rate adaptation.""" + return (-(-config[CONF_SAMPLE_RATE] // 1000) + 1) * bytes_per_frame(config) + + +def request_speaker_fifo(config: ConfigType) -> ConfigType: + """Ask usb_host for USB FIFO space for the speaker's isochronous OUT packets.""" + usb_host.request_periodic_fifo(out_bytes=max_packet_bytes(config)) + return config + + +def request_microphone_fifo(config: ConfigType) -> ConfigType: + """Ask usb_host for USB FIFO space for the microphone's isochronous IN packets.""" + usb_host.request_periodic_fifo(in_bytes=max_packet_bytes(config)) + return config + + +def stream_info(config: ConfigType) -> MockObj: + """The C++ AudioStreamInfo for a platform's configured format.""" + return AudioStreamInfo( + int(config[CONF_BITS_PER_SAMPLE]), + config[CONF_NUM_CHANNELS], + config[CONF_SAMPLE_RATE], + ) + + +async def to_code(config: ConfigType) -> None: + # Keep in sync with esphome/idf_component.yml. usb_host adds espressif/usb on IDF 6.0 and later. + esp32.add_idf_component(name="espressif/usb_host_uac", ref="1.5.0") + # 18 ms of transfers in flight instead of the driver's 9 ms, so a late transfer completion does not leave the bus + # idle while the ring buffer still holds audio. + esp32.set_idf_sdkconfig_default("CONFIG_UAC_NUM_ISOC_URBS", 6) + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add_define("USE_USB_AUDIO") diff --git a/esphome/components/usb_audio/microphone/__init__.py b/esphome/components/usb_audio/microphone/__init__.py new file mode 100644 index 00000000000..51ab3ac47ea --- /dev/null +++ b/esphome/components/usb_audio/microphone/__init__.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import microphone +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +from .. import ( + CONF_USB_AUDIO_ID, + apply_buffer_size, + bytes_per_frame, + final_validate_one_per_hub, + request_microphone_fifo, + set_stream_limits, + stream_info, + stream_schema, + usb_audio_ns, +) + +USBAudioMicrophone = usb_audio_ns.class_( + "USBAudioMicrophone", microphone.Microphone, cg.Component +) + +# Keep in sync with USBAudioMicrophone::READ_TIMEOUT_MS in usb_audio_microphone.h +READ_PERIOD_MS = 20 + + +def validate_buffer_size(config: ConfigType) -> ConfigType: + """The driver buffer must hold the audio that arrives between two reads, with room to spare.""" + bytes_per_second = config[CONF_SAMPLE_RATE] * bytes_per_frame(config) + min_size = bytes_per_second * 2 * READ_PERIOD_MS // 1000 + return apply_buffer_size(config, min_size, f"{2 * READ_PERIOD_MS} ms of audio") + + +CONFIG_SCHEMA = cv.All( + microphone.MICROPHONE_SCHEMA.extend(stream_schema(16000, 1)).extend( + { + cv.GenerateID(): cv.declare_id(USBAudioMicrophone), + } + ), + set_stream_limits, + validate_buffer_size, + request_microphone_fifo, +) + +FINAL_VALIDATE_SCHEMA = final_validate_one_per_hub("microphone") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], stream_info(config), config[CONF_BUFFER_SIZE] + ) + await cg.register_component(var, config) + await microphone.register_microphone(var, config) + hub = await cg.get_variable(config[CONF_USB_AUDIO_ID]) + await cg.register_parented(var, hub) + cg.add(hub.register_microphone(var)) diff --git a/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp b/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp new file mode 100644 index 00000000000..9d699baccf2 --- /dev/null +++ b/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp @@ -0,0 +1,182 @@ +#include "usb_audio_microphone.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/core/log.h" + +#include +#include + +extern "C" { +#include "esp_err.h" +} + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio.mic"); + +void USBAudioMicrophone::setup() { + this->frame_.reserve(this->audio_stream_info_.ms_to_bytes(2 * READ_TIMEOUT_MS)); + if (!this->mic_task_.create(&USBAudioMicrophone::mic_task, "usb_mic", MIC_TASK_STACK_SIZE, this, MIC_TASK_PRIORITY, + false)) { + this->mark_failed(LOG_STR("Failed to create microphone task")); + return; + } + // start() and stop() wake the loop. + this->disable_loop(); +} + +void USBAudioMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "USB Microphone:\n" + " Sample rate: %" PRIu32 " Hz\n" + " Bits per sample: %u\n" + " Channels: %u\n" + " Buffer size: %" PRIu32, + this->audio_stream_info_.get_sample_rate(), this->audio_stream_info_.get_bits_per_sample(), + this->audio_stream_info_.get_channels(), this->buffer_size_); +} + +void USBAudioMicrophone::loop() { + const uint8_t commands = this->commands_.exchange(0); + if (commands & COMMAND_STOP) { + // Also when a failed start left the microphone STOPPED, so the next session's retries start from the minimum. + this->cancel_timeout(RETRY_START_ID); + this->retry_delay_ms_ = RETRY_MIN_MS; + } + if ((commands & COMMAND_STOP) && + (this->state_ == microphone::STATE_STARTING || this->state_ == microphone::STATE_RUNNING)) { + this->parent_->suspend(Endpoint::MICROPHONE); + // The task notices within one read timeout; the stop finishes below once it is idle. + this->running_ = false; + this->state_ = microphone::STATE_STOPPING; + } + if (this->state_ == microphone::STATE_STOPPING && this->task_idle_) { + this->state_ = microphone::STATE_STOPPED; + } + // A retry can race the last stop(), so only start while someone is still listening. + if ((commands & COMMAND_START) && this->listeners_ > 0) { + if (this->state_ == microphone::STATE_STOPPED) { + this->state_ = microphone::STATE_STARTING; + // Answers through on_stream_state() now or once the device connects. + this->parent_->resume(Endpoint::MICROPHONE); + } else if (this->state_ == microphone::STATE_STOPPING) { + // Retry once the task is idle. + this->commands_.fetch_or(COMMAND_START); + } + } + if (this->state_ != microphone::STATE_STOPPING) { + this->disable_loop(); + } +} + +void USBAudioMicrophone::request_(uint8_t set, uint8_t clear) { + this->commands_.fetch_and(static_cast(~clear)); + this->commands_.fetch_or(set); + this->enable_loop_soon_any_context(); +} + +// Every MicrophoneSource forwards its own start() and stop(), so capture runs while any of them is listening, like +// i2s_audio's listener count. +void USBAudioMicrophone::start() { + if (this->is_failed()) { + return; + } + uint8_t listeners = this->listeners_.load(); + do { + if (listeners >= MAX_LISTENERS) { + return; + } + } while (!this->listeners_.compare_exchange_weak(listeners, listeners + 1)); + if (listeners == 0) { + this->request_(COMMAND_START, COMMAND_STOP); + } +} + +void USBAudioMicrophone::stop() { + uint8_t listeners = this->listeners_.load(); + do { + if (listeners == 0) { + return; + } + } while (!this->listeners_.compare_exchange_weak(listeners, listeners - 1)); + if (listeners == 1) { + this->request_(COMMAND_STOP, COMMAND_START); + } +} + +void USBAudioMicrophone::on_stream_state(StreamEvent event) { + switch (event) { + case StreamEvent::RUNNING: + if (this->state_ == microphone::STATE_STARTING) { + this->task_idle_ = false; + this->running_ = true; + xTaskNotifyGive(this->mic_task_.get_handle()); + this->state_ = microphone::STATE_RUNNING; + this->retry_delay_ms_ = RETRY_MIN_MS; + } + break; + case StreamEvent::LOST: + // The hub restarts the stream on reconnect, since the microphone still wants it, so keep waiting in STARTING; + // this also covers a disconnect while the stream was still starting. + if (this->state_ == microphone::STATE_RUNNING) { + ESP_LOGW(TAG, "USB device disconnected, waiting for it to return"); + this->running_ = false; + this->state_ = microphone::STATE_STARTING; + } + break; + case StreamEvent::FAILED: + // Only a start or resume fails, so the task is idle. + if (this->state_ == microphone::STATE_STARTING) { + this->state_ = microphone::STATE_STOPPED; + // A MicrophoneSource calls start() once and then waits for data, and start() only asks for the stream when + // the first listener arrives, so retry here for the listeners still held. + if (this->listeners_ > 0) { + this->set_timeout(RETRY_START_ID, this->retry_delay_ms_, [this]() { + if (this->listeners_ > 0) { + this->request_(COMMAND_START, 0); + } + }); + this->retry_delay_ms_ = std::min(this->retry_delay_ms_ * 2, RETRY_MAX_MS); + } + } + break; + } +} + +void USBAudioMicrophone::mic_task(void *param) { static_cast(param)->mic_task_loop_(); } + +void USBAudioMicrophone::mic_task_loop_() { + while (true) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (this->running_) { + size_t bytes_read = 0; + this->frame_.resize(this->frame_.capacity()); + esp_err_t err = + this->parent_->read_microphone(this->frame_.data(), this->frame_.size(), &bytes_read, READ_TIMEOUT_MS); + if (!this->running_) { + break; + } + + if (err == ESP_OK && bytes_read > 0) { + this->frame_.resize(bytes_read); + this->data_callbacks_.call(this->frame_); + } else if (err == ESP_ERR_TIMEOUT) { + // The driver already waited READ_TIMEOUT_MS for data; read again. + } else if (err == ESP_ERR_INVALID_STATE) { + // The stream is starting or suspended. + vTaskDelay(pdMS_TO_TICKS(READ_TIMEOUT_MS)); + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); + vTaskDelay(pdMS_TO_TICKS(READ_TIMEOUT_MS)); + } + } + + this->task_idle_ = true; + } +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/microphone/usb_audio_microphone.h b/esphome/components/usb_audio/microphone/usb_audio_microphone.h new file mode 100644 index 00000000000..1b2ea744e0e --- /dev/null +++ b/esphome/components/usb_audio/microphone/usb_audio_microphone.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/components/usb_audio/usb_audio.h" + +#include "esphome/components/audio/audio.h" +#include "esphome/components/microphone/microphone.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#include +#include + +namespace esphome::usb_audio { + +class USBAudioMicrophone : public microphone::Microphone, + public Component, + public Parented, + public USBAudioStream { + public: + USBAudioMicrophone(const audio::AudioStreamInfo &stream_info, uint32_t buffer_size) : USBAudioStream(buffer_size) { + this->audio_stream_info_ = stream_info; + } + + void setup() override; + void dump_config() override; + void loop() override; + + // Safe from any task; loop() acts on the request. + void start() override; + void stop() override; + + audio::AudioStreamInfo get_usb_stream_info() override { return this->audio_stream_info_; } + void on_stream_state(StreamEvent event) override; + bool stream_idle() const override { return this->task_idle_; } + + protected: + static constexpr uint8_t COMMAND_START = 1 << 0; + static constexpr uint8_t COMMAND_STOP = 1 << 1; + // Same limit as i2s_audio's microphone. + static constexpr uint8_t MAX_LISTENERS = 16; + static constexpr uint32_t READ_TIMEOUT_MS = 20; + // A failed start is retried while listeners are held, backing off so a device that keeps failing does not flood the + // log. + static constexpr uint32_t RETRY_MIN_MS = 1000; + static constexpr uint32_t RETRY_MAX_MS = 30000; + // Scheduler ids. + static constexpr uint32_t RETRY_START_ID = 0; + static constexpr uint32_t MIC_TASK_STACK_SIZE = 4096; + static constexpr UBaseType_t MIC_TASK_PRIORITY = 18; + + static void mic_task(void *param); + void mic_task_loop_(); + void request_(uint8_t set, uint8_t clear); + + // The data callback takes a std::vector, so this is reserved once in setup() and only ever resized within its + // capacity by the mic task. + std::vector frame_; + // Created once in setup() and parked between streams. + StaticTask mic_task_; + std::atomic commands_{0}; + // start() calls not yet matched by a stop(); capture stops when the last listener stops. + std::atomic listeners_{0}; + std::atomic running_{false}; + std::atomic task_idle_{true}; + // Main loop only. + uint32_t retry_delay_ms_{RETRY_MIN_MS}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/speaker/__init__.py b/esphome/components/usb_audio/speaker/__init__.py new file mode 100644 index 00000000000..62555f89d3c --- /dev/null +++ b/esphome/components/usb_audio/speaker/__init__.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import speaker +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +from .. import ( + CONF_USB_AUDIO_ID, + apply_buffer_size, + bytes_per_frame, + final_validate_one_per_hub, + request_speaker_fifo, + set_stream_limits, + stream_info, + stream_schema, + usb_audio_ns, +) + +USBAudioSpeaker = usb_audio_ns.class_("USBAudioSpeaker", speaker.Speaker, cg.Component) + +CONF_WRITE_TIMEOUT = "write_timeout" + +# Keep in sync with the usb_host_uac default of CONFIG_UAC_NUM_PACKETS_PER_URB (1 ms packets per transfer) +PACKETS_PER_TRANSFER = 3 + + +def validate_buffer_size(config: ConfigType) -> ConfigType: + """The driver only sends complete transfers, so the buffer must hold two of them to keep audio flowing.""" + frames_per_packet = -(-config[CONF_SAMPLE_RATE] // 1000) + transfer_size = PACKETS_PER_TRANSFER * frames_per_packet * bytes_per_frame(config) + return apply_buffer_size(config, 2 * transfer_size, "two USB transfers") + + +CONFIG_SCHEMA = cv.All( + speaker.SPEAKER_SCHEMA.extend(stream_schema(48000, 2)).extend( + { + cv.GenerateID(): cv.declare_id(USBAudioSpeaker), + cv.Optional( + CONF_WRITE_TIMEOUT, default="20ms" + ): cv.positive_time_period_milliseconds, + } + ), + set_stream_limits, + validate_buffer_size, + request_speaker_fifo, +) + +FINAL_VALIDATE_SCHEMA = final_validate_one_per_hub("speaker") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], + stream_info(config), + config[CONF_BUFFER_SIZE], + config[CONF_WRITE_TIMEOUT].total_milliseconds, + ) + await cg.register_component(var, config) + await speaker.register_speaker(var, config) + hub = await cg.get_variable(config[CONF_USB_AUDIO_ID]) + await cg.register_parented(var, hub) + cg.add(hub.register_speaker(var)) diff --git a/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp b/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp new file mode 100644 index 00000000000..2e1613b8ccb --- /dev/null +++ b/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp @@ -0,0 +1,359 @@ +#include "usb_audio_speaker.h" + +#ifdef USE_USB_AUDIO + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esp_err.h" +#include "esp_timer.h" +#include "sdkconfig.h" + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio.spk"); + +static constexpr size_t MAX_TIMEOUT_RETRIES = 4; +// Zeros are silence for the signed PCM the driver streams; 240 is a whole number of frames for every format. +static constexpr uint8_t SILENCE[240] = {}; + +void USBAudioSpeaker::setup() { + const auto &info = this->usb_stream_info_; + this->write_chunk_ = info.frames_to_bytes(std::max(info.bytes_to_frames(this->buffer_size_ / 4), 1)); + // The driver sends 1 ms packets, grouped into transfers it keeps in flight. + this->frame_bytes_ = info.frames_to_bytes(1); + this->packet_bytes_ = info.frames_to_bytes(info.get_sample_rate() / 1000); + this->packet_frac_ = info.get_sample_rate() % 1000; + this->transfer_bytes_ = info.frames_to_bytes((info.get_sample_rate() + 999) / 1000 * CONFIG_UAC_NUM_PACKETS_PER_URB); + this->transfer_us_ = CONFIG_UAC_NUM_PACKETS_PER_URB * 1000; + this->in_flight_us_ = CONFIG_UAC_NUM_ISOC_URBS * this->transfer_us_; + // About 10 ms per report, like the I2S speaker's DMA buffers. + this->report_frames_ = std::max(info.get_sample_rate() / 100, 1); + // start(), stop() and finish() wake the loop. + this->disable_loop(); +} + +void USBAudioSpeaker::dump_config() { + ESP_LOGCONFIG(TAG, + "USB Speaker:\n" + " Sample rate: %" PRIu32 " Hz\n" + " Bits per sample: %u\n" + " Channels: %u\n" + " Buffer size: %" PRIu32 "\n" + " Write timeout: %" PRIu32 " ms", + this->usb_stream_info_.get_sample_rate(), this->usb_stream_info_.get_bits_per_sample(), + this->usb_stream_info_.get_channels(), this->buffer_size_, this->write_timeout_ms_); +} + +void USBAudioSpeaker::loop() { + this->deliver_reports_(); + const uint8_t commands = this->commands_.exchange(0); + if (commands & COMMAND_STOP) { + this->stop_(); + } + if ((commands & COMMAND_START) && this->state_ == speaker::STATE_STOPPED) { + // Suspending flushed the driver's ring buffer, and resuming restarts its packet sizing. + portENTER_CRITICAL(&this->taken_lock_); + this->ring_bytes_ = 0; + this->ring_padding_ = 0; + // Resuming resets the driver's accumulator, then prepares every transfer, which advances it by their packets. + this->packet_frac_accum_ = CONFIG_UAC_NUM_ISOC_URBS * CONFIG_UAC_NUM_PACKETS_PER_URB * this->packet_frac_ % 1000; + this->unreported_frames_ = 0; + this->report_frames_pending_ = 0; + this->last_finish_us_ = 0; + portEXIT_CRITICAL(&this->taken_lock_); + this->state_ = speaker::STATE_STARTING; + // Answers through on_stream_state() now or once the device connects. + this->parent_->resume(Endpoint::SPEAKER); + } + + if (commands & COMMAND_FINISH) { + if (this->state_ == speaker::STATE_RUNNING) { + this->begin_finishing_(); + } else if (this->state_ == speaker::STATE_STARTING) { + // The stream is still starting; on_stream_state() finishes it once it runs. + this->finish_after_start_ = true; + } + } + if (this->finishing_ && this->pad_remaining_ > 0) { + this->pad_with_silence_(); + } + // has_buffered_data_() turns false once the driver has taken the last whole transfer and it has had time to play. + if (this->finishing_ && this->pad_remaining_ == 0 && !this->has_buffered_data_(esp_timer_get_time())) { + this->stop_(); + } + if (!this->finishing_) { + this->disable_loop(); + } +} + +void USBAudioSpeaker::request_(uint8_t set, uint8_t clear) { + this->commands_.fetch_and(static_cast(~clear)); + this->commands_.fetch_or(set); + this->enable_loop_soon_any_context(); +} + +void USBAudioSpeaker::start() { + if (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING) { + return; + } + this->request_(COMMAND_START, COMMAND_STOP); +} + +void USBAudioSpeaker::stop() { this->request_(COMMAND_STOP, COMMAND_START | COMMAND_FINISH); } + +void USBAudioSpeaker::finish() { this->request_(COMMAND_FINISH, 0); } + +void USBAudioSpeaker::begin_finishing_() { + if (this->finishing_) { + return; + } + this->finishing_ = true; + this->pad_remaining_ = this->transfer_bytes_; + // loop() pads the last transfer and stops once the audio has played. + this->enable_loop(); +} + +void USBAudioSpeaker::stop_() { + this->finishing_ = false; + this->finish_after_start_ = false; + this->pad_remaining_ = 0; + if (this->state_ == speaker::STATE_STOPPED) { + return; + } + this->parent_->suspend(Endpoint::SPEAKER); + this->state_ = speaker::STATE_STOPPED; +} + +void USBAudioSpeaker::on_stream_state(StreamEvent event) { + if (event == StreamEvent::RUNNING) { + if (this->state_ == speaker::STATE_STARTING) { + this->state_ = speaker::STATE_RUNNING; + if (this->finish_after_start_) { + this->finish_after_start_ = false; + this->begin_finishing_(); + } + } + return; + } + // Playback stops on a lost device as well as a failed start; the next start() opens it again. + if (event == StreamEvent::LOST && this->state_ == speaker::STATE_RUNNING) { + ESP_LOGW(TAG, "USB device disconnected, stopping playback"); + } + this->stop_(); +} + +size_t USBAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + // 0 ticks is one non-blocking pass; portMAX_DELAY is capped by the configured write timeout. + const uint32_t budget_ms = ticks_to_wait == portMAX_DELAY ? this->write_timeout_ms_ : pdTICKS_TO_MS(ticks_to_wait); + return this->play_internal_(data, length, budget_ms); +} + +// Callers such as voice_assistant and rtttl call this from loop(), so it never blocks. +size_t USBAudioSpeaker::play(const uint8_t *data, size_t length) { return this->play(data, length, 0); } + +size_t USBAudioSpeaker::play_internal_(const uint8_t *data, size_t length, uint32_t budget_ms) { + if (this->audio_stream_info_ != this->usb_stream_info_) { + // Playing it through the configured stream would change the speed and channel layout. + if (!this->format_mismatch_.exchange(true)) { + const auto &info = this->audio_stream_info_; + ESP_LOGE(TAG, + "Audio format (%" PRIu32 " Hz, %u bits, %u channels) does not match the USB stream; use a resampler " + "speaker", + info.get_sample_rate(), info.get_bits_per_sample(), info.get_channels()); + } + if (budget_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(budget_ms)); + } + return 0; + } + this->format_mismatch_ = false; + + if (this->state_ != speaker::STATE_RUNNING) { + this->start(); + // Wait out the budget so a pipeline task retrying play() does not spin. + if (budget_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(budget_ms)); + } + return 0; + } + + // Counted before the hub's handle is read, so the hub never closes the device under this call. + this->writes_in_flight_.fetch_add(1); + + // Runs on the caller's task, so the cached loop time does not apply. + const uint32_t start_ms = millis(); + uint32_t now = start_ms; + size_t written = 0; + size_t timeout_retries = 0; + while (written < length) { + const uint32_t elapsed = now - start_ms; + if (budget_ms > 0 && elapsed >= budget_ms) { + break; + } + const size_t chunk = std::min({length - written, this->write_chunk_, this->ring_room_()}); + if (chunk == 0) { + // Back-pressure: wait for the driver to take a transfer. + if (budget_ms == 0) { + break; + } + vTaskDelay(1); + now = millis(); + continue; + } + const uint32_t timeout_ms = budget_ms > 0 ? budget_ms - elapsed : 0; + const esp_err_t err = this->write_(data + written, chunk, timeout_ms, false); + now = millis(); + if (err == ESP_OK) { + written += chunk; + timeout_retries = 0; + } else if (err == ESP_ERR_INVALID_STATE) { + // The device went away. + break; + } else if (err != ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Write error: %s", esp_err_to_name(err)); + break; + } else if (budget_ms == 0 || ++timeout_retries >= MAX_TIMEOUT_RETRIES) { + // A full driver buffer is back-pressure; the caller retries. + break; + } + } + this->writes_in_flight_.fetch_sub(1); + return written; +} + +size_t USBAudioSpeaker::ring_room_() const { + // Whole frames up to buffer_size_. ring_bytes_ is never below the real fill, so a write of this much does not wait. + const uint32_t ring = this->ring_bytes_; + const uint32_t room = ring < this->buffer_size_ ? this->buffer_size_ - ring : 0; + return room - room % this->frame_bytes_; +} + +esp_err_t USBAudioSpeaker::write_(const uint8_t *data, size_t length, uint32_t timeout_ms, bool padding) { + // Counted first: the driver submits transfers inside the write, and on_transfer_taken() must see these bytes then. + // A failed write stores nothing, since the driver only writes whole chunks. + this->ring_bytes_.fetch_add(length); + if (padding) { + this->ring_padding_.fetch_add(length); + } + const esp_err_t err = this->parent_->write_speaker(data, length, timeout_ms); + if (err != ESP_OK) { + this->ring_bytes_.fetch_sub(length); + if (padding) { + this->ring_padding_.fetch_sub(length); + } + } + return err; +} + +void USBAudioSpeaker::pad_with_silence_() { + if (this->state_ != speaker::STATE_RUNNING) { + this->pad_remaining_ = 0; + return; + } + // Counted before the hub's handle is read, so the hub never closes the device under this call. + this->writes_in_flight_.fetch_add(1); + const size_t piece = sizeof(SILENCE) - sizeof(SILENCE) % this->usb_stream_info_.frames_to_bytes(1); + while (this->pad_remaining_ > 0) { + const size_t chunk = std::min({this->pad_remaining_, piece, this->ring_room_()}); + if (chunk == 0 || this->write_(SILENCE, chunk, 0, true) != ESP_OK) { + // A full buffer; the rest is written on a later loop(). + break; + } + this->pad_remaining_ -= chunk; + } + this->writes_in_flight_.fetch_sub(1); +} + +void USBAudioSpeaker::on_transfer_taken() { + bool report = false; + portENTER_CRITICAL(&this->taken_lock_); + // Mirror the driver: the next transfer is CONFIG_UAC_NUM_PACKETS_PER_URB packets, and the driver takes it only when + // the ring buffer holds all of it; otherwise it parks the transfer until the next write. + uint32_t accum = this->packet_frac_accum_; + uint32_t size = 0; + for (int i = 0; i < CONFIG_UAC_NUM_PACKETS_PER_URB; i++) { + accum += this->packet_frac_; + size += this->packet_bytes_; + if (accum >= 1000) { + size += this->frame_bytes_; + accum -= 1000; + } + } + const uint32_t ring = this->ring_bytes_; + if (ring >= size) { + this->packet_frac_accum_ = accum; + this->ring_bytes_.fetch_sub(size); + // Padding sits at the end of the ring buffer and is not audio from the caller, so it is not reported. + const uint32_t padding = std::min(this->ring_padding_, ring); + const uint32_t audio = std::min(size, ring - padding); + if (audio < size) { + this->ring_padding_.fetch_sub(std::min(size - audio, padding)); + } + const int64_t now_us = esp_timer_get_time(); + this->last_taken_us_ = static_cast(now_us); + // The bus plays one packet per millisecond, so while it streams a transfer finishes one transfer after the one + // taken before it, however late this callback runs. It cannot finish later than now plus the transfers in flight, + // so a callback that runs on time pulls back any lateness the chain picked up. When the bus is idle, after a start + // (the driver submits nothing until the first write) or once it ran dry, the transfer plays right away. + const int64_t finish_us = this->last_finish_us_ < now_us + ? now_us + this->transfer_us_ + : std::min(this->last_finish_us_ + this->transfer_us_, now_us + this->in_flight_us_); + this->last_finish_us_ = finish_us; + this->unreported_frames_ += this->usb_stream_info_.bytes_to_frames(audio); + // Batched like the I2S speaker's DMA buffers: about every 10 ms, and once the caller's audio runs out. + if (this->unreported_frames_ > 0 && (this->unreported_frames_ >= this->report_frames_ || audio == ring - padding)) { + this->report_frames_pending_ += this->unreported_frames_; + this->report_finish_us_ = finish_us; + this->unreported_frames_ = 0; + report = true; + } + } + portEXIT_CRITICAL(&this->taken_lock_); + if (report) { + // The listeners can block, so loop() calls them; the frames and their finish time travel together, so the delay + // does not change what they compute. + this->enable_loop_soon_any_context(); + } +} + +void USBAudioSpeaker::deliver_reports_() { + portENTER_CRITICAL(&this->taken_lock_); + const uint32_t frames = this->report_frames_pending_; + const int64_t finish_us = this->report_finish_us_; + this->report_frames_pending_ = 0; + portEXIT_CRITICAL(&this->taken_lock_); + if (frames > 0) { + this->audio_output_callback_(frames, finish_us); + } +} + +bool USBAudioSpeaker::has_buffered_data_(int64_t now_us) const { + if (this->state_ != speaker::STATE_RUNNING) { + return false; + } + // The driver still holds a whole transfer to take, or one it took has not finished playing. Less than a transfer + // stays in the ring buffer until the next write. + return this->ring_bytes_ >= this->transfer_bytes_ || + static_cast(static_cast(now_us) - this->last_taken_us_) < + static_cast(this->in_flight_us_); +} + +bool USBAudioSpeaker::has_buffered_data() const { return this->has_buffered_data_(esp_timer_get_time()); } + +// The hub's worker sends volume and mute to the device. +void USBAudioSpeaker::set_volume(float volume) { + speaker::Speaker::set_volume(volume); + this->parent_->set_speaker_volume_level(volume); +} + +void USBAudioSpeaker::set_mute_state(bool mute_state) { + speaker::Speaker::set_mute_state(mute_state); + this->parent_->set_speaker_mute_state(mute_state); +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/speaker/usb_audio_speaker.h b/esphome/components/usb_audio/speaker/usb_audio_speaker.h new file mode 100644 index 00000000000..a4edda7e0ea --- /dev/null +++ b/esphome/components/usb_audio/speaker/usb_audio_speaker.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/components/usb_audio/usb_audio.h" + +#include "esphome/components/audio/audio.h" +#include "esphome/components/speaker/speaker.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::usb_audio { + +class USBAudioSpeaker : public speaker::Speaker, + public Component, + public Parented, + public USBAudioStream { + public: + USBAudioSpeaker(const audio::AudioStreamInfo &stream_info, uint32_t buffer_size, uint32_t write_timeout_ms) + : USBAudioStream(buffer_size), usb_stream_info_(stream_info), write_timeout_ms_(write_timeout_ms) { + this->audio_stream_info_ = stream_info; + } + + void setup() override; + void dump_config() override; + void loop() override; + + // Safe from any task; loop() acts on the request. + void start() override; + void stop() override; + void finish() override; + + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + size_t play(const uint8_t *data, size_t length) override; + bool has_buffered_data() const override; + + void set_volume(float volume) override; + void set_mute_state(bool mute_state) override; + + audio::AudioStreamInfo get_usb_stream_info() override { return this->usb_stream_info_; } + void on_stream_state(StreamEvent event) override; + bool stream_idle() const override { return this->writes_in_flight_ == 0; } + void on_transfer_taken() override; + + protected: + static constexpr uint8_t COMMAND_START = 1 << 0; + static constexpr uint8_t COMMAND_STOP = 1 << 1; + static constexpr uint8_t COMMAND_FINISH = 1 << 2; + void request_(uint8_t set, uint8_t clear); + void deliver_reports_(); + void begin_finishing_(); + void stop_(); + size_t play_internal_(const uint8_t *data, size_t length, uint32_t budget_ms); + /// Write silence until a whole USB transfer is padded, so the driver sends a final partial transfer. + void pad_with_silence_(); + /// Write to the driver, counting the bytes in ring_bytes_ (and in ring_padding_ for silence padding). + esp_err_t write_(const uint8_t *data, size_t length, uint32_t timeout_ms, bool padding); + /// Bytes that can be written without filling the driver's ring buffer past buffer_size_. + size_t ring_room_() const; + bool has_buffered_data_(int64_t now_us) const; + + // The configured format the USB stream runs with. Callers such as the media pipeline set audio_stream_info_ for + // each item, so play() checks it against this instead of reopening the stream with another format. + const audio::AudioStreamInfo usb_stream_info_; + uint32_t write_timeout_ms_; + // Frame-aligned size of each driver write, set in setup(). + uint32_t write_chunk_{0}; + // The driver's packet sizing, mirrored so on_transfer_taken() knows how much each transfer takes: whole frames per + // 1 ms packet, plus a frame whenever the remainder of sample_rate / 1000 adds up. Set in setup(). + uint32_t frame_bytes_{0}; + uint32_t packet_bytes_{0}; + uint32_t packet_frac_{0}; + // Bytes in the largest USB transfer, how long one transfer plays and how long all the driver keeps in flight play, + // and the batch of frames reported at a time; set in setup(). + uint32_t transfer_bytes_{0}; + uint32_t transfer_us_{0}; + uint32_t in_flight_us_{0}; + uint32_t report_frames_{0}; + // Silence still to write while finishing. + uint32_t pad_remaining_{0}; + // Bytes in the driver's ring buffer: added before each write, since the driver can take a transfer inside it, and + // taken off for each transfer it takes. ring_padding_ is the silence padding at its end. + std::atomic ring_bytes_{0}; + std::atomic ring_padding_{0}; + // Low 32 bits of esp_timer_get_time() when the driver last took a transfer. + std::atomic last_taken_us_{0}; + // on_transfer_taken() runs on the USB driver's task, which must not block, and inside writes, so its state is + // guarded by a spinlock and the reports it collects are delivered from loop(). Reset while the stream is stopped. + portMUX_TYPE taken_lock_ = portMUX_INITIALIZER_UNLOCKED; + uint32_t packet_frac_accum_{0}; + uint32_t unreported_frames_{0}; + uint32_t report_frames_pending_{0}; + int64_t report_finish_us_{0}; + // When the last transfer taken finishes playing; reset on a start, so the next one starts the chain from now. + int64_t last_finish_us_{0}; + std::atomic commands_{0}; + // play() calls and silence padding that may be using the device handle; the hub closes a lost device only at zero. + std::atomic writes_in_flight_{0}; + // Set while play() rejects audio in a format other than usb_stream_info_, so the error is logged once. + std::atomic format_mismatch_{false}; + bool finishing_{false}; + // finish() arrived while the stream was still starting. + bool finish_after_start_{false}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/usb_audio.cpp b/esphome/components/usb_audio/usb_audio.cpp new file mode 100644 index 00000000000..ec9537e1dce --- /dev/null +++ b/esphome/components/usb_audio/usb_audio.cpp @@ -0,0 +1,876 @@ +#include "usb_audio.h" + +#ifdef USE_USB_AUDIO + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +extern "C" { +#include "esp_err.h" +} + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio"); + +// The driver task completes and resubmits the speaker's transfers, with only CONFIG_UAC_NUM_ISOC_URBS of them +// queued on the bus, so it runs above the network stack like the I2S speaker task; each wakeup is short. +static constexpr UBaseType_t UAC_TASK_PRIORITY = 19; +static constexpr uint32_t UAC_TASK_STACK_SIZE = 4096; +// Below the driver's task, so its transfers are not held up by control requests. +static constexpr UBaseType_t WORKER_TASK_PRIORITY = 5; +static constexpr uint32_t WORKER_TASK_STACK_SIZE = 4096; +// The worker retries volume and mute up to this many times, this far apart. +static constexpr uint8_t MAX_CONTROL_ATTEMPTS = 5; +static constexpr uint32_t CONTROL_RETRY_MS = 100; +// A failed close is retried up to this many times, this far apart, before the handle is given up. +static constexpr uint8_t MAX_CLOSE_ATTEMPTS = 5; +static constexpr uint32_t CLOSE_RETRY_MS = 500; + +static const LogString *endpoint_name(Endpoint endpoint) { + return endpoint == Endpoint::SPEAKER ? LOG_STR("Speaker") : LOG_STR("Microphone"); +} + +static uint8_t discrete_rate_count(const uac_host_dev_alt_param_t &alt) { + return std::min(alt.sample_freq_type, UAC_FREQ_NUM_MAX); +} + +static bool alt_supports_rate(const uac_host_dev_alt_param_t &alt, uint32_t sample_rate) { + if (alt.sample_freq_type == 0) { + return sample_rate >= alt.sample_freq_lower && sample_rate <= alt.sample_freq_upper; + } + const uint8_t count = discrete_rate_count(alt); + return std::find(alt.sample_freq, alt.sample_freq + count, sample_rate) != alt.sample_freq + count; +} + +void USBAudioComponent::setup() { + this->event_queue_ = xQueueCreateStatic(EVENT_QUEUE_LENGTH, sizeof(USBAudioEvent), this->event_queue_storage_.data(), + &this->event_queue_struct_); + this->op_queue_ = + xQueueCreateStatic(OP_QUEUE_LENGTH, sizeof(Op), this->op_queue_storage_.data(), &this->op_queue_struct_); + + uac_host_driver_config_t uac_cfg = { + .create_background_task = true, + .task_priority = UAC_TASK_PRIORITY, + .stack_size = UAC_TASK_STACK_SIZE, + .core_id = 0, + .callback = &USBAudioComponent::driver_event_stub, + .callback_arg = this, + }; + + esp_err_t err = uac_host_install(&uac_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "uac_host_install failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + this->uac_installed_ = true; + + // Opening, starting, suspending and closing streams and setting volume are control transfers that can block for + // seconds, so they run here instead of on the main loop. + if (!this->worker_.create(&USBAudioComponent::worker_task, "usb_audio", WORKER_TASK_STACK_SIZE, this, + WORKER_TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create worker task")); + return; + } + // The USB callbacks and the worker wake the loop when there is work. + this->disable_loop(); +} + +void USBAudioComponent::loop() { + this->process_events_(App.get_loop_component_start_time()); + + if (const uint32_t errors = this->transfer_errors_.exchange(0); errors != 0) { + ESP_LOGW(TAG, "%" PRIu32 " USB transfer errors", errors); + } + + // Stay awake while a lost device still waits to be closed. + if (std::any_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &ep) { return ep.closing != nullptr; })) { + return; + } + this->disable_loop(); +} + +void USBAudioComponent::process_events_(uint32_t now) { + this->finish_closes_(now); + + USBAudioEvent event; + // Only the events already queued: one posted again while this runs waits for the next loop(). + for (UBaseType_t queued = uxQueueMessagesWaiting(this->event_queue_); + queued > 0 && xQueueReceive(this->event_queue_, &event, 0) == pdTRUE; queued--) { + this->process_event_(event, now); + } +} + +void USBAudioComponent::dump_config() { + const auto &speaker = this->endpoint_(Endpoint::SPEAKER); + const auto µphone = this->endpoint_(Endpoint::MICROPHONE); + ESP_LOGCONFIG(TAG, + "USB Audio:\n" + " Device connected: %s\n" + " Speaker registered: %s\n" + " Microphone registered: %s", + YESNO(speaker.handle.load() != nullptr || microphone.handle.load() != nullptr), + YESNO(speaker.platform != nullptr), YESNO(microphone.platform != nullptr)); +} + +bool USBAudioComponent::teardown() { + if (this->event_queue_ == nullptr) { + return true; + } + // Stop the platforms first, then let the worker close each device once its platform is idle. + this->begin_close_(Endpoint::SPEAKER); + this->begin_close_(Endpoint::MICROPHONE); + // The loop time does not advance while components tear down. + const uint32_t now = millis(); + this->process_events_(now); + if (!this->finish_closes_(now)) { + return false; + } + // Wait for the worker to finish whatever it was still running. + if (uxQueueMessagesWaiting(this->op_queue_) != 0 || this->driver_busy_) { + return false; + } + xQueueReset(this->event_queue_); + + if (this->uac_installed_) { + esp_err_t err = uac_host_uninstall(); + if (err == ESP_ERR_INVALID_STATE) { + return false; + } + if (err != ESP_OK) { + ESP_LOGW(TAG, "uac_host_uninstall returned %s", esp_err_to_name(err)); + } + this->uac_installed_ = false; + } + return true; +} + +void USBAudioComponent::resume(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + ep.active = true; + ep.request_seq++; + const uac_host_device_handle_t handle = ep.handle; + if (handle == nullptr) { + if (ep.open_failed && !ep.open_pending) { + // The device is still plugged in but its last open failed; try again for this start. + ep.open_failed = false; + this->open_stream_(endpoint, ep.failed_addr, ep.failed_iface); + return; + } + ESP_LOGD(TAG, "%s interface not connected yet", LOG_STR_ARG(endpoint_name(endpoint))); + return; + } + if (ep.start_pending) { + // The pending start reports back to the platform, and checks `active` first. + return; + } + if (!ep.stream_started) { + if (ep.start_rejected) { + // Already probed and logged for this device; a retrying caller must not flood the log. + this->stream_failed_(endpoint); + return; + } + this->start_stream_(endpoint); + return; + } + // Queued even while an earlier resume or suspend is pending: the worker runs them in order, so the driver ends in + // the latest requested state, and only this request's RESUMED reports back. + Op op; + op.type = Op::Type::RESUME; + op.endpoint = endpoint; + op.handle = handle; + op.seq = ep.request_seq; + if (!this->queue_op_(op)) { + this->stream_failed_(endpoint); + } +} + +void USBAudioComponent::suspend(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + ep.active = false; + ep.request_seq++; + const uac_host_device_handle_t handle = ep.handle; + // A start still running on the worker is suspended once its STARTED arrives. + if (!ep.stream_started || handle == nullptr) { + return; + } + Op op; + op.type = Op::Type::SUSPEND; + op.endpoint = endpoint; + op.handle = handle; + this->queue_op_(op); +} + +bool USBAudioComponent::enter_driver_(uint32_t &timeout_ms) { + const uint32_t start = millis(); + while (true) { + // Counted before the busy check, so the worker waits for this call to leave the driver before it takes it. + this->driver_users_.fetch_add(1); + if (!this->driver_busy_) { + const uint32_t waited = millis() - start; + timeout_ms = waited < timeout_ms ? timeout_ms - waited : 0; + return true; + } + this->driver_users_.fetch_sub(1); + // A control request takes a few milliseconds, so go on as soon as the worker is done with it. + if (millis() - start >= timeout_ms) { + return false; + } + vTaskDelay(1); + } +} + +esp_err_t USBAudioComponent::read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms) { + *bytes_read = 0; + if (!this->enter_driver_(timeout_ms)) { + return ESP_ERR_TIMEOUT; + } + uac_host_device_handle_t handle = this->endpoint_(Endpoint::MICROPHONE).handle; + if (handle == nullptr) { + this->driver_users_.fetch_sub(1); + return ESP_ERR_INVALID_STATE; + } + // The driver collects up to `size` bytes for the whole timeout and returns what it got, or ESP_ERR_TIMEOUT when + // nothing arrived. + uint32_t actual = 0; + esp_err_t err = uac_host_device_read(handle, buffer, static_cast(size), &actual, pdMS_TO_TICKS(timeout_ms)); + this->driver_users_.fetch_sub(1); + *bytes_read = actual; + return err; +} + +esp_err_t USBAudioComponent::write_speaker(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (!this->enter_driver_(timeout_ms)) { + // A non-blocking caller retries later. + return ESP_ERR_TIMEOUT; + } + uac_host_device_handle_t handle = this->endpoint_(Endpoint::SPEAKER).handle; + if (handle == nullptr) { + this->driver_users_.fetch_sub(1); + return ESP_ERR_INVALID_STATE; + } + esp_err_t err = uac_host_device_write(handle, const_cast(data), static_cast(length), + pdMS_TO_TICKS(timeout_ms)); + this->driver_users_.fetch_sub(1); + if (err == ESP_FAIL || err == ESP_ERR_INVALID_STATE) { + // Ring buffer full or stream inactive; report as timeout so callers back off and retry. + err = ESP_ERR_TIMEOUT; + } + return err; +} + +void USBAudioComponent::process_event_(const USBAudioEvent &event, uint32_t now) { + switch (event.type) { + case USBAudioEvent::Type::CONNECTED: { + auto &ep = this->endpoint_(event.endpoint); + if (ep.closing != nullptr) { + // A device that came back waits until its old handle is closed; the queue keeps draining meanwhile, so the + // worker's CLOSED can arrive. + ep.connect_deferred = true; + ep.deferred_addr = event.addr; + ep.deferred_iface = event.iface_num; + break; + } + this->open_stream_(event.endpoint, event.addr, event.iface_num); + break; + } + case USBAudioEvent::Type::DISCONNECTED: + this->handle_disconnect_(event.handle); + break; + case USBAudioEvent::Type::OPENED: + this->on_opened_(event); + break; + case USBAudioEvent::Type::STARTED: + this->on_started_(event); + break; + case USBAudioEvent::Type::RESUMED: + this->on_resumed_(event); + break; + case USBAudioEvent::Type::CLOSED: + this->on_closed_(event, now); + break; + } +} + +void USBAudioComponent::open_stream_(Endpoint endpoint, uint8_t addr, uint8_t iface_num) { + auto &ep = this->endpoint_(endpoint); + const LogString *name = endpoint_name(endpoint); + if (ep.platform == nullptr) { + ESP_LOGD(TAG, "%s interface connected but not configured", LOG_STR_ARG(name)); + return; + } + if (ep.open_pending) { + // Either a duplicate, or a replacement for a device that went away during the pending open; on_opened_() tells + // them apart and replays a replacement. + ep.connect_deferred = true; + ep.deferred_addr = addr; + ep.deferred_iface = iface_num; + return; + } + if (ep.handle.load() != nullptr) { + ESP_LOGW(TAG, "%s interface already open, ignoring duplicate event", LOG_STR_ARG(name)); + return; + } + Op op; + op.type = Op::Type::OPEN; + op.endpoint = endpoint; + op.addr = addr; + op.iface_num = iface_num; + if (!this->queue_op_(op)) { + // The event is already off the queue, so post it again for a later loop() instead of losing the device. + USBAudioEvent event; + event.type = USBAudioEvent::Type::CONNECTED; + event.endpoint = endpoint; + event.addr = addr; + event.iface_num = iface_num; + this->post_event_(event); + return; + } + ep.open_pending = true; +} + +void USBAudioComponent::on_opened_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + ep.open_pending = false; + bool lost_meanwhile = false; + for (auto &lost : this->lost_while_opening_) { + if (event.ok && lost == event.handle) { + lost = nullptr; + lost_meanwhile = true; + } + } + if (std::none_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &other) { return other.open_pending; })) { + // Only a pending open can still claim an entry; a failed one reports no handle, so its entry would stay behind + // and could match a later device that reuses the freed handle. + this->lost_while_opening_.fill(nullptr); + } + if (!event.ok) { + if (ep.connect_deferred) { + // A newer connect arrived during the failed open; try that one. + ep.connect_deferred = false; + this->open_stream_(event.endpoint, ep.deferred_addr, ep.deferred_iface); + return; + } + // Keep where the device is, so the next start() retries the open without a replug. + ep.open_failed = true; + ep.failed_addr = event.addr; + ep.failed_iface = event.iface_num; + if (ep.active) { + this->stream_failed_(event.endpoint); + } + return; + } + ep.open_failed = false; + ep.handle = event.handle; + ep.iface = event.iface_num; + ESP_LOGI(TAG, "%s interface %u connected (addr=%u)", LOG_STR_ARG(endpoint_name(event.endpoint)), ep.iface, + event.addr); + if (lost_meanwhile) { + // It went away while the worker was opening it; a connect deferred meanwhile is opened once it is closed. + this->handle_disconnect_(event.handle); + return; + } + // The device that was being opened is still here, so a connect deferred meanwhile was a duplicate. + ep.connect_deferred = false; + if (ep.active) { + this->start_stream_(event.endpoint); + } +} + +void USBAudioComponent::start_stream_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + Op op; + op.type = Op::Type::START; + op.endpoint = endpoint; + op.handle = ep.handle; + if (!this->queue_op_(op)) { + this->stream_failed_(endpoint); + return; + } + ep.start_pending = true; +} + +void USBAudioComponent::on_started_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + ep.start_pending = false; + if (event.handle != ep.handle.load()) { + // The device went away meanwhile; the platform was already told. + return; + } + if (!event.ok) { + ep.start_rejected = event.rejected; + if (ep.active) { + this->stream_failed_(event.endpoint); + } + return; + } + ep.stream_started = true; + if (event.endpoint == Endpoint::SPEAKER) { + // Send the current volume and mute to the newly started stream. + this->volume_.pending = true; + this->mute_.pending = true; + this->queue_controls_(); + } + if (!ep.active) { + // The platform stopped while the stream was starting. + this->suspend(event.endpoint); + return; + } + ep.platform->on_stream_state(StreamEvent::RUNNING); +} + +void USBAudioComponent::on_resumed_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + if (event.handle != ep.handle.load() || event.seq != ep.request_seq || !ep.active) { + // The device went away, or a later suspend or resume is queued behind this one and decides the state. + return; + } + if (!event.ok) { + this->stream_failed_(event.endpoint); + return; + } + ep.platform->on_stream_state(StreamEvent::RUNNING); +} + +void USBAudioComponent::stream_failed_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + // The platform goes back to stopped, so a reconnect must not restart the stream; its next start() retries. + ep.active = false; + ep.platform->on_stream_state(StreamEvent::FAILED); +} + +void USBAudioComponent::set_speaker_volume_level(float volume) { + this->speaker_volume_ = static_cast(std::lround(std::clamp(volume, 0.0f, 1.0f) * 100.0f)); + this->volume_.pending = true; + this->queue_controls_(); +} + +void USBAudioComponent::set_speaker_mute_state(bool mute_state) { + this->speaker_muted_ = mute_state; + this->mute_.pending = true; + this->queue_controls_(); +} + +void USBAudioComponent::queue_controls_() { + if (this->op_queue_ == nullptr) { + return; + } + if (this->controls_queued_.exchange(true)) { + // The queued op sends the latest values, so lifecycle ops keep their queue slots. + return; + } + Op op; + op.type = Op::Type::CONTROLS; + if (xQueueSend(this->op_queue_, &op, 0) != pdTRUE) { + // The controls stay pending and the next start or change queues them again. + this->controls_queued_ = false; + } +} + +void USBAudioComponent::handle_disconnect_(uac_host_device_handle_t handle) { + for (size_t i = 0; i < this->endpoints_.size(); i++) { + auto &ep = this->endpoints_[i]; + if (handle != nullptr && ep.handle.load() == handle) { + const auto endpoint = static_cast(i); + ESP_LOGI(TAG, "%s interface %u disconnected", LOG_STR_ARG(endpoint_name(endpoint)), ep.iface); + this->begin_close_(endpoint); + return; + } + } + if (std::any_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &ep) { return ep.open_pending; })) { + // Its open is still running on the worker; close it once it reports back. + for (auto &lost : this->lost_while_opening_) { + if (lost == nullptr || lost == handle) { + lost = handle; + return; + } + } + // Both endpoints are already waiting on a lost open; the oldest one is the least likely to still report back. + this->lost_while_opening_[0] = handle; + return; + } + ESP_LOGD(TAG, "Disconnect event for an interface with no active stream"); +} + +void USBAudioComponent::begin_close_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + // Platform reads and writes see nullptr from here on and return at once. + const uac_host_device_handle_t handle = ep.handle.exchange(nullptr); + if (handle == nullptr) { + return; + } + ep.closing = handle; + ep.close_queued = false; + // `active` is kept, so the stream restarts when the device returns. + ep.platform->on_stream_state(StreamEvent::LOST); +} + +bool USBAudioComponent::finish_closes_(uint32_t now) { + bool done = true; + for (size_t i = 0; i < this->endpoints_.size(); i++) { + auto &ep = this->endpoints_[i]; + if (ep.closing == nullptr) { + continue; + } + done = false; + // Closing frees the driver's interface, so wait until no platform task can still be using the handle. + if (ep.close_queued || !ep.platform->stream_idle()) { + continue; + } + if (ep.close_attempts > 0 && static_cast(now - ep.close_retry_ms) < 0) { + // A failed close waits a moment before it is retried. + continue; + } + Op op; + op.type = Op::Type::CLOSE; + op.endpoint = static_cast(i); + op.handle = ep.closing; + op.stop = ep.stream_started; + ep.close_queued = this->queue_op_(op); + } + return done; +} + +void USBAudioComponent::on_closed_(const USBAudioEvent &event, uint32_t now) { + auto &ep = this->endpoint_(event.endpoint); + ep.close_queued = false; + if (!event.ok && ++ep.close_attempts < MAX_CLOSE_ATTEMPTS) { + // Keep the handle, so finish_closes_() retries the close. + ep.close_retry_ms = now + CLOSE_RETRY_MS; + return; + } + if (!event.ok) { + ESP_LOGE(TAG, "Giving up closing the %s interface after %u attempts", LOG_STR_ARG(endpoint_name(event.endpoint)), + ep.close_attempts); + } + ep.close_attempts = 0; + ep.closing = nullptr; + ep.stream_started = false; + ep.start_rejected = false; + ep.start_pending = false; + if (ep.connect_deferred) { + // The device came back while its old handle was closing. + ep.connect_deferred = false; + this->open_stream_(event.endpoint, ep.deferred_addr, ep.deferred_iface); + } +} + +bool USBAudioComponent::queue_op_(const Op &op) { + if (xQueueSend(this->op_queue_, &op, 0) != pdTRUE) { + ESP_LOGW(TAG, "USB audio work queue full, dropping %s request", LOG_STR_ARG(endpoint_name(op.endpoint))); + return false; + } + return true; +} + +void USBAudioComponent::post_event_(const USBAudioEvent &event) { + if (xQueueSend(this->event_queue_, &event, 0) != pdTRUE) { + ESP_LOGW(TAG, "USB audio event queue full, dropping event"); + } + this->enable_loop_soon_any_context(); +} + +void USBAudioComponent::worker_task(void *param) { + auto *self = static_cast(param); + Op op; + while (true) { + if (xQueueReceive(self->op_queue_, &op, portMAX_DELAY) != pdTRUE) { + continue; + } + self->lock_driver_(); + self->run_op_(op); + self->unlock_driver_(); + } +} + +void USBAudioComponent::lock_driver_() { + this->driver_busy_ = true; + // Reads and writes give up within their timeout; wait for those already inside the driver. + while (this->driver_users_ != 0) { + vTaskDelay(1); + } +} + +void USBAudioComponent::unlock_driver_() { this->driver_busy_ = false; } + +void USBAudioComponent::run_op_(const Op &op) { + const LogString *name = endpoint_name(op.endpoint); + USBAudioEvent result; + result.endpoint = op.endpoint; + result.handle = op.handle; + + switch (op.type) { + case Op::Type::OPEN: { + uint32_t buffer_size = this->endpoint_(op.endpoint).platform->get_buffer_size(); + uint32_t threshold = std::max(buffer_size / 4, 1U); + if (op.endpoint == Endpoint::SPEAKER) { + // The driver raises TX_DONE after taking a transfer only while the ring buffer holds at most the threshold, + // and it needs the threshold below the size. The speaker never fills past its buffer_size, so it hears about + // every transfer even when a waiting write lands right after one. + threshold = buffer_size; + buffer_size += SPEAKER_RING_SLACK; + } + uac_host_device_config_t dev_cfg = { + .addr = op.addr, + .iface_num = op.iface_num, + .buffer_size = buffer_size, + .buffer_threshold = threshold, + .callback = &USBAudioComponent::device_event_stub, + .callback_arg = this, + }; + uac_host_device_handle_t handle{nullptr}; + esp_err_t err = uac_host_device_open(&dev_cfg, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open %s interface: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + result.type = USBAudioEvent::Type::OPENED; + result.ok = err == ESP_OK; + result.handle = handle; + result.addr = op.addr; + result.iface_num = op.iface_num; + this->post_event_(result); + return; + } + case Op::Type::START: { + result.type = USBAudioEvent::Type::STARTED; + uac_host_stream_config_t stm_cfg{}; + if (this->populate_stream_config_(op.endpoint, op.handle, stm_cfg, result.rejected)) { + esp_err_t err = uac_host_device_start(op.handle, &stm_cfg); + // Already started or active is ESP_OK; ESP_ERR_INVALID_STATE means the interface is not ready. + result.ok = err == ESP_OK; + // The host cannot allocate the endpoint's pipe, for example when its max packet size exceeds the USB FIFO. + result.rejected = err == ESP_ERR_NOT_SUPPORTED; + if (!result.ok) { + ESP_LOGW(TAG, "Failed to start %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + } + this->post_event_(result); + return; + } + case Op::Type::RESUME: { + esp_err_t err = uac_host_device_resume(op.handle); + result.type = USBAudioEvent::Type::RESUMED; + result.seq = op.seq; + // Already started or active is ESP_OK; ESP_ERR_INVALID_STATE means the interface is not ready. + result.ok = err == ESP_OK; + if (!result.ok) { + ESP_LOGW(TAG, "Failed to resume %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + this->post_event_(result); + return; + } + case Op::Type::SUSPEND: { + esp_err_t err = uac_host_device_suspend(op.handle); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to suspend %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + return; + } + case Op::Type::CLOSE: { + if (op.stop) { + esp_err_t err = uac_host_device_stop(op.handle); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to stop %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + } + esp_err_t err = uac_host_device_close(op.handle); + result.type = USBAudioEvent::Type::CLOSED; + result.ok = err == ESP_OK; + if (!result.ok) { + // The interface may still be held, so the main loop keeps the handle and retries. + ESP_LOGW(TAG, "uac_host_device_close failed: %s", esp_err_to_name(err)); + this->post_event_(result); + return; + } + if (op.endpoint == Endpoint::SPEAKER) { + // A reconnected device may support different controls. + for (ControlState *control : {&this->volume_, &this->mute_}) { + control->supported = true; + control->attempts = 0; + } + } + this->post_event_(result); + return; + } + case Op::Type::CONTROLS: { + // Cleared first, so a change made during the transfer queues another op. + this->controls_queued_ = false; + if (this->apply_pending_speaker_controls_()) { + // Give the device a moment before retrying; the queue keeps any other work in order. + this->unlock_driver_(); + vTaskDelay(pdMS_TO_TICKS(CONTROL_RETRY_MS)); + this->lock_driver_(); + this->queue_controls_(); + } + return; + } + } +} + +bool USBAudioComponent::populate_stream_config_(Endpoint endpoint, uac_host_device_handle_t handle, + uac_host_stream_config_t &stream_config, bool &rejected) { + const LogString *name = endpoint_name(endpoint); + const audio::AudioStreamInfo info = this->endpoint_(endpoint).platform->get_usb_stream_info(); + const uint8_t channels = info.get_channels(); + const uint8_t bits = info.get_bits_per_sample(); + const uint32_t sample_rate = info.get_sample_rate(); + + uac_host_dev_info_t dev_info{}; + esp_err_t err = uac_host_get_device_info(handle, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to query %s interface info: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + return false; + } + + // uac_host_device_start() picks the alternate setting itself from channels, bit depth and rate: the last match with + // discrete rates, or the first match with a rate range. Find the one it will pick, so its subframe size is checked. + uint8_t selected_alt = 0; + uint8_t selected_subframe = 0; + for (uint8_t alt = 1; alt <= dev_info.iface_alt_num; alt++) { + uac_host_dev_alt_param_t alt_param{}; + if (uac_host_get_device_alt_param(handle, alt, &alt_param) != ESP_OK || alt_param.channels != channels || + alt_param.bit_resolution != bits || !alt_supports_rate(alt_param, sample_rate)) { + continue; + } + selected_alt = alt; + selected_subframe = alt_param.subframe_size; + if (alt_param.sample_freq_type == 0) { + break; + } + } + + // ESPHome's samples are packed, so a device that pads them (e.g. 24-bit samples in 4-byte subframes) cannot be + // streamed unchanged. + if (selected_alt != 0 && selected_subframe * 8 == bits) { + stream_config.channels = channels; + stream_config.bit_resolution = bits; + stream_config.sample_freq = sample_rate; + stream_config.flags = 0; + return true; + } + + rejected = true; + if (selected_alt != 0) { + ESP_LOGW(TAG, "%s interface %u would stream alt %u with %u-bit samples padded to %u bytes, which is not supported", + LOG_STR_ARG(name), dev_info.iface_num, selected_alt, bits, selected_subframe); + } + ESP_LOGW(TAG, "%s interface %u does not support requested format (channels=%u bits=%u sample_rate=%" PRIu32 " Hz)", + LOG_STR_ARG(name), dev_info.iface_num, channels, bits, sample_rate); + for (uint8_t alt = 1; alt <= dev_info.iface_alt_num; alt++) { + uac_host_dev_alt_param_t alt_param{}; + if (uac_host_get_device_alt_param(handle, alt, &alt_param) != ESP_OK) { + continue; + } + if (alt_param.sample_freq_type == 0) { + ESP_LOGI(TAG, " alt %u: channels=%u bits=%u subframe=%u bytes sample rate range=%" PRIu32 "-%" PRIu32 " Hz", alt, + alt_param.channels, alt_param.bit_resolution, alt_param.subframe_size, alt_param.sample_freq_lower, + alt_param.sample_freq_upper); + continue; + } + ESP_LOGI(TAG, " alt %u: channels=%u bits=%u subframe=%u bytes sample rates:", alt, alt_param.channels, + alt_param.bit_resolution, alt_param.subframe_size); + const uint8_t count = discrete_rate_count(alt_param); + for (uint8_t i = 0; i < count; i++) { + // Some devices list a rate more than once. + if (std::find(alt_param.sample_freq, alt_param.sample_freq + i, alt_param.sample_freq[i]) == + alt_param.sample_freq + i) { + ESP_LOGI(TAG, " %" PRIu32 " Hz", alt_param.sample_freq[i]); + } + } + } + return false; +} + +bool USBAudioComponent::apply_pending_speaker_controls_() { + const uac_host_device_handle_t handle = this->endpoint_(Endpoint::SPEAKER).handle; + if (handle == nullptr) { + // Sent once the stream starts. + return false; + } + bool retry = false; + if (this->volume_.pending.exchange(false) && this->volume_.supported) { + retry |= this->apply_control_(this->volume_, uac_host_device_set_volume(handle, this->speaker_volume_), + LOG_STR("volume")); + } + if (this->mute_.pending.exchange(false) && this->mute_.supported) { + retry |= this->apply_control_(this->mute_, uac_host_device_set_mute(handle, this->speaker_muted_), LOG_STR("mute")); + } + return retry; +} + +bool USBAudioComponent::apply_control_(ControlState &control, esp_err_t err, const LogString *name) { + if (err == ESP_OK) { + control.attempts = 0; + return false; + } + if (err == ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Speaker %s control not supported by device", LOG_STR_ARG(name)); + control.supported = false; + return false; + } + if (err == ESP_ERR_INVALID_STATE) { + // The stream is not started yet; starting it queues the controls again. + control.pending = true; + return false; + } + if (++control.attempts >= MAX_CONTROL_ATTEMPTS) { + ESP_LOGW(TAG, "Failed to set speaker %s: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + control.attempts = 0; + return false; + } + control.pending = true; + return true; +} + +void USBAudioComponent::driver_event_stub(uint8_t addr, uint8_t iface_num, const uac_host_driver_event_t event, + void *user_data) { + auto *self = static_cast(user_data); + USBAudioEvent evt; + switch (event) { + case UAC_HOST_DRIVER_EVENT_TX_CONNECTED: + evt.endpoint = Endpoint::SPEAKER; + break; + case UAC_HOST_DRIVER_EVENT_RX_CONNECTED: + evt.endpoint = Endpoint::MICROPHONE; + break; + default: + return; + } + evt.type = USBAudioEvent::Type::CONNECTED; + evt.addr = addr; + evt.iface_num = iface_num; + self->post_event_(evt); +} + +void USBAudioComponent::device_event_stub(uac_host_device_handle_t handle, const uac_host_device_event_t event, + void *user_data) { + auto *self = static_cast(user_data); + if (event == UAC_HOST_DEVICE_EVENT_TX_DONE) { + auto &speaker = self->endpoint_(Endpoint::SPEAKER); + // Only the speaker's own interface; the handle is cleared first when the device goes away. + if (speaker.platform != nullptr && handle == speaker.handle.load()) { + speaker.platform->on_transfer_taken(); + } + } else if (event == UAC_HOST_DEVICE_EVENT_TRANSFER_ERROR) { + self->transfer_errors_.fetch_add(1, std::memory_order_relaxed); + self->enable_loop_soon_any_context(); + } else if (event == UAC_HOST_DRIVER_EVENT_DISCONNECTED) { + USBAudioEvent evt; + evt.type = USBAudioEvent::Type::DISCONNECTED; + evt.handle = handle; + self->post_event_(evt); + } +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/usb_audio.h b/esphome/components/usb_audio/usb_audio.h new file mode 100644 index 00000000000..a283fb18f2e --- /dev/null +++ b/esphome/components/usb_audio/usb_audio.h @@ -0,0 +1,239 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/core/component.h" +#include "esphome/core/static_task.h" + +extern "C" { +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "usb/uac_host.h" +} + +namespace esphome::usb_audio { + +enum class Endpoint : uint8_t { + SPEAKER, + MICROPHONE, +}; + +/// What the hub reports to a platform about its stream. +enum class StreamEvent : uint8_t { + // The stream runs. + RUNNING, + // The device went away; the hub restarts the stream when it returns while the platform still wants it. + LOST, + // The stream failed to start; the platform goes back to stopped and its next start() retries. + FAILED, +}; + +/// A speaker or microphone platform that streams through the hub. The hub calls it on the main loop only. +class USBAudioStream { + public: + explicit USBAudioStream(uint32_t buffer_size) : buffer_size_(buffer_size) {} + + uint32_t get_buffer_size() const { return this->buffer_size_; } + /// The fixed format the hub opens the stream with. + virtual audio::AudioStreamInfo get_usb_stream_info() = 0; + virtual void on_stream_state(StreamEvent event) = 0; + /// No platform task is inside a read or write call on the device; the hub closes a lost device only then. + virtual bool stream_idle() const = 0; + /// Speaker only: the driver tried to take its next transfer from the ring buffer, which it does each time a transfer + /// completes and on each write, and takes it only when the ring buffer holds all of it. Called on the USB driver's + /// task or inside a write. + virtual void on_transfer_taken() {} + + protected: + uint32_t buffer_size_; +}; + +/// Posted to the main loop, by the USB driver task (CONNECTED, DISCONNECTED) or the hub's worker (the results). +struct USBAudioEvent { + enum class Type : uint8_t { + CONNECTED, + // The driver reports disconnects through the device callback, not the driver callback. + DISCONNECTED, + OPENED, + STARTED, + RESUMED, + CLOSED, + }; + + uac_host_device_handle_t handle{nullptr}; + uint8_t addr{0}; + uint8_t iface_num{0}; + // RESUMED: the request it answers; only the latest request's result is acted on. + uint8_t seq{0}; + Type type{Type::CONNECTED}; + Endpoint endpoint{Endpoint::SPEAKER}; + bool ok{false}; + // STARTED: the start will fail the same way until the device reconnects: the device does not support the configured + // format, or the host cannot allocate its pipe. + bool rejected{false}; +}; + +class USBAudioComponent : public Component { + public: + void setup() override; + void dump_config() override; + void loop() override; + // Just after usb_host, which installs the USB host library that uac_host_install needs. + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + bool teardown() override; + + void register_speaker(USBAudioStream *speaker) { this->endpoint_(Endpoint::SPEAKER).platform = speaker; } + void register_microphone(USBAudioStream *microphone) { this->endpoint_(Endpoint::MICROPHONE).platform = microphone; } + + /// Start streaming as soon as the device is connected; the platform hears back through on_stream_state(). + void resume(Endpoint endpoint); + void suspend(Endpoint endpoint); + + /// Safe from any task; the worker sends the values to the device. + void set_speaker_volume_level(float volume); + void set_speaker_mute_state(bool mute_state); + + // Safe from any task. While the worker holds the driver for a control transfer, these wait for it within timeout_ms + // and return ESP_ERR_TIMEOUT if it is still busy, instead of blocking on the driver's interface lock for seconds. + esp_err_t read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms); + esp_err_t write_speaker(const uint8_t *data, size_t length, uint32_t timeout_ms); + + protected: + static constexpr size_t EVENT_QUEUE_LENGTH = 16; + static constexpr size_t OP_QUEUE_LENGTH = 8; + // Extra driver ring buffer space above the speaker's buffer_size, see Op::Type::OPEN. + static constexpr uint32_t SPEAKER_RING_SLACK = 4; + + struct EndpointState { + // Read by the platform tasks and the worker; cleared first when the device goes away. + std::atomic handle{nullptr}; + // A lost device, closed by the worker once the platform reports stream_idle(). + uac_host_device_handle_t closing{nullptr}; + USBAudioStream *platform{nullptr}; + uint8_t iface{0}; + bool stream_started{false}; + // The platform asked for the stream and has not stopped it. + bool active{false}; + // Starting the connected device failed for good (see USBAudioEvent::rejected); cleared when it is closed. + bool start_rejected{false}; + // An open or start is queued on the worker; its result event clears it. + bool open_pending{false}; + bool start_pending{false}; + bool close_queued{false}; + // Counts resume() and suspend() calls, so a RESUMED that a later suspend overtook is ignored. + uint8_t request_seq{0}; + // A connect that arrived while the old handle was still opening or closing; opened once that finishes. + bool connect_deferred{false}; + uint8_t deferred_addr{0}; + uint8_t deferred_iface{0}; + // The last open failed; the next resume() retries it at this address and interface. + bool open_failed{false}; + uint8_t failed_addr{0}; + uint8_t failed_iface{0}; + // A failed close keeps `closing` and is retried after this time, a limited number of times. + uint8_t close_attempts{0}; + uint32_t close_retry_ms{0}; + }; + + struct ControlState { + std::atomic pending{false}; + // Only touched by the worker, and reset while the speaker is closed. + bool supported{true}; + uint8_t attempts{0}; + }; + + /// A blocking driver call, run on the worker. + struct Op { + enum class Type : uint8_t { + OPEN, + START, + RESUME, + SUSPEND, + CLOSE, + CONTROLS, + }; + + uac_host_device_handle_t handle{nullptr}; + uint8_t addr{0}; + uint8_t iface_num{0}; + // RESUME: copied to the result, see EndpointState::request_seq. + uint8_t seq{0}; + Type type{Type::OPEN}; + Endpoint endpoint{Endpoint::SPEAKER}; + // CLOSE: stop the stream before closing. + bool stop{false}; + }; + + EndpointState &endpoint_(Endpoint endpoint) { return this->endpoints_[static_cast(endpoint)]; } + const EndpointState &endpoint_(Endpoint endpoint) const { return this->endpoints_[static_cast(endpoint)]; } + void process_events_(uint32_t now); + void process_event_(const USBAudioEvent &event, uint32_t now); + void open_stream_(Endpoint endpoint, uint8_t addr, uint8_t iface_num); + void on_opened_(const USBAudioEvent &event); + void on_started_(const USBAudioEvent &event); + void on_resumed_(const USBAudioEvent &event); + void on_closed_(const USBAudioEvent &event, uint32_t now); + void start_stream_(Endpoint endpoint); + void stream_failed_(Endpoint endpoint); + void handle_disconnect_(uac_host_device_handle_t handle); + void begin_close_(Endpoint endpoint); + bool finish_closes_(uint32_t now); + bool queue_op_(const Op &op); + void queue_controls_(); + void post_event_(const USBAudioEvent &event); + + // Worker side: the only place that makes blocking driver calls after setup(). + static void worker_task(void *param); + void run_op_(const Op &op); + /// Keep platform reads and writes out of the driver while the worker uses it. + void lock_driver_(); + /// Platform side: wait, within timeout_ms, until the worker is not using the driver, and count the caller as a + /// driver user; timeout_ms is reduced by the wait. Returns false when the worker still holds it. + bool enter_driver_(uint32_t &timeout_ms); + void unlock_driver_(); + bool populate_stream_config_(Endpoint endpoint, uac_host_device_handle_t handle, + uac_host_stream_config_t &stream_config, bool &rejected); + /// Send pending volume and mute; returns true when one should be retried later. + bool apply_pending_speaker_controls_(); + bool apply_control_(ControlState &control, esp_err_t err, const LogString *name); + + static void driver_event_stub(uint8_t addr, uint8_t iface_num, uac_host_driver_event_t event, void *user_data); + static void device_event_stub(uac_host_device_handle_t handle, uac_host_device_event_t event, void *user_data); + + StaticTask worker_; + // Filled from the USB driver task and the worker, drained on the main loop. + QueueHandle_t event_queue_{nullptr}; + // Filled on the main loop (and by volume/mute changes from any task), drained by the worker. + QueueHandle_t op_queue_{nullptr}; + std::array endpoints_{}; + // Disconnects for devices whose open is still running on the worker, one per endpoint since a speakerphone opens + // two interfaces; each is closed once its OPENED arrives. + std::array lost_while_opening_{}; + // Counted on the USB driver task, logged from loop(). + std::atomic transfer_errors_{0}; + StaticQueue_t event_queue_struct_{}; + StaticQueue_t op_queue_struct_{}; + std::array event_queue_storage_{}; + std::array op_queue_storage_{}; + ControlState volume_{}; + ControlState mute_{}; + std::atomic speaker_volume_{100}; + std::atomic speaker_muted_{false}; + // A CONTROLS op is queued; the pending flags carry the latest values, so one is enough. + std::atomic controls_queued_{false}; + // Set while the worker runs a driver call; platform reads and writes counted in driver_users_ back off. + std::atomic driver_busy_{false}; + std::atomic driver_users_{0}; + bool uac_installed_{false}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 317cec8236b..07a5cae0ae5 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -2,7 +2,7 @@ import itertools import logging import esphome.codegen as cg -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_MANUFACTURER, CONF_MAX_PACKET_SIZE from esphome.components.esp32 import ( VARIANT_ESP32H4, VARIANT_ESP32P4, @@ -11,11 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, + get_esp32_variant, idf_version, only_on_variant, ) import esphome.config_validation as cv -from esphome.const import CONF_DEVICES, CONF_ID +from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component @@ -37,7 +38,6 @@ CONF_PID = "pid" CONF_PRODUCT = "product" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" -CONF_MAX_PACKET_SIZE = "max_packet_size" # VID/PID set to 0 or `None` product/manufacturer are wildcards @@ -117,12 +117,101 @@ def _final_validate(config: ConfigType) -> ConfigType: if redundant else "narrow its filter so it no longer matches that device", ) + _reconcile_fifo() return config FINAL_VALIDATE_SCHEMA = _final_validate +# S2/S3 full-speed controller: 200 usable 4-byte FIFO lines (EPINFO takes the rest of 256) shared by RX, +# non-periodic TX and periodic TX +FIFO_VARIANTS = (VARIANT_ESP32S2, VARIANT_ESP32S3) +FIFO_LINES = 200 +FIFO_LINE_BYTES = 4 +FIFO_RX_STATUS_LINES = 2 +# (RX, NPTX, PTX) lines ESP-IDF picks for each CONFIG_USB_HOST_HW_BUFFER_BIAS_* option +FIFO_BIAS_SPLITS = { + "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN": (152, 16, 32), + "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT": (34, 16, 150), +} +FIFO_BALANCED_SPLIT = (104, 64, 32) +# Custom split: 64-byte control/bulk, at least 128 bytes each way +FIFO_NPTX_LINES = 16 +FIFO_PTX_MIN_LINES = 32 +FIFO_RX_MIN_LINES = 32 + FIFO_RX_STATUS_LINES +# usb_host_config_t::fifo_settings_custom; USB host is not reliable on older releases +FIFO_CUSTOM_MIN_IDF = cv.Version(5, 5, 0) +KEY_PERIODIC_FIFO = "periodic_fifo" + + +def request_periodic_fifo(in_bytes: int = 0, out_bytes: int = 0) -> None: + """Request FIFO space for the largest periodic IN/OUT packet. Call from CONFIG_SCHEMA validation.""" + requested = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_PERIODIC_FIFO, [0, 0]) + requested[0] = max(requested[0], in_bytes) + requested[1] = max(requested[1], out_bytes) + + +def _fifo_lines(size: int) -> int: + return -(-size // FIFO_LINE_BYTES) + + +def _bias_option() -> str | None: + framework = fv.full_config.get().get("esp32", {}).get(CONF_FRAMEWORK, {}) + options = framework.get("sdkconfig_options", {}) + return next((o for o in FIFO_BIAS_SPLITS if options.get(o) in ("y", True)), None) + + +def fifo_split() -> tuple[int, int, int] | None: + """(RX, NPTX, PTX) lines for the requests, or None when the default split fits them.""" + requested = CORE.data.get(DOMAIN, {}).get(KEY_PERIODIC_FIFO) + if requested is None or get_esp32_variant() not in FIFO_VARIANTS: + return None + in_bytes, out_bytes = requested + rx = _fifo_lines(in_bytes) + FIFO_RX_STATUS_LINES + ptx = _fifo_lines(out_bytes) + bias = _bias_option() + default_rx, _, default_ptx = FIFO_BIAS_SPLITS[bias] if bias else FIFO_BALANCED_SPLIT + if rx <= default_rx and ptx <= default_ptx: + return None + if bias: + raise cv.Invalid( + f"The requested USB packets don't fit the FIFO split {bias} selects; remove it from " + "sdkconfig_options so usb_host can size the FIFO" + ) + rx = max(FIFO_RX_MIN_LINES, rx) + ptx = max(FIFO_PTX_MIN_LINES, ptx) + spare = FIFO_LINES - FIFO_NPTX_LINES - rx - ptx + if spare < 0: + raise cv.Invalid( + f"The requested USB packets need {(rx + ptx + FIFO_NPTX_LINES) * FIFO_LINE_BYTES} bytes of USB FIFO, " + f"but the {get_esp32_variant()} has {FIFO_LINES * FIFO_LINE_BYTES} usable" + ) + # Devices may declare a larger max packet size, so the requested directions share the rest + if in_bytes and out_bytes: + rx += spare // 2 + ptx += spare - spare // 2 + elif out_bytes: + ptx += spare + else: + rx += spare + return rx, FIFO_NPTX_LINES, ptx + + +def _reconcile_fifo() -> None: + if (split := fifo_split()) is None: + return + if idf_version() < FIFO_CUSTOM_MIN_IDF: + raise cv.Invalid( + f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later" + ) + rx, nptx, ptx = split + cg.add_define("USE_USB_HOST_FIFO_CONFIG") + cg.add_define("USB_HOST_FIFO_RX_LINES", rx) + cg.add_define("USB_HOST_FIFO_NPTX_LINES", nptx) + cg.add_define("USB_HOST_FIFO_PTX_LINES", ptx) + + def _set_max_packet_size(config: dict) -> dict: CORE.data.setdefault(DOMAIN, {})[CONF_MAX_PACKET_SIZE] = config[ CONF_MAX_PACKET_SIZE diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 102311348aa..c2a87eeab70 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -9,6 +9,12 @@ namespace esphome::usb_host { void USBHost::setup() { usb_host_config_t config{}; +#ifdef USE_USB_HOST_FIFO_CONFIG + // Sized in Python from component requests + config.fifo_settings_custom.rx_fifo_lines = USB_HOST_FIFO_RX_LINES; + config.fifo_settings_custom.nptx_fifo_lines = USB_HOST_FIFO_NPTX_LINES; + config.fifo_settings_custom.ptx_fifo_lines = USB_HOST_FIFO_PTX_LINES; +#endif if (usb_host_install(&config) != ESP_OK) { this->status_set_error(LOG_STR("usb_host_install failed")); diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 5c111c22942..000b69667dc 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -7,11 +7,11 @@ namespace esphome::vbus { ESPHOME_LOG_TAG(TAG, "vbus.sensor"); static inline uint16_t get_u16(std::vector &message, int start) { - return (message[start + 1] << 8) + message[start]; + return encode_value_le(&message[start]); } static inline int16_t get_i16(std::vector &message, int start) { - return (int16_t) ((message[start + 1] << 8) + message[start]); + return encode_value_le(&message[start]); } void DeltaSolBSPlusSensor::dump_config() { diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 6b6a3393ee4..c45ae6bdaa3 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -28,24 +28,18 @@ static bool checksum(const uint8_t *data, int start, int count) { } void VBus::loop() { - if (!available()) - return; - - while (available()) { - uint8_t c; - read_byte(&c); - + this->read_available([this](uint8_t c) { if (c == 0xaa) { this->state_ = 1; this->buffer_.clear(); - continue; + return; } if (c & 0x80) { this->state_ = 0; - continue; + return; } if (this->state_ == 0) - continue; + return; if (this->state_ == 1) { this->buffer_.push_back(c); @@ -59,7 +53,7 @@ void VBus::loop() { this->state_ = 0; if (!checksum(this->buffer_.data(), 0, 15)) { ESP_LOGE(TAG, "P2 checksum failed"); - continue; + return; } septet_spread(this->buffer_.data(), 7, 6, this->buffer_[13]); uint16_t id = (this->buffer_[8] << 8) + this->buffer_[7]; @@ -70,7 +64,7 @@ void VBus::loop() { if (!checksum(this->buffer_.data(), 0, 9)) { ESP_LOGE(TAG, "P1 checksum failed"); this->state_ = 0; - continue; + return; } this->frames_ = this->buffer_[7]; if (this->frames_) { @@ -86,23 +80,23 @@ void VBus::loop() { ESP_LOGW(TAG, "Unknown protocol 0x%02x, discarding", this->protocol_); this->state_ = 0; } - continue; + return; } if (this->state_ == 2) { this->fbytes_[this->fbcount_++] = c; if (this->fbcount_ < 6) - continue; + return; this->fbcount_ = 0; if (!checksum(this->fbytes_, 0, 6)) { ESP_LOGE(TAG, "frame checksum failed"); - continue; + return; } septet_spread(this->fbytes_, 0, 4, this->fbytes_[4]); for (int i = 0; i < 4; i++) this->buffer_.push_back(this->fbytes_[i]); if (++this->cframe_ < this->frames_) - continue; + return; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_size(VBUS_MAX_LOG_BYTES)]; size_t log_bytes = std::min(this->buffer_.size(), static_cast(VBUS_MAX_LOG_BYTES)); @@ -112,9 +106,9 @@ void VBus::loop() { for (auto &listener : this->listeners_) listener->on_message(this->command_, this->source_, this->dest_, this->buffer_); this->state_ = 0; - continue; + return; } - } + }); } void VBusListener::on_message(uint16_t command, uint16_t source, uint16_t dest, std::vector &message) { diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index ffaaf9eb2a3..1b8984247a4 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -54,15 +54,12 @@ void VEML3235Sensor::update() { void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { uint8_t als_regs[] = {0, 0}; - if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register(ALS_REG, als_regs, sizeof als_regs) == i2c::ERROR_OK)) { this->publish_state(NAN); this->measurement_in_progress_ = false; return; } - this->status_clear_warning(); - uint16_t als_counts = encode_value_le(als_regs); if (this->auto_gain_ && adjustments_left > 0) { diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 0b89ca8b561..e01fd924e7b 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -2,14 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -48,19 +41,11 @@ async def to_code(config: ConfigType) -> None: await i2c.new_i2c_component(config) -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 - - WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( WaveshareIOCH32V003GPIOPin, cv.int_range(min=0, max=7), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -73,13 +58,4 @@ WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) async def waveshare_io_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WAVESHARE_IO_CH32V003) diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 0347cfa083c..86571363257 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -51,13 +51,10 @@ uint16_t WaveshareIOCH32V003Component::get_adc_value() { if (this->is_failed()) return 0; - uint8_t data[2]; - if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { - this->status_set_warning(LOG_STR("Failed to read ADC register")); + uint16_t adc_value; + if (!this->status_comm_result(this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value), + LOG_STR("Failed to read ADC register"))) return 0; - } - uint16_t adc_value = (data[1] << 8) | data[0]; - this->status_clear_warning(); return adc_value; } @@ -66,11 +63,9 @@ uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { return 0; uint8_t data = 0; - if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1), + LOG_STR("Failed to read RTC interrupt register"))) return 0; - } - this->status_clear_warning(); return data; } @@ -82,34 +77,21 @@ void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { // based on circuit schematic requirements. This follows the pattern from the // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". - if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { - this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte(IO_EXTENSION_PWM_ADDR, value), LOG_STR("Failed to set PWM duty cycle")); } bool WaveshareIOCH32V003Component::write_gpio_modes_() { if (this->is_failed()) return false; - if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { - this->status_set_warning(LOG_STR("Failed to write mode register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_), + LOG_STR("Failed to write mode register")); } bool WaveshareIOCH32V003Component::write_gpio_outputs_() { if (this->is_failed()) return false; - if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_), + LOG_STR("Failed to write output register")); } bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { @@ -117,13 +99,10 @@ bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { return false; uint8_t data = 0; - if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read input register")); + if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1), + LOG_STR("Failed to read input register"))) return false; - } this->input_mask_ = data; - - this->status_clear_warning(); return true; } @@ -138,27 +117,13 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { } uint8_t data = this->output_mask_; - if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data), + LOG_STR("Failed to write output register")); } bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } -void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } -void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { - this->parent_->digital_write(this->pin_, value ^ this->inverted_); -} - -size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); -} - } // namespace esphome::waveshare_io_ch32v003 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::waveshare_io_ch32v003::WaveshareIOCH32V003Component, "EXIO%u via WaveshareIO") diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h index 4a31602fa41..adecaa681cc 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -4,6 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::waveshare_io_ch32v003 { @@ -27,8 +28,6 @@ class WaveshareIOCH32V003Component : public Component, void set_pwm_value(uint8_t value); // 0 - 255 protected: - friend class WaveshareIOCH32V003GPIOPin; - 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; @@ -41,25 +40,8 @@ class WaveshareIOCH32V003Component : public Component, bool write_gpio_outputs_(); }; -/// Helper class to expose a WaveshareIO pin as a GPIO pin. -class WaveshareIOCH32V003GPIOPin : 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_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using WaveshareIOCH32V003GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::waveshare_io_ch32v003 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::waveshare_io_ch32v003::WaveshareIOCH32V003Component); diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 58bfc6f815d..5fb54a906aa 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -1,3 +1,4 @@ +from esphome import pins import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS @@ -90,10 +91,7 @@ async def register_weikai(var: MockObj, config: ConfigType) -> None: def validate_pin_mode(value: ConfigType) -> ConfigType: """Checks input/output mode inconsistency""" - if not (value[CONF_MODE][CONF_INPUT] or value[CONF_MODE][CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_MODE][CONF_INPUT] and value[CONF_MODE][CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") + pins.gpio_validate_input_xor_output(value[CONF_MODE]) return value diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index e341ce04bbc..59b48014579 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -1,4 +1,5 @@ #include "whirlpool.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::whirlpool { @@ -136,10 +137,8 @@ void WhirlpoolClimate::transmit_state() { } // Checksum - for (uint8_t i = 2; i < 13; i++) - remote_state[13] ^= remote_state[i]; - for (uint8_t i = 14; i < 20; i++) - remote_state[20] ^= remote_state[i]; + remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]); + remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]); ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X " @@ -269,13 +268,9 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum13 = 0; - uint8_t checksum20 = 0; // Calculate checksum and compare with signal value. - for (uint8_t i = 2; i < 13; i++) - checksum13 ^= remote_state[i]; - for (uint8_t i = 14; i < 20; i++) - checksum20 ^= remote_state[i]; + uint8_t checksum13 = xor_bytes(&remote_state[2], 11); + uint8_t checksum20 = xor_bytes(&remote_state[14], 6); if (checksum13 != remote_state[13] || (!skip_footer && checksum20 != remote_state[20])) { ESP_LOGVV(TAG, "Checksum fail"); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5660b1189b8..886b7ef9990 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -3,7 +3,6 @@ import math from typing import Any from esphome import automation, preferences -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( @@ -241,12 +240,6 @@ def _validate_dns_servers(value: list) -> list: return value -WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) -WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) -WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) -WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) -WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) -WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action) WiFiConfigureAction = wifi_ns.class_( "WiFiConfigureAction", automation.Action, cg.Component ) @@ -1042,49 +1035,27 @@ async def to_code(config): CORE.add_job(final_step) -automation.register_bare_condition( - "wifi.connected", - WiFiConnectedCondition, - cv.Schema({}), +WIFI_AUTOMATION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(WiFiComponent), + } ) +for _name, _check in ( + ("wifi.connected", "is_connected()"), + ("wifi.enabled", "is_disabled() == false"), + ("wifi.ap_active", "is_ap_active()"), +): + automation.register_apply_condition(_name, WIFI_AUTOMATION_SCHEMA, _check) -automation.register_bare_condition( - "wifi.enabled", - WiFiEnabledCondition, - cv.Schema({}), -) - - -automation.register_bare_condition( - "wifi.ap_active", - WiFiAPActiveCondition, - cv.Schema({}), -) - - -automation.register_bare_action( - "wifi.enable", - WiFiEnableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "wifi.disable", - WiFiDisableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "wifi.roam", - WiFiRoamAction, - cv.Schema({}), - synchronous=True, -) +for _name, _call in ( + ("wifi.enable", "enable()"), + ("wifi.disable", "disable()"), + ("wifi.roam", "force_roam_check()"), +): + automation.register_apply_action( + _name, WIFI_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index eb3d7471dc1..052fb11661d 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,36 +6,6 @@ namespace esphome::wifi { -template class WiFiConnectedCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } -}; - -template class WiFiEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } -}; - -template class WiFiAPActiveCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } -}; - -template class WiFiEnableAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->enable(); } -}; - -template class WiFiDisableAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->disable(); } -}; - -template class WiFiRoamAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->force_roam_check(); } -}; - template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) diff --git a/esphome/components/wk2168_i2c/__init__.py b/esphome/components/wk2168_i2c/__init__.py index c450717ea2c..9c2b074e503 100644 --- a/esphome/components/wk2168_i2c/__init__.py +++ b/esphome/components/wk2168_i2c/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -52,11 +52,4 @@ WK2168_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2168_I2C, WK2168_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2168_I2C]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2168_I2C) diff --git a/esphome/components/wk2168_spi/__init__.py b/esphome/components/wk2168_spi/__init__.py index ef12117a2c7..12192e71db0 100644 --- a/esphome/components/wk2168_spi/__init__.py +++ b/esphome/components/wk2168_spi/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import spi, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,11 +50,4 @@ WK2168_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2168_SPI, WK2168_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2168_SPI]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2168_SPI) diff --git a/esphome/components/wk2212_i2c/__init__.py b/esphome/components/wk2212_i2c/__init__.py index 1f51fea3145..73c41b36504 100644 --- a/esphome/components/wk2212_i2c/__init__.py +++ b/esphome/components/wk2212_i2c/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -52,11 +52,4 @@ WK2212_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2212_I2C, WK2212_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2212_I2C]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2212_I2C) diff --git a/esphome/components/wk2212_spi/__init__.py b/esphome/components/wk2212_spi/__init__.py index a0cce3331ad..cc4c358f284 100644 --- a/esphome/components/wk2212_spi/__init__.py +++ b/esphome/components/wk2212_spi/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import spi, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,11 +50,4 @@ WK2212_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2212_SPI, WK2212_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2212_SPI]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2212_SPI) diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index b7b9e5cfa99..93eb1f3227e 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -12,13 +12,7 @@ constexpr uint8_t HEADER_4 = 0x04; ESPHOME_LOG_TAG(TAG, "wts01"); void WTS01Sensor::loop() { - // Process all available data at once - while (this->available()) { - uint8_t c; - if (this->read_byte(&c)) { - this->handle_char_(c); - } - } + this->read_available([this](uint8_t c) { this->handle_char_(c); }); } void WTS01Sensor::dump_config() { LOG_SENSOR("", "WTS01 Sensor", this); } diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 90ea5c32094..1f752f55b10 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -38,12 +38,12 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ } // temperature, 2 bytes, 16-bit signed integer (LE), 0.1 °C else if ((value_type == 0x1004) && (value_length == 2)) { - const int16_t temperature = encode_uint16(data[1], data[0]); + const int16_t temperature = encode_value_le(data); result.temperature = temperature / 10.0f; } // humidity, 2 bytes, 16-bit signed integer (LE), 0.1 % else if ((value_type == 0x1006) && (value_length == 2)) { - const int16_t humidity = encode_uint16(data[1], data[0]); + const int16_t humidity = encode_value_le(data); result.humidity = humidity / 10.0f; } // illuminance (+ motion), 3 bytes, 24-bit unsigned integer (LE), 1 lx @@ -69,8 +69,8 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ } // temperature + humidity, 4 bytes, 16-bit signed integer (LE) each, 0.1 °C, 0.1 % else if ((value_type == 0x100D) && (value_length == 4)) { - const int16_t temperature = encode_uint16(data[1], data[0]); - const int16_t humidity = encode_uint16(data[3], data[2]); + const int16_t temperature = encode_value_le(data); + const int16_t humidity = encode_value_le(&data[2]); result.temperature = temperature / 10.0f; result.humidity = humidity / 10.0f; } diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py index cb0802d270a..bd66e2eb400 100644 --- a/esphome/components/xiaomi_body_scale/sensor.py +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -3,7 +3,6 @@ from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BINDKEY, - CONF_ID, CONF_MAC_ADDRESS, CONF_WEIGHT, DEVICE_CLASS_WEIGHT, @@ -72,11 +71,9 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] ) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) sensors = sensor.sub_sensors(config) await sensors(CONF_WEIGHT, var.set_weight) diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index 162ba7016be..5649a1d9746 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index e1d44d6bac5..7e96b231371 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 01ca8d1f84d..a81d8d2f4e4 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if CONF_BINDKEY in config: diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 75138970246..f9e44fb94fd 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 11bbceef5e3..f9d06017f0e 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index c5d256db1d3..76fe80b19c8 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index ac1541c386d..83ba611e6fb 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -35,7 +35,7 @@ bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { const uint8_t *data = service_data.data.data(); if (this->temperature_ != nullptr) { - const int16_t temperature = encode_uint16(data[1], data[2]); + const int16_t temperature = encode_value(&data[1]); this->temperature_->publish_state((float) temperature / 10.0f); } diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index 73d5f3ff192..555c6a164ae 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_MOISTURE, ICON_FLOWER, @@ -47,9 +46,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index 4bfe9c8f756..5c4600f5d12 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -66,9 +65,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index d37d7540c76..d29cd7c789a 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 5b088318ae7..a1a6cef3a4a 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index 32977d0ed6f..a1d7af7590f 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 75a8a2d847e..7f98c88df7f 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 44fb9c76548..76acc91dfd3 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index e74c656e512..d9f2f3f5cf3 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index c6277a515d7..9c3120d064a 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR_IMPEDANCE, - CONF_ID, CONF_IMPEDANCE, CONF_MAC_ADDRESS, CONF_WEIGHT, @@ -51,9 +50,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 438b9b55298..7c44eccd411 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -1,5 +1,6 @@ #include "xiaomi_miscale.h" #include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::xiaomi_miscale { @@ -92,7 +93,7 @@ bool XiaomiMiscale::parse_message_v1_(const std::vector &message, Parse const uint8_t *data = message.data(); // weight, 2 bytes, 16-bit unsigned integer, 1 kg - const int16_t weight = uint16_t(data[1]) | (uint16_t(data[2]) << 8); + const int16_t weight = encode_value_le(&data[1]); if (data[0] == 0x22 || data[0] == 0xa2) { result.weight = weight * 0.01f / 2.0f; // unit 'kg' } else if (data[0] == 0x12 || data[0] == 0xb2) { @@ -126,7 +127,7 @@ bool XiaomiMiscale::parse_message_v2_(const std::vector &message, Parse } // weight, 2 bytes, 16-bit unsigned integer, 1 kg - const int16_t weight = uint16_t(data[11]) | (uint16_t(data[12]) << 8); + const int16_t weight = encode_value_le(&data[11]); if (data[0] == 0x02) { result.weight = weight * 0.01f / 2.0f; // unit 'kg' } else if (data[0] == 0x03) { @@ -135,7 +136,7 @@ bool XiaomiMiscale::parse_message_v2_(const std::vector &message, Parse if (has_impedance) { // impedance, 2 bytes, 16-bit - const int16_t impedance = uint16_t(data[9]) | (uint16_t(data[10]) << 8); + const int16_t impedance = encode_value_le(&data[9]); result.impedance = impedance; if (impedance == 0 || impedance >= 3000) { diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 71d7e0a265b..9cbf9456959 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import ble_device_base import esphome.config_validation as cv -from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS +from esphome.const import CONF_BINDKEY, CONF_MAC_ADDRESS from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] @@ -29,9 +29,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index 8c47b526b1d..6a57b3699e0 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 85330357d2d..8d1e03ff156 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -2,19 +2,13 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType CONF_XL9535 = "xl9535" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] CODEOWNERS = ["@mreditor97"] DOMAIN = "xl9535" @@ -36,45 +30,24 @@ async def to_code(config: ConfigType) -> None: await i2c.new_i2c_component(config) -def validate_mode(mode: ConfigType) -> ConfigType: - if not (mode[CONF_INPUT] or mode[CONF_OUTPUT]) or ( - mode[CONF_INPUT] and mode[CONF_OUTPUT] - ): - raise cv.Invalid("Mode must be either a input or a output") - return mode - - def validate_pin(pin: int) -> int: if pin in (8, 9): raise cv.Invalid(f"pin {pin} doesn't exist") return pin -XL9535_PIN_SCHEMA = cv.All( +XL9535_PIN_SCHEMA = pins.gpio_base_schema( + XL9535GPIOPin, + cv.All(cv.int_range(min=0, max=17), validate_pin), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=pins.gpio_validate_input_xor_output, +).extend( { - cv.GenerateID(): cv.declare_id(XL9535GPIOPin), cv.Required(CONF_XL9535): pins.use_id_or_address(XL9535Component), - cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_XL9535, XL9535_PIN_SCHEMA) async def xl9535_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_XL9535]) - - cg.add(var.set_parent(parent)) - 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 + return await pins.new_parented_pin(config, CONF_XL9535) diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 9be536353c8..d26641c3afa 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -103,14 +103,6 @@ void XL9535Component::pin_mode(uint8_t pin, gpio::Flags mode) { } } -void XL9535GPIOPin::setup() { this->pin_mode(this->flags_); } - -size_t XL9535GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via XL9535", this->pin_); -} - -void XL9535GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool XL9535GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void XL9535GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } - } // namespace esphome::xl9535 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::xl9535::XL9535Component, "%u via XL9535") diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 11fb3acc8d0..8f883d36df7 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::xl9535 { @@ -28,27 +29,8 @@ class XL9535Component final : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin final : public GPIOPin { - public: - void set_parent(XL9535Component *parent) { this->parent_ = parent; } - 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_; } - - void setup() override; - size_t dump_summary(char *buffer, size_t len) const override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - - protected: - XL9535Component *parent_; - - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using XL9535GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::xl9535 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::xl9535::XL9535Component); diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 44f83696f25..c0b0ce453b8 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -176,9 +176,7 @@ CONFIG_SCHEMA = cv.All( cv.Length(max=31), _validate_ascii, ), - cv.Optional(CONF_VERSION, default=0): cv.All( - cv.int_range(min=0x00, max=0xFF), - ), + cv.Optional(CONF_VERSION, default=0): cv.uint8_t, cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), cv.Optional(CONF_ON_START): automation.validate_automation({}), diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f7da147b88f..ad2bb4f869e 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -392,8 +392,7 @@ async def _add_zigbee_ep( cg.add(var.set_endpoint(slot_index + 1)) cg.add(var.set_cluster_attributes(attrs)) - hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) - cg.add(var.set_parent(hub)) + await cg.register_parented(var, config[CONF_ZIGBEE_ID]) async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 852ea3439a9..70838d897c8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,7 @@ #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_POWER_ESTIMATION #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LINE_READER @@ -541,6 +542,10 @@ #define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 +#define USE_USB_HOST_FIFO_CONFIG +#define USB_HOST_FIFO_RX_LINES 93 +#define USB_HOST_FIFO_NPTX_LINES 16 +#define USB_HOST_FIFO_PTX_LINES 91 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #define USE_USB_UART_CH34X #define USE_USB_UART_CP210X @@ -552,6 +557,11 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #define USE_SERIAL_PROXY_USB_IDENTITY #endif +// usb_host_uac supports only these variants +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#define USE_USB_AUDIO +#endif #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) diff --git a/esphome/espota2.py b/esphome/espota2.py index aaef178753a..340722206ce 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -918,29 +918,36 @@ def _tcp_retransmits(sock: socket.socket) -> int | None: return struct.unpack_from(fmt, info, offset)[0] -def _link_lossy( +def _loss_reason( 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)) - ) +) -> str | None: + """Why the link looks lossy, so the data phase should use UDP; None if clean.""" + if prefer_udp: + return "an earlier attempt failed" + if slowest_exchange > UDP_SLOW_EXCHANGE: + return f"a handshake reply took {slowest_exchange:.1f}s" + if _tcp_retransmits(tcp_sock): + return "TCP resent data during the handshake" + return None def _start_udp( - sock: socket.socket | NoiseSocketWrapper, tcp_sock: socket.socket, lossy: bool + sock: socket.socket | NoiseSocketWrapper, + tcp_sock: socket.socket, + loss_reason: str | None, ) -> 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") + send_check( + sock, UDP_FALLBACK if loss_reason is None else UDP_REQUEST, "UDP request" + ) token = receive_exactly(sock, 4, "UDP token", None, decode=False) - if not lossy: + if loss_reason is None: _LOGGER.debug("Link looks clean, staying on TCP") return None + _LOGGER.info("Link is lossy (%s), trying UDP", loss_reason) if token == bytes(4): - _LOGGER.debug("Device declined UDP") + _LOGGER.info("Device declined UDP, using TCP") return None udp_sock = socket.socket(tcp_sock.family, socket.SOCK_DGRAM) try: @@ -1192,7 +1199,7 @@ def perform_ota( # 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) + sock, tcp_sock, _loss_reason(tcp_sock, slowest_exchange, prefer_udp) ) if udp is not None: # The device drops TCP once it hears the committed channel diff --git a/esphome/final_validate.py b/esphome/final_validate.py index cebd2f1cdad..46be8f0b3f5 100644 --- a/esphome/final_validate.py +++ b/esphome/final_validate.py @@ -3,7 +3,7 @@ import contextvars from typing import Any import esphome.config_validation as cv -from esphome.types import ID, ConfigFragmentType, ConfigPathType +from esphome.types import ID, ConfigFragmentType, ConfigPathType, ConfigType class FinalValidateConfig(ABC): @@ -39,6 +39,21 @@ full_config: contextvars.ContextVar[FinalValidateConfig] = contextvars.ContextVa ) +def get_declaring_config( + id_: ID, config: FinalValidateConfig | None = None +) -> ConfigType: + """Return the config block that declares id_, such as a hub's own config. + + Uses the full config being validated unless config is given. + Raises KeyError if the id was not declared, TypeError if its parent is not a dict. + """ + fconf = full_config.get() if config is None else config + declaring = fconf.get_config_for_path(fconf.get_path_for_id(id_)[:-1]) + if not isinstance(declaring, dict): + raise TypeError(f"ID {id_} is not declared in a config block") + return declaring + + def id_declaration_match_schema(schema): """A final-validation schema function that applies a schema to the outer config fragment of an ID declaration. diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index dd837d61632..2c12eec6d47 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -108,6 +108,11 @@ dependencies: version: "1.4.1" rules: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" + # Keep in sync with esphome/components/usb_audio/__init__.py + espressif/usb_host_uac: + version: "1.5.0" + rules: + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 # sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below) diff --git a/esphome/pins.py b/esphome/pins.py index d42cc489a1b..f18cd25a4aa 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_ALLOW_OTHER_USES, + CONF_ID, CONF_IGNORE_STRAPPING_WARNING, CONF_INPUT, CONF_INVERTED, @@ -19,8 +20,9 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import schema_extractor +from esphome.types import ConfigType, SafeExpType class PinRegistry(dict): @@ -348,6 +350,38 @@ def gpio_validate_modes(value): return value +def gpio_validate_input_xor_output(value: dict[str, bool]) -> dict[str, bool]: + """Mode validator for pins that are either an input or an output, never both.""" + if value[CONF_INPUT] == value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +def gpio_validate_input_xor_output_pullup(value: dict[str, bool]) -> dict[str, bool]: + """``gpio_validate_input_xor_output`` for chips whose pull-up only works on inputs.""" + gpio_validate_input_xor_output(value) + if value[CONF_PULLUP] and not value[CONF_INPUT]: + raise cv.Invalid("Pullup only available with input") + return value + + +async def new_parented_pin( + config: ConfigType, parent_key: str, *args: SafeExpType +) -> MockObj: + """Create a pin parented to ``config[parent_key]`` and set its number, inversion and mode flags. + + ``args`` go to ``cg.new_Pvariable``, e.g. ``cg.TemplateArguments`` for a templated pin class. + """ + import esphome.codegen as cg + + var = cg.new_Pvariable(config[CONF_ID], *args) + await cg.register_parented(var, config[parent_key]) + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(gpio_flags_expr(config[CONF_MODE]))) + return var + + def gpio_base_schema( pin_type: MockObjClass, number_validator: Callable[[Any], Any], diff --git a/esphome/writer.py b/esphome/writer.py index 934595ef7e8..e6c0c0e2e90 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path import re +import subprocess import time from esphome import loader @@ -16,6 +17,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive +from esphome.git import _GIT_REPO_SCOPING_ENV from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -722,7 +724,11 @@ def clean_all(configuration: list[str]): _LOGGER.info("Cleaning %s", dir) # Don't remove storage or .json files which are needed by the dashboard for item in dir.iterdir(): - if item.is_file() and not item.name.endswith(".json"): + if ( + item.is_file() + and not item.name.endswith(".json") + and item.name != ".gitkeep" + ): item.unlink() elif item.is_dir() and item.name != "storage": rmtree(item) @@ -764,3 +770,82 @@ def write_gitignore(): path = CORE.relative_config_path(".gitignore") if not path.is_file(): path.write_text(GITIGNORE_CONTENT, encoding="utf-8") + + +def check_build_tree_not_tracked() -> None: + """Warn if the build tree is tracked by git. + + ``write_gitignore`` never touches an existing ``.gitignore``, so a build + tree that was committed before it was excluded (or on a config predating + ESPHome's auto-generated ``.gitignore``) stays tracked forever: adding a + pattern to ``.gitignore`` does not untrack files already in the index. + Every subsequent build would then keep changing the regenerated files + under version control. + """ + if os.environ.get("CI"): + # CI checkouts build fixture configs, not a user's own repository, so + # there is nothing meaningful to warn about -- skip the extra process + # spawn on every single build. + return + data_dir = CORE.data_dir + if (data_dir / ".gitkeep").is_file(): + # The user deliberately placed a .gitkeep file, opting the build + # directory into version control on purpose. + return + + try: + relative_data_dir = data_dir.relative_to(CORE.config_dir) + except ValueError: + # Outside the config directory (e.g. the Home Assistant add-on's + # /data, or ESPHOME_DATA_DIR pointing elsewhere) -- can't be part of + # this repository. + return + + # Strip the same repo-scoping variables run_git_command() strips (see + # esphome/git.py): a git hook or CI wrapper exporting GIT_DIR/GIT_INDEX_FILE + # would otherwise redirect this read-only query to its own repository. + env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV} + try: + result = subprocess.run( + ["git", "ls-files", "-z", "--", str(relative_data_dir)], + cwd=CORE.config_dir, + capture_output=True, + check=False, + env=env, + timeout=5, + ) + except FileNotFoundError: + # No git installed, so the directory cannot be tracked. + return + except (OSError, subprocess.TimeoutExpired) as err: + # This is a purely advisory check -- an unusable or slow git must + # never turn into a hard failure of the build itself. + _LOGGER.debug( + "Could not check whether %s is tracked by git: %s", relative_data_dir, err + ) + return + if result.returncode != 0: + if result.stderr: + _LOGGER.debug( + "git ls-files failed (%d): %s", + result.returncode, + result.stderr.decode("utf-8", errors="replace").strip(), + ) + return + if not result.stdout: + return + + tracked_count = sum(1 for name in result.stdout.split(b"\0") if name) + _LOGGER.warning( + "%s is tracked by git (%d file%s). This is the ESPHome build " + "directory; it holds generated files that may contain your secrets " + "(e.g. Wi-Fi credentials or API keys) in plain text, and should not " + "be committed. Add it to your .gitignore, then remove it from " + "version control with: git rm -r --cached %s " + "(if you really want to keep this directory in git, add a file " + "named .gitkeep to it to silence this warning)", + relative_data_dir, + tracked_count, + "" if tracked_count == 1 else "s", + data_dir, + ) diff --git a/requirements.txt b/requirements.txt index 13625818ad8..7d48235966c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.12.0 +aioesphomeapi==46.14.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 1a4246237df..3190500b8e6 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -303,6 +303,16 @@ def test_entity_id_is_optional_only_for_wizard_inputs( ), errors +def test_light_is_a_wizard_input(tmp_path: Path) -> None: + entities = "light:\n - platform: homeassistant\n id: ha_light\n" + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_light"), entities + ) + ) + assert errors == [] + + def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked( tmp_path: Path, ) -> None: diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 644a505bf82..5e9cc6d703a 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -143,7 +143,7 @@ def test_all_predefined_models( # Add dimensions if not provided by model if not model.get_default(CONF_WIDTH): - config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 120, CONF_WIDTH: 120} # Add init sequence if model doesn't provide one if model.initsequence is None: @@ -199,7 +199,7 @@ def test_individual_models( config[CONF_DC_PIN] = 21 if not model.get_default(CONF_WIDTH): - config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 120, CONF_WIDTH: 120} if model.initsequence is None: config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] diff --git a/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml b/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml new file mode 100644 index 00000000000..b921899b3c7 --- /dev/null +++ b/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +globals: + - id: rtc_string + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 15 + initial_value: '"boot"' diff --git a/tests/component_tests/globals/config/globals_esp8266_test.yaml b/tests/component_tests/globals/config/globals_esp8266_test.yaml new file mode 100644 index 00000000000..f321ecf84f1 --- /dev/null +++ b/tests/component_tests/globals/config/globals_esp8266_test.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp8266: + board: d1_mini + restore_from_flash: true + +globals: + - id: my_restoring_int + type: int + restore_value: true + initial_value: "8" diff --git a/tests/component_tests/globals/config/globals_storage_test.yaml b/tests/component_tests/globals/config/globals_storage_test.yaml new file mode 100644 index 00000000000..d224408921f --- /dev/null +++ b/tests/component_tests/globals/config/globals_storage_test.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + +globals: + - id: rtc_int + type: int + restore_value: true + storage: rtc + initial_value: "7" + - id: flash_string + type: std::string + restore_value: true + storage: flash + max_restore_data_length: 15 diff --git a/tests/component_tests/globals/config/globals_test.yaml b/tests/component_tests/globals/config/globals_test.yaml index 1d1a9edaa62..a05ea84fd9b 100644 --- a/tests/component_tests/globals/config/globals_test.yaml +++ b/tests/component_tests/globals/config/globals_test.yaml @@ -14,3 +14,7 @@ globals: - id: my_global_bool type: bool initial_value: "true" + - id: my_restoring_int + type: int + restore_value: true + initial_value: "8" diff --git a/tests/component_tests/globals/test_storage.py b/tests/component_tests/globals/test_storage.py new file mode 100644 index 00000000000..6f1ea3aff48 --- /dev/null +++ b/tests/component_tests/globals/test_storage.py @@ -0,0 +1,103 @@ +"""Tests for the globals storage option.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import globals as globals_component +from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32C2 +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.const import ( + CONF_ID, + CONF_RESTORE_VALUE, + CONF_STORAGE, + CONF_TYPE, + PlatformFramework, +) +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(**extra: object) -> ConfigType: + return globals_component.CONFIG_SCHEMA( + {CONF_ID: "my_global", CONF_TYPE: "int", **extra} + ) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +@pytest.mark.parametrize( + ("platform_framework", "platform_data"), + [ + (PlatformFramework.ESP32_IDF, {KEY_VARIANT: VARIANT_ESP32C2}), + (PlatformFramework.RP2040_ARDUINO, None), + ], +) +def test_rtc_rejected_without_rtc_memory( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + platform_data: ConfigType | None, +) -> None: + set_core_config(platform_framework, platform_data=platform_data) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate(**{CONF_RESTORE_VALUE: True, CONF_STORAGE: "rtc"}) + + +@pytest.mark.parametrize("extra", [{}, {CONF_RESTORE_VALUE: False}]) +def test_storage_rejected_without_restore_value( + set_core_config: SetCoreConfigCallable, extra: ConfigType +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + with pytest.raises(cv.Invalid, match="'storage' needs 'restore_value: true'"): + _validate(**extra, **{CONF_STORAGE: "rtc"}) + + +def test_storage_generates_template_argument( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("globals_storage_test.yaml")) + assert ( + "globals::RestoringGlobalsComponent" + in main_cpp + ) + assert ( + "globals::RestoringGlobalStringComponent" in main_cpp + ) + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +# On the ESP8266 the omitted form must stay the two-argument call so restore_from_flash keeps deciding +@pytest.mark.parametrize("config", ["globals_test.yaml", "globals_esp8266_test.yaml"]) +def test_omitted_storage_keeps_platform_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config: str, +) -> None: + main_cpp = generate_main(component_config_path(config)) + assert ( + "new(my_restoring_int) globals::RestoringGlobalsComponent(8)" in main_cpp + ) + assert "globals::Storage" not in main_cpp + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_restored_string_reserves_esp8266_store( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("globals_esp8266_storage_test.yaml")) + # 15 characters plus the length byte, in RTC memory + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(preference_words(16), False)] diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml index 8c96e1ad521..da9246f2fea 100644 --- a/tests/component_tests/homeassistant/mixed_entities.yaml +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -63,3 +63,9 @@ button: id: ha_button name: HA Button entity_id: button.restart + +light: + - platform: homeassistant + id: ha_light + name: HA Light + entity_id: light.kitchen diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py index c3832369117..5e7afed1ade 100644 --- a/tests/component_tests/homeassistant/test_state_subscription_count.py +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -15,12 +15,12 @@ def test_mixed_entities_state_subscription_count( generate_main(DIR / "mixed_entities.yaml") # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) - # + select (2) + number (4) + text (4) + button (0) = 15 + # + select (2) + number (4) + text (4) + button (0) + light (8) = 23 define_match = [ d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" ] assert len(define_match) == 1 - assert str(define_match[0].value) == "15" + assert str(define_match[0].value) == "23" assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) diff --git a/tests/component_tests/i2s_audio/test_variants.py b/tests/component_tests/i2s_audio/test_variants.py new file mode 100644 index 00000000000..3c5245565fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_variants.py @@ -0,0 +1,68 @@ +"""Tests for the per-variant limits of i2s_audio: port count and PDM support.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import i2s_audio +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import ( + VARIANT_ESP32C3, + VARIANT_ESP32S2, + VARIANT_ESP32S31, +) +from esphome.components.i2s_audio import microphone +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_FULL_DUPLEX = i2s_audio.CONF_FULL_DUPLEX + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, buses: int +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + full_config={ + i2s_audio.DOMAIN: [ + {CONF_ID: ID(f"bus_{n}"), CONF_FULL_DUPLEX: False} for n in range(buses) + ] + }, + ) + + +def test_esp32s31_has_two_i2s_ports(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=2) + i2s_audio._final_validate({}) + + +def test_esp32s31_rejects_a_third_bus(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=3) + with pytest.raises(cv.Invalid, match="Only 2 I2S audio ports"): + i2s_audio._final_validate({}) + + +def _pdm_microphone(pdm: bool) -> ConfigType: + return { + microphone.CONF_ADC_TYPE: "external", + microphone.CONF_PDM: pdm, + } + + +def test_esp32s31_supports_pdm_microphones( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=1) + microphone._validate_esp32_variant(_pdm_microphone(True)) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32S2, VARIANT_ESP32C3]) +def test_pdm_rejected_where_the_soc_has_none( + set_core_config: SetCoreConfigCallable, variant: str +) -> None: + _set_variant(set_core_config, variant, buses=1) + with pytest.raises(cv.Invalid, match="does not support PDM"): + microphone._validate_esp32_variant(_pdm_microphone(True)) + microphone._validate_esp32_variant(_pdm_microphone(False)) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index b2b421c1e20..e3471721730 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -601,3 +601,70 @@ class TestSwapXYNativeDimensions: # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 assert pad_w == 20 assert pad_h == 0 + + +class TestGC9107Dimensions: + """Test the GC9107 native size and the M5Stack AtomS3 visible area.""" + + @pytest.mark.parametrize( + "config", + [ + pytest.param({"model": "M5STACK-ATOMS3"}, id="atoms3_model"), + pytest.param( + { + "model": "GC9107", + "dc_pin": 33, + "dimensions": { + "width": 128, + "height": 128, + "offset_width": 2, + "offset_height": 1, + }, + }, + id="gc9107_explicit_offsets", + ), + ], + ) + @pytest.mark.parametrize("rotation", [0, 180]) + def test_atoms3_offsets_and_pads( + self, + config: ConfigType, + rotation: int, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Pads come from the 132x130 GC9107 memory, so rotation 180 lands on the visible area.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config = validated_config({**config, "rotation": rotation}) + _, templateargs = get_instance(config) + + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT + assert templateargs[5:11] == [128, 128, 2, 1, 2, 1] + # Hardware rotation swaps in the pads as offsets for rotation 180 + assert templateargs[12] is True + + def test_atoms3r_defaults_unchanged( + self, + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + ) -> None: + """The GC9107 native size does not change the AtomS3R model defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + set_component_config("psram", True) + + config = validated_config({"model": "M5STACK-ATOMS3R-GC9107"}) + _, templateargs = get_instance(config) + + assert templateargs[5:11] == [128, 128, 2, 1, 2, 1] diff --git a/tests/component_tests/partition/__init__.py b/tests/component_tests/partition/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/partition/test_light.py b/tests/component_tests/partition/test_light.py new file mode 100644 index 00000000000..eee7d8a48fe --- /dev/null +++ b/tests/component_tests/partition/test_light.py @@ -0,0 +1,29 @@ +"""Partition light schema.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light import CONF_POWER_ESTIMATION +from esphome.components.partition.light import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_partition_rejects_its_own_power_estimation( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="belongs on the strip") as exc: + CONFIG_SCHEMA( + { + "name": "Part", + "segments": [{"id": "strip", "from": 0, "to": 3}], + CONF_POWER_ESTIMATION: { + "ma_per_led_red": 16.0, + "ma_per_led_green": 11.0, + "ma_per_led_blue": 15.0, + "idle_ma_per_led": 1.0, + }, + } + ) + assert exc.value.path == [CONF_POWER_ESTIMATION] diff --git a/tests/component_tests/usb_audio/__init__.py b/tests/component_tests/usb_audio/__init__.py new file mode 100644 index 00000000000..284b0c6d298 --- /dev/null +++ b/tests/component_tests/usb_audio/__init__.py @@ -0,0 +1 @@ +"""Tests for the usb_audio platforms' config validation.""" diff --git a/tests/component_tests/usb_audio/test_validation.py b/tests/component_tests/usb_audio/test_validation.py new file mode 100644 index 00000000000..c7b0300b3a1 --- /dev/null +++ b/tests/component_tests/usb_audio/test_validation.py @@ -0,0 +1,132 @@ +"""Tests for the usb_audio buffer floors, the one-platform-per-hub check and the USB FIFO requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_audio, usb_host +from esphome.components.usb_audio import ( + CONF_USB_AUDIO_ID, + microphone as usb_microphone, + speaker as usb_speaker, +) +from esphome.config import Config +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_SIZE, + CONF_NUM_CHANNELS, + CONF_PLATFORM, + CONF_SAMPLE_RATE, + PlatformFramework, +) +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _stream(sample_rate: int, channels: int, bits: int, buffer_size: int) -> ConfigType: + return { + CONF_SAMPLE_RATE: sample_rate, + CONF_NUM_CHANNELS: channels, + CONF_BITS_PER_SAMPLE: float(bits), + CONF_BUFFER_SIZE: buffer_size, + } + + +def _entry(hub: str, platform: str = "usb_audio") -> ConfigType: + return {CONF_PLATFORM: platform, CONF_USB_AUDIO_ID: ID(hub)} + + +def _set_full_config( + set_core_config: SetCoreConfigCallable, **domains: list[ConfigType] +) -> None: + full = Config() + full.update(domains) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + + +def test_speaker_buffer_floor() -> None: + # Two transfers of 3 packets x 48 frames x 4 bytes at 48 kHz 16-bit stereo. + usb_speaker.validate_buffer_size(_stream(48000, 2, 16, 1152)) + with pytest.raises(cv.Invalid, match="at least 1152 bytes"): + usb_speaker.validate_buffer_size(_stream(48000, 2, 16, 1151)) + + +def test_speaker_floor_rounds_up_fractional_rates() -> None: + # 44.1 kHz packets carry up to 45 frames. + with pytest.raises(cv.Invalid, match="at least 1080 bytes"): + usb_speaker.validate_buffer_size(_stream(44100, 2, 16, 1079)) + + +def test_speaker_default_buffer_meets_floor() -> None: + usb_speaker.validate_buffer_size(_stream(96000, 2, 32, 6400)) + + +def test_microphone_buffer_floor() -> None: + # 16 kHz 16-bit mono is 32000 bytes/s, so two read periods (40 ms) are 1280 bytes. + usb_microphone.validate_buffer_size(_stream(16000, 1, 16, 1280)) + with pytest.raises(cv.Invalid, match="at least 1280 bytes"): + usb_microphone.validate_buffer_size(_stream(16000, 1, 16, 1279)) + + +def _unset_buffer(sample_rate: int, channels: int, bits: int) -> ConfigType: + config = _stream(sample_rate, channels, bits, 0) + del config[CONF_BUFFER_SIZE] + return config + + +def test_default_buffer_size() -> None: + assert ( + usb_speaker.validate_buffer_size(_unset_buffer(48000, 2, 16))[CONF_BUFFER_SIZE] + == 6400 + ) + assert ( + usb_microphone.validate_buffer_size(_unset_buffer(16000, 1, 16))[ + CONF_BUFFER_SIZE + ] + == 6400 + ) + + +def test_default_buffer_size_grows_to_the_floor() -> None: + # 48 kHz 16-bit stereo is 192000 bytes/s, so 40 ms need 7680 bytes, more than the 6400 default. + assert ( + usb_microphone.validate_buffer_size(_unset_buffer(48000, 2, 16))[ + CONF_BUFFER_SIZE + ] + == 7680 + ) + + +def test_one_speaker_per_hub_accepted(set_core_config: SetCoreConfigCallable) -> None: + entries = [_entry("hub_a"), _entry("hub_b"), _entry("hub_a", "i2s_audio")] + _set_full_config(set_core_config, speaker=entries) + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[0]) + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[1]) + + +def test_two_speakers_on_one_hub_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + entries = [_entry("hub_a"), _entry("hub_a")] + _set_full_config(set_core_config, speaker=entries) + with pytest.raises(cv.Invalid, match="Only one usb_audio speaker"): + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[0]) + + +def test_two_microphones_on_one_hub_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + entries = [_entry("hub_a"), _entry("hub_a")] + _set_full_config(set_core_config, microphone=entries, speaker=[_entry("hub_a")]) + with pytest.raises(cv.Invalid, match="Only one usb_audio microphone"): + usb_microphone.FINAL_VALIDATE_SCHEMA(entries[0]) + + +def test_platforms_request_usb_fifo_space( + set_core_config: SetCoreConfigCallable, +) -> None: + # 48 kHz 16-bit stereo packets carry up to 49 frames of 4 bytes; 16 kHz 16-bit mono up to 17 frames of 2 bytes. + set_core_config(PlatformFramework.ESP32_IDF) + usb_audio.request_speaker_fifo(_stream(48000, 2, 16, 6400)) + usb_audio.request_microphone_fifo(_stream(16000, 1, 16, 6400)) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [34, 196] diff --git a/tests/component_tests/usb_host/__init__.py b/tests/component_tests/usb_host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/usb_host/test_fifo.py b/tests/component_tests/usb_host/test_fifo.py new file mode 100644 index 00000000000..8bc12a3a183 --- /dev/null +++ b/tests/component_tests/usb_host/test_fifo.py @@ -0,0 +1,172 @@ +"""Tests for the usb_host FIFO split sized from periodic packet requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_host +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +from esphome.config import Config +from esphome.const import ( + CONF_FRAMEWORK, + KEY_IDF_VERSION, + KEY_VARIANT, + PlatformFramework, +) +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +IDF_5_5 = cv.Version(5, 5, 6) + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, idf: cv.Version = IDF_5_5 +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant, KEY_IDF_VERSION: idf}, + ) + + +def test_no_request_keeps_the_default_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + assert usb_host.fifo_split() is None + + +def test_in_and_out_requests_share_the_spare_lines( + set_core_config: SetCoreConfigCallable, +) -> None: + # 196-byte packets each way, such as 48 kHz 16-bit stereo audio: 49 + 51 lines, plus 84 spare. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=196, out_bytes=196) + rx, nptx, ptx = usb_host.fifo_split() + assert (rx, nptx, ptx) == (93, 16, 91) + assert rx + nptx + ptx == 200 + assert ptx * 4 >= 196 + assert (rx - 2) * 4 >= 196 + + +def test_out_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S2) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() == (34, 16, 150) + + +def test_in_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + # 512 bytes is more than the default 408 bytes of RX. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() == (152, 16, 32) + + +@pytest.mark.parametrize( + ("in_bytes", "out_bytes"), [(0, 0), (196, 0), (408, 128), (0, 128)] +) +def test_requests_that_fit_the_default_keep_it( + set_core_config: SetCoreConfigCallable, in_bytes: int, out_bytes: int +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + assert usb_host.fifo_split() is None + + +def test_requests_that_fit_the_default_need_no_newer_idf( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=cv.Version(5, 4, 2)) + usb_host.request_periodic_fifo(in_bytes=34, out_bytes=128) + usb_host.FINAL_VALIDATE_SCHEMA({}) + assert "USE_USB_HOST_FIFO_CONFIG" not in _defines() + + +def test_requests_keep_the_largest_packet( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=400) + usb_host.request_periodic_fifo(in_bytes=96, out_bytes=196) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [96, 400] + + +def test_only_full_speed_variants_are_sized( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32P4) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() is None + + +def test_requests_that_do_not_fit_are_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=776, out_bytes=776) + with pytest.raises(cv.Invalid, match="bytes of USB FIFO"): + usb_host.fifo_split() + + +def _defines() -> dict[str, str]: + return {define.name: str(define.value) for define in CORE.defines} + + +def test_final_validate_emits_the_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=196) + usb_host.FINAL_VALIDATE_SCHEMA({}) + defines = _defines() + assert "USE_USB_HOST_FIFO_CONFIG" in defines + assert ( + defines["USB_HOST_FIFO_RX_LINES"], + defines["USB_HOST_FIFO_NPTX_LINES"], + defines["USB_HOST_FIFO_PTX_LINES"], + ) == ("34", "16", "150") + + +def _set_bias(set_core_config: SetCoreConfigCallable, option: str) -> None: + full = Config() + full["esp32"] = {CONF_FRAMEWORK: {"sdkconfig_options": {option: "y"}}} + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3, KEY_IDF_VERSION: IDF_5_5}, + full_config=full, + ) + + +def test_request_that_fits_the_selected_bias_keeps_it( + set_core_config: SetCoreConfigCallable, +) -> None: + # The IN bias leaves RX 152 lines, enough for 512-byte IN packets. + _set_bias(set_core_config, "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN") + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() is None + + +@pytest.mark.parametrize( + ("option", "in_bytes", "out_bytes"), + [ + # The IN bias reserves 152 RX lines, leaving no room for more than 128-byte OUT packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", 0, 196), + # The periodic OUT bias reserves 150 PTX lines, leaving no room for more than 128-byte IN packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", 196, 0), + ], +) +def test_request_conflicting_with_the_selected_bias_is_rejected( + set_core_config: SetCoreConfigCallable, option: str, in_bytes: int, out_bytes: int +) -> None: + _set_bias(set_core_config, option) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + with pytest.raises(cv.Invalid, match=f"FIFO split {option} selects"): + usb_host.fifo_split() + + +@pytest.mark.parametrize("idf", [cv.Version(5, 3, 4), cv.Version(5, 4, 3)]) +def test_final_validate_needs_idf_5_5( + set_core_config: SetCoreConfigCallable, idf: cv.Version +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=idf) + usb_host.request_periodic_fifo(out_bytes=196) + with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"): + usb_host.FINAL_VALIDATE_SCHEMA({}) diff --git a/tests/components/audio/test_audio_stream_info.cpp b/tests/components/audio/test_audio_stream_info.cpp new file mode 100644 index 00000000000..ce5b24b21e7 --- /dev/null +++ b/tests/components/audio/test_audio_stream_info.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/audio/audio.h" + +namespace esphome::audio::testing { + +TEST(AudioStreamInfo, FramesToMicroseconds) { + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.frames_to_microseconds(48), 1000U); + EXPECT_EQ(info.frames_to_microseconds(1), 21U); // 20.83 us, rounded +} + +TEST(AudioStreamInfo, FramesToMicrosecondsAboveFourThousandFrames) { + // frames * 1000000 no longer fits 32 bits above 4294 frames, about 90 ms at 48 kHz. + AudioStreamInfo info(24, 2, 48000); + EXPECT_EQ(info.frames_to_microseconds(4800), 100000U); + EXPECT_EQ(info.frames_to_microseconds(48000), 1000000U); +} + +TEST(AudioStreamInfo, MsToBytesAboveTheThirtyTwoBitProduct) { + // ms * 8 bytes * 96000 Hz no longer fits 32 bits above 5592 ms. + AudioStreamInfo info(32, 2, 96000); + EXPECT_EQ(info.ms_to_bytes(10000), 7680000U); +} + +TEST(AudioStreamInfo, MsToSamplesAboveTheThirtyTwoBitProduct) { + // ms * 2 channels * 48000 Hz no longer fits 32 bits above 44739 ms; sound_level allows 60 s. + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.ms_to_samples(60000), 5760000U); +} + +TEST(AudioStreamInfo, MsToFramesAboveTheThirtyTwoBitProduct) { + // ms * 96000 Hz no longer fits 32 bits above 44739 ms. + AudioStreamInfo info(16, 2, 96000); + EXPECT_EQ(info.ms_to_frames(60000), 5760000U); +} + +TEST(AudioStreamInfo, BytesToMsAboveFourMegabytes) { + // bytes * 1000 no longer fits 32 bits above 4294967 bytes. + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.bytes_to_ms(19200000), 100000U); +} + +TEST(AudioStreamInfo, FramesToMillisecondsAboveFourMillionFrames) { + // frames * 1000 no longer fits 32 bits above 4294967 frames, about 89 s at 48 kHz. + AudioStreamInfo info(16, 2, 48000); + uint32_t frames = 4800000 + 7; + EXPECT_EQ(info.frames_to_milliseconds_with_remainder(&frames), 100000U); + EXPECT_EQ(frames, 7U); +} + +} // namespace esphome::audio::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 144c60dd2ff..d0d5f5a6aab 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -285,6 +285,28 @@ 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.13" V3 B series 3-color e-paper (104x212, BWR, UC8151D) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv3 + 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 + 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)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) - platform: epaper_spi spi_id: spi_bus diff --git a/tests/components/globals/storage.yaml b/tests/components/globals/storage.yaml new file mode 100644 index 00000000000..40ca7f3e089 --- /dev/null +++ b/tests/components/globals/storage.yaml @@ -0,0 +1,16 @@ +globals: + - id: glob_rtc_int + type: int + restore_value: true + storage: rtc + initial_value: "0" + - id: glob_rtc_string + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 15 + - id: glob_flash_float + type: float + restore_value: true + storage: flash + initial_value: "0.0f" diff --git a/tests/components/globals/test.esp32-idf.yaml b/tests/components/globals/test.esp32-idf.yaml index dade44d145b..ae95448a794 100644 --- a/tests/components/globals/test.esp32-idf.yaml +++ b/tests/components/globals/test.esp32-idf.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml + storage: !include storage.yaml diff --git a/tests/components/globals/test.esp8266-ard.yaml b/tests/components/globals/test.esp8266-ard.yaml index dade44d145b..ae95448a794 100644 --- a/tests/components/globals/test.esp8266-ard.yaml +++ b/tests/components/globals/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml + storage: !include storage.yaml diff --git a/tests/components/globals/test.rp2040-ard.yaml b/tests/components/globals/test.rp2040-ard.yaml index dade44d145b..d27f5d02d97 100644 --- a/tests/components/globals/test.rp2040-ard.yaml +++ b/tests/components/globals/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml diff --git a/tests/components/gpio_expander/test_expander_gpio_pin.cpp b/tests/components/gpio_expander/test_expander_gpio_pin.cpp new file mode 100644 index 00000000000..38b3823aa6e --- /dev/null +++ b/tests/components/gpio_expander/test_expander_gpio_pin.cpp @@ -0,0 +1,78 @@ +#include + +#include "esphome/components/gpio_expander/expander_gpio_pin.h" + +namespace esphome::gpio_expander::testing { + +class FakeExpander { + public: + void pin_mode(uint8_t pin, gpio::Flags flags) { + this->mode_pin = pin; + this->mode_flags = flags; + } + bool digital_read(uint8_t pin) { return this->level; } + void digital_write(uint8_t pin, bool value) { + this->write_pin = pin; + this->written = value; + } + + uint8_t mode_pin{0xFF}; + gpio::Flags mode_flags{gpio::FLAG_NONE}; + bool level{false}; + uint8_t write_pin{0xFF}; + bool written{false}; +}; + +using FakeExpanderPin = ExpanderGPIOPin; + +} // namespace esphome::gpio_expander::testing + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::gpio_expander::testing::FakeExpander); +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::gpio_expander::testing::FakeExpander, "%u via FAKE") + +namespace esphome::gpio_expander::testing { + +static FakeExpanderPin make_pin(FakeExpander *parent, uint8_t pin, bool inverted, gpio::Flags flags) { + FakeExpanderPin gpio_pin; + gpio_pin.set_parent(parent); + gpio_pin.set_pin(pin); + gpio_pin.set_inverted(inverted); + gpio_pin.set_flags(flags); + return gpio_pin; +} + +TEST(ExpanderGPIOPin, SetupAppliesConfiguredFlags) { + FakeExpander parent; + auto pin = make_pin(&parent, 3, false, gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + pin.setup(); + EXPECT_EQ(parent.mode_pin, 3); + EXPECT_EQ(parent.mode_flags, gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + EXPECT_EQ(pin.get_flags(), gpio::FLAG_INPUT | gpio::FLAG_PULLUP); +} + +TEST(ExpanderGPIOPin, ReadAndWriteHonourInversion) { + FakeExpander parent; + auto plain = make_pin(&parent, 1, false, gpio::FLAG_OUTPUT); + auto inverted = make_pin(&parent, 2, true, gpio::FLAG_OUTPUT); + + parent.level = true; + EXPECT_TRUE(plain.digital_read()); + EXPECT_FALSE(inverted.digital_read()); + + inverted.digital_write(true); + EXPECT_EQ(parent.write_pin, 2); + EXPECT_FALSE(parent.written); + plain.digital_write(true); + EXPECT_EQ(parent.write_pin, 1); + EXPECT_TRUE(parent.written); +} + +TEST(ExpanderGPIOPin, SummaryUsesTheExpanderFormat) { + FakeExpander parent; + auto pin = make_pin(&parent, 7, false, gpio::FLAG_INPUT); + char buffer[32]; + pin.dump_summary(buffer, sizeof(buffer)); + EXPECT_STREQ(buffer, "7 via FAKE"); +} + +} // namespace esphome::gpio_expander::testing diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 078036598a9..f39b3e1b2d2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -54,6 +54,12 @@ switch: entity_id: switch.my_cool_switch id: my_cool_switch +light: + - platform: homeassistant + entity_id: light.my_cool_light + id: ha_my_cool_light + report_timeout: 30s + binary_sensor: - platform: homeassistant entity_id: binary_sensor.hello_world diff --git a/tests/components/i2s_audio/test.esp32-s31-idf.yaml b/tests/components/i2s_audio/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..6457440bb4e --- /dev/null +++ b/tests/components/i2s_audio/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +packages: + i2s_audio: !include common.yaml diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index 2d5eb9223dc..f15cad320ba 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -10,6 +10,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_WEBSERVER_PORT", 80) # The gamma table and its stored gamma are behind this define cg.add_define("USE_LIGHT_GAMMA_LUT") + # The current estimate and limit of addressable lights + cg.add_define("USE_LIGHT_POWER_ESTIMATION") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/test_power_limit.cpp b/tests/components/light/test_power_limit.cpp new file mode 100644 index 00000000000..62183e5da33 --- /dev/null +++ b/tests/components/light/test_power_limit.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/esp_color_correction.h" + +namespace esphome::light::testing { + +namespace { + +std::array build_gamma_table(double gamma) { + std::array table{}; + for (int i = 1; i < 256; i++) { + double raw = std::round(std::pow(i / 255.0, gamma) * 65535.0); + table[i] = static_cast(std::max(1.0, std::min(65535.0, raw))); + } + return table; +} + +// A strip of RGBW pixels in memory, with the protected hooks the tests drive +class Strip : public AddressableLight { + public: + explicit Strip(int32_t count) : buffer_(count * 4) {} + ~Strip() { delete this->power_estimation_; } // NOLINT(cppcoreguidelines-owning-memory) + int32_t size() const override { return this->buffer_.size() / 4; } + void clear_effect_data() override {} + LightTraits get_traits() override { return {}; } + void write_state(LightState *state) override { this->sent_red = (*this)[0].get_red_raw(); } + uint8_t sent_red{0}; + // Every channel draws ma_per_channel at full level; setup turns the settings into the idle draw and budget + void configure_power(float ma_per_channel, float idle_ma_per_led, float max_current_ma) { + this->set_power_estimation(ma_per_channel, ma_per_channel, ma_per_channel, ma_per_channel, idle_ma_per_led); + this->set_max_current_ma(max_current_ma); + this->call_setup(); + } + // What the light state does before a frame goes out + void limit() { this->limit_power_(); } + uint8_t power_brightness() const { return this->correction_.get_power_brightness(); } + + protected: + ESPColorView get_view_internal(int32_t index) const override { + auto *pixel = const_cast(&this->buffer_[index * 4]); + return {pixel, pixel + 1, pixel + 2, pixel + 3, nullptr, &this->correction_}; + } + + std::vector buffer_; +}; + +} // namespace + +TEST(PowerLimit, CorrectionRoundTripsUnderTheLimit) { + const auto table = build_gamma_table(2.2); + for (uint8_t scale : {128, 200}) { + for (bool with_gamma : {false, true}) { + ESPColorCorrection correction; + if (with_gamma) + correction.set_gamma_table(table.data()); + correction.set_power_brightness(scale); + for (int value = 0; value < 256; value++) { + const uint8_t stored = correction.color_correct_red(value); + const int back = correction.color_uncorrect_red(stored); + // A read, change-nothing and write re-encodes to the stored level, so the dimming never compounds + EXPECT_EQ(correction.color_correct_red(back), stored) + << "scale=" << int(scale) << " gamma=" << with_gamma << " value=" << value; + // Below 96 the gamma curve leaves the limited hardware range too few levels to tell apart + if (with_gamma ? value >= 96 : value >= 2) { + EXPECT_LE(std::abs(back - value), with_gamma ? 4 : 2) + << "scale=" << int(scale) << " gamma=" << with_gamma << " value=" << value; + } + } + } + } +} + +TEST(PowerLimit, StaysAtFullScaleUnderBudget) { + Strip strip(10); + // 10 pixels at 20 mA per channel draw 10 x 60 + 10 mA idle; the budget is twice that + strip.configure_power(20, 1, 1300); + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + EXPECT_EQ(strip.power_brightness(), 255); + EXPECT_NEAR(strip.get_estimated_current_ma(), 610.0f, 0.5f); +} + +TEST(PowerLimit, SettlesOnTheBudgetAndKeepsLogicalLevels) { + Strip strip(10); + strip.configure_power(20, 1, 310); // 300 mA for the pixels of a 600 mA frame + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_NEAR(scale, 128, 2); + // The estimate is of the frame that goes out, not of what was asked for + EXPECT_NEAR(strip.get_estimated_current_ma(), 310.0f, 5.0f); + // The sent frame was rescaled, so a second pass sees the same draw and keeps the scale + strip.limit(); + EXPECT_EQ(strip.power_brightness(), scale); + EXPECT_NEAR(strip.get_estimated_current_ma(), 310.0f, 5.0f); + // Read-back still gives the levels that were written + EXPECT_NEAR(strip[0].get_red(), 255, 2); + EXPECT_NEAR(strip[0].get_green(), 255, 2); +} + +TEST(PowerLimit, APixelWrittenUnderTheLimitLandsLimited) { + Strip strip(4); + strip.configure_power(20, 1, 124); // 120 mA for pixels that ask for 240 mA + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + ASSERT_LT(scale, 255); + // A single pixel rewritten by a lambda lands at the limited level straight away + strip[1] = Color(128, 0, 0, 0); + EXPECT_NEAR(strip[1].get_red_raw(), (128 * scale) / 255.0f, 2); + EXPECT_NEAR(strip[1].get_red(), 128, 2); +} + +TEST(PowerLimit, LevelsDoNotCreepOverALimitCycle) { + Strip strip(4); + // Levels written once and never touched again, as a lambda would leave them + for (int i = 0; i < 4; i++) + strip[i].set_raw(1 + i * 40, 2, 100, 0); + // 1020 mA at full level, so the budget of 300 mA limits the strip + strip.configure_power(255, 0, 300); + strip.limit(); + const uint8_t limited = strip.power_brightness(); + ASSERT_LT(limited, 255); + // Lit channels stay lit while limited + for (int i = 0; i < 4; i++) + EXPECT_GE(strip[i].get_green_raw(), 1) << i; + // A budget the strip cannot reach, and the next frame restores the scale + strip.configure_power(255, 0, 100000); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255); + // Each level comes back within one quantisation step of where it started, never below it + const int step = (255 + limited - 1) / limited; + for (int i = 0; i < 4; i++) { + const int red = 1 + i * 40; + EXPECT_GE(strip[i].get_red_raw(), red) << i; + EXPECT_LE(strip[i].get_red_raw(), red + step) << i; + EXPECT_GE(strip[i].get_blue_raw(), 100) << i; + EXPECT_LE(strip[i].get_blue_raw(), 100 + step) << i; + } +} + +TEST(PowerLimit, ADimPixelSettlesWhileDemandAlternates) { + Strip strip(2); + strip.configure_power(20, 0, 10.04f); // two full red pixels would draw 40 mA + strip[0].set_raw(1, 0, 0, 0); + uint8_t dim = 0; + for (int cycle = 0; cycle < 4; cycle++) { + strip[1].set_raw(255, 0, 0, 0); + strip.limit(); + ASSERT_LT(strip.power_brightness(), 255) << cycle; + strip[1].set_raw(0, 0, 0, 0); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255) << cycle; + // The first cycle may move the level by one quantisation step; after that it stays put + if (cycle > 0) + EXPECT_EQ(strip[0].get_red_raw(), dim) << cycle; + dim = strip[0].get_red_raw(); + EXPECT_GE(dim, 1) << cycle; + EXPECT_LE(dim, 3) << cycle; + } +} + +TEST(PowerLimit, TheStateLimitsAFrameBeforeTheDriverSendsIt) { + Strip strip(2); + AddressableLightState state(&strip); + strip.setup_state(&state); + strip.configure_power(20, 0, 10); // two full red pixels would draw 40 mA + strip.all() = Color(255, 0, 0, 0); + strip.schedule_show(); + state.loop(); + EXPECT_LT(strip.sent_red, 255); + EXPECT_EQ(strip.sent_red, strip[0].get_red_raw()); + // Nothing pending: the next loop neither limits nor writes + strip[0].set_raw(255, 0, 0, 0); + state.loop(); + EXPECT_EQ(strip[0].get_red_raw(), 255); +} + +TEST(PowerLimit, ALevelPastTheScaleInForceSaturatesWhenTheScaleRises) { + Strip strip(2); + strip.configure_power(20, 0, 30); // two full red pixels would draw 40 mA + strip.all() = Color(255, 0, 0, 0); + strip.limit(); + ASSERT_LT(strip.power_brightness(), 255); + // A raw write past the scale, as a lambda or a partition over another strip can make; one such pixel + // alone fits the budget, so the scale goes back up + strip[0].set_raw(255, 0, 0, 0); + strip[1].set_raw(0, 0, 0, 0); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255); + EXPECT_EQ(strip[0].get_red_raw(), 255); +} + +TEST(PowerLimit, RepeatedPassesOverAnUnchangedFrameHoldTheScale) { + Strip strip(1); + strip.configure_power(20, 0, 5); // a half-red pixel asks for 10 mA + strip[0] = Color(128, 0, 0, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_NEAR(scale, 127, 2); + // A driver retry re-runs the pass with nothing rewritten: the scale and the read-back stay put + for (int pass = 0; pass < 20; pass++) + strip.limit(); + EXPECT_EQ(strip.power_brightness(), scale); + EXPECT_NEAR(strip[0].get_red(), 128, 2); +} + +TEST(PowerLimit, AHeavyLimitStaysWithinOneStepOfTheBudget) { + Strip strip(1); + strip.configure_power(20, 0, 0.3f); // a full red pixel asks for 20 mA against a 0.3 mA budget + strip[0] = Color(255, 0, 0, 0); + for (int pass = 0; pass < 20; pass++) + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_GE(scale, 1); + EXPECT_LE(scale, 4); + // No dead band this low: the frame is within one level of the budget + EXPECT_LE(strip.get_estimated_current_ma(), 0.3f + 20.0f / 255.0f); +} + +TEST(PowerLimit, AChangedFrameTakesTheDropAtALowScale) { + Strip strip(10); + strip.configure_power(20, 1, 20); // white pixels draw 60 mA each against 10 mA left for the pixels + for (int i = 0; i < 5; i++) + strip[i] = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t five = strip.power_brightness(); + EXPECT_NEAR(five, 8, 1); + // Twice the pixels lit: well past one level per channel over the budget, so the scale drops + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t ten = strip.power_brightness(); + EXPECT_LT(ten, five); + // A retry with nothing rewritten then holds it + strip.limit(); + EXPECT_EQ(strip.power_brightness(), ten); +} + +TEST(PowerLimit, AChangingPixelOverAStaticBackgroundDoesNotWalkTheScaleDown) { + Strip strip(100); + strip.configure_power(20, 0, 60); // 99 pixels at red 100 ask for 776 mA + for (int i = 1; i < 100; i++) + strip[i] = Color(100, 0, 0, 0); + strip[0] = Color(255, 0, 0, 0); + strip.limit(); + const uint8_t first = strip.power_brightness(); + EXPECT_NEAR(first, 19, 2); + // A dot blinks over the background: every frame differs, so every pass runs + for (int pass = 0; pass < 40; pass++) { + strip[0] = Color(pass % 2 ? 255 : 0, 0, 0, 0); + strip.limit(); + } + EXPECT_NEAR(strip.power_brightness(), first, 2); + // One raw level at this scale is about 13 logical levels + EXPECT_NEAR(strip[50].get_red(), 100, 16); +} + +TEST(PowerLimit, AFrameJustOverBudgetAtFullScaleIsLimited) { + Strip strip(10); + strip.configure_power(20, 0, 199); // ten full red pixels draw 200 mA + strip.all() = Color(255, 0, 0, 0); + strip.limit(); + // Nothing was rounded at full scale, so the overshoot is real and the scale drops + EXPECT_LT(strip.power_brightness(), 255); + EXPECT_LE(strip.get_estimated_current_ma(), 199.0f + 10 * 20.0f / 255.0f); +} + +} // namespace esphome::light::testing diff --git a/tests/components/microphone/test.esp32-s31-idf.yaml b/tests/components/microphone/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..00f535b1692 --- /dev/null +++ b/tests/components/microphone/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_din_pin1: GPIO6 + i2s_din_pin2: GPIO7 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s31-idf.yaml + microphone: !include common-pdm.yaml diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp index 3500069ba10..1caf86febaf 100644 --- a/tests/components/pcf8574/test_pcf8574.cpp +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -50,10 +50,8 @@ class PCF8574ResetTest : public ::testing::TestWithParam { } void configure_input_(uint8_t number) { - PCF8574GPIOPin pin; - pin.set_parent(&this->component_); - pin.set_pin(number); - pin.set_flags(gpio::FLAG_INPUT); + // Codegen registers input pins with the parent before setup() + this->component_.register_input_pin(number); EXPECT_EQ(this->bus_.writes, 0); EXPECT_EQ(this->bus_.reads, 0); } diff --git a/tests/components/scd4x/common.yaml b/tests/components/scd4x/common.yaml index cedb88977ea..e875cb0cc57 100644 --- a/tests/components/scd4x/common.yaml +++ b/tests/components/scd4x/common.yaml @@ -14,3 +14,19 @@ sensor: ambient_pressure_compensation: 961mBar temperature_offset: 4.2C update_interval: 15s + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (bare hPa) + ambient_pressure_compensation: 961 + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (bar) + ambient_pressure_compensation: 0.961bar + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (altitude only) + altitude_compensation: 500m + ambient_pressure_compensation: 0 diff --git a/tests/components/sensor/sensor_with_dedup_test.cpp b/tests/components/sensor/sensor_with_dedup_test.cpp new file mode 100644 index 00000000000..1bff52193b0 --- /dev/null +++ b/tests/components/sensor/sensor_with_dedup_test.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include "esphome/components/sensor/sensor_with_dedup.h" + +namespace esphome::sensor::testing { + +class SensorWithDedupTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.add_on_state_callback([this](float value) { this->published_.push_back(value); }); + } + + Sensor sensor_; + std::vector published_; +}; + +TEST_F(SensorWithDedupTest, NoSensorDoesNothing) { + SensorWithDedup dedup; + EXPECT_FALSE(dedup.has_sensor()); + EXPECT_EQ(dedup.get_sensor(), nullptr); + dedup.publish_state_if_not_dup(5); + dedup.publish_state_unknown(); + EXPECT_TRUE(this->published_.empty()); +} + +TEST_F(SensorWithDedupTest, SkipsRepeatedValues) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + EXPECT_TRUE(dedup.has_sensor()); + EXPECT_EQ(dedup.get_sensor(), &this->sensor_); + dedup.publish_state_if_not_dup(-3); + dedup.publish_state_if_not_dup(-3); + dedup.publish_state_if_not_dup(7); + dedup.publish_state_if_not_dup(7); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_EQ(this->published_[0], -3.0f); + EXPECT_EQ(this->published_[1], 7.0f); +} + +TEST_F(SensorWithDedupTest, UnknownPublishedOnceThenValueAgain) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(1.5f); + dedup.publish_state_unknown(); + dedup.publish_state_unknown(); + dedup.publish_state_if_not_dup(1.5f); + ASSERT_EQ(this->published_.size(), 3u); + EXPECT_EQ(this->published_[0], 1.5f); + EXPECT_TRUE(std::isnan(this->published_[1])); + EXPECT_EQ(this->published_[2], 1.5f); +} + +TEST_F(SensorWithDedupTest, SetSensorResetsHistory) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(100); + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(100); + EXPECT_EQ(this->published_.size(), 2u); +} + +} // namespace esphome::sensor::testing diff --git a/tests/components/speaker/test.esp32-s31-idf.yaml b/tests/components/speaker/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..00a41ac969b --- /dev/null +++ b/tests/components/speaker/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_dout_pin: GPIO10 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s31-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s31-idf.yaml + speaker: !include common.yaml diff --git a/tests/components/spi_led_strip/common.yaml b/tests/components/spi_led_strip/common.yaml index 30e2ef22f78..1927f53fae4 100644 --- a/tests/components/spi_led_strip/common.yaml +++ b/tests/components/spi_led_strip/common.yaml @@ -5,3 +5,16 @@ light: id: rgb_led name: RGB LED data_rate: 8MHz + power_estimation: + ma_per_led_red: 16.0 + ma_per_led_green: 11.0 + ma_per_led_blue: 15.0 + idle_ma_per_led: 1.0 + max_current_ma: 100.0 + +# get_estimated_current_ma() is callable from a lambda +interval: + - interval: 60s + then: + - lambda: |- + ESP_LOGI("test", "Estimated current: %.1f mA", id(rgb_led).get_estimated_current_ma()); diff --git a/tests/components/usb_audio/common.yaml b/tests/components/usb_audio/common.yaml new file mode 100644 index 00000000000..7e60ca0da02 --- /dev/null +++ b/tests/components/usb_audio/common.yaml @@ -0,0 +1,21 @@ +usb_audio: + id: usb_audio_id + +microphone: + - platform: usb_audio + id: usb_mic + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 9600 + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 6400 + write_timeout: 20ms diff --git a/tests/components/usb_audio/test.esp32-p4-idf.yaml b/tests/components/usb_audio/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..53588849d1a --- /dev/null +++ b/tests/components/usb_audio/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_audio: !include common.yaml diff --git a/tests/components/usb_audio/test.esp32-s3-idf.yaml b/tests/components/usb_audio/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..53588849d1a --- /dev/null +++ b/tests/components/usb_audio/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_audio: !include common.yaml diff --git a/tests/integration/fixtures/api_homeassistant_light.yaml b/tests/integration/fixtures/api_homeassistant_light.yaml new file mode 100644 index 00000000000..e2cf9e5fbe5 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_light.yaml @@ -0,0 +1,39 @@ +esphome: + name: ha-light + +host: + +api: + actions: + - action: set_force_off + variables: + value: bool + then: + - globals.set: + id: force_off + value: !lambda return value; + +logger: + level: DEBUG + +globals: + - id: force_off + type: bool + initial_value: "false" + +light: + - platform: homeassistant + name: HA Light + id: ha_light + entity_id: light.kitchen + internal: false + report_timeout: 2s + on_turn_off: + - logger.log: "turned off" + on_turn_on: + # An automation reacting to a state reported by Home Assistant with a local change + - if: + condition: + lambda: return id(force_off); + then: + - light.turn_off: ha_light diff --git a/tests/integration/fixtures/light_toggle_action.yaml b/tests/integration/fixtures/light_toggle_action.yaml index 265d8ba1acb..6044f524234 100644 --- a/tests/integration/fixtures/light_toggle_action.yaml +++ b/tests/integration/fixtures/light_toggle_action.yaml @@ -20,14 +20,14 @@ light: default_transition_length: 0s button: - # Test 1: light.toggle without transition_length (HasTransitionLength=false) + # Test 1: light.toggle without transition_length - platform: template id: btn_toggle name: "Toggle" on_press: - light.toggle: test_light - # Test 2: light.toggle with transition_length (HasTransitionLength=true) + # Test 2: light.toggle with transition_length - platform: template id: btn_toggle_with_trans name: "Toggle With Trans" diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 75bf0b48427..c7169c1d6db 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,180 +1,197 @@ { - "tests/integration/test_action_concurrent_reentry.py": 14.33, - "tests/integration/test_addressable_light_transition.py": 18.53, - "tests/integration/test_alarm_control_panel_state_transitions.py": 15.5, - "tests/integration/test_api_action_metadata.py": 11.08, - "tests/integration/test_api_action_responses.py": 15.46, - "tests/integration/test_api_action_timeout.py": 16.54, - "tests/integration/test_api_conditional_memory.py": 11.84, - "tests/integration/test_api_custom_services.py": 13.37, - "tests/integration/test_api_decode_wire_types.py": 17.49, - "tests/integration/test_api_empty_message_roundtrip.py": 12.35, - "tests/integration/test_api_encode_boundaries.py": 10.93, - "tests/integration/test_api_get_time_response_timezone.py": 13.5, - "tests/integration/test_api_homeassistant.py": 18.7, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76, - "tests/integration/test_api_homeassistant_entities.py": 15.12, - "tests/integration/test_api_list_entities_backpressure.py": 14.2, - "tests/integration/test_api_message_size_batching.py": 13.72, - "tests/integration/test_api_outgoing_connection.py": 28.66, - "tests/integration/test_api_reboot_timeout.py": 9.01, - "tests/integration/test_api_string_lambda.py": 12.16, - "tests/integration/test_api_vv_logging.py": 10.14, - "tests/integration/test_api_zero_psk_provisioning.py": 18.26, - "tests/integration/test_areas_and_devices.py": 10.34, - "tests/integration/test_automation_wait_actions.py": 9.93, - "tests/integration/test_automations.py": 20.02, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03, - "tests/integration/test_binary_sensor_invalidate_state.py": 8.43, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39, - "tests/integration/test_branch_resume_async.py": 16.64, - "tests/integration/test_build_info.py": 12.41, - "tests/integration/test_camera_mock.py": 13.29, - "tests/integration/test_climate_control_action.py": 15.18, - "tests/integration/test_climate_custom_modes.py": 14.12, - "tests/integration/test_continuation_actions.py": 14.06, - "tests/integration/test_cover_control_action.py": 9.59, - "tests/integration/test_crc8_helper.py": 11.51, - "tests/integration/test_device_id_in_state.py": 18.41, - "tests/integration/test_duplicate_entities.py": 14.95, - "tests/integration/test_entity_icon.py": 15.57, - "tests/integration/test_entity_missing_state.py": 13.91, - "tests/integration/test_fan_turn_on_action.py": 13.85, - "tests/integration/test_fnv1_hash_object_id.py": 10.12, - "tests/integration/test_fnv1a_hash.py": 9.95, - "tests/integration/test_gpio_expander_cache.py": 4.43, - "tests/integration/test_host_logger_thread_safety.py": 12.54, - "tests/integration/test_host_mode_basic.py": 12.66, - "tests/integration/test_host_mode_batch_delay.py": 13.34, - "tests/integration/test_host_mode_climate_basic_state.py": 15.19, - "tests/integration/test_host_mode_climate_control.py": 15.35, - "tests/integration/test_host_mode_empty_string_options.py": 13.04, - "tests/integration/test_host_mode_entity_fields.py": 10.42, - "tests/integration/test_host_mode_fan_preset.py": 11.7, - "tests/integration/test_host_mode_many_entities.py": 16.79, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62, - "tests/integration/test_host_mode_noise_encryption.py": 21.06, - "tests/integration/test_host_mode_reconnect.py": 11.29, - "tests/integration/test_host_mode_sensor.py": 12.25, - "tests/integration/test_host_ota.py": 109.97, - "tests/integration/test_host_preferences.py": 12.92, - "tests/integration/test_host_preferences_suspend_resume.py": 12.3, - "tests/integration/test_host_write_after_peer_close.py": 14.42, - "tests/integration/test_improv_serial_uart.py": 16.29, - "tests/integration/test_ir_rf_transmit_complete.py": 10.73, - "tests/integration/test_large_message_batching.py": 13.94, - "tests/integration/test_legacy_area.py": 13.34, - "tests/integration/test_legacy_climate_compat.py": 13.19, - "tests/integration/test_legacy_fan_compat.py": 9.31, - "tests/integration/test_light_automations.py": 14.39, - "tests/integration/test_light_binary_effect_off_phase.py": 14.75, - "tests/integration/test_light_calls.py": 13.85, - "tests/integration/test_light_constant_brightness.py": 12.22, - "tests/integration/test_light_control_action.py": 15.46, - "tests/integration/test_light_dim_relative_action.py": 15.3, - "tests/integration/test_light_effect_zero_brightness.py": 16.75, - "tests/integration/test_light_initial_state.py": 9.8, - "tests/integration/test_light_repeat_effect.py": 22.27, - "tests/integration/test_light_restore_state.py": 15.67, - "tests/integration/test_light_resume_effect.py": 17.79, - "tests/integration/test_light_toggle_action.py": 15.22, - "tests/integration/test_light_transition_state_publish_interval.py": 44.96, - "tests/integration/test_lock_automations.py": 12.55, - "tests/integration/test_logger_buffered_recursion_guard.py": 11.78, - "tests/integration/test_loop_disable_enable.py": 13.19, - "tests/integration/test_loop_interval_decoupling.py": 15.14, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91, - "tests/integration/test_lvgl_color_picker_render.py": 91.0, - "tests/integration/test_lvgl_headless_render.py": 46.26, - "tests/integration/test_micros_to_millis.py": 9.14, - "tests/integration/test_multi_click_trigger.py": 13.62, - "tests/integration/test_multi_device_preferences.py": 15.18, - "tests/integration/test_noise_encryption_key_protection.py": 21.14, - "tests/integration/test_object_id_api_verification.py": 9.54, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78, - "tests/integration/test_object_id_no_friendly_name.py": 24.2, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9, - "tests/integration/test_online_image_bmp.py": 24.53, - "tests/integration/test_oversized_payloads.py": 43.03, - "tests/integration/test_preference_key_stability.py": 14.22, - "tests/integration/test_runtime_stats.py": 15.85, - "tests/integration/test_safe_mode_loop_runs.py": 8.89, - "tests/integration/test_scheduler_background_wake.py": 17.82, - "tests/integration/test_scheduler_blocking_warning.py": 30.27, - "tests/integration/test_scheduler_bulk_cleanup.py": 8.78, - "tests/integration/test_scheduler_defer_cancel.py": 12.9, - "tests/integration/test_scheduler_defer_cancel_regular.py": 12.79, - "tests/integration/test_scheduler_defer_fifo_simple.py": 13.14, - "tests/integration/test_scheduler_defer_stress.py": 13.2, - "tests/integration/test_scheduler_heap_stress.py": 9.15, - "tests/integration/test_scheduler_internal_id_no_collision.py": 14.81, - "tests/integration/test_scheduler_interval_reschedule.py": 13.07, - "tests/integration/test_scheduler_interval_zero_coerced.py": 12.32, - "tests/integration/test_scheduler_null_name.py": 3.81, - "tests/integration/test_scheduler_numeric_id_test.py": 9.77, - "tests/integration/test_scheduler_pool.py": 10.16, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.7, - "tests/integration/test_scheduler_recursive_timeout.py": 13.72, - "tests/integration/test_scheduler_removed_item_race.py": 12.73, - "tests/integration/test_scheduler_self_keyed.py": 13.03, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88, - "tests/integration/test_scheduler_string_test.py": 14.1, - "tests/integration/test_script_array_params.py": 11.14, - "tests/integration/test_script_delay_params.py": 13.84, - "tests/integration/test_script_queued.py": 15.45, - "tests/integration/test_script_queued_idle_loop.py": 13.15, - "tests/integration/test_script_wait_on_boot.py": 12.64, - "tests/integration/test_sdl_headless_screenshot.py": 15.19, - "tests/integration/test_select_stringref_trigger.py": 13.51, - "tests/integration/test_sensor_filters_delta.py": 13.6, - "tests/integration/test_sensor_filters_ring_buffer.py": 9.22, - "tests/integration/test_sensor_filters_sliding_window.py": 40.87, - "tests/integration/test_sensor_filters_value_list.py": 15.43, - "tests/integration/test_sensor_raw_state.py": 25.12, - "tests/integration/test_sensor_timeout_filter.py": 9.64, - "tests/integration/test_sensor_timeout_throttle_filter.py": 14.63, - "tests/integration/test_set_internal_at_boot.py": 13.18, - "tests/integration/test_snapshot_display.py": 16.01, - "tests/integration/test_socket_ipv4_resolve.py": 11.3, - "tests/integration/test_socket_tcp_client_link.py": 13.88, - "tests/integration/test_socket_wake_gate_tcp.py": 14.28, - "tests/integration/test_status_flags.py": 20.55, - "tests/integration/test_status_momentary.py": 12.63, - "tests/integration/test_strftime_to.py": 9.47, - "tests/integration/test_syslog.py": 17.73, - "tests/integration/test_tcp_uart_bridge.py": 16.27, - "tests/integration/test_tcp_uart_server.py": 13.64, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42, - "tests/integration/test_template_climate_basic.py": 14.42, - "tests/integration/test_template_climate_custom_modes.py": 14.3, - "tests/integration/test_template_climate_nonoptimistic.py": 10.11, - "tests/integration/test_template_climate_on_control_ordering.py": 4.57, - "tests/integration/test_template_climate_publish_all_fields.py": 12.56, - "tests/integration/test_template_climate_sensor_push.py": 14.89, - "tests/integration/test_template_climate_set_actions.py": 14.12, - "tests/integration/test_template_climate_two_point_temperature.py": 9.08, - "tests/integration/test_template_text_save.py": 13.96, - "tests/integration/test_template_time.py": 12.73, - "tests/integration/test_text_command.py": 13.09, - "tests/integration/test_text_sensor_raw_state.py": 14.3, - "tests/integration/test_uart_mock_ld2410.py": 33.11, - "tests/integration/test_uart_mock_ld2412.py": 61.26, - "tests/integration/test_uart_mock_ld2420.py": 23.39, - "tests/integration/test_uart_mock_ld2450.py": 11.79, - "tests/integration/test_uart_mock_modbus.py": 323.58, - "tests/integration/test_uart_mock_ufm01.py": 71.03, - "tests/integration/test_uart_tcp_bridge.py": 13.3, - "tests/integration/test_udp.py": 7.04, - "tests/integration/test_use_address_runtime.py": 12.4, - "tests/integration/test_valve_control_action.py": 9.25, - "tests/integration/test_varint_five_byte_device_id.py": 9.34, - "tests/integration/test_wait_until_mid_loop_timing.py": 13.21, - "tests/integration/test_wait_until_on_boot.py": 12.66, - "tests/integration/test_wait_until_ordering.py": 9.06, - "tests/integration/test_wait_until_reentrant_restart.py": 10.83, - "tests/integration/test_wake_loop_forces_phase_b.py": 14.23, - "tests/integration/test_water_heater_template.py": 21.96 + "tests/integration/test_action_concurrent_reentry.py": 14.84, + "tests/integration/test_addressable_light_transition.py": 17.96, + "tests/integration/test_alarm_control_panel_state_transitions.py": 16.42, + "tests/integration/test_api_action_metadata.py": 12.08, + "tests/integration/test_api_action_responses.py": 16.62, + "tests/integration/test_api_action_timeout.py": 17.38, + "tests/integration/test_api_conditional_memory.py": 12.24, + "tests/integration/test_api_custom_services.py": 10.53, + "tests/integration/test_api_decode_wire_types.py": 17.97, + "tests/integration/test_api_empty_message_roundtrip.py": 12.54, + "tests/integration/test_api_encode_boundaries.py": 14.46, + "tests/integration/test_api_get_time_response_timezone.py": 16.43, + "tests/integration/test_api_homeassistant.py": 17.44, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 11.63, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.61, + "tests/integration/test_api_homeassistant_entities.py": 10.45, + "tests/integration/test_api_list_entities_backpressure.py": 13.7, + "tests/integration/test_api_message_size_batching.py": 9.04, + "tests/integration/test_api_outgoing_connection.py": 26.32, + "tests/integration/test_api_reboot_timeout.py": 11.82, + "tests/integration/test_api_string_lambda.py": 11.94, + "tests/integration/test_api_vv_logging.py": 13.47, + "tests/integration/test_api_zero_psk_provisioning.py": 27.07, + "tests/integration/test_areas_and_devices.py": 9.9, + "tests/integration/test_automation_wait_actions.py": 13.22, + "tests/integration/test_automations.py": 26.92, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 14.21, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 13.7, + "tests/integration/test_binary_sensor_invalidate_state.py": 13.52, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 12.48, + "tests/integration/test_branch_resume_async.py": 16.18, + "tests/integration/test_build_info.py": 11.15, + "tests/integration/test_camera_mock.py": 13.74, + "tests/integration/test_climate_control_action.py": 14.33, + "tests/integration/test_climate_custom_modes.py": 13.85, + "tests/integration/test_continuation_actions.py": 12.9, + "tests/integration/test_cover_control_action.py": 10.2, + "tests/integration/test_crc8_helper.py": 11.98, + "tests/integration/test_device_id_in_state.py": 25.5, + "tests/integration/test_duplicate_entities.py": 14.91, + "tests/integration/test_entity_icon.py": 15.33, + "tests/integration/test_entity_missing_state.py": 8.06, + "tests/integration/test_fan_turn_on_action.py": 13.45, + "tests/integration/test_fnv1_hash_object_id.py": 10.16, + "tests/integration/test_fnv1a_hash.py": 9.94, + "tests/integration/test_gpio_expander_cache.py": 10.52, + "tests/integration/test_host_logger_thread_safety.py": 11.38, + "tests/integration/test_host_mode_basic.py": 10.96, + "tests/integration/test_host_mode_batch_delay.py": 13.46, + "tests/integration/test_host_mode_climate_basic_state.py": 7.78, + "tests/integration/test_host_mode_climate_control.py": 14.03, + "tests/integration/test_host_mode_empty_string_options.py": 12.45, + "tests/integration/test_host_mode_entity_fields.py": 15.66, + "tests/integration/test_host_mode_fan_preset.py": 9.7, + "tests/integration/test_host_mode_many_entities.py": 23.55, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 14.87, + "tests/integration/test_host_mode_noise_encryption.py": 29.31, + "tests/integration/test_host_mode_reconnect.py": 7.15, + "tests/integration/test_host_mode_sensor.py": 12.09, + "tests/integration/test_host_ota.py": 307.27, + "tests/integration/test_host_preferences.py": 13.14, + "tests/integration/test_host_preferences_suspend_resume.py": 14.41, + "tests/integration/test_host_write_after_peer_close.py": 13.85, + "tests/integration/test_improv_serial_uart.py": 14.8, + "tests/integration/test_in_range_conditions.py": 13.63, + "tests/integration/test_ir_rf_transmit_complete.py": 17.32, + "tests/integration/test_large_message_batching.py": 15.13, + "tests/integration/test_legacy_area.py": 11.23, + "tests/integration/test_legacy_climate_compat.py": 13.99, + "tests/integration/test_legacy_fan_compat.py": 8.48, + "tests/integration/test_light_automations.py": 8.52, + "tests/integration/test_light_binary_effect_off_phase.py": 20.04, + "tests/integration/test_light_calls.py": 16.91, + "tests/integration/test_light_constant_brightness.py": 10.63, + "tests/integration/test_light_control_action.py": 14.43, + "tests/integration/test_light_dim_relative_action.py": 16.59, + "tests/integration/test_light_effect_zero_brightness.py": 17.36, + "tests/integration/test_light_initial_state.py": 13.38, + "tests/integration/test_light_repeat_effect.py": 13.04, + "tests/integration/test_light_restore_state.py": 14.38, + "tests/integration/test_light_resume_effect.py": 17.23, + "tests/integration/test_light_toggle_action.py": 15.35, + "tests/integration/test_light_transition_state_publish_interval.py": 44.06, + "tests/integration/test_lock_automations.py": 13.01, + "tests/integration/test_logger_buffered_recursion_guard.py": 8.93, + "tests/integration/test_loop_disable_enable.py": 12.38, + "tests/integration/test_loop_interval_decoupling.py": 15.54, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.16, + "tests/integration/test_lvgl_color_picker_render.py": 53.51, + "tests/integration/test_lvgl_headless_render.py": 58.96, + "tests/integration/test_micros_to_millis.py": 2.84, + "tests/integration/test_multi_click_trigger.py": 8.88, + "tests/integration/test_multi_device_preferences.py": 14.72, + "tests/integration/test_noise_encryption_key_protection.py": 21.32, + "tests/integration/test_object_id_api_verification.py": 12.83, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 7.81, + "tests/integration/test_object_id_no_friendly_name.py": 24.12, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 25.21, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 25.14, + "tests/integration/test_online_image_bmp.py": 16.77, + "tests/integration/test_online_image_stalled_download.py": 17.06, + "tests/integration/test_oversized_payloads.py": 37.98, + "tests/integration/test_preference_key_stability.py": 13.43, + "tests/integration/test_runtime_stats.py": 12.02, + "tests/integration/test_safe_mode_loop_runs.py": 11.39, + "tests/integration/test_scheduler_background_wake.py": 17.85, + "tests/integration/test_scheduler_blocking_warning.py": 30.7, + "tests/integration/test_scheduler_bulk_cleanup.py": 12.73, + "tests/integration/test_scheduler_defer_cancel.py": 13.05, + "tests/integration/test_scheduler_defer_cancel_regular.py": 12.12, + "tests/integration/test_scheduler_defer_fifo_simple.py": 12.73, + "tests/integration/test_scheduler_defer_stress.py": 12.9, + "tests/integration/test_scheduler_heap_stress.py": 13.61, + "tests/integration/test_scheduler_internal_id_no_collision.py": 9.06, + "tests/integration/test_scheduler_interval_reschedule.py": 12.19, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.33, + "tests/integration/test_scheduler_loop_phase_timeout.py": 10.81, + "tests/integration/test_scheduler_null_name.py": 3.45, + "tests/integration/test_scheduler_numeric_id_test.py": 13.88, + "tests/integration/test_scheduler_pool.py": 13.86, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.54, + "tests/integration/test_scheduler_recursive_timeout.py": 4.32, + "tests/integration/test_scheduler_removed_item_race.py": 13.06, + "tests/integration/test_scheduler_self_keyed.py": 4.29, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 12.79, + "tests/integration/test_scheduler_string_test.py": 8.51, + "tests/integration/test_script_array_params.py": 8.05, + "tests/integration/test_script_delay_params.py": 14.57, + "tests/integration/test_script_on_stop_and_choose.py": 14.11, + "tests/integration/test_script_queued.py": 15.68, + "tests/integration/test_script_queued_idle_loop.py": 8.46, + "tests/integration/test_script_wait_on_boot.py": 12.91, + "tests/integration/test_sdl_headless_screenshot.py": 15.05, + "tests/integration/test_select_stringref_trigger.py": 11.48, + "tests/integration/test_sensor_filters_delta.py": 13.28, + "tests/integration/test_sensor_filters_ring_buffer.py": 13.95, + "tests/integration/test_sensor_filters_sliding_window.py": 40.27, + "tests/integration/test_sensor_filters_value_list.py": 14.95, + "tests/integration/test_sensor_raw_state.py": 24.93, + "tests/integration/test_sensor_timeout_filter.py": 14.93, + "tests/integration/test_sensor_timeout_throttle_filter.py": 16.23, + "tests/integration/test_set_internal_at_boot.py": 12.69, + "tests/integration/test_snapshot_display.py": 15.83, + "tests/integration/test_socket_ipv4_resolve.py": 10.41, + "tests/integration/test_socket_tcp_client_link.py": 12.96, + "tests/integration/test_socket_wake_gate_tcp.py": 11.61, + "tests/integration/test_socket_wake_gate_udp.py": 10.45, + "tests/integration/test_status_flags.py": 20.81, + "tests/integration/test_status_momentary.py": 15.46, + "tests/integration/test_strftime_to.py": 12.97, + "tests/integration/test_syslog.py": 21.29, + "tests/integration/test_tcp_uart_bridge.py": 16.41, + "tests/integration/test_tcp_uart_server.py": 13.43, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.34, + "tests/integration/test_template_climate_basic.py": 12.78, + "tests/integration/test_template_climate_custom_modes.py": 14.81, + "tests/integration/test_template_climate_nonoptimistic.py": 8.48, + "tests/integration/test_template_climate_on_control_ordering.py": 7.38, + "tests/integration/test_template_climate_publish_all_fields.py": 8.89, + "tests/integration/test_template_climate_sensor_push.py": 15.73, + "tests/integration/test_template_climate_set_actions.py": 11.23, + "tests/integration/test_template_climate_two_point_temperature.py": 9.47, + "tests/integration/test_template_text_save.py": 13.65, + "tests/integration/test_template_time.py": 9.1, + "tests/integration/test_text_command.py": 12.29, + "tests/integration/test_text_sensor_raw_state.py": 13.19, + "tests/integration/test_uart_mock_bl0906_stray_rx.py": 13.99, + "tests/integration/test_uart_mock_dfrobot_sen0465.py": 20.14, + "tests/integration/test_uart_mock_dfrobot_sen0609.py": 10.57, + "tests/integration/test_uart_mock_ld2410.py": 31.61, + "tests/integration/test_uart_mock_ld2410s.py": 49.83, + "tests/integration/test_uart_mock_ld2412.py": 89.93, + "tests/integration/test_uart_mock_ld2420.py": 20.19, + "tests/integration/test_uart_mock_ld2450.py": 16.45, + "tests/integration/test_uart_mock_ld2450_has_target_only.py": 17.25, + "tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py": 10.7, + "tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py": 15.95, + "tests/integration/test_uart_mock_ld2450_polygon_zones.py": 11.68, + "tests/integration/test_uart_mock_ld2460.py": 15.46, + "tests/integration/test_uart_mock_modbus.py": 214.58, + "tests/integration/test_uart_mock_pylontech.py": 14.66, + "tests/integration/test_uart_mock_ufm01.py": 70.02, + "tests/integration/test_uart_tcp_bridge.py": 10.46, + "tests/integration/test_udp.py": 9.38, + "tests/integration/test_udp_ipv6.py": 8.74, + "tests/integration/test_udp_multicast.py": 11.71, + "tests/integration/test_use_address_runtime.py": 15.24, + "tests/integration/test_valve_control_action.py": 13.75, + "tests/integration/test_varint_five_byte_device_id.py": 13.56, + "tests/integration/test_wait_until_mid_loop_timing.py": 8.57, + "tests/integration/test_wait_until_on_boot.py": 9.51, + "tests/integration/test_wait_until_ordering.py": 4.48, + "tests/integration/test_wait_until_reentrant_restart.py": 5.63, + "tests/integration/test_wake_loop_forces_phase_b.py": 9.4, + "tests/integration/test_water_heater_template.py": 28.16 } diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py new file mode 100644 index 00000000000..2cb9b4d6d05 --- /dev/null +++ b/tests/integration/test_api_homeassistant_light.py @@ -0,0 +1,393 @@ +"""Test the homeassistant light platform against a fake Home Assistant.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from aioesphomeapi import ( + APIClient, + EntityState, + HomeassistantServiceCall, + LightColorCapability, + LightState, + UserService, +) +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +ENTITY = "light.kitchen" +SUBSCRIPTIONS = { + (ENTITY, None), + (ENTITY, "supported_color_modes"), + (ENTITY, "min_color_temp_kelvin"), + (ENTITY, "max_color_temp_kelvin"), + (ENTITY, "color_mode"), + (ENTITY, "brightness"), + (ENTITY, "color_temp_kelvin"), + (ENTITY, "rgb_color"), +} +COLOR_TEMPERATURE = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE +) +RGB = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB +) + + +@dataclass +class FakeHomeAssistant: + """The light entity as the device sees it over the API, and what the device sends back.""" + + client: APIClient + key: int + services: list[UserService] + waiter: LineWaiter + calls: asyncio.Queue[HomeassistantServiceCall] + states: asyncio.Queue[LightState] + + async def next_call(self) -> HomeassistantServiceCall: + return await asyncio.wait_for(self.calls.get(), timeout=5.0) + + async def next_state(self, matches: Callable[[LightState], bool]) -> LightState: + async with asyncio.timeout(5.0): + while not matches(state := await self.states.get()): + pass + return state + + def report(self, attribute: str | None, value: str) -> None: + self.client.send_home_assistant_state(ENTITY, attribute, value) + + +@asynccontextmanager +async def light_entity( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + modes: str, +) -> AsyncIterator[FakeHomeAssistant]: + """Boots the device and answers its subscriptions with a light of the given color modes.""" + loop = asyncio.get_running_loop() + waiter = LineWaiter() + subscribed: set[tuple[str, str | None]] = set() + all_subscribed = loop.create_future() + calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue() + states: asyncio.Queue[LightState] = asyncio.Queue() + + def on_state_sub(entity_id: str, attribute: str | None) -> None: + subscribed.add((entity_id, attribute or None)) + if not all_subscribed.done() and subscribed >= SUBSCRIPTIONS: + all_subscribed.set_result(None) + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState): + states.put_nowait(state) + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + key = next(e.key for e in entities if e.name == "HA Light") + client.subscribe_service_calls(calls.put_nowait) + client.subscribe_states(on_state) + client.subscribe_home_assistant_states(on_state_sub) + try: + await asyncio.wait_for(all_subscribed, timeout=5.0) + except TimeoutError: + pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}") + ha = FakeHomeAssistant(client, key, services, waiter, calls, states) + ha.report("supported_color_modes", modes) + ha.report("min_color_temp_kelvin", "2000") + ha.report("max_color_temp_kelvin", "6500") + await waiter.wait_for("Supported color modes retrieved", timeout=5.0) + yield ha + + +@pytest.mark.asyncio +async def test_api_homeassistant_light( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Imported attributes, echo suppression and the actions sent for local changes.""" + async with light_entity( + yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']" + ) as ha: + set_force_off = next(s for s in ha.services if s.name == "set_force_off") + # Normal while Home Assistant starts, so no warning + ha.report(None, "unavailable") + ha.report(None, "unknown") + # Rejected, so the color stays unknown and the warnings name the attribute + ha.report("rgb_color", "(127.5, 64.0, 0.0)") + ha.report("rgb_color", "(0, 256, 0)") + ha.report("color_temp_kelvin", "0") + await ha.waiter.wait_for( + "'color_temp_kelvin' value 0 is out of range", timeout=5.0 + ) + assert sum("Can't parse 'rgb_color'" in line for line in ha.waiter.lines) == 2 + assert not any("as state!" in line for line in ha.waiter.lines) + + # A report arrives as several attribute messages and is applied as one change + ha.report(None, "on") + ha.report("color_mode", "rgb") + ha.report("brightness", "128") + ha.report("rgb_color", "(255, 0, 0)") + state = await ha.next_state(lambda s: s.state) + assert state.color_mode == RGB + assert state.brightness == pytest.approx(128 / 255) + assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0) + # A rejected color leaves the reported one untouched + ha.report("rgb_color", "(0, 256, 0)") + ha.report("brightness", "255") + state = await ha.next_state(lambda s: s.brightness == 1.0) + assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0) + + # The attributes of other modes stay with their mode, whatever order they arrive in + ha.report("color_mode", "color_temp") + ha.report("color_temp_kelvin", "4000") + state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) + assert state.color_temperature == 250.0 + ha.report("rgb_color", "(0, 255, 0)") + ha.report("brightness", "200") + state = await ha.next_state(lambda s: s.brightness == pytest.approx(200 / 255)) + assert state.color_mode == COLOR_TEMPERATURE + + # Local changes go to Home Assistant with its attribute names and scales; the color + # temperature it reported is not repeated + ha.client.light_command(ha.key, brightness=0.5) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert not call.data_template + ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0)) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert call.data_template == {"rgb_color": "[0,0,255]"} + + # Home Assistant echoes the result; that is not sent back. Returning to the state it + # reported last is a local change and is sent. + ha.report(None, "off") + ha.report("brightness", "None") + await ha.next_state(lambda s: not s.state) + await ha.waiter.wait_for( + "turned off", timeout=5.0 + ) # a reported state runs the triggers too + ha.client.light_command(ha.key, state=True) + assert (await ha.next_call()).service == "light.turn_on" + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + + # Without a report of the change within report_timeout, the light goes back to the reported state + ha.report(None, "on") + await ha.next_state(lambda s: s.state) + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + await ha.next_state(lambda s: s.state) + await ha.waiter.wait_for("did not report the change back", timeout=5.0) + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # The fallback also drops the levels of the unsent change: the light keeps the brightness + # Home Assistant reported last, which a later turn_on without a brightness would send + ha.client.light_command(ha.key, state=True, brightness=0.4) + call = await ha.next_call() + assert call.data["brightness"] == "102" + state = await ha.next_state(lambda s: not s.state) + assert state.brightness == pytest.approx(200 / 255) + + # An automation changing the light while a report is applied is a local change too + await ha.client.execute_service(set_force_off, {"value": True}) + ha.report(None, "on") + assert (await ha.next_call()).service == "light.turn_off" + await ha.client.execute_service(set_force_off, {"value": False}) + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A change that leaves Home Assistant as it is gets no report, so none is sent + ha.client.light_command(ha.key, state=False) + await ha.waiter.wait_for("already has this state", timeout=5.0) + + # A trait that arrives while a change waits for its report is not that report, and does + # not bring the old state back early either + ha.client.light_command(ha.key, state=True) + assert (await ha.next_call()).service == "light.turn_on" + await ha.next_state(lambda s: s.state) + ha.report("max_color_temp_kelvin", "6500") + await ha.next_state(lambda s: not s.state) + assert ( + sum("did not report the change back" in line for line in ha.waiter.lines) + == 3 + ) + + # A report that arrives during a flash is applied when the flash ends instead of being undone + ha.client.light_command(ha.key, state=True, flash_length=1.0) + ha.report(None, "on") + ha.report("brightness", "77") + state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) + assert state.state + await ha.waiter.wait_for("arrived during the transition", timeout=5.0) + + # A trait or a repeated report during a local transition is nothing new, so the change the + # transition ends at is still sent + ha.client.light_command(ha.key, state=False, transition_length=1.0) + ha.report("max_color_temp_kelvin", "6500") + ha.report(None, "on") + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A newer local command wins over a report held during a transition + ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) # held before the command arrives + ha.client.light_command(ha.key, state=True, brightness=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data["brightness"] == "255" + ha.report(None, "on") + ha.report("brightness", "255") + await ha.next_state(lambda s: s.state and s.brightness == 1.0) + + # Also when it changes only the color, at the level the transition was heading for + ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, color_temperature=400.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == { + "entity_id": ENTITY, + "brightness": "128", + "color_temp_kelvin": "2500", + } + ha.report("brightness", "128") + ha.report("color_temp_kelvin", "2500") + await ha.next_state(lambda s: s.color_temperature == 400.0) + + # Or when it is a transition itself, replacing the one the report was held for + ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == { + "entity_id": ENTITY, + "brightness": "255", + "color_temp_kelvin": "4000", + } + ha.report("brightness", "255") + ha.report("color_temp_kelvin", "4000") + await ha.next_state(lambda s: s.color_temperature == 250.0) + + # Or a transition back to where the one the report was held for started + ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data["brightness"] == "255" + ha.report("brightness", "255") + await ha.next_state(lambda s: s.brightness == 1.0) + + # And when it goes back to the state the transition started from + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.report(None, "on") + ha.report("brightness", "60") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told + ha.client.light_command(ha.key, state=True, flash_length=0.5) + await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0) + + assert ha.calls.empty() + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("api_homeassistant_light") +async def test_api_homeassistant_light_rgbww( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A cold/warm white light keeps its white balance: the color goes only when it changes.""" + async with light_entity( + yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgbww']" + ) as ha: + # Home Assistant reports rgb_color in every color mode, so the light is an RGB one here + ha.report(None, "on") + ha.report("color_mode", "rgbww") + ha.report("brightness", "200") + ha.report("rgb_color", "(255, 180, 120)") + state = await ha.next_state(lambda s: s.state) + assert state.color_mode == RGB + assert state.brightness == pytest.approx(200 / 255) + assert (state.red, state.green, state.blue) == pytest.approx( + (1.0, 180 / 255, 120 / 255) + ) + + # A level change sends the level alone; Home Assistant derived the color it reports from + # the white balance and would derive a different balance from the color sent back + ha.client.light_command(ha.key, brightness=0.5) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert not call.data_template + ha.report("brightness", "128") + await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255)) + + # A new color goes along + ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert call.data_template == {"rgb_color": "[0,255,0]"} + ha.report("rgb_color", "(0, 255, 0)") + await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0) + + # Switching to color temperature sends it, and the level alone once there + ha.client.light_command(ha.key, color_temperature=250.0) + call = await ha.next_call() + assert call.data == { + "entity_id": ENTITY, + "brightness": "128", + "color_temp_kelvin": "4000", + } + assert not call.data_template + ha.report("color_mode", "color_temp") + ha.report("color_temp_kelvin", "4000") + state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) + assert state.color_temperature == 250.0 + ha.client.light_command(ha.key, brightness=1.0) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.report("brightness", "255") + await ha.next_state(lambda s: s.brightness == 1.0) + + # Back to the color Home Assistant still reports: the mode changed, so the color is sent + ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "255"} + assert call.data_template == {"rgb_color": "[0,255,0]"} + ha.report("color_mode", "rgbww") + await ha.next_state(lambda s: s.color_mode == RGB) + + assert ha.calls.empty() diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py index ffbadabb5bd..e4dbc98075c 100644 --- a/tests/integration/test_light_toggle_action.py +++ b/tests/integration/test_light_toggle_action.py @@ -1,8 +1,4 @@ -"""Integration test for light::ToggleAction. - -Tests both ToggleAction and -ToggleAction instantiations. -""" +"""Integration test for the light.toggle action, with and without transition_length.""" from __future__ import annotations diff --git a/tests/test_build_components/build_components_base.esp32-s31-idf.yaml b/tests/test_build_components/build_components_base.esp32-s31-idf.yaml new file mode 100644 index 00000000000..6dbd51db0ad --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-s31-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32s31idf + friendly_name: $component_name + +esp32: + variant: esp32s31 + framework: + type: esp-idf + version: 6.1.0 + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/i2c/esp32-s31-idf.yaml b/tests/test_build_components/common/i2c/esp32-s31-idf.yaml new file mode 100644 index 00000000000..77eece9b3b1 --- /dev/null +++ b/tests/test_build_components/common/i2c/esp32-s31-idf.yaml @@ -0,0 +1,11 @@ +# Common I2C configuration for ESP32-S31 IDF tests + +substitutions: + scl_pin: GPIO9 + sda_pin: GPIO8 + +i2c: + - id: i2c_bus + scl: ${scl_pin} + sda: ${sda_pin} + scan: true diff --git a/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml new file mode 100644 index 00000000000..28587958f0f --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S31 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py index e5b6519700d..250eb5be9bc 100644 --- a/tests/unit_tests/components/test_pcf8574.py +++ b/tests/unit_tests/components/test_pcf8574.py @@ -5,7 +5,14 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from esphome.components import pcf8574 -from esphome.const import CONF_ID, CONF_RESET +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, + CONF_RESET, +) @pytest.mark.asyncio @@ -31,3 +38,33 @@ async def test_reset_configuration(options: dict, expected: bool) -> None: var.set_reset.assert_not_called() else: var.set_reset.assert_called_once_with(False) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "number, is_input", + [ + (0, True), + (15, True), + (0, False), + (15, False), + ], +) +async def test_pin_registers_inputs_only(number: int, is_input: bool) -> None: + config = { + CONF_NUMBER: number, + CONF_MODE: {CONF_INPUT: is_input, CONF_OUTPUT: not is_input}, + pcf8574.CONF_PCF8574: "hub", + } + parent = Mock() + with ( + patch.object(pcf8574.pins, "new_parented_pin", new_callable=AsyncMock), + patch.object(pcf8574.cg, "get_variable", AsyncMock(return_value=parent)), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.pcf8574_pin_to_code(config) + + if is_input: + parent.register_input_pin.assert_called_once_with(number) + else: + parent.register_input_pin.assert_not_called() diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 04b09b4d542..c28944024bc 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1695,45 +1695,50 @@ def test_start_udp_falls_back_when_blocked(mock_socket: Mock) -> None: 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 espota2._start_udp(mock_socket, mock_socket, "lossy") 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: +def test_start_udp_declined( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> 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 + with caplog.at_level(logging.INFO, logger=espota2.__name__): + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + assert "Link is lossy (lossy), trying UDP" in caplog.text + assert "Device declined UDP, using TCP" in caplog.text 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 + assert espota2._start_udp(mock_socket, mock_socket, None) 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"), + ("slowest", "prefer_udp", "retransmits", "reason"), [ - (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), + (0.01, False, 0, None), + (0.01, False, None, None), + (1.5, False, 0, "a handshake reply took 1.5s"), + (0.01, True, 0, "an earlier attempt failed"), + (0.01, False, 2, "TCP resent data during the handshake"), ], ) -def test_link_lossy( - slowest: float, prefer_udp: bool, retransmits: int | None, lossy: bool +def test_loss_reason( + slowest: float, prefer_udp: bool, retransmits: int | None, reason: str | None ) -> None: - """A slow exchange, a retransmit or an earlier failure means UDP.""" + """A slow exchange, a retransmit or an earlier failure means UDP, with the reason.""" with patch.object(espota2, "_tcp_retransmits", return_value=retransmits): - assert espota2._link_lossy(Mock(), slowest, prefer_udp) is lossy + assert espota2._loss_reason(Mock(), slowest, prefer_udp) == reason @pytest.mark.parametrize( @@ -1825,7 +1830,7 @@ def test_perform_ota_over_udp(mock_socket: Mock) -> None: 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 start.call_args.args[2] == "an earlier attempt failed" assert udp.recv.call_count == 4 # no chunk acks udp.flush.assert_called_once() udp.close.assert_called_once() @@ -1887,7 +1892,7 @@ def test_start_udp_probe_succeeds(mock_socket: Mock) -> None: 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) + channel = espota2._start_udp(mock_socket, mock_socket, "lossy") assert channel is not None channel.close() assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] @@ -1971,7 +1976,7 @@ def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None: 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 espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [ call(espota2.UDP_REQUEST), call(espota2.UDP_FALLBACK), diff --git a/tests/unit_tests/test_final_validate.py b/tests/unit_tests/test_final_validate.py new file mode 100644 index 00000000000..834dc92ffbd --- /dev/null +++ b/tests/unit_tests/test_final_validate.py @@ -0,0 +1,47 @@ +"""Tests for esphome.final_validate helpers.""" + +import pytest + +from esphome import final_validate as fv +from esphome.config import Config +from esphome.const import CONF_ID +from esphome.core import ID + + +def _config() -> tuple[Config, ID, dict]: + hub_id = ID("my_hub", is_declaration=True) + hub = {CONF_ID: hub_id, "mode": "fast"} + config = Config() + config["other"] = {"x": 1} + config["my_comp"] = [{"y": 2}, hub] + config.declare_ids.append((hub_id, ["my_comp", 1, CONF_ID])) + return config, hub_id, hub + + +def test_returns_the_declaring_block_from_the_full_config() -> None: + config, hub_id, hub = _config() + token = fv.full_config.set(config) + try: + assert fv.get_declaring_config(hub_id) is hub + finally: + fv.full_config.reset(token) + + +def test_uses_the_given_config() -> None: + config, hub_id, hub = _config() + assert fv.get_declaring_config(ID("my_hub"), config) is hub + + +def test_unknown_id_raises_key_error() -> None: + config, _, _ = _config() + with pytest.raises(KeyError): + fv.get_declaring_config(ID("missing"), config) + + +def test_non_dict_parent_raises_type_error() -> None: + config, _, _ = _config() + list_id = ID("in_list", is_declaration=True) + config["listed"] = [list_id] + config.declare_ids.append((list_id, ["listed", 0])) + with pytest.raises(TypeError): + fv.get_declaring_config(ID("in_list"), config) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 801eda4496a..88a91e38cf9 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -69,6 +69,7 @@ from esphome.__main__ import ( upload_using_esptool, upload_using_picotool, upload_using_platformio, + write_cpp, ) from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult @@ -108,6 +109,7 @@ from esphome.const import ( CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, + ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, @@ -4861,6 +4863,52 @@ def test_run_esphome_dashboard_redirects_to_device_builder( assert "esphome-device-builder" in caplog.text +def test_write_cpp_writes_gitignore_and_checks_build_tree( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """write_cpp writes the .gitignore and checks the build tree isn't tracked.""" + monkeypatch.delenv(ENV_NOGITIGNORE, raising=False) + + with ( + patch("esphome.writer.update_storage_json") as mock_update_storage, + patch("esphome.writer.write_gitignore") as mock_write_gitignore, + patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked, + patch("esphome.__main__.generate_cpp_contents") as mock_generate, + patch("esphome.__main__.write_cpp_file", return_value=0) as mock_write_file, + ): + result = write_cpp({}) + + assert result == 0 + mock_update_storage.assert_called_once() + mock_write_gitignore.assert_called_once() + mock_check_tracked.assert_called_once() + mock_generate.assert_called_once_with({}) + mock_write_file.assert_called_once() + + +def test_write_cpp_skips_gitignore_but_still_checks_build_tree( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ESPHOME_NOGITIGNORE skips writing .gitignore. + + The (read-only) tracked-file check still runs -- it isn't tied to + whether ESPHome is allowed to write files. + """ + monkeypatch.setenv(ENV_NOGITIGNORE, "true") + + with ( + patch("esphome.writer.update_storage_json"), + patch("esphome.writer.write_gitignore") as mock_write_gitignore, + patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked, + patch("esphome.__main__.generate_cpp_contents"), + patch("esphome.__main__.write_cpp_file", return_value=0), + ): + write_cpp({}) + + mock_write_gitignore.assert_not_called() + mock_check_tracked.assert_called_once() + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py index 18dc95a5c6c..cd4a71d2c0b 100644 --- a/tests/unit_tests/test_pins.py +++ b/tests/unit_tests/test_pins.py @@ -1,5 +1,7 @@ """Unit tests for esphome/pins.py.""" +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from esphome import config_validation as cv, pins @@ -108,3 +110,59 @@ def test_use_id_or_address_registers_as_use_id_schema(monkeypatch) -> None: validator = pins.use_id_or_address(SomeHub) assert schema_extractors.hidden_schemas.get(repr(validator)) == "use_id" + + +def _mode(input_: bool, output: bool, pullup: bool = False) -> dict[str, bool]: + return {"input": input_, "output": output, "pullup": pullup} + + +@pytest.mark.parametrize(("input_", "output"), [(True, False), (False, True)]) +def test_input_xor_output_accepts_one(input_: bool, output: bool) -> None: + mode = _mode(input_, output) + assert pins.gpio_validate_input_xor_output(mode) is mode + + +@pytest.mark.parametrize(("input_", "output"), [(True, True), (False, False)]) +def test_input_xor_output_rejects_both_or_neither(input_: bool, output: bool) -> None: + with pytest.raises(cv.Invalid, match="either input or output"): + pins.gpio_validate_input_xor_output(_mode(input_, output)) + + +def test_input_xor_output_pullup_allows_input_pullup() -> None: + mode = _mode(True, False, pullup=True) + assert pins.gpio_validate_input_xor_output_pullup(mode) is mode + + +def test_input_xor_output_pullup_rejects_output_pullup() -> None: + with pytest.raises(cv.Invalid, match="Pullup only available with input"): + pins.gpio_validate_input_xor_output_pullup(_mode(False, True, pullup=True)) + + +def test_input_xor_output_pullup_still_requires_one_mode() -> None: + with pytest.raises(cv.Invalid, match="either input or output"): + pins.gpio_validate_input_xor_output_pullup(_mode(True, True)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("args", [(), ("templ",)]) +async def test_new_parented_pin(args: tuple[str, ...]) -> None: + config = { + "id": "pin_id", + "hub": "hub_id", + "number": 5, + "inverted": True, + "mode": {"input": True, "output": False}, + } + var = MagicMock() + with ( + patch("esphome.codegen.new_Pvariable", return_value=var) as new_pvariable, + patch("esphome.codegen.register_parented", new=AsyncMock()) as parented, + patch("esphome.codegen.add") as add, + ): + assert await pins.new_parented_pin(config, "hub", *args) is var + new_pvariable.assert_called_once_with("pin_id", *args) + parented.assert_awaited_once_with(var, "hub_id") + var.set_pin.assert_called_once_with(5) + var.set_inverted.assert_called_once_with(True) + var.set_flags.assert_called_once() + assert add.call_count == 3 diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 67acfa6a189..f03a42b6e8d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -5,10 +5,12 @@ from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime import json +import logging import os from pathlib import Path import re import stat +import subprocess from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +34,7 @@ from esphome.writer import ( CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, + check_build_tree_not_tracked, clean_all, clean_build, clean_cmake_cache, @@ -874,6 +877,244 @@ def test_write_gitignore_skips_existing_file( assert gitignore_path.read_text() == existing_content +@patch("esphome.writer.subprocess.run") +def test_check_build_tree_not_tracked_silent_in_ci( + mock_run: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CI builds fixture configs, not a user's repo -- skip the git spawn entirely.""" + monkeypatch.setenv("CI", "true") + + check_build_tree_not_tracked() + + mock_run.assert_not_called() + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_warns_when_tracked( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked warns when the build dir is tracked by git.""" + monkeypatch.delenv("CI", raising=False) + data_dir = tmp_path / ".esphome" + mock_core.config_dir = tmp_path + mock_core.data_dir = data_dir + mock_run.return_value = MagicMock( + returncode=0, + stdout=b".esphome/build/node/main.cpp\0.esphome/node.json\0", + stderr=b"", + ) + + with ( + patch.dict(os.environ, {"GIT_DIR": "/somewhere/else/.git"}), + caplog.at_level("WARNING"), + ): + check_build_tree_not_tracked() + + assert "is tracked by git" in caplog.text + assert "2 files" in caplog.text + assert "secrets" in caplog.text + assert ".gitkeep" in caplog.text + # The remediation command must use the absolute path so it also works + # when pasted from the repository root of a nested config layout. + assert f"git rm -r --cached {data_dir}" in caplog.text + + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert args[0] == ["git", "ls-files", "-z", "--", ".esphome"] + assert kwargs["cwd"] == tmp_path + assert kwargs["capture_output"] is True + assert kwargs["check"] is False + assert kwargs["timeout"] == 5 + # Repo-scoping env vars must be stripped so a hook/CI wrapper can't + # redirect this read-only query to a different repository. + assert "GIT_DIR" not in kwargs["env"] + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_gitkeep_present( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked is skipped when .gitkeep opts in.""" + monkeypatch.delenv("CI", raising=False) + data_dir = tmp_path / ".esphome" + data_dir.mkdir() + (data_dir / ".gitkeep").touch() + mock_core.config_dir = tmp_path + mock_core.data_dir = data_dir + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + mock_run.assert_not_called() + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_untracked( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when nothing is tracked.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock(returncode=0, stdout=b"", stderr=b"") + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_outside_git_repo( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when not inside a git repo.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock( + returncode=128, stdout=b"", stderr=b"fatal: not a git repository\n" + ) + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_failure_without_stderr( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-zero exit with no stderr must not raise trying to log the reason.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock(returncode=1, stdout=b"", stderr=b"") + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_git_missing( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when git isn't installed.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = FileNotFoundError() + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert caplog.text == "" + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_other_os_error( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-executable git on PATH (PermissionError) must not abort the build.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = PermissionError("git is not executable") + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert "Could not check whether" in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_timeout( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A hung git process must not block the build indefinitely.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert "Could not check whether" in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_data_dir_outside_config_dir( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked skips the check for an external data dir. + + Covers the Home Assistant add-on (/data) and ESPHOME_DATA_DIR cases, where + the build directory can't be part of the config's git repository. + """ + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path / "config" + mock_core.data_dir = tmp_path / "data" + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + mock_run.assert_not_called() + assert "tracked by git" not in caplog.text + + @patch("esphome.writer.write_file_if_changed") # Mock to capture output @patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex @patch("esphome.writer.CORE") @@ -1373,6 +1614,7 @@ def test_clean_all( build_dir2.mkdir() (build_dir1 / "dummy.txt").write_text("x") (build_dir2 / "dummy.txt").write_text("x") + (build_dir1 / ".gitkeep").touch() # Create PlatformIO directories pio_cache = tmp_path / "pio_cache" @@ -1415,6 +1657,10 @@ def test_clean_all( # Verify that files in .esphome were removed assert not (build_dir1 / "dummy.txt").exists() assert not (build_dir2 / "dummy.txt").exists() + + # .gitkeep is the opt-out check_build_tree_not_tracked() advertises; it + # must survive clean-all or the warning comes right back on next compile. + assert (build_dir1 / ".gitkeep").exists() assert not pio_cache.exists() assert not pio_packages.exists() assert not pio_platforms.exists()