mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8915d06b05 | ||
|
|
85dd0f71b3 | ||
|
|
7cf72c287d | ||
|
|
dc56b27f33 | ||
|
|
384c2ec26c | ||
|
|
6868011336 | ||
|
|
8ffdbfd4ce | ||
|
|
1cd5641900 | ||
|
|
15b988be9c | ||
|
|
2d0e3faa3d | ||
|
|
2087d28684 | ||
|
|
b919b692f7 | ||
|
|
4498ad2c77 | ||
|
|
2addbaaa7f | ||
|
|
1dc4006bfc | ||
|
|
7df9b5ea2f | ||
|
|
a8ddb2535e | ||
|
|
d4922fb446 | ||
|
|
7acbe82c27 | ||
|
|
1a0b43449e | ||
|
|
860d819e33 | ||
|
|
f8f25c1ad4 | ||
|
|
22536f9338 | ||
|
|
c64a7fb678 | ||
|
|
6793dad8ed | ||
|
|
3dd9a28ed2 | ||
|
|
5a93de82bf |
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DOMAIN = "st7305"
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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
|
||||
@@ -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 µphone = this->endpoint_(Endpoint::MICROPHONE);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"USB Audio:\n"
|
||||
" Device connected: %s\n"
|
||||
" Speaker registered: %s\n"
|
||||
" Microphone registered: %s",
|
||||
YESNO(speaker.handle.load() != nullptr || microphone.handle.load() != nullptr),
|
||||
YESNO(speaker.platform != nullptr), YESNO(microphone.platform != nullptr));
|
||||
}
|
||||
|
||||
bool USBAudioComponent::teardown() {
|
||||
if (this->event_queue_ == nullptr) {
|
||||
return true;
|
||||
}
|
||||
// Stop the platforms first, then let the worker close each device once its platform is idle.
|
||||
this->begin_close_(Endpoint::SPEAKER);
|
||||
this->begin_close_(Endpoint::MICROPHONE);
|
||||
// The loop time does not advance while components tear down.
|
||||
const uint32_t now = millis();
|
||||
this->process_events_(now);
|
||||
if (!this->finish_closes_(now)) {
|
||||
return false;
|
||||
}
|
||||
// Wait for the worker to finish whatever it was still running.
|
||||
if (uxQueueMessagesWaiting(this->op_queue_) != 0 || this->driver_busy_) {
|
||||
return false;
|
||||
}
|
||||
xQueueReset(this->event_queue_);
|
||||
|
||||
if (this->uac_installed_) {
|
||||
esp_err_t err = uac_host_uninstall();
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
return false;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "uac_host_uninstall returned %s", esp_err_to_name(err));
|
||||
}
|
||||
this->uac_installed_ = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void USBAudioComponent::resume(Endpoint endpoint) {
|
||||
auto &ep = this->endpoint_(endpoint);
|
||||
ep.active = true;
|
||||
ep.request_seq++;
|
||||
const uac_host_device_handle_t handle = ep.handle;
|
||||
if (handle == nullptr) {
|
||||
if (ep.open_failed && !ep.open_pending) {
|
||||
// The device is still plugged in but its last open failed; try again for this start.
|
||||
ep.open_failed = false;
|
||||
this->open_stream_(endpoint, ep.failed_addr, ep.failed_iface);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "%s interface not connected yet", LOG_STR_ARG(endpoint_name(endpoint)));
|
||||
return;
|
||||
}
|
||||
if (ep.start_pending) {
|
||||
// The pending start reports back to the platform, and checks `active` first.
|
||||
return;
|
||||
}
|
||||
if (!ep.stream_started) {
|
||||
if (ep.start_rejected) {
|
||||
// Already probed and logged for this device; a retrying caller must not flood the log.
|
||||
this->stream_failed_(endpoint);
|
||||
return;
|
||||
}
|
||||
this->start_stream_(endpoint);
|
||||
return;
|
||||
}
|
||||
// Queued even while an earlier resume or suspend is pending: the worker runs them in order, so the driver ends in
|
||||
// the latest requested state, and only this request's RESUMED reports back.
|
||||
Op op;
|
||||
op.type = Op::Type::RESUME;
|
||||
op.endpoint = endpoint;
|
||||
op.handle = handle;
|
||||
op.seq = ep.request_seq;
|
||||
if (!this->queue_op_(op)) {
|
||||
this->stream_failed_(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
void USBAudioComponent::suspend(Endpoint endpoint) {
|
||||
auto &ep = this->endpoint_(endpoint);
|
||||
ep.active = false;
|
||||
ep.request_seq++;
|
||||
const uac_host_device_handle_t handle = ep.handle;
|
||||
// A start still running on the worker is suspended once its STARTED arrives.
|
||||
if (!ep.stream_started || handle == nullptr) {
|
||||
return;
|
||||
}
|
||||
Op op;
|
||||
op.type = Op::Type::SUSPEND;
|
||||
op.endpoint = endpoint;
|
||||
op.handle = handle;
|
||||
this->queue_op_(op);
|
||||
}
|
||||
|
||||
bool USBAudioComponent::enter_driver_(uint32_t &timeout_ms) {
|
||||
const uint32_t start = millis();
|
||||
while (true) {
|
||||
// Counted before the busy check, so the worker waits for this call to leave the driver before it takes it.
|
||||
this->driver_users_.fetch_add(1);
|
||||
if (!this->driver_busy_) {
|
||||
const uint32_t waited = millis() - start;
|
||||
timeout_ms = waited < timeout_ms ? timeout_ms - waited : 0;
|
||||
return true;
|
||||
}
|
||||
this->driver_users_.fetch_sub(1);
|
||||
// A control request takes a few milliseconds, so go on as soon as the worker is done with it.
|
||||
if (millis() - start >= timeout_ms) {
|
||||
return false;
|
||||
}
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t USBAudioComponent::read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms) {
|
||||
*bytes_read = 0;
|
||||
if (!this->enter_driver_(timeout_ms)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
uac_host_device_handle_t handle = this->endpoint_(Endpoint::MICROPHONE).handle;
|
||||
if (handle == nullptr) {
|
||||
this->driver_users_.fetch_sub(1);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
// The driver collects up to `size` bytes for the whole timeout and returns what it got, or ESP_ERR_TIMEOUT when
|
||||
// nothing arrived.
|
||||
uint32_t actual = 0;
|
||||
esp_err_t err = uac_host_device_read(handle, buffer, static_cast<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
|
||||
@@ -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
|
||||
@@ -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_;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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[] = "";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"]
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user