From 384c2ec26c70ba04c97a3cd0f1d39a2b83703ff3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:49:34 -1000 Subject: [PATCH] [ci] Extend the hardware-test skill with the audio, globals, outgoing connection and Home Assistant light flows (#21104) --- .agents/skills/hardware-test/SKILL.md | 13 +- .../hardware-test/reference/recipes/README.md | 5 +- .../hardware-test/reference/recipes/api.md | 23 +++ .../reference/recipes/globals.md | 15 ++ .../reference/recipes/homeassistant.md | 35 +++++ .../reference/recipes/usb_audio.md | 52 +++++++ .../hardware-test/scripts/api_button.py | 33 ++++ .../scripts/api_outgoing/cycles.py | 73 +++++++++ .../scripts/api_outgoing/wificycles.py | 107 +++++++++++++ .../scripts/homeassistant_light/bulb.py | 78 ++++++++++ .../hardware-test/scripts/serial_capture.py | 30 ++-- .../scripts/usb_audio/playurl.py | 30 ++++ .../templates/aux-2-preprod-stock-dut.yaml | 23 +++ .../aux-2-preprod-usb-audio-dut.yaml | 39 +++++ .../cast-pro-1-sendspin-control.yaml | 27 ++++ .../templates/deep-sleep-outgoing-dut.yaml | 46 ++++++ .../templates/globals-storage-dut.yaml | 66 ++++++++ .../templates/homeassistant-light-dut.yaml | 145 ++++++++++++++++++ .../templates/outgoing-wifi-drop-dut.yaml | 55 +++++++ .../usb-audio-sendspin-s3-eth-dut.yaml | 76 +++++++++ .../usb-audio-waveshare-s3-eth-dut.yaml | 142 +++++++++++++++++ 21 files changed, 1097 insertions(+), 16 deletions(-) create mode 100644 .agents/skills/hardware-test/reference/recipes/globals.md create mode 100644 .agents/skills/hardware-test/reference/recipes/homeassistant.md create mode 100644 .agents/skills/hardware-test/reference/recipes/usb_audio.md create mode 100644 .agents/skills/hardware-test/scripts/api_button.py create mode 100644 .agents/skills/hardware-test/scripts/api_outgoing/cycles.py create mode 100644 .agents/skills/hardware-test/scripts/api_outgoing/wificycles.py create mode 100644 .agents/skills/hardware-test/scripts/homeassistant_light/bulb.py create mode 100644 .agents/skills/hardware-test/scripts/usb_audio/playurl.py create mode 100644 .agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml create mode 100644 .agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/globals-storage-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/homeassistant-light-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index d5e994a82f0..ec841c97612 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -93,13 +93,15 @@ ESP-IDF OTA image: `.esphome/build//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 ``, 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 ``, 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/ (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/.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 diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md index 7c7871f45ce..3db5e05f05a 100644 --- a/.agents/skills/hardware-test/reference/recipes/README.md +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -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 diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md index fa6733a72c6..4776fe72dc4 100644 --- a/.agents/skills/hardware-test/reference/recipes/api.md +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -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 diff --git a/.agents/skills/hardware-test/reference/recipes/globals.md b/.agents/skills/hardware-test/reference/recipes/globals.md new file mode 100644 index 00000000000..05617e32606 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/globals.md @@ -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 diff --git a/.agents/skills/hardware-test/reference/recipes/homeassistant.md b/.agents/skills/hardware-test/reference/recipes/homeassistant.md new file mode 100644 index 00000000000..61f0614973b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/homeassistant.md @@ -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 diff --git a/.agents/skills/hardware-test/reference/recipes/usb_audio.md b/.agents/skills/hardware-test/reference/recipes/usb_audio.md new file mode 100644 index 00000000000..b990a263326 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/usb_audio.md @@ -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//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. diff --git a/.agents/skills/hardware-test/scripts/api_button.py b/.agents/skills/hardware-test/scripts/api_button.py new file mode 100644 index 00000000000..b1f0d962648 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_button.py @@ -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()) diff --git a/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py b/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py new file mode 100644 index 00000000000..e64eed18b3a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py @@ -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)}" + ) diff --git a/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py b/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py new file mode 100644 index 00000000000..db315cc99ea --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py @@ -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)}" + ) diff --git a/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py b/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py new file mode 100644 index 00000000000..61efe8f0e21 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py @@ -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()) diff --git a/.agents/skills/hardware-test/scripts/serial_capture.py b/.agents/skills/hardware-test/scripts/serial_capture.py index d93b3ca720f..bf6fad218d1 100644 --- a/.agents/skills/hardware-test/scripts/serial_capture.py +++ b/.agents/skills/hardware-test/scripts/serial_capture.py @@ -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"", 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) diff --git a/.agents/skills/hardware-test/scripts/usb_audio/playurl.py b/.agents/skills/hardware-test/scripts/usb_audio/playurl.py new file mode 100644 index 00000000000..23afafcb45e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_audio/playurl.py @@ -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()) diff --git a/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml b/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml new file mode 100644 index 00000000000..825ca265620 --- /dev/null +++ b/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml @@ -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 diff --git a/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml b/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml new file mode 100644 index 00000000000..3b72ba12539 --- /dev/null +++ b/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml @@ -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 diff --git a/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml b/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml new file mode 100644 index 00000000000..9d7f71df4a1 --- /dev/null +++ b/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml @@ -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 diff --git a/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml b/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml new file mode 100644 index 00000000000..775936c0b6d --- /dev/null +++ b/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml @@ -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 diff --git a/.agents/skills/hardware-test/templates/globals-storage-dut.yaml b/.agents/skills/hardware-test/templates/globals-storage-dut.yaml new file mode 100644 index 00000000000..4cd6c639170 --- /dev/null +++ b/.agents/skills/hardware-test/templates/globals-storage-dut.yaml @@ -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 diff --git a/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml new file mode 100644 index 00000000000..9681a41de7a --- /dev/null +++ b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml @@ -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()); diff --git a/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml b/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml new file mode 100644 index 00000000000..4f6ca276b5d --- /dev/null +++ b/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml @@ -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: diff --git a/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml new file mode 100644 index 00000000000..079ffab7fde --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml @@ -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 diff --git a/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml new file mode 100644 index 00000000000..229f5147bd4 --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml @@ -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));