Tech8 min read

M5Stack CoreS3 on Tailscale: 9-second WAV downloads via DERP

IkesanContents

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

ItemEnvironment used
DeviceM5Stack CoreS3, ESP32-S3, 16 MiB flash
Development PCWindows, already enrolled in Tailscale
USB connectionCoreS3’s USB-C port, USB Serial/JTAG, COM3
BuildESP-IDF 6.0, Docker image espressif/idf:v6.0
Flash reads and writesesptool 5.3.0
DestinationHome 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.

CheckWindows PC result
DestinationHome server’s Tailscale IPv4 address, port 8357
Audio file/filler/filler_0.wav
HTTP status200
Download size433,964 bytes
Audio format48,000 Hz, 16-bit, mono
Length216,960 frames, 4.52 seconds; one frame is one sample for mono audio
PC download timeAbout 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.

CheckFirst CoreS3 bootAfter reboot
HTTP status200200
Download size433,964 bytes433,964 bytes
Successful attemptSecondSecond
Successful request duration9.025 seconds8.816 seconds
Internal free RAM after transfer89,780 bytes89,784 bytes
Minimum internal free RAM during processing70,108 bytes69,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.