Arduino sketch for the Waveshare ESP32-S3 Zero that bridges SigmaStudio (Analog Devices' DSP programming GUI) to a physical ADAU1401/1701 DSP over WiFi → TCP → I²C. It also hosts an HTTP UI for writing DSP firmware to a 24C256 EEPROM, and an HTTP OTA page for reflashing the ESP32 itself. The sketch runs on the companion MSD ADAU14-1701 Adapter PCB (Altium files in the parent directory).
index_html.h, ota_html.h) are PROGMEM string literals compiled into flash — no separate LittleFS image upload is requiredTwo servers run in parallel — one TCP, one HTTP:
tcpServer (port 8086): SigmaTCP protocol. handleTcpBridgeClient() blocks loop() for the duration of a session (single client at a time).httpServer (port 80): EEPROM upload (/), OTA firmware update (/ota), and status endpoints. httpServer.handleClient() is called inside the TCP client loop so HTTP stays responsive during a TCP session.loop() only accepts a new TCP client after the previous one disconnects. There is no concurrency. Sessions time out after TCP_IDLE_TIMEOUT_MS (30 s) of inactivity — lastActivityMs is reset on every received byte and checked in the idle path between commands.
maintainWifi() is called at the top of every loop() iteration and checks WiFi status every 5 seconds, attempting reconnect if the link has dropped.
The build identifier uses YYMMDD_rev format (e.g. 260304_13). It appears in two places that must always match:
// Build: 260304_13 (YYMMDD_rev)const char* version = "VER: 260304_13";When cutting a new build, update both. The version constant is displayed on the LCD and printed to Serial at boot.
Hardcoded at the top of ModulosDSP_101.ino:
const char* ssid = "alfred";
const char* password = "alfred16";
Change before flashing to a different network. No runtime config exists. WiFi.setAutoReconnect(true) is set in setup() and maintainWifi() handles mid-session drops.
maintainWifi()Runs every 5 seconds (throttled by millis()). On first detection of a drop: logs to Serial, updates LCD row 3, sets s_wifiLost = true, calls WiFi.reconnect(). On recovery: calls tcpServer.begin() to re-register the listening socket (required after the TCP/IP stack resets), then calls printWifiInfo() to update the LCD with the current IP. Does not run while handleTcpBridgeClient() is blocking — that is fine because an active TCP session implies WiFi is up.
The LCD is detected at boot via the return value of lcd.begin(20, 4) (hd44780 returns 0 on success). The result is stored in static bool s_lcdOk. All subsequent lcd.* calls throughout the sketch are gated on s_lcdOk. If the LCD is absent the sketch boots and runs normally; all status information is available on Serial (115200 baud) instead.
| Device | 8-bit | 7-bit (Wire) |
|---|---|---|
| ADAU1401 DSP | 0x68 | 0x34 |
| 24C256 EEPROM | 0xA0 | 0x50 |
Both devices share the bus at 400 kHz on SDA=GPIO13, SCL=GPIO12.
SigmaStudio sends chipAddr as a chip index (0x01 = DSP, 0x02 = EEPROM), not a raw I²C address. chipAddrTo7bit() maps these — and also handles legacy 8-bit addresses (0x68, 0xA0) and already-shifted 7-bit addresses (0x34, 0x50).
WRITE (opcode 0x09) — 10-byte header:
[0] 0x09 command
[1] safeload 1 = use safeload registers, 0 = direct write
[2] placement reserved
[3-4] totalLen big-endian: header + payload bytes
[5] chipAddr chip index or I2C address
[6-7] dataLen big-endian: payload bytes
[8-9] address big-endian: DSP/EEPROM start address
[10+] payload
totalLen is validated against WRITE_HDR_LEN + dataLen immediately after parsing. A mismatch drops the connection — do not remove this check; it prevents dataLen from being blindly trusted to drive buffer waits or I²C writes when a packet is structurally inconsistent.
ACK response: {0x09, 0x00, 0x04, status} (4 bytes). status reflects the actual I²C result — writeRegisterBlock() returns bool and the result is threaded through to the ACK. Do not replace with a hardcoded success.
READ (opcode 0x0A) — 8-byte header:
[0] 0x0A
[1-2] totalLen
[3] chipAddr
[4-5] dataLen
[6-7] address
Response: {0x0B, totalLen_hi, totalLen_lo, status, dataLen_hi, dataLen_lo, [payload]} (6-byte header + data). On I²C failure, zeros fill the payload rather than dropping the connection — this allows SigmaStudio to probe an unresponsive DSP without aborting.
The DSP has 5 safeload slots. Filling them and writing 0x003C to the Core Register (0x081C) commits all pending parameters atomically (no audio glitch).
s_safeload_count is static in DSPWriter.cpp — it survives across DSPWriter instances.DSPWriter::resetSafeload() must be called at both the start and end of every TCP session. It is called at the top of handleTcpBridgeClient() (session start) and just before client.stop() (session end).resetSafeload() now flushes before resetting: if s_safeload_count > 0 it writes {0x00, 0x3C} to the Core Register to trigger IST, clearing the DSP's own internal safeload counter. A session ending with count == 0 (normal case) is a no-op.{0x00, byte1, byte2, byte3, byte4}. The leading 0x00 is mandatory padding.safeload == 1 in a WRITE packet, dataLen must be a multiple of 4 (each parameter is 4 bytes). The sketch validates this and drops the connection if violated.writeRegisterBlock — Error PropagationDSPWriter::writeRegisterBlock() returns bool. On any Wire.endTransmission() error it logs the failing address and error code to Serial and returns false immediately (stops writing — continuing would advance the address pointer and write misaligned data). The return value is captured at the call site and passed directly to sendWriteAck().
registerSizeForAddress() derives the I²C bytes-per-register from the address:
| Address range | Register size |
|---|---|
| 0x0000–0x03FF (Parameter RAM) | 4 bytes |
| 0x0400–0x07FF (Program RAM) | 5 bytes |
| 0x081C (Core Register), dataLen == 2 | 2 bytes (R0 reset) |
| 0x081C (Core Register), dataLen == 24 | 1 byte (24 x 1-byte hardware config regs) |
| Everything else (hardware regs) | 1 byte |
Do not hard-code register sizes elsewhere — always use this function or the constants from DSPWriter.h.
dataBuffer is 50 KB, statically allocated. The receive loop always drains client.available() into it before processing — this keeps the TCP receive window open. If you only drain during processing, SigmaStudio hits ZeroWindow and stalls mid-transfer.
Processing is decoupled from reception: the inner loop works from buffer contents (readIndex/writeIndex) without checking client.available(). Large program blocks that span multiple TCP segments are handled correctly this way.
When the buffer is fully consumed, all three index variables reset to 0 to prevent unbounded growth.
i2cAckPoll() polls for ACK up to 120 ms — this is the EEPROM's internal write cycle; do not remove this delay.GET /gpio and POST /gpiohandleGpioGet() reads GpioAllRegister (0x0808, 1 byte) and MpCfg0/1 (0x0820–0x0821, 1 byte each) and returns JSON:
{"ok":true,"gpio":0,"mpcfg0":0,"mpcfg1":0}
handleGpioSet() accepts a form-encoded value parameter (0–255) and writes it to GpioAllRegister via DSPWriter::writeRegister. Returns 400 if value is missing or out of range, 200 on success.
The /ota page adds a row of four toggle buttons (GP0–GP3). Each click XORs the corresponding bit in the cached gpioVal and POSTs the new byte to /gpio. The page also polls GET /gpio every 5 seconds to stay in sync with any external state changes. Buttons render green (btn-success) when the bit is high, outline-secondary when low.
Only output-configured pins respond to writes — which pins are outputs is determined by MpCfg registers (read-only in the UI, visible in the JSON response).
POST /dsp_resethandleDspReset() writes {0x00, 0x00} (stop) to the Core Register, waits 100 ms, then writes {0x00, 0x01} (run). Parameter and program RAM are preserved — the DSP restarts execution without reloading from EEPROM. Returns 200 "DSP soft reset complete." on success.
GET /dsp_statushandleDspStatus() reads three register regions via I²C and returns a JSON object:
{"ok":true,"running":true,"coreReg":"0x0001","gpio":"0x00","adc":["0x00","0x00","0x00","0x00"]}
| Field | Source | Notes |
|---|---|---|
ok |
AND of all three read results | false if any I²C read failed |
running |
Core Register bit 0 | true when DSP is executing |
coreReg |
0x081C (2 bytes) | Full register value as hex string |
gpio |
0x0808 (1 byte) | GPIO All Register |
adc[0–3] |
0x0809–0x080C (4-byte burst) | ADC input values |
HTTP status is 200 on success, 500 if any I²C read failed. The /ota page polls this endpoint every 2 seconds via XHR (pollStatus()) and displays a live status card at the top of the page.
Routes: GET /ota → handleOtaPage(), POST /ota_do → handleOtaDone() + handleOtaStream().
Uses the ESP32 Arduino core's built-in Update library (#include <Update.h>). The upload handler calls Update.begin(UPDATE_SIZE_UNKNOWN) → Update.write() per chunk → Update.end(true). On success handleOtaDone() sends a 200 response then calls ESP.restart() after a 500 ms delay. The LED turns cyan during the flash write.
The firmware binary is produced by Arduino IDE: Sketch → Export Compiled Binary. Upload the .bin file (not .elf or .map).
ADAU1401 parameter RAM uses 5.23 fixed-point (4 bytes per word). DataConversion::floatToFixed() and ::intToFixed() convert native C types to the 5-byte safeload form {0x00, b3, b2, b1, b0}.
DSPWriter::downloadProgram() is commented out. It was intended for standalone firmware loading at boot; this use case is now handled via the EEPROM (DSP reads EEPROM autonomously via SELFBOOT pin).| File | Role |
|---|---|
ModulosDSP_101.ino |
Top-level: WiFi, TCP/HTTP servers, I²C helpers, CRC32, OTA |
DSPWriter.h |
I²C address constants, register enums, DSPWriter class declaration |
DSPWriter.cpp |
writeRegister, writeRegisterBlock (returns bool), safeload with flush |
DataConversion.h / .cpp |
5.23 fixed-point to C type conversions |
index_html.h |
Embedded HTML string for the EEPROM upload UI |
ota_html.h |
Embedded HTML string for the OTA firmware update UI |