M5Stack CoreS3 on Tailscale: 9-second WAV downloads via DERP
Contents
My earlier CoreS3 voice chat sent recorded audio to a rented VPS, where a PHP program relayed it to the voice server at home.
The VPS and home PC use Tailscale to connect over a private network. I wanted to try enrolling the CoreS3 itself in Tailscale and removing the VPS relay.
ciniml/serial_wifi_logger retrieves USB serial logs over a network and includes a Tailscale client for the ESP32-S3.
My CoreS3 has the same chip, so I used that implementation to build a program that downloads one WAV audio file from the home server.
To check connectivity before integrating the full voice chat application, I chose an existing filler audio file used during response delays.
The probe does not call speech recognition, response generation, or speech synthesis APIs. It also omits display output and speaker playback.
Test environment
| Item | Environment used |
|---|---|
| Device | M5Stack CoreS3, ESP32-S3, 16 MiB flash |
| Development PC | Windows, already enrolled in Tailscale |
| USB connection | CoreS3’s USB-C port, USB Serial/JTAG, COM3 |
| Build | ESP-IDF 6.0, Docker image espressif/idf:v6.0 |
| Flash reads and writes | esptool 5.3.0 |
| Destination | Home voice server’s Tailscale IPv4 address, HTTP, TCP port 8357 |
The ESP32 Tailscale implementation
I pinned serial_wifi_logger to commit c537f4b8b64306bc2510641045160ff19fcec5d2 and copied components/tailscale and components/wireguard.
Development used ESP-IDF, Espressif’s framework for the ESP32. The Tailscale header file identifies ESP-IDF 6.0 as its target, so I created a separate project from the Arduino code used for the previous voice chat application.
The routing code sends traffic for Tailscale’s 100.64.0.0/10 address range through WireGuard, which encrypts VPN traffic.
Alongside direct connections between devices, the implementation includes DERP support for sending encrypted traffic through a relay server.
Downloading the WAV from a PC
The Windows PC was already enrolled in Tailscale, so I connected it to the same voice server first.
/health responded successfully, and /fillers returned the list of filler audio files. I downloaded /filler/filler_0.wav from that list.
| Check | Windows PC result |
|---|---|
| Destination | Home server’s Tailscale IPv4 address, port 8357 |
| Audio file | /filler/filler_0.wav |
| HTTP status | 200 |
| Download size | 433,964 bytes |
| Audio format | 48,000 Hz, 16-bit, mono |
| Length | 216,960 frames, 4.52 seconds; one frame is one sample for mono audio |
| PC download time | About 0.154 seconds, one measurement |
Building with ESP-IDF
The development PC did not have an ESP-IDF build environment, so I used Espressif’s Docker image to run the compiler and SDK inside a container.
The connection and WAV download code, build configuration, and USB serial logging script are available in the experiment source directory.
I put the Wi-Fi settings in a Git-ignored config.h and left the Tailscale authentication key empty. This configured the device to use browser approval through a URL printed to the USB serial console.
In the published directory, copy the example configuration, edit the connection settings, and then build:
Copy-Item config.example.h config.h
.\build.ps1
I allocated a 3 MiB application partition. The first build produced a 956,176-byte binary.
Backing up before flashing
With the CoreS3 connected over USB, esptool identified an ESP32-S3 and 16 MiB of flash. Since flash stores the program and settings, I backed up the entire device before writing to it.
The normal esptool 5.3.0 read stopped at around 4 KiB.
By default, esptool transfers a small helper program into RAM and runs it to read the flash. I switched to --no-stub, which uses the chip’s built-in ROM routines. I do not know why the normal read stopped.
The backup took about 479 seconds. I checked the contents in 64 KiB blocks and also checked all 16,777,216 bytes, including unused areas. The MD5 checksum calculated from the data matched the device’s checksum.
I then flashed the bootloader, which handles startup, the partition table, which specifies storage regions, and the application. Writing the application took about 12.4 seconds, and the hash matched after transfer.
Enrolling the CoreS3
After Wi-Fi connection and time synchronization, the Tailscale control server returned a device approval URL.
The original implementation tried to fetch network information before approval, received node not found, and generated a different URL on every registration retry.
Tailscale’s registration request definition includes a Followup field for supplying an existing URL while waiting for approval. I changed tailscale_control.c to send it on registration retries. In tailscale_esp32.c, I also deferred network information requests until approval completed.
After flashing the 956,256-byte revision, the device waited for approval at the same URL. The Python serial logging script also needed its standard output changed to UTF-8 because the default Windows encoding could not print a dash character in the log.
Browser approval assigned the CoreS3 a Tailscale IP address, and it retrieved information about the destination device. However, the HTTP connection timed out when downloading the WAV, and the CoreS3 restarted during retries.
***ERROR*** A stack overflow in task main has been detected.
The log identified a stack overflow in the main task. The stack holds local variables and function call information; the download function also used a 4 KiB receive buffer there.
Increasing the main task’s allocation from 8 KiB to 16 KiB let the next attempt continue without restarting.
HTTP connections still timed out, so I checked the network path.
Changing the DERP destination
The imported implementation selected the first advertised UDP endpoint for direct communication between devices. An option to restrict connections to DERP was defined, but the endpoint selection code did not apply it.
I added that behavior and switched to the relay path.
The original code selected the lowest region ID, which sent the CoreS3 to New York. The voice server used Tokyo, so I checked the official DERP map, confirmed Tokyo’s ID was 7, and added a setting to prefer it.
CONFIG_TAILSCALE_DERP_ONLY=y
CONFIG_TAILSCALE_PREFERRED_DERP_REGION=7
flowchart LR
A[CoreS3] <-->|Encrypted traffic| R[Tokyo DERP relay]
R <-->|Encrypted traffic| B[Home voice server]
TLS encrypts the connection between the CoreS3 and the DERP server here. The HTTP request for the WAV travels inside that connection.
With these DERP settings, requests returned HTTP 200, but a NULL dereference in the TLS send operation crashed the device during reception.
There were two TLS crashes, followed by a reboot that successfully downloaded 433,964 bytes. Downloads were still unreliable, so I changed the send/receive handling in tailscale_derp.c and the TLS configuration.
I disabled dynamic TLS buffers and added mutual exclusion so that send and receive operations could not access the same connection simultaneously. I also used nonblocking I/O, which returns immediately when no data is available, so a waiting reader would not prevent sending.
These changes were applied together; this trial cannot establish which individual changes were necessary to prevent the crashes.
Receiving the WAV on the CoreS3
The revised program was 951,824 bytes. On the device, it received HTTP 200 and completed the WAV download.
HTTP status=200 length=433964
PASS: WAV bytes=433964 time_ms=9025 rate_KiB_s=47.0
The program received the WAV in 4 KiB chunks, checked the RIFF and WAVE identifiers at the start, and verified that the file size calculated from the header matched the HTTP download size. It discarded received chunks, so it did not hold the entire WAV in memory.
I rebooted with the same firmware and reconnected using the stored device credentials. It downloaded the file again without another browser approval.
On both boots, the first connection timed out, and the retry after a five-second wait succeeded. Neither run crashed during reception. The durations in the table exclude boot, enrollment, and the initial failure.
| Check | First CoreS3 boot | After reboot |
|---|---|---|
| HTTP status | 200 | 200 |
| Download size | 433,964 bytes | 433,964 bytes |
| Successful attempt | Second | Second |
| Successful request duration | 9.025 seconds | 8.816 seconds |
| Internal free RAM after transfer | 89,780 bytes | 89,784 bytes |
| Minimum internal free RAM during processing | 70,108 bytes | 69,576 bytes |
The CoreS3 connected through the same Tokyo DERP region as the voice server. Direct UDP connections and automatic switching to other regions remain untested.