# CLAUDE.md — ModulosDSP_101 ## What This Is 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). ## Build Environment - **Arduino IDE** 1.8.x or 2.x with the ESP32 Arduino core - Board: **ESP32S3 Dev Module** (Flash mode: QIO, 4 MB, Default 4MB with spiffs partition) - Both HTML UIs (`index_html.h`, `ota_html.h`) are PROGMEM string literals compiled into flash — no separate LittleFS image upload is required - Baud rate for Serial monitor: **115200** ## Architecture Two 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. ## Build / Version Scheme The build identifier uses `YYMMDD_rev` format (e.g. `260304_13`). It appears in two places that must always match: 1. The file header comment: `// Build: 260304_13 (YYMMDD_rev)` 2. The runtime constant: `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. ## WiFi Credentials Hardcoded at the top of `ModulosDSP_101.ino`: ```cpp 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. ## WiFi Watchdog — `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. ## LCD — Optional Hardware 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. ## I²C Addresses | 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. ## SigmaTCP Protocol Details 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:** ```text [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:** ```text [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. ## Safeload — Critical Rules 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. - Safeload writes are always 5 bytes: `{0x00, byte1, byte2, byte3, byte4}`. The leading `0x00` is mandatory padding. - When `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 Propagation `DSPWriter::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()`. ## Register Sizes `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`. ## TCP Receive Buffer `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. ## EEPROM Write Constraints - Page size: 64 bytes. Writes crossing a page boundary must be split. - Max I²C bytes per transaction: 28 (Wire buffer limit minus the 2-byte address overhead). - After each chunk, `i2cAckPoll()` polls for ACK up to 120 ms — this is the EEPROM's internal write cycle; do not remove this delay. ## GPIO Register — `GET /gpio` and `POST /gpio` `handleGpioGet()` reads `GpioAllRegister` (0x0808, 1 byte) and `MpCfg0/1` (0x0820–0x0821, 1 byte each) and returns JSON: ```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). ## DSP Soft Reset — `POST /dsp_reset` `handleDspReset()` 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. ## DSP Status — `GET /dsp_status` `handleDspStatus()` reads three register regions via I²C and returns a JSON object: ```json {"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. ## OTA Firmware Update Routes: `GET /ota` → `handleOtaPage()`, `POST /ota_do` → `handleOtaDone()` + `handleOtaStream()`. Uses the ESP32 Arduino core's built-in `Update` library (`#include `). 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`). ## Fixed-Point Format 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}`. ## Known Stubs and Intentional Gaps - `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). - WiFi credentials are compile-time constants — no captive portal or NVS storage. - Only one TCP client is served at a time; a second SigmaStudio instance cannot connect simultaneously. ## Files | 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 |