Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/flash.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jamro marked this conversation as resolved.
Resolved
```

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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/hardware/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/hardware/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
2 changes: 1 addition & 1 deletion docs/hardware/pinout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/hardware/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 2 additions & 1 deletion include/pins.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mods/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ mods/<mod_name>/

## Audio

Optional speaker replacements live in `mods/<mod_name>/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/<mod_name>/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).

Expand Down
2 changes: 1 addition & 1 deletion mods/halloween/assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions partitions_ota.csv
Original file line number Diff line number Diff line change
@@ -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,
11 changes: 11 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

[env:native]
platform = native
test_framework = unity
Expand Down
75 changes: 63 additions & 12 deletions scripts/audio_pack.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion scripts/copy_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
67 changes: 67 additions & 0 deletions scripts/ota.py
Original file line number Diff line number Diff line change
@@ -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",
)
Loading
Loading