Compare commits

...
Author SHA1 Message Date
J. Nick Koston 8915d06b05 Merge remote-tracking branch 'origin/dev' into number-traits-set-range
# Conflicts:
#	esphome/components/number/__init__.py
2026-10-19 08:01:39 -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
J. Nick Koston 7df9b5ea2f Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-09 18:27:29 -10:00
J. Nick Koston a8ddb2535e Merge remote-tracking branch 'origin/dev' into number-traits-set-range 2026-10-07 16:45:04 -10:00
J. Nick Koston d4922fb446 Merge remote-tracking branch 'origin/dev' into number-traits-set-range 2026-10-04 18:07:01 -05:00
J. Nick Koston 7acbe82c27 Also guard the NumberRange size so an added field fails the build 2026-10-03 05:47:52 -05:00
J. Nick Koston 1a0b43449e Split the field type guards into one static_assert per field
clang-tidy flags the chained is_same_v checks as a redundant expression.
2026-10-02 21:42:36 -05:00
J. Nick Koston 860d819e33 Guard the NumberRange field types read from flash 2026-10-02 20:55:16 -05:00
J. Nick Koston f8f25c1ad4 Make the render time statement a slotted dataclass 2026-10-02 17:47:47 -05:00
J. Nick Koston 22536f9338 Collapse the range statements into one render time statement 2026-10-02 17:47:06 -05:00
J. Nick Koston c64a7fb678 Declare shared range tables in the global section
The table is a lazy global statement that renders only when the range is
shared, so it no longer depends on the first user's position in setup().
One helper builds the setter expressions for both paths.
2026-10-02 17:36:28 -05:00
J. Nick Koston 6793dad8ed Only use a range table when enough numbers share it
Below three users the table and set_range() cost more than the inline
setters, so the choice is made at render time once every number is
known. Lambda bounds (lvgl arc/bar) keep the setters. set_range reads
the table with aligned loads instead of memcpy_P.
2026-10-02 17:32:24 -05:00
J. Nick Koston 3dd9a28ed2 Emit the range table with progmem_array and a generated ID 2026-10-02 17:14:31 -05:00
J. Nick Koston 5a93de82bf [number] Set range from a shared PROGMEM table
Each number used to emit three inline trait setters in setup(). Codegen
now emits one PROGMEM NumberRange per distinct (min, max, step) and each
number makes a single out of line set_range() call with its address.
2026-10-02 17:10:41 -05:00
100 changed files with 6249 additions and 606 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
View File
@@ -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
+1 -1
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,
@@ -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
+85 -7
View File
@@ -1,3 +1,6 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
@@ -82,6 +85,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,
@@ -89,10 +93,13 @@ from esphome.core.entity_helpers import (
setup_entity,
setup_unit_of_measurement,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
from esphome.cpp_generator import MockObj, MockObjClass, ProgmemAssignmentExpression
from esphome.types import ConfigType, SafeExpType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "number"
CONF_RANGE_ID = "range_id"
DEVICE_CLASSES = [
DEVICE_CLASS_ABSOLUTE_HUMIDITY,
DEVICE_CLASS_APPARENT_POWER,
@@ -160,6 +167,8 @@ IS_PLATFORM_COMPONENT = True
number_ns = cg.esphome_ns.namespace("number")
Number = number_ns.class_("Number", cg.EntityBase)
NumberPtr = Number.operator("ptr")
NumberRange = number_ns.struct("NumberRange")
ApplyNumber = number_ns.class_("ApplyNumber", Number)
# Triggers
ValueRangeTrigger = number_ns.class_(
@@ -199,6 +208,7 @@ _NUMBER_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent),
cv.GenerateID(CONF_RANGE_ID): cv.declare_id(NumberRange),
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation(
{
@@ -274,13 +284,74 @@ async def _build_number_automations(var, config):
await automation.build_automation(trigger, [(float, "x")], conf)
# A shared table only pays for set_range() and the table itself once this many numbers use it.
RANGE_TABLE_MIN_USERS = 3
@dataclass
class NumberData:
# (min, max, step) -> range_id of every number using it; the first one names the table.
ranges: dict[tuple[float, float, float], list[ID]] = field(default_factory=dict)
def _get_data() -> NumberData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = NumberData()
return CORE.data[DOMAIN]
@dataclass(slots=True)
class _RenderTimeStatement(cg.Statement):
"""Text chosen when main.cpp is written, after every number has been counted."""
render: Callable[[], str]
def __str__(self) -> str:
return self.render()
def _add_range(
var: MockObj,
range_id: ID,
min_value: SafeExpType,
max_value: SafeExpType,
step: SafeExpType,
) -> None:
setters = (
var.traits.set_min_value(min_value),
var.traits.set_max_value(max_value),
var.traits.set_step(step),
)
# Lambda bounds (e.g. lvgl arc/bar) are runtime expressions and can't go in a table.
if not all(isinstance(v, (int, float)) for v in (min_value, max_value, step)):
for expr in setters:
cg.add(expr)
return
key = (float(min_value), float(max_value), float(step))
users = _get_data().ranges.setdefault(key, [])
users.append(range_id)
table = users[0]
def render_table() -> str:
if len(users) < RANGE_TABLE_MIN_USERS:
return ""
return f"{ProgmemAssignmentExpression(NumberRange, table, cg.safe_exp([key]))};"
def render_call() -> str:
if len(users) < RANGE_TABLE_MIN_USERS:
return "\n".join(str(cg.statement(expr)) for expr in setters)
return str(cg.statement(var.set_range(MockObj(table, "."))))
if len(users) == 1:
cg.add_global(_RenderTimeStatement(render_table))
cg.add(_RenderTimeStatement(render_call))
@setup_entity("number")
async def setup_number_core_(
var, config, *, min_value: float, max_value: float, step: float
):
cg.add(var.traits.set_min_value(min_value))
cg.add(var.traits.set_max_value(max_value))
cg.add(var.traits.set_step(step))
_add_range(var, config[CONF_RANGE_ID], min_value, max_value, step)
# Skip the setter when the config matches the C++ initializer (DEFAULT_MODE).
# The validated value is the enum key string, not the C++ enum expression.
@@ -321,11 +392,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(
+7
View File
@@ -19,6 +19,13 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o
LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj);
}
void Number::set_range(const NumberRange *range) {
// Aligned 32-bit loads are safe from PROGMEM on ESP8266 (same as pgm_read_float_aligned).
this->traits.set_min_value(range->min_value);
this->traits.set_max_value(range->max_value);
this->traits.set_step(range->step);
}
void Number::publish_state(float state) {
this->set_has_state(true);
this->state = state;
+13
View File
@@ -40,6 +40,9 @@ class Number : public EntityBase {
NumberTraits traits;
// Out of line so each generated call site loads one pointer instead of three floats.
void set_range(const NumberRange *range);
protected:
friend class NumberCall;
@@ -54,4 +57,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
+13
View File
@@ -2,6 +2,7 @@
#include <cmath>
#include <cstdint>
#include <type_traits>
namespace esphome::number {
@@ -11,6 +12,18 @@ enum NumberMode : uint8_t {
NUMBER_MODE_SLIDER = 2,
};
/// Boundaries and step for a number; codegen shares one PROGMEM table between numbers with the same values.
struct NumberRange {
float min_value;
float max_value;
float step;
};
// Read straight from flash on ESP8266, so every field must stay a word.
static_assert(std::is_same_v<decltype(NumberRange::min_value), float>, "NumberRange fields must stay float");
static_assert(std::is_same_v<decltype(NumberRange::max_value), float>, "NumberRange fields must stay float");
static_assert(std::is_same_v<decltype(NumberRange::step), float>, "NumberRange fields must stay float");
static_assert(sizeof(NumberRange) == 3 * sizeof(float), "NumberRange is read from flash with word loads");
class NumberTraits {
public:
// Set/get the number value boundaries.
+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; }
+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
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.5.0
click==8.5.0
aioesphomeapi==46.12.0
aioesphomeapi==46.14.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -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,32 @@
---
esphome:
name: test
esp32:
board: esp32dev
number:
- platform: template
id: shared_1
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: shared_2
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: shared_3
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: single
min_value: -5
max_value: 5
step: 0.5
optimistic: true
@@ -3,6 +3,10 @@
from collections.abc import Callable
from pathlib import Path
from esphome.components import number
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, RawExpression
def test_default_mode_is_not_emitted(
generate_main: Callable[[str | Path], str],
@@ -14,3 +18,36 @@ def test_default_mode_is_not_emitted(
assert "auto_number->traits.set_mode(" not in main_cpp
assert "explicit_auto_number->traits.set_mode(" not in main_cpp
assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp
def test_range_table_only_for_shared_ranges(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A range used by enough numbers gets one PROGMEM table; others keep the setters."""
main_cpp = generate_main(component_config_path("range.yaml"))
assert (
"static constexpr number::NumberRange number_numberrange_id[] PROGMEM = "
"{{0.0f, 10.0f, 1.0f}};" in main_cpp
)
assert main_cpp.count("number::NumberRange ") == 1
for var in ("shared_1", "shared_2", "shared_3"):
assert f"{var}->set_range(number_numberrange_id);" in main_cpp
assert "single->set_range(" not in main_cpp
assert "single->traits.set_min_value(-5.0f);" in main_cpp
assert "single->traits.set_max_value(5.0f);" in main_cpp
assert "single->traits.set_step(0.5f);" in main_cpp
def test_range_with_lambda_bounds_uses_setters() -> None:
"""Runtime bounds (lvgl arc/bar lambdas) can't go in a PROGMEM table."""
var = MockObj("lambda_number", "->")
range_id = ID("lambda_range", is_declaration=True, type=number.NumberRange)
number._add_range(var, range_id, RawExpression("get_min()"), 10, 1)
main_cpp = CORE.cpp_main_section
assert "lambda_number->traits.set_min_value(get_min());" in main_cpp
assert "lambda_number->traits.set_max_value(10);" in main_cpp
assert "lambda_number->traits.set_step(1);" in main_cpp
assert "set_range(" not in main_cpp
@@ -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,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
@@ -0,0 +1,21 @@
substitutions:
cs_pin: GPIO12
dc_pin: GPIO13
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: st7305
model: CUSTOM
width: 400
height: 300
portrait: false
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}
init_sequence:
- [0x01]
- [0x11, 0x00]
- [0x29]
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
@@ -0,0 +1,22 @@
substitutions:
cs_pin: GPIO12
dc_pin: GPIO13
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: st7305
model: CUSTOM
width: 168
height: 384
portrait: true
column_start: 0x17
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}
init_sequence:
- [0x01]
- [0x11, 0x00]
- [0x29]
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
@@ -0,0 +1,37 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/switch/switch.h"
namespace esphome::switch_::testing {
static Switch *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static std::vector<bool> writes; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void on_write(Switch *entity, bool state) {
last_entity = entity;
writes.push_back(state);
}
TEST(ApplySwitch, WriteRunsTheFunctionWithTheState) {
last_entity = nullptr;
writes.clear();
ApplySwitch<on_write> sw;
sw.turn_on();
sw.turn_off();
EXPECT_EQ(writes, (std::vector<bool>{true, false}));
EXPECT_EQ(last_entity, &sw);
}
TEST(ApplySwitch, InversionIsAppliedBeforeTheFunction) {
writes.clear();
ApplySwitch<on_write> sw;
sw.set_inverted(true);
sw.turn_on();
EXPECT_EQ(writes, (std::vector<bool>{false}));
}
TEST(ApplySwitch, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplySwitch<on_write>), sizeof(Switch)); }
} // namespace esphome::switch_::testing
+21
View File
@@ -0,0 +1,21 @@
usb_audio:
id: usb_audio_id
microphone:
- platform: usb_audio
id: usb_mic
usb_audio_id: usb_audio_id
sample_rate: 48000
bits_per_sample: 16bit
num_channels: 2
buffer_size: 9600
speaker:
- platform: usb_audio
id: usb_speaker
usb_audio_id: usb_audio_id
sample_rate: 48000
bits_per_sample: 16bit
num_channels: 2
buffer_size: 6400
write_timeout: 20ms
@@ -0,0 +1,2 @@
packages:
usb_audio: !include common.yaml
@@ -0,0 +1,2 @@
packages:
usb_audio: !include common.yaml
+195 -178
View File
@@ -1,180 +1,197 @@
{
"tests/integration/test_action_concurrent_reentry.py": 14.33,
"tests/integration/test_addressable_light_transition.py": 18.53,
"tests/integration/test_alarm_control_panel_state_transitions.py": 15.5,
"tests/integration/test_api_action_metadata.py": 11.08,
"tests/integration/test_api_action_responses.py": 15.46,
"tests/integration/test_api_action_timeout.py": 16.54,
"tests/integration/test_api_conditional_memory.py": 11.84,
"tests/integration/test_api_custom_services.py": 13.37,
"tests/integration/test_api_decode_wire_types.py": 17.49,
"tests/integration/test_api_empty_message_roundtrip.py": 12.35,
"tests/integration/test_api_encode_boundaries.py": 10.93,
"tests/integration/test_api_get_time_response_timezone.py": 13.5,
"tests/integration/test_api_homeassistant.py": 18.7,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76,
"tests/integration/test_api_homeassistant_entities.py": 15.12,
"tests/integration/test_api_list_entities_backpressure.py": 14.2,
"tests/integration/test_api_message_size_batching.py": 13.72,
"tests/integration/test_api_outgoing_connection.py": 28.66,
"tests/integration/test_api_reboot_timeout.py": 9.01,
"tests/integration/test_api_string_lambda.py": 12.16,
"tests/integration/test_api_vv_logging.py": 10.14,
"tests/integration/test_api_zero_psk_provisioning.py": 18.26,
"tests/integration/test_areas_and_devices.py": 10.34,
"tests/integration/test_automation_wait_actions.py": 9.93,
"tests/integration/test_automations.py": 20.02,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03,
"tests/integration/test_binary_sensor_invalidate_state.py": 8.43,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39,
"tests/integration/test_branch_resume_async.py": 16.64,
"tests/integration/test_build_info.py": 12.41,
"tests/integration/test_camera_mock.py": 13.29,
"tests/integration/test_climate_control_action.py": 15.18,
"tests/integration/test_climate_custom_modes.py": 14.12,
"tests/integration/test_continuation_actions.py": 14.06,
"tests/integration/test_cover_control_action.py": 9.59,
"tests/integration/test_crc8_helper.py": 11.51,
"tests/integration/test_device_id_in_state.py": 18.41,
"tests/integration/test_duplicate_entities.py": 14.95,
"tests/integration/test_entity_icon.py": 15.57,
"tests/integration/test_entity_missing_state.py": 13.91,
"tests/integration/test_fan_turn_on_action.py": 13.85,
"tests/integration/test_fnv1_hash_object_id.py": 10.12,
"tests/integration/test_fnv1a_hash.py": 9.95,
"tests/integration/test_gpio_expander_cache.py": 4.43,
"tests/integration/test_host_logger_thread_safety.py": 12.54,
"tests/integration/test_host_mode_basic.py": 12.66,
"tests/integration/test_host_mode_batch_delay.py": 13.34,
"tests/integration/test_host_mode_climate_basic_state.py": 15.19,
"tests/integration/test_host_mode_climate_control.py": 15.35,
"tests/integration/test_host_mode_empty_string_options.py": 13.04,
"tests/integration/test_host_mode_entity_fields.py": 10.42,
"tests/integration/test_host_mode_fan_preset.py": 11.7,
"tests/integration/test_host_mode_many_entities.py": 16.79,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62,
"tests/integration/test_host_mode_noise_encryption.py": 21.06,
"tests/integration/test_host_mode_reconnect.py": 11.29,
"tests/integration/test_host_mode_sensor.py": 12.25,
"tests/integration/test_host_ota.py": 109.97,
"tests/integration/test_host_preferences.py": 12.92,
"tests/integration/test_host_preferences_suspend_resume.py": 12.3,
"tests/integration/test_host_write_after_peer_close.py": 14.42,
"tests/integration/test_improv_serial_uart.py": 16.29,
"tests/integration/test_ir_rf_transmit_complete.py": 10.73,
"tests/integration/test_large_message_batching.py": 13.94,
"tests/integration/test_legacy_area.py": 13.34,
"tests/integration/test_legacy_climate_compat.py": 13.19,
"tests/integration/test_legacy_fan_compat.py": 9.31,
"tests/integration/test_light_automations.py": 14.39,
"tests/integration/test_light_binary_effect_off_phase.py": 14.75,
"tests/integration/test_light_calls.py": 13.85,
"tests/integration/test_light_constant_brightness.py": 12.22,
"tests/integration/test_light_control_action.py": 15.46,
"tests/integration/test_light_dim_relative_action.py": 15.3,
"tests/integration/test_light_effect_zero_brightness.py": 16.75,
"tests/integration/test_light_initial_state.py": 9.8,
"tests/integration/test_light_repeat_effect.py": 22.27,
"tests/integration/test_light_restore_state.py": 15.67,
"tests/integration/test_light_resume_effect.py": 17.79,
"tests/integration/test_light_toggle_action.py": 15.22,
"tests/integration/test_light_transition_state_publish_interval.py": 44.96,
"tests/integration/test_lock_automations.py": 12.55,
"tests/integration/test_logger_buffered_recursion_guard.py": 11.78,
"tests/integration/test_loop_disable_enable.py": 13.19,
"tests/integration/test_loop_interval_decoupling.py": 15.14,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91,
"tests/integration/test_lvgl_color_picker_render.py": 91.0,
"tests/integration/test_lvgl_headless_render.py": 46.26,
"tests/integration/test_micros_to_millis.py": 9.14,
"tests/integration/test_multi_click_trigger.py": 13.62,
"tests/integration/test_multi_device_preferences.py": 15.18,
"tests/integration/test_noise_encryption_key_protection.py": 21.14,
"tests/integration/test_object_id_api_verification.py": 9.54,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78,
"tests/integration/test_object_id_no_friendly_name.py": 24.2,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9,
"tests/integration/test_online_image_bmp.py": 24.53,
"tests/integration/test_oversized_payloads.py": 43.03,
"tests/integration/test_preference_key_stability.py": 14.22,
"tests/integration/test_runtime_stats.py": 15.85,
"tests/integration/test_safe_mode_loop_runs.py": 8.89,
"tests/integration/test_scheduler_background_wake.py": 17.82,
"tests/integration/test_scheduler_blocking_warning.py": 30.27,
"tests/integration/test_scheduler_bulk_cleanup.py": 8.78,
"tests/integration/test_scheduler_defer_cancel.py": 12.9,
"tests/integration/test_scheduler_defer_cancel_regular.py": 12.79,
"tests/integration/test_scheduler_defer_fifo_simple.py": 13.14,
"tests/integration/test_scheduler_defer_stress.py": 13.2,
"tests/integration/test_scheduler_heap_stress.py": 9.15,
"tests/integration/test_scheduler_internal_id_no_collision.py": 14.81,
"tests/integration/test_scheduler_interval_reschedule.py": 13.07,
"tests/integration/test_scheduler_interval_zero_coerced.py": 12.32,
"tests/integration/test_scheduler_null_name.py": 3.81,
"tests/integration/test_scheduler_numeric_id_test.py": 9.77,
"tests/integration/test_scheduler_pool.py": 10.16,
"tests/integration/test_scheduler_rapid_cancellation.py": 4.7,
"tests/integration/test_scheduler_recursive_timeout.py": 13.72,
"tests/integration/test_scheduler_removed_item_race.py": 12.73,
"tests/integration/test_scheduler_self_keyed.py": 13.03,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88,
"tests/integration/test_scheduler_string_test.py": 14.1,
"tests/integration/test_script_array_params.py": 11.14,
"tests/integration/test_script_delay_params.py": 13.84,
"tests/integration/test_script_queued.py": 15.45,
"tests/integration/test_script_queued_idle_loop.py": 13.15,
"tests/integration/test_script_wait_on_boot.py": 12.64,
"tests/integration/test_sdl_headless_screenshot.py": 15.19,
"tests/integration/test_select_stringref_trigger.py": 13.51,
"tests/integration/test_sensor_filters_delta.py": 13.6,
"tests/integration/test_sensor_filters_ring_buffer.py": 9.22,
"tests/integration/test_sensor_filters_sliding_window.py": 40.87,
"tests/integration/test_sensor_filters_value_list.py": 15.43,
"tests/integration/test_sensor_raw_state.py": 25.12,
"tests/integration/test_sensor_timeout_filter.py": 9.64,
"tests/integration/test_sensor_timeout_throttle_filter.py": 14.63,
"tests/integration/test_set_internal_at_boot.py": 13.18,
"tests/integration/test_snapshot_display.py": 16.01,
"tests/integration/test_socket_ipv4_resolve.py": 11.3,
"tests/integration/test_socket_tcp_client_link.py": 13.88,
"tests/integration/test_socket_wake_gate_tcp.py": 14.28,
"tests/integration/test_status_flags.py": 20.55,
"tests/integration/test_status_momentary.py": 12.63,
"tests/integration/test_strftime_to.py": 9.47,
"tests/integration/test_syslog.py": 17.73,
"tests/integration/test_tcp_uart_bridge.py": 16.27,
"tests/integration/test_tcp_uart_server.py": 13.64,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42,
"tests/integration/test_template_climate_basic.py": 14.42,
"tests/integration/test_template_climate_custom_modes.py": 14.3,
"tests/integration/test_template_climate_nonoptimistic.py": 10.11,
"tests/integration/test_template_climate_on_control_ordering.py": 4.57,
"tests/integration/test_template_climate_publish_all_fields.py": 12.56,
"tests/integration/test_template_climate_sensor_push.py": 14.89,
"tests/integration/test_template_climate_set_actions.py": 14.12,
"tests/integration/test_template_climate_two_point_temperature.py": 9.08,
"tests/integration/test_template_text_save.py": 13.96,
"tests/integration/test_template_time.py": 12.73,
"tests/integration/test_text_command.py": 13.09,
"tests/integration/test_text_sensor_raw_state.py": 14.3,
"tests/integration/test_uart_mock_ld2410.py": 33.11,
"tests/integration/test_uart_mock_ld2412.py": 61.26,
"tests/integration/test_uart_mock_ld2420.py": 23.39,
"tests/integration/test_uart_mock_ld2450.py": 11.79,
"tests/integration/test_uart_mock_modbus.py": 323.58,
"tests/integration/test_uart_mock_ufm01.py": 71.03,
"tests/integration/test_uart_tcp_bridge.py": 13.3,
"tests/integration/test_udp.py": 7.04,
"tests/integration/test_use_address_runtime.py": 12.4,
"tests/integration/test_valve_control_action.py": 9.25,
"tests/integration/test_varint_five_byte_device_id.py": 9.34,
"tests/integration/test_wait_until_mid_loop_timing.py": 13.21,
"tests/integration/test_wait_until_on_boot.py": 12.66,
"tests/integration/test_wait_until_ordering.py": 9.06,
"tests/integration/test_wait_until_reentrant_restart.py": 10.83,
"tests/integration/test_wake_loop_forces_phase_b.py": 14.23,
"tests/integration/test_water_heater_template.py": 21.96
"tests/integration/test_action_concurrent_reentry.py": 14.84,
"tests/integration/test_addressable_light_transition.py": 17.96,
"tests/integration/test_alarm_control_panel_state_transitions.py": 16.42,
"tests/integration/test_api_action_metadata.py": 12.08,
"tests/integration/test_api_action_responses.py": 16.62,
"tests/integration/test_api_action_timeout.py": 17.38,
"tests/integration/test_api_conditional_memory.py": 12.24,
"tests/integration/test_api_custom_services.py": 10.53,
"tests/integration/test_api_decode_wire_types.py": 17.97,
"tests/integration/test_api_empty_message_roundtrip.py": 12.54,
"tests/integration/test_api_encode_boundaries.py": 14.46,
"tests/integration/test_api_get_time_response_timezone.py": 16.43,
"tests/integration/test_api_homeassistant.py": 17.44,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 11.63,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.61,
"tests/integration/test_api_homeassistant_entities.py": 10.45,
"tests/integration/test_api_list_entities_backpressure.py": 13.7,
"tests/integration/test_api_message_size_batching.py": 9.04,
"tests/integration/test_api_outgoing_connection.py": 26.32,
"tests/integration/test_api_reboot_timeout.py": 11.82,
"tests/integration/test_api_string_lambda.py": 11.94,
"tests/integration/test_api_vv_logging.py": 13.47,
"tests/integration/test_api_zero_psk_provisioning.py": 27.07,
"tests/integration/test_areas_and_devices.py": 9.9,
"tests/integration/test_automation_wait_actions.py": 13.22,
"tests/integration/test_automations.py": 26.92,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 14.21,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 13.7,
"tests/integration/test_binary_sensor_invalidate_state.py": 13.52,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 12.48,
"tests/integration/test_branch_resume_async.py": 16.18,
"tests/integration/test_build_info.py": 11.15,
"tests/integration/test_camera_mock.py": 13.74,
"tests/integration/test_climate_control_action.py": 14.33,
"tests/integration/test_climate_custom_modes.py": 13.85,
"tests/integration/test_continuation_actions.py": 12.9,
"tests/integration/test_cover_control_action.py": 10.2,
"tests/integration/test_crc8_helper.py": 11.98,
"tests/integration/test_device_id_in_state.py": 25.5,
"tests/integration/test_duplicate_entities.py": 14.91,
"tests/integration/test_entity_icon.py": 15.33,
"tests/integration/test_entity_missing_state.py": 8.06,
"tests/integration/test_fan_turn_on_action.py": 13.45,
"tests/integration/test_fnv1_hash_object_id.py": 10.16,
"tests/integration/test_fnv1a_hash.py": 9.94,
"tests/integration/test_gpio_expander_cache.py": 10.52,
"tests/integration/test_host_logger_thread_safety.py": 11.38,
"tests/integration/test_host_mode_basic.py": 10.96,
"tests/integration/test_host_mode_batch_delay.py": 13.46,
"tests/integration/test_host_mode_climate_basic_state.py": 7.78,
"tests/integration/test_host_mode_climate_control.py": 14.03,
"tests/integration/test_host_mode_empty_string_options.py": 12.45,
"tests/integration/test_host_mode_entity_fields.py": 15.66,
"tests/integration/test_host_mode_fan_preset.py": 9.7,
"tests/integration/test_host_mode_many_entities.py": 23.55,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 14.87,
"tests/integration/test_host_mode_noise_encryption.py": 29.31,
"tests/integration/test_host_mode_reconnect.py": 7.15,
"tests/integration/test_host_mode_sensor.py": 12.09,
"tests/integration/test_host_ota.py": 307.27,
"tests/integration/test_host_preferences.py": 13.14,
"tests/integration/test_host_preferences_suspend_resume.py": 14.41,
"tests/integration/test_host_write_after_peer_close.py": 13.85,
"tests/integration/test_improv_serial_uart.py": 14.8,
"tests/integration/test_in_range_conditions.py": 13.63,
"tests/integration/test_ir_rf_transmit_complete.py": 17.32,
"tests/integration/test_large_message_batching.py": 15.13,
"tests/integration/test_legacy_area.py": 11.23,
"tests/integration/test_legacy_climate_compat.py": 13.99,
"tests/integration/test_legacy_fan_compat.py": 8.48,
"tests/integration/test_light_automations.py": 8.52,
"tests/integration/test_light_binary_effect_off_phase.py": 20.04,
"tests/integration/test_light_calls.py": 16.91,
"tests/integration/test_light_constant_brightness.py": 10.63,
"tests/integration/test_light_control_action.py": 14.43,
"tests/integration/test_light_dim_relative_action.py": 16.59,
"tests/integration/test_light_effect_zero_brightness.py": 17.36,
"tests/integration/test_light_initial_state.py": 13.38,
"tests/integration/test_light_repeat_effect.py": 13.04,
"tests/integration/test_light_restore_state.py": 14.38,
"tests/integration/test_light_resume_effect.py": 17.23,
"tests/integration/test_light_toggle_action.py": 15.35,
"tests/integration/test_light_transition_state_publish_interval.py": 44.06,
"tests/integration/test_lock_automations.py": 13.01,
"tests/integration/test_logger_buffered_recursion_guard.py": 8.93,
"tests/integration/test_loop_disable_enable.py": 12.38,
"tests/integration/test_loop_interval_decoupling.py": 15.54,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.16,
"tests/integration/test_lvgl_color_picker_render.py": 53.51,
"tests/integration/test_lvgl_headless_render.py": 58.96,
"tests/integration/test_micros_to_millis.py": 2.84,
"tests/integration/test_multi_click_trigger.py": 8.88,
"tests/integration/test_multi_device_preferences.py": 14.72,
"tests/integration/test_noise_encryption_key_protection.py": 21.32,
"tests/integration/test_object_id_api_verification.py": 12.83,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 7.81,
"tests/integration/test_object_id_no_friendly_name.py": 24.12,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 25.21,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 25.14,
"tests/integration/test_online_image_bmp.py": 16.77,
"tests/integration/test_online_image_stalled_download.py": 17.06,
"tests/integration/test_oversized_payloads.py": 37.98,
"tests/integration/test_preference_key_stability.py": 13.43,
"tests/integration/test_runtime_stats.py": 12.02,
"tests/integration/test_safe_mode_loop_runs.py": 11.39,
"tests/integration/test_scheduler_background_wake.py": 17.85,
"tests/integration/test_scheduler_blocking_warning.py": 30.7,
"tests/integration/test_scheduler_bulk_cleanup.py": 12.73,
"tests/integration/test_scheduler_defer_cancel.py": 13.05,
"tests/integration/test_scheduler_defer_cancel_regular.py": 12.12,
"tests/integration/test_scheduler_defer_fifo_simple.py": 12.73,
"tests/integration/test_scheduler_defer_stress.py": 12.9,
"tests/integration/test_scheduler_heap_stress.py": 13.61,
"tests/integration/test_scheduler_internal_id_no_collision.py": 9.06,
"tests/integration/test_scheduler_interval_reschedule.py": 12.19,
"tests/integration/test_scheduler_interval_zero_coerced.py": 3.33,
"tests/integration/test_scheduler_loop_phase_timeout.py": 10.81,
"tests/integration/test_scheduler_null_name.py": 3.45,
"tests/integration/test_scheduler_numeric_id_test.py": 13.88,
"tests/integration/test_scheduler_pool.py": 13.86,
"tests/integration/test_scheduler_rapid_cancellation.py": 4.54,
"tests/integration/test_scheduler_recursive_timeout.py": 4.32,
"tests/integration/test_scheduler_removed_item_race.py": 13.06,
"tests/integration/test_scheduler_self_keyed.py": 4.29,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 12.79,
"tests/integration/test_scheduler_string_test.py": 8.51,
"tests/integration/test_script_array_params.py": 8.05,
"tests/integration/test_script_delay_params.py": 14.57,
"tests/integration/test_script_on_stop_and_choose.py": 14.11,
"tests/integration/test_script_queued.py": 15.68,
"tests/integration/test_script_queued_idle_loop.py": 8.46,
"tests/integration/test_script_wait_on_boot.py": 12.91,
"tests/integration/test_sdl_headless_screenshot.py": 15.05,
"tests/integration/test_select_stringref_trigger.py": 11.48,
"tests/integration/test_sensor_filters_delta.py": 13.28,
"tests/integration/test_sensor_filters_ring_buffer.py": 13.95,
"tests/integration/test_sensor_filters_sliding_window.py": 40.27,
"tests/integration/test_sensor_filters_value_list.py": 14.95,
"tests/integration/test_sensor_raw_state.py": 24.93,
"tests/integration/test_sensor_timeout_filter.py": 14.93,
"tests/integration/test_sensor_timeout_throttle_filter.py": 16.23,
"tests/integration/test_set_internal_at_boot.py": 12.69,
"tests/integration/test_snapshot_display.py": 15.83,
"tests/integration/test_socket_ipv4_resolve.py": 10.41,
"tests/integration/test_socket_tcp_client_link.py": 12.96,
"tests/integration/test_socket_wake_gate_tcp.py": 11.61,
"tests/integration/test_socket_wake_gate_udp.py": 10.45,
"tests/integration/test_status_flags.py": 20.81,
"tests/integration/test_status_momentary.py": 15.46,
"tests/integration/test_strftime_to.py": 12.97,
"tests/integration/test_syslog.py": 21.29,
"tests/integration/test_tcp_uart_bridge.py": 16.41,
"tests/integration/test_tcp_uart_server.py": 13.43,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.34,
"tests/integration/test_template_climate_basic.py": 12.78,
"tests/integration/test_template_climate_custom_modes.py": 14.81,
"tests/integration/test_template_climate_nonoptimistic.py": 8.48,
"tests/integration/test_template_climate_on_control_ordering.py": 7.38,
"tests/integration/test_template_climate_publish_all_fields.py": 8.89,
"tests/integration/test_template_climate_sensor_push.py": 15.73,
"tests/integration/test_template_climate_set_actions.py": 11.23,
"tests/integration/test_template_climate_two_point_temperature.py": 9.47,
"tests/integration/test_template_text_save.py": 13.65,
"tests/integration/test_template_time.py": 9.1,
"tests/integration/test_text_command.py": 12.29,
"tests/integration/test_text_sensor_raw_state.py": 13.19,
"tests/integration/test_uart_mock_bl0906_stray_rx.py": 13.99,
"tests/integration/test_uart_mock_dfrobot_sen0465.py": 20.14,
"tests/integration/test_uart_mock_dfrobot_sen0609.py": 10.57,
"tests/integration/test_uart_mock_ld2410.py": 31.61,
"tests/integration/test_uart_mock_ld2410s.py": 49.83,
"tests/integration/test_uart_mock_ld2412.py": 89.93,
"tests/integration/test_uart_mock_ld2420.py": 20.19,
"tests/integration/test_uart_mock_ld2450.py": 16.45,
"tests/integration/test_uart_mock_ld2450_has_target_only.py": 17.25,
"tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py": 10.7,
"tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py": 15.95,
"tests/integration/test_uart_mock_ld2450_polygon_zones.py": 11.68,
"tests/integration/test_uart_mock_ld2460.py": 15.46,
"tests/integration/test_uart_mock_modbus.py": 214.58,
"tests/integration/test_uart_mock_pylontech.py": 14.66,
"tests/integration/test_uart_mock_ufm01.py": 70.02,
"tests/integration/test_uart_tcp_bridge.py": 10.46,
"tests/integration/test_udp.py": 9.38,
"tests/integration/test_udp_ipv6.py": 8.74,
"tests/integration/test_udp_multicast.py": 11.71,
"tests/integration/test_use_address_runtime.py": 15.24,
"tests/integration/test_valve_control_action.py": 13.75,
"tests/integration/test_varint_five_byte_device_id.py": 13.56,
"tests/integration/test_wait_until_mid_loop_timing.py": 8.57,
"tests/integration/test_wait_until_on_boot.py": 9.51,
"tests/integration/test_wait_until_ordering.py": 4.48,
"tests/integration/test_wait_until_reentrant_restart.py": 5.63,
"tests/integration/test_wake_loop_forces_phase_b.py": 9.4,
"tests/integration/test_water_heater_template.py": 28.16
}
+120 -86
View File
@@ -12,12 +12,16 @@ from aioesphomeapi import (
EntityState,
HomeassistantServiceCall,
LightColorCapability,
LightInfo,
LightState,
UserService,
)
from aioesphomeapi.api_pb2 import HomeAssistantStateResponse, LightCommandRequest
from google.protobuf import message
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
ENTITY = "light.kitchen"
@@ -43,6 +47,19 @@ RGB = (
)
def command_msg(key: int, **fields: float | bool) -> LightCommandRequest:
"""A light command with has_<field> set for each field given."""
return LightCommandRequest(
key=key, **fields, **{f"has_{name}": True for name in fields}
)
def report_msg(attribute: str, value: str) -> HomeAssistantStateResponse:
return HomeAssistantStateResponse(
entity_id=ENTITY, attribute=attribute, state=value
)
@dataclass
class FakeHomeAssistant:
"""The light entity as the device sees it over the API, and what the device sends back."""
@@ -57,6 +74,19 @@ class FakeHomeAssistant:
async def next_call(self) -> HomeassistantServiceCall:
return await asyncio.wait_for(self.calls.get(), timeout=5.0)
async def expect_call(
self, service: str = "light.turn_on", **data: str
) -> HomeassistantServiceCall:
"""The next action sent; its data is checked in full when given."""
call = await self.next_call()
assert call.service == service
if data:
assert call.data == {"entity_id": ENTITY, **data}
return call
def command(self, **kwargs: float | bool | tuple[float, float, float]) -> None:
self.client.light_command(self.key, **kwargs)
async def next_state(self, matches: Callable[[LightState], bool]) -> LightState:
async with asyncio.timeout(5.0):
while not matches(state := await self.states.get()):
@@ -66,6 +96,14 @@ class FakeHomeAssistant:
def report(self, attribute: str | None, value: str) -> None:
self.client.send_home_assistant_state(ENTITY, attribute, value)
async def report_off(self) -> None:
self.report(None, "off")
await self.next_state(lambda s: not s.state)
def send_together(self, *messages: message.Message) -> None:
"""One write, so the device reads all of them in one loop pass."""
self.client._get_connection().send_messages(messages)
@asynccontextmanager
async def light_entity(
@@ -96,7 +134,7 @@ async def light_entity(
api_client_connected() as client,
):
entities, services = await client.list_entities_services()
key = next(e.key for e in entities if e.name == "HA Light")
key = require_entity(entities, "ha_light", LightInfo).key
client.subscribe_service_calls(calls.put_nowait)
client.subscribe_states(on_state)
client.subscribe_home_assistant_states(on_state_sub)
@@ -163,15 +201,11 @@ async def test_api_homeassistant_light(
# Local changes go to Home Assistant with its attribute names and scales; the color
# temperature it reported is not repeated
ha.client.light_command(ha.key, brightness=0.5)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
ha.command(brightness=0.5)
call = await ha.expect_call(brightness="128")
assert not call.data_template
ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0))
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
ha.command(rgb=(0.0, 0.0, 1.0))
call = await ha.expect_call(brightness="128")
assert call.data_template == {"rgb_color": "[0,0,255]"}
# Home Assistant echoes the result; that is not sent back. Returning to the state it
@@ -182,25 +216,24 @@ async def test_api_homeassistant_light(
await ha.waiter.wait_for(
"turned off", timeout=5.0
) # a reported state runs the triggers too
ha.client.light_command(ha.key, state=True)
assert (await ha.next_call()).service == "light.turn_on"
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
ha.command(state=True)
await ha.expect_call()
ha.command(state=False)
await ha.expect_call("light.turn_off")
# Without a report of the change within report_timeout, the light goes back to the reported state
ha.report(None, "on")
await ha.next_state(lambda s: s.state)
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
ha.command(state=False)
await ha.expect_call("light.turn_off")
await ha.next_state(lambda s: s.state)
await ha.waiter.wait_for("did not report the change back", timeout=5.0)
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
await ha.report_off()
# The fallback also drops the levels of the unsent change: the light keeps the brightness
# Home Assistant reported last, which a later turn_on without a brightness would send
ha.client.light_command(ha.key, state=True, brightness=0.4)
call = await ha.next_call()
ha.command(state=True, brightness=0.4)
call = await ha.expect_call()
assert call.data["brightness"] == "102"
state = await ha.next_state(lambda s: not s.state)
assert state.brightness == pytest.approx(200 / 255)
@@ -208,19 +241,18 @@ async def test_api_homeassistant_light(
# An automation changing the light while a report is applied is a local change too
await ha.client.execute_service(set_force_off, {"value": True})
ha.report(None, "on")
assert (await ha.next_call()).service == "light.turn_off"
await ha.expect_call("light.turn_off")
await ha.client.execute_service(set_force_off, {"value": False})
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
await ha.report_off()
# A change that leaves Home Assistant as it is gets no report, so none is sent
ha.client.light_command(ha.key, state=False)
ha.command(state=False)
await ha.waiter.wait_for("already has this state", timeout=5.0)
# A trait that arrives while a change waits for its report is not that report, and does
# not bring the old state back early either
ha.client.light_command(ha.key, state=True)
assert (await ha.next_call()).service == "light.turn_on"
ha.command(state=True)
await ha.expect_call()
await ha.next_state(lambda s: s.state)
ha.report("max_color_temp_kelvin", "6500")
await ha.next_state(lambda s: not s.state)
@@ -230,7 +262,7 @@ async def test_api_homeassistant_light(
)
# A report that arrives during a flash is applied when the flash ends instead of being undone
ha.client.light_command(ha.key, state=True, flash_length=1.0)
ha.command(state=True, flash_length=1.0)
ha.report(None, "on")
ha.report("brightness", "77")
state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255))
@@ -239,84 +271,96 @@ async def test_api_homeassistant_light(
# A trait or a repeated report during a local transition is nothing new, so the change the
# transition ends at is still sent
ha.client.light_command(ha.key, state=False, transition_length=1.0)
ha.command(state=False, transition_length=1.0)
ha.report("max_color_temp_kelvin", "6500")
ha.report(None, "on")
assert (await ha.next_call()).service == "light.turn_off"
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
await ha.expect_call("light.turn_off")
await ha.report_off()
# A newer local command wins over a report held during a transition
ha.client.light_command(ha.key, state=True, transition_length=1.0)
ha.command(state=True, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1) # held before the command arrives
ha.client.light_command(ha.key, state=True, brightness=1.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
ha.command(state=True, brightness=1.0)
call = await ha.expect_call()
assert call.data["brightness"] == "255"
ha.report(None, "on")
ha.report("brightness", "255")
await ha.next_state(lambda s: s.state and s.brightness == 1.0)
# Also when it changes only the color, at the level the transition was heading for
ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0)
ha.command(brightness=0.5, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, color_temperature=400.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {
"entity_id": ENTITY,
"brightness": "128",
"color_temp_kelvin": "2500",
}
ha.command(color_temperature=400.0)
await ha.expect_call(brightness="128", color_temp_kelvin="2500")
ha.report("brightness", "128")
ha.report("color_temp_kelvin", "2500")
await ha.next_state(lambda s: s.color_temperature == 400.0)
# Or when it is a transition itself, replacing the one the report was held for
ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0)
ha.command(brightness=1.0, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {
"entity_id": ENTITY,
"brightness": "255",
"color_temp_kelvin": "4000",
}
ha.command(color_temperature=250.0, transition_length=1.0)
await ha.expect_call(brightness="255", color_temp_kelvin="4000")
ha.report("brightness", "255")
ha.report("color_temp_kelvin", "4000")
await ha.next_state(lambda s: s.color_temperature == 250.0)
# Or a transition back to where the one the report was held for started
ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0)
ha.command(brightness=0.5, transition_length=1.0)
ha.report("brightness", "50")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0)
call = await ha.next_call()
assert call.service == "light.turn_on"
ha.command(brightness=1.0, transition_length=1.0)
call = await ha.expect_call()
assert call.data["brightness"] == "255"
ha.report("brightness", "255")
await ha.next_state(lambda s: s.brightness == 1.0)
# And when it goes back to the state the transition started from
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
ha.client.light_command(ha.key, state=True, transition_length=1.0)
ha.command(state=False)
await ha.expect_call("light.turn_off")
await ha.report_off()
ha.command(state=True, transition_length=1.0)
ha.report(None, "on")
ha.report("brightness", "60")
await asyncio.sleep(0.1)
ha.client.light_command(ha.key, state=False)
assert (await ha.next_call()).service == "light.turn_off"
ha.report(None, "off")
await ha.next_state(lambda s: not s.state)
ha.command(state=False)
await ha.expect_call("light.turn_off")
await ha.report_off()
# Also when the report and the call that replaces the transition are read in the same loop
# pass: the report is applied after the call, but it is older
ha.send_together(
command_msg(ha.key, state=True, brightness=0.5, transition_length=1000),
report_msg("brightness", "50"),
command_msg(ha.key, brightness=1.0, transition_length=1000),
)
call = await ha.expect_call()
assert call.data["brightness"] == "255"
ha.report(None, "on")
ha.report("brightness", "255")
await ha.next_state(lambda s: s.state and s.brightness == 1.0)
# And a report read after that call in the same pass is newer than it: the hold follows the
# last changed report, even when a trait opened the batch, so it is applied at the end
ha.send_together(
report_msg("max_color_temp_kelvin", "6500"),
command_msg(ha.key, brightness=0.5, transition_length=1000),
report_msg("brightness", "50"),
command_msg(ha.key, brightness=0.8, transition_length=1000),
report_msg("brightness", "77"),
)
state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255))
assert state.state
assert ha.calls.empty()
ha.command(state=False)
await ha.expect_call("light.turn_off")
await ha.report_off()
# A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told
ha.client.light_command(ha.key, state=True, flash_length=0.5)
ha.command(state=True, flash_length=0.5)
await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0)
assert ha.calls.empty()
@@ -347,45 +391,35 @@ async def test_api_homeassistant_light_rgbww(
# A level change sends the level alone; Home Assistant derived the color it reports from
# the white balance and would derive a different balance from the color sent back
ha.client.light_command(ha.key, brightness=0.5)
call = await ha.next_call()
assert call.service == "light.turn_on"
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
ha.command(brightness=0.5)
call = await ha.expect_call(brightness="128")
assert not call.data_template
ha.report("brightness", "128")
await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255))
# A new color goes along
ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0))
call = await ha.next_call()
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
ha.command(rgb=(0.0, 1.0, 0.0))
call = await ha.expect_call(brightness="128")
assert call.data_template == {"rgb_color": "[0,255,0]"}
ha.report("rgb_color", "(0, 255, 0)")
await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0)
# Switching to color temperature sends it, and the level alone once there
ha.client.light_command(ha.key, color_temperature=250.0)
call = await ha.next_call()
assert call.data == {
"entity_id": ENTITY,
"brightness": "128",
"color_temp_kelvin": "4000",
}
ha.command(color_temperature=250.0)
call = await ha.expect_call(brightness="128", color_temp_kelvin="4000")
assert not call.data_template
ha.report("color_mode", "color_temp")
ha.report("color_temp_kelvin", "4000")
state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE)
assert state.color_temperature == 250.0
ha.client.light_command(ha.key, brightness=1.0)
call = await ha.next_call()
assert call.data == {"entity_id": ENTITY, "brightness": "255"}
ha.command(brightness=1.0)
await ha.expect_call(brightness="255")
ha.report("brightness", "255")
await ha.next_state(lambda s: s.brightness == 1.0)
# Back to the color Home Assistant still reports: the mode changed, so the color is sent
ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0))
call = await ha.next_call()
assert call.data == {"entity_id": ENTITY, "brightness": "255"}
ha.command(rgb=(0.0, 1.0, 0.0))
call = await ha.expect_call(brightness="255")
assert call.data_template == {"rgb_color": "[0,255,0]"}
ha.report("color_mode", "rgbww")
await ha.next_state(lambda s: s.color_mode == RGB)
+317 -3
View File
@@ -7,6 +7,8 @@ from typing import Any
import pytest
import esphome.codegen as cg
from esphome.components import button, number, select, switch
from esphome.config_validation import Invalid
from esphome.const import (
CONF_DEVICE_CLASS,
@@ -33,7 +35,7 @@ from esphome.core.entity_helpers import (
setup_entity,
setup_unit_of_measurement,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_generator import MockObj, MockObjClass, RawExpression
from esphome.helpers import fnv1_hash, sanitize, snake_case
from .common import load_config_from_fixture
@@ -1255,10 +1257,11 @@ async def test_sub_entities_creates_and_sets(
monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented)
hub = MockObj("hub", "->")
sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub)
conf = {CONF_ID: ID("child")}
sub = entity_helpers.SubEntities(new_entity, {"child": conf}, parent=hub)
assert await sub("child", hub.set_child, 1, step=2) is created
assert calls == [({}, (1,), {"step": 2})]
assert calls == [(conf, (1,), {"step": 2})]
assert setup_test_environment == ["parent child hub", "hub->set_child(child)"]
@@ -1294,3 +1297,314 @@ async def test_sub_entities_without_parent(
assert await sub("child", hub.set_child) is not None
assert setup_test_environment == ["hub->set_child(child)"]
@pytest.fixture
def apply_functions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str]]:
"""Record every generated apply function as (name, parameters, body)."""
emitted: list[tuple[str, str, str]] = []
def fake_static_function(
name: str, return_type: Any, parameters: list[tuple[Any, str]], body: list[str]
) -> RawExpression:
assert str(return_type) == "void"
params = ", ".join(f"{type_} {arg}" for type_, arg in parameters)
emitted.append((name, params, "\n".join(body)))
return RawExpression(name)
monkeypatch.setattr(entity_helpers.cg, "static_function", fake_static_function)
CORE.data.pop(entity_helpers._APPLY_DOMAIN, None)
return emitted
_HUB = MockObj("hub", "->")
_APPLY_SWITCH_CLS = cg.esphome_ns.namespace("switch_").class_("ApplySwitch")
_SWITCH_APPLY = entity_helpers.ApplyEntity(
_APPLY_SWITCH_CLS,
((RawExpression("switch_::Switch *"), "entity"), (RawExpression("bool"), "state")),
"entity->publish_state(state);",
)
_BUTTON_APPLY = entity_helpers.ApplyEntity(
cg.esphome_ns.namespace("button").class_("ApplyButton"),
((RawExpression("button::Button *"), "entity"),),
)
def _apply_conf(name: str, type_: MockObjClass = _APPLY_SWITCH_CLS) -> dict[str, ID]:
return {CONF_ID: ID(name, type=type_)}
def _apply_sub(
calls: list[tuple[Any, ...]],
config: dict[str, Any],
apply_entity: entity_helpers.ApplyEntity = _SWITCH_APPLY,
parent: MockObj | ID | None = _HUB,
) -> entity_helpers.SubEntities:
async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj:
calls.append((conf[CONF_ID].id, *(str(a) for a in args)))
return MockObj(conf[CONF_ID].id)
return entity_helpers.SubEntities(new_entity, config, parent, apply_entity)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("publish", "body"),
[
(None, "::hub->set_bluetooth(state);"),
(
"before",
"entity->publish_state(state);\n::hub->set_bluetooth(state);",
),
("after", "::hub->set_bluetooth(state);\nentity->publish_state(state);"),
],
)
async def test_sub_entities_apply_generates_function(
monkeypatch: pytest.MonkeyPatch,
setup_test_environment: list[str],
apply_functions: list[tuple[str, str, str]],
publish: str | None,
body: str,
) -> None:
"""With apply the entity is templated on a generated function and stores no parent."""
async def fail_register_parented(var: MockObj, parent: MockObj) -> None:
raise AssertionError("must not be called")
monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented)
calls: list[tuple[Any, ...]] = []
sub = _apply_sub(calls, {"bt": _apply_conf("bt_sw")})
var = await sub(
"bt", _HUB.set_bt_switch, apply="set_bluetooth(state)", publish=publish
)
assert str(var) == "bt_sw"
assert calls == [("bt_sw", "<esphome__bt_sw__fn>")]
assert apply_functions == [
("esphome__bt_sw__fn", "switch_::Switch * entity, bool state", body)
]
assert setup_test_environment == ["hub->set_bt_switch(bt_sw)"]
_SELECT_APPLY = entity_helpers.ApplyEntity(
cg.esphome_ns.namespace("select").class_("ApplySelect"),
((RawExpression("select::Select *"), "entity"), (RawExpression("size_t"), "index")),
"entity->publish_state(index);",
(("option", "const char *option = entity->option_at(index);"),),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("apply", "publish", "body"),
[
(
"set_mode(option)",
"after",
(
"const char *option = entity->option_at(index);\n"
"::hub->set_mode(option);\nentity->publish_state(index);"
),
),
(
"set_mode(option)",
"before",
(
"entity->publish_state(index);\n"
"const char *option = entity->option_at(index);\n"
"::hub->set_mode(option);"
),
),
(
"set_mode(index, options_)",
"after",
"::hub->set_mode(index, options_);\nentity->publish_state(index);",
),
],
)
async def test_sub_entities_apply_locals(
setup_test_environment: list[str],
apply_functions: list[tuple[str, str, str]],
apply: str,
publish: str,
body: str,
) -> None:
"""A local is declared before its first use, and only when named as a whole identifier."""
conf = _apply_conf("m_sel", _SELECT_APPLY.cls)
sub = _apply_sub([], {"m": conf}, _SELECT_APPLY)
await sub("m", None, apply=apply, publish=publish)
assert apply_functions == [
("esphome__m_sel__fn", "select::Select * entity, size_t index", body)
]
@pytest.mark.asyncio
async def test_sub_entities_apply_shares_equal_functions(
setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]]
) -> None:
"""Entities with the same statement share one function, so one class instantiation."""
calls: list[tuple[Any, ...]] = []
config = {
"a": _apply_conf("a_sw"),
"b": _apply_conf("b_sw"),
"c": _apply_conf("c_sw"),
}
sub = _apply_sub(calls, config)
await sub("a", _HUB.set_a, apply="refresh()", publish="before")
await sub("b", _HUB.set_b, apply="refresh()", publish="before")
await sub("c", _HUB.set_c, apply="refresh()")
assert calls == [
("a_sw", "<esphome__a_sw__fn>"),
("b_sw", "<esphome__a_sw__fn>"),
("c_sw", "<esphome__c_sw__fn>"),
]
assert [name for name, _, _ in apply_functions] == [
"esphome__a_sw__fn",
"esphome__c_sw__fn",
]
@pytest.mark.asyncio
async def test_sub_entities_apply_parent_id(
monkeypatch: pytest.MonkeyPatch,
setup_test_environment: list[str],
apply_functions: list[tuple[str, str, str]],
) -> None:
"""A parent given as an ID is looked up once and named from global scope."""
async def fake_get_variable(id_: ID) -> MockObj:
assert id_ == ID("my_hub")
return MockObj("my_hub", "->")
monkeypatch.setattr(entity_helpers, "get_variable", fake_get_variable)
calls: list[tuple[Any, ...]] = []
sub = _apply_sub(calls, {"r": _apply_conf("r_sw")}, parent=ID("my_hub"))
await sub("r", MockObj("my_hub", "->").set_r, apply="restart()")
assert apply_functions[0][2] == "::my_hub->restart();"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("apply_entity", "conf", "kwargs", "match"),
[
(_SWITCH_APPLY, _apply_conf("k_id"), {}, "needs apply="),
(
_SWITCH_APPLY,
_apply_conf("k_id", cg.esphome_ns.namespace("x").class_("MySwitch")),
{"apply": "x()"},
"needs the domain's Apply class",
),
(
_BUTTON_APPLY,
_apply_conf(
"k_id", cg.esphome_ns.namespace("button").class_("ApplyButton")
),
{"apply": "restart()", "publish": "before"},
"publish= needs",
),
(
_SWITCH_APPLY,
_apply_conf("k_id", cg.esphome_ns.namespace("x").class_("MySwitch")),
{"publish": "before"},
"publish= needs",
),
(
_SWITCH_APPLY,
_apply_conf("k_id"),
{"apply": "x()", "publish": "Before"},
"must be",
),
],
)
async def test_sub_entities_apply_errors(
setup_test_environment: list[str],
apply_functions: list[tuple[str, str, str]],
apply_entity: entity_helpers.ApplyEntity,
conf: dict[str, ID],
kwargs: dict[str, str],
match: str,
) -> None:
"""A mismatch between the declared class and apply= fails in Python, not in C++."""
calls: list[tuple[Any, ...]] = []
sub = _apply_sub(calls, {"k": conf}, apply_entity)
with pytest.raises(ValueError, match=match):
await sub("k", MockObj("hub", "->").set_k, **kwargs)
assert calls == []
assert apply_functions == []
@pytest.mark.asyncio
async def test_sub_entities_apply_needs_parent(
setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]]
) -> None:
calls: list[tuple[Any, ...]] = []
sub = _apply_sub(calls, {"k": _apply_conf("k_id")}, parent=None)
with pytest.raises(ValueError, match="needs a parent"):
await sub("k", None, apply="x()")
@pytest.mark.parametrize(
("sub_fn", "cls", "params", "publish"),
[
(button.sub_buttons, button.ApplyButton, "button::Button * entity", None),
(
switch.sub_switches,
switch.ApplySwitch,
"switch_::Switch * entity, bool state",
"entity->publish_state(state);",
),
(
number.sub_numbers,
number.ApplyNumber,
"number::Number * entity, float value",
"entity->publish_state(value);",
),
(
select.sub_selects,
select.ApplySelect,
"select::Select * entity, size_t index",
"entity->publish_state(index);",
),
],
)
def test_domain_apply_entities(
sub_fn: Callable[..., entity_helpers.SubEntities],
cls: MockObjClass,
params: str,
publish: str | None,
) -> None:
"""Each domain's sub helper declares its Apply class and the function it calls."""
apply_entity = sub_fn({}).apply_entity
assert apply_entity.cls is cls
assert ", ".join(f"{type_} {arg}" for type_, arg in apply_entity.params) == params
assert apply_entity.publish == publish
@pytest.mark.asyncio
async def test_sub_entities_without_setter(
setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]]
) -> None:
"""A hub that does not keep the entity passes no setter; nothing else is emitted."""
calls: list[tuple[Any, ...]] = []
sub = _apply_sub(calls, {"vent": _apply_conf("vent_sw")})
assert await sub("vent", None, apply="vent_door()") is not None
assert calls == [("vent_sw", "<esphome__vent_sw__fn>")]
assert setup_test_environment == []
@pytest.mark.asyncio
async def test_sub_entities_create_from_whole_config(
setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]]
) -> None:
"""create() builds the entity from a config that is the entity itself."""
calls: list[tuple[Any, ...]] = []
sub = _apply_sub(calls, {})
var = await sub.create(
_apply_conf("rf_sw"),
_HUB.set_rf_switch,
apply="set_rf_mode(state)",
publish="before",
)
assert str(var) == "rf_sw"
assert calls == [("rf_sw", "<esphome__rf_sw__fn>")]
assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"]
+48
View File
@@ -69,6 +69,7 @@ from esphome.__main__ import (
upload_using_esptool,
upload_using_picotool,
upload_using_platformio,
write_cpp,
)
from esphome.address_cache import AddressCache
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
@@ -108,6 +109,7 @@ from esphome.const import (
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
@@ -4861,6 +4863,52 @@ def test_run_esphome_dashboard_redirects_to_device_builder(
assert "esphome-device-builder" in caplog.text
def test_write_cpp_writes_gitignore_and_checks_build_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""write_cpp writes the .gitignore and checks the build tree isn't tracked."""
monkeypatch.delenv(ENV_NOGITIGNORE, raising=False)
with (
patch("esphome.writer.update_storage_json") as mock_update_storage,
patch("esphome.writer.write_gitignore") as mock_write_gitignore,
patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked,
patch("esphome.__main__.generate_cpp_contents") as mock_generate,
patch("esphome.__main__.write_cpp_file", return_value=0) as mock_write_file,
):
result = write_cpp({})
assert result == 0
mock_update_storage.assert_called_once()
mock_write_gitignore.assert_called_once()
mock_check_tracked.assert_called_once()
mock_generate.assert_called_once_with({})
mock_write_file.assert_called_once()
def test_write_cpp_skips_gitignore_but_still_checks_build_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ESPHOME_NOGITIGNORE skips writing .gitignore.
The (read-only) tracked-file check still runs -- it isn't tied to
whether ESPHome is allowed to write files.
"""
monkeypatch.setenv(ENV_NOGITIGNORE, "true")
with (
patch("esphome.writer.update_storage_json"),
patch("esphome.writer.write_gitignore") as mock_write_gitignore,
patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked,
patch("esphome.__main__.generate_cpp_contents"),
patch("esphome.__main__.write_cpp_file", return_value=0),
):
write_cpp({})
mock_write_gitignore.assert_not_called()
mock_check_tracked.assert_called_once()
def test_command_config_hash(
tmp_path: Path,
capfd: CaptureFixture[str],
+254
View File
@@ -5,10 +5,12 @@ from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
import json
import logging
import os
from pathlib import Path
import re
import stat
import subprocess
from typing import Any
from unittest.mock import MagicMock, patch
@@ -32,6 +34,7 @@ from esphome.writer import (
CPP_INCLUDE_BEGIN,
CPP_INCLUDE_END,
GITIGNORE_CONTENT,
check_build_tree_not_tracked,
clean_all,
clean_build,
clean_cmake_cache,
@@ -874,6 +877,244 @@ def test_write_gitignore_skips_existing_file(
assert gitignore_path.read_text() == existing_content
@patch("esphome.writer.subprocess.run")
def test_check_build_tree_not_tracked_silent_in_ci(
mock_run: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CI builds fixture configs, not a user's repo -- skip the git spawn entirely."""
monkeypatch.setenv("CI", "true")
check_build_tree_not_tracked()
mock_run.assert_not_called()
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_warns_when_tracked(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_build_tree_not_tracked warns when the build dir is tracked by git."""
monkeypatch.delenv("CI", raising=False)
data_dir = tmp_path / ".esphome"
mock_core.config_dir = tmp_path
mock_core.data_dir = data_dir
mock_run.return_value = MagicMock(
returncode=0,
stdout=b".esphome/build/node/main.cpp\0.esphome/node.json\0",
stderr=b"",
)
with (
patch.dict(os.environ, {"GIT_DIR": "/somewhere/else/.git"}),
caplog.at_level("WARNING"),
):
check_build_tree_not_tracked()
assert "is tracked by git" in caplog.text
assert "2 files" in caplog.text
assert "secrets" in caplog.text
assert ".gitkeep" in caplog.text
# The remediation command must use the absolute path so it also works
# when pasted from the repository root of a nested config layout.
assert f"git rm -r --cached {data_dir}" in caplog.text
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
assert args[0] == ["git", "ls-files", "-z", "--", ".esphome"]
assert kwargs["cwd"] == tmp_path
assert kwargs["capture_output"] is True
assert kwargs["check"] is False
assert kwargs["timeout"] == 5
# Repo-scoping env vars must be stripped so a hook/CI wrapper can't
# redirect this read-only query to a different repository.
assert "GIT_DIR" not in kwargs["env"]
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_when_gitkeep_present(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_build_tree_not_tracked is skipped when .gitkeep opts in."""
monkeypatch.delenv("CI", raising=False)
data_dir = tmp_path / ".esphome"
data_dir.mkdir()
(data_dir / ".gitkeep").touch()
mock_core.config_dir = tmp_path
mock_core.data_dir = data_dir
with caplog.at_level("WARNING"):
check_build_tree_not_tracked()
mock_run.assert_not_called()
assert "tracked by git" not in caplog.text
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_when_untracked(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_build_tree_not_tracked stays silent when nothing is tracked."""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path
mock_core.data_dir = tmp_path / ".esphome"
mock_run.return_value = MagicMock(returncode=0, stdout=b"", stderr=b"")
with caplog.at_level("WARNING"):
check_build_tree_not_tracked()
assert "tracked by git" not in caplog.text
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_outside_git_repo(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_build_tree_not_tracked stays silent when not inside a git repo."""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path
mock_core.data_dir = tmp_path / ".esphome"
mock_run.return_value = MagicMock(
returncode=128, stdout=b"", stderr=b"fatal: not a git repository\n"
)
with caplog.at_level("WARNING"):
check_build_tree_not_tracked()
assert "tracked by git" not in caplog.text
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_on_failure_without_stderr(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A non-zero exit with no stderr must not raise trying to log the reason."""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path
mock_core.data_dir = tmp_path / ".esphome"
mock_run.return_value = MagicMock(returncode=1, stdout=b"", stderr=b"")
with caplog.at_level("WARNING"):
check_build_tree_not_tracked()
assert "tracked by git" not in caplog.text
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_when_git_missing(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_build_tree_not_tracked stays silent when git isn't installed."""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path
mock_core.data_dir = tmp_path / ".esphome"
mock_run.side_effect = FileNotFoundError()
with caplog.at_level("DEBUG"):
check_build_tree_not_tracked()
assert caplog.text == ""
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_on_other_os_error(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A non-executable git on PATH (PermissionError) must not abort the build."""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path
mock_core.data_dir = tmp_path / ".esphome"
mock_run.side_effect = PermissionError("git is not executable")
with caplog.at_level("DEBUG"):
check_build_tree_not_tracked()
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
assert "Could not check whether" in caplog.text
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_on_timeout(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A hung git process must not block the build indefinitely."""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path
mock_core.data_dir = tmp_path / ".esphome"
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5)
with caplog.at_level("DEBUG"):
check_build_tree_not_tracked()
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
assert "Could not check whether" in caplog.text
@patch("esphome.writer.subprocess.run")
@patch("esphome.writer.CORE")
def test_check_build_tree_not_tracked_silent_when_data_dir_outside_config_dir(
mock_core: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_build_tree_not_tracked skips the check for an external data dir.
Covers the Home Assistant add-on (/data) and ESPHOME_DATA_DIR cases, where
the build directory can't be part of the config's git repository.
"""
monkeypatch.delenv("CI", raising=False)
mock_core.config_dir = tmp_path / "config"
mock_core.data_dir = tmp_path / "data"
with caplog.at_level("WARNING"):
check_build_tree_not_tracked()
mock_run.assert_not_called()
assert "tracked by git" not in caplog.text
@patch("esphome.writer.write_file_if_changed") # Mock to capture output
@patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex
@patch("esphome.writer.CORE")
@@ -1373,6 +1614,7 @@ def test_clean_all(
build_dir2.mkdir()
(build_dir1 / "dummy.txt").write_text("x")
(build_dir2 / "dummy.txt").write_text("x")
(build_dir1 / ".gitkeep").touch()
# Create PlatformIO directories
pio_cache = tmp_path / "pio_cache"
@@ -1415,6 +1657,10 @@ def test_clean_all(
# Verify that files in .esphome were removed
assert not (build_dir1 / "dummy.txt").exists()
assert not (build_dir2 / "dummy.txt").exists()
# .gitkeep is the opt-out check_build_tree_not_tracked() advertises; it
# must survive clean-all or the warning comes right back on next compile.
assert (build_dir1 / ".gitkeep").exists()
assert not pio_cache.exists()
assert not pio_packages.exists()
assert not pio_platforms.exists()
@@ -1944,6 +2190,7 @@ def test_generate_build_info_data_h_format() -> None:
assert "#pragma once" in result
assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result
assert "extern const uint32_t ESPHOME_CONFIG_VERSION_HASH;" in result
assert "extern const time_t ESPHOME_BUILD_TIME;" in result
assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result
assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result
@@ -1960,6 +2207,13 @@ def test_generate_build_info_data_h_esp8266_progmem() -> None:
assert "PROGMEM" in result
def test_generate_build_info_data_cpp_config_version_hash() -> None:
"""The config version hash is folded from the config hash and ESPHOME_VERSION."""
result = generate_build_info_data_cpp(0x12345678, 1700000000, "", "")
assert "fnv1a_hash_extend(0x12345678U, ESPHOME_VERSION);" in result
assert "ESPHOME_CONFIG_VERSION_HASH = CONFIG_VERSION_HASH;" in result
def test_generate_build_info_data_cpp_format() -> None:
"""Test generate_build_info_data_cpp produces correct data definitions."""
result = generate_build_info_data_cpp(