Reverse Engineering Techniques
This page details the process used to reverse engineer the device to figure out which pins the peripherals were connected to. This was an LLM-assisted process that used Claude Code running in a Pi sandbox connected to the EP-2350 device. The sections on this page will include links to download session history logs, and primary markdown outputs, along with a human written summary.
Breaking up the work, Plans & Findings docs
When I first started, there were many unknowns about the device. I first attempted to follow traces on the board to the myriad available pogo pin test points, but it was not going very well. When I turned to the LLM for assistance, I wanted to do so with an intentionally controlled approach rather than just letting it loose and seeing what came out after a while.
I broke the unknowns down and focused on one thing at a time. I tried to keep the LLM sessions on the short side. If a session started approaching 200k tokens and had made progress but not gotten a final result yet, I would interject and have the agent write the findings that were made during the session into a markdown file and include action items to pick up on in a future session.
For some tasks I first asked the agent to write a plan markdown file for how to go about it. I manually reviewed and edited the plan and findings documents before handing them off to a new session to actually carry out the work.
Environment
Claude Code with Opus model was used for the agent harness. In addition to the connected EP-2350 device, the agent was given access to the following:
- ep-2350_firmware_1_0_8.uf2 file containing the latest standard device firmware
- Readme files found on the TING DISK drive, internal /rom storage, and firmware download zip
- A link to the official ep-2350 guide page.
- main.py code file that runs under the standard firmware
- RP2350 datasheet PDF
- NAU88L21 datasheet PDF
- NAU88L21 Linux driver repository
- Stock MicroPython repository
These components were connected for relevant portions work, but weren't available the whole time:
- USB camera used during LED mapping.
- USB audio adapter and speakers. This gave the agent a full feedback loop to play audio from the speakers -> record it with the EP-2350 mic -> pass-through Python code -> play out of the EP-2350 3.5mm -> capture with USB adapter.
Audio CODEC
I knew that the audio CODEC was the NAU88L21 from markings found on the chip during the teardown. I decided to start with it because I figured that it would be more difficult to map the pins for it than the buttons, LEDs, and potentiometer. Also because audio is the main point of the device.
Planning Session (log) (plan file)
I started with a plan file for finding the I2C and I2S pins first. The plan revealed a key detail about the RP2350 that I was unaware of: The pin muxing registers in the device can be read back from code. That makes it possible to figure out how each pin is configured, which is a big start towards figuring out what it is connected to. In addition to reading the IO registers the plan mentions static analysis of the UF2 firmware as a backup plan.
I2C Pins (log) (findings file)
In the first working session, I asked the agent to look for I2C pins. The first thing it did is make some tools to run code via the REPL and get the results back. It read the IO registers and formulated some theories on pins that turned out to be incorrect, confirmed wrong by bit-banging I2C scans on them. Interestingly, it actually found most of the I2S pins during this session.
- **GPIO8** = input (DIN, codec→MCU), **GPIO9** = output (DOUT), **GPIO10/11** = driven clocks (BCLK/LRCLK) — matches the enabled SM's pinctrl (in8, out9, side-set 10–11).
It did some poking at the TE firmware binary, and some more register reading. Eventually finding that that pins GP14 and GP15 showed the I2C signature of internally set pull downs, yet reading high due to external pull ups. While trying to confirm these pins, it crashed the device a few times. After the 2nd crash that I recovered it from manually, I interjected and asked it to run any last minute tests and write the findings file.
Evaluate Stock MicroPython Requirements & Risk (log) (instructions file)
At first, I was worried about flashing stock MicroPython on the device and having it get locked in some way that prevented me from getting back to the bootloader to recover. I also don't have a lot of experience using MicroPython outside of CircuitPython, so I wasn't 100% sure of the right firmware to flash. I used one session to look further into this and write a rundown of how to get stock MicroPython flashed for further testing. After this session, I looked into the crystal frequency and tried to identify the flash chip for the worst-case recovery option in the instructions.
MicroPython Tests Confirm I2C and I2S Clocks (log) (findings file)
I flashed stock MicroPython onto it and it worked without trouble. I asked the agent to run tests on the live device to validate the pins theorized so far. It ran an I2C scan and found the NAU88L21 and accelerometer on the bus. It confirmed the NAU88L21 further by checking the expected silicon-revision register. In an earlier session, it had made high confidence guesses at the I2S pins DIN, DOUT, BCLK, and WS. It had a guess for the MCLK pin on GP16 with only medium confidence, so it tried to confirm MCLK next. After running some tests and reading the NAU88L21 datasheet, GP16 was ruled out as a possibility. A sweeping test of many pins was run and pointed towards GP12 instead. The test also uncovered that BLCK and WS were swapped around. I cut it off before it could go on to confirm DIN and DOUT.
Confirming I2S Data Pins (log) (findings file)
Next up was validating GP8 and GP9 as the DIN and DOUT pins. To do this, the agent found the I2S tri-state bit in a control register and measured GP8 while enabling and disabling the bit. To confirm GP9 as DOUT, it observed that the CODEC never drove GP9 through various states. That is expected, because the MCU would always be the one sending data on this pin into the CODEC. It was further confirmed later by sending I2S audio on the pin.
Power Management (log) (findings file)
Up until this point, to keep it powered on I had wrapped a rubber band around it, squeezing the handle in all the way. Any time the handle was released the device immediately powered off. The next task was figuring out what the stock firmware does to keep the device running. The official instructions mention the device falling asleep after 5 minutes and powering off after 20, so I knew there must be a way control the power.
The agent narrowed it down to a set of likely pins and then drove them low one at a time checking for the device to disappear from the serial connection after each. During the process it asked me to remove and re-apply the band a few times. GP2 was positively identified as the power control pin. The agent designed and ran a few faulty experiments that powered off the device without recovering usable info so I intervened to have it output what was found so far.
Buttons & Volume Potentiometer (log) (findings files)
The buttons and volume knob were easy to map with MicroPython. The agent wrote a script that prompted me to press each of the various buttons in a specified order and then turn the volume knob all the way one direction and then the other. It watched for the pins that changed as I pressed buttons and twisted the knob, then recorded which button mapped to which pin, and how the potentiometer was connected.
Handle Potentiometer (log)
There is a potentiometer hidden inside the pivot point of the handle. I didn't realize this at first, but once I discovered it I used an agent session with the device running CircuitPython to identify the pin. The agent eliminated several options based on other known pins and identified a small set to test. It ran a test on the device watching the candidate pins while I pressed and released the handle. It determined that pin GP28 had been previously incorrectly identified and was actually the pin that this handle potentiometer is connected to.
LEDs (log) (findings file)
The LEDs were also pretty easy to map using MicroPython and adding a standard USB camera to the agent sandbox Pi. Using the camera, the agent could iterate over the pins and capture photos of the state of the LEDs as needed to work out which pins are connected to which LEDs. After nailing down the mapping, it also worked on PWM. It discovered that the top white and red LEDs share a PWM slice, and thus are not independently PWM controllable. All other LEDs can be PWM controlled individually.
This photo contains a sampling from the 90+ images captured while mapping the LEDs. The agent used PIL to crop the full shot down to just the rectangle containing the LEDs, and to assemble grids like these in order to evaluate many photos at once.
While working, the agent noticed that the bright LEDs were bleeding light out of the neighboring holes making it appear as if 3 of them were lit. To work around this, it used a low PWM frequency, and applied a darkened filter when capturing the photos with ffmpeg.
Interactive Pin Validation Test
After creating the CircuitPython board def I used an interactive test to validate that all pins were correct. The video below shows the test sequence, and the code follows it.
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
Hardware validation test for the TE ting fx EP-2350 CircuitPython board
definition.
Connect to the serial console and follow the prompts. Exercises every LED,
every button, both handle switches, the handle position potentiometer, the
volume knob, and the I2C bus.
Nothing here touches board.POWER_HOLD -- driving it low powers the unit off.
"""
import time
import analogio
import board
import digitalio
import pwmio
# Seconds to wait for the user at each prompt before marking a step failed.
TIMEOUT = 15
WHITE = ("LED_WHITE1", "LED_WHITE2", "LED_WHITE3", "LED_WHITE4")
RED = ("LED_RED1", "LED_RED2", "LED_RED3", "LED_RED4")
# name, board pin attribute, value that means "actuated"
BUTTONS = (
("top side button (nearest the handle)", "BUTTON_TOP", False),
("middle side button", "BUTTON_MIDDLE", False),
("bottom side button", "BUTTON_BOTTOM", False),
)
# The handle pot only swings across a narrow slice of the ADC range: roughly
# 32370 counts at rest down to 29850 counts fully seated. Raw noise is about
# +/-80 counts, so every reading is averaged.
HANDLE_POT_SAMPLES = 32
HANDLE_POT_MIN_SWING = 1200
results = []
def record(name, passed, detail=""):
results.append((name, passed, detail))
print(" {}: {}{}".format("PASS" if passed else "FAIL", name,
" ({})".format(detail) if detail else ""))
def make_leds():
leds = {}
for name in WHITE + RED:
led = digitalio.DigitalInOut(getattr(board, name))
led.switch_to_output(value=False)
leds[name] = led
return leds
def make_input(attr):
pin = digitalio.DigitalInOut(getattr(board, attr))
pin.switch_to_input(pull=digitalio.Pull.UP)
return pin
def wait_for(pin, wanted, timeout=TIMEOUT):
"""Wait for pin.value == wanted. Returns True, or False on timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pin.value == wanted:
return True
time.sleep(0.01)
return False
def test_leds(leds):
print("\n== LEDs ==")
print("Watch the front panel. Each LED lights on its own, top to bottom.")
time.sleep(2)
for column, names in (("white", WHITE), ("red", RED)):
print(" {} column...".format(column))
for index, name in enumerate(names):
leds[name].value = True
time.sleep(0.35)
leds[name].value = False
print(" all on...")
for led in leds.values():
led.value = True
time.sleep(1.0)
for led in leds.values():
led.value = False
print(" brightness sweep on the bottom white LED...")
# GP0 and GP16 share a PWM slice, so dim a pin that is not one of those.
leds["LED_WHITE4"].deinit()
dim = pwmio.PWMOut(board.LED_WHITE4, frequency=1000, duty_cycle=0)
for step in range(0, 65536, 1024):
dim.duty_cycle = step
time.sleep(0.005)
dim.deinit()
leds["LED_WHITE4"] = digitalio.DigitalInOut(board.LED_WHITE4)
leds["LED_WHITE4"].switch_to_output(value=False)
answer = input(" Did all 8 LEDs light, and did the last one fade? [Y/n] ")
record("LEDs", answer.strip().lower() in ("", "y", "yes"))
def test_buttons(leds):
print("\n== Buttons ==")
print("Each prompt lights a white LED while it waits; press and release.")
for index, (label, attr, pressed_value) in enumerate(BUTTONS):
pin = make_input(attr)
marker = leds[WHITE[index]]
marker.value = True
print(" Press the {}...".format(label))
if not wait_for(pin, pressed_value):
record(label, False, "no press within {}s".format(TIMEOUT))
elif not wait_for(pin, not pressed_value, timeout=5):
record(label, False, "stuck pressed")
else:
record(label, True)
marker.value = False
pin.deinit()
def read_pot(pot, samples=HANDLE_POT_SAMPLES):
"""Averaged read of a noisy pot."""
total = 0
for _ in range(samples):
total += pot.value
return total // samples
def test_handle(leds):
print("\n== Handle ==")
print("The handle is a two-stage switch: HANDLE_OUT moves first,")
print("HANDLE_IN confirms the handle is fully seated. HANDLE_POSITION")
print("is a pot that reads how far through the travel it is.")
held = make_input("HANDLE_OUT")
seated = make_input("HANDLE_IN")
pot = analogio.AnalogIn(board.HANDLE_POSITION)
try:
if held.value or not seated.value:
print(" Release the handle first...")
if not wait_for(held, False):
record("handle", False, "handle never read as released")
return
at_rest = read_pot(pot)
print(" handle position at rest = {}".format(at_rest))
for led in leds.values():
led.value = True
print(" Now squeeze the handle all the way in...")
stage1 = wait_for(held, True)
stage2 = wait_for(seated, False, timeout=5) if stage1 else False
# Sample while the handle is still held in, before anyone lets go.
pressed = read_pot(pot)
for led in leds.values():
led.value = False
record("handle stage 1 (HANDLE_OUT)", stage1,
"" if stage1 else "no movement within {}s".format(TIMEOUT))
record("handle stage 2 (HANDLE_IN)", stage2,
"" if stage2 else "never reached the end stop")
# The pot falls as the handle goes in, so rest should read higher.
swing = at_rest - pressed
print(" handle position seated = {} (swing {})".format(pressed, swing))
record("handle position pot moves", abs(swing) >= HANDLE_POT_MIN_SWING,
"rest {} -> seated {}".format(at_rest, pressed))
record("handle position pot direction", swing > 0,
"" if swing > 0 else "expected the value to fall as it seats")
print(" Release the handle slowly; watch the red LEDs track it...")
track_handle(pot, leds, at_rest, pressed)
wait_for(held, False)
released = read_pot(pot)
print(" handle position after release = {}".format(released))
record("handle position pot returns",
abs(released - at_rest) < HANDLE_POT_MIN_SWING // 2,
"rest {} -> released {}".format(at_rest, released))
finally:
held.deinit()
seated.deinit()
pot.deinit()
for led in leds.values():
led.value = False
def track_handle(pot, leds, at_rest, pressed, timeout=TIMEOUT):
"""Show handle travel on the red LEDs until it sits back near rest."""
deadline = time.monotonic() + timeout
span = at_rest - pressed
if span <= 0:
return
while time.monotonic() < deadline:
value = read_pot(pot, samples=8)
travel = (at_rest - value) / span
if travel < 0.05:
break
lit = int(travel * 4)
for index, name in enumerate(RED):
leds[name].value = index < lit
time.sleep(0.02)
for name in RED:
leds[name].value = False
def test_volume(leds):
print("\n== Volume knob ==")
knob = analogio.AnalogIn(board.VOLUME)
try:
print(" Turn the knob all the way DOWN...")
low = track_knob(knob, leds, target="low")
print(" Turn the knob all the way UP...")
high = track_knob(knob, leds, target="high")
print(" low = {}, high = {}".format(low, high))
# Documented range is 68 .. 65340 counts; allow generous margin.
record("volume knob low end", low < 2000, "read {}".format(low))
record("volume knob high end", high > 63000, "read {}".format(high))
finally:
knob.deinit()
for led in leds.values():
led.value = False
def track_knob(knob, leds, target):
"""Show the knob position on the red LEDs until it holds at an end."""
deadline = time.monotonic() + TIMEOUT
best = knob.value if target == "high" else 65535
while time.monotonic() < deadline:
value = knob.value
if target == "high":
best = max(best, value)
if value > 63000:
break
else:
best = min(best, value)
if value < 2000:
break
# Red column as a 4-segment bar graph.
lit = value * 4 // 65536
for index, name in enumerate(RED):
leds[name].value = index < lit
time.sleep(0.02)
for name in RED:
leds[name].value = False
return best
def test_i2c():
print("\n== I2C ==")
i2c = board.I2C()
try:
while not i2c.try_lock():
pass
found = i2c.scan()
finally:
i2c.unlock()
i2c.deinit()
print(" found: {}".format([hex(address) for address in found]))
record("accelerometer at 0x18", 0x18 in found)
record("NAU88L21 codec at 0x1b", 0x1B in found)
def main():
print("\nEP-2350 hardware validation")
print("===========================")
leds = make_leds()
try:
test_i2c()
test_leds(leds)
test_buttons(leds)
test_handle(leds)
test_volume(leds)
finally:
for led in leds.values():
led.deinit()
failures = [name for name, passed, _ in results if not passed]
print("\n===========================")
print("{} of {} checks passed".format(len(results) - len(failures),
len(results)))
for name in failures:
print(" FAILED: {}".format(name))
if not failures:
print("All good.")
main()
Page last edited August 06, 2026
Text editor powered by tinymce.