==============================================================================
Claude Code session : 1b654052-359b-4c76-a397-09f16e1a835d
Project directory : /home/agent/TE_ting_circuitpython
Started : 2026-07-28 13:24:12
Ended : 2026-07-28 13:35:06
User prompts : 3
Claude Code version : 2.1.220
==============================================================================
[2026-07-28 13:24:12] user:
/effort
effort
[2026-07-28 13:24:12] user:
Set effort level to medium (saved as your default for new sessions): Balanced approach with standard implementation and testing
[2026-07-28 13:26:11] user:
The TE EP-2350 running CircuitPython is connected. I have learned that it has a poteniometer on the handle that can measure how far pressed the handle is. Currently we've only mapped two buttons for the CircuitPython board def. Can you write a test to discover the pin that the pot is on. I can press and release the handle as needed when the time comes. It won't be GP2 because that is the power hold pin.
[2026-07-28 13:26:13] agent:
I'll start by looking at the current board definition and how the pins are mapped.
[2026-07-28 13:26:14] tool use: Bash
command: ls /home/agent/TE_ting_circuitpython
description: List project directory
[2026-07-28 13:26:14] tool result: Bash
Adafruit_CircuitPython_LIS331
Adafruit_CircuitPython_LIS3DH
Adafruit_CircuitPython_NAU88L21
Adafruit_CircuitPython_Register
Adafruit_CircuitPython_TLV320
audio_codec_bringup_session3.md
boot.py
circuitpython
circuitpython_codec_findings.md
code_ep2350_demo.py
code_passthrough_amp.py
code_passthrough.py
code.py
code_usb_mic_passthru.py
DS_NAU88L21_DataSheet_EN_Rev2.8.pdf
ep2350_codec.py
ep-2350-info.md
i2s_external_clock_plan.md
mic_findings.md
nau88l21_driver_plan.md
Nuvoton-ASoC
__pycache__
register_address_width_plan.md
RP-008373-DS-2-rp2350-datasheet.pdf
scratchpad
scripts
test_audio.wav
test_recording.wav
[2026-07-28 13:26:15] tool use: Bash
command: cat /home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/memory/ep2350-repl-workflow.md /home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/memory/ep2350-dangerous-pins.md
description: Read workflow memories
[2026-07-28 13:26:15] tool result: Bash
---
name: ep2350-repl-workflow
description: "How to drive the EP-2350 CircuitPython board over the raw REPL, including the gotcha that state is lost between connections"
metadata:
node_type: memory
type: project
originSessionId: 7bfa2c3d-a166-4115-bfc2-fc7a59c14a8f
modified: 2026-07-19T23:14:44.971Z
---
The EP-2350 runs CircuitPython on `/dev/ttyACM0`, mounting as
`/media/timc/CIRCUITPY/`. Use `~/agent_venv/` (has pyserial, numpy) for host-side
code. Drive the board via the **raw REPL** (ctrl-C ctrl-C, ctrl-A, code, ctrl-D).
**Why:** `ampy`/`circup` are installed but the raw REPL is the only way to run
ad-hoc test code and read results back without touching the filesystem.
**How to apply:**
- **Board state does NOT survive closing the serial port.** Objects created in
one exec are gone on the next connection. Anything that must stay alive while
the host does something (e.g. `arecord`) has to run in the *same* exec — write
the setup, then `time.sleep(n)`, and do the host-side work concurrently.
- An exec that raises leaves objects un-deinit'd, so the next run fails with
`ValueError: GPnn in use`. Wrap teardown in a `cleanup()` helper.
- CIRCUITPY is read-only to the board while USB MSC is mounted; use `io.BytesIO`
as a sink and `binascii.b2a_base64` to ship data back to the host.
- RAM is ~400 KB free; an `array.array("h")` of 44100 already fails to allocate.
See [[audio-test-rig]] for verifying audio output.
---
name: ep2350-dangerous-pins
description: GP2 on the EP-2350 powers the unit off if driven low; GP25/26/27 are not really floating
metadata:
node_type: memory
type: project
originSessionId: 585846fd-0977-4015-ae39-5cd0bfdf85d5
modified: 2026-07-23T18:08:37.696Z
---
**Never drive GP2.** It is `POWER_HOLD` — driving it low powers the EP-2350 off
instantly and the user has to physically power it back on. Never sweep a range
of GPIOs configuring them as outputs; pick pins deliberately from
`boards/teenage_engineering_ep2350/pins.c`.
**Also never call `microcontroller.reset()`** over the REPL — verified 2026-07-23
it drops POWER_HOLD and powers the board OFF (needs a physical power-on), it does
NOT just reboot. To re-run code use Ctrl-D (soft reboot) or save a file over USB.
Note: repeated soft reboots can wedge the NAU88L21 codec's I2C/FLL bring-up, so
`import code_ep2350_demo` throws `OSError [Errno 5]` at code.py line 4 — retry
Ctrl-D a few times and it comes up; it is not a code bug. See
[[ep2350-repl-workflow]].
**Why:** on 2026-07-20 a loop that tried every `board.GPn` as a PIO set-pin
killed the board mid-session.
**How to apply:** the genuinely safe scratch pins are **GP7, GP13, GP22, GP23** —
they float and respond to the internal pulls. GP25/GP26/GP27 are labelled
"unconnected" in `pins.c` but all three read high against an armed internal
pull-down, so they are useless as pull-sensing test pins. Also in use: GP0/4/5/6
and GP16/17/18/24 (LEDs), GP1/3/21 (buttons), GP19/20 (handle), GP8-GP12 (I2S),
GP14/15 (I2C), GP28/29 (rail sense, volume pot).
See [[ep2350-repl-workflow]] and [[ep2350-firmware-build]].
[2026-07-28 13:26:18] thinking: [not recorded in transcript]
[2026-07-28 13:26:20] tool use: Bash
command: cd /home/agent/TE_ting_circuitpython/circuitpython/ports/raspberrypi/boards/teenage_engineering_ep2350 && ls && cat pins.c && cat mpconfigboard.h 2>/dev/null | head -40
description: Read board pin definitions
[2026-07-28 13:26:20] tool result: Bash
board.c
mpconfigboard.h
mpconfigboard.mk
pico-sdk-configboard.h
pins.c
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks
//
// SPDX-License-Identifier: MIT
#include "shared-bindings/board/__init__.h"
static const mp_rom_map_elem_t board_module_globals_table[] = {
CIRCUITPYTHON_BOARD_DICT_STANDARD_ITEMS
// LEDs, one dedicated GPIO each, active high.
// Two columns of four, numbered top (nearest the handle) to bottom.
// Note: GP0 and GP16 share a PWM slice/channel, so the their LEDs
// cannot be PWM-dimmed independently.
{ MP_ROM_QSTR(MP_QSTR_GP0), MP_ROM_PTR(&pin_GPIO0) },
{ MP_ROM_QSTR(MP_QSTR_LED_WHITE1), MP_ROM_PTR(&pin_GPIO0) },
{ MP_ROM_QSTR(MP_QSTR_LED), MP_ROM_PTR(&pin_GPIO0) },
{ MP_ROM_QSTR(MP_QSTR_GP6), MP_ROM_PTR(&pin_GPIO6) },
{ MP_ROM_QSTR(MP_QSTR_LED_WHITE2), MP_ROM_PTR(&pin_GPIO6) },
{ MP_ROM_QSTR(MP_QSTR_GP4), MP_ROM_PTR(&pin_GPIO4) },
{ MP_ROM_QSTR(MP_QSTR_LED_WHITE3), MP_ROM_PTR(&pin_GPIO4) },
{ MP_ROM_QSTR(MP_QSTR_GP5), MP_ROM_PTR(&pin_GPIO5) },
{ MP_ROM_QSTR(MP_QSTR_LED_WHITE4), MP_ROM_PTR(&pin_GPIO5) },
{ MP_ROM_QSTR(MP_QSTR_GP16), MP_ROM_PTR(&pin_GPIO16) },
{ MP_ROM_QSTR(MP_QSTR_LED_RED1), MP_ROM_PTR(&pin_GPIO16) },
{ MP_ROM_QSTR(MP_QSTR_GP24), MP_ROM_PTR(&pin_GPIO24) },
{ MP_ROM_QSTR(MP_QSTR_LED_RED2), MP_ROM_PTR(&pin_GPIO24) },
{ MP_ROM_QSTR(MP_QSTR_GP18), MP_ROM_PTR(&pin_GPIO18) },
{ MP_ROM_QSTR(MP_QSTR_LED_RED3), MP_ROM_PTR(&pin_GPIO18) },
{ MP_ROM_QSTR(MP_QSTR_GP17), MP_ROM_PTR(&pin_GPIO17) },
{ MP_ROM_QSTR(MP_QSTR_LED_RED4), MP_ROM_PTR(&pin_GPIO17) },
// Side buttons. Switches to ground, no external pull-ups: use Pull.UP,
// pressed reads False.
{ MP_ROM_QSTR(MP_QSTR_GP21), MP_ROM_PTR(&pin_GPIO21) },
{ MP_ROM_QSTR(MP_QSTR_BUTTON_TOP), MP_ROM_PTR(&pin_GPIO21) },
{ MP_ROM_QSTR(MP_QSTR_GP3), MP_ROM_PTR(&pin_GPIO3) },
{ MP_ROM_QSTR(MP_QSTR_BUTTON_MIDDLE), MP_ROM_PTR(&pin_GPIO3) },
{ MP_ROM_QSTR(MP_QSTR_GP1), MP_ROM_PTR(&pin_GPIO1) },
{ MP_ROM_QSTR(MP_QSTR_BUTTON_BOTTOM), MP_ROM_PTR(&pin_GPIO1) },
// Handle: a two-stage switch. HANDLE_IN reads False when the handle is
// fully seated; HANDLE_OUT reads True as soon as the handle is moved.
{ MP_ROM_QSTR(MP_QSTR_GP19), MP_ROM_PTR(&pin_GPIO19) },
{ MP_ROM_QSTR(MP_QSTR_HANDLE_IN), MP_ROM_PTR(&pin_GPIO19) },
{ MP_ROM_QSTR(MP_QSTR_GP20), MP_ROM_PTR(&pin_GPIO20) },
{ MP_ROM_QSTR(MP_QSTR_HANDLE_OUT), MP_ROM_PTR(&pin_GPIO20) },
// Power hold latch. Held high by the board so the unit stays powered once
// the handle is released. Driving it low powers the unit off immediately.
{ MP_ROM_QSTR(MP_QSTR_GP2), MP_ROM_PTR(&pin_GPIO2) },
{ MP_ROM_QSTR(MP_QSTR_POWER_HOLD), MP_ROM_PTR(&pin_GPIO2) },
// I2C1: NAU88L21 codec at 0x1b, accelerometer at 0x18. External pull-ups.
{ MP_ROM_QSTR(MP_QSTR_GP14), MP_ROM_PTR(&pin_GPIO14) },
{ MP_ROM_QSTR(MP_QSTR_SDA), MP_ROM_PTR(&pin_GPIO14) },
{ MP_ROM_QSTR(MP_QSTR_GP15), MP_ROM_PTR(&pin_GPIO15) },
{ MP_ROM_QSTR(MP_QSTR_SCL), MP_ROM_PTR(&pin_GPIO15) },
// I2S to the codec. The codec is the I2S clock source (it drives BCLK and
// LRCLK); the MCU only supplies MCLK.
{ MP_ROM_QSTR(MP_QSTR_GP8), MP_ROM_PTR(&pin_GPIO8) },
{ MP_ROM_QSTR(MP_QSTR_I2S_DIN), MP_ROM_PTR(&pin_GPIO8) },
{ MP_ROM_QSTR(MP_QSTR_GP9), MP_ROM_PTR(&pin_GPIO9) },
{ MP_ROM_QSTR(MP_QSTR_I2S_DOUT), MP_ROM_PTR(&pin_GPIO9) },
{ MP_ROM_QSTR(MP_QSTR_GP10), MP_ROM_PTR(&pin_GPIO10) },
{ MP_ROM_QSTR(MP_QSTR_I2S_WS), MP_ROM_PTR(&pin_GPIO10) },
{ MP_ROM_QSTR(MP_QSTR_GP11), MP_ROM_PTR(&pin_GPIO11) },
{ MP_ROM_QSTR(MP_QSTR_I2S_BIT_CLOCK), MP_ROM_PTR(&pin_GPIO11) },
{ MP_ROM_QSTR(MP_QSTR_GP12), MP_ROM_PTR(&pin_GPIO12) },
{ MP_ROM_QSTR(MP_QSTR_I2S_MCLK), MP_ROM_PTR(&pin_GPIO12) },
// Unidentified / unconnected.
{ MP_ROM_QSTR(MP_QSTR_GP7), MP_ROM_PTR(&pin_GPIO7) },
{ MP_ROM_QSTR(MP_QSTR_GP13), MP_ROM_PTR(&pin_GPIO13) },
{ MP_ROM_QSTR(MP_QSTR_GP22), MP_ROM_PTR(&pin_GPIO22) },
{ MP_ROM_QSTR(MP_QSTR_GP23), MP_ROM_PTR(&pin_GPIO23) },
{ MP_ROM_QSTR(MP_QSTR_GP25), MP_ROM_PTR(&pin_GPIO25) },
// Analog. GP26/GP27 are unconnected.
{ MP_ROM_QSTR(MP_QSTR_GP26), MP_ROM_PTR(&pin_GPIO26) },
{ MP_ROM_QSTR(MP_QSTR_A0), MP_ROM_PTR(&pin_GPIO26) },
{ MP_ROM_QSTR(MP_QSTR_GP27), MP_ROM_PTR(&pin_GPIO27) },
{ MP_ROM_QSTR(MP_QSTR_A1), MP_ROM_PTR(&pin_GPIO27) },
// Rail sense through a resistive divider (VSYS/VBAT / 2).
{ MP_ROM_QSTR(MP_QSTR_GP28), MP_ROM_PTR(&pin_GPIO28) },
{ MP_ROM_QSTR(MP_QSTR_A2), MP_ROM_PTR(&pin_GPIO28) },
{ MP_ROM_QSTR(MP_QSTR_VOLTAGE_MONITOR), MP_ROM_PTR(&pin_GPIO28) },
// Volume potentiometer wiper, full scale 0 - 3.3V.
{ MP_ROM_QSTR(MP_QSTR_GP29), MP_ROM_PTR(&pin_GPIO29) },
{ MP_ROM_QSTR(MP_QSTR_A3), MP_ROM_PTR(&pin_GPIO29) },
{ MP_ROM_QSTR(MP_QSTR_VOLUME), MP_ROM_PTR(&pin_GPIO29) },
{ MP_ROM_QSTR(MP_QSTR_I2C), MP_ROM_PTR(&board_i2c_obj) },
};
MP_DEFINE_CONST_DICT(board_module_globals, board_module_globals_table);
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks
//
// SPDX-License-Identifier: MIT
#define MICROPY_HW_BOARD_NAME "Teenage Engineering ting fx EP-2350"
#define MICROPY_HW_MCU_NAME "rp2350a"
// Top white LED. Also available as board.LED_WHITE1.
#define MICROPY_HW_LED_STATUS (&pin_GPIO0)
// GPIO2 is the power hold latch: high keeps the unit powered, low powers it
// off immediately. It is asserted in board_reset_pin_number() so that it
// survives every pin reset.
#define MICROPY_HW_POWER_HOLD_PIN_NUMBER (2)
// Codec (0x1b) and accelerometer (0x18) share I2C1. External pull-ups.
#define CIRCUITPY_BOARD_I2C (1)
#define CIRCUITPY_BOARD_I2C_PIN {{.scl = &pin_GPIO15, .sda = &pin_GPIO14}}
[2026-07-28 13:26:21] tool use: Bash
command: cat /home/agent/TE_ting_circuitpython/ep-2350-info.md
description: Read board info notes
[2026-07-28 13:26:21] tool result: Bash
# EP-2350 "Ting" — hardware reference
Consolidated hardware facts for the Teenage Engineering EP-2350. MCU is an
**RP2350A** (GPIO0–GPIO29 only). Audio codec is a **Nuvoton NAU88L21**.
---
## 1. Complete GPIO map
| GPIO | Function | Direction / sense |
|-----:|----------|-------------------|
| **0** | LED — white 1 (top, nearest handle) | out, active high |
| **1** | Side button — bottom | in, 0 = pressed (needs internal pull-up) |
| **2** | **POWER HOLD latch** | out, **high = stay powered, low = power off** |
| **3** | Side button — middle | in, 0 = pressed (needs internal pull-up) |
| **4** | LED — white 3 | out, active high |
| **5** | LED — white 4 (bottom) | out, active high |
| **6** | LED — white 2 | out, active high |
| 7 | unidentified | — |
| **8** | I²S **DIN** — codec ADCOUT → MCU | codec output |
| **9** | I²S **DOUT** — MCU → codec DACIN | codec input (board pull-up, idles high) |
| **10** | I²S **FS / LRCLK / WS** | codec output in codec-master mode |
| **11** | I²S **BCLK** | codec output in codec-master mode |
| **12** | I²S **MCLK** (codec master clock) | MCU output |
| 13 | unidentified | — |
| **14** | **I²C1 SDA** (codec + accelerometer) | external pull-up |
| **15** | **I²C1 SCL** (codec + accelerometer) | external pull-up |
| **16** | LED — red 1 (top, nearest handle) | out, active high |
| **17** | LED — red 4 (bottom) | out, active high |
| **18** | LED — red 3 | out, active high |
| **19** | Handle switch — **fully in** (travel end-stop) | in, 0 = pressed |
| **20** | Handle switch — **at rest** | in, 0 = handle released, 1 = handle held |
| **21** | Side button — top (nearest handle) | in, 0 = pressed (needs internal pull-up) |
| 22, 23 | unidentified | — |
| **24** | LED — red 2 | out, active high |
| 25 | unidentified (not ADC-capable) | — |
| 26 (ADC0) | floating / unconnected | — |
| 27 (ADC1) | floating / unconnected | — |
| **28 (ADC2)** | analog rail sense (VSYS/VBAT ÷ 2, likely) | real divider, R_th ≈ 4.6 kΩ |
| **29 (ADC3)** | **Volume knob** (potentiometer wiper) | analog, 0 → 3.29 V |
Unidentified pins: GP7, GP13, GP22, GP23, GP25, plus a `get_vbus` digital
VBUS-present sense line that has no candidate pin.
### I²C bus
Hardware **I2C1** on SDA=GP14 / SCL=GP15, external pull-ups. Only two devices:
| Address | Device |
|---------|--------|
| `0x18` | accelerometer (shake detection) |
| `0x1b` | NAU88L21 codec (CSB strap = 0) |
There is **no PMIC / power-management chip** on the bus.
---
## 2. Power
**GPIO2 is the power hold latch.** Drive it HIGH to keep the unit powered; drive
it LOW to power off immediately.
```python
from machine import Pin
hold = Pin(2, Pin.OUT, value=1) # assert before the handle is released
# hold.value(0) # cut power
```
Behaviour:
- **At power-on the latch is OFF.** The handle switch supplies power directly
while it is held in. Firmware that never asserts GP2 dies the instant the
handle is released.
- **It is a true set/reset latch, not a keep-alive.** Once set, it retains even
with GP2 floating (all output-enables cleared) — no periodic refresh needed.
- **Driving GP2 LOW resets the latch → instant power-off**, USB drops.
- Pressing the handle is the only way to power the unit *on*; plugging in USB
does not power on a powered-off unit.
- **Pulling USB powers the unit off**, even with the latch set and batteries
fitted — it does not fall back to battery. The power-path handover appears to
brown out the RP2350, which releases GP2 and clears the latch.
- The documented 5-minute power-save / 20-minute auto-off are firmware timers on
top of this: the stock firmware watches GP19, then drives GP2 low.
**⚠️ Never call `machine.reset()` on this board** — it releases GP2, clearing the
latch, and the unit powers off completely, requiring a physical power-on.
Rail sensing: **GP28 (ADC2)** is the only genuine analog sense line — a real
resistive divider (R_th ≈ 4.6 kΩ, consistent with 10k‖10k). VSYS/VBAT ÷ 2 is the
leading interpretation (its reading moved −7.7 % when batteries were fitted), but
VBUS ÷ N has not been fully excluded.
---
## 3. Audio interface (NAU88L21)
### 3.1 Topology
| Item | Value |
|------|-------|
| Codec | NAU88L21, I²C address **`0x1b`**, ID register `0x58` reads **`0x1b23`** |
| Register access | **16-bit register address, 16-bit data, big-endian** |
| MCLK | **GP12**, driven by the MCU (`machine.PWM`) |
| BCLK | **GP11** = MCLK / 8 |
| FS / LRCLK | **GP10** = BCLK / 32 = MCLK / 256 |
| DACIN (MCU → codec) | **GP9** |
| ADCOUT (codec → MCU) | **GP8** |
| Microphone | **one analog mic on the codec's LEFT channel** (MICL+/MICL− → FEPGA → ADCL). Right channel carries no signal — the mic path is **mono**. |
| Headphone | codec HP amp → jack; the analog path **inverts** |
| Volume knob | not connected to the codec — it is an ADC input on GP29 (§1) |
### 3.2 Working clock configuration
The reliable topology is **codec = I²S master, MCU = I²S slave** (both TX and
RX), with the MCU supplying only MCLK on GP12. The codec's own slave-mode I²S
receiver does not render data reliably; codec-master does.
```
machine.freq(144_000_000)
MCLK = 12.000 MHz on GP12 (PWM, sysclk ÷ 12)
BCLK = MCLK / 8 = 1.5 MHz (codec output, GP11)
FS = BCLK / 32 = 46875 Hz (codec output, GP10)
MCLK / fs = 256 exactly; codec DAC clk = SYSCLK/2 with OSR128 → coherent
```
`machine.freq(153_600_000)` is also settable and yields exact fs = 48000 if
wanted. 147.456 MHz and 122.88 MHz are **not** settable.
The MCU must set **GP10 and GP11 to `Pin.IN`** — they are codec outputs in this
mode and driving them causes bus contention.
Frame/phase facts: FS transitions on **BCLK falling** edges; frame = 32 BCLK
(16 per channel); the codec latches DACIN on **BCLK rising**, and drives ADCOUT
on **BCLK rising** — so a PIO receiver must sample ADCOUT on **BCLK falling**
(mid-bit) to be deterministic.
### 3.3 Codec registers — I²S
```
0x1C I2S_PCM_CTRL1 = 0x0001 DL_16 | DF_LEFT (left-justified, 16-bit)
0x1D I2S_PCM_CTRL2 = 0x301B I2S_TRI=0 (bit15 — default 1 tri-states all
I²S pins!), LRC_DIV=11 (FS=BCLK/32),
MS0=1 (master), BCLK_DIV=011 (BCLK=MCLK/8)
0x03 CLK_DIVIDER = 0x0050 default: SYSCLK from MCLK pin, no FLL needed
0x01 ENA_CTRL = 0x00FF default already has CLK_I2S_EN=1
```
Reg 0x1D bit map: `I2S_TRI`=b15, `LRC_DIV`=[13:12], `ADCDAT0_OE`=b4, `MS0`=b3,
`BCLK_DIV`=[2:0].
### 3.4 Codec registers — DAC → headphone playback
```
0x01 ENA_CTRL = 0x0CFF EN_DACL/R (b10/b11) + low clocks incl EN_DAC_CLK
0x2C DAC_CTRL1 = 0x0082 default, OSR128
0x31 MUTE = 0x0000
0x32 HSVOL = 0x0000 0 dB headset volume
0x34 DAC gain = 0xCFCF DGAINL=[7:0], DGAINR=[15:8]; ≈ +0.5 dB
0x0D = 0xC000 output pulldowns disabled
0x4B = 0x0007 class-G enable + L/R DAC
0x66 BIAS_ADJ = 0x0060 VMIDEN + VMID_SEL=125k
0x6A = 0x1003
0x73 RDAC = 0x772C DACL/R_EN + DACL/R_CLK_EN
0x76 BOOST = 0x3140 HP boost
0x7F = 0x003F HP output drivers
0x80 = 0x0020 charge pump enable
```
**⚠️ R66 bits [9:8] are BIAS_TESTDAC** (`0x100`=L, `0x200`=R). Setting them
switches both DACs to a test input tied to GND — guaranteed silence. Keep them
clear (`R66 = 0x0060`).
### 3.5 Codec registers — microphone / ADC capture
Applied on top of the master-I²S setup, in this order:
```
0x66 = 0x0060 VMIDEN + VMID tie-off (bias first)
0x76 = 0x1000 GLOBAL_BIAS_EN
0x71 = 0x0011 MIC-detect powered down (default)
0x74 = 0x0106 MICBIAS POWERUP + default voltage
0x77 = 0x0000 FEPGA: MICP/MICN connected
0x7E = gg<<8 | gg FEPGA gain: 0x00 = −1 dB … 0x25 = 36 dB
0x7F = 0xC000 PUP_PGA_L | PUP_PGA_R
0x72 = 0x0070 ADC analog power: PDNOTL | PDNOTR
0x2B = 0x0002 ADC rate
0x30 = 0x0000
0x35 = 0xCFCF ADC digital gains (0 dB)
0x01 = 0x03FF defaults + EN_ADCL (b8) | EN_ADCR (b9)
```
Allow **~400 ms** after the analog power-up before capturing — 60 ms yields
samples but the bias is still ramping and the capture is a DC drift with almost
no AC content. The ADC's DC offset continues decaying for the first 1–2 s; high-
pass (~120–150 Hz) or discard the first second if you need a stable baseline.
`0x35` ADCR_CH_VOL has no effect on the right-slot word (ADCL_CH_VOL works
bit-exactly). Irrelevant in practice — the right slot carries no audio.
The codec's internal `R55 D2A_LOOP` (ADC→DAC) does not appear to work; route
audio digitally through the MCU instead.
### 3.6 I²S word layout
- **RX (from ADCOUT):** syncing on **FS falling** starts in the right-channel
slot, so each 32-bit word is `[right << 16 | left]` — **the mic sample is the
LOW half**. Syncing on FS rising lands one bit early.
- **TX (to DACIN):** slot order is `[left, right]`.
Consequence: a byte-for-byte RX→TX passthrough puts the mic in the *right* slot
and silence in the left. For mono duplication, do it in the PIO TX program:
```
pull(); out(null,16); mov(x,osr); <16 bits>; mov(osr,x); <16 bits>
```
**Autopull must be OFF** for this — with `autopull=True, pull_thresh=32` the
refill fires immediately before `mov(osr, x)` and clobbers the fresh word,
eating every other sample. Use an explicit blocking `pull()` per frame.
### 3.7 Bring-up requirements (all mandatory)
1. **Abort every DMA channel before a PIO+DMA bring-up.** `machine.soft_reset()`
does *not* stop running `rp2.DMA()` channels; orphans keep writing the PIO TX
FIFO and the next bring-up either starves (total silence) or renders garbage.
RP2350 `CHAN_ABORT` = `0x50000000 + 0x464`; write a channel bitmap (`0xFFFF`),
poll until it self-clears, and also clear each channel's non-triggering CTRL
alias (`chan_base + 0x10`).
2. **Free the PIO before re-running.** `soft_reset` leaves SMs and instruction
memory claimed → `ENOMEM` at `StateMachine(...)`. Clear `PIO0/1 CTRL`
(SM_ENABLE=0) and call `rp2.PIO(n).remove_program()`.
3. **Arm the TX DMA *before* `sm.active(1)`.** Otherwise the slave TX syncs to an
FS edge, stalls at `out` on an empty FIFO, and resumes mid-frame with word
alignment lost → scrambled output.
4. **Put RX and TX on separate PIO blocks** (e.g. RX on PIO1, TX on PIO0) — they
share 32-slot instruction memory within a block.
5. Sample ADCOUT on **BCLK falling** (§3.2).
With these in place the slave-TX word phase is **deterministic** (measured
offset 0 on 10/10 bring-ups) — no retry loop is needed.
### 3.8 Validated audio routes
| Route | Status |
|-------|--------|
| Tone generation → DAC → headphone jack | Working, deterministic, SNR 260–745 |
| Mic → ADC → RAM (≤ ~1 s, 187 KB buffer) | Working |
| Mic → ADC → DMA ring → host over REPL (unbounded length) | Working, 0 overruns at 94 KB/s |
| Mic → RAM → DAC → headphone (record then replay) | Working, verified by matched filter (r = 0.988) |
| **Live mic → headphone passthrough, zero CPU in the data path** | Working |
The passthrough works because both PIO SMs are I²S slaves to the same codec
BCLK/FS, so each moves exactly one word per frame. Two free-running DMA channels
wrap inside the *same* aligned buffer — RX wrapping its **write** address
(`ring_sel=1`), TX wrapping its **read** address (`ring_sel=0`) — so the gap
between pointers is fixed at startup and never drifts. Latency = that gap
(64 words ≈ 1.4 ms by default); keep it above 8 words, since the TX FIFO
pre-buffers 8.
Level control without CPU involvement: `0x7E` (analog FEPGA gain) and `0x34`
(DAC digital gain). Output level at the jack is low as configured — raise DAC or
HP gain for a listening demo.
Practical limits: ~1 s is the maximum in-RAM capture (2 s raises `MemoryError`);
extracting the low half of each word must be `@micropython.viper` to keep up
with 46875 words/s. Measured device fs runs +0.13–0.2 % vs the nominal 46875 Hz.
---
## 4. LEDs
Eight LEDs, **eight dedicated GPIOs, one per LED, active HIGH**. No matrix,
charlieplexing, shift register, or enable line.
| Position (top → bottom) | Red column | White column |
|---|---:|---:|
| 1 (nearest handle) | **GP16** | **GP0** |
| 2 | **GP24** | **GP6** |
| 3 | **GP18** | **GP4** |
| 4 | **GP17** | **GP5** |
```python
RED = [16, 24, 18, 17] # top -> bottom
WHITE = [0, 6, 4, 5] # top -> bottom
```
- Full brightness is just a digital `1`. Each LED has its own series resistor, so
raising pad drive strength (4 mA → 12 mA) makes no visible difference.
- Dimming is plain PWM (`freq(1000)`, `duty_u16()`) — clean and monotonic.
duty ≈ 1200/65535 is a comfortable dim-indicator level.
- `Pin.IN` with no pull = fully off. `Pin.IN, Pin.PULL_UP` gives a free dim
standby glow (the internal pull-up sources ~40 µA through the LED).
- **⚠️ GP0 and GP16 share a PWM slice/channel.** The top red and top white LEDs
cannot be PWM-dimmed independently — the second `duty_u16()` silently
overrides the first. Use PWM on one and plain on/off on the other. The
mirroring only occurs when a pin is actually muxed to the PWM function.
---
## 5. Controls
### Buttons
All five are simple switches to ground with **no external pull-ups** — every one
needs `Pin(n, Pin.IN, Pin.PULL_UP)`.
| Button | GPIO | Sense |
|--------|-----:|-------|
| Side button — top (nearest handle) | **GP21** | 0 = pressed |
| Side button — middle | **GP3** | 0 = pressed |
| Side button — bottom | **GP1** | 0 = pressed |
| Handle fully in (travel end-stop) | **GP19** | 0 = pressed |
| Handle at rest / not held | **GP20** | 0 = released, 1 = held |
**The handle is a two-stage switch.** GP19 and GP20 are separate switches on the
same mechanism and change in sequence, with a dead band mid-travel where both
read 1:
- `GP20 == 1` is the earliest "user is touching the handle" signal — it fires
~300 ms before GP19 confirms full insertion.
- `GP19 == 0` means fully seated; this is what gates the power-on path.
- Neither pin alone tells you the handle position — use the pair.
Baseline `GPIO_IN` with the handle in and nothing pressed: `0x3ff7ffff`.
### Volume knob
A plain potentiometer across the 3V3 rail with its wiper on **GP29 = ADC3**. It
is wired to the RP2350, **not** to the codec.
```python
from machine import ADC
vol = ADC(3) # GP29
level = vol.read_u16() # 0..65535, full scale usable
```
- Not a rotary encoder, and there is **no integral push-switch** — no digital pin
moves during any knob action.
- Range: 68 counts (0.003 V) at minimum → 65340 counts (3.290 V) at maximum. It
bottoms out at both rails, so full scale is usable without calibration.
- Static noise: ±3 counts on a 16-sample average.
- Taper is not established (linear vs mild audio taper is within measurement
error) — treat the raw value as a control input and apply taper in software.
---
## 6. Firmware & bootloader
- The board ships with a custom TE MicroPython build (`_machine = 'EP-2350 1.0.8'`,
MicroPython v1.26.0-preview). It has **no `machine` module**; available modules
include `rp2, _rp2, uctypes, micropython, teenage, ui, spl, fx, test, vfs, os`.
Register access there must go through `uctypes.struct`. Audio/codec bring-up
lives in the compiled C modules `ui`/`spl`/`fx`, not in Python.
- Everything in this document was validated under **stock MicroPython for the
Pico 2 (`RPI_PICO2`, ARM build)**, which the board runs fine.
- **Update mode:** remove the lower lid, connect USB-C, hold the handle, then
double-click the small button above the USB-C port. A drive named **`TING BOOT`**
appears (USB `2367:0620`, `INFO_UF2.TXT` → `UF2 Bootloader v1.0 / Model:
EP-2350`).
- This is **TE's own flash-resident bootloader, not the RP2350 ROM BOOTSEL**, so
**`picotool` cannot see it**. Flash by drag-and-drop of a UF2 onto the drive.
TE UF2s are standard RP2350 UF2s (base `0x10000000`, family `0xe48bff59` =
RP2350 ARM-S), the same format as the stock MicroPython image.
- The real ROM BOOTSEL (drive `rp2350`, USB `2e8a:000f`) is reachable — from
running MicroPython, `machine.bootloader()` enters it, and `picotool` works
there. **Note this also cuts power to the latch.**
- Restore stock TE firmware by dragging `ep-2350_firmware_1_0_8.uf2` onto either
drive.
### QSPI flash — size and layout
**Winbond W25Q16JV — 2 MB (16 Mbit) on CS0. Nothing on CS1.** JEDEC RDID
(`0x9F`) returns `EF 40 15`: `0xEF` Winbond, device `0x4015` = W25Q16 family in
the standard-SPI (`0x40`) variant, i.e. W25Q16JV-IQ/JQ; capacity byte `0x15` =
2²¹ = 2 MB, which agrees with the aliasing measurement below.
| flash range | size | contents |
|---|---|---|
| `0x000000`–`0x0E47FF` | 929 KB | firmware image; matches `ep-2350_firmware_1_0_8.uf2` byte ranges exactly, including the `/rom` romfs at `0x0E0000` (18,339 B) |
| `0x0E4800`–`0x0FFFFF` | 110 KB | erased `0xFF`, unused |
| `0x100000`–`0x1FFFFF` | 1 MB | FAT12 volume `NO NAME` = `/fat`, the user drive |
`/fat`: 4096-byte sectors, 1 reserved, 1 FAT sector, 512 root entries, **250 data
clusters × 4 KB = 1000 KB**. `rp2.Flash()` exposes exactly this region
(`ioctl(4)` = 256 blocks, `ioctl(5)` = 4096). `os.statvfs('/fat')` on a factory
device with no samples loaded reports all 250 clusters free.
**How the size was measured** (all reads passive via `uctypes`, safe on live
firmware): XIP address aliasing. Content at `+1 MB` differs, but `+2 MB`, and the
`0x400000`/`0x800000`/`0xC00000` flash offsets reached through the other ATRANS
windows, are byte-identical to offset 0 — 48 scattered 64-byte samples over a
4 MB span, 24 of them non-uniform, zero mismatches. So the chip ignores A21 and
up. `0x11000000` (CS1) reads back as `0xCC` filler.
Two traps that make the flash look bigger than it is:
- **`QMI_ATRANS0` is not at its reset value.** Observed
`ATRANS0 = 0x04000C00` → base `0xC00000`, size 4 MB; `ATRANS1..4` hold the
default `0x000000/0x400000/0x800000/0xC00000` bases and `ATRANS5..7` are zero.
So `0x10000000` nominally maps to flash 12 MB — which wraps to 0 on a 2 MB
part. The windows are the default sequence rotated by one slot.
- **OTP says nothing.** `FLASH_DEVINFO` (row `0x054`, guarded read at
`0x40130000 + row*2`) is `0x0000`, and `BOOT_FLAGS0` (row `0x048`) is `0x0000`,
so `FLASH_DEVINFO_ENABLE` is clear and the bootrom is running on its **default
assumption of 16 MB CS0** — not a measured value. That default is the likely
reason for the ATRANS rotation.
OTP read strides (both verified against the CHIPID rows): guarded/ECC-corrected
`0x40130000 + row*2` (16 bits per row), raw `0x40134000 + row*4` (24 bits, data
in the low 16). Config rows are triple-redundant, e.g. `BOOT_FLAGS1` at
`0x04B`/`0x04C`/`0x04D` = `0x0020`.
#### Reading the JEDEC ID (done under stock MicroPython)
Not passive — it needs QMI direct mode, which suspends XIP. Under **stock
MicroPython** `M0_RFMT` has PREFIX_LEN=0 and `M0_RCMD = 0xA0EB`, i.e. the chip is
left sitting in **continuous-read (XIP) mode**, so `0x9F` cannot just be issued;
the device must be returned to serial-command state first or XIP dies on return.
(TE's firmware is the opposite: PREFIX_LEN=1, `M0_RCMD = 0x00EB`, so it re-sends
the command every read and the chip is never in continuous mode.)
Don't hand-roll the exit sequence — use the bootrom. Working recipe, script in
`scratchpad/jedec.py`:
1. `rom_table_lookup` = `mem16[0x16]` (ROM magic `'M','u',0x02` at `0x10`).
Look up with **`RT_FLAG_FUNC_ARM_SEC = 0x0004`**; `'E','X'` → `flash_exit_xip`
(`0xD65` on this A2 ROM). Codes are `c1 | (c2 << 8)`. `RT_FLAG_DATA = 0x0040`.
2. MicroPython's inline asm has **no `blx`**; call the ROM pointer with a
trampoline — `bl(CALL); b(DONE); label(CALL); bx(rN); label(DONE)` — so the
`bl` leaves the return address in `lr` for the callee.
3. `disable_irq()` → call `flash_exit_xip()` → `enable_irq()`. It returns with
XIP working (03h reads, CLKDIV=12), so the interpreter can run normally
between steps; IRQs only need to be off during the call itself, because an
interrupt handler fetching from flash mid-call would fault.
4. `disable_irq()` → RDID in a `@micropython.viper` routine (native code lives in
RAM, so it keeps executing while XIP is down) → `enable_irq()`.
Sequence: preserve CLKDIV/RXDELAY, set `EN`, poll `BUSY` clear, drain RX, set
`ASSERT_CS0N`, then per byte push `DIRECT_TX` (`0x9F` then zeros) and pop
`DIRECT_RX`; finally poll `BUSY`, deassert CS, clear `EN`. Bound every wait
loop with a counter — a spin with interrupts off is an unrecoverable hang.
The first RX byte is the one clocked in during the command byte; the ID starts
at byte 1.
Viper gotcha: `x & 0xffc00000` fails to compile with
`ViperTypeError: can't do binary op between 'int' and 'object'` — constants above
30 bits become objects. Use `(x >> 22) << 22`.
**This leaves XIP in the slow 03h mode** (`M0_RFMT = 0x1000`, `M0_RCMD = 0x03`).
Everything works, flash reads are just slower; only a hard reset re-runs boot2
and restores fast QSPI XIP.
#### ⚠️ Stock MicroPython's filesystem is oversized for this board
The `RPI_PICO2` build targets the Pico 2's **4 MB** W25Q32 and reports
`os.statvfs('/')` = 768 × 4096 = **3 MB** on this 2 MB part. Only the first
~1.9 MB of that region physically exists; beyond it the address wraps to flash 0
and **overwrites the firmware image**. Fine for light use, but do not copy large
files onto stock MicroPython while it is running on the EP-2350.
---
## 7. RP2350 platform notes
Register bases and offsets used throughout (these differ from RP2040 — using
RP2040 SIO offsets silently leaves pins driven):
```
IO_BANK0 0x40028000 GPIOn STATUS @ +8n (OETOPAD=b13, OUTTOPAD=b9, INFROMPAD=b17)
GPIOn CTRL @ +8n+4, FUNCSEL = CTRL[4:0]
PADS_BANK0 0x40038000 GPIOn pad @ +4+4n (IE=b6, OD=b7, DRV=[5:4], PUE=b3, PDE=b2, SCH=b1)
SIO 0xD0000000 GPIO_IN +0x004, GPIO_OUT +0x010, OUT_SET +0x018,
OUT_CLR +0x020, OUT_XOR +0x028, GPIO_OE +0x030,
OE_SET +0x038, OE_CLR +0x040
I2C0 / I2C1 0x40090000 / 0x40098000 IC_CON +0x00, IC_TAR +0x04,
IC_DATA_CMD +0x10, IC_ENABLE +0x6c
PIO0/1/2 0x50200000 / 0x50300000 / 0x50400000
CTRL +0, SMx PINCTRL @ +0xDC+0x18*x, SM0_ADDR +0xD4
PIO0_TXF0 = 0x50200010
DMA 0x50000000 CHAN_ABORT +0x464
FUNCSEL: 0 SPI, 1 UART, 2 I2C, 3 PWM, 4 SIO, 5 PIO0, 6 PIO1, 7 PIO2, 31 NULL
```
**⚠️ RP2350-E9 erratum affects input probing.** A naive "internal pull-up vs
pull-down" survey reports *every* pin as externally pulled high: with the
internal pull-down enabled, a pad that has been high latches high. The reliable
method is to drive the pin to the opposite state first to clear the latch, then
release it to the internal pull and read:
- external pull-up test: drive LOW → release with internal pull-DOWN → reads 1
- external pull-down test: drive HIGH → release with internal pull-UP → reads 0
Pull-*up*-only scans (as used for the buttons) are unaffected by the erratum.
Also note `GPIO_IN` reads all-zero on a fresh MicroPython boot because RP2350
pads default to input-disabled (`IE=0`); pins must be instantiated as `Pin`
objects first.
---
## 8. Known open questions
- **GP28**: VBAT/VSYS sense vs VBUS sense — not decisively settled.
- **`get_vbus`**: probably a digital VBUS-present pin; no candidate identified.
- **GP7, GP13, GP22, GP23, GP25**: unidentified (none drive an LED).
- **Crystal frequency** — unconfirmed.
- **QSPI flash CS pad location** — unconfirmed; needed for worst-case hardware
recovery into ROM BOOTSEL. (The chip itself is identified: Winbond W25Q16JV,
2 MB — see §6.)
- Whether the power latch has a hardware auto-off in addition to the firmware
timers.
- A broadband ~13 kHz hump observed at the headphone jack that tracks mic gain —
probably preamp noise shaped by the capture adapter; outside the audio band.
[2026-07-28 13:26:28] thinking: [not recorded in transcript]
[2026-07-28 13:26:29] tool use: Bash
command: ls /home/agent/TE_ting_circuitpython/scripts | head -50
[2026-07-28 13:26:29] tool use: Bash
command: ls -l /dev/ttyACM* /media/*/CIRCUITPY 2>&1 | head -20
[2026-07-28 13:26:29] tool result: Bash
btn_monitor.py
device_main.py
device_mic_loop.py
device_mic_passthru.py
device_mic_rx.py
device_mic_stream.py
device_tone.py
device_tone_slave.py
dma_reset.py
i2s_slave
jedec.py
knob_monitor.py
led_test.py
lock_test.py
measure_freq.py
mic_capfn.py
mic_host.py
mic_loop_verify.py
mic_record.py
mic_stream_host.py
mic_to_hp.py
play_loopback.py
probe_phase.py
__pycache__
repl.py
rp2pio_fixes
rp.py
rx_probe.py
sweep_mclk.py
t1_baseline.py
t2b_adcdat_pull.py
t2c_oe_tri.py
t2_din.py
t3_dout.py
test_d2a_loop.py
tone_autolock.py
[2026-07-28 13:26:29] tool result: Bash
crw-rw---- 1 root dialout 166, 0 Jul 28 13:23 /dev/ttyACM0
/media/timc/CIRCUITPY:
total 2
-rwxrwxr-x 1 timc removable_storage 183 Dec 31 1999 boot_out.txt
-rwxrwxr-x 1 timc removable_storage 22 Dec 31 1999 code.py
drwxrwxr-x 2 timc removable_storage 512 Dec 31 1999 lib
drwxrwxr-x 2 timc removable_storage 512 Dec 31 1999 sd
-rwxrwxr-x 1 timc removable_storage 0 Dec 31 1999 settings.toml
[2026-07-28 13:26:31] tool use: Bash
command: cd /home/agent/TE_ting_circuitpython/scripts && sed -n 1,80p knob_monitor.py && echo ==== && sed -n 1,60p repl.py
[2026-07-28 13:26:31] tool result: Bash
#!/usr/bin/env python3
"""Hunt for the EP-2350 volume knob: digital + analog at the same time.
Digital: every GPIO except GP2 (power hold) and GP26-29 (claimed by the ADC)
gets an internal pull-up; debounced GPIO_IN changes are streamed. Catches a
rotary encoder (two pins chattering in quadrature) or a switch.
Analog: ADC0-3 (GP26-29) sampled continuously; a reading is reported whenever
it moves more than THRESH counts from the last reported value. Catches a
potentiometer wiper.
Usage: python3 knob_monitor.py [seconds]
"""
import sys, time, serial
PORT = "/dev/ttyACM0"
DUR = float(sys.argv[1]) if len(sys.argv) > 1 else 40.0
DEV = """
from machine import Pin, ADC, mem32
import time
SIO = 0xd0000000
hold = Pin(2, Pin.OUT, value=1)
DIG = [i for i in range(30) if i != 2 and not (26 <= i <= 29)]
pins = [Pin(i, Pin.IN, Pin.PULL_UP) for i in DIG]
MASK = 0
for i in DIG:
MASK |= 1 << i
adcs = [ADC(i) for i in range(4)]
THRESH = 400
def rd():
return mem32[SIO + 0x004] & MASK
prev = rd()
last = [a.read_u16() for a in adcs]
t0 = time.ticks_ms()
print("BASE %08x A %d %d %d %d" % (prev, last[0], last[1], last[2], last[3]))
end = t0 + int(__DUR__ * 1000)
while time.ticks_diff(end, time.ticks_ms()) > 0:
v = rd()
if v != prev:
time.sleep_ms(5)
v = rd()
if v != prev:
print("D %6d %08x %08x" % (time.ticks_diff(time.ticks_ms(), t0), v, v ^ prev))
prev = v
for j in range(4):
c = adcs[j].read_u16()
if abs(c - last[j]) > THRESH:
print("A %6d %d %d" % (time.ticks_diff(time.ticks_ms(), t0), j, c))
last[j] = c
time.sleep_ms(2)
print("DONE")
""".replace("__DUR__", repr(DUR))
DIG = [i for i in range(30) if i != 2 and not (26 <= i <= 29)]
def fmt(s):
p = s.split()
try:
if p[0] == "D":
t, v, ch = int(p[1]), int(p[2], 16), int(p[3], 16)
low = [i for i in DIG if not (v >> i) & 1]
prs = [i for i in DIG if (ch >> i) & 1 and not (v >> i) & 1]
rel = [i for i in DIG if (ch >> i) & 1 and (v >> i) & 1]
bits = []
if prs:
bits.append("->0 " + ",".join("GP%d" % i for i in prs))
if rel:
bits.append("->1 " + ",".join("GP%d" % i for i in rel))
return "t=%7.2fs DIG %-28s low=%s" % (
t / 1000.0, " ".join(bits), ",".join("GP%d" % i for i in low) or "-")
if p[0] == "A":
t, ch, val = int(p[1]), int(p[2]), int(p[3])
return "t=%7.2fs ADC%d (GP%d) = %5d %.3f V" % (
t / 1000.0, ch, 26 + ch, val, val * 3.3 / 65535)
except (ValueError, IndexError):
pass
return s
====
#!/usr/bin/env python3
"""Reliable raw-REPL exec helper for the EP-2350 over /dev/ttyACM0."""
import sys, time, serial
PORT = "/dev/ttyACM0"
BAUD = 115200
def _read_until(ser, ending, timeout=5.0):
ending = ending if isinstance(ending, bytes) else ending.encode()
buf = bytearray()
deadline = time.time() + timeout
while time.time() < deadline:
n = ser.in_waiting
if n:
buf += ser.read(n)
if buf.endswith(ending) or ending in buf:
return bytes(buf), True
deadline = time.time() + timeout
else:
time.sleep(0.005)
return bytes(buf), False
def run(code, timeout=10.0):
if isinstance(code, str):
code = code.encode()
with serial.Serial(PORT, BAUD, timeout=0.1) as ser:
ser.write(b"\r\x03\x03") # interrupt
time.sleep(0.2)
ser.reset_input_buffer()
# Enter the raw REPL, and insist on it. If ctrl-A is missed the code
# still *runs* -- the friendly REPL echoes and executes it -- but a
# blank line inside a block silently truncates it, so a half-executed
# script comes back looking like a successful run. Retry, then give up.
for attempt in range(3):
ser.write(b"\x01") # ctrl-A -> raw REPL
_, ok = _read_until(ser, b"raw REPL; CTRL-B to exit\r\n>", timeout=2.0)
if ok:
break
ser.write(b"\r\x03\x03")
time.sleep(0.2)
ser.reset_input_buffer()
else:
raise RuntimeError("could not enter raw REPL on " + PORT)
ser.reset_input_buffer()
ser.write(code)
ser.write(b"\x04") # ctrl-D -> execute
# The reply is OK\x04\x04>. Accumulate the whole thing
# rather than reading it a field at a time: a short, fast result can
# land in a single read(), and splitting on the first \x04 found would
# then throw away everything after it.
resp = bytearray()
deadline = time.time() + timeout
while time.time() < deadline:
n = ser.in_waiting
if n:
resp += ser.read(n)
if resp.count(b"\x04") >= 2:
break
[2026-07-28 13:26:40] thinking: [not recorded in transcript]
[2026-07-28 13:26:41] tool use: Bash
command: sed -n 80,200p knob_monitor.py; echo ====; sed -n 60,200p repl.py; echo ==== BTN; sed -n 1,40p btn_monitor.py
[2026-07-28 13:26:41] tool result: Bash
def main():
with serial.Serial(PORT, 115200, timeout=0.1) as ser:
ser.write(b"\r\x03\x03")
time.sleep(0.2)
ser.reset_input_buffer()
ser.write(b"\x01")
time.sleep(0.3)
ser.reset_input_buffer()
ser.write(DEV.encode())
ser.write(b"\x04")
buf = b""
deadline = time.time() + DUR + 15
while time.time() < deadline:
n = ser.in_waiting
if n:
buf += ser.read(n)
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
s = line.decode(errors="replace").strip().lstrip("OK").strip()
if not s or s == ">":
continue
if s.startswith("DONE"):
print("--- done ---")
ser.write(b"\x02")
return
print(fmt(s), flush=True)
else:
time.sleep(0.005)
ser.write(b"\x03\x02")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
====
break
deadline = time.time() + timeout
else:
time.sleep(0.005)
ser.write(b"\x02") # ctrl-B -> back to normal
body = bytes(resp)
if body.startswith(b"OK"):
body = body[2:]
parts = body.split(b"\x04")
stdout = parts[0]
stderr = parts[1] if len(parts) > 1 else b""
return stdout.decode(errors="replace"), stderr.decode(errors="replace")
if __name__ == "__main__":
src = sys.stdin.read() if len(sys.argv) < 2 else open(sys.argv[1]).read()
o, e = run(src)
sys.stdout.write(o)
if e.strip():
sys.stderr.write("\n--- STDERR ---\n" + e)
==== BTN
#!/usr/bin/env python3
"""Live GPIO change monitor for EP-2350 button hunting.
Configures every GPIO except GP2 (power hold) as input with an internal
pull-up, then streams every debounced change of GPIO_IN to stdout as
t= IN= low=[pins reading 0] changed=[pins that moved]
Usage: python3 btn_monitor.py [seconds]
Ctrl-C to stop early; the device is left with GP2 still held high.
"""
import sys, time, serial
PORT = "/dev/ttyACM0"
DUR = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
DEV = """
from machine import Pin, mem32
import time
SIO = 0xd0000000
hold = Pin(2, Pin.OUT, value=1)
pins = [Pin(i, Pin.IN, Pin.PULL_UP) for i in range(30) if i != 2]
MASK = 0x3fffffff & ~(1 << 2)
def rd():
return mem32[SIO + 0x004] & MASK
prev = rd()
t0 = time.ticks_ms()
print("BASE %08x" % prev)
end = t0 + int(__DUR__ * 1000)
while time.ticks_diff(end, time.ticks_ms()) > 0:
v = rd()
if v != prev:
time.sleep_ms(8) # debounce settle
v = rd()
if v != prev:
print("%6d %08x %08x" % (time.ticks_diff(time.ticks_ms(), t0), v, v ^ prev))
prev = v
time.sleep_ms(2)
print("DONE")
""".replace("__DUR__", repr(DUR))
[2026-07-28 13:26:52] thinking: [not recorded in transcript]
[2026-07-28 13:26:53] agent:
Only GP26–GP29 are ADC-capable on the RP2350A, and GP28/GP29 are already claimed (rail sense / volume), so GP26 and GP27 are the prime suspects. I'll write a monitor that samples all four while you work the handle.
[2026-07-28 13:27:17] tool use: Write
file_path: /home/agent/TE_ting_circuitpython/scripts/handle_pot_monitor.py
content:
#!/usr/bin/env python3
"""Find the EP-2350 handle position potentiometer (CircuitPython build).
The RP2350A only has four ADC-capable pins -- GP26 (A0), GP27 (A1), GP28 (A2)
and GP29 (A3) -- so the handle pot has to be one of them. GP28 is the rail
sense divider and GP29 is the volume knob, which leaves GP26/GP27 as the
likely candidates; all four are sampled anyway so nothing is assumed.
The device samples the four ADCs plus the two digital handle switches
(GP19 HANDLE_IN, GP20 HANDLE_OUT) every ~25 ms and streams them back. The
host prints a live line, walks you through a press/release script, and then
ranks the channels by how much they moved and by how well they track the
handle switches.
Usage: python3 handle_pot_monitor.py [seconds] (default 30)
"""
import sys, time, serial
PORT = "/dev/ttyACM0"
BAUD = 115200
DUR = float(sys.argv[1]) if len(sys.argv) > 1 else 30.0
DEV = """
import board, analogio, digitalio, time
NAMES = ("GP26", "GP27", "GP28", "GP29")
adcs = [analogio.AnalogIn(p) for p in
(board.GP26, board.GP27, board.GP28, board.GP29)]
sw = []
for p in (board.HANDLE_IN, board.HANDLE_OUT):
d = digitalio.DigitalInOut(p)
d.switch_to_input(digitalio.Pull.UP)
sw.append(d)
t0 = time.monotonic()
end = t0 + __DUR__
print("START")
try:
while time.monotonic() < end:
ms = int((time.monotonic() - t0) * 1000)
v = [a.value for a in adcs]
print("S %d %d %d %d %d %d %d" % (ms, v[0], v[1], v[2], v[3],
sw[0].value, sw[1].value))
time.sleep(0.025)
finally:
for a in adcs:
a.deinit()
for d in sw:
d.deinit()
print("DONE")
""".replace("__DUR__", repr(DUR))
NAMES = ["GP26/A0", "GP27/A1", "GP28/A2 (rail)", "GP29/A3 (volume)"]
def script_for(t):
"""The press/release choreography, as a function of elapsed seconds."""
f = t / DUR
if f < 0.15:
return "hands OFF the handle (baseline)"
if f < 0.35:
return "press the handle IN slowly, all the way"
if f < 0.50:
return "HOLD it fully in"
if f < 0.70:
return "release it slowly, all the way out"
if f < 0.85:
return "press IN and hold half way, if you can"
return "release, hands off"
def analyse(samples):
if len(samples) < 10:
print("\nNot enough samples to analyse (%d)." % len(samples))
return
print("\n--- per-channel summary (%d samples) ---" % len(samples))
cols = list(zip(*[s[1:5] for s in samples]))
hin = [s[5] for s in samples]
hout = [s[6] for s in samples]
moved = hin.count(0) or hout.count(1)
rows = []
for i, col in enumerate(cols):
lo, hi = min(col), max(col)
span = hi - lo
# Mean while the handle reads "out/at rest" vs "moved or seated".
rest = [c for c, a, b in zip(col, hin, hout) if a == 1 and b == 0]
used = [c for c, a, b in zip(col, hin, hout) if a == 0 or b == 1]
sep = 0
if rest and used:
sep = abs(sum(used) / len(used) - sum(rest) / len(rest))
rows.append((span, sep, i, lo, hi))
print("%-18s min %5d max %5d span %5d (%.3f V) "
"handle-state separation %6.0f" %
(NAMES[i], lo, hi, span, span * 3.3 / 65535, sep))
rows.sort(reverse=True)
print()
if not moved:
print("The handle switches never changed -- was the handle actually "
"worked during the run? Treat the ranking below with caution.")
best = rows[0]
if best[0] < 500:
print("No channel moved more than %d counts: none of GP26-GP29 looks "
"like a handle pot." % best[0])
else:
print("Most movement: %s (span %d counts, %.2f V)." %
(NAMES[best[2]], best[0], best[0] * 3.3 / 65535))
def main():
samples = []
phase = None
with serial.Serial(PORT, BAUD, timeout=0.1) as ser:
ser.write(b"\r\x03\x03")
time.sleep(0.2)
ser.reset_input_buffer()
ser.write(b"\x01")
time.sleep(0.3)
ser.reset_input_buffer()
ser.write(DEV.encode())
ser.write(b"\x04")
buf = b""
deadline = time.time() + DUR + 15
try:
while time.time() < deadline:
n = ser.in_waiting
if not n:
time.sleep(0.005)
continue
buf += ser.read(n)
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
s = line.decode(errors="replace").strip().lstrip("OK").strip()
if not s or s == ">":
continue
if s.startswith("DONE"):
raise StopIteration
if s.startswith("START"):
print("--- sampling for %.0f s ---" % DUR)
continue
if not s.startswith("S "):
print(s, flush=True)
continue
p = s.split()
try:
rec = [int(x) for x in p[1:8]]
except ValueError:
continue
samples.append(rec)
t = rec[0] / 1000.0
ph = script_for(t)
if ph != phase:
phase = ph
print("\n>>> %s" % ph.upper(), flush=True)
print("\rt=%5.1fs GP26=%5d GP27=%5d GP28=%5d GP29=%5d "
"IN=%d OUT=%d" % (t, rec[1], rec[2], rec[3], rec[4],
rec[5], rec[6]), end="", flush=True)
except (StopIteration, KeyboardInterrupt):
pass
finally:
ser.write(b"\x03\x03\x02")
print()
analyse(samples)
if __name__ == "__main__":
main()
[2026-07-28 13:27:17] tool result: Write
File created successfully at: /home/agent/TE_ting_circuitpython/scripts/handle_pot_monitor.py (file state is current in your context — no need to Read it back)
[2026-07-28 13:27:22] thinking: [not recorded in transcript]
[2026-07-28 13:27:23] agent:
Quick baseline check first (no handle action needed):
[2026-07-28 13:27:29] tool use: Bash
command:
cd /home/agent/TE_ting_circuitpython && ~/agent_venv/bin/python - <<'EOF'
import sys; sys.path.insert(0,"scripts")
import repl
o,e = repl.run("""
import board, analogio, digitalio, time
for nm, p in (("GP26",board.GP26),("GP27",board.GP27),("GP28",board.GP28),("GP29",board.GP29)):
a = analogio.AnalogIn(p)
vals = []
for _ in range(16):
vals.append(a.value)
time.sleep(0.002)
a.deinit()
print(nm, min(vals), max(vals), sum(vals)//16)
for nm, p in (("HANDLE_IN/GP19",board.HANDLE_IN),("HANDLE_OUT/GP20",board.HANDLE_OUT)):
d = digitalio.DigitalInOut(p); d.switch_to_input(digitalio.Pull.UP)
print(nm, d.value); d.deinit()
""", timeout=15)
print(o); print("ERR:", e)
EOF
description: Read baseline ADC values over REPL
[2026-07-28 13:27:33] tool result: Bash
GP26 49564 49836 49672
GP27 31703 32327 31879
GP28 32247 32407 32362
GP29 27510 27670 27616
HANDLE_IN/GP19 True
HANDLE_OUT/GP20 False
ERR:
[2026-07-28 13:27:46] thinking: [not recorded in transcript]
[2026-07-28 13:27:50] agent:
Baseline (handle at rest, `HANDLE_IN`=True, `HANDLE_OUT`=False):
| pin | counts | volts |
|---|---:|---:|
| GP26/A0 | 49672 | 2.50 V |
| GP27/A1 | 31879 | 1.60 V |
| GP28/A2 (rail) | 32362 | 1.63 V |
| GP29/A3 (volume) | 27616 | 1.39 V |
GP26 and GP27 both sit at stable non-rail voltages, so neither is really "floating" — both are live candidates. Now I need the handle motion to tell which one tracks it.
The test runs for 30 seconds and records all four channels plus the two handle switches. During it, please: **hands off for ~5 s, then slowly press the handle all the way in, hold ~3 s, slowly release, and repeat that press/release cycle until I say stop** — slow and continuous is what distinguishes a pot from a switch.
[2026-07-28 13:27:52] tool use: AskUserQuestion
questions:
[
{
"question": "Ready to start the 30-second handle capture?",
"header": "Ready?",
"multiSelect": false,
"options": [
{
"label": "Start now",
"description": "I'll launch the 30 s capture immediately — begin the hands-off/press/release cycle as described."
},
{
"label": "Give me a moment",
"description": "Hold off; I'll tell you when to start."
}
]
}
]
[2026-07-28 13:28:18] tool result: AskUserQuestion
Your questions have been answered: "Ready to start the 30-second handle capture?"="Start now". You can now continue with these answers in mind.
[2026-07-28 13:28:21] tool use: Bash
command: ~/agent_venv/bin/python scripts/handle_pot_monitor.py 30 2>&1 | tail -30
description: Run handle pot capture
timeout: 70000
[2026-07-28 13:28:53] tool result: Bash
Output too large (76.8KB). Full output saved to: /home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/1b654052-359b-4c76-a397-09f16e1a835d/tool-results/bvi7g85s9.txt
Preview (first 2KB):
--- sampling for 30 s ---
>>> HANDS OFF THE HANDLE (BASELINE)
t= 0.0s GP26=49660 GP27=31607 GP28=32391 GP29=27638 IN=1 OUT=0
t= 0.0s GP26=49019 GP27=31751 GP28=32391 GP29=27510 IN=1 OUT=0
t= 0.1s GP26=49196 GP27=31815 GP28=32311 GP29=27622 IN=1 OUT=0
t= 0.1s GP26=49147 GP27=31815 GP28=32359 GP29=27638 IN=1 OUT=0
t= 0.1s GP26=49212 GP27=31847 GP28=32263 GP29=27638 IN=1 OUT=0
t= 0.1s GP26=49164 GP27=31687 GP28=32391 GP29=27638 IN=1 OUT=0
t= 0.2s GP26=49147 GP27=31799 GP28=32359 GP29=27638 IN=1 OUT=0
t= 0.2s GP26=49340 GP27=31703 GP28=32375 GP29=27638 IN=1 OUT=0
t= 0.2s GP26=49212 GP27=31735 GP28=32407 GP29=27638 IN=1 OUT=0
t= 0.2s GP26=49324 GP27=31703 GP28=32407 GP29=27622 IN=1 OUT=0
t= 0.3s GP26=49356 GP27=31671 GP28=32391 GP29=27638 IN=1 OUT=0
t= 0.3s GP26=49308 GP27=31639 GP28=32407 GP29=27638 IN=1 OUT=0
t= 0.3s GP26=49228 GP27=31655 GP28=32407 GP29=27638 IN=1 OUT=0
t= 0.3s GP26=49147 GP27=31735 GP28=32391 GP29=27622 IN=1 OUT=0
t= 0.4s GP26=49099 GP27=31815 GP28=32311 GP29=27638 IN=1 OUT=0
t= 0.4s GP26=49115 GP27=31783 GP28=32359 GP29=27638 IN=1 OUT=0
t= 0.4s GP26=49212 GP27=31639 GP28=32391 GP29=27622 IN=1 OUT=0
t= 0.4s GP26=49228 GP27=31879 GP28=32375 GP29=27622 IN=1 OUT=0
t= 0.5s GP26=49164 GP27=31799 GP28=32263 GP29=27638 IN=1 OUT=0
t= 0.5s GP26=49260 GP27=31639 GP28=32391 GP29=27622 IN=1 OUT=0
t= 0.5s GP26=49212 GP27=31591 GP28=32375 GP29=27638 IN=1 OUT=0
t= 0.5s GP26=49131 GP27=31799 GP28=32391 GP29=27622 IN=1 OUT=0
t= 0.6s GP26=49212 GP27=31927 GP28=32263 GP29=27638 IN=1 OUT=0
t= 0.6s GP26=49196 GP27=31639 GP28=32407 GP29=27622 IN=1 OUT=0
t= 0.6s GP26=49083 GP27=31735 GP28=32391 GP29=27574 IN=1 OUT=0
t= 0.6s GP26=49131 GP27=31767 GP28=32375 GP29=27574 IN=1 OUT=0
t= 0.7s GP26=49180 GP27=31751 GP28=32375 GP29=27638 IN=1 OUT=0
t= 0.7s GP26=49164 GP27=31671 GP28=32407 GP29=27622 IN=1 OUT=0
t= 0.7s GP26=49164 GP27=31687 GP28=32407 GP29=27622 IN=1 OUT=0
t= 0.7s GP26=49196
...
[2026-07-28 13:28:57] tool use: Bash
command: tail -c 1200 /home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/1b654052-359b-4c76-a397-09f16e1a835d/tool-results/bvi7g85s9.txt
description: Show capture summary
[2026-07-28 13:28:57] tool result: Bash
.7s GP26=49228 GP27=31895 GP28=29815 GP29=27638 IN=0 OUT=1
t= 29.8s GP26=49244 GP27=31655 GP28=29863 GP29=27574 IN=0 OUT=1
t= 29.8s GP26=49164 GP27=31735 GP28=29863 GP29=27622 IN=0 OUT=1
t= 29.8s GP26=49276 GP27=31623 GP28=29863 GP29=27638 IN=0 OUT=1
t= 29.8s GP26=49340 GP27=31655 GP28=29879 GP29=27638 IN=0 OUT=1
t= 29.9s GP26=49260 GP27=31799 GP28=29863 GP29=27622 IN=0 OUT=1
t= 29.9s GP26=49228 GP27=31575 GP28=29863 GP29=27622 IN=0 OUT=1
t= 29.9s GP26=49180 GP27=31671 GP28=29783 GP29=27622 IN=0 OUT=1
t= 29.9s GP26=49260 GP27=31687 GP28=29879 GP29=27622 IN=0 OUT=1
t= 30.0s GP26=48987 GP27=31671 GP28=29863 GP29=27638 IN=0 OUT=1
t= 30.0s GP26=49180 GP27=31863 GP28=29863 GP29=27622 IN=0 OUT=1
--- per-channel summary (1180 samples) ---
GP26/A0 min 48907 max 49660 span 753 (0.038 V) handle-state separation 1
GP27/A1 min 31255 max 32135 span 880 (0.044 V) handle-state separation 6
GP28/A2 (rail) min 29703 max 32423 span 2720 (0.137 V) handle-state separation 2125
GP29/A3 (volume) min 27494 max 27686 span 192 (0.010 V) handle-state separation 3
Most movement: GP28/A2 (rail) (span 2720 counts, 0.14 V).
[2026-07-28 13:29:05] thinking: [not recorded in transcript]
[2026-07-28 13:29:10] tool use: Bash
command:
~/agent_venv/bin/python - <<'EOF'
import re
p="/home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/1b654052-359b-4c76-a397-09f16e1a835d/tool-results/bvi7g85s9.txt"
txt=open(p).read()
recs=[(float(a),int(b),int(c),int(d),int(e),int(f),int(g)) for a,b,c,d,e,f,g in
re.findall(r"t=\s*([\d.]+)s\s+GP26=\s*(\d+) GP27=\s*(\d+) GP28=\s*(\d+) GP29=\s*(\d+)\s+IN=(\d) OUT=(\d)", txt)]
print(len(recs))
# downsample: average every 0.5s
import statistics as st
buckets={}
for r in recs:
k=round(r[0]*2)/2
buckets.setdefault(k,[]).append(r)
for k in sorted(buckets):
b=buckets[k]
m=lambda i: int(st.mean(x[i] for x in b))
print("%5.1f 26=%5d 27=%5d 28=%5d 29=%5d IN=%s OUT=%s" % (k,m(1),m(2),m(3),m(4),
"".join(str(x[5]) for x in b[:4]), "".join(str(x[6]) for x in b[:4])))
EOF
description: Downsample and analyze trace
[2026-07-28 13:29:12] tool result: Bash
1180
0.0 26=49242 27=31746 28=32365 29=27622 IN=1111 OUT=0000
0.5 26=49189 27=31732 28=32373 29=27624 IN=1111 OUT=0000
1.0 26=49178 27=31735 28=32394 29=27611 IN=1111 OUT=0000
1.5 26=49188 27=31787 28=32362 29=27632 IN=1111 OUT=0000
2.0 26=49189 27=31726 28=32385 29=27619 IN=1111 OUT=0000
2.5 26=49171 27=31711 28=32387 29=27623 IN=1111 OUT=0000
3.0 26=49174 27=31761 28=32376 29=27629 IN=1111 OUT=0000
3.5 26=49154 27=31738 28=32370 29=27610 IN=1111 OUT=0000
4.0 26=49214 27=31731 28=32379 29=27622 IN=1111 OUT=0000
4.5 26=49175 27=31771 28=32379 29=27611 IN=1111 OUT=0000
5.0 26=49215 27=31737 28=32357 29=27620 IN=1111 OUT=0000
5.5 26=49171 27=31716 28=31591 29=27612 IN=1111 OUT=1111
6.0 26=49179 27=31734 28=30781 29=27606 IN=1111 OUT=1111
6.5 26=49186 27=31745 28=29887 29=27620 IN=1111 OUT=1111
7.0 26=49176 27=31737 28=29853 29=27607 IN=1111 OUT=1111
7.5 26=49162 27=31763 28=29846 29=27612 IN=0000 OUT=1111
8.0 26=49169 27=31771 28=29858 29=27612 IN=0000 OUT=1111
8.5 26=49165 27=31778 28=29850 29=27622 IN=0000 OUT=1111
9.0 26=49196 27=31783 28=29849 29=27622 IN=0000 OUT=1111
9.5 26=49185 27=31743 28=29836 29=27607 IN=0000 OUT=1111
10.0 26=49182 27=31762 28=29846 29=27607 IN=0000 OUT=1111
10.5 26=49151 27=31735 28=29870 29=27618 IN=0000 OUT=1111
11.0 26=49196 27=31763 28=30005 29=27610 IN=1111 OUT=1111
11.5 26=49171 27=31767 28=30418 29=27622 IN=1111 OUT=1111
12.0 26=49194 27=31764 28=31064 29=27616 IN=1111 OUT=1111
12.5 26=49213 27=31710 28=31944 29=27616 IN=1111 OUT=1111
13.0 26=49162 27=31742 28=32341 29=27623 IN=1111 OUT=0000
13.5 26=49145 27=31715 28=32347 29=27602 IN=1111 OUT=0000
14.0 26=49153 27=31761 28=32383 29=27598 IN=1111 OUT=0000
14.5 26=49163 27=31747 28=32350 29=27619 IN=1111 OUT=0000
15.0 26=49175 27=31726 28=31410 29=27610 IN=1111 OUT=1111
15.5 26=49183 27=31766 28=30440 29=27604 IN=1111 OUT=1111
16.0 26=49178 27=31778 28=29911 29=27623 IN=1111 OUT=1111
16.5 26=49171 27=31757 28=29852 29=27621 IN=1111 OUT=1111
17.0 26=49221 27=31737 28=29856 29=27633 IN=0000 OUT=1111
17.5 26=49183 27=31735 28=29863 29=27617 IN=0000 OUT=1111
18.0 26=49171 27=31747 28=29842 29=27612 IN=0000 OUT=1111
18.5 26=49204 27=31721 28=29865 29=27600 IN=0000 OUT=1111
19.0 26=49165 27=31778 28=29855 29=27607 IN=0000 OUT=1111
19.5 26=49181 27=31729 28=29871 29=27620 IN=0000 OUT=1111
20.0 26=49166 27=31749 28=29847 29=27606 IN=0000 OUT=1111
20.5 26=49178 27=31733 28=29849 29=27626 IN=0000 OUT=1111
21.0 26=49203 27=31765 28=29839 29=27623 IN=0000 OUT=1111
21.5 26=49237 27=31745 28=29857 29=27615 IN=0000 OUT=1111
22.0 26=49206 27=31733 28=29952 29=27605 IN=1111 OUT=1111
22.5 26=49152 27=31732 28=30687 29=27617 IN=1111 OUT=1111
23.0 26=49202 27=31748 28=31361 29=27601 IN=1111 OUT=1111
23.5 26=49184 27=31765 28=32094 29=27618 IN=1111 OUT=1111
24.0 26=49177 27=31753 28=32351 29=27622 IN=1111 OUT=0000
24.5 26=49172 27=31726 28=32359 29=27610 IN=1111 OUT=0000
25.0 26=49221 27=31728 28=32375 29=27617 IN=1111 OUT=0000
25.5 26=49179 27=31747 28=32365 29=27617 IN=1111 OUT=0000
26.0 26=49159 27=31749 28=32357 29=27600 IN=1111 OUT=0000
26.5 26=49170 27=31779 28=32353 29=27628 IN=1111 OUT=0000
27.0 26=49182 27=31735 28=32254 29=27618 IN=1111 OUT=0000
27.5 26=49148 27=31743 28=30968 29=27610 IN=1111 OUT=1111
28.0 26=49156 27=31751 28=29967 29=27615 IN=1111 OUT=1111
28.5 26=49194 27=31761 28=29867 29=27625 IN=1111 OUT=1111
29.0 26=49178 27=31736 28=29847 29=27622 IN=0000 OUT=1111
29.5 26=49184 27=31738 28=29831 29=27626 IN=0000 OUT=1111
30.0 26=49211 27=31693 28=29858 29=27622 IN=0000 OUT=1111
[2026-07-28 13:29:30] thinking: [not recorded in transcript]
[2026-07-28 13:29:34] agent:
GP28 is the answer — and it's genuinely analog. It ramps smoothly through the travel (32370 at rest → ~29850 fully in), holds flat while held, and ramps back on release, with the ramp lasting exactly as long as you took to move the handle:
```
5.0 28=32365 hands off
5.5 28=31591 \
6.0 28=30781 > pressing
6.5 28=29887 /
7.5 28=29846 fully in (HANDLE_IN=0)
11.5 28=30418 \
12.0 28=31064 > releasing
12.5 28=31944 /
13.0 28=32341 at rest
```
GP26, GP27 and GP29 never moved beyond noise. But GP28 is currently mapped as `VOLTAGE_MONITOR`, so I need to rule out that this is just rail droop from the handle engaging the power path. Two checks — an LED load test (rail droop should show up there too) and holding at partial positions:
[2026-07-28 13:29:38] tool use: Bash
command:
~/agent_venv/bin/python - <<'EOF'
import sys; sys.path.insert(0,"scripts")
import repl
o,e = repl.run("""
import board, analogio, digitalio, time
a = analogio.AnalogIn(board.GP28)
def rd(n=32):
v=[a.value for _ in range(n)]
return sum(v)//n
leds=[]
for p in (board.LED_WHITE1,board.LED_WHITE2,board.LED_WHITE3,board.LED_WHITE4,
board.LED_RED1,board.LED_RED2,board.LED_RED3,board.LED_RED4):
d=digitalio.DigitalInOut(p); d.switch_to_output(False); leds.append(d)
print("leds off", rd())
for d in leds: d.value=True
time.sleep(0.3)
print("leds ON ", rd())
for d in leds: d.value=False
time.sleep(0.3)
print("leds off", rd())
for d in leds: d.deinit()
a.deinit()
""", timeout=20)
print(o); print("ERR:",e)
EOF
description: LED load test on GP28
[2026-07-28 13:29:41] tool result: Bash
leds off 32361
leds ON 32361
leds off 32367
ERR:
[2026-07-28 13:29:51] thinking: [not recorded in transcript]
[2026-07-28 13:29:53] agent:
All eight LEDs on = **0 counts** of change on GP28. It's insensitive to real current load, so the handle response is not rail droop — GP28 is a position sensor.
One more confirmation that it holds intermediate positions (rather than just responding to motion). For the next 24 seconds, please: **hold the handle about a quarter in for ~5 s, then half in for ~5 s, then three-quarters for ~5 s, then fully in, then release.**
[2026-07-28 13:30:00] tool use: Bash
command:
~/agent_venv/bin/python - <<'EOF' 2>&1 | tail -40
import sys, time; sys.path.insert(0,"scripts")
import serial
DEV = """
import board, analogio, digitalio, time
a = analogio.AnalogIn(board.GP28)
hi = digitalio.DigitalInOut(board.HANDLE_IN); hi.switch_to_input(digitalio.Pull.UP)
ho = digitalio.DigitalInOut(board.HANDLE_OUT); ho.switch_to_input(digitalio.Pull.UP)
t0=time.monotonic()
while time.monotonic()-t0 < 24:
v=[a.value for _ in range(32)]
print("S %.1f %d %d %d %d %d" % (time.monotonic()-t0, sum(v)//32, min(v), max(v), hi.value, ho.value))
time.sleep(0.2)
a.deinit(); hi.deinit(); ho.deinit()
print("DONE")
"""
with serial.Serial("/dev/ttyACM0",115200,timeout=0.1) as ser:
ser.write(b"\r\x03\x03"); time.sleep(0.2); ser.reset_input_buffer()
ser.write(b"\x01"); time.sleep(0.3); ser.reset_input_buffer()
ser.write(DEV.encode()); ser.write(b"\x04")
buf=b""; end=time.time()+32
while time.time()This memory is 5 days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.
1 - [Audio test rig](audio-test-rig.md) — headphone out wired to USB adapter mic-in (`arecord`, card number shifts); speakers next to the EP-2350 mic give an acoustic stimulus via `aplay`.
2 - [EP-2350 REPL workflow](ep2350-repl-workflow.md) — driving the board over the raw REPL, and why board state vanishes between serial connections.
3 - [EP-2350 dangerous pins](ep2350-dangerous-pins.md) — GP2 is POWER_HOLD and kills the board; which pins are actually safe to poke.
4 - [EP-2350 firmware build](ep2350-firmware-build.md) — the exact make invocation, the venv it needs, and who flashes.
5 - [CIRCUITPY has its own module copies](circuitpy-has-its-own-module-copies.md) — the board imports `ep2350_codec.py` from CIRCUITPY; host edits do nothing until copied over.
6 - [Output level ceiling — RESOLVED](ep2350-output-level-ceiling.md) — the "quiet, clips at −12 dBFS" output was the R80 PDB_DAC driver bug (DAC output stage powered down); fixed 2026-07-22, pre-fix jack measurements are invalid.
7 - [Ask before installing system packages](ask-before-installing-system-packages.md) — no sudo here; request apt installs instead of unpacking debs.
8 - [Mic "wind" subsonic fix](mic-wind-subsonic-fix.md) — passthrough wind is mic DC/bias-drift; fixed with codec ADC biquad HPF (`configure_adc_highpass`), not a host filter.
9 - [Static is the USB capture dongle](static-is-usb-capture-dongle.md) — the recording hiss is mostly the card-3 dongle's own noise floor at max capture gain, not the EP-2350.
10 - [Demo is a 4-effect switcher](ep2350-demo-multi-effect.md) — code_ep2350_demo.py: pitch up/down + reverb + echo, TOP selects, MIDDLE toggles, red LEDs show state.
11
[2026-07-28 13:31:49] tool use: Edit
replace_all: false
file_path: /home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/memory/MEMORY.md
old_string: - [Demo is a 4-effect switcher](ep2350-demo-multi-effect.md)
new_string:
- [Handle position pot is GP28](ep2350-handle-position-pot.md) — GP28/ADC2 is the handle travel pot, not rail sense; narrow ~2520-count swing.
- [Demo is a 4-effect switcher](ep2350-demo-multi-effect.md)
[2026-07-28 13:31:49] tool result: Edit
The file /home/agent/.claude/projects/-home-agent-TE-ting-circuitpython/memory/MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)
[2026-07-28 13:32:02] agent:
**The handle pot is on GP28 — ADC2, `board.A2`.** It was the pin already mapped as `VOLTAGE_MONITOR`, which is why it never showed up as a candidate.
How it was pinned down:
- The RP2350A only has four ADC pins (GP26–GP29), so the search space was small. GP29 is the volume knob; GP26/GP27 sit at stable 2.50 V / 1.60 V but did not budge during any handle motion.
- GP28 tracked the handle continuously — smooth ramps whose duration matched how slowly you moved it, and flat plateaus at quarter/half/three-quarter positions held for seconds.
- Ruled out rail droop from the handle engaging the power path: switching all eight LEDs on (real current) moves GP28 by **0 counts**.
Calibration: **32370 counts (1.63 V) at rest → 29850 (1.50 V) fully seated**. Two caveats worth knowing:
- The usable swing is only ~2520 counts, about 4% of full scale, with ±80 counts of noise — scale it and average 16–32 samples.
- The last quarter of travel is only ~70 counts, so the pot can't distinguish "nearly in" from "seated". Keep using GP19/`HANDLE_IN` for that.
Changes made:
- `circuitpython/ports/raspberrypi/boards/teenage_engineering_ep2350/pins.c:104` — `VOLTAGE_MONITOR` replaced with `HANDLE_POSITION` on GP28, with the calibration in a comment. This needs a firmware rebuild and flash before `board.HANDLE_POSITION` exists; `board.GP28` / `board.A2` works right now.
- `ep-2350-info.md` — new §5 "Handle position potentiometer" with the calibration table, GPIO map row corrected, and the rail-sensing claim retracted (no pin is now known to sense VBAT/VSYS/VBUS).
- `scripts/handle_pot_monitor.py` — the discovery script, reusable if you want to re-measure the endpoints.
One loose end I did not chase: the old note that GP28's reading shifted −7.7% when batteries were fitted. That suggests its divider may be referenced to VSYS rather than 3V3, which would make the position reading drift slightly with supply. Worth a check if you build anything precise on it.