# Findings: RP2350 ↔ NAU88L21 pin mapping (EP-2350 "Ting")

Session date: 2026-07-17. Method: live read-only register introspection over the
serial REPL via a pyserial raw-REPL driver (`scratch_repl.py`), plus static
inspection of the firmware image. `mpremote` was **not** used (unreliable here);
raw pyserial worked well.

## TL;DR

| Signal            | GPIO | Confidence | Evidence |
|-------------------|-----:|-----------|----------|
| **I²C SDA**       | **GPIO14** | High | external pull-up + I2C1-capable + I2C1 enabled |
| **I²C SCL**       | **GPIO15** | High | external pull-up + I2C1-capable + I2C1 enabled |
| I²S DIN (ADC, codec→MCU)  | GPIO8  | High | PIO SM IN pin; pad is an input |
| I²S DOUT (DAC, MCU→codec) | GPIO9  | High | PIO SM OUT pin; driven output |
| I²S BCLK          | GPIO10 | High (pin) / Med (which-of-10/11) | PIO SM side-set pin |
| I²S LRCLK / WS    | GPIO11 | High (pin) / Med (which-of-10/11) | PIO SM side-set pin |
| MCLK (codec master clock) | GPIO16 | Medium | 2nd PIO SM, high-rate side-set, driven output |

The **I²C control bus is hardware I2C1 on GPIO14 (SDA) / GPIO15 (SCL)**. This is
the primary answer requested. The NAU88L21's own ACK (0x1B/0x54) could not be
captured cleanly on the running firmware (see caveats), but every passive
indicator points to this pin pair, and it is the only pin pair on the board that
carries the I²C electrical signature.

---

## 1. Environment / important discovery about the firmware

- Firmware reports `MicroPython v1.26.0-preview`, `_machine='EP-2350 1.0.8'`,
  `machine='EP-2350 with RP2350'`. Chip is an **RP2350A** (only GPIO0–29 exist;
  GPIO30–47 read NULL / `help('modules')` shows `GPIO0..GPIO29`).
- **There is no `machine` module** in this custom firmware. Available modules:
  `rp2, _rp2, uctypes, micropython, teenage, ui, spl, fx, test, vfs, os, ...`.
  Register access was therefore done with **`uctypes.struct(addr, ...)`** rather
  than `machine.mem32`. This matters for follow-up work (see §6).
- The REPL stays fully interactive while audio runs. Register reads are safe.
- There is a factory-test module `test` (`test.chip_id/mcu/audio/adc/...`) and the
  audio/CODEC bring-up lives in the compiled C modules `ui`/`spl`/`fx`, not Python.

## 2. Primary result — I²C (SDA/SCL)

### Key evidence chain

1. **No GPIO is in I²C function (FUNCSEL==2) at the REPL.** So the CODEC I²C is
   configured once in C at boot and the pins are then parked in another function —
   live mux alone does not show it.

2. **Hardware I2C1 is still enabled; I2C0 is not.** Reading the DesignWare I²C
   peripheral registers:
   - `I2C1 @ 0x40098000`: `IC_ENABLE=1`, `IC_TAR=0x018`, `IC_CON=0x165`.
   - `I2C0 @ 0x40090000`: `IC_ENABLE=0`, `IC_TAR=0x055` (0x055 is the *reset
     default* of `IC_TAR`/`IC_SAR` → I2C0 was effectively never used).
   → The active control bus is **I2C1**. `IC_TAR=0x18` is the last-addressed
   device; `0x18` is a classic accelerometer address (the device has an
   accelerometer, `ui.acc`/shaker) sharing the same control bus.

3. **GPIO14 and GPIO15 uniquely show the I²C electrical signature.** For every
   pin I compared the pad pull config against the actual line level:
   - GPIO14, GPIO15: pad has **internal pull-DOWN** (`PADS=0x056`, PDE=1, PUE=0),
     the pin is **not driven** (`OETOPAD=0`), yet it **reads HIGH** (`GPIO_IN=1`).
     The only way an undriven, internally-pulled-down pin sits high is an
     **external pull-up** → these are open-drain bus lines held idle-high.
   - No other pin has this pattern. (Pins 1/3/19/21 also read high but have
     *internal* pull-ups and are PIO-driven — not I²C. When I released them to
     inputs they fell low, i.e. no external pull-up.)

4. **GPIO14/15 are exactly the I2C1 SDA/SCL pins** in the RP2350 fixed function
   map: pins `(4k+2, 4k+3)` = I2C1 SDA/SCL, so GPIO14 = I2C1 **SDA** (even),
   GPIO15 = I2C1 **SCL** (odd). This matches the one enabled peripheral (I2C1).

5. **Touching the pair resets the device.** Both times I actively drove/reclocked
   GPIO14/15 (crude bit-bang, and a hardware-I2C1 reconfigure) the firmware
   hung and the board watchdog-reset / re-enumerated over USB. That the *pair*
   is disruptive is consistent with it being a live, shared bus (accelerometer
   polling collided with my traffic). The board always recovered from flash.

