Compare commits

...
Author SHA1 Message Date
J. Nick Koston 35059556f0 [core] Add cv.ignored_key to drop a key that no longer has any effect 2026-10-19 13:11:50 -10:00
Igor Stadnyk 01524a2ffd [ld2410] Wait for complete query parameter acknowledgements (#21145) 2026-10-19 11:29:39 -10:00
dependabot[bot] 65ba64b9a6 Bump ruff from 0.17.0 to 0.17.1 (#21137) 2026-10-19 09:59:49 -10:00
dependabot[bot] 1b6a43a8a1 Bump esphome/workflows/.github/workflows/lock.yml from 2026.9.0 to 2026.10.2 (#21142) 2026-10-19 09:59:35 -10:00
Jonathan Swoboda 7a88c73fac [espota2] Stop the fake OTA device once the client is done (#21134) 2026-10-19 15:36:22 -04:00
dependabot[bot] e3a7437bdd Bump CodSpeedHQ/action from 5.4.2 to 5.5.0 (#21143)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:34:30 -10:00
dependabot[bot] ee53f8ab1c Bump platformdirs from 4.13.0 to 4.13.3 (#21136)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:33:07 -10:00
dependabot[bot] bc8bebfaba Bump filelock from 4.1.1 to 4.2.0 (#21138)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 19:33:00 +00:00
dependabot[bot] 0ab57126d8 Bump github/codeql-action/analyze from 4.38.3 to 4.39.0 (#21139)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:54 -10:00
dependabot[bot] 6d8e36cc97 Bump esphome/workflows/.github/workflows/stale.yml from 4fbce13026c393d6eb6b247e394dcd9ef1af76c2 to 31d160e5f0ffa0ee65d5b27cb55c153d415f2abe (#21141)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:46 -10:00
dependabot[bot] 0b70f297c2 Bump github/codeql-action/init from 4.38.3 to 4.39.0 (#21140)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:35 -10:00
dependabot[bot] 89a222b95a Bump esptool from 5.5.0 to 5.6.0 (#21135)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 09:32:21 -10:00
Daeho RoandJonathan Swoboda b537611bfa [core] Support Python 3.15 (#21052)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-19 09:15:54 -10:00
a3d3dd4a51 [combination] Remove @Cat-Ion from codeowners (#21133)
Co-authored-by: Lynn Ochs <lynn@gremlynn.de>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-19 18:48:53 +00:00
Igor Stadnyk a5fbd34a1e [ld2460] Preserve buffered frames across overflow (#21123) 2026-10-19 08:23:31 -10:00
Igor Stadnyk fd3c1fad45 [ld2410s] Fix parameter writes after long uptime (#21121) 2026-10-19 08:23:07 -10:00
Igor Stadnyk f250fa73d0 [ld2420] Resume UART reads after a silent command timeout (#21131) 2026-10-19 08:22:32 -10:00
J. Nick Koston 85dd0f71b3 [core] Add Apply entities so hub forwarding buttons, switches, numbers and selects need no class (#20935) 2026-10-19 07:50:56 -10:00
J. Nick Koston 7cf72c287d [web_server] Pass the listing flag as a bool and share the state event send (#21112) 2026-10-19 07:50:39 -10:00
J. Nick Koston dc56b27f33 [api] Send the device info strings from flash and keep the version out of RAM (#21106) 2026-10-19 07:50:21 -10:00
J. Nick Koston 384c2ec26c [ci] Extend the hardware-test skill with the audio, globals, outgoing connection and Home Assistant light flows (#21104) 2026-10-19 07:49:34 -10:00
Jonathan Swoboda 6868011336 [combination][speaker_source][const] Share CONF_SOURCES from components/const (#21129) 2026-10-19 07:49:05 -10:00
J. Nick Koston 8ffdbfd4ce [homeassistant] Judge a light report against the transition it arrived during (#21100) 2026-10-19 07:48:32 -10:00
J. Nick Koston 1cd5641900 [web_server] Keep the remaining ESP8266 web server strings in flash (#21113) 2026-10-19 12:25:30 -04:00
Clyde Stubbs 15b988be9c [cst9220] Fix long press reported as several short presses (#21034) 2026-10-19 20:44:22 +11:00
Clyde Stubbs 2d0e3faa3d [st7305] Add reflective LCD display driver (#21099) 2026-10-19 19:57:05 +11:00
esphome[bot] 2087d28684 Bump aioesphomeapi from 46.12.0 to 46.14.0 (#21114) 2026-10-19 08:07:50 +00:00
b919b692f7 [scd4x] Fix ambient_pressure_compensation unit handling (#19888)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-18 21:54:33 -10:00
Clyde Stubbs 4498ad2c77 [core] Warn when the .esphome build tree is tracked by git (#18973) 2026-10-19 18:38:47 +11:00
esphome[bot] 2addbaaa7f [ci] Refresh integration test durations (#21105) 2026-10-18 21:31:16 -10:00
1dc4006bfc [usb_audio] Add USB Audio Class (UAC) host support (#11728)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-18 17:44:59 -10:00
117 changed files with 6887 additions and 619 deletions
+9 -4
View File
@@ -93,13 +93,15 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
`--serial-of PORT` (the serial number behind a path).
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append] [--ms]`: bounded timestamped
capture (`--ms` adds milliseconds for timing work), ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now
and then; retry up to three times.
- `api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the
recipes drive their test configs with it.
- `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),
@@ -113,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder).
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `homeassistant_light/` (direct bulb driver), `usb_audio/` (URL player).
## Templates in `$SKILL/templates`
@@ -125,7 +127,10 @@ capture decoder).
- Per-topic device configs, each described in `reference/recipes/<component>.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`.
`bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`,
`homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`,
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`.
## When something looks wrong
@@ -4,7 +4,7 @@ Each flow names the pull requests it was used on, so their result tables show wh
One file per component, named after it:
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop
- [`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
@@ -24,3 +24,6 @@ One file per component, named after it:
- [`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
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
@@ -5,3 +5,26 @@
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.
## Outgoing connection wake timing on a deep sleep build (#21077)
`templates/deep-sleep-outgoing-dut.yaml`: classic ESP32 ESP-IDF, `wifi: fast_connect`, `api: encryption` with a bare
`outgoing_connection:`, `deep_sleep` 15 s run and 15 s sleep, a `homeassistant` text sensor, and `millis()` logged
from `wifi: on_connect`, `api: on_client_connected` and the sensor's first `on_value`. Home Assistant's dial-in
listener is on 6054; reuse a device name it already has. Flash by USB, `serial_capture.py PORT 360 OUT --reset` for
12 cycles, then `scripts/api_outgoing/cycles.py OUT LABEL` prints per cycle the wifi, client and state millis with
the client-wifi and state-client gaps. Wi-Fi takes 4 to 8 s after a wake, so compare the client-wifi gap, and run
the PR twice around one base run because the spread is a few hundred ms.
Results: https://github.com/esphome/esphome/pull/21077#issuecomment-6254098014
## Outgoing connection on a mains build after a Wi-Fi drop (#21085)
`templates/outgoing-wifi-drop-dut.yaml`: classic ESP32, `outgoing_connection: delay: 2s`, a "Wifi cycle" button doing
`wifi.disable`, 4 s, `wifi.enable`, a `debug` loop-time sensor, millis from the wifi and api triggers. Flash by USB,
`serial_capture.py PORT SECONDS OUT --ms`, press the button N times 40 s apart with `api_button.py DEV_IP KEY press
"Wifi cycle"`, then `wificycles.py CAPTURE LABEL` prints per drop the Wi-Fi-to-dial gap, dials and dials closed, who
connected first, and the loop-time maxima. For the loop cost of polling, repeat with an unreachable
`host: 192.0.2.1`. Home Assistant refuses a dial-in while its own session is READY (`Refusing dial-in adoption`),
so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the
first dial landing in the loop the network flag flips.
Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791
@@ -0,0 +1,15 @@
# globals
## Restoring globals with storage: rtc and flash (#13954)
`templates/globals-storage-dut.yaml` is the shared package: five restoring globals (rtc int, flash int, default int,
rtc `std::string`, rtc `uint8_t[2]`), bumped and logged with the reset reason on every boot, saved and synced after
2 s, odd boots `App.safe_reboot()`. Platform files add the board (`packages: dut: !include globals-storage-dut.yaml`), the `debug` reset-reason text sensor and, on the
ESP32, `deep_sleep: run_duration: 12s, sleep_duration: 8s` (even boots sleep). No network needed: flash by USB,
`serial_capture.py PORT 110 OUT --reset` gives the reset pulse plus a run of soft restarts and sleeps; the ESP32's
EN pulse is a `POWERON_RESET` that empties RTC memory, the D1 mini's RST pulse (`rst cause:2`) keeps it, so the
ESP8266 needs a real power cut by hand; arm long `--released` captures on both ports first (the capture
reopens the port when it comes back, the very first boot lines are still lost, read the next boot's restored
values). Unchanged-build check: the config with every `storage:` removed, built on the PR and on the dev merge
commit under the PR's own commits (not the merge base, which is older dev), `cmp -l` plus an `nm -S` symbol diff;
only the template names differ. Results: https://github.com/esphome/esphome/pull/13954#issuecomment-6254794712
@@ -0,0 +1,35 @@
# homeassistant
## homeassistant light platform against a local Home Assistant (#11354)
A local Home Assistant dev install (`hass -c config`, https://127.0.0.1:8123 with a self-signed cert); its ESPHome
entries and entity ids are in `config/.storage/`. Two ESPHome RGBCW bulbs with a plaintext API are the lights, so an
"HA side" change is made directly on a bulb with `scripts/homeassistant_light/bulb.py BULB_IP - on|off|state|restart`.
Add the test devices to HA by hand with the shared `api_key` and "Allow the device to perform Home Assistant actions".
`templates/homeassistant-light-dut.yaml` is a package: two mirrors, buttons for each change, a "Log state" button, a
30 s alive line, an `on_boot` `light.turn_on` for the change-before-first-write path, and VERBOSE logging with the
noisy tags at DEBUG (a tag cannot exceed the global level). A platform file sets the board and includes it:
```yaml
esphome:
name: ha-light-dut
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
dut: !include homeassistant-light-dut.yaml
```
Flash by USB, start a bounded `esphome logs` capture per device, then press buttons with
`scripts/api_button.py DEV_IP KEY press "Button"` and read the bulb with `bulb.py`. Cases: each
button against the bulb and the device log; a direct bulb change that both mirrors follow; restart the bulb, send a
change, expect the 5 s `did not report the change back` and the fallback (with an off and with an on last report);
OTA reboot with a serial capture running to see the boot-time change (the API log client reconnects too late).
Lessons: the light core clamps mireds to the range HA reported; a flash sends nothing; a change sent while HA is
connected but not yet subscribed is dropped by the api server (`client has not subscribed to actions (yet)`); after a
fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has
no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and
https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409
@@ -0,0 +1,52 @@
# usb_audio
## USB audio on the Waveshare ESP32-S3-ETH (#21081, #11728)
`templates/usb-audio-waveshare-s3-eth-dut.yaml`: ESP-IDF, PoE, logs over the API on Ethernet (the native USB port is
the host port, so there is no console), `usb_audio` hub, a 48 kHz 16-bit microphone, a 48 kHz 24-bit speaker (many
speakerphones offer only 24-bit; a wrong format logs the device's alternates), buttons for capture start and stop,
a non-blocking beep fed from an 8 ms interval with `play(buf, len, 0)` (a blocking `play()` on the main loop trips
the task watchdog), and a status interval with mic bytes, speaker state and heap. Drive the buttons with
`scripts/api_button.py IP KEY press "Beep"`. Expect 192 kB/s microphone bytes and the FIFO split
RX 80 / NPTX 16 / PTX 104 in the build's `defines.h`; with the balanced split the 294-byte OUT pipe is refused
(`EP MPS (294) exceeds supported limit (128)`). A device whose OUT endpoint is larger than 600 bytes cannot be
claimed on an S3 at all.
## Music Assistant through sendspin
`templates/usb-audio-sendspin-s3-eth-dut.yaml` overlays the template above with the Apollo CAST-1 wiring: resampler
speakers into a mixer whose inputs never time out, `speaker_source` media player with the sendspin source, and
`sendspin: unpaired_access: true`. A `media_player: platform: speaker` is not a media-source consumer and gives
"Player over its buffer", no audio. Music Assistant finds the device by mDNS within 15 s of boot.
Read the result from the device, not only by ear: sendspin's sync task logs `Lost sync (N us off)` (DEBUG, tag
`SsSync`) for every correction over 5 ms, a positive N inserts that much silence, and each one is an audible click.
For the chunk lead histogram, edit the managed library in the build directory,
`.esphome/build/<name>/managed_components/sendspin__sendspin-cpp/src/sync_task.cpp` (an `SS_LOGD` before the
late-chunk skip in `decode_chunk()` printing the chunk's lead against `new_audio_client_playtime`), update only that
file's `size` and sha256 `hash` in the component's `CHECKSUMS.json`, and compile again; deleting `.component_hash` or
recomputing it makes the manager refuse or refetch. Check the edit is in the image with `strings firmware.ota.bin`.
A lead below -5 ms makes the library skip the whole chunk; an I2S speaker keeps it within 0.1 ms of zero, which is
the figure a USB speaker has to match.
Gotchas: wait two minutes after a reboot before the measured stream (safe mode's boot counter write stalls the cores
for about 140 ms once, 60 to 75 s after boot); with the serial console on, a DEBUG log line per report saturates
UART0 and trips the task watchdog, so use `logger: baud_rate: 0` and the API log; two overlapping `esphome logs`
captures double every count; and every audio test must include a pause and resume cycle, since a stream end that
leaves less than one transfer parked in the driver was what hung the speaker after #11728 merged.
## Controls
Apollo CAST PRO-1 (`templates/cast-pro-1-sendspin-control.yaml`): `Core.yaml` next to it is
`Integrations/ESPHome/Core.yaml` from ApolloAutomation/CAST_PRO-1, which also defines the `version` substitution.
Keep its framework log level at ERROR (DEBUG floods driver output and trips the watchdog); the package reboots itself
into Ethernet-only once it sees a link and appends the MAC suffix to the name. `wifi: enable_on_boot: false` keeps
sendspin off Wi-Fi.
Nabu Casa Connect AUX-2 (a pre-production unit): build from `packages/aux-2-pre-prod.yaml` at NabuCasa/aux-2 commit
283f2e64 (dropped in bde41e01), not the public `aux-2.yaml`, whose W5500, codec and I2S pins differ (the public one
reads W5500 chip ID 0x00 and fails the codec's I2C writes). Point its local `external_components` source at
`github://NabuCasa/aux-2@main`. Templates `aux-2-preprod-stock-dut.yaml` (I2S codec) and
`aux-2-preprod-usb-audio-dut.yaml` (the USB speaker as the mixer output via `!extend mixing_speaker`). The console is
the CH343 (1a86:55d3); first flash over it, OTA after. Forcing the Wi-Fi flag off with `restore_value: no` boot-loops
when Ethernet fails, so let the package switch itself.
@@ -0,0 +1,33 @@
"""Press a template button on the test device over the API, or list its buttons.
usage: api_button.py HOST KEY list | press "Button name" [...]
"""
import asyncio
import sys
from aioesphomeapi import APIClient, ButtonInfo
HOST, KEY = sys.argv[1], sys.argv[2]
CMD, ARGS = sys.argv[3], sys.argv[4:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
if CMD == "list":
print("\n".join(sorted(buttons)))
else:
for name in ARGS:
if name not in buttons:
sys.exit(f"no button named {name!r}; have {sorted(buttons)}")
c.button_command(buttons[name].key)
print("pressed", name)
await asyncio.sleep(0.3)
await asyncio.sleep(0.5)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,73 @@
"""Per wake cycle timings from a serial capture of the deep sleep timing config.
usage: cycles.py CAPTURE [LABEL]
A cycle starts at the boot banner; the numbers are the device's millis() from the timing lines, and the
capture clock gap from the first line of the cycle to the "Dialing" line.
"""
import pathlib
import re
import statistics
import sys
cap = (
pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
)
label = sys.argv[2] if len(sys.argv) > 2 else ""
TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]")
def secs(line):
m = TS.match(line)
if not m:
return None
h, mi, s, f = m.groups()
return int(h) * 3600 + int(mi) * 60 + int(s) + (int(f) / 10 ** len(f) if f else 0)
cycles = []
cur = None
for line in cap:
if "rst:0x" in line or (cur is None and "Log initialized" in line):
cur = {"start": secs(line), "reset": line.split("]", 1)[-1].strip()[:60]}
cycles.append(cur)
continue
if cur is None:
continue
if "Dialing" in line:
cur.setdefault("dial_cap", secs(line))
m = re.search(r"wifi connected at (\d+) ms", line)
if m:
cur.setdefault("wifi", int(m.group(1)))
m = re.search(r"client connected at (\d+) ms \((.*?)\)", line)
if m and "client" not in cur:
cur["client"] = int(m.group(1))
cur["who"] = m.group(2)
m = re.search(r"first HA state '(.*?)' at (\d+) ms", line)
if m and "state" not in cur:
cur["state"] = int(m.group(2))
print(f"{label} cycles: {len(cycles)}")
print(
f"{'n':>2} {'wifi ms':>8} {'dial +ms':>9} {'client ms':>10} {'state ms':>9} {'client-wifi':>11} {'state-client':>12} client reset"
)
for i, c in enumerate(cycles, 1):
dial = ""
if c.get("dial_cap") is not None and c.get("start") is not None:
dial = f"{(c['dial_cap'] - c['start']) * 1000:.0f}"
cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else ""
sc = c["state"] - c["client"] if "state" in c and "client" in c else ""
print(
f"{i:>2} {c.get('wifi', ''):>8} {dial:>9} {c.get('client', ''):>10} {c.get('state', ''):>9} {cw:>11} {sc:>12} {c.get('who', '')} {c.get('reset', '')[:28]}"
)
for c in cycles:
if "client" in c and "wifi" in c:
c["client-wifi"] = c["client"] - c["wifi"]
if "state" in c and "client" in c:
c["state-client"] = c["state"] - c["client"]
for key in ("wifi", "client", "state", "client-wifi", "state-client"):
vals = [c[key] for c in cycles if key in c]
if vals:
print(
f"{key}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}"
)
@@ -0,0 +1,107 @@
"""Per Wi-Fi drop timings from a serial capture of the outgoing connection timing config.
usage: wificycles.py CAPTURE [LABEL]
A cycle starts at "wifi cycle disabling"; the numbers are the device's millis() from the timing lines.
"dial" says whether the client that connected first came from the device's own dial ("Dialing" seen after the
reconnect and before the hello) or from an incoming connection ("Accept" seen first).
"""
import pathlib
import re
import statistics
import sys
cap = (
pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
)
label = sys.argv[2] if len(sys.argv) > 2 else ""
TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]")
def secs(line):
m = TS.match(line)
if not m:
return None
h, mi, se, f = m.groups()
return int(h) * 3600 + int(mi) * 60 + int(se) + (int(f) / 10 ** len(f) if f else 0)
cycles = []
cur = None
loop_max = []
for line in cap:
m = re.search(r"loop time max (\d+) ms", line)
if m:
loop_max.append(int(m.group(1)))
if "wifi cycle disabling" in line:
cur = {"order": []}
cycles.append(cur)
continue
if cur is None:
continue
m = re.search(r"wifi connected at (\d+) ms", line)
if m and "wifi" not in cur:
cur["wifi"] = int(m.group(1))
cur["wifi_cap"] = secs(line)
if "Dialing" in line:
cur["order"].append("dial")
cur["dials"] = cur.get("dials", 0) + 1
if "dial_cap" not in cur and "wifi_cap" in cur:
cur["dial_cap"] = secs(line)
elif "dial_cap" not in cur and "dial_before_wifi_cap" not in cur:
cur["dial_before_wifi_cap"] = secs(line)
if (
re.search(r"client disconnected at \d+ ms \(\d+\.\d+\.\d+\.\d+\)", line)
and "wifi" in cur
and "client" not in cur
):
cur["dial_closed"] = cur.get("dial_closed", 0) + 1
if "Accept " in line and "wifi" in cur:
cur["order"].append("accept")
m = re.search(r"client connected at (\d+) ms \((.*?)\)", line)
if m and "wifi" in cur and "client" not in cur:
cur["client"] = int(m.group(1))
cur["who"] = m.group(2).split(" ")[0]
cur["via"] = cur["order"][0] if cur["order"] else "?"
m = re.search(r"Connect (failed|timeout)", line)
if m:
cur.setdefault("connect_errors", 0)
cur["connect_errors"] += 1
print(f"{label} cycles: {len(cycles)}")
print(
f"{'n':>2} {'wifi ms':>8} {'dial-wifi cap ms':>16} {'client ms':>10} {'client-wifi':>11} via dials closed client"
)
for i, c in enumerate(cycles, 1):
cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else ""
c["client-wifi"] = cw
dw = ""
if "wifi_cap" in c and c.get("wifi_cap") is not None:
# the first dial of the cycle, whether it was logged before or after the Wi-Fi line
if c.get("dial_before_wifi_cap") is not None:
dw = f"{(c['dial_before_wifi_cap'] - c['wifi_cap']) * 1000:.0f}"
elif "dial_cap" in c and c["dial_cap"] is not None:
dw = f"{(c['dial_cap'] - c['wifi_cap']) * 1000:.0f}"
c["dial-wifi"] = dw
print(
f"{i:>2} {c.get('wifi', ''):>8} {dw:>16} {c.get('client', ''):>10} {cw:>11} {c.get('via', ''):7s} {c.get('dials', 0):>5} {c.get('dial_closed', 0):>6} {c.get('who', '')}"
)
for via in ("dial", "accept"):
vals = [
c["client-wifi"]
for c in cycles
if c.get("via") == via and c["client-wifi"] != ""
]
if vals:
print(
f"client-wifi via {via}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}"
)
dws = [float(c["dial-wifi"]) for c in cycles if c.get("dial-wifi") not in ("", None)]
if dws:
print(
f"dial after wifi connected (capture clock): n={len(dws)} min={min(dws):.0f} median={statistics.median(dws):.0f} max={max(dws):.0f}"
)
if loop_max:
print(
f"loop time max per 5 s: n={len(loop_max)} min={min(loop_max)} median={statistics.median(loop_max):.0f} max={max(loop_max)}"
)
@@ -0,0 +1,78 @@
"""Read or drive a bulb directly over its own ESPHome API (an HA-side change, independent of the mirror).
usage: bulb.py HOST KEY state | on [brightness 0-1] [ct_mireds|rgb r,g,b] | off | restart
"""
import asyncio
import sys
from aioesphomeapi import APIClient, ButtonInfo, LightColorCapability, LightInfo
HOST, KEY, CMD = sys.argv[1], sys.argv[2], sys.argv[3]
ARGS = sys.argv[4:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
light = next((e for e in ents if isinstance(e, LightInfo)), None)
if light is None:
sys.exit("no light entity on this device; is HOST a bulb?")
if CMD == "state":
fut = asyncio.get_running_loop().create_future()
def on_state(st):
if getattr(st, "key", None) == light.key and not fut.done():
fut.set_result(st)
c.subscribe_states(on_state)
st = await asyncio.wait_for(fut, 5)
print(
f"{light.name}: on={st.state} mode={st.color_mode} bri={st.brightness:.2f} "
f"ct={st.color_temperature:.1f} rgb={st.red:.2f}/{st.green:.2f}/{st.blue:.2f} "
f"white={st.white:.2f} cold={st.cold_white:.2f} warm={st.warm_white:.2f}"
)
elif CMD == "on":
kw = {"state": True}
if ARGS:
kw["brightness"] = float(ARGS[0])
if len(ARGS) > 1:
if "," in ARGS[1]:
r, g, b = (float(x) for x in ARGS[1].split(","))
kw["rgb"] = (r, g, b)
kw["color_mode"] = (
LightColorCapability.RGB
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
)
else:
kw["color_temperature"] = float(ARGS[1])
kw["color_mode"] = (
LightColorCapability.COLOR_TEMPERATURE
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
)
c.light_command(light.key, **kw)
print("bulb on", kw)
elif CMD == "off":
c.light_command(light.key, state=False)
print("bulb off")
elif CMD == "restart":
btn = next(
(
e
for e in ents
if isinstance(e, ButtonInfo) and "restart" in e.name.lower()
),
None,
)
if btn is None:
sys.exit("no restart button on this device")
c.button_command(btn.key)
print("bulb restart pressed")
await asyncio.sleep(0.5)
await c.disconnect()
asyncio.run(main())
@@ -1,6 +1,6 @@
"""Bounded, timestamped serial capture that always exits and closes the port.
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append]
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] [--ms]
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
@@ -76,14 +76,21 @@ def open_port(args: argparse.Namespace, path: str) -> serial.Serial:
return s
def write_line(out, tag: bytes, data: bytes) -> None:
stamp = time.strftime("[%H:%M:%S]").encode()
def write_line(out, tag: bytes, data: bytes, ms: bool = False) -> None:
if ms:
now = time.time()
stamp = (
time.strftime("[%H:%M:%S", time.localtime(now))
+ f".{int(now * 1000) % 1000:03d}]"
).encode()
else:
stamp = time.strftime("[%H:%M:%S]").encode()
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
out.flush()
def note(out, text: str) -> None:
write_line(out, b"[capture] ", text.encode())
def note(out, text: str, ms: bool = False) -> None:
write_line(out, b"[capture] ", text.encode(), ms)
def capture(args: argparse.Namespace, out) -> int:
@@ -102,18 +109,18 @@ def capture(args: argparse.Namespace, out) -> int:
if serial_number is None:
serial_number, index = identify(path)
if path != args.port:
note(out, f"reopened as {path}")
note(out, f"reopened as {path}", args.ms)
except PORT_ERRORS as err:
note(out, f"open failed: {err}")
note(out, f"open failed: {err}", args.ms)
time.sleep(1)
continue
try:
data = s.read(4096)
except PORT_ERRORS as err:
if buf: # keep the cut-off line apart from the next boot's first line
write_line(out, b"[partial]", buf)
write_line(out, b"[partial]", buf, args.ms)
buf = b""
note(out, f"port lost: {err}")
note(out, f"port lost: {err}", args.ms)
with contextlib.suppress(*PORT_ERRORS):
s.close()
s = None
@@ -125,9 +132,9 @@ def capture(args: argparse.Namespace, out) -> int:
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
write_line(out, b"", line.rstrip(b"\r"))
write_line(out, b"", line.rstrip(b"\r"), args.ms)
if buf:
write_line(out, b"[partial]", buf)
write_line(out, b"[partial]", buf, args.ms)
if s is not None:
s.close()
return received
@@ -144,6 +151,7 @@ def main() -> int:
ap.add_argument("--released", action="store_true")
ap.add_argument("--reset", action="store_true")
ap.add_argument("--append", action="store_true")
ap.add_argument("--ms", action="store_true", help="millisecond timestamps")
args = ap.parse_args()
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
received = capture(args, out)
@@ -0,0 +1,30 @@
"""Play a URL on the speaker_source media player over the API (no sync task involved).
usage: playurl.py HOST KEY URL [PLAYER_NAME]
"""
import asyncio
import sys
from aioesphomeapi import APIClient, MediaPlayerInfo
HOST, KEY, URL = sys.argv[1], sys.argv[2], sys.argv[3]
PLAYER = sys.argv[4] if len(sys.argv) > 4 else "Player"
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
players = {e.name: e for e in ents if isinstance(e, MediaPlayerInfo)}
print("media players:", sorted(players))
if PLAYER not in players:
sys.exit(f"no media player named {PLAYER!r}; have {sorted(players)}")
p = players[PLAYER]
c.media_player_command(p.key, media_url=URL)
print("sent play_media to", PLAYER)
await asyncio.sleep(1)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,23 @@
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local
# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md).
# Nabu Casa Connect AUX-2 on the usb_audio PR tree: stock package (I2S PCM5122 output) as a control.
packages:
core: !include aux-2-pre-prod.yaml
esphome:
name: aux-2-dut
name_add_mac_suffix: false
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
logger:
level: DEBUG
sendspin:
unpaired_access: true
@@ -0,0 +1,39 @@
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local
# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md).
# Nabu Casa Connect AUX-2 on the usb_audio PR tree: the Anker S330 on the USB host port as the mixer output.
packages:
core: !include aux-2-pre-prod.yaml
esphome:
name: aux-2-usb-dut
name_add_mac_suffix: false
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
logger:
level: DEBUG
baud_rate: 0
sendspin:
unpaired_access: true
usb_audio:
id: usb_audio_id
speaker:
- platform: usb_audio
id: usb_speaker
usb_audio_id: usb_audio_id
sample_rate: 48000
bits_per_sample: 24bit
num_channels: 2
buffer_size: 28800
write_timeout: 50ms
- id: !extend mixing_speaker
output_speaker: usb_speaker
@@ -0,0 +1,27 @@
# CAST PRO-1 control for the sendspin sync comparison: Apollo's current package, DEBUG logging as on the S3.
esphome:
name: "cast-pro-1-dut"
friendly_name: CAST PRO-1 test
project:
name: "ApolloAutomation.CAST_PRO-1"
version: ${version}
packages:
core: !include Core.yaml
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
enable_on_boot: false
logger:
level: DEBUG
api:
encryption:
key: !secret api_key
sendspin:
manufacturer: Apollo
model: CAST PRO-1 control
unpaired_access: true
@@ -0,0 +1,46 @@
# Deep sleep wake timing for the outgoing API connection: boot -> dial -> client hello -> first HA state.
esphome:
name: deep-sleep-dut
on_boot:
priority: -100
then:
- lambda: ESP_LOGI("timing", "setup done at %u ms", (unsigned) millis());
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
on_connect:
- lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis());
api:
encryption:
key: !secret api_key
outgoing_connection:
on_client_connected:
- lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str());
on_client_disconnected:
- lambda: ESP_LOGI("timing", "client disconnected at %u ms", (unsigned) millis());
ota:
- platform: esphome
text_sensor:
- platform: homeassistant
id: ha_state
entity_id: light.ENTITY
on_value:
- lambda: ESP_LOGI("timing", "first HA state '%s' at %u ms", x.c_str(), (unsigned) millis());
deep_sleep:
id: ds
run_duration: 15s
sleep_duration: 15s
@@ -0,0 +1,66 @@
# Restoring globals with every storage choice; each boot bumps the counters, logs them with the reset reason, saves
# and syncs; odd boots soft restart, even boots deep sleep (ESP32) or idle (D1 mini). The host's reset pulse and a
# power cut give the other reset kinds.
logger:
level: DEBUG
preferences:
flash_write_interval: 1s
globals:
- id: cnt_rtc
type: int
restore_value: true
storage: rtc
initial_value: "0"
- id: cnt_flash
type: int
restore_value: true
storage: flash
initial_value: "0"
- id: cnt_default
type: int
restore_value: true
initial_value: "0"
- id: str_rtc
type: std::string
restore_value: true
storage: rtc
max_restore_data_length: 24
initial_value: '"fresh"'
- id: arr_rtc
type: uint8_t[2]
restore_value: true
storage: rtc
initial_value: "{0, 0}"
esphome:
on_boot:
priority: -100
then:
- lambda: |-
id(cnt_rtc) += 1;
id(cnt_flash) += 1;
id(cnt_default) += 1;
id(arr_rtc)[0] += 1;
id(arr_rtc)[1] += 2;
char buf[24];
snprintf(buf, sizeof(buf), "boot%d", id(cnt_rtc));
ESP_LOGI("gl", "restored rtc=%d flash=%d default=%d str='%s' arr=%u/%u (values before this boot's bump: one less)",
id(cnt_rtc) - 1, id(cnt_flash) - 1, id(cnt_default) - 1, id(str_rtc).c_str(), id(arr_rtc)[0] - 1, id(arr_rtc)[1] - 2);
id(str_rtc) = buf;
- delay: 2s
- lambda: |-
global_preferences->sync();
ESP_LOGI("gl", "saved rtc=%d flash=%d default=%d str='%s' arr=%u/%u", id(cnt_rtc), id(cnt_flash), id(cnt_default),
id(str_rtc).c_str(), id(arr_rtc)[0], id(arr_rtc)[1]);
- if:
condition:
lambda: 'return id(cnt_rtc) % 2 == 1;'
then:
- delay: 3s
- lambda: 'ESP_LOGI("gl", "odd boot, soft restart now"); App.safe_reboot();'
button:
- platform: restart
name: Restart
@@ -0,0 +1,145 @@
# shared part: two Home Assistant lights mirrored on the device plus buttons that drive them
esphome:
# A local change made before the light core's first write (b19a776 sends it instead of merging it).
on_boot:
priority: -100
then:
- lambda: 'ESP_LOGI("halight", "on_boot turning B on at 35 percent before the first write");'
- light.turn_on:
id: bulb_b
brightness: 35%
logger:
level: VERBOSE
logs:
api: DEBUG
api.connection: DEBUG
api.service: DEBUG
wifi: DEBUG
scheduler: DEBUG
component: DEBUG
sensor: DEBUG
mdns: DEBUG
ota: DEBUG
socket: DEBUG
esp-idf: DEBUG
preferences: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
light:
- platform: homeassistant
id: bulb_a
name: Mirror A
entity_id: light.ENTITY_A
report_timeout: 5s
on_turn_on:
- logger.log: "bulb_a on_turn_on"
on_turn_off:
- logger.log: "bulb_a on_turn_off"
- platform: homeassistant
id: bulb_b
name: Mirror B
entity_id: light.ENTITY_B
report_timeout: 5s
button:
- platform: template
name: A warm 40
id: a_warm_40
on_press:
- light.turn_on:
id: bulb_a
brightness: 40%
color_temperature: 370 mireds
- platform: template
name: A cool 100
on_press:
- light.turn_on:
id: bulb_a
brightness: 100%
color_temperature: 153 mireds
- platform: template
name: A red
on_press:
- light.turn_on:
id: bulb_a
brightness: 80%
red: 100%
green: 0%
blue: 0%
- platform: template
name: A green dim
on_press:
- light.turn_on:
id: bulb_a
brightness: 20%
red: 0%
green: 100%
blue: 0%
- platform: template
name: A brightness only 60
on_press:
- light.turn_on:
id: bulb_a
brightness: 60%
- platform: template
name: A transition 100
on_press:
- light.turn_on:
id: bulb_a
brightness: 100%
transition_length: 3s
- platform: template
name: A flash
on_press:
- light.turn_on:
id: bulb_a
flash_length: 1s
- platform: template
name: A off
on_press:
- light.turn_off: bulb_a
- platform: template
name: A toggle
on_press:
- light.toggle: bulb_a
- platform: template
name: B warm 50
on_press:
- light.turn_on:
id: bulb_b
brightness: 50%
color_temperature: 300 mireds
- platform: template
name: B off
on_press:
- light.turn_off: bulb_b
- platform: template
name: Log state
on_press:
- lambda: |-
auto v = id(bulb_a).current_values;
ESP_LOGI("halight", "A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f",
v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(),
v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(),
id(bulb_b).current_values.get_brightness());
interval:
- interval: 30s
then:
- lambda: |-
auto v = id(bulb_a).current_values;
ESP_LOGI("halight", "alive A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f",
v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(),
v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(),
id(bulb_b).current_values.get_brightness());
@@ -0,0 +1,55 @@
# Mains build (no deep sleep): outgoing API connection timing after a Wi-Fi drop, and the loop cost while a
# connect is pending or the network is down.
esphome:
name: wifi-drop-dut
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
on_connect:
- lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis());
on_disconnect:
- lambda: ESP_LOGI("timing", "wifi disconnected at %u ms", (unsigned) millis());
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
outgoing_connection:
delay: 2s
on_client_connected:
- lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str());
on_client_disconnected:
- lambda: ESP_LOGI("timing", "client disconnected at %u ms (%s)", (unsigned) millis(), client_info.c_str());
ota:
- platform: esphome
debug:
update_interval: 5s
sensor:
- platform: debug
loop_time:
name: Loop Time
on_value:
- lambda: ESP_LOGI("timing", "loop time max %.0f ms over the last 5 s at %u ms", x, (unsigned) millis());
button:
- platform: template
name: Wifi cycle
on_press:
- lambda: ESP_LOGI("timing", "wifi cycle disabling at %u ms", (unsigned) millis());
- wifi.disable:
- delay: 4s
- lambda: ESP_LOGI("timing", "wifi cycle enabling at %u ms", (unsigned) millis());
- wifi.enable:
@@ -0,0 +1,76 @@
# Music Assistant target: the plain USB audio template plus the CAST-1 style sendspin wiring (resampler speakers
# into a mixer whose inputs never time out, speaker_source media player, sendspin hub and sources).
packages:
base: !include usb-audio-waveshare-s3-eth-dut.yaml
# Logs over the API only: a DEBUG line per report would saturate UART0 and trip the task watchdog.
logger:
baud_rate: 0
speaker:
- id: !extend usb_speaker
buffer_size: 28800
write_timeout: 50ms
- platform: mixer
id: mixer_speaker_id
output_speaker: usb_speaker
num_channels: 2
task_stack_in_psram: true
source_speakers:
- id: announcement_mixer_input
timeout: never
- id: media_mixer_input
timeout: never
- platform: resampler
id: announcement_resampling_speaker
output_speaker: announcement_mixer_input
sample_rate: 48000
bits_per_sample: 16
- platform: resampler
id: media_resampling_speaker
output_speaker: media_mixer_input
sample_rate: 48000
bits_per_sample: 16
sendspin:
id: sendspin_hub
# PSRAM stack skews time sync; keep internal
task_stack_in_psram: false
manufacturer: Waveshare
model: ESP32-S3-ETH USB audio
firmware_version: "0.1"
unpaired_access: true
media_source:
- platform: sendspin
id: sendspin_source
- platform: audio_http
id: http_media_source
buffer_size: 500000
- platform: audio_http
id: http_announcement_source
buffer_size: 250000
media_player:
- platform: sendspin
id: sendspin_group_media_player
name: Sendspin Player
- platform: speaker_source
id: external_media_player
name: Player
volume_increment: 0.05
announcement_pipeline:
format: FLAC
num_channels: 1
sample_rate: 48000
speaker: announcement_resampling_speaker
sources:
- http_announcement_source
media_pipeline:
speaker: media_resampling_speaker
format: FLAC
num_channels: 2
sample_rate: 48000
sources:
- sendspin_source
- http_media_source
@@ -0,0 +1,142 @@
# Waveshare ESP32-S3-ETH: USB host for a speakerphone (usb_audio), logs over Ethernet, the USB port is the host port
esphome:
name: usb-audio-dut
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: UART0
logs:
usb_host: DEBUG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
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
on_data:
- lambda: |-
id(mic_bytes) += x.size();
id(mic_chunks) += 1;
speaker:
- platform: usb_audio
id: usb_speaker
usb_audio_id: usb_audio_id
sample_rate: 48000
bits_per_sample: 24bit
num_channels: 2
buffer_size: 9600
write_timeout: 20ms
globals:
- id: beep_left
type: int
initial_value: "0"
- id: beep_written
type: uint32_t
initial_value: "0"
- id: beep_short
type: uint32_t
initial_value: "0"
- id: beep_phase
type: float
initial_value: "0"
- id: mic_bytes
type: uint32_t
initial_value: "0"
- id: mic_chunks
type: uint32_t
initial_value: "0"
button:
- platform: template
name: Mic start
on_press:
- microphone.capture: usb_mic
- platform: template
name: Mic stop
on_press:
- microphone.stop_capture: usb_mic
- platform: template
name: Beep
on_press:
- lambda: |-
// 2 s of a 1 kHz tone at 48 kHz 24-bit stereo, fed 10 ms at a time from the interval below
id(beep_left) = 200;
id(beep_written) = 0;
id(beep_short) = 0;
id(beep_phase) = 0.0f;
id(usb_speaker).start();
ESP_LOGI("uatest", "beep started");
- platform: template
name: Speaker stop
on_press:
- speaker.stop: usb_speaker
- platform: template
name: Restart
on_press:
- lambda: App.safe_reboot();
interval:
- interval: 8ms
then:
- lambda: |-
if (id(beep_left) <= 0) return;
// 10 ms of 48 kHz stereo 24-bit samples, 3 bytes little-endian per sample
static uint8_t buf[480 * 2 * 3];
float phase = id(beep_phase);
for (int i = 0; i < 480; i++) {
int32_t s = (int32_t) (1500000.0f * sinf(phase));
phase += 2.0f * 3.14159265f * 1000.0f / 48000.0f;
if (phase > 6.2831853f) phase -= 6.2831853f;
for (int c = 0; c < 2; c++) {
uint8_t *o = buf + (i * 2 + c) * 3;
o[0] = s & 0xFF; o[1] = (s >> 8) & 0xFF; o[2] = (s >> 16) & 0xFF;
}
}
size_t n = id(usb_speaker).play(buf, sizeof(buf), 0);
if (n == 0) { id(beep_short) += 1; return; } // not accepted yet, try the same chunk next time
id(beep_phase) = phase;
id(beep_written) += n;
id(beep_left) -= 1;
if (id(beep_left) == 0) ESP_LOGI("uatest", "beep done: %u bytes queued, %u retries", (unsigned) id(beep_written), (unsigned) id(beep_short));
- interval: 5s
then:
- lambda: |-
ESP_LOGI("uatest", "mic capturing=%d bytes=%u chunks=%u | speaker running=%d | heap free %u",
id(usb_mic).is_running(), (unsigned) id(mic_bytes), (unsigned) id(mic_chunks),
id(usb_speaker).is_running(), (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
+2 -1
View File
@@ -313,6 +313,7 @@ jobs:
- "3.12"
- "3.13"
- "3.14"
- "3.15"
os:
- ubuntu-latest
- macOS-latest
@@ -629,7 +630,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
with:
run: |
. venv/bin/activate
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0
uses: esphome/workflows/.github/workflows/lock.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # 2026.10.2
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main
uses: esphome/workflows/.github/workflows/stale.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.17.0
rev: v0.17.1
hooks:
# Run the linter.
- id: ruff
+3 -1
View File
@@ -133,7 +133,7 @@ esphome/components/climate/* @esphome/core
esphome/components/climate_ir/* @glmnet
esphome/components/cm1106/* @andrewjswan
esphome/components/color_temperature/* @jesserockz
esphome/components/combination/* @Cat-Ion @kahrendt
esphome/components/combination/* @kahrendt
esphome/components/const/* @esphome/core
esphome/components/coolix/* @glmnet
esphome/components/copy/* @OttoWinter
@@ -602,6 +602,7 @@ esphome/components/ssd1331_spi/* @kbx81
esphome/components/ssd1351_base/* @kbx81
esphome/components/ssd1351_spi/* @kbx81
esphome/components/st7123/* @miniskipper
esphome/components/st7305/* @clydebarrow
esphome/components/st7567_base/* @latonita
esphome/components/st7567_i2c/* @latonita
esphome/components/st7567_spi/* @latonita
@@ -684,6 +685,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
+1
View File
@@ -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()
+11 -3
View File
@@ -430,12 +430,20 @@ def _apply_function(
) -> MockObj:
"""Emit the generated function and declare ``id_`` as the ``ApplyAction`` or
``ApplyCondition`` templated on it, so ``play()`` calls it directly."""
fn = cg.static_function(
f"esphome__{id_.id}__fn", return_type, lambda_args, statements
)
fn = apply_static_function(id_, return_type, lambda_args, statements)
return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg))
def apply_static_function(
id_: ID,
return_type: SafeExpType,
params: TemplateArgsType,
statements: list[str],
) -> MockObj:
"""Emit the static function generated for ``id_`` and return an expression naming it."""
return cg.static_function(f"esphome__{id_.id}__fn", return_type, params, statements)
async def _render_values(
name: str,
target: str,
+6 -6
View File
@@ -142,7 +142,7 @@ message HelloResponse {
// A string identifying the server (ESP); like client info this may be empty
// and only exists for debugging/logging purposes.
// Currently set to ESPHOME_VERSION string literal.
string server_info = 3 [(max_data_length) = 32, (force) = true];
string server_info = 3 [(max_data_length) = 32, (force) = true, (progmem) = true];
// The name of the server (App.get_name() - device hostname)
// max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname)
@@ -278,7 +278,7 @@ message DeviceInfoResponse {
string mac_address = 3 [(max_data_length) = 17, (force) = true];
// A string describing the ESPHome version. For example "1.10.0"
string esphome_version = 4 [(max_data_length) = 32, (force) = true];
string esphome_version = 4 [(max_data_length) = 32, (force) = true, (progmem) = true];
// A string describing the date of compilation, this is generated by the compiler
// and therefore may not be in the same format all the time.
@@ -287,14 +287,14 @@ message DeviceInfoResponse {
// The model of the board. For example NodeMCU
// max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas)
string model = 6 [(max_data_length) = 127, (force) = true];
string model = 6 [(max_data_length) = 127, (force) = true, (progmem) = true];
bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"];
// The esphome project details if set
// max_data_length matches core/config.PROJECT_MAX_LENGTH
string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"];
string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"];
string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME", (progmem) = true];
string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME", (progmem) = true];
uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"];
@@ -304,7 +304,7 @@ message DeviceInfoResponse {
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
string manufacturer = 12 [(max_data_length) = 20, (force) = true, (progmem) = true];
string friendly_name = 13 [(max_data_length) = 120, (force) = true];
+13 -39
View File
@@ -28,6 +28,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
@@ -84,8 +85,6 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS *
// 28-30s. See https://github.com/esphome/esphome/issues/14999
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000;
static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION);
// Cross-validate C++ constants against proto max_data_length annotations in api.proto
static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17,
"Update max_data_length for mac_address/bluetooth_mac_address in api.proto");
@@ -1899,6 +1898,11 @@ void APIConnection::complete_authentication_() {
#endif
}
// A flash string on ESP8266, a mergeable literal elsewhere; #undef after the device info reply
#define PROGMEM_STRING_REF(s) ProgmemStringRef(ESPHOME_PSTR(s), sizeof(s) - 1)
// One flash copy for the hello and device info replies
static ProgmemStringRef esphome_version_ref() { return PROGMEM_STRING_REF(ESPHOME_VERSION); }
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Copy client name with truncation if needed (set_client_name handles truncation)
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
@@ -1914,7 +1918,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
resp.api_version_major = 1;
resp.api_version_minor = 19;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.server_info = esphome_version_ref();
resp.name = StringRef(App.get_name());
#ifdef USE_PROVISIONING
@@ -1969,14 +1973,13 @@ bool APIConnection::send_device_info_response_() {
format_mac_addr_upper(mac, mac_address);
resp.mac_address = StringRef(mac_address);
resp.esphome_version = ESPHOME_VERSION_REF;
resp.esphome_version = esphome_version_ref();
// Stack buffer for build time string
char build_time_str[Application::BUILD_TIME_STR_SIZE];
App.get_build_time_string(build_time_str);
resp.compilation_time = StringRef(build_time_str);
// Manufacturer string - define once, handle ESP8266 PROGMEM separately
#if defined(USE_ESP8266) || defined(USE_ESP32)
#define ESPHOME_MANUFACTURER "Espressif"
#elif defined(USE_RP2)
@@ -1993,48 +1996,19 @@ bool APIConnection::send_device_info_response_() {
#define ESPHOME_MANUFACTURER "Host"
#endif
#ifdef USE_ESP8266
// ESP8266 requires PROGMEM for flash storage, copy to stack for memcpy compatibility
static const char MANUFACTURER_PROGMEM[] PROGMEM = ESPHOME_MANUFACTURER;
char manufacturer_buf[sizeof(MANUFACTURER_PROGMEM)];
memcpy_P(manufacturer_buf, MANUFACTURER_PROGMEM, sizeof(MANUFACTURER_PROGMEM));
resp.manufacturer = StringRef(manufacturer_buf, sizeof(MANUFACTURER_PROGMEM) - 1);
#else
static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER);
resp.manufacturer = MANUFACTURER;
#endif
resp.manufacturer = PROGMEM_STRING_REF(ESPHOME_MANUFACTURER);
static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto");
#undef ESPHOME_MANUFACTURER
#ifdef USE_ESP8266
static const char MODEL_PROGMEM[] PROGMEM = ESPHOME_BOARD;
char model_buf[sizeof(MODEL_PROGMEM)];
memcpy_P(model_buf, MODEL_PROGMEM, sizeof(MODEL_PROGMEM));
resp.model = StringRef(model_buf, sizeof(MODEL_PROGMEM) - 1);
#else
static constexpr auto MODEL = StringRef::from_lit(ESPHOME_BOARD);
resp.model = MODEL;
#endif
resp.model = PROGMEM_STRING_REF(ESPHOME_BOARD);
#ifdef USE_DEEP_SLEEP
resp.has_deep_sleep = deep_sleep::global_has_deep_sleep;
#endif
#ifdef ESPHOME_PROJECT_NAME
#ifdef USE_ESP8266
static const char PROJECT_NAME_PROGMEM[] PROGMEM = ESPHOME_PROJECT_NAME;
static const char PROJECT_VERSION_PROGMEM[] PROGMEM = ESPHOME_PROJECT_VERSION;
char project_name_buf[sizeof(PROJECT_NAME_PROGMEM)];
char project_version_buf[sizeof(PROJECT_VERSION_PROGMEM)];
memcpy_P(project_name_buf, PROJECT_NAME_PROGMEM, sizeof(PROJECT_NAME_PROGMEM));
memcpy_P(project_version_buf, PROJECT_VERSION_PROGMEM, sizeof(PROJECT_VERSION_PROGMEM));
resp.project_name = StringRef(project_name_buf, sizeof(PROJECT_NAME_PROGMEM) - 1);
resp.project_version = StringRef(project_version_buf, sizeof(PROJECT_VERSION_PROGMEM) - 1);
#else
static constexpr auto PROJECT_NAME = StringRef::from_lit(ESPHOME_PROJECT_NAME);
static constexpr auto PROJECT_VERSION = StringRef::from_lit(ESPHOME_PROJECT_VERSION);
resp.project_name = PROJECT_NAME;
resp.project_version = PROJECT_VERSION;
#endif
resp.project_name = PROGMEM_STRING_REF(ESPHOME_PROJECT_NAME);
resp.project_version = PROGMEM_STRING_REF(ESPHOME_PROJECT_VERSION);
#endif
#undef PROGMEM_STRING_REF
#ifdef USE_WEBSERVER
resp.webserver_port = USE_WEBSERVER_PORT;
#endif
+6 -6
View File
@@ -31,7 +31,7 @@ uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos P
const auto &msg = *static_cast<const HelloResponse *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info);
pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.name);
return pos;
}
@@ -117,17 +117,17 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__
const auto &msg = *static_cast<const DeviceInfoResponse *>(self);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version);
pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, msg.compilation_time);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model);
pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model);
#ifdef USE_DEEP_SLEEP
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.has_deep_sleep);
#endif
#ifdef ESPHOME_PROJECT_NAME
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name);
pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name);
#endif
#ifdef ESPHOME_PROJECT_NAME
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version);
pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version);
#endif
#ifdef USE_WEBSERVER
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.webserver_port);
@@ -135,7 +135,7 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__
#ifdef USE_BLUETOOTH_PROXY
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.bluetooth_proxy_feature_flags);
#endif
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer);
pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, msg.friendly_name);
#ifdef USE_VOICE_ASSISTANT
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.voice_assistant_feature_flags);
+6 -6
View File
@@ -466,7 +466,7 @@ class HelloResponse final : public ProtoMessage {
#endif
uint32_t api_version_major{0};
uint32_t api_version_minor{0};
StringRef server_info{};
ProgmemStringRef server_info{};
StringRef name{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -608,17 +608,17 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
StringRef name{};
StringRef mac_address{};
StringRef esphome_version{};
ProgmemStringRef esphome_version{};
StringRef compilation_time{};
StringRef model{};
ProgmemStringRef model{};
#ifdef USE_DEEP_SLEEP
bool has_deep_sleep{false};
#endif
#ifdef ESPHOME_PROJECT_NAME
StringRef project_name{};
ProgmemStringRef project_name{};
#endif
#ifdef ESPHOME_PROJECT_NAME
StringRef project_version{};
ProgmemStringRef project_version{};
#endif
#ifdef USE_WEBSERVER
uint32_t webserver_port{0};
@@ -626,7 +626,7 @@ class DeviceInfoResponse final : public ProtoMessage {
#ifdef USE_BLUETOOTH_PROXY
uint32_t bluetooth_proxy_feature_flags{0};
#endif
StringRef manufacturer{};
ProgmemStringRef manufacturer{};
StringRef friendly_name{};
#ifdef USE_VOICE_ASSISTANT
uint32_t voice_assistant_feature_flags{0};
+6 -6
View File
@@ -970,7 +970,7 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HelloResponse"));
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
dump_field(out, ESPHOME_PSTR("server_info"), this->server_info);
dump_progmem_string_field(out, ESPHOME_PSTR("server_info"), this->server_info);
dump_field(out, ESPHOME_PSTR("name"), this->name);
return out.c_str();
}
@@ -1021,17 +1021,17 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceInfoResponse"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
dump_field(out, ESPHOME_PSTR("esphome_version"), this->esphome_version);
dump_progmem_string_field(out, ESPHOME_PSTR("esphome_version"), this->esphome_version);
dump_field(out, ESPHOME_PSTR("compilation_time"), this->compilation_time);
dump_field(out, ESPHOME_PSTR("model"), this->model);
dump_progmem_string_field(out, ESPHOME_PSTR("model"), this->model);
#ifdef USE_DEEP_SLEEP
dump_field(out, ESPHOME_PSTR("has_deep_sleep"), this->has_deep_sleep);
#endif
#ifdef ESPHOME_PROJECT_NAME
dump_field(out, ESPHOME_PSTR("project_name"), this->project_name);
dump_progmem_string_field(out, ESPHOME_PSTR("project_name"), this->project_name);
#endif
#ifdef ESPHOME_PROJECT_NAME
dump_field(out, ESPHOME_PSTR("project_version"), this->project_version);
dump_progmem_string_field(out, ESPHOME_PSTR("project_version"), this->project_version);
#endif
#ifdef USE_WEBSERVER
dump_field(out, ESPHOME_PSTR("webserver_port"), this->webserver_port);
@@ -1039,7 +1039,7 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_BLUETOOTH_PROXY
dump_field(out, ESPHOME_PSTR("bluetooth_proxy_feature_flags"), this->bluetooth_proxy_feature_flags);
#endif
dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer);
dump_progmem_string_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer);
dump_field(out, ESPHOME_PSTR("friendly_name"), this->friendly_name);
#ifdef USE_VOICE_ASSISTANT
dump_field(out, ESPHOME_PSTR("voice_assistant_feature_flags"), this->voice_assistant_feature_flags);
+7 -2
View File
@@ -23,6 +23,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
ApplyEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -46,6 +47,7 @@ DEVICE_CLASSES = [
button_ns = cg.esphome_ns.namespace("button")
Button = button_ns.class_("Button", cg.EntityBase)
ButtonPtr = Button.operator("ptr")
ApplyButton = button_ns.class_("ApplyButton", Button)
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
@@ -155,11 +157,14 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj:
return var
APPLY_ENTITY = ApplyEntity(ApplyButton, ((ButtonPtr, "entity"),))
def sub_buttons(
config: ConfigType, *, parent: MockObj | ID | None = None
) -> SubEntities:
"""Return a SubEntities bound to new_button."""
return SubEntities(new_button, config, parent)
"""Return a SubEntities bound to new_button; ``apply`` statements can use ``entity``."""
return SubEntities(new_button, config, parent, APPLY_ENTITY)
BUTTON_PRESS_SCHEMA = maybe_simple_id(
+10
View File
@@ -46,4 +46,14 @@ class Button : public EntityBase {
LazyCallbackManager<void()> press_callback_{};
};
/// Calls the codegen-generated Fn(this) on a press; the hub and any publish are baked into Fn.
template<auto Fn> class ApplyButton final : public Button {
public:
// Not "= default": placement new would zero-fill .bss that is already zero.
ApplyButton() {}
protected:
void press_action() override { Fn(this); }
};
} // namespace esphome::button
@@ -58,8 +58,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
request->send(stream);
}
void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
const auto &ssid = request->arg("ssid");
const auto &psk = request->arg("psk");
const auto &ssid = request->arg(ESPHOME_F("ssid"));
const auto &psk = request->arg(ESPHOME_F("psk"));
ESP_LOGI(TAG,
"Requested WiFi Settings Change:\n"
" SSID='%s'\n"
@@ -117,11 +117,7 @@ void CaptivePortal::handleRequest(AsyncWebServerRequest *req) {
// All other requests get the captive portal page
// This includes OS captive portal detection endpoints which will trigger
// the captive portal when they don't receive their expected responses
#ifndef USE_ESP8266
auto *response = req->beginResponse(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ));
#else
auto *response = req->beginResponse_P(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ));
#endif
#ifdef USE_CAPTIVE_PORTAL_GZIP
response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip"));
#else
+2 -2
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_SOURCES
import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
@@ -16,7 +17,7 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
CODEOWNERS = ["@kahrendt"]
combination_ns = cg.esphome_ns.namespace("combination")
@@ -59,7 +60,6 @@ CONF_MEDIAN = "median"
CONF_MIN = "min"
CONF_MOST_RECENTLY_UPDATED = "most_recently_updated"
CONF_PROCESS_STD_DEV = "process_std_dev"
CONF_SOURCES = "sources"
CONF_STD_DEV = "std_dev"
+1
View File
@@ -69,6 +69,7 @@ CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SERIAL_NUMBER = "serial_number"
CONF_SHA256 = "sha256"
CONF_SLOT = "slot"
CONF_SOURCES = "sources"
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
CONF_STOP_BITS = "stop_bits"
CONF_TARGET_COUNT = "target_count"
@@ -5,6 +5,10 @@
namespace esphome::cst9220 {
// Id 0 is the base Touchscreen class touch timeout.
static constexpr uint32_t REREAD_TIMEOUT_ID = 1;
static constexpr uint32_t REREAD_DELAY_MS = 20;
void CST9220Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
@@ -92,15 +96,15 @@ void CST9220Touchscreen::update_touches() {
return;
}
// Acknowledge the report so the controller can prepare the next one.
uint8_t ack = TOUCH_ACK;
this->write_register16(REG_TOUCH_DATA, &ack, 1);
// A valid report carries the ACK marker at offset 6; offset 0 holds the first
// point and must be neither the ACK marker nor empty. Anything else means no
// valid touch data this cycle, which we report as zero touches (not a skip).
if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK)
// A valid report carries the marker at offset 6. Writing an acknowledge back makes the controller return
// invalid frames during a long press, so none is written. An invalid frame keeps the previous points; while
// a finger is down, read again shortly so a lost release frame does not leave the touch held.
if (data[6] != TOUCH_ACK) {
this->skip_update_ = true;
if (!this->touches_.empty())
this->set_timeout(REREAD_TIMEOUT_ID, REREAD_DELAY_MS, [this] { this->store_.touched = true; });
return;
}
uint8_t num_touches = data[5] & 0x7F;
if (num_touches > CST9220_MAX_TOUCHES)
@@ -131,9 +131,7 @@ void HomeassistantLight::setup() {
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;
this->import_(this->imported_.state, optional<bool>(on_off == PARSE_ON));
});
server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) {
if (is_unset(value)) {
@@ -144,19 +142,16 @@ void HomeassistantLight::setup() {
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;
this->import_(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;
this->import_(this->imported_.brightness, static_cast<uint8_t>(*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;
this->import_(this->imported_.color_temp_kelvin, *kelvin);
}
});
server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) {
@@ -202,11 +197,19 @@ optional<uint16_t> HomeassistantLight::parse_integer_(const char *name, StringRe
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);
if (changed) {
if (this->echo_pending_) {
this->echo_pending_ = false;
this->cancel_timeout(ECHO_TIMEOUT_ID);
}
// Noted as the report arrives, not when the deferred apply runs: a call read from the same loop
// pass before this report is older than it, one read after it is newer and must not pass for
// the transition the report is held for
this->report_in_transition_ = this->state_->is_transitioning();
if (this->report_in_transition_) {
this->held_hash_ = state_hash(this->state_->remote_values);
}
}
this->report_changed_ |= changed;
this->schedule_apply_();
}
@@ -255,9 +258,8 @@ void HomeassistantLight::apply_imported_() {
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_) {
if (this->report_in_transition_) {
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
@@ -266,7 +268,7 @@ void HomeassistantLight::apply_imported_() {
ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_);
return;
}
this->report_changed_ = false;
this->report_in_transition_ = false;
ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_);
}
@@ -289,15 +291,17 @@ void HomeassistantLight::update_state(LightState *state) {
}
this->initial_seen_ = true;
}
// A call without a transition replaces whatever ran, so a report held for it is older than the call
// A call without a transition replaces whatever ran, so a report held for it, or still waiting to
// be applied, is older than the call
if (!state->is_transitioning()) {
this->report_held_ = false;
this->report_in_transition_ = 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) {
if (!outgoing.state.has_value() || imported.state != outgoing.state) {
return false;
}
if (!*outgoing.state) {
@@ -346,7 +350,7 @@ void HomeassistantLight::write_state(LightState *state) {
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
this->report_in_transition_ = 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) {
@@ -430,10 +434,10 @@ void HomeassistantLight::write_state(LightState *state) {
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 add_number = [&](ProgmemStringRef key, char *buf, size_t size, 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)));
entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, size, 0, "%u", number)));
};
if (!*outgoing.state) {
resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF);
@@ -443,7 +447,7 @@ void HomeassistantLight::write_state(LightState *state) {
// 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);
level_buf, sizeof(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,
@@ -451,7 +455,8 @@ void HomeassistantLight::write_state(LightState *state) {
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);
add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, sizeof(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
@@ -4,6 +4,7 @@
#include "esphome/components/light/light_output.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
@@ -42,6 +43,11 @@ class HomeassistantLight final : public light::LightOutput, public Component {
optional<uint16_t> 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);
/// Takes over a reported attribute, reporting whether it changed
template<typename T> void import_(T &field, const T &value) {
this->on_report_(field != value);
field = value;
}
/// 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
@@ -56,15 +62,15 @@ class HomeassistantLight final : public light::LightOutput, public Component {
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
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_in_transition_ : 1 {false}; // the last changed report, not yet applied, arrived during a transition
uint8_t report_timeout_s_{10};
uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at
};
} // namespace esphome::homeassistant
+4
View File
@@ -549,6 +549,10 @@ bool LD2410Component::handle_ack_data_() {
break;
case CMD_QUERY: { // Query parameters response
// Config values can contain the footer bytes, so wait for the whole 38 byte frame
if (this->buffer_pos_ < 38) {
return false;
}
if (this->buffer_data_[10] != HEADER)
return true; // value head=0xAA
#ifdef USE_NUMBER
+4 -1
View File
@@ -116,7 +116,10 @@ void LD2410SComponent::loop() {
#ifdef USE_LD2410S_PARAMETERS
} else if (this->parameters_dirty_) {
this->parameters_dirty_ = false;
this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_);
// Keep the pause after the last acknowledgement; a start time further ahead than that is long past,
// and after 2^31 ms the run_sequence_() check would read it as the future
const bool in_gap = this->next_send_at_ - now <= COMMAND_GAP_MS;
this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, in_gap ? this->next_send_at_ : now);
#endif
}
}
+1
View File
@@ -683,6 +683,7 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
this->handle_cmd_error(this->cmd_reply_.error);
}
}
this->cmd_active_ = false; // Resend and give-up are done; let loop() read the UART again
return error;
}
+27 -2
View File
@@ -37,6 +37,22 @@ static bool matches(const uint8_t *bytes, const uint8_t *pattern) {
static constexpr uint8_t FRAME_PREFIX_SIZE = 7;
static uint16_t frame_length(const uint8_t *frame) { return encode_uint16(frame[6], frame[5]); }
// First offset after 0 whose bytes, as far as they go, match a header; len when there is none (len >= 1)
static uint8_t next_header_start(const uint8_t *buffer, uint8_t len) {
uint8_t data_header[HEADER_FOOTER_SIZE];
uint8_t cmd_header[HEADER_FOOTER_SIZE];
progmem_memcpy(data_header, DATA_FRAME_HEADER, sizeof(data_header));
progmem_memcpy(cmd_header, CMD_FRAME_HEADER, sizeof(cmd_header));
uint8_t start = 1;
for (; start < len; start++) {
const size_t n = std::min<size_t>(len - start, HEADER_FOOTER_SIZE);
if (std::memcmp(&buffer[start], data_header, n) == 0 || std::memcmp(&buffer[start], cmd_header, n) == 0) {
break;
}
}
return start;
}
static constexpr float RADIANS_TO_DEGREES = 180.0f / std::numbers::pi_v<float>;
void LD2460Component::setup() { this->send_query_(); }
@@ -155,8 +171,17 @@ void LD2460Component::handle_periodic_data_() {
void LD2460Component::readline_(uint8_t readch) {
if (this->buffer_pos_ >= MAX_LINE_LENGTH) {
ESP_LOGW(TAG, "Max frame length exceeded; ignoring");
this->buffer_pos_ = 0;
// Keep the bytes from the next offset that could start a header, so a frame already partly
// buffered behind stray bytes survives; it is still fully validated once its footer arrives
const uint8_t start = next_header_start(this->buffer_data_, this->buffer_pos_);
this->buffer_pos_ -= start;
std::memmove(this->buffer_data_, &this->buffer_data_[start], this->buffer_pos_);
// Header-shaped noise keeps a whole header; warning only otherwise bounds the warning rate
if (this->buffer_pos_ < HEADER_FOOTER_SIZE) {
ESP_LOGW(TAG, "Max frame length exceeded; ignoring");
} else {
ESP_LOGV(TAG, "Max frame length exceeded; kept %u bytes", this->buffer_pos_);
}
}
this->buffer_data_[this->buffer_pos_++] = readch;
+11 -2
View File
@@ -82,6 +82,7 @@ from esphome.const import (
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
ApplyEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -160,6 +161,7 @@ IS_PLATFORM_COMPONENT = True
number_ns = cg.esphome_ns.namespace("number")
Number = number_ns.class_("Number", cg.EntityBase)
NumberPtr = Number.operator("ptr")
ApplyNumber = number_ns.class_("ApplyNumber", Number)
# Triggers
ValueRangeTrigger = number_ns.class_(
@@ -321,11 +323,18 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl
return var
APPLY_ENTITY = ApplyEntity(
ApplyNumber,
((NumberPtr, "entity"), (cg.float_, "value")),
"entity->publish_state(value);",
)
def sub_numbers(
config: ConfigType, *, parent: MockObj | ID | None = None
) -> SubEntities:
"""Return a SubEntities bound to new_number."""
return SubEntities(new_number, config, parent)
"""Return a SubEntities bound to new_number; ``apply`` statements can use ``entity`` and ``value``."""
return SubEntities(new_number, config, parent, APPLY_ENTITY)
NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All(
+10
View File
@@ -54,4 +54,14 @@ class Number : public EntityBase {
LazyCallbackManager<void(float)> state_callback_;
};
/// Calls the codegen-generated Fn(this, value) on a new value; the hub and any publish are baked into Fn.
template<auto Fn> class ApplyNumber final : public Number {
public:
// Not "= default": placement new would zero-fill .bss that is already zero.
ApplyNumber() {}
protected:
void control(float value) override { Fn(this, value); }
};
} // namespace esphome::number
+12 -1
View File
@@ -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
+12 -2
View File
@@ -19,6 +19,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
ApplyEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -35,6 +36,7 @@ IS_PLATFORM_COMPONENT = True
select_ns = cg.esphome_ns.namespace("select")
Select = select_ns.class_("Select", cg.EntityBase)
SelectPtr = Select.operator("ptr")
ApplySelect = select_ns.class_("ApplySelect", Select)
# Conditions
SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition)
@@ -121,11 +123,19 @@ async def new_select(config, *args, options: list[str]):
return var
APPLY_ENTITY = ApplyEntity(
ApplySelect,
((SelectPtr, "entity"), (cg.size_t, "index")),
"entity->publish_state(index);",
(("option", "const char *option = entity->option_at(index);"),),
)
def sub_selects(
config: ConfigType, *, parent: MockObj | ID | None = None
) -> SubEntities:
"""Return a SubEntities bound to new_select."""
return SubEntities(new_select, config, parent)
"""Return a SubEntities bound to new_select; ``apply`` statements can use ``entity``, ``index`` and ``option``."""
return SubEntities(new_select, config, parent, APPLY_ENTITY)
@coroutine_with_priority(CoroPriority.CORE)
+10
View File
@@ -112,4 +112,14 @@ class Select : public EntityBase {
LazyCallbackManager<void(size_t)> state_callback_;
};
/// Calls the codegen-generated Fn(this, index) on a new index; the hub and any publish are baked into Fn.
template<auto Fn> class ApplySelect final : public Select {
public:
// Not "= default": placement new would zero-fill .bss that is already zero.
ApplySelect() {}
protected:
void control(size_t index) override { Fn(this, index); }
};
} // namespace esphome::select
@@ -2,6 +2,7 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import audio, media_player, media_source, speaker
from esphome.components.const import (
CONF_SOURCES,
CONF_VOLUME_INCREMENT,
CONF_VOLUME_INITIAL,
CONF_VOLUME_MAX,
@@ -29,7 +30,6 @@ CONF_ON_MUTE = "on_mute"
CONF_PIPELINE = "pipeline"
CONF_ON_UNMUTE = "on_unmute"
CONF_ON_VOLUME = "on_volume"
CONF_SOURCES = "sources"
speaker_source_ns = cg.esphome_ns.namespace("speaker_source")
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "st7305"
+224
View File
@@ -0,0 +1,224 @@
import importlib
import pkgutil
from esphome import pins
import esphome.codegen as cg
from esphome.components import display, spi
from esphome.components.display import CONF_SHOW_TEST_CARD
from esphome.components.mipi import (
DriverChip,
flatten_sequence,
map_sequence,
model_schema_extractor,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_CS_PIN,
CONF_DATA_RATE,
CONF_DC_PIN,
CONF_HEIGHT,
CONF_ID,
CONF_INIT_SEQUENCE,
CONF_LAMBDA,
CONF_MODEL,
CONF_PAGES,
CONF_RESET_PIN,
CONF_ROTATION,
CONF_WIDTH,
)
from esphome.types import ConfigType
from . import models
DEPENDENCIES = ["spi"]
CONF_COLUMN_START = "column_start"
CONF_COLUMN_END = "column_end"
CONF_PORTRAIT = "portrait"
CONF_ROW_START = "row_start"
CONF_ROW_END = "row_end"
st7305_ns = cg.esphome_ns.namespace("st7305")
ST7305 = st7305_ns.class_("ST7305", display.DisplayBuffer, spi.SPIDevice)
Orientation = st7305_ns.enum("Orientation")
# CUSTOM must be registered before the model modules are imported.
DriverChip("CUSTOM")
for module_info in pkgutil.iter_modules(models.__path__):
importlib.import_module(f".models.{module_info.name}", package=__package__)
MODELS = DriverChip.get_models()
DEFAULT_MODEL = "WAVESHARE-400X300"
def _model_schema(model: DriverChip) -> cv.Schema:
iseqconf = (
cv.Required(CONF_INIT_SEQUENCE)
if model.initsequence is None
else cv.Optional(CONF_INIT_SEQUENCE)
)
return (
display.FULL_DISPLAY_SCHEMA.extend(
spi.spi_device_schema(
cs_pin_required=False,
default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000),
default_mode="MODE0",
)
)
.extend(cv.COMPONENT_SCHEMA)
.extend(
{
cv.GenerateID(): cv.declare_id(ST7305),
cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True),
model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024),
cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024),
cv.Optional(CONF_PORTRAIT): cv.boolean,
cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255),
cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255),
cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255),
cv.Optional(CONF_ROW_END): cv.int_range(min=0, max=255),
iseqconf: cv.ensure_list(map_sequence),
}
)
)
def _resolve(config: ConfigType, model: DriverChip) -> None:
"""Fill in model defaults and validate the geometry and address window."""
if (width := config.get(CONF_WIDTH)) is None:
if (width := model.get_default(CONF_WIDTH, None)) is None:
raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH])
config[CONF_WIDTH] = width
if (height := config.get(CONF_HEIGHT)) is None:
if (height := model.get_default(CONF_HEIGHT, None)) is None:
raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT])
config[CONF_HEIGHT] = height
if config.get(CONF_PORTRAIT) is None:
if (default := model.get_default(CONF_PORTRAIT, None)) is None:
raise cv.Invalid(
"'portrait' is required for this model", path=[CONF_PORTRAIT]
)
config[CONF_PORTRAIT] = default
if config[CONF_PORTRAIT]:
if width % 12 or height % 2:
raise cv.Invalid(
"portrait panels require a width divisible by 12 and a height divisible by 2"
)
columns = width // 12
rows = height // 2
else:
if width % 2 or height % 12:
raise cv.Invalid(
"landscape panels require a width divisible by 2 and a height divisible by 12"
)
columns = height // 12
rows = width // 2
column_start = config.get(
CONF_COLUMN_START, model.get_default(CONF_COLUMN_START, 0)
)
row_start = config.get(CONF_ROW_START, model.get_default(CONF_ROW_START, 0))
column_end = config.get(
CONF_COLUMN_END, model.get_default(CONF_COLUMN_END, column_start + columns - 1)
)
row_end = config.get(
CONF_ROW_END, model.get_default(CONF_ROW_END, row_start + rows - 1)
)
if column_end < column_start or row_end < row_start:
raise cv.Invalid("address window end must not be before its start")
if not (
0 <= column_start <= 255
and 0 <= column_end <= 255
and 0 <= row_start <= 255
and 0 <= row_end <= 255
):
raise cv.Invalid("address window values must be between 0 and 255")
if (column_end - column_start + 1) * (row_end - row_start + 1) * 3 < (
width * height // 8
):
raise cv.Invalid("address window is too small for the display dimensions")
config[CONF_COLUMN_START] = column_start
config[CONF_COLUMN_END] = column_end
config[CONF_ROW_START] = row_start
config[CONF_ROW_END] = row_end
if config.get(CONF_INIT_SEQUENCE) is None:
config[CONF_INIT_SEQUENCE] = model.initsequence
for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")):
if key not in config:
raise cv.Invalid(f"'{label}' is required for this model", path=[key])
def model_schema(config: ConfigType) -> cv.Schema:
"""Return the schema for the model selected in the config."""
return _model_schema(MODELS[config[CONF_MODEL]])
@model_schema_extractor(MODELS, model_schema)
def customise_schema(config: ConfigType) -> ConfigType:
config = cv.Schema(
{
cv.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.one_of(
*MODELS, upper=True
)
},
extra=cv.ALLOW_EXTRA,
)(config)
model = MODELS[config[CONF_MODEL]]
config = _model_schema(model)(config)
_resolve(config, model)
display.add_metadata(
config[CONF_ID],
config[CONF_WIDTH],
config[CONF_HEIGHT],
has_hardware_rotation=False,
byte_order=cv.UNDEFINED,
has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True
or config.get(CONF_PAGES) is not None
or config.get(CONF_LAMBDA) is not None
or config.get(CONF_SHOW_TEST_CARD) is True,
rotation=config.get(CONF_ROTATION, 0),
)
return config
CONFIG_SCHEMA = customise_schema
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
"st7305", require_miso=False, require_mosi=True
)
async def to_code(config: ConfigType) -> None:
sequence = flatten_sequence(config[CONF_INIT_SEQUENCE])
table = cg.shared_progmem_array("st7305_init_sequence", cg.uint8, sequence)
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_MODEL],
config[CONF_WIDTH],
config[CONF_HEIGHT],
Orientation.ORIENTATION_PORTRAIT
if config[CONF_PORTRAIT]
else Orientation.ORIENTATION_LANDSCAPE,
config[CONF_COLUMN_START],
config[CONF_COLUMN_END],
config[CONF_ROW_START],
config[CONF_ROW_END],
table,
len(sequence),
)
await display.register_display(var, config)
await spi.register_spi_device(var, config, write_only=True)
await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN])
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
await display.add_writer(var, config)
@@ -0,0 +1,60 @@
from esphome.components.mipi import DriverChip, delay
# Waveshare ESP32-S3-RLCD-4.2, 4.2" reflective panel. The same controller command set is
# used by the 300x400 ST7306 panels. Values follow the vendor demo code, which is the sequence
# proven on this panel; the datasheet's referential sequence is close but not identical.
DriverChip(
"WAVESHARE-400X300",
width=400,
height=300,
portrait=False,
cs_pin=40,
dc_pin=5,
reset_pin=41,
column_start=0x12,
column_end=0x2A,
row_start=0x00,
row_end=0xC7,
initsequence=(
(0xD6, 0x17, 0x02), # NVM load control
(0xD1, 0x01), # booster enable
(0xC0, 0x11, 0x04), # gate voltage
(0xC1, 0x69, 0x69, 0x69, 0x69), # VSHP
(0xC2, 0x19, 0x19, 0x19, 0x19), # VSLP
(0xC4, 0x4B, 0x4B, 0x4B, 0x4B), # VSHN
(0xC5, 0x19, 0x19, 0x19, 0x19), # VSLN
(0xD8, 0x80, 0xE9), # oscillator
(0xB2, 0x02), # frame rate
(
0xB3,
0xE5,
0xF6,
0x05,
0x46,
0x77,
0x77,
0x77,
0x77,
0x76,
0x45,
), # HPM gate EQ
(0xB4, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45), # LPM gate EQ
(0x62, 0x32, 0x03, 0x1F), # gate timing
(0xB7, 0x13), # source EQ
(0xB0, 0x64), # gate line setting, 400 lines
(0x11,), # sleep out
delay(200),
(0xC9, 0x00), # source voltage select
(0x36, 0x48), # MADCTL: MX=1, DO=1
(0x3A, 0x11), # 1-bit data format, 3 bytes per 24-pixel block
(0xB9, 0x20), # gamma, mono
(0xB8, 0x29), # panel setting
(0x21,), # display inversion on
(0x2A, 0x12, 0x2A), # column address window
(0x2B, 0x00, 0xC7), # row address window
(0x35, 0x00), # tearing effect line on
(0xD0, 0xFF), # auto power down
(0x38,), # high power mode
(0x29,), # display on
),
)
+258
View File
@@ -0,0 +1,258 @@
#include "st7305.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/log.h"
namespace esphome::st7305 {
ESPHOME_LOG_TAG(TAG, "st7305");
void ST7305::setup() {
this->spi_setup();
this->dc_pin_->setup();
if (this->reset_pin_ != nullptr)
this->reset_pin_->setup();
if (!this->init_buffer_())
return;
this->reset_();
}
bool ST7305::init_buffer_() {
this->init_internal_(this->buffer_length_);
if (this->buffer_ == nullptr) {
this->mark_failed(LOG_STR("Buffer allocation failed"));
return false;
}
memset(this->buffer_, 0x00, this->buffer_length_);
this->clear_dirty_();
return true;
}
void ST7305::reset_() {
static constexpr uint32_t RESET_HIGH_MS = 50;
static constexpr uint32_t RESET_LOW_MS = 20;
static constexpr uint32_t RESET_SETTLE_MS = 50;
static constexpr uint32_t SOFT_RESET_MS = 5;
if (this->reset_pin_ == nullptr) {
this->enable();
this->send_command_(0x01); // software reset
this->disable();
this->set_timeout(STARTUP_TIMEOUT_ID, SOFT_RESET_MS, [this]() { this->send_init_sequence_(); });
return;
}
this->reset_pin_->digital_write(true);
this->set_timeout(STARTUP_TIMEOUT_ID, RESET_HIGH_MS, [this]() {
this->reset_pin_->digital_write(false);
this->set_timeout(STARTUP_TIMEOUT_ID, RESET_LOW_MS, [this]() {
this->reset_pin_->digital_write(true);
this->set_timeout(STARTUP_TIMEOUT_ID, RESET_SETTLE_MS, [this]() { this->send_init_sequence_(); });
});
});
}
void ST7305::send_init_sequence_() {
while (this->init_index_ < this->init_sequence_length_) {
if (this->init_sequence_length_ - this->init_index_ < 2) {
this->mark_failed(LOG_STR("Malformed init sequence"));
return;
}
const uint8_t command = progmem_read_byte(&this->init_sequence_[this->init_index_++]);
const uint8_t count = progmem_read_byte(&this->init_sequence_[this->init_index_++]);
if (count == 0xFF) {
// Resume the sequence after the delay instead of blocking the main loop.
this->set_timeout(STARTUP_TIMEOUT_ID, command, [this]() { this->send_init_sequence_(); });
return;
}
if (this->init_sequence_length_ - this->init_index_ < count) {
this->mark_failed(LOG_STR("Malformed init sequence"));
return;
}
this->enable();
this->send_command_(command);
if (count != 0) {
this->dc_pin_->digital_write(true);
for (uint8_t i = 0; i < count; i++)
this->write_byte(progmem_read_byte(&this->init_sequence_[this->init_index_ + i]));
}
this->disable();
this->init_index_ += count;
}
this->finish_init_();
}
void ST7305::finish_init_() {
this->write_full_frame_();
this->clear_dirty_();
this->ready_ = true;
}
void ST7305::write_full_frame_() {
const uint8_t caset[] = {this->column_start_, this->column_end_};
const uint8_t raset[] = {this->row_start_, this->row_end_};
this->enable();
this->send_command_(0x2A);
this->send_data_(caset, sizeof(caset));
this->send_command_(0x2B);
this->send_data_(raset, sizeof(raset));
this->send_command_(0x2C);
this->send_data_(this->buffer_, this->buffer_length_);
this->disable();
}
ST7305::Window ST7305::compute_window_() const {
Window window{};
if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) {
// RASET rows are x pairs; CASET columns are 12-pixel bands of y blocks.
window.stride = static_cast<uint16_t>(this->height_ / 4);
window.outer_start = static_cast<uint16_t>(this->x_low_ >> 1);
window.outer_end = static_cast<uint16_t>(this->x_high_ >> 1);
const int inv_min = this->height_ - 1 - this->y_high_;
const int inv_max = this->height_ - 1 - this->y_low_;
window.block_start = static_cast<uint16_t>((inv_min >> 2) / 3 * 3);
window.block_end =
static_cast<uint16_t>(std::min(((inv_max >> 2) / 3) * 3 + 2, static_cast<int>(window.stride) - 1));
} else {
// RASET rows are y pairs; CASET columns are 12-pixel bands of x blocks.
window.stride = static_cast<uint16_t>(this->width_ / 4);
window.outer_start = static_cast<uint16_t>(this->y_low_ >> 1);
window.outer_end = static_cast<uint16_t>(this->y_high_ >> 1);
const int block_min = this->x_low_ >> 2;
const int block_max = this->x_high_ >> 2;
window.block_start = static_cast<uint16_t>(block_min / 3 * 3);
window.block_end = static_cast<uint16_t>(std::min((block_max / 3) * 3 + 2, static_cast<int>(window.stride) - 1));
}
window.caset_start = static_cast<uint8_t>(this->column_start_ + window.block_start / 3);
window.caset_end = static_cast<uint8_t>(this->column_start_ + window.block_end / 3);
return window;
}
void ST7305::write_window_(const Window &window) {
const uint8_t caset[] = {window.caset_start, window.caset_end};
const uint8_t raset[] = {static_cast<uint8_t>(this->row_start_ + window.outer_start),
static_cast<uint8_t>(this->row_start_ + window.outer_end)};
this->enable();
this->send_command_(0x2A);
this->send_data_(caset, sizeof(caset));
this->send_command_(0x2B);
this->send_data_(raset, sizeof(raset));
this->send_command_(0x2C);
if (window.block_start == 0 && window.block_end == window.stride - 1) {
// Full-height window: every outer unit's data is contiguous with the next.
this->send_data_(this->buffer_ + window.outer_start * window.stride,
static_cast<size_t>(window.outer_end - window.outer_start + 1) * window.stride);
} else {
const size_t length = window.block_end - window.block_start + 1;
for (uint16_t outer = window.outer_start; outer <= window.outer_end; outer++)
this->send_data_(this->buffer_ + outer * window.stride + window.block_start, length);
}
this->disable();
}
void ST7305::update() {
if (!this->ready_)
return;
this->do_update_();
if (!this->is_dirty_())
return;
const Window window = this->compute_window_();
if (window.data_length() > this->buffer_length_ / 2) {
this->write_full_frame_();
} else {
this->write_window_(window);
}
this->clear_dirty_();
}
void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) {
if (x < 0 || y < 0 || x >= this->width_ || y >= this->height_)
return;
size_t index;
uint8_t mask;
if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) {
const int inv_y = this->height_ - 1 - y;
index = static_cast<size_t>(x >> 1) * (this->height_ / 4) + (inv_y >> 2);
mask = static_cast<uint8_t>(1 << (7 - (((inv_y & 3) << 1) | (x & 1))));
} else {
index = static_cast<size_t>(y >> 1) * (this->width_ / 4) + (x >> 2);
mask = static_cast<uint8_t>(1 << (7 - (((x & 3) << 1) | (y & 1))));
}
// The init sequence enables INVON, so a set bit is white and a clear bit is black.
uint8_t &value = this->buffer_[index];
const uint8_t previous = value;
if (color.is_on()) {
value |= mask;
} else {
value &= ~mask;
}
if (value == previous)
return;
this->extend_dirty_(x, y, x, y);
}
void ST7305::fill(Color color) {
if (this->get_clipping().is_set()) {
display::Display::fill(color);
return;
}
if (this->buffer_ == nullptr)
return;
const uint8_t value = color.is_on() ? 0xFF : 0x00;
size_t first = this->buffer_length_;
size_t last = 0;
for (size_t index = 0; index < this->buffer_length_; index++) {
if (this->buffer_[index] == value)
continue;
this->buffer_[index] = value;
if (first == this->buffer_length_)
first = index;
last = index;
}
if (first == this->buffer_length_)
return;
// Widen the changed index range to whole outer units, and the other axis to the full
// display. The extra pixels are current in the buffer, so sending them is harmless.
if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) {
const int stride = this->height_ / 4;
this->extend_dirty_(static_cast<int>(first / stride) * 2, 0, static_cast<int>(last / stride) * 2 + 1,
this->height_ - 1);
} else {
const int stride = this->width_ / 4;
this->extend_dirty_(0, static_cast<int>(first / stride) * 2, this->width_ - 1,
static_cast<int>(last / stride) * 2 + 1);
}
}
void ST7305::extend_dirty_(int x0, int y0, int x1, int y1) {
if (x0 < this->x_low_)
this->x_low_ = x0;
if (x1 > this->x_high_)
this->x_high_ = x1;
if (y0 < this->y_low_)
this->y_low_ = y0;
if (y1 > this->y_high_)
this->y_high_ = y1;
}
void ST7305::dump_config() {
LOG_DISPLAY("", "ST7305", this);
ESP_LOGCONFIG(TAG, " Model: %s", this->model_);
ESP_LOGCONFIG(TAG, " Orientation: %s",
this->orientation_ == Orientation::ORIENTATION_LANDSCAPE ? LOG_STR_LITERAL("landscape")
: LOG_STR_LITERAL("portrait"));
ESP_LOGCONFIG(TAG, " Window: columns %u..%u, rows %u..%u", this->column_start_, this->column_end_, this->row_start_,
this->row_end_);
LOG_PIN(" DC Pin: ", this->dc_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_UPDATE_INTERVAL(this);
}
} // namespace esphome::st7305
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
namespace esphome::st7305 {
enum Orientation : uint8_t {
ORIENTATION_LANDSCAPE = 0,
ORIENTATION_PORTRAIT = 1,
};
/// Sitronix ST7305/ST7306 reflective monochrome LCD, 1 bit per pixel.
///
/// The controller RAM is addressed in 12-column x 2-row blocks (3 bytes per address). With a
/// landscape panel one byte covers 2 pixels in x and 4 in y; with a portrait panel it covers
/// 4 pixels in x and 2 in y.
class ST7305 : public display::DisplayBuffer,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_10MHZ> {
public:
ST7305(const char *model, int width, int height, Orientation orientation, uint8_t column_start, uint8_t column_end,
uint8_t row_start, uint8_t row_end, const uint8_t *init_sequence, size_t init_sequence_length)
: model_(model),
width_(width),
height_(height),
orientation_(orientation),
column_start_(column_start),
column_end_(column_end),
row_start_(row_start),
row_end_(row_end),
buffer_length_(static_cast<size_t>(width) * static_cast<size_t>(height) / 8),
init_sequence_(init_sequence),
init_sequence_length_(init_sequence_length) {}
void setup() override;
void update() override;
void dump_config() override;
void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; }
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
void fill(Color color) override;
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; }
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
protected:
/// An addressable write window. RASET addresses are the outer index (x pairs in landscape,
/// y pairs in portrait); CASET addresses are 12-pixel bands of the inner index.
struct Window {
uint16_t outer_start;
uint16_t outer_end;
uint16_t block_start;
uint16_t block_end;
uint16_t stride;
uint8_t caset_start;
uint8_t caset_end;
size_t data_length() const {
return static_cast<size_t>(this->outer_end - this->outer_start + 1) *
static_cast<size_t>(this->block_end - this->block_start + 1);
}
};
void draw_absolute_pixel_internal(int x, int y, Color color) override;
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
bool init_buffer_();
void reset_();
void send_init_sequence_();
void finish_init_();
void write_full_frame_();
void write_window_(const Window &window);
Window compute_window_() const;
/// Scheduler id for the non-blocking reset and init sequence. The steps are sequential,
/// so one id is enough; a new timeout replaces any pending one.
static constexpr uint32_t STARTUP_TIMEOUT_ID = 0;
void send_command_(uint8_t command) {
this->dc_pin_->digital_write(false);
this->write_byte(command);
}
void send_data_(const uint8_t *data, size_t length) {
this->dc_pin_->digital_write(true);
this->write_array(data, length);
}
bool is_dirty_() const { return this->x_high_ >= this->x_low_ && this->y_high_ >= this->y_low_; }
void clear_dirty_() {
this->x_low_ = this->width_;
this->x_high_ = -1;
this->y_low_ = this->height_;
this->y_high_ = -1;
}
void extend_dirty_(int x0, int y0, int x1, int y1);
GPIOPin *dc_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
const char *model_;
int width_;
int height_;
Orientation orientation_;
uint8_t column_start_;
uint8_t column_end_;
uint8_t row_start_;
uint8_t row_end_;
size_t buffer_length_;
const uint8_t *init_sequence_;
size_t init_sequence_length_;
size_t init_index_{0};
bool ready_{false};
int x_low_{0};
int x_high_{-1};
int y_low_{0};
int y_high_{-1};
};
} // namespace esphome::st7305
+11 -2
View File
@@ -22,6 +22,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
ApplyEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -43,6 +44,7 @@ DEVICE_CLASSES = [
switch_ns = cg.esphome_ns.namespace("switch_")
Switch = switch_ns.class_("Switch", cg.EntityBase)
SwitchPtr = Switch.operator("ptr")
ApplySwitch = switch_ns.class_("ApplySwitch", Switch)
SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode")
@@ -176,11 +178,18 @@ async def new_switch(config, *args):
return var
APPLY_ENTITY = ApplyEntity(
ApplySwitch,
((SwitchPtr, "entity"), (cg.bool_, "state")),
"entity->publish_state(state);",
)
def sub_switches(
config: ConfigType, *, parent: MockObj | ID | None = None
) -> SubEntities:
"""Return a SubEntities bound to new_switch."""
return SubEntities(new_switch, config, parent)
"""Return a SubEntities bound to new_switch; ``apply`` statements can use ``entity`` and ``state``."""
return SubEntities(new_switch, config, parent, APPLY_ENTITY)
SWITCH_ACTION_SCHEMA = maybe_simple_id(
+10
View File
@@ -147,4 +147,14 @@ class Switch : public EntityBase {
#define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj))
void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj);
/// Calls the codegen-generated Fn(this, state) on a state write; the hub and any publish are baked into Fn.
template<auto Fn> class ApplySwitch final : public Switch {
public:
// Not "= default": placement new would zero-fill .bss that is already zero.
ApplySwitch() {}
protected:
void write_state(bool state) override { Fn(this, state); }
};
} // namespace esphome::switch_
+152
View File
@@ -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")
@@ -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))
@@ -0,0 +1,182 @@
#include "usb_audio_microphone.h"
#ifdef USE_USB_AUDIO
#include "esphome/core/log.h"
#include <algorithm>
#include <cinttypes>
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<uint8_t>(~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<USBAudioMicrophone *>(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
@@ -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 <atomic>
#include <vector>
namespace esphome::usb_audio {
class USBAudioMicrophone : public microphone::Microphone,
public Component,
public Parented<USBAudioComponent>,
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<uint8_t> frame_;
// Created once in setup() and parked between streams.
StaticTask mic_task_;
std::atomic<uint8_t> commands_{0};
// start() calls not yet matched by a stop(); capture stops when the last listener stops.
std::atomic<uint8_t> listeners_{0};
std::atomic<bool> running_{false};
std::atomic<bool> task_idle_{true};
// Main loop only.
uint32_t retry_delay_ms_{RETRY_MIN_MS};
};
} // namespace esphome::usb_audio
#endif
@@ -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))
@@ -0,0 +1,359 @@
#include "usb_audio_speaker.h"
#ifdef USE_USB_AUDIO
#include <algorithm>
#include <cinttypes>
#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<uint32_t>(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<uint32_t>(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<uint8_t>(~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<size_t>({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<size_t>({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<uint32_t>(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<uint32_t>(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<int32_t>(static_cast<uint32_t>(now_us) - this->last_taken_us_) <
static_cast<int32_t>(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
@@ -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 <atomic>
namespace esphome::usb_audio {
class USBAudioSpeaker : public speaker::Speaker,
public Component,
public Parented<USBAudioComponent>,
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<uint32_t> ring_bytes_{0};
std::atomic<uint32_t> ring_padding_{0};
// Low 32 bits of esp_timer_get_time() when the driver last took a transfer.
std::atomic<uint32_t> 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<uint8_t> 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<uint8_t> 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<bool> format_mismatch_{false};
bool finishing_{false};
// finish() arrived while the stream was still starting.
bool finish_after_start_{false};
};
} // namespace esphome::usb_audio
#endif
+876
View File
@@ -0,0 +1,876 @@
#include "usb_audio.h"
#ifdef USE_USB_AUDIO
#include <algorithm>
#include <cinttypes>
#include <cmath>
#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<uint8_t>(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 &microphone = 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<uint32_t>(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<uint8_t *>(data), static_cast<uint32_t>(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<uint8_t>(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<Endpoint>(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<int32_t>(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<Endpoint>(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<USBAudioComponent *>(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<uint32_t>(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<USBAudioComponent *>(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<USBAudioComponent *>(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
+239
View File
@@ -0,0 +1,239 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_USB_AUDIO
#include <array>
#include <atomic>
#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<uac_host_device_handle_t> 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<bool> 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<size_t>(endpoint)]; }
const EndpointState &endpoint_(Endpoint endpoint) const { return this->endpoints_[static_cast<size_t>(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<EndpointState, 2> 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<uac_host_device_handle_t, 2> lost_while_opening_{};
// Counted on the USB driver task, logged from loop().
std::atomic<uint32_t> transfer_errors_{0};
StaticQueue_t event_queue_struct_{};
StaticQueue_t op_queue_struct_{};
std::array<uint8_t, EVENT_QUEUE_LENGTH * sizeof(USBAudioEvent)> event_queue_storage_{};
std::array<uint8_t, OP_QUEUE_LENGTH * sizeof(Op)> op_queue_storage_{};
ControlState volume_{};
ControlState mute_{};
std::atomic<uint8_t> speaker_volume_{100};
std::atomic<bool> speaker_muted_{false};
// A CONTROLS op is queued; the pending flags carry the latest values, so one is enough.
std::atomic<bool> controls_queued_{false};
// Set while the worker runs a driver call; platform reads and writes counted in driver_users_ back off.
std::atomic<bool> driver_busy_{false};
std::atomic<uint8_t> driver_users_{0};
bool uac_installed_{false};
};
} // namespace esphome::usb_audio
#endif
+27 -24
View File
@@ -14,155 +14,158 @@ ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource
ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es)
: web_server_(ws), events_(es) {}
#endif
void ListEntitiesIterator::send_all_(void *source, message_generator_t *generator) {
this->events_->deferrable_send_state(source, true, generator);
}
#ifdef USE_BINARY_SENSOR
bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator);
this->send_all_(obj, WebServer::binary_sensor_all_json_generator);
return true;
}
#endif
#ifdef USE_COVER
bool ListEntitiesIterator::on_cover(cover::Cover *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::cover_all_json_generator);
this->send_all_(obj, WebServer::cover_all_json_generator);
return true;
}
#endif
#ifdef USE_FAN
bool ListEntitiesIterator::on_fan(fan::Fan *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::fan_all_json_generator);
this->send_all_(obj, WebServer::fan_all_json_generator);
return true;
}
#endif
#ifdef USE_LIGHT
bool ListEntitiesIterator::on_light(light::LightState *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::light_all_json_generator);
this->send_all_(obj, WebServer::light_all_json_generator);
return true;
}
#endif
#ifdef USE_SENSOR
bool ListEntitiesIterator::on_sensor(sensor::Sensor *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::sensor_all_json_generator);
this->send_all_(obj, WebServer::sensor_all_json_generator);
return true;
}
#endif
#ifdef USE_SWITCH
bool ListEntitiesIterator::on_switch(switch_::Switch *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::switch_all_json_generator);
this->send_all_(obj, WebServer::switch_all_json_generator);
return true;
}
#endif
#ifdef USE_BUTTON
bool ListEntitiesIterator::on_button(button::Button *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::button_all_json_generator);
this->send_all_(obj, WebServer::button_all_json_generator);
return true;
}
#endif
#ifdef USE_TEXT_SENSOR
bool ListEntitiesIterator::on_text_sensor(text_sensor::TextSensor *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_sensor_all_json_generator);
this->send_all_(obj, WebServer::text_sensor_all_json_generator);
return true;
}
#endif
#ifdef USE_LOCK
bool ListEntitiesIterator::on_lock(lock::Lock *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::lock_all_json_generator);
this->send_all_(obj, WebServer::lock_all_json_generator);
return true;
}
#endif
#ifdef USE_VALVE
bool ListEntitiesIterator::on_valve(valve::Valve *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::valve_all_json_generator);
this->send_all_(obj, WebServer::valve_all_json_generator);
return true;
}
#endif
#ifdef USE_CLIMATE
bool ListEntitiesIterator::on_climate(climate::Climate *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::climate_all_json_generator);
this->send_all_(obj, WebServer::climate_all_json_generator);
return true;
}
#endif
#ifdef USE_NUMBER
bool ListEntitiesIterator::on_number(number::Number *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::number_all_json_generator);
this->send_all_(obj, WebServer::number_all_json_generator);
return true;
}
#endif
#ifdef USE_DATETIME_DATE
bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::date_all_json_generator);
this->send_all_(obj, WebServer::date_all_json_generator);
return true;
}
#endif
#ifdef USE_DATETIME_TIME
bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::time_all_json_generator);
this->send_all_(obj, WebServer::time_all_json_generator);
return true;
}
#endif
#ifdef USE_DATETIME_DATETIME
bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::datetime_all_json_generator);
this->send_all_(obj, WebServer::datetime_all_json_generator);
return true;
}
#endif
#ifdef USE_TEXT
bool ListEntitiesIterator::on_text(text::Text *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_all_json_generator);
this->send_all_(obj, WebServer::text_all_json_generator);
return true;
}
#endif
#ifdef USE_SELECT
bool ListEntitiesIterator::on_select(select::Select *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::select_all_json_generator);
this->send_all_(obj, WebServer::select_all_json_generator);
return true;
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::alarm_control_panel_all_json_generator);
this->send_all_(obj, WebServer::alarm_control_panel_all_json_generator);
return true;
}
#endif
#ifdef USE_WATER_HEATER
bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::water_heater_all_json_generator);
this->send_all_(obj, WebServer::water_heater_all_json_generator);
return true;
}
#endif
#ifdef USE_INFRARED
bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::infrared_all_json_generator);
this->send_all_(obj, WebServer::infrared_all_json_generator);
return true;
}
#endif
#ifdef USE_RADIO_FREQUENCY
bool ListEntitiesIterator::on_radio_frequency(radio_frequency::RadioFrequency *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::radio_frequency_all_json_generator);
this->send_all_(obj, WebServer::radio_frequency_all_json_generator);
return true;
}
#endif
#ifdef USE_EVENT
bool ListEntitiesIterator::on_event(event::Event *obj) {
// Null event type, since we are just iterating over entities
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::event_all_json_generator);
this->send_all_(obj, WebServer::event_all_json_generator);
return true;
}
#endif
#ifdef USE_UPDATE
bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) {
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::update_all_json_generator);
this->send_all_(obj, WebServer::update_all_json_generator);
return true;
}
#endif
@@ -4,6 +4,10 @@
#ifdef USE_WEBSERVER
#include "esphome/core/component.h"
#include "esphome/core/component_iterator.h"
namespace esphome::json {
class JsonBuilder;
} // namespace esphome::json
namespace esphome::web_server_idf {
#ifdef USE_ESP32
class AsyncEventSource;
@@ -37,6 +41,9 @@ class ListEntitiesIterator final : public ComponentIterator {
// NOLINTEND(bugprone-macro-parentheses)
protected:
// Same type as message_generator_t, which this header cannot include
void send_all_(void *source, void (*generator)(WebServer *, void *, json::JsonBuilder &));
const WebServer *web_server_;
#ifdef USE_ESP32
esphome::web_server_idf::AsyncEventSource *events_;
+39 -132
View File
@@ -278,28 +278,17 @@ void DeferredUpdateEventSource::loop() {
this->entities_iterator_.try_advance(1);
}
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
void DeferredUpdateEventSource::deferrable_send_state(void *source, bool detail_all,
message_generator_t *message_generator) {
// Skip if no connected clients to avoid unnecessary deferred queue processing
if (this->count() == 0)
return;
// allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing
// up in the web GUI and reduces event load during initial connect
if (!entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all"))
// Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and
// initial connect sends less
if (!detail_all && !this->entities_iterator_.completed())
return;
if (source == nullptr)
return;
if (event_type == nullptr)
return;
if (message_generator == nullptr)
return;
if (0 != strcmp(event_type, "state_detail_all") && 0 != strcmp(event_type, "state")) {
ESP_LOGE(TAG, "Can't defer non-state event");
}
if (!deferred_queue_.empty())
process_deferred_queue_();
if (!deferred_queue_.empty()) {
@@ -332,13 +321,13 @@ bool DeferredUpdateEventSourceList::loop() {
return !this->empty();
}
void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type,
void DeferredUpdateEventSourceList::deferrable_send_state(void *source, bool detail_all,
message_generator_t *message_generator) {
// Skip if no event sources (no connected clients) to avoid unnecessary iteration
if (this->empty())
return;
for (DeferredUpdateEventSource *dues : *this) {
dues->deferrable_send_state(source, event_type, message_generator);
dues->deferrable_send_state(source, detail_all, message_generator);
}
}
@@ -350,7 +339,7 @@ void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t
}
void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServerRequest *request) {
DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events");
DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, ESPHOME_F("/events"));
this->push_back(es);
es->onConnect([this, es](AsyncEventSourceClient *client) { this->on_client_connect_(es); });
@@ -533,11 +522,7 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f
#ifdef USE_WEBSERVER_LOCAL
void WebServer::handle_index_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ));
#else
AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ));
#endif
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ));
#ifdef USE_WEBSERVER_GZIP
response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip"));
#else
@@ -547,13 +532,8 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
}
#elif USE_WEBSERVER_VERSION >= 2
void WebServer::handle_index_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response =
request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE);
#else
AsyncWebServerResponse *response =
request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE);
#endif
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/html"), ESPHOME_WEBSERVER_INDEX_HTML,
ESPHOME_WEBSERVER_INDEX_HTML_SIZE);
// No gzip header here because the HTML file is so small
request->send(response);
}
@@ -625,13 +605,8 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
#ifdef USE_WEBSERVER_CSS_INCLUDE
void WebServer::handle_css_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response =
request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE);
#else
AsyncWebServerResponse *response =
request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE);
#endif
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/css"), ESPHOME_WEBSERVER_CSS_INCLUDE,
ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE);
response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip"));
request->send(response);
}
@@ -639,13 +614,8 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) {
#ifdef USE_WEBSERVER_JS_INCLUDE
void WebServer::handle_js_request(AsyncWebServerRequest *request) {
#ifndef USE_ESP8266
AsyncWebServerResponse *response =
request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE);
#else
AsyncWebServerResponse *response =
request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE);
#endif
AsyncWebServerResponse *response = request->beginResponse(
200, ESPHOME_F("text/javascript"), ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE);
response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip"));
request->send(response);
}
@@ -730,12 +700,13 @@ static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const ch
return request->arg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE;
}
#ifdef USE_SENSOR
void WebServer::on_sensor_update(sensor::Sensor *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", sensor_state_json_generator);
void WebServer::send_state_event_(void *source, const EntityBase *entity, message_generator_t *generator) {
if (!entity->is_internal() || this->include_internal_)
this->events_.deferrable_send_state(source, false, generator);
}
#ifdef USE_SENSOR
void WebServer::on_sensor_update(sensor::Sensor *obj) { this->send_state_event_(obj, sensor_state_json_generator); }
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (sensor::Sensor *obj : App.get_sensors()) {
auto entity_match = match.match_entity(obj);
@@ -777,9 +748,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_
#ifdef USE_TEXT_SENSOR
void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", text_sensor_state_json_generator);
this->send_state_event_(obj, text_sensor_state_json_generator);
}
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (text_sensor::TextSensor *obj : App.get_text_sensors()) {
@@ -837,11 +806,7 @@ static void execute_switch_action(switch_::Switch *obj, SwitchAction action) {
}
}
void WebServer::on_switch_update(switch_::Switch *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", switch_state_json_generator);
}
void WebServer::on_switch_update(switch_::Switch *obj) { this->send_state_event_(obj, switch_state_json_generator); }
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (switch_::Switch *obj : App.get_switches()) {
auto entity_match = match.match_entity(obj);
@@ -930,9 +895,7 @@ void WebServer::button_json_(button::Button *obj, JsonDetail start_config, json:
#ifdef USE_BINARY_SENSOR
void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", binary_sensor_state_json_generator);
this->send_state_event_(obj, binary_sensor_state_json_generator);
}
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) {
@@ -970,11 +933,7 @@ void WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value
#endif
#ifdef USE_FAN
void WebServer::on_fan_update(fan::Fan *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", fan_state_json_generator);
}
void WebServer::on_fan_update(fan::Fan *obj) { this->send_state_event_(obj, fan_state_json_generator); }
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (fan::Fan *obj : App.get_fans()) {
auto entity_match = match.match_entity(obj);
@@ -1049,11 +1008,7 @@ void WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuil
#endif
#ifdef USE_LIGHT
void WebServer::on_light_update(light::LightState *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", light_state_json_generator);
}
void WebServer::on_light_update(light::LightState *obj) { this->send_state_event_(obj, light_state_json_generator); }
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (light::LightState *obj : App.get_lights()) {
auto entity_match = match.match_entity(obj);
@@ -1128,11 +1083,7 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso
#endif
#ifdef USE_COVER
void WebServer::on_cover_update(cover::Cover *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", cover_state_json_generator);
}
void WebServer::on_cover_update(cover::Cover *obj) { this->send_state_event_(obj, cover_state_json_generator); }
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (cover::Cover *obj : App.get_covers()) {
auto entity_match = match.match_entity(obj);
@@ -1202,11 +1153,7 @@ void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::Js
#endif
#ifdef USE_NUMBER
void WebServer::on_number_update(number::Number *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", number_state_json_generator);
}
void WebServer::on_number_update(number::Number *obj) { this->send_state_event_(obj, number_state_json_generator); }
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_numbers()) {
auto entity_match = match.match_entity(obj);
@@ -1276,11 +1223,7 @@ void WebServer::number_json_(number::Number *obj, JsonDetail start_config, json:
#endif
#ifdef USE_DATETIME_DATE
void WebServer::on_date_update(datetime::DateEntity *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", date_state_json_generator);
}
void WebServer::on_date_update(datetime::DateEntity *obj) { this->send_state_event_(obj, date_state_json_generator); }
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_dates()) {
auto entity_match = match.match_entity(obj);
@@ -1335,11 +1278,7 @@ void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, j
#endif // USE_DATETIME_DATE
#ifdef USE_DATETIME_TIME
void WebServer::on_time_update(datetime::TimeEntity *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", time_state_json_generator);
}
void WebServer::on_time_update(datetime::TimeEntity *obj) { this->send_state_event_(obj, time_state_json_generator); }
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_times()) {
auto entity_match = match.match_entity(obj);
@@ -1394,9 +1333,7 @@ void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, j
#ifdef USE_DATETIME_DATETIME
void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", datetime_state_json_generator);
this->send_state_event_(obj, datetime_state_json_generator);
}
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_datetimes()) {
@@ -1452,11 +1389,7 @@ void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_c
#endif // USE_DATETIME_DATETIME
#ifdef USE_TEXT
void WebServer::on_text_update(text::Text *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", text_state_json_generator);
}
void WebServer::on_text_update(text::Text *obj) { this->send_state_event_(obj, text_state_json_generator); }
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_texts()) {
auto entity_match = match.match_entity(obj);
@@ -1515,11 +1448,7 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail
#endif
#ifdef USE_SELECT
void WebServer::on_select_update(select::Select *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", select_state_json_generator);
}
void WebServer::on_select_update(select::Select *obj) { this->send_state_event_(obj, select_state_json_generator); }
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_selects()) {
auto entity_match = match.match_entity(obj);
@@ -1575,11 +1504,7 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st
#endif
#ifdef USE_CLIMATE
void WebServer::on_climate_update(climate::Climate *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", climate_state_json_generator);
}
void WebServer::on_climate_update(climate::Climate *obj) { this->send_state_event_(obj, climate_state_json_generator); }
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_climates()) {
auto entity_match = match.match_entity(obj);
@@ -1762,11 +1687,7 @@ static void execute_lock_action(lock::Lock *obj, LockAction action) {
}
}
void WebServer::on_lock_update(lock::Lock *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", lock_state_json_generator);
}
void WebServer::on_lock_update(lock::Lock *obj) { this->send_state_event_(obj, lock_state_json_generator); }
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (lock::Lock *obj : App.get_locks()) {
auto entity_match = match.match_entity(obj);
@@ -1819,11 +1740,7 @@ void WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail st
#endif
#ifdef USE_VALVE
void WebServer::on_valve_update(valve::Valve *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", valve_state_json_generator);
}
void WebServer::on_valve_update(valve::Valve *obj) { this->send_state_event_(obj, valve_state_json_generator); }
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (valve::Valve *obj : App.get_valves()) {
auto entity_match = match.match_entity(obj);
@@ -1889,9 +1806,7 @@ void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::Js
#ifdef USE_ALARM_CONTROL_PANEL
void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", alarm_control_panel_state_json_generator);
this->send_state_event_(obj, alarm_control_panel_state_json_generator);
}
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) {
@@ -1959,9 +1874,7 @@ void WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel
#ifdef USE_WATER_HEATER
void WebServer::on_water_heater_update(water_heater::WaterHeater *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", water_heater_state_json_generator);
this->send_state_event_(obj, water_heater_state_json_generator);
}
void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (water_heater::WaterHeater *obj : App.get_water_heaters()) {
@@ -2254,11 +2167,7 @@ void WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, Json
#endif
#ifdef USE_EVENT
void WebServer::on_event(event::Event *obj) {
if (!this->include_internal_ && obj->is_internal())
return;
this->events_.deferrable_send_state(obj, "state", event_state_json_generator);
}
void WebServer::on_event(event::Event *obj) { this->send_state_event_(obj, event_state_json_generator); }
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (event::Event *obj : App.get_events()) {
@@ -2311,9 +2220,7 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail
#endif
#ifdef USE_UPDATE
void WebServer::on_update(update::UpdateEntity *obj) {
this->events_.deferrable_send_state(obj, "state", update_state_json_generator);
}
void WebServer::on_update(update::UpdateEntity *obj) { this->send_state_event_(obj, update_state_json_generator); }
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (update::UpdateEntity *obj : App.get_updates()) {
auto entity_match = match.match_entity(obj);
+14 -2
View File
@@ -162,7 +162,10 @@ class DeferredUpdateEventSource final : public AsyncEventSource {
void loop();
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator);
// detail_all events go out while entities are still being listed; state events wait until listing is done
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
};
@@ -176,7 +179,9 @@ class DeferredUpdateEventSourceList final : public std::list<DeferredUpdateEvent
/// Returns true if there are event sources remaining (including pending cleanup).
bool loop();
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator);
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
@@ -544,6 +549,13 @@ class WebServer final : public Component, public AsyncWebHandler {
// Declared here so it packs into the padding after include_internal_
bool expose_log_{true};
// Out of line so the per-domain update callbacks stay small
// source stays the concrete entity pointer, since the generators cast it back to that type
template<typename T> void send_state_event_(T *obj, message_generator_t *generator) {
this->send_state_event_(obj, static_cast<const EntityBase *>(obj), generator);
}
void send_state_event_(void *source, const EntityBase *entity, message_generator_t *generator);
void add_sorting_info_(JsonObject &root, EntityBase *entity);
#ifdef USE_LIGHT
@@ -797,14 +797,13 @@ void AsyncEventSource::try_send_nodefer(const char *message, size_t message_len,
}
}
void AsyncEventSource::deferrable_send_state(void *source, const char *event_type,
message_generator_t *message_generator) {
void AsyncEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) {
// Skip if no connected clients to avoid unnecessary processing
if (this->empty())
return;
for (auto *ses : this->sessions_) {
if (ses->fd_.load() != 0) { // Skip dead sessions
ses->deferrable_send_state(source, event_type, message_generator);
ses->deferrable_send_state(source, detail_all, message_generator);
}
}
}
@@ -1268,24 +1267,13 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t mess
return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent);
}
void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *event_type,
void AsyncEventSourceResponse::deferrable_send_state(void *source, bool detail_all,
message_generator_t *message_generator) {
// allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing
// up in the web GUI and reduces event load during initial connect
if (!this->entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all"))
// Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and
// initial connect sends less
if (!detail_all && !this->entities_iterator_.completed())
return;
if (source == nullptr)
return;
if (event_type == nullptr)
return;
if (message_generator == nullptr)
return;
if (0 != strcmp(event_type, "state_detail_all") && 0 != strcmp(event_type, "state")) {
ESP_LOGE(TAG, "Can't defer non-state event");
}
drain_tail_();
process_deferred_queue_();
@@ -301,7 +301,10 @@ class AsyncEventSourceResponse {
public:
bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator);
// detail_all events go out while entities are still being listed; state events wait until listing is done
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
void loop();
protected:
@@ -413,7 +416,9 @@ class AsyncEventSource : public AsyncWebHandler {
void handleRequest(AsyncWebServerRequest *request) override;
void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0,
uint32_t reconnect = 0);
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator);
void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator);
// The old event name argument would otherwise convert to true
void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete;
/// Returns true if there are sessions remaining (including pending cleanup).
bool loop();
bool empty() { return this->count() == 0; }
+24
View File
@@ -2927,3 +2927,27 @@ def rename_key(
return config
return validator
def ignored_key(key: str, *, removed_in: str, component: str | None = None):
"""Drop ``key``, which no longer has any effect, with a warning that it will be rejected.
Place it before the schema in ``cv.All`` so existing configs keep validating until
``removed_in``. Input that is not a dictionary is returned unchanged.
"""
def validator(config: dict) -> dict:
if not isinstance(config, dict) or key not in config:
return config
prefix = f"[{component}] " if component else ""
_LOGGER.warning(
"%s'%s' has no effect and can be removed. It will be rejected in %s",
prefix,
key,
removed_in,
)
config = config.copy()
del config[key]
return config
return validator
+1 -1
View File
@@ -559,7 +559,7 @@ void Application::get_comment_string(std::span<char, ESPHOME_COMMENT_SIZE_MAX> b
uint32_t Application::get_config_hash() { return ESPHOME_CONFIG_HASH; }
uint32_t Application::get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); }
uint32_t Application::get_config_version_hash() { return ESPHOME_CONFIG_VERSION_HASH; }
time_t Application::get_build_time() { return ESPHOME_BUILD_TIME; }
+4 -3
View File
@@ -5,8 +5,9 @@
//
// This file is only used by static analyzers and IDEs.
#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT
#define ESPHOME_BUILD_TIME 1700000000 // NOLINT
#define ESPHOME_COMMENT_SIZE 1 // NOLINT
#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT
#define ESPHOME_CONFIG_VERSION_HASH 0x9abcdef0U // NOLINT
#define ESPHOME_BUILD_TIME 1700000000 // NOLINT
#define ESPHOME_COMMENT_SIZE 1 // NOLINT
static const char ESPHOME_BUILD_TIME_STR[] = "2024-01-01 00:00:00 +0000";
static const char ESPHOME_COMMENT_STR[] = "";
+5
View File
@@ -557,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)
+94 -9
View File
@@ -2,8 +2,10 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import functools
import logging
from typing import Any
import re
from typing import Any, Literal
from esphome.automation import apply_static_function, parent_ref
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -23,7 +25,7 @@ from esphome.core.config import (
ICON_MAX_LENGTH,
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
from esphome.cpp_generator import MockObj, MockObjClass, RawStatement, add, get_variable
from esphome.cpp_types import App
import esphome.final_validate as fv
from esphome.helpers import (
@@ -468,33 +470,116 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
config[_KEY_ICON_IDX] = icon_idx
ApplyPublish = Literal["before", "after"] | None
@dataclass(frozen=True, slots=True)
class ApplyEntity:
"""A domain's ``Apply*`` class, its generated function's parameters and its publish statement.
``locals`` are (name, declaration) pairs, declared only when the body uses the name.
"""
cls: MockObjClass
params: tuple[tuple[SafeExpType, str], ...]
publish: str | None = None
locals: tuple[tuple[str, str], ...] = ()
_APPLY_DOMAIN = "entity_apply"
@dataclass(frozen=True, slots=True)
class SubEntities:
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
Extra arguments go to new_entity. Returns None only when key is absent, so the result can be
used directly as a condition.
Returns None only when key is absent. ``apply``/``publish`` are documented on ``create``.
"""
new_entity: Callable[..., Awaitable[MockObj]]
config: ConfigType
parent: MockObj | ID | None = None
apply_entity: ApplyEntity | None = None
async def __call__(
self,
key: str,
setter: Callable[[MockObj], Expression],
setter: Callable[[MockObj], Expression] | None,
*args: SafeExpType,
**kwargs: Any,
) -> MockObj | None:
if (conf := self.config.get(key)) is None:
return None
var = await self.new_entity(conf, *args, **kwargs)
if self.parent is not None:
await cg.register_parented(var, self.parent)
add(setter(var))
return await self.create(conf, setter, *args, **kwargs)
async def create(
self,
conf: ConfigType,
setter: Callable[[MockObj], Expression] | None,
*args: SafeExpType,
apply: str | None = None,
publish: ApplyPublish = None,
**kwargs: Any,
) -> MockObj:
"""Create the entity from ``conf``; ``setter`` may be None when the hub keeps no pointer.
With ``apply`` the entity is the domain's ``Apply*`` class and its control runs
``parent->apply``, with ``publish`` ("before"/"after") placing the domain's publish.
"""
entity_id: ID = conf[CONF_ID]
is_apply_class = self.apply_entity is not None and str(entity_id.type) == str(
self.apply_entity.cls
)
if apply is None and is_apply_class:
raise ValueError(f"{entity_id}: {entity_id.type} needs apply=")
if apply is not None and not is_apply_class:
raise ValueError(
f"{entity_id}: apply= needs the domain's Apply class, not {entity_id.type}"
)
if apply is not None and self.parent is None:
raise ValueError(f"{entity_id}: apply= needs a parent")
if publish is not None and (apply is None or self.apply_entity.publish is None):
raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain")
if publish not in (None, "before", "after"):
raise ValueError(f'{entity_id}: publish= must be "before" or "after"')
if apply is None:
var = await self.new_entity(conf, *args, **kwargs)
if self.parent is not None:
await cg.register_parented(var, self.parent)
else:
fn = await self._apply_function(entity_id, apply, publish)
var = await self.new_entity(conf, cg.TemplateArguments(fn), *args, **kwargs)
if setter is not None:
add(setter(var))
return var
async def _apply_function(
self, entity_id: ID, apply: str, publish: ApplyPublish
) -> SafeExpType:
"""Emit the control function once per distinct body, so equal entities share one class."""
parent = self.parent
if isinstance(parent, ID):
parent = await get_variable(parent)
apply_entity = self.apply_entity
body = [f"{parent_ref(parent)}->{apply};"]
if publish == "before":
body.insert(0, apply_entity.publish)
elif publish == "after":
body.append(apply_entity.publish)
for name, decl in apply_entity.locals:
# Declared right before its first use, so calls keep their order.
uses = (i for i, line in enumerate(body) if re.search(rf"\b{name}\b", line))
if (first := next(uses, None)) is not None:
body.insert(first, decl)
functions: dict[str, SafeExpType] = CORE.data.setdefault(_APPLY_DOMAIN, {})
key = "\n".join([*(f"{t} {n}" for t, n in apply_entity.params), *body])
if (fn := functions.get(key)) is None:
fn = functions[key] = apply_static_function(
entity_id, cg.void, list(apply_entity.params), body
)
return fn
def inherit_property_from(property_to_inherit, parent_id_property, transform=None):
"""Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA.
+5
View File
@@ -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)
+92 -1
View File
@@ -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,
@@ -470,6 +472,7 @@ def generate_build_info_data_h() -> str:
namespace esphome {
extern const uint32_t ESPHOME_CONFIG_HASH;
extern const uint32_t ESPHOME_CONFIG_VERSION_HASH;
extern const time_t ESPHOME_BUILD_TIME;
extern const size_t ESPHOME_COMMENT_SIZE;
#ifdef USE_ESP8266
@@ -498,9 +501,14 @@ def generate_build_info_data_cpp(
comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL
return f"""// Auto-generated build_info data
#include "esphome/core/build_info_data.h"
#include "esphome/core/helpers.h"
#include "esphome/core/version.h"
namespace esphome {{
const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT
// Folded at compile time, so the version string is not kept in RAM
static constexpr uint32_t CONFIG_VERSION_HASH = fnv1a_hash_extend(0x{config_hash:08x}U, ESPHOME_VERSION);
const uint32_t ESPHOME_CONFIG_VERSION_HASH = CONFIG_VERSION_HASH; // NOLINT
const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT
const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT
#ifdef USE_ESP8266
@@ -722,7 +730,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 +776,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,
)
+1 -1
View File
@@ -20,7 +20,7 @@ classifiers = [
"Topic :: Home Automation",
]
requires-python = ">=3.12.0,<3.15"
requires-python = ">=3.12.0,<3.16"
dynamic = ["dependencies", "optional-dependencies", "version"]
+4 -4
View File
@@ -10,9 +10,9 @@ tzlocal==5.4.4 # from time
tzdata>=2026.5 # from time
pyserial==3.5
platformio==6.2.0
esptool==5.5.0
esptool==5.6.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
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.4
platformdirs==4.13.0 # native esp-idf toolchain global cache dir
platformdirs==4.13.3 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==4.2.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.3
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.1.3
flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
prek==0.5.5 # .github/workflows/ci.yml reads this pin
yamlrocks==0.7.0 # used by script/sync_dependency_versions.py
@@ -123,7 +123,7 @@ static void Encode_HelloResponse(benchmark::State &state) {
HelloResponse msg;
msg.api_version_major = 1;
msg.api_version_minor = 10;
msg.server_info = StringRef::from_lit("esphome v2026.3.0");
msg.server_info = ProgmemStringRef::from_lit("esphome v2026.3.0");
msg.name = StringRef::from_lit("living-room-sensor");
uint32_t size = msg.calculate_size();
(void) buffer.resize(size);
@@ -204,10 +204,10 @@ static DeviceInfoResponse make_device_info_response() {
DeviceInfoResponse msg;
msg.name = StringRef::from_lit("living-room-sensor");
msg.mac_address = StringRef::from_lit("AA:BB:CC:DD:EE:FF");
msg.esphome_version = StringRef::from_lit("2026.3.0");
msg.esphome_version = ProgmemStringRef::from_lit("2026.3.0");
msg.compilation_time = StringRef::from_lit("Mar 16 2026, 12:00:00");
msg.model = StringRef::from_lit("esp32-poe-iso");
msg.manufacturer = StringRef::from_lit("Olimex");
msg.model = ProgmemStringRef::from_lit("esp32-poe-iso");
msg.manufacturer = ProgmemStringRef::from_lit("Olimex");
msg.friendly_name = StringRef::from_lit("Living Room Sensor");
#ifdef USE_DEVICES
for (uint32_t i = 0; i < ESPHOME_DEVICE_COUNT && i < 20; i++) {
@@ -0,0 +1,74 @@
"""Tests for the st7305 configuration validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.st7305.display import (
CONF_COLUMN_END,
CONF_COLUMN_START,
CONF_PORTRAIT,
CONF_ROW_END,
MODELS,
_resolve,
)
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, CONF_HEIGHT, CONF_WIDTH
def _config(**overrides):
config = {
CONF_WIDTH: 400,
CONF_HEIGHT: 300,
CONF_PORTRAIT: False,
CONF_CS_PIN: 1,
CONF_DC_PIN: 2,
}
config.update(overrides)
return config
def test_landscape_derives_window() -> None:
config = _config()
_resolve(config, MODELS["CUSTOM"])
assert config[CONF_COLUMN_START] == 0
assert config[CONF_COLUMN_END] == 24
assert config[CONF_ROW_END] == 199
@pytest.mark.parametrize("height", [160, 200, 296, 308])
def test_landscape_rejects_inner_not_multiple_of_12(height: int) -> None:
with pytest.raises(cv.Invalid, match="height divisible by 12"):
_resolve(_config(**{CONF_HEIGHT: height}), MODELS["CUSTOM"])
def test_portrait_derives_window() -> None:
config = _config(
**{
CONF_WIDTH: 168,
CONF_HEIGHT: 384,
CONF_PORTRAIT: True,
CONF_COLUMN_START: 0x17,
}
)
_resolve(config, MODELS["CUSTOM"])
assert config[CONF_COLUMN_END] == 0x24
assert config[CONF_ROW_END] == 191
@pytest.mark.parametrize("width", [160, 200, 208])
def test_portrait_rejects_inner_not_multiple_of_12(width: int) -> None:
with pytest.raises(cv.Invalid, match="width divisible by 12"):
_resolve(
_config(
**{
CONF_WIDTH: width,
CONF_HEIGHT: 384,
CONF_PORTRAIT: True,
}
),
MODELS["CUSTOM"],
)
def test_window_ends_must_fit_in_a_byte() -> None:
with pytest.raises(cv.Invalid, match="between 0 and 255"):
_resolve(_config(**{CONF_WIDTH: 1024, CONF_HEIGHT: 300}), MODELS["CUSTOM"])
@@ -0,0 +1 @@
"""Tests for the usb_audio platforms' config validation."""
@@ -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]
@@ -0,0 +1,27 @@
#include <gtest/gtest.h>
#include "esphome/components/button/button.h"
namespace esphome::button::testing {
static Button *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static int presses = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void on_press(Button *entity) {
last_entity = entity;
presses++;
}
TEST(ApplyButton, PressRunsTheFunctionWithTheEntity) {
last_entity = nullptr;
presses = 0;
ApplyButton<on_press> button;
button.press();
button.press();
EXPECT_EQ(presses, 2);
EXPECT_EQ(last_entity, &button);
}
TEST(ApplyButton, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplyButton<on_press>), sizeof(Button)); }
} // namespace esphome::button::testing
@@ -0,0 +1,179 @@
#include <algorithm>
#include <cstdint>
#include <vector>
#include <gtest/gtest.h>
#include "esphome/components/ld2410s/ld2410s.h"
#include "esphome/components/uart/uart_component.h"
#include "esphome/core/application.h"
#if !defined(USE_LD2410S_PARAMETERS) || !defined(USE_LD2410S_VERSION)
#error "tests/components/ld2410s/number/__init__.py must define the LD2410S features"
#endif
namespace esphome::ld2410s::testing {
// Vectors, so EXPECT_EQ prints both command lists on a mismatch
static const std::vector<uint16_t> INIT_COMMANDS = {0x00FF, 0x0000, 0x0071, 0x007A, 0x00FE};
static const std::vector<uint16_t> WRITE_COMMANDS = {0x00FF, 0x0070, 0x00FE};
static const std::vector<uint16_t> CONFIG_MODE_ON = {0x00FF};
static constexpr uint32_t STEP_MS = 300;
// 0x0070 frame: 4 header + 2 length + 2 command + 6 * (2 key + 4 value) + 4 footer
static constexpr size_t WRITE_FRAME_SIZE = 48;
static constexpr size_t MAX_DISTANCE_VALUE_POS = 10;
static constexpr size_t NO_DELAY_VALUE_POS = 22;
// Init far from the wrap, across it, and ending just before it: nine 300 ms passes put the last
// acknowledgement at 0xFFFFFF00, so the gap itself crosses the wrap
static constexpr uint32_t START_FAR_FROM_WRAP = 1000;
static constexpr uint32_t START_ACROSS_WRAP = 0xFFFFFC00;
static constexpr uint32_t START_GAP_ACROSS_WRAP = 0xFFFFF474;
// Acknowledges every command in the layout ld2410s.cpp parses; a fixture, not a hardware claim
class FakeUART : public uart::UARTComponent {
public:
void write_array(const uint8_t *data, size_t len) override {
const uint16_t command = static_cast<uint16_t>(data[6] | (data[7] << 8));
this->commands.push_back(command);
this->frames.emplace_back(data, data + len);
std::vector<uint8_t> payload = {data[6], static_cast<uint8_t>(data[7] | 0x01), 0x00, 0x00};
if (command == 0x0071) // six 32-bit values, each within a byte
payload.insert(payload.end(), {8, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0, 0, 5, 0, 0, 0});
if (command == 0x0000) // device type and three version words
payload.insert(payload.end(), size_t{10}, uint8_t{0});
std::vector<uint8_t> frame = {0xFD, 0xFC, 0xFB, 0xFA, static_cast<uint8_t>(payload.size()), 0x00};
frame.insert(frame.end(), payload.begin(), payload.end());
frame.insert(frame.end(), {0x04, 0x03, 0x02, 0x01});
this->queue(frame);
}
void queue(const std::vector<uint8_t> &bytes) { this->rx_.insert(this->rx_.end(), bytes.begin(), bytes.end()); }
bool read_array(uint8_t *data, size_t len) override {
if (this->rx_.size() - this->pos_ < len)
return false;
std::copy_n(this->rx_.begin() + this->pos_, len, data);
this->pos_ += len;
return true;
}
size_t available() override { return this->rx_.size() - this->pos_; }
bool peek_byte(uint8_t *data) override { return false; }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
void check_logger_conflict() override {}
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif
std::vector<uint16_t> commands;
std::vector<std::vector<uint8_t>> frames;
protected:
std::vector<uint8_t> rx_;
size_t pos_{0};
};
class LD2410SWriteTest : public ::testing::Test {
public:
LD2410SWriteTest() { this->radar_.set_uart_parent(&this->uart_); }
protected:
void loop_at_(uint32_t now) {
LoopBlockingGuard guard{nullptr, nullptr, now};
this->radar_.loop();
}
// Runs init until the configuration-mode-off acknowledgement is taken, then queues one change
void finish_init_(uint32_t now) {
for (int pass = 0; pass < 20; pass++, now += STEP_MS) {
this->loop_at_(now);
if (!this->uart_.commands.empty() && this->uart_.commands.back() == 0x00FE && this->uart_.available() == 0)
break;
}
ASSERT_EQ(this->uart_.commands, INIT_COMMANDS);
ASSERT_EQ(this->uart_.available(), 0u);
ASSERT_TRUE(this->radar_.set_parameter(Parameter::PARAMETER_NO_DELAY, 30));
this->acked_ = now;
this->sent_ = this->uart_.commands.size();
}
// Loops every 300 ms with a short data frame before each pass, so the link check never restarts init
std::vector<uint16_t> write_from_(uint32_t now, int passes) {
for (int pass = 0; pass < passes; pass++, now += STEP_MS) {
this->uart_.queue({0x6E, 0x02, 0x64, 0x00, 0x62});
this->loop_at_(now);
}
return {this->uart_.commands.begin() + this->sent_, this->uart_.commands.end()};
}
void check_waits_for_gap_(uint32_t start, uint32_t elapsed) {
this->finish_init_(start);
if (::testing::Test::HasFatalFailure())
return;
EXPECT_TRUE(this->write_from_(this->acked_ + elapsed, 1).empty());
EXPECT_EQ(this->write_from_(this->acked_ + STEP_MS, 1), CONFIG_MODE_ON);
EXPECT_EQ(this->write_from_(this->acked_ + 2 * STEP_MS, 5), WRITE_COMMANDS);
}
void check_starts_on_first_pass_(uint32_t start, uint32_t elapsed) {
this->finish_init_(start);
if (::testing::Test::HasFatalFailure())
return;
const uint32_t now = this->acked_ + elapsed;
EXPECT_EQ(this->write_from_(now, 1), CONFIG_MODE_ON);
// The write, not a re-init (which would send 0000 next)
ASSERT_EQ(this->write_from_(now + STEP_MS, 5), WRITE_COMMANDS);
const auto &frame = this->uart_.frames[this->sent_ + 1];
ASSERT_EQ(frame.size(), WRITE_FRAME_SIZE);
EXPECT_EQ(frame[NO_DELAY_VALUE_POS], 30);
EXPECT_EQ(frame[MAX_DISTANCE_VALUE_POS], 8); // the module's own value, read during init
}
FakeUART uart_;
LD2410SComponent radar_;
uint32_t acked_{0};
size_t sent_{0};
};
// Time since the last acknowledgement is inside the 300 ms gap: nothing is sent until it ends
class LD2410SGapTest : public LD2410SWriteTest {};
// Time since the last acknowledgement is at or past the gap end, up to the full 32-bit range
class LD2410SStartTest : public LD2410SWriteTest {};
TEST_F(LD2410SGapTest, FarFromWrapElapsed0) { this->check_waits_for_gap_(START_FAR_FROM_WRAP, 0); }
TEST_F(LD2410SGapTest, FarFromWrapElapsed100) { this->check_waits_for_gap_(START_FAR_FROM_WRAP, 100); }
TEST_F(LD2410SGapTest, FarFromWrapElapsed299) { this->check_waits_for_gap_(START_FAR_FROM_WRAP, 299); }
TEST_F(LD2410SGapTest, AcrossWrapElapsed0) { this->check_waits_for_gap_(START_ACROSS_WRAP, 0); }
TEST_F(LD2410SGapTest, AcrossWrapElapsed100) { this->check_waits_for_gap_(START_ACROSS_WRAP, 100); }
TEST_F(LD2410SGapTest, AcrossWrapElapsed299) { this->check_waits_for_gap_(START_ACROSS_WRAP, 299); }
TEST_F(LD2410SGapTest, GapAcrossWrapElapsed0) { this->check_waits_for_gap_(START_GAP_ACROSS_WRAP, 0); }
TEST_F(LD2410SGapTest, GapAcrossWrapElapsed100) { this->check_waits_for_gap_(START_GAP_ACROSS_WRAP, 100); }
TEST_F(LD2410SGapTest, GapAcrossWrapElapsed299) { this->check_waits_for_gap_(START_GAP_ACROSS_WRAP, 299); }
TEST_F(LD2410SStartTest, FarFromWrapGapEnd) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 300); }
TEST_F(LD2410SStartTest, FarFromWrapGapEndPlus1) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 301); }
TEST_F(LD2410SStartTest, FarFromWrapHalfRangeMinus1) {
this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0x8000012B);
}
TEST_F(LD2410SStartTest, FarFromWrapHalfRange) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0x8000012C); }
TEST_F(LD2410SStartTest, FarFromWrapThreeQuarterRange) {
this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0xC0000000);
}
TEST_F(LD2410SStartTest, FarFromWrapFullRange) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0xFFFFFFFF); }
TEST_F(LD2410SStartTest, AcrossWrapGapEnd) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 300); }
TEST_F(LD2410SStartTest, AcrossWrapGapEndPlus1) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 301); }
TEST_F(LD2410SStartTest, AcrossWrapHalfRangeMinus1) {
this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0x8000012B);
}
TEST_F(LD2410SStartTest, AcrossWrapHalfRange) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0x8000012C); }
TEST_F(LD2410SStartTest, AcrossWrapThreeQuarterRange) {
this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0xC0000000);
}
TEST_F(LD2410SStartTest, AcrossWrapFullRange) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0xFFFFFFFF); }
TEST_F(LD2410SStartTest, GapAcrossWrapGapEnd) { this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 300); }
TEST_F(LD2410SStartTest, GapAcrossWrapGapEndPlus1) { this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 301); }
TEST_F(LD2410SStartTest, GapAcrossWrapHalfRangeMinus1) {
this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0x8000012B);
}
TEST_F(LD2410SStartTest, GapAcrossWrapHalfRange) {
this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0x8000012C);
}
TEST_F(LD2410SStartTest, GapAcrossWrapThreeQuarterRange) {
this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0xC0000000);
}
TEST_F(LD2410SStartTest, GapAcrossWrapFullRange) {
this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0xFFFFFFFF);
}
} // namespace esphome::ld2410s::testing
@@ -0,0 +1,16 @@
import esphome.codegen as cg
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The hub's to_code emits no feature defines; this platform stub turns on the parameter and
# version code in the real ld2410s.cpp for the host test build
async def to_code_testing(config: ConfigType) -> None:
CORE.testing_ensure_platform_registered("text_sensor")
cg.add_define("USE_LD2410S_PARAMETERS")
cg.add_define("USE_LD2410S_VERSION")
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["text_sensor"]
@@ -180,4 +180,43 @@ TEST_F(LD2460ReadlineTest, DataCallbackOncePerValidFrame) {
EXPECT_EQ(calls, 2);
}
TEST_F(LD2460ReadlineTest, TruncatedPrefixBeforeLargeFrames) {
// Three targets still fit behind the truncated prefix; four and five overflow the buffer
const std::vector<Point> targets = {{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}};
for (int n = 3; n <= MAX_TARGETS; n++) {
SCOPED_TRACE(n);
this->ld2460_.feed({0x00, 0xF4, 0xF3, 0xF2, 0xF1, 0x04});
this->ld2460_.feed(make_periodic_frame(std::vector<Point>(targets.begin(), targets.begin() + n)));
EXPECT_FLOAT_EQ(this->count_.state, n);
EXPECT_NEAR(this->x_[n - 1].state, (2 * n - 1) * 0.1f, 0.001f);
}
}
TEST_F(LD2460ReadlineTest, LostFooterTwoTargetResync) {
// A two-target frame missing its last byte; the next frame overflows the buffer
auto broken = make_periodic_frame({{10, 10}, {20, 20}});
broken.pop_back();
this->ld2460_.feed(broken);
this->ld2460_.feed(make_periodic_frame({{30, 40}, {50, 60}}));
EXPECT_FLOAT_EQ(this->count_.state, 2);
EXPECT_NEAR(this->x_[1].state, 5.0f, 0.001f);
this->ld2460_.feed(make_periodic_frame({{70, 80}}));
EXPECT_FLOAT_EQ(this->count_.state, 1);
EXPECT_NEAR(this->x_[0].state, 7.0f, 0.001f);
}
TEST_F(LD2460ReadlineTest, HeaderLikeGarbageThenMaxTargets) {
// Header first bytes and whole command headers in the noise do not hold back a real frame
this->ld2460_.feed(std::vector<uint8_t>(70, 0xF4));
this->ld2460_.feed(make_periodic_frame({{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}}));
EXPECT_FLOAT_EQ(this->count_.state, 5);
std::vector<uint8_t> garbage;
for (int i = 0; i < 40; i++) {
garbage.insert(garbage.end(), {0xFD, 0xFC, 0xFB, 0xFA});
}
this->ld2460_.feed(garbage);
this->ld2460_.feed(make_periodic_frame({{11, 12}, {3, 4}, {5, 6}, {7, 8}, {9, 10}}));
EXPECT_NEAR(this->x_[0].state, 1.1f, 0.001f);
}
} // namespace esphome::ld2460::testing
@@ -0,0 +1,31 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/number/number.h"
namespace esphome::number::testing {
static Number *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static std::vector<float> values; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void on_control(Number *entity, float value) {
last_entity = entity;
values.push_back(value);
}
TEST(ApplyNumber, ControlRunsTheFunctionWithTheValue) {
last_entity = nullptr;
values.clear();
ApplyNumber<on_control> number;
number.traits.set_min_value(0);
number.traits.set_max_value(10);
number.traits.set_step(1);
number.make_call().set_value(4).perform();
EXPECT_EQ(values, (std::vector<float>{4}));
EXPECT_EQ(last_entity, &number);
}
TEST(ApplyNumber, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplyNumber<on_control>), sizeof(Number)); }
} // namespace esphome::number::testing
+16
View File
@@ -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
@@ -0,0 +1,27 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "esphome/components/select/select.h"
namespace esphome::select::testing {
static constexpr const char *const APPLY_OPTIONS[] = {"low", "medium", "high"};
static std::vector<std::string> chosen; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void on_control(Select *entity, size_t index) { chosen.emplace_back(entity->option_at(index)); }
TEST(ApplySelect, ControlRunsTheFunctionWithTheIndex) {
chosen.clear();
ApplySelect<on_control> select;
select.traits.set_options_static(APPLY_OPTIONS, 3);
select.make_call().set_index(2).perform();
select.make_call().set_option("low").perform();
EXPECT_EQ(chosen, (std::vector<std::string>{"high", "low"}));
}
TEST(ApplySelect, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplySelect<on_control>), sizeof(Select)); }
} // namespace esphome::select::testing
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include <cstdio>
#include <utility>
#include <vector>
#include "esphome/components/spi/spi.h"
#include "esphome/components/st7305/st7305.h"
#include "esphome/core/hal.h"
namespace esphome::st7305::testing {
class TestableST7305 : public ST7305 {
public:
TestableST7305(int width = 400, int height = 300, Orientation orientation = Orientation::ORIENTATION_LANDSCAPE,
uint8_t column_start = 0x12, uint8_t column_end = 0x2A, uint8_t row_start = 0x00,
uint8_t row_end = 0xC7)
: ST7305("TEST", width, height, orientation, column_start, column_end, row_start, row_end, nullptr, 0) {}
bool init_buffer() { return this->init_buffer_(); }
void clear_dirty() { this->clear_dirty_(); }
bool is_dirty() const { return this->is_dirty_(); }
int x_low() const { return this->x_low_; }
int x_high() const { return this->x_high_; }
int y_low() const { return this->y_low_; }
int y_high() const { return this->y_high_; }
Window compute_window() const { return this->compute_window_(); }
void write_window() { this->write_window_(this->compute_window_()); }
uint8_t *buffer() { return this->buffer_; }
void attach_spi(spi::SPIDelegate *delegate, GPIOPin *dc_pin) {
this->delegate_ = delegate;
this->set_dc_pin(dc_pin);
}
size_t buffer_length() const { return this->buffer_length_; }
int width() const { return this->width_; }
int height() const { return this->height_; }
};
/// GPIO pin that remembers the last level written to it.
class RecordingPin : public GPIOPin {
public:
void setup() override {}
void pin_mode(gpio::Flags flags) override {}
gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; }
bool digital_read() override { return false; }
void digital_write(bool value) override { this->level = value; }
size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); }
bool level{true};
};
/// SPI delegate that records every payload and whether it was a command or data phase.
class RecordingDelegate : public spi::SPIDelegate {
public:
explicit RecordingDelegate(const RecordingPin *dc)
: spi::SPIDelegate(10'000'000, spi::BIT_ORDER_MSB_FIRST, spi::MODE0, nullptr), dc_(dc) {}
uint8_t transfer(uint8_t data) override {
this->record_(&data, 1);
return 0;
}
void write_array(const uint8_t *ptr, size_t length) override { this->record_(ptr, length); }
std::vector<uint8_t> bytes;
std::vector<std::pair<bool, size_t>> chunks; // (data phase, length)
protected:
void record_(const uint8_t *ptr, size_t length) {
this->chunks.emplace_back(this->dc_->level, length);
this->bytes.insert(this->bytes.end(), ptr, ptr + length);
}
const RecordingPin *dc_;
};
} // namespace esphome::st7305::testing
+6
View File
@@ -0,0 +1,6 @@
display:
- platform: st7305
model: WAVESHARE-400X300
id: st7305_display
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
@@ -0,0 +1,255 @@
#include <gtest/gtest.h>
#include "../common.h"
namespace esphome::st7305::testing {
class ST7305Test : public ::testing::Test {
protected:
void SetUp() override { ASSERT_TRUE(this->display_.init_buffer()); }
void reset_screen_() {
this->display_.fill(Color::BLACK);
this->display_.clear_dirty();
}
TestableST7305 display_;
};
TEST_F(ST7305Test, FillSetsAllBytes) {
this->display_.fill(Color::WHITE);
for (size_t i = 0; i < this->display_.buffer_length(); i++)
EXPECT_EQ(this->display_.buffer()[i], 0xFF) << "byte " << i;
this->display_.fill(Color::BLACK);
for (size_t i = 0; i < this->display_.buffer_length(); i++)
EXPECT_EQ(this->display_.buffer()[i], 0x00) << "byte " << i;
}
TEST_F(ST7305Test, PixelMapping) {
struct PixelCase {
int x;
int y;
size_t index;
uint8_t mask;
};
const PixelCase cases[] = {
{0, 299, 0, 0x80}, {1, 299, 0, 0x40}, {0, 298, 0, 0x20}, {0, 297, 0, 0x08}, {0, 296, 0, 0x02},
{2, 299, 75, 0x80}, {0, 0, 74, 0x02}, {1, 0, 74, 0x01}, {200, 150, 7537, 0x20}, {399, 0, 14999, 0x01},
};
for (const auto &pixel : cases) {
this->display_.draw_pixel_at(pixel.x, pixel.y, Color::WHITE);
EXPECT_EQ(this->display_.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y;
this->display_.draw_pixel_at(pixel.x, pixel.y, Color::BLACK);
EXPECT_EQ(this->display_.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y;
}
}
TEST_F(ST7305Test, ClearingOnePixelLeavesNeighbours) {
this->display_.draw_pixel_at(0, 299, Color::WHITE);
this->display_.draw_pixel_at(1, 299, Color::WHITE);
EXPECT_EQ(this->display_.buffer()[0], 0xC0);
this->display_.draw_pixel_at(0, 299, Color::BLACK);
EXPECT_EQ(this->display_.buffer()[0], 0x40);
}
TEST_F(ST7305Test, DirtyBoundsFollowChanges) {
EXPECT_FALSE(this->display_.is_dirty());
this->display_.draw_pixel_at(10, 20, Color::WHITE);
EXPECT_TRUE(this->display_.is_dirty());
EXPECT_EQ(this->display_.x_low(), 10);
EXPECT_EQ(this->display_.x_high(), 10);
EXPECT_EQ(this->display_.y_low(), 20);
EXPECT_EQ(this->display_.y_high(), 20);
this->display_.clear_dirty();
this->display_.draw_pixel_at(10, 20, Color::WHITE);
EXPECT_FALSE(this->display_.is_dirty());
this->display_.draw_pixel_at(10, 20, Color::BLACK);
EXPECT_TRUE(this->display_.is_dirty());
this->display_.clear_dirty();
this->display_.draw_pixel_at(10, 20, Color::WHITE);
this->display_.draw_pixel_at(100, 200, Color::WHITE);
EXPECT_EQ(this->display_.x_low(), 10);
EXPECT_EQ(this->display_.x_high(), 100);
EXPECT_EQ(this->display_.y_low(), 20);
EXPECT_EQ(this->display_.y_high(), 200);
}
TEST_F(ST7305Test, FillOnlyDirtiesChangedBytes) {
this->display_.clear_dirty();
this->display_.fill(Color::BLACK);
EXPECT_FALSE(this->display_.is_dirty());
this->display_.fill(Color::WHITE);
EXPECT_TRUE(this->display_.is_dirty());
EXPECT_EQ(this->display_.x_low(), 0);
EXPECT_EQ(this->display_.x_high(), 399);
EXPECT_EQ(this->display_.y_low(), 0);
EXPECT_EQ(this->display_.y_high(), 299);
this->display_.clear_dirty();
this->display_.fill(Color::WHITE);
EXPECT_FALSE(this->display_.is_dirty());
}
TEST_F(ST7305Test, WindowAlignsToAddressBlocks) {
this->reset_screen_();
this->display_.draw_pixel_at(0, 299, Color::WHITE);
this->display_.draw_pixel_at(1, 299, Color::WHITE);
const auto window = this->display_.compute_window();
EXPECT_EQ(window.outer_start, 0);
EXPECT_EQ(window.outer_end, 0);
EXPECT_EQ(window.block_start, 0);
EXPECT_EQ(window.block_end, 2);
EXPECT_EQ(window.caset_start, 0x12);
EXPECT_EQ(window.caset_end, 0x12);
EXPECT_EQ(window.data_length(), 3u);
this->reset_screen_();
this->display_.draw_pixel_at(200, 150, Color::WHITE);
const auto middle = this->display_.compute_window();
EXPECT_EQ(middle.outer_start, 100);
EXPECT_EQ(middle.outer_end, 100);
EXPECT_EQ(middle.block_start, 36);
EXPECT_EQ(middle.block_end, 38);
EXPECT_EQ(middle.caset_start, 0x1E);
EXPECT_EQ(middle.caset_end, 0x1E);
EXPECT_EQ(middle.data_length(), 3u);
this->reset_screen_();
this->display_.draw_pixel_at(0, 0, Color::WHITE);
this->display_.draw_pixel_at(0, 299, Color::WHITE);
const auto full_height = this->display_.compute_window();
EXPECT_EQ(full_height.outer_start, 0);
EXPECT_EQ(full_height.outer_end, 0);
EXPECT_EQ(full_height.block_start, 0);
EXPECT_EQ(full_height.block_end, 74);
EXPECT_EQ(full_height.caset_start, 0x12);
EXPECT_EQ(full_height.caset_end, 0x2A);
EXPECT_EQ(full_height.data_length(), 75u);
}
TEST_F(ST7305Test, WriteWindowEmitsWindowCommands) {
for (size_t i = 0; i < this->display_.buffer_length(); i++)
this->display_.buffer()[i] = static_cast<uint8_t>(i & 0xFF);
RecordingPin dc_pin;
RecordingDelegate delegate(&dc_pin);
this->display_.attach_spi(&delegate, &dc_pin);
this->display_.draw_pixel_at(200, 151, Color::WHITE);
this->display_.write_window();
const uint8_t expected[] = {
0x2A,
0x1E,
0x1E,
0x2B,
100,
100,
0x2C,
this->display_.buffer()[100 * 75 + 36],
this->display_.buffer()[100 * 75 + 37],
this->display_.buffer()[100 * 75 + 38],
};
ASSERT_EQ(delegate.bytes.size(), sizeof(expected));
for (size_t i = 0; i < sizeof(expected); i++)
EXPECT_EQ(delegate.bytes[i], expected[i]) << "byte " << i;
ASSERT_EQ(delegate.chunks.size(), 6u);
EXPECT_EQ(delegate.chunks[0], std::make_pair(false, size_t{1}));
EXPECT_EQ(delegate.chunks[1], std::make_pair(true, size_t{2}));
EXPECT_EQ(delegate.chunks[2], std::make_pair(false, size_t{1}));
EXPECT_EQ(delegate.chunks[3], std::make_pair(true, size_t{2}));
EXPECT_EQ(delegate.chunks[4], std::make_pair(false, size_t{1}));
EXPECT_EQ(delegate.chunks[5], std::make_pair(true, size_t{3}));
}
TEST_F(ST7305Test, PortraitPixelMapping) {
TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF);
ASSERT_TRUE(portrait.init_buffer());
ASSERT_EQ(portrait.get_width(), 168);
ASSERT_EQ(portrait.get_height(), 384);
struct PixelCase {
int x;
int y;
size_t index;
uint8_t mask;
};
const PixelCase cases[] = {
{0, 0, 0, 0x80}, {1, 0, 0, 0x20}, {2, 0, 0, 0x08}, {3, 0, 0, 0x02},
{0, 1, 0, 0x40}, {4, 0, 1, 0x80}, {0, 2, 42, 0x80}, {167, 383, 8063, 0x01},
};
for (const auto &pixel : cases) {
portrait.draw_pixel_at(pixel.x, pixel.y, Color::WHITE);
EXPECT_EQ(portrait.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y;
portrait.draw_pixel_at(pixel.x, pixel.y, Color::BLACK);
EXPECT_EQ(portrait.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y;
}
}
TEST_F(ST7305Test, PortraitWindowAlignsToAddressBlocks) {
TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF);
ASSERT_TRUE(portrait.init_buffer());
portrait.draw_pixel_at(84, 192, Color::WHITE);
const auto window = portrait.compute_window();
EXPECT_EQ(window.outer_start, 96);
EXPECT_EQ(window.outer_end, 96);
EXPECT_EQ(window.stride, 42);
EXPECT_EQ(window.block_start, 21);
EXPECT_EQ(window.block_end, 23);
EXPECT_EQ(window.caset_start, 0x1E);
EXPECT_EQ(window.caset_end, 0x1E);
EXPECT_EQ(window.data_length(), 3u);
portrait.fill(Color::BLACK);
portrait.clear_dirty();
portrait.draw_pixel_at(0, 0, Color::WHITE);
portrait.draw_pixel_at(3, 1, Color::WHITE);
const auto corner = portrait.compute_window();
EXPECT_EQ(corner.outer_start, 0);
EXPECT_EQ(corner.outer_end, 0);
EXPECT_EQ(corner.block_start, 0);
EXPECT_EQ(corner.block_end, 2);
EXPECT_EQ(corner.caset_start, 0x17);
EXPECT_EQ(corner.caset_end, 0x17);
EXPECT_EQ(corner.data_length(), 3u);
portrait.fill(Color::BLACK);
portrait.clear_dirty();
portrait.draw_pixel_at(167, 200, Color::WHITE);
const auto right_edge = portrait.compute_window();
EXPECT_EQ(right_edge.outer_start, 100);
EXPECT_EQ(right_edge.outer_end, 100);
EXPECT_EQ(right_edge.block_start, 39);
EXPECT_EQ(right_edge.block_end, 41);
EXPECT_EQ(right_edge.caset_start, 0x24);
EXPECT_EQ(right_edge.caset_end, 0x24);
EXPECT_EQ(right_edge.data_length(), 3u);
portrait.fill(Color::BLACK);
portrait.clear_dirty();
portrait.draw_pixel_at(167, 383, Color::WHITE);
const auto bottom_right = portrait.compute_window();
EXPECT_EQ(bottom_right.outer_start, 191);
EXPECT_EQ(bottom_right.outer_end, 191);
EXPECT_EQ(bottom_right.block_start, 39);
EXPECT_EQ(bottom_right.block_end, 41);
EXPECT_EQ(bottom_right.caset_start, 0x24);
EXPECT_EQ(bottom_right.caset_end, 0x24);
EXPECT_EQ(bottom_right.data_length(), 3u);
}
} // namespace esphome::st7305::testing
@@ -0,0 +1,8 @@
substitutions:
clk_pin: GPIO18
mosi_pin: GPIO17
miso_pin: GPIO16
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
st7305: !include common.yaml

Some files were not shown because too many files have changed in this diff Show More