diff --git a/AGENTS.md b/AGENTS.md index 41454fc1..f62c4fb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ python3 scripts/check_pcb.py `pio test -e native` is not `pio run -e native`. PCB checks need KiCad 10 (`python3 scripts/check_pcb.py`). Details: [docs/testing.md](docs/testing.md). -Do **not** flash (`pio run -t upload`) unless the user asks. +Do **not** flash (`pio run -t upload`, `-t uploadfs`, `pio run -e ota -t upload`, `-t ota`, or `-t otafs`) unless the user asks. ## After significant changes diff --git a/assets/README.md b/assets/README.md index aff99b4d..34aebc28 100644 --- a/assets/README.md +++ b/assets/README.md @@ -4,7 +4,7 @@ Source files for Tiny Engineer branding and speaker clips. Firmware does not rea ## Audio -WAV clips played by animations and the setup wizard. Format: **44100 Hz, mono, 16-bit PCM**. +WAV clips played by animations and the setup wizard. Format: **44100 Hz, mono, 16-bit PCM**. The build downsamples them to 22050 Hz, the firmware's playback rate, when it packs LittleFS. `pio run` copies these files into `data/` via [`scripts/copy_assets.py`](../scripts/copy_assets.py). The next filesystem image overwrites `data/`, so edit the files here. Flash them with `pio run -t upload` or `pio run -t uploadfs`. diff --git a/docs/api.md b/docs/api.md index 3b0cae8d..a0b1270c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -251,7 +251,7 @@ curl -X POST http://tiny-engineer.local/test/audio ### `POST /test/audio/bell` -Plays `/bell.wav` from LittleFS (`playBell()`). Git source is [`assets/bell.wav`](../assets/bell.wav) (44100 Hz mono PCM); `pio run` copies it into `data/` for the filesystem image. After changing the asset, upload with `pio run -t upload` or `pio run -t uploadfs`. Do not edit `data/` by hand — the next build overwrites it. +Plays `/bell.wav` from LittleFS (`playBell()`). Git source is [`assets/bell.wav`](../assets/bell.wav) (44100 Hz mono PCM); `pio run` downsamples it to 22050 Hz into `data/` for the filesystem image. After changing the asset, upload with `pio run -t upload` or `pio run -t uploadfs`. Do not edit `data/` by hand — the next build overwrites it. ```bash curl -X POST http://tiny-engineer.local/test/audio/bell diff --git a/docs/flash.md b/docs/flash.md index 20d5495b..20b8b452 100644 --- a/docs/flash.md +++ b/docs/flash.md @@ -55,6 +55,36 @@ Pick the entry whose hardware ID shows Espressif's `VID:PID=303A:1001` (the ESP32-C3's native USB) or your board's USB-serial bridge — `pio device list` also lists Bluetooth serial ports, which are not the board. +## Over-the-air updates (optional) + +The `ota` build environment adds Wi-Fi updates. Compared with the default build it trades: + +- the coredump partition, for a second firmware slot ([`partitions_ota.csv`](../partitions_ota.csv)) +- LittleFS shrinks from about 1.81 MB to 960 KB, so there is less room for longer mod clips + +Switching layouts needs one USB flash of firmware and filesystem together: + +```bash +pio run -e ota -t upload +``` + +After that, with the robot on your home Wi-Fi: + +```bash +export TINY_ENGINEER_URL=http://192.168.x.x # default: tiny-engineer.local +export TINY_ENGINEER_TOKEN=... # only if access_token is set +pio run -e ota -t ota # firmware +pio run -e ota -t otafs # LittleFS (WAV assets) +``` + +These are the same variables the agent integrations read ([integration.md](integration.md)). OTA listens on UDP/TCP port 3232 and only runs while connected to home Wi-Fi, not in setup AP mode. When `access_token` is set it is also the OTA password; changing it applies to OTA without a reboot. + +An update is written to the inactive slot and booted once; it becomes permanent only after it connects to Wi-Fi and starts its OTA listener. Firmware that never gets that far is rolled back to the previous slot on the next reset or power cycle. A firmware that hangs needs that power cycle to recover. + +A filesystem update that fails midway reboots the robot; re-run `pio run -e ota -t otafs` or fall back to `pio run -e ota -t uploadfs` over USB. + +Partition changes, including a switch back to the default build, cannot be applied over the air. Flash those with `pio run -t upload` over USB. + ## Done when Firmware + LittleFS uploaded, and: diff --git a/docs/hardware-for-software-engineers/04-buses-and-protocols.md b/docs/hardware-for-software-engineers/04-buses-and-protocols.md index 031a34a8..0ef38fcf 100644 --- a/docs/hardware-for-software-engineers/04-buses-and-protocols.md +++ b/docs/hardware-for-software-engineers/04-buses-and-protocols.md @@ -66,9 +66,9 @@ Three lines from ESP32 to the amp: ESP32 is master: it generates clocks and **pushes**. The MAX98357A receives and drives the speaker. You don't query the amp for the next sample. -> **If you've written backend code…** I2C is REST. I2S is a one-way socket pumping PCM. While audio plays, samples go out at 44100 Hz until you stop. +> **If you've written backend code…** I2C is REST. I2S is a one-way socket pumping PCM. While audio plays, samples go out at 22050 Hz until you stop. -Firmware here: **44100 Hz**, 16-bit. Amp is mono; firmware writes the same sample to both stereo slots. Extra amp pins (GAIN, SD) are unwired — the breakout's defaults apply. +Firmware here: **22050 Hz**, 16-bit. Amp is mono; firmware writes the same sample to both stereo slots. Extra amp pins (GAIN, SD) are unwired — the breakout's defaults apply. **Trap:** speaker connects **SPK+** to **SPK−** only. **SPK− is not GND.** Tying it to ground can damage the amp. #1 audio wiring mistake. diff --git a/docs/hardware/components.md b/docs/hardware/components.md index 21b15cf3..2c5583c3 100644 --- a/docs/hardware/components.md +++ b/docs/hardware/components.md @@ -128,7 +128,7 @@ Print [`3d_models/parts/hd1370a/3mf/`](../../3d_models/parts/hd1370a/3mf/). | Operating voltage | **Vin** 2.5–5.5 V. Drawing: USB **5V** → **Vin** | | I2S logic | 3.3 V from ESP32 (compatible) | | Interface | I2S: **BCLK**, **WS/LRC**, **DIN**. No MCLK | -| Sample rates | Chip: 8–96 kHz. Firmware: **44100 Hz**, 16-bit, stereo slot mode | +| Sample rates | Chip: 8–96 kHz. Firmware: **22050 Hz**, 16-bit, stereo slot mode | | Output power (typical) | ~1.8 W into 8 Ω @ 5 V / 10% THD (datasheet-class figure) | | Important pins | Drawn: **Vin**, **GND**, **BCLK**, **LRC**, **DIN**. **SPK+** / **SPK-** not drawn. **GAIN** / **SD** not wired (breakout defaults: GAIN floating ≈ 9 dB, SD pulled up ≈ left channel) | | Limits | Output is **BTL**. Do not connect SPK- to GND. Do not feed SPK+/SPK- into another amplifier. Do not drive the speaker from ESP32 GPIO | diff --git a/docs/hardware/interfaces.md b/docs/hardware/interfaces.md index ef30784c..c0162d9e 100644 --- a/docs/hardware/interfaces.md +++ b/docs/hardware/interfaces.md @@ -35,7 +35,7 @@ Bring-up configuration in [`src/main.cpp`](../../src/main.cpp): | Parameter | Value | | --- | --- | | Mode | `I2S_MODE_STD` | -| Sample rate | **44100** (`SAMPLE_RATE`) | +| Sample rate | **22050** (`SAMPLE_RATE`) | | Width | `I2S_DATA_BIT_WIDTH_16BIT` | | Slots | `I2S_SLOT_MODE_STEREO` | diff --git a/docs/hardware/pinout.md b/docs/hardware/pinout.md index 5e228e90..1177f987 100644 --- a/docs/hardware/pinout.md +++ b/docs/hardware/pinout.md @@ -53,7 +53,7 @@ Power pads (not GPIO): **5V**, **GND**, **3V3**. | Constant | Value | Meaning | | --- | --- | --- | -| `SAMPLE_RATE` | 44100 | I2S sample rate | +| `SAMPLE_RATE` | 22050 | I2S sample rate | | `PCA9685_ADDRESS` | `0x40` | I2C | | `OLED_ADDRESS` | `0x3C` | I2C | | `OLED_WIDTH` / `OLED_HEIGHT` | 128 / 32 | Display | diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 138a05c6..887f8873 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -22,7 +22,7 @@ Build/flash: [flash.md](../flash.md) (`pio run`, `pio run -t upload`, serial 115 | PCA9685 | Probe `0x40` early; park neutral; OE skipped (`PCA9685_OE_WIRED` is false until GP5→OE is implemented) | | OLED | Probe `0x3C`, init (optional) | | Wi-Fi | STA connect from saved NVS credentials, or setup AP `TinyEngineer-XXXX` when unset/failed; mDNS `{hostname}.local` after STA connect | -| MAX98357A / I2S | `I2S.begin` 44.1 kHz 16-bit stereo | +| MAX98357A / I2S | `I2S.begin` 22.05 kHz 16-bit stereo | | Servos | Smooth move to mid (or sleep pose) at 35°/s | | HTTP | Port 80 when STA connected, or on setup AP at `192.168.4.1` | | Success | Dim green RGB during init; then animation LED (see below) | diff --git a/include/pins.h b/include/pins.h index 47cdd722..88240103 100644 --- a/include/pins.h +++ b/include/pins.h @@ -26,7 +26,8 @@ constexpr int PCA9685_OE_PIN = 5; // AUDIO // ===================================================== -constexpr int SAMPLE_RATE = 44100; +// assets/ ships 44100 Hz WAVs; scripts/audio_pack.py halves them while packing LittleFS. +constexpr int SAMPLE_RATE = 22050; // ===================================================== // PCA9685 / SERVOS diff --git a/mods/README.md b/mods/README.md index 8d57a3b7..24722041 100644 --- a/mods/README.md +++ b/mods/README.md @@ -19,7 +19,7 @@ mods// ## Audio -Optional speaker replacements live in `mods//assets/`. Format matches stock: **44100 Hz, mono, 16-bit PCM**. Firmware still plays `/welcome.wav` and the other root names. The build overlay copies stock [`assets/`](../assets/) first, then overwrites any basename the mod ships. A missing file stays the stock clip. +Optional speaker replacements live in `mods//assets/`. Format matches stock: **44100 Hz, mono, 16-bit PCM**; the build downsamples to 22050 Hz. Firmware still plays `/welcome.wav` and the other root names. The build overlay copies stock [`assets/`](../assets/) first, then overwrites any basename the mod ships. A missing file stays the stock clip. Set the mod in [`platformio.ini`](../platformio.ini) (`custom_audio_mod = halloween`) and flash with `pio run -t upload`. An empty option keeps the stock image. How to flash: [docs/flash.md](../docs/flash.md). diff --git a/mods/halloween/assets/README.md b/mods/halloween/assets/README.md index a7d12bc6..b40a3779 100644 --- a/mods/halloween/assets/README.md +++ b/mods/halloween/assets/README.md @@ -4,7 +4,7 @@ Source files for Tiny Engineer branding and speaker clips. Firmware does not rea ## Audio -WAV clips played by animations and the setup wizard. Format: **44100 Hz, mono, 16-bit PCM**. +WAV clips played by animations and the setup wizard. Format: **44100 Hz, mono, 16-bit PCM**. The build downsamples them to 22050 Hz, the firmware's playback rate, when it packs LittleFS. These files are not in the default image. Set `custom_audio_mod = halloween` in [`platformio.ini`](../../../platformio.ini). [`scripts/copy_assets.py`](../../../scripts/copy_assets.py) copies stock [`assets/`](../../../assets/) first, then overwrites any clip that exists here. `bell` and `dead` are absent, so those stay stock. Flash with `pio run -t upload` or `pio run -t uploadfs`. Do not edit `data/` by hand. diff --git a/partitions_ota.csv b/partitions_ota.csv new file mode 100644 index 00000000..84a1f494 --- /dev/null +++ b/partitions_ota.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size, Flags +# 4MB, used by [env:ota]: two 1.5MB OTA app slots replace the coredump +# partition, and LittleFS shrinks to 960KB. +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x180000, +app1, app, ota_1, 0x190000,0x180000, +spiffs, data, spiffs, 0x310000,0xF0000, diff --git a/platformio.ini b/platformio.ini index 47dad924..dc05321c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -43,6 +43,17 @@ extra_scripts = ; Host Unity tests — never flash. Run: pio test -e native test_ignore = * +; Opt-in Wi-Fi updates: two OTA app slots instead of coredump, and 960 KB LittleFS. +[env:ota] +extends = env:esp32-c3-devkitm-1 +board_build.partitions = partitions_ota.csv +build_flags = + ${env:esp32-c3-devkitm-1.build_flags} + -D TE_OTA +extra_scripts = + ${env:esp32-c3-devkitm-1.extra_scripts} + post:scripts/ota.py + [env:native] platform = native test_framework = unity diff --git a/scripts/audio_pack.py b/scripts/audio_pack.py index f338554d..adb92416 100644 --- a/scripts/audio_pack.py +++ b/scripts/audio_pack.py @@ -1,10 +1,15 @@ -"""Merge stock WAVs with an optional mod overlay for the LittleFS image.""" +"""Merge stock WAVs with an optional mod overlay and halve their rate for the LittleFS image.""" +import math import re import shutil import wave +from array import array from pathlib import Path +SOURCE_RATE = 44100 +PACKED_RATE = SOURCE_RATE // 2 + STOCK_WAVS = ( "bell.wav", "welcome.wav", @@ -108,20 +113,62 @@ def validate_mod_name(name): return name +def _require_source_format(wav, path): + if wav.getnchannels() != 1 or wav.getframerate() != SOURCE_RATE or wav.getsampwidth() != 2: + raise AudioPackError( + f"{path} must be {SOURCE_RATE} Hz mono 16-bit PCM " + f"(got {wav.getframerate()} Hz, " + f"{wav.getnchannels()} ch, " + f"{wav.getsampwidth() * 8}-bit)" + ) + + def wav_duration_ms(path): with wave.open(str(path), "rb") as wav: - if wav.getnchannels() != 1 or wav.getframerate() != 44100 or wav.getsampwidth() != 2: - raise AudioPackError( - f"{path} must be 44100 Hz mono 16-bit PCM " - f"(got {wav.getframerate()} Hz, " - f"{wav.getnchannels()} ch, " - f"{wav.getsampwidth() * 8}-bit)" - ) + _require_source_format(wav, path) frames = wav.getnframes() - rate = wav.getframerate() - if rate == 0: - raise AudioPackError(f"{path} has a zero sample rate") - return int(round(frames * 1000 / rate)) + return int(round(frames * 1000 / SOURCE_RATE)) + + +def _halfband_taps(count=31): + # Blackman-windowed sinc with its cutoff at the output Nyquist. Every + # even offset from the centre is zero, so only the odd pairs are kept. + middle = count // 2 + window = [ + 0.42 - 0.5 * math.cos(2 * math.pi * n / (count - 1)) + + 0.08 * math.cos(4 * math.pi * n / (count - 1)) + for n in range(count) + ] + pairs = [ + (offset, math.sin(math.pi * offset / 2) / (math.pi * offset) * window[middle + offset]) + for offset in range(1, middle + 1, 2) + ] + gain = 0.5 + 2 * sum(tap for _, tap in pairs) + return 0.5 / gain, [(offset, tap / gain) for offset, tap in pairs] + + +def halve_sample_rate(path): + """Low-pass and keep every second sample, rewriting the WAV in place.""" + with wave.open(str(path), "rb") as wav: + _require_source_format(wav, path) + samples = array("h", wav.readframes(wav.getnframes())) + + centre, pairs = _halfband_taps() + reach = pairs[-1][0] + count = len(samples) // 2 + padded = [0] * reach + samples.tolist() + [0] * reach + acc = [centre * value for value in padded[reach:reach + 2 * count:2]] + for offset, tap in pairs: + before = padded[reach - offset::2] + after = padded[reach + offset::2] + acc = [total + tap * (left + right) for total, left, right in zip(acc, before, after)] + + out = array("h", (max(-32768, min(32767, round(value))) for value in acc)) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(PACKED_RATE) + wav.writeframes(out.tobytes()) def parse_cue_text(text, label): @@ -222,6 +269,10 @@ def pack_audio(assets_dir, dest_dir, partition_bytes, mod_dir=None): if mod_dir is not None: _overlay_mod(Path(mod_dir), dest_dir) + for name in STOCK_WAVS: + halve_sample_rate(dest_dir / name) + print(f"Resampled WAVs to {PACKED_RATE} Hz") + total = sum( path.stat().st_size for path in dest_dir.iterdir() diff --git a/scripts/copy_assets.py b/scripts/copy_assets.py index 0d9000cb..2c32e283 100644 --- a/scripts/copy_assets.py +++ b/scripts/copy_assets.py @@ -21,6 +21,6 @@ pack_audio( project_dir / "assets", project_dir / "data", - spiffs_size_bytes(project_dir / "partitions.csv"), + spiffs_size_bytes(project_dir / env.GetProjectOption("board_build.partitions")), mod_dir, ) diff --git a/scripts/ota.py b/scripts/ota.py new file mode 100644 index 00000000..3d1dd87e --- /dev/null +++ b/scripts/ota.py @@ -0,0 +1,67 @@ +Import("env") + +import os +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +DEFAULT_HOST = "tiny-engineer.local" + + +def ota_host(): + url = os.environ.get("TINY_ENGINEER_URL", "") + # urlparse only finds a hostname after "//"; accept a bare host or IP too. + host = urlparse(url if "//" in url else f"//{url}").hostname + return host or DEFAULT_HOST + + +def espota(image, filesystem): + command = [ + env.subst("$PYTHONEXE"), + str(Path(env.PioPlatform().get_package_dir("framework-arduinoespressif32")) / "tools" / "espota.py"), + "--ip", ota_host(), + "--file", str(image), + "--progress", + ] + + token = os.environ.get("TINY_ENGINEER_TOKEN") + + if token: + command += ["--auth", token] + + if filesystem: + command.append("--spiffs") + + print(f"OTA {'filesystem' if filesystem else 'firmware'} -> {ota_host()}") + subprocess.run(command, check=True) + + +def upload_firmware(source, target, env): + espota(env.subst("$BUILD_DIR/${PROGNAME}.bin"), filesystem=False) + + +def upload_filesystem(source, target, env): + project_dir = env.subst("$PROJECT_DIR") + # The platform only wires up the LittleFS image builder when buildfs is a command-line target. + subprocess.run( + [env.subst("$PYTHONEXE"), "-m", "platformio", "run", "-t", "buildfs", "-e", env.subst("$PIOENV"), "-d", project_dir], + check=True, + ) + espota(env.subst("$BUILD_DIR/${ESP32_FS_IMAGE_NAME}.bin"), filesystem=True) + + +env.AddCustomTarget( + name="ota", + dependencies="$BUILD_DIR/${PROGNAME}.bin", + actions=upload_firmware, + title="Upload OTA", + description="Upload firmware over Wi-Fi", +) + +env.AddCustomTarget( + name="otafs", + dependencies=None, + actions=upload_filesystem, + title="Upload Filesystem OTA", + description="Upload LittleFS image over Wi-Fi", +) diff --git a/scripts/test_audio_pack.py b/scripts/test_audio_pack.py index 33945291..1f2fa289 100644 --- a/scripts/test_audio_pack.py +++ b/scripts/test_audio_pack.py @@ -29,6 +29,11 @@ def write_wav(path, duration_ms, rate=44100, channels=1, width=2): wav.writeframes(b"\x00" * (frames * channels * width)) +def frames_and_rate(path): + with wave.open(str(path), "rb") as wav: + return wav.getnframes(), wav.getframerate() + + def welcome_cue(end_ms): return ( "greeting_end_ms=100\n" @@ -56,6 +61,10 @@ def setUp(self): def tearDown(self): self.tmp.cleanup() + def assertPackedFrom(self, packed, source): + source_frames, _ = frames_and_rate(source) + self.assertEqual(frames_and_rate(packed), (source_frames // 2, 22050)) + def test_stock_rebuild_drops_old_cue(self): (self.dest / "welcome.cue").write_text("end_ms=1\n") pack_audio(self.assets, self.dest, 2_000_000) @@ -66,26 +75,26 @@ def test_stock_copy_has_no_cues(self): for name in STOCK_WAVS: self.assertTrue((self.dest / name).is_file(), name) self.assertFalse(list(self.dest.glob("*.cue"))) - self.assertEqual( - (self.dest / "bell.wav").read_bytes(), - (self.assets / "bell.wav").read_bytes(), + self.assertPackedFrom( + self.dest / "bell.wav", + self.assets / "bell.wav", ) def test_partial_overlay_keeps_omitted_clips(self): write_wav(self.mod / "welcome.wav", 800) (self.mod / "welcome.cue").write_text(welcome_cue(800)) pack_audio(self.assets, self.dest, 2_000_000, self.mod) - self.assertEqual( - (self.dest / "welcome.wav").read_bytes(), - (self.mod / "welcome.wav").read_bytes(), + self.assertPackedFrom( + self.dest / "welcome.wav", + self.mod / "welcome.wav", ) - self.assertEqual( - (self.dest / "bell.wav").read_bytes(), - (self.assets / "bell.wav").read_bytes(), + self.assertPackedFrom( + self.dest / "bell.wav", + self.assets / "bell.wav", ) - self.assertEqual( - (self.dest / "dead.wav").read_bytes(), - (self.assets / "dead.wav").read_bytes(), + self.assertPackedFrom( + self.dest / "dead.wav", + self.assets / "dead.wav", ) self.assertTrue((self.dest / "welcome.cue").is_file()) self.assertFalse((self.dest / "dead.cue").exists()) @@ -128,23 +137,36 @@ def test_halloween_overlay_fits_partition(self): ) for name in STOCK_WAVS: self.assertTrue((dest / name).is_file(), name) - self.assertEqual( - (dest / "bell.wav").read_bytes(), - (ROOT / "assets" / "bell.wav").read_bytes(), + self.assertPackedFrom( + dest / "bell.wav", + ROOT / "assets" / "bell.wav", ) - self.assertEqual( - (dest / "dead.wav").read_bytes(), - (ROOT / "assets" / "dead.wav").read_bytes(), + self.assertPackedFrom( + dest / "dead.wav", + ROOT / "assets" / "dead.wav", ) for clip in ("welcome", "attention", "error", "abort"): - self.assertEqual( - (dest / f"{clip}.wav").read_bytes(), - (ROOT / "mods" / "halloween" / "assets" / f"{clip}.wav").read_bytes(), + self.assertPackedFrom( + dest / f"{clip}.wav", + ROOT / "mods" / "halloween" / "assets" / f"{clip}.wav", ) self.assertTrue((dest / f"{clip}.cue").is_file()) self.assertFalse((dest / "bell.cue").exists()) self.assertFalse((dest / "dead.cue").exists()) + def test_halloween_overlay_fits_ota_partition(self): + pack_audio( + ROOT / "assets", + self.dest, + spiffs_size_bytes(ROOT / "partitions_ota.csv"), + ROOT / "mods" / "halloween" / "assets", + ) + + def test_stock_wav_at_packed_rate_fails(self): + write_wav(self.assets / "bell.wav", 500, rate=22050) + with self.assertRaises(AudioPackError): + pack_audio(self.assets, self.dest, 2_000_000) + if __name__ == "__main__": unittest.main() diff --git a/scripts/upload_fs_after_upload.py b/scripts/upload_fs_after_upload.py index 333dc443..1823ee0a 100644 --- a/scripts/upload_fs_after_upload.py +++ b/scripts/upload_fs_after_upload.py @@ -17,6 +17,7 @@ def run_pio(pio_target): env.subst("$PYTHONEXE"), "-m", "platformio", "run", "-t", pio_target, + "-e", env.subst("$PIOENV"), "-d", str(project_dir), ], check=True, diff --git a/src/audio/wav_header.h b/src/audio/wav_header.h new file mode 100644 index 00000000..e0308500 --- /dev/null +++ b/src/audio/wav_header.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include +#include + +enum class WavHeaderResult : uint8_t { + Ok, + NotRiffWave, + Truncated, + MissingFmt, + DataBeforeFmt, + MissingData, + NotPcm, + NotMono, + Not16Bit, + SampleRateMismatch, +}; + +inline const char* wavHeaderResultText(WavHeaderResult result) { + switch (result) { + case WavHeaderResult::Ok: return "ok"; + case WavHeaderResult::NotRiffWave: return "not RIFF/WAVE"; + case WavHeaderResult::Truncated: return "truncated header"; + case WavHeaderResult::MissingFmt: return "no fmt chunk"; + case WavHeaderResult::DataBeforeFmt: return "data before fmt"; + case WavHeaderResult::MissingData: return "no data chunk"; + case WavHeaderResult::NotPcm: return "not PCM"; + case WavHeaderResult::NotMono: return "not mono"; + case WavHeaderResult::Not16Bit: return "not 16-bit"; + case WavHeaderResult::SampleRateMismatch: return "sample rate mismatch"; + } + + return "invalid"; +} + +namespace wav_header_detail { + +inline uint16_t le16(const uint8_t* bytes) { + return (uint16_t)(bytes[0] | (bytes[1] << 8)); +} + +inline uint32_t le32(const uint8_t* bytes) { + return (uint32_t)bytes[0] | + ((uint32_t)bytes[1] << 8) | + ((uint32_t)bytes[2] << 16) | + ((uint32_t)bytes[3] << 24); +} + +} // namespace wav_header_detail + +// Reader needs `bool read(uint8_t* dest, size_t len)` (all bytes or false) and +// `bool skip(uint32_t len)`. On Ok the reader sits at the first PCM byte. +template +WavHeaderResult readWavHeader(Reader& reader, uint32_t sampleRate) { + using wav_header_detail::le16; + using wav_header_detail::le32; + + uint8_t riff[12]; + + if (!reader.read(riff, sizeof(riff)) || + memcmp(riff, "RIFF", 4) != 0 || + memcmp(riff + 8, "WAVE", 4) != 0) { + return WavHeaderResult::NotRiffWave; + } + + bool fmtSeen = false; + uint8_t chunk[8]; + + while (reader.read(chunk, sizeof(chunk))) { + const uint32_t chunkSize = le32(chunk + 4); + // RIFF pads odd-sized chunks to an even length. + const uint32_t padding = chunkSize & 1; + + if (memcmp(chunk, "data", 4) == 0) { + return fmtSeen ? WavHeaderResult::Ok : WavHeaderResult::DataBeforeFmt; + } + + if (memcmp(chunk, "fmt ", 4) != 0) { + if (!reader.skip(chunkSize + padding)) { + return WavHeaderResult::Truncated; + } + + continue; + } + + uint8_t fmt[16]; + + if (chunkSize < sizeof(fmt) || !reader.read(fmt, sizeof(fmt))) { + return WavHeaderResult::Truncated; + } + + if (le16(fmt) != 1) { + return WavHeaderResult::NotPcm; + } + + if (le16(fmt + 2) != 1) { + return WavHeaderResult::NotMono; + } + + if (le16(fmt + 14) != 16) { + return WavHeaderResult::Not16Bit; + } + + if (le32(fmt + 4) != sampleRate) { + return WavHeaderResult::SampleRateMismatch; + } + + if (!reader.skip(chunkSize - sizeof(fmt) + padding)) { + return WavHeaderResult::Truncated; + } + + fmtSeen = true; + } + + return fmtSeen ? WavHeaderResult::MissingData : WavHeaderResult::MissingFmt; +} diff --git a/src/audio/wav_stream.cpp b/src/audio/wav_stream.cpp index 236e1d80..247b23ca 100644 --- a/src/audio/wav_stream.cpp +++ b/src/audio/wav_stream.cpp @@ -6,6 +6,8 @@ #include #include "audio/audio.h" +#include "audio/wav_header.h" +#include "pins.h" #include "settings/settings.h" #include "serial_log.h" @@ -54,46 +56,17 @@ void logAudioStorageContents() { root.close(); } -bool skipWavPcmData(File& file) { - char tag[4]; +struct WavFileReader { + File& file; - if (file.readBytes(tag, 4) != 4 || - memcmp(tag, "RIFF", 4) != 0) { - return false; + bool read(uint8_t* dest, size_t len) { + return file.read(dest, len) == len; } - file.seek(8); - - if (file.readBytes(tag, 4) != 4 || - memcmp(tag, "WAVE", 4) != 0) { - return false; + bool skip(uint32_t len) { + return file.seek(file.position() + len); } - - while (file.available()) { - if (file.readBytes(tag, 4) != 4) { - return false; - } - - uint32_t chunkSize = 0; - - if (file.read( - reinterpret_cast(&chunkSize), - sizeof(chunkSize) - ) != sizeof(chunkSize)) { - return false; - } - - if (memcmp(tag, "data", 4) == 0) { - return true; - } - - if (!file.seek(file.position() + chunkSize)) { - return false; - } - } - - return false; -} +}; bool pumpWavChunk(File& file) { int16_t buffer[kAudioFrames * 2]; @@ -182,10 +155,15 @@ bool wavStreamStart(WavClip clip) { return false; } - if (!skipWavPcmData(g_file)) { + WavFileReader reader{g_file}; + const WavHeaderResult header = readWavHeader(reader, SAMPLE_RATE); + + if (header != WavHeaderResult::Ok) { g_file.close(); serialLogPrint(def.label); - serialLogPrintln(" failed: invalid WAV"); + serialLogPrint(" failed: invalid WAV ("); + serialLogPrint(wavHeaderResultText(header)); + serialLogPrintln(")"); return false; } diff --git a/src/main.cpp b/src/main.cpp index 11434ac3..ef5846c1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include "hardware/pca9685_servos.h" #include "hardware/servo_wrapper.h" #include "network/wifi_connect.h" +#include "network/ota.h" #include "http/http_server.h" #include "settings/settings.h" #include "serial_log.h" @@ -156,6 +157,10 @@ void setup() { if (wifiConnected() || wifiProvisioningMode()) { startHttpServer(); } + + if (wifiConnected()) { + startOta(); + } initSleep(); } @@ -163,6 +168,7 @@ void loop() { const uint32_t now = millis(); pollWifi(); pollHttpServer(); + pollOta(); updateProvisioningOled(now); updateAnimation(); // After HTTP/anim so idle timestamp from this frame is not compared to a stale `now`. diff --git a/src/network/ota.cpp b/src/network/ota.cpp new file mode 100644 index 00000000..d18adbd6 --- /dev/null +++ b/src/network/ota.cpp @@ -0,0 +1,128 @@ +#include "network/ota.h" + +#ifdef TE_OTA + +#include +#include +#include +#include + +#include +#include + +#include "audio/audio.h" +#include "display/oled.h" +#include "settings/settings.h" +#include "serial_log.h" + +namespace { + +// ArduinoOTAClass cannot drop a password once set, so a token change rebuilds the instance. +std::unique_ptr g_ota; +char g_token[SETTINGS_ACCESS_TOKEN_MAX_LEN + 1] = {}; +int g_lastPercent = -1; + +void showOtaProgress(unsigned int progress, unsigned int total) { + const int percent = total > 0 ? (int)(progress * 100ULL / total) : 0; + + if (percent == g_lastPercent) { + return; + } + + g_lastPercent = percent; + + char line[8]; + snprintf(line, sizeof(line), "%d%%", percent); + showOledText("OTA UPDATE", line); +} + +void confirmRunningFirmware() { + esp_ota_img_states_t state; + + if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) != ESP_OK || + state != ESP_OTA_IMG_PENDING_VERIFY) { + return; + } + + esp_ota_mark_app_valid_cancel_rollback(); + serialLogPrintln("OTA: new firmware confirmed"); +} + +} // namespace + +// Defers the Arduino core's boot-time confirmation of fresh OTA firmware until startOta() proves +// it can reach Wi-Fi and accept the next update. Unconfirmed firmware is rolled back on reset. +extern "C" bool verifyRollbackLater() { + return true; +} + +void startOta() { + strncpy(g_token, settingsAccessToken(), SETTINGS_ACCESS_TOKEN_MAX_LEN); + g_token[SETTINGS_ACCESS_TOKEN_MAX_LEN] = '\0'; + + g_ota = std::make_unique(); + g_ota->setHostname(settingsHostname()); + // wifi_connect owns mDNS; ArduinoOTA::end() would tear it down. + g_ota->setMdnsEnabled(false); + + if (settingsAccessTokenSet()) { + g_ota->setPassword(g_token); + } + + g_ota->onStart([]() { + g_lastPercent = -1; + stopAllWavPlayback(); + + if (g_ota->getCommand() != U_FLASH) { + LittleFS.end(); + } + + serialLogPrintln(g_ota->getCommand() == U_FLASH ? "OTA: firmware" : "OTA: filesystem"); + showOledText("OTA UPDATE", "0%"); + }); + + g_ota->onProgress(showOtaProgress); + + g_ota->onEnd([]() { + serialLogPrintln("OTA: done, rebooting"); + showOledText("OTA UPDATE", "Rebooting"); + }); + + g_ota->onError([](ota_error_t error) { + serialLogPrint("OTA error: "); + serialLogPrintln((int)error); + + if (error == OTA_AUTH_ERROR) { + return; + } + + if (g_ota->getCommand() == U_FLASH) { + showOledText("OTA FAILED"); + return; + } + + // LittleFS is unmounted and possibly half-written; a reboot remounts or reports it. + showOledText("OTA FAILED", "Rebooting"); + delay(2000); + ESP.restart(); + }); + + g_ota->begin(); + confirmRunningFirmware(); + serialLogPrintln(settingsAccessTokenSet() ? "OTA ready (password)" : "OTA ready"); +} + +void pollOta() { + if (!g_ota) { + return; + } + + if (strcmp(g_token, settingsAccessToken()) != 0) { + g_ota.reset(); + startOta(); + } + + g_ota->handle(); +} + +#endif // TE_OTA diff --git a/src/network/ota.h b/src/network/ota.h new file mode 100644 index 00000000..4bd15103 --- /dev/null +++ b/src/network/ota.h @@ -0,0 +1,9 @@ +#pragma once + +#ifdef TE_OTA +void startOta(); +void pollOta(); +#else +inline void startOta() {} +inline void pollOta() {} +#endif diff --git a/test/test_wav_header/test_wav_header.cpp b/test/test_wav_header/test_wav_header.cpp new file mode 100644 index 00000000..50b6eaa2 --- /dev/null +++ b/test/test_wav_header/test_wav_header.cpp @@ -0,0 +1,168 @@ +#include + +#include + +#include "audio/wav_header.h" + +namespace { + +constexpr uint32_t kRate = 22050; + +struct BufferReader { + const std::vector& bytes; + size_t position = 0; + + bool read(uint8_t* dest, size_t len) { + if (position + len > bytes.size()) { + return false; + } + + memcpy(dest, bytes.data() + position, len); + position += len; + return true; + } + + bool skip(uint32_t len) { + if (position + len > bytes.size()) { + return false; + } + + position += len; + return true; + } +}; + +void put16(std::vector& out, uint16_t value) { + out.push_back(value & 0xFF); + out.push_back(value >> 8); +} + +void put32(std::vector& out, uint32_t value) { + put16(out, value & 0xFFFF); + put16(out, value >> 16); +} + +void putChunk(std::vector& out, const char* id, const std::vector& body) { + out.insert(out.end(), id, id + 4); + put32(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); + + if (body.size() & 1) { + out.push_back(0); + } +} + +std::vector fmtBody(uint16_t format, uint16_t channels, uint32_t rate, uint16_t bits) { + std::vector body; + put16(body, format); + put16(body, channels); + put32(body, rate); + put32(body, rate * channels * bits / 8); + put16(body, channels * bits / 8); + put16(body, bits); + return body; +} + +std::vector riffWave() { + std::vector out = {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'A', 'V', 'E'}; + return out; +} + +const std::vector kPcm = {0x34, 0x12, 0x78, 0x56}; + +WavHeaderResult parse(const std::vector& bytes, size_t* dataOffset = nullptr) { + BufferReader reader{bytes}; + const WavHeaderResult result = readWavHeader(reader, kRate); + + if (dataOffset != nullptr) { + *dataOffset = reader.position; + } + + return result; +} + +} // namespace + +void test_valid_header_stops_at_pcm() { + std::vector wav = riffWave(); + putChunk(wav, "fmt ", fmtBody(1, 1, kRate, 16)); + const size_t pcmStart = wav.size() + 8; + putChunk(wav, "data", kPcm); + + size_t offset = 0; + TEST_ASSERT_EQUAL(WavHeaderResult::Ok, parse(wav, &offset)); + TEST_ASSERT_EQUAL(pcmStart, offset); +} + +void test_rate_mismatch_rejected() { + std::vector wav = riffWave(); + putChunk(wav, "fmt ", fmtBody(1, 1, 44100, 16)); + putChunk(wav, "data", kPcm); + TEST_ASSERT_EQUAL(WavHeaderResult::SampleRateMismatch, parse(wav)); +} + +void test_format_mismatches_rejected() { + std::vector stereo = riffWave(); + putChunk(stereo, "fmt ", fmtBody(1, 2, kRate, 16)); + putChunk(stereo, "data", kPcm); + TEST_ASSERT_EQUAL(WavHeaderResult::NotMono, parse(stereo)); + + std::vector eightBit = riffWave(); + putChunk(eightBit, "fmt ", fmtBody(1, 1, kRate, 8)); + putChunk(eightBit, "data", kPcm); + TEST_ASSERT_EQUAL(WavHeaderResult::Not16Bit, parse(eightBit)); + + std::vector floatPcm = riffWave(); + putChunk(floatPcm, "fmt ", fmtBody(3, 1, kRate, 16)); + putChunk(floatPcm, "data", kPcm); + TEST_ASSERT_EQUAL(WavHeaderResult::NotPcm, parse(floatPcm)); +} + +void test_odd_and_extended_chunks_skipped() { + std::vector wav = riffWave(); + putChunk(wav, "LIST", {1, 2, 3}); + std::vector fmt = fmtBody(1, 1, kRate, 16); + fmt.push_back(0); + fmt.push_back(0); + putChunk(wav, "fmt ", fmt); + putChunk(wav, "junk", {9}); + const size_t pcmStart = wav.size() + 8; + putChunk(wav, "data", kPcm); + + size_t offset = 0; + TEST_ASSERT_EQUAL(WavHeaderResult::Ok, parse(wav, &offset)); + TEST_ASSERT_EQUAL(pcmStart, offset); +} + +void test_data_before_fmt_rejected() { + std::vector wav = riffWave(); + putChunk(wav, "data", kPcm); + putChunk(wav, "fmt ", fmtBody(1, 1, kRate, 16)); + TEST_ASSERT_EQUAL(WavHeaderResult::DataBeforeFmt, parse(wav)); +} + +void test_missing_or_short_fmt_rejected() { + std::vector noFmt = riffWave(); + putChunk(noFmt, "LIST", {1, 2}); + TEST_ASSERT_EQUAL(WavHeaderResult::MissingFmt, parse(noFmt)); + + std::vector shortFmt = riffWave(); + putChunk(shortFmt, "fmt ", {1, 0, 1, 0}); + TEST_ASSERT_EQUAL(WavHeaderResult::Truncated, parse(shortFmt)); + + std::vector cutFmt = riffWave(); + putChunk(cutFmt, "fmt ", fmtBody(1, 1, kRate, 16)); + cutFmt.resize(cutFmt.size() - 4); + TEST_ASSERT_EQUAL(WavHeaderResult::Truncated, parse(cutFmt)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_valid_header_stops_at_pcm); + RUN_TEST(test_rate_mismatch_rejected); + RUN_TEST(test_format_mismatches_rejected); + RUN_TEST(test_odd_and_extended_chunks_skipped); + RUN_TEST(test_data_before_fmt_rejected); + RUN_TEST(test_missing_or_short_fmt_rejected); + return UNITY_END(); +}