Everything converges: **I²C = I2C1, SDA=GPIO14, SCL=GPIO15, with external
pull-ups, currently parked in FUNCSEL=PWM(F3) between transactions.**

### Caveats / what is NOT yet proven

- I never got a clean **ACK from the NAU88L21 itself (0x1B or 0x54)**, so:
  - the **CSB strap state** (→ 0x1B vs 0x54) is unknown, and
  - strictly, I confirmed "the I²C bus and its pins", and that an accelerometer
    (0x18) lives on it, but not the codec's presence by direct ACK.
  My active scans were the *only* things that crashed the firmware, precisely
  because a second master was fighting the running one. A clean scan needs the
  TE firmware to not be driving the bus concurrently (see §6 — stock MicroPython).
- The codec is almost certainly on this same I2C1 bus (I2C0 was never used), but
  a follow-up scan should verify by reading a known NAU88L21 ID register.

## 3. I²S (audio bus) — bonus, read-only

Only two PIO state machines are enabled. Decoding their `PINCTRL`:

- **I²S data/clocks** — SM0 of the PIO register block at `0x50200000`,
  `CLKDIV≈5.0`, `PINCTRL`: `OUT_BASE=9 (cnt1)`, `IN_BASE=8`,
  `SIDESET_BASE=10 (cnt2)`. Cross-checked against the actual pad direction
  (`GPIOn_STATUS.OETOPAD` / `INFROMPAD`):
  - **GPIO8** = **DIN** (input; ADC/mic data codec→MCU) — pad is an input.
  - **GPIO9** = **DOUT** (output; DAC data MCU→codec) — driven output.
  - **GPIO10 / GPIO11** = **BCLK** and **LRCLK/WS** (the two side-set clocks;
    both driven outputs). Which is which is not yet disambiguated (needs the PIO
    program / a scope). Convention makes GPIO10=BCLK, GPIO11=LRCLK likely.
- **MCLK candidate** — SM0 of the PIO block at `0x50300000`, `CLKDIV≈4.0`,
  side-set on **GPIO16** with `SIDE_EN` (a single high-rate clock output). GPIO16
  is a driven output. This looks like the codec **MCLK**.

### An unresolved labeling quirk (does not change the GPIO numbers)

The pins carrying I²S (GPIO8–11) read `FUNCSEL=6` (=PIO1) and MCLK (GPIO16)
reads `FUNCSEL=7` (=PIO2), yet the *matching, enabled* state-machine configs sit
in the register blocks one lower (0x50200000 "PIO0" and 0x50300000 "PIO1"
respectively). I could not reconcile this off-by-one purely from registers.
It does **not** affect the pin assignments above (those are anchored on actual
pad direction + the SM's own PINCTRL), only the PIO-instance *name*. Worth a
glance under stock firmware where `rp2.PIO`/`StateMachine` introspection is easy.

## 4. Full active-pin snapshot (post-reset, read-only)

FUNCSEL, pad-driven (OEtoPAD), input level, pad reg, internal pulls:

```
gpio fn    OEpad IN pad   PUE PDE  note
 0  SIO     1/0*  -  056  0   1    (dynamic: LED/switch)
 1  PIO0    0     1  05a  1   0    internal pull-up (PIO-driven)
 2  PIO0    1     1  056  0   1    driven high
 3  PIO0    0     1  05a  1   0    internal pull-up
 4  SIO     1     0  056  0   1
 5  SIO     1     0  056  0   1
 6  SIO     1     0  056  0   1
 8  PIO1    0     0  05a  1   0    I2S DIN  (input)
 9  PIO1    1     0  056  0   1    I2S DOUT (output)
10  PIO1    1     -  056  0   1    I2S BCLK/LRCLK (side-set)
11  PIO1    1     -  056  0   1    I2S BCLK/LRCLK (side-set)
14  PWM     0     1  056  0   1    *** I2C1 SDA  (external pull-up) ***
15  PWM     0     1  056  0   1    *** I2C1 SCL  (external pull-up) ***
16  PIO2    1     0  056  0   1    MCLK candidate (side-set, clkdiv 4)
17  SIO     1     0  056  0   1
18  SIO     1     0  056  0   1
19  PIO0    0     1  05a  1   0    internal pull-up
20  PIO0    0     0  05a  1   0
21  PIO0    0     1  05a  1   0    internal pull-up
24  SIO     1     0  056  0   1
25  PIO0    1     0  056  0   1    (likely status LED)
```
`pad 0x056` = PDE(pull-down), `0x05a` = PUE(pull-up); both have IE=1, SchmittEn,
drive=4mA. I2C0 disabled; I2C1 enabled, TAR=0x18.

## 5. Reference register locations used (RP2350, verified vs datasheet)

```
IO_BANK0     0x40028000   GPIOn: STATUS @ +8n (OETOPAD=bit13, OUTTOPAD=bit9, INFROMPAD=bit17)
                          CTRL @ +8n+4 (FUNCSEL = CTRL[4:0])
PADS_BANK0   0x40038000   GPIOn pad @ +4+4n (IE=b6, OD=b7, DRV=b5:4, PUE=b3, PDE=b2, SCH=b1)
SIO          0xd0000000   GPIO_IN +0x004, GPIO_OUT +0x010, GPIO_OE +0x030 (SET/CLR +8/+0x10)
I2C0/I2C1    0x40090000 / 0x40098000   IC_CON +0x00, IC_TAR +0x04, IC_DATA_CMD +0x10,
                          IC_ENABLE +0x6c, IC_ENABLE_STATUS +0x9c, IC_TX_ABRT_SOURCE +0x80
PIO0/1/2     0x50200000 / 0x50300000 / 0x50400000   CTRL +0, SMx PINCTRL @ +0xdc+0x18*x
FUNCSEL: 0 SPI, 1 UART, 2 I2C, 3 PWM, 4 SIO, 5 PIO0, 6 PIO1, 7 PIO2, 31 NULL
```

## 6. Suggested follow-up tests (recommended: stock MicroPython)

The cleanest confirmation is to **temporarily flash stock MicroPython for the
Pico 2 / RP2350** (board `RPI_PICO2`), which restores the `machine` module and —
crucially — means **no competing firmware is driving the I²C bus**, so a scan
won't cause contention. Keep `ep-2350_firmware_1_0_8.uf2` handy to restore the TE
firmware afterward (drag-and-drop over BOOTSEL). All tests below are quick.

**T1 — Confirm I²C pins + enumerate devices (settles SDA/SCL + finds codec):**
```python
from machine import Pin, I2C, SoftI2C
# hardware I2C1 is SDA=GP14, SCL=GP15:
i2c = I2C(1, sda=Pin(14), scl=Pin(15), freq=100_000)
print([hex(a) for a in i2c.scan()])   # expect 0x1b or 0x54 (codec) AND 0x18 (accel)
# also try SoftI2C in case of any HW-mux surprise:
s = SoftI2C(scl=Pin(15), sda=Pin(14), freq=100_000)
print([hex(a) for a in s.scan()])
```
Success = the scan returns the NAU88L21 address → proves GPIO14/15 and reveals
the CSB strap (0x1B = CSB0, 0x54 = CSB1).

**T2 — Positively identify the NAU88L21 (not just "something ACKed"):**
Read a NAU88L21 ID/reset register over I²C (16-bit register address, 16-bit data,
big-endian per its datasheet), e.g. read the software-reset/device-ID region:
```python
addr = 0x1b  # or whatever T1 found
i2c.writeto(addr, bytes([0x00, 0x00]))      # set register pointer to 0x0000
print(i2c.readfrom(addr, 2).hex())          # read back 16-bit reg value
```
Compare against the NAU88L21 datasheet's ID/default value to be 100% sure it is
the codec and not another device.

**T3 — Disambiguate BCLK vs LRCLK and confirm MCLK (GPIO16):**
Under stock firmware you can't easily see the *original* config, but you can:
- reason from I²S timing: LRCLK = sample rate (e.g. 48 kHz), BCLK = LRCLK × bits ×
  channels; MCLK (GP16) is much faster (256×/384× fs). A logic-analyzer/scope on
  GP10, GP11, GP16 settles it instantly.
- Or, better, capture the *TE firmware's* live PIO program: in a session on the
  original firmware, read the PIO instruction memory (`INSTR_MEM`, base+0x048 for
  32 words) of the enabled block at 0x50200000 and decode the side-set to see the
  clock phase relationship. (Read-only, safe.)

**T4 — If staying on the TE firmware (no reflash), safe read-only re-checks:**
Re-run the passive dumps in this repo's scratch scripts — they never disturb the
bus. Do **not** bit-bang or reconfigure I2C1 live: both attempts here crashed the
running firmware because its own driver was mid-transaction. Any *active* probe
must first quiesce the firmware's audio/accel loop (not obviously possible from
the REPL), which is why stock MicroPython (T1) is the recommended route.

## 7. Files produced this session

- `scratch_repl.py` — pyserial classic raw-REPL driver (send code blob, capture
  stdout/stderr). Reusable for follow-up sessions on the TE firmware.
- scratchpad scripts: `a1_dump.py` (FUNCSEL), `a2_dump.py` (pads+SIO),
  `status_dump.py` (OE/IN to pad), `pio_recheck.py` (PIO SMs), `i2c_regs.py`
  (I²C peripheral state), plus the (now-known-disruptive) active probes.
- `/tmp/fw.bin` — UF2 unpacked to a flat image at load base 0x10000000
  (family 0xe48bff59 = RP2350), for static analysis.
