In the last two posts I reverse engineered a ₹249 smartwatch, flashed my own firmware onto its TLSR8232, and then gutted it into an AirTag. Gutting it meant desoldering the display, which went into a parts box.
A bike navigation idea led me to reverse engineer a ₹249 smartwatch, crack its SWire debug port, and flash my own firmware on it.
I lost my wallet on a night bus, so I turned a hacked ₹249 smartwatch into an Apple FindMy tracker and built my own app to follow it.
I have wanted a desktop buddy for years. Small screen, sits on the monitor, shows whatever I tell it to. I had the screen. I had a soldering iron. What I did not have was any idea how much of this project would turn out to be about USB rather than about pixels.
The finished thing plays video at 15.7 fps and the number is not a disappointment. Getting there took two dead ends, an etched PCB, and a colour bug that survived every test I threw at it.
The screen I was working with
The panel is an ST7735S, 128 by 128, RGB565. Two bytes per pixel:
128 × 128 × 2 = 32,768 bytes per frame
That number decides the whole project. At 30 fps you need 983,040 bytes per second, which is about 7.9 Mbps of payload before any protocol overhead. Keep it in mind, because every transport I considered gets measured against it.
It comes off the watch motherboard on a 13 pin flex connector, and this is the one part of the project I never had to work out for myself. The panel's FPC pinout is already documented in rbaron's m6-reveng, thirteen pins from TP0 through GND, and I had used it in the previous post to trace which SoC pin each one landed on.
The wrong question, asked well
My first plan was to keep the TLSR8232 and make it a USB to SPI bridge. The watch already had it, it was already wired to the display, and I had already written firmware for it. Reusing it felt obvious.
The TLSR8232 has no USB peripheral. Not a limited one, none at all. It has SPI, UART, I2C, ADC, PWM, GPIO and a BLE radio, and its own single wire debug interface called SWire. So a USB bridge meant bit-banging USB on two GPIOs, the way V-USB does on AVR.
The timing budget is the first thing to check. USB Low Speed runs at 1.5 Mbps, so one bit lasts 667 ns.
TC32 core at 24 MHz → 41.7 ns per cycle → 16 cycles per USB bit
TC32 core at 48 MHz → 20.8 ns per cycle → 32 cycles per USB bit
I had assumed 24 MHz. The datasheet says up to 48 MHz, which doubled the budget and made the idea look plausible for about a day. In those 32 cycles the firmware has to sample the line, decode NRZI, undo bit stuffing, update a CRC, advance a state machine and drive the next output bit. V-USB does it in cycle counted AVR assembly, which is exactly the part that does not port to a proprietary core.
There is no software USB implementation for the TLSR8232. I looked. There is none for anything in that family, because the family has no reason to want one.
Measuring instead of guessing
I have no oscilloscope and no logic analyzer. All I had was an FT232RL, which does up to 3 Mbaud.
So I used UART as a proxy. It does not prove USB will work, since USB is timing critical signalling and UART is not, but it does answer the question underneath: is this chip fast enough to be in the conversation at all? The watch already had PB4 and PB5 free, and they were the factory UART pins.
I already had the test rig from the previous project: a UART example ported from m6-reveng that sends a counter over the FPC debug pads every half second, and a helper that sets the baud rate up from a 16 MHz system clock.
int main() {
cpu_wakeup_init();
clock_init(SYS_CLK_16M_Crystal);
gpio_init();
uart_helper_init(recv_buff, sizeof(recv_buff));
int tick = 0;
while (1) {
my_sprintf((char *)send_buff.data, "Tick counter: #%d\n", tick++);
send_buff.len = strlen((char *)send_buff.data);
uart_send(&send_buff);
sleep_ms(500);
}
}
A counter is a better test than a fixed byte, because a dropped or corrupted byte shows up as a gap in the sequence rather than as garbage you have to squint at. All I had to change was the divider pair in uart_helper_init, which is where the 115200 comes from.
921600 baud worked. Then 2,000,000 baud worked, stable, no dropped bytes, and that is from a 16 MHz system clock rather than the 48 MHz the datasheet allows. For a watch SoC that is genuinely impressive, and it removed every hardware doubt I had about the chip.
It also killed the project.
UART at 2 Mbps ≈ 200,000 bytes/s → 6.1 fps full screen
USB Low Speed ≈ 150,000 bytes/s → 4.6 fps full screen
That is the whole decision, and it took a calculator rather than a sprint. Frame size against transport speed gives the frame rate, the frame rate against the risk of hand writing a USB stack gives the answer, and the answer was that succeeding would leave me slower than the UART cable already sitting on my desk. So the plan went in the bin the same evening it was measured, which is the cheapest possible way for a plan to die.
The experiment I ran to justify the plan is the one that ended it.
Why Full Speed is the only USB worth wanting
Once the numbers were on the table the shape of the problem changed. It was never "can I do USB", it was "which USB is worth doing".
Transport | Practical throughput | Full frame fps |
|---|---|---|
BLE on this chip | 10 to 20 KB/s | under 1 |
Software USB Low Speed | 120 to 150 KB/s | 4.6 |
UART at 2 Mbps (measured) | 200 KB/s | 6.1 |
CH343 UART at 6 Mbps | 600 KB/s | 18 |
USB Full Speed | about 1.2 MB/s | 36 |
Only the last row clears the 30 fps bar. And Full Speed is exactly where software implementations stop being possible:
USB Full Speed = 12 Mbps → 83.3 ns per bit
ESP32 at 240 MHz = 4.17 ns per cycle → 20 cycles per bit
USB High Speed = 480 Mbps → 2.08 ns per bit
→ under one instruction per bit
Twenty cycles is not enough to decode a bit and also decide what to do about it. This is the part I found genuinely interesting: when people show off software USB on an RP2040, the CPU is not doing the work. The PIO block is. It is a hardware state machine that happens to be programmable, so the timing lives in silicon and the CPU only sees finished packets. That is the same reason a real USB peripheral works, just with the boundary drawn in a different place.
I did go down one more branch. The ESP32-WROOM-32 has no native USB either, and its dev board USB goes through a CP2102 or CH340, so the chip never sees USB at all. There is a working software USB host for it, but no maintained device stack, and on the ESP32 the software environment is a bigger enemy than the hardware: WiFi interrupts and FreeRTOS scheduling will eat your timing window. The TLSR8232 was actually the better candidate for software USB in one narrow sense, since it runs bare metal with nothing else competing for cycles.
Picking the chip
At this point I was buying hardware, so the question became which cheap board has real USB Full Speed silicon.
Board | USB | Price (India) |
|---|---|---|
RP2040-Zero | native FS, 12 Mbps | ₹180 to ₹250 |
ESP32-C3 SuperMini | native FS, 12 Mbps | ₹200 to ₹250 |
STM32F103 Blue Pill | native FS, 12 Mbps | ₹120 to ₹200 |
CH552 | native FS, 12 Mbps | ₹80 to ₹150 |
I already owned a Blue Pill, which by the "use what is on the desk" rule should have won. The ESP32-C3 SuperMini was the other real contender, and I nearly bought it.
ESP32-C3 | RP2040 |
|---|---|
Single RISC-V core | Dual Cortex-M0+ |
No PIO | PIO, the reason software USB works on this chip |
400 KB SRAM | 264 KB SRAM, still 8 framebuffers |
USB Serial/JTAG, fixed function | TinyUSB, any class you like |
WiFi and BLE you do not need here | No radio, no radio overhead |
The deciding axis was the fixed function USB controller. The C3's USB is a Serial/JTAG peripheral, so CDC works fine but you cannot reshape it into a custom class later. The RP2040 with TinyUSB leaves that door open, and given that I did eventually need to think about vendor bulk classes, that turned out to matter.
Naming took a whole conversation. Both AIs pushed PixelBridge, on the reasonable grounds that it would still be accurate once ST7789 and ILI9341 were supported. I went with USB2TFT because it says what it does and matches the USB2UART and USB2SPI naming everyone already knows.
Then nothing worked for two weeks
Here is the part the throughput tables do not prepare you for.
I desoldered the display, stuck it on a piece of perf board, and connected it to the RP2040 with fine enamelled wire.
Then I wrote firmware with Codex and a couple of other AIs, and got a blank screen.
Hours of it. I swapped pins. I tried hardware SPI, then software SPI, then different pins again. Nothing ever appeared. The problem with a blank screen is that it tells you nothing about where the fault is: bad solder joint on a 0.5 mm flex pad, a broken enamelled wire, wrong pin, wrong init sequence, wrong SPI mode. Every one of those looks identical from the outside, and I could not eliminate the wiring.
So I stopped. Not for an evening, for weeks. The display stayed on the perf board on my desk, in the open, with nothing protecting the glass.
That cost me. By the time I came back, two edges of the panel were damaged and the pixels along both of them are permanently scrambled. You can see it in every photo further down: a strip of confetti along the right side and the bottom. The picture area works perfectly, the edges are dead, and there is no firmware fix for a bruised panel. If you salvage a bare LCD, put it in a box the day you take it out.
When I did come back I finally did the thing I should have done first, which was to remove a variable instead of adding effort. I designed a board that carries both the display and the RP2040-Zero, so the whole assembly becomes one rigid object rather than a bundle of wires that moves every time I pick it up.
Then I made it the cheap way: printed on photo paper, toner transferred onto copper clad, etched at the kitchen sink.
I moved the display off the perf board onto that, and ran the same firmware test. Still nothing.
That was the point where the wiring stopped being a suspect. Same result on two completely different pieces of hardware means the fault is in the firmware, and now I could stop guessing and start reading code.
The bring-up bugs
A fresh Codex session found the display problem quickly, and it was not subtle. The firmware was holding chip select inactive for the entire transaction, skipped most of the initialisation sequence, and never actually wrote any pixels. CS on the ST7735S is active low. Fix those three things and the panel cycled red, green and blue once per second.
Getting from there to USB took five more bugs, and the sequence is worth reading if you are ever stuck on TinyUSB not enumerating.
Five bugs between a working panel and a working USB device
- The 32 KB framebuffer was a local variable. The RP2040's default main stack is far smaller than that, so the firmware crashed before reaching its main loop and never enumerated. Moving it to static RAM fixed the crash.
CFG_TUD_ENABLEDwas never set in tusb_config.h. The CDC class was configured, but the device stack itself was off, sotusb_init()was a no-op. Nothing on the wire at all.- TinyUSB could not see tusb_config.h, because the project directory was not on the include path.
- With those fixed, still nothing. The next move was to add
pico_fix_rp2040_usb_device_enumeration, the SDK's workaround for the RP2040-E5 erratum. That is where it got confusing, because the device started enumerating and then dropping off the bus. - The diagnosis came from painting the screen as a status light, since there was no serial console: red on entry to USB init, green once TinyUSB was up, blue on mount, red again on unmount. The panel showed blue and then red. The host was completing enumeration and then losing the link, which pointed straight at the workaround that had just been added. Removing it fixed everything.
That last one deserves a note. The enumeration workaround exists for a real erratum on early RP2040 silicon, and on this board it was the cause rather than the cure. It is still deliberately disabled in the repo, with the reason written next to it, because it is exactly the kind of thing a future me would "fix" by turning it back on.
The other thing worth saying: none of that debugging needed to be that hard. dmesg is restricted on this machine, so several sessions went by without anyone reading the kernel log. journalctl -k works unprivileged and says precisely what the host thinks of your device:
usb 1-4: new full-speed USB device number 34 using xhci_hcd
usb 1-4: New USB device found, idVendor=cafe, idProduct=4001
usb 1-4: Product: USB2TFT
usb 1-4: Manufacturer: e-lab innovations
cdc_acm 1-4:1.0: ttyACM0: USB ACM device
Eight lines that would have saved a lot of flash cycles.
The protocol
Deliberately boring. Eight byte header, then a full frame, big endian RGB565.
"TFT1" + 0x00008000 + 32,768 bytes of pixel data
The reader scans for that header rather than assuming stream alignment, so a sender that dies halfway through a frame costs one frame instead of desynchronising everything after it. The board sends back a single K byte for each frame it has finished painting, which lets the host pace itself instead of guessing.
Making it fast
The first working version did about 12.5 fps and had two obvious problems, both of which were mine.
It read USB one byte at a time, which is 32,768 calls to tud_cdc_read() per frame. And it pushed pixels with a blocking SPI write at 8 MHz, which takes 32.8 ms per frame, during which tud_task() never runs and the USB stack is starved.
The rewrite was three changes:
What actually changed in the firmware
- Bulk reads straight into the framebuffer Only the eight header bytes are read one at a time. The payload is copied in whatever chunks the CDC FIFO happens to hold, which turns 32,768 calls into a handful.
- SPI at 31.25 MHz The RP2040 divides its 125 MHz peripheral clock, so the reachable rates are 125/2, 125/4, 125/6 and so on. 31.25 MHz sends a frame in 8.4 ms instead of 32.8 ms. The ST7735S datasheet wants about 15 MHz, so this is an overclock that happens to work on short wires.
- DMA into double buffers The panel gets painted from one framebuffer while USB fills the other, so the transfer costs no CPU time and
tud_task()keeps running throughout. Two 32 KB buffers out of 264 KB is a cheap trade.
The DMA setup is the only part with a real trap in it:
static void display_poll(void) {
if (!dma_busy || dma_channel_is_busy(dma_chan)) return;
// DMA has queued the last byte; the SPI FIFO still has to shift it out
// before CS may be released.
while (spi_is_busy(SPI_PORT)) tight_loop_contents();
cs_high();
dma_busy = false;
ack_pending = true;
}
DMA completion means the last byte reached the SPI peripheral, not that it reached the display. Release chip select on the DMA interrupt and you truncate the final pixels.
While I was in there I added one thing that changed the whole workflow. Opening the port at 1200 baud reboots the board into BOOTSEL, the same convention the Pico's own stdio driver uses:
void tud_cdc_line_coding_cb(uint8_t itf, cdc_line_coding_t const *coding) {
(void) itf;
if (coding->bit_rate == 1200) reset_usb_boot(0, 0);
}
Before this, every firmware iteration meant physically holding a button and replugging. After it, reflashing is two commands. When you are iterating twenty times an evening that is the difference between testing an idea and not bothering.
Where 16 fps actually comes from
The rewrite got me to 15.8 fps and no further. Something was capping it at almost exactly 512 KiB/s, and I wanted to know what before optimising anything else.
Three experiments, cheapest first, each designed to eliminate one suspect:
Test | Result |
|---|---|
Paced at 30 fps | 12.5 fps, 120 frames dropped |
Unpaced, send everything | 15.8 fps |
Raw writes, no frame semantics | 512 KiB/s |
1, 4 and 16 frames per | 512, 514, 514 KiB/s |
Panel writes deleted from the firmware entirely | 512 to 521 KiB/s |
libusb straight to endpoint 0x02, cdc_acm detached | 533, 543, 549 KiB/s |
The batching test rules out host side write boundaries. Removing the display code rules out my SPI and DMA path, which costs about 2 percent. And the last row is the one I am glad I ran: with the kernel's cdc_acm driver detached and libusb writing to the same bulk endpoint, with completely unchanged firmware, throughput improves by 5 to 7 percent. Not double.
So the tty layer is not the problem, and neither is my code.
USB Full Speed bulk ceiling = 19 packets × 64 bytes × 1000/s = 1,216,000 B/s
= 37.1 fps for 32,768 byte frames
Measured = 524,288 B/s = 16.0 fps = 43% of ceiling
Forty three percent is the normal figure for a single bulk endpoint on this silicon, because TinyUSB's RP2040 port does not double buffer bulk endpoints. The host cannot push into an endpoint that is not armed, and there is a re-arm gap after every 64 byte packet.
What I assumed was slow | What was actually slow |
|---|---|
SPI clock at 8 MHz | Real, worth fixing, 2% of the total |
Byte-at-a-time USB reads | Real, worth fixing, got 12.5 to 15.8 fps |
The Linux tty layer | 5 to 7% |
Python doing the writes | Nothing, batching changed nothing |
Nobody suspected the endpoint | Everything else |
This is why the 15.7 fps in the demo video is not a disappointment. It is 43% of what the bus can do and the remaining 57% is not in my code. Every cheap option left points at sending fewer bytes rather than sending them faster, so I worked out what each one would buy:
Approach | Bytes per frame | fps |
|---|---|---|
RGB565, today | 32,768 | 16 |
RGB444, 12 bit colour | 24,576 | 21 |
96 by 96, upscaled on the device | 18,432 | 28 |
64 by 64, upscaled on the device | 8,192 | 60 plus |
Two alternating bulk OUT endpoints | 32,768 | about 32, estimated |
Then I dropped the middle three. Every one of them pays for frame rate with picture quality, on a panel whose entire appeal is that it shows a real image, and 16 fps is already past the point where a video reads as video. Trading colour depth or resolution to chase a number I do not need is the kind of optimisation that feels productive and makes the product worse.
That leaves the last row, which is the only one that keeps full quality, and also the only number in this post I have not measured. Everything above 60 fps is academic anyway, because FRMCTR1 puts the panel's own refresh near 60 Hz and there is no TE pin wired to sync against.
Compression is a dead end here, which surprised me until I thought about it. RLE and inter-frame delta both need flat or static content. Video is neither. For a dashboard or a navigation screen it would be the single biggest win available.
Any video, any resolution
The host side stopped being interesting the moment I let ffmpeg do the work. It converts straight to rgb565be, which is exactly the byte order the firmware wants, so Python only relays finished frames.
ffmpeg -i video.mp4 \
-vf "fps=16,scale=128:128:force_original_aspect_ratio=increase,crop=128:128,setsar=1" \
-an -f rawvideo -pix_fmt rgb565be -
The earlier image sender converts with a per pixel Python loop over 16,384 pixels, which is fine for a photo and hopeless for video. That is the entire reason there are two tools.
fps=16 resamples by time rather than by decimation, so the source rate genuinely does not matter. I tested that instead of assuming it:
Source | Result |
|---|---|
1920 by 1080, h264, 60 fps | 15.5 fps |
480 by 854 portrait, 24 fps | 15.6 fps |
100 by 36 mkv, 5 fps | 15.8 fps |
720 by 576 anamorphic, SAR 16:11 | 15.6 fps |
640 by 480 mpeg4 in an .avi | 15.5 fps |
The 5 fps clip has frames duplicated up and the 60 fps clip has them dropped, both at correct speed. setsar=1 is what keeps the anamorphic source from arriving squashed.
A real clip, 360 by 360, running for two and a half minutes:
sent 2408 frames in 153.6s (15.7 fps), 24 dropped, 2407 acknowledged by the board
Twenty four dropped out of the 2432 it tried to send, and 153.6 seconds of wall clock for a 152.0 second video. No drift, no disconnect.
Here it is running, filmed off the panel:
The colour bug
Here is my favourite part, and the most embarrassing.
I had that photo lined up for an Instagram story, captioned "watching The Odyssey as Christopher Nolan intended". What I did not notice is that the frame is wrong. Every frame I had ever sent was wrong.
The panel's colour filter is physically B-G-R behind the glass. Bit 3 of MADCTL tells the controller to swap red and blue for you, and my init sequence had it clear. So 0xF800, which every reference calls red, painted blue.
I know this panel does that. I diagnosed it on the same display in the TLSR8232 project and wrote it up in that post. I still shipped the bug.
The reason it survived is worth more than the fix:
The BGR bit swaps red and blue and never touches green. So a frame with two channels transposed still shows correct greens, which makes it look plausible instead of broken.
The bring-up test was a red, green, blue colour cycle. That test cannot detect this fault. One channel is correct and the other two are silently traded, and the screen dutifully cycles three saturated colours in the right rhythm. Everybody involved, me included, took that as proof the panel was working and built DMA, double buffering, a full throughput investigation and video playback on top of it.
The fix is one bit:
// main.c
// This panel's colour filter is physically B-G-R, so bit 3 of MADCTL is set
// to make the controller swap red and blue for us and ordinary RGB565 comes
// out correct. With the bit clear, 0xF800 paints blue. Note that the bit
// never moves green, so a host-side (B, R, G) packing cannot be corrected
// here at all.
st_cmd(ST77XX_MADCTL); st_data8(0x08);
There is a corollary I had to relearn. Because green sits in the middle six bits either way, no MADCTL value can rescue a host that packs its pixels as blue, red, green. On the TLSR8232 the bug was on the host side and the panel bit was already correct. Here it was the exact opposite. The two failures look identical on screen.
So there is now a third tool in the repo whose only job is to make colour order measurable rather than a judgement call. It sends known words and prints what the panel should read:
python3 tools/color_test.py $PORT --all
If red and blue trade places while green stays put, the MADCTL bit is wrong. Flat saturated bars are also the easiest way to spot SPI trouble, since signal noise shows up as speckle against a solid fill. On my panel the two damaged edges speckle no matter what, which is its own small lesson: once the hardware has a permanent fault, every test you run has to be read around it.
What I would tell past me
Remove variables before adding effort. Etching that PCB fixed nothing, and it was still the most useful thing I did, because it turned "wiring or firmware" into "firmware".
Put the fragile part away. Leaving a bare LCD face up on a desk for weeks cost me two edges of a panel I cannot buy a replacement for. That is the only damage in this project that no amount of debugging can undo.
A test that cannot fail is not evidence. The RGB cycle passed for weeks while the colours were wrong. I now write the test that would catch the specific thing I am claiming, not the test that makes the screen light up.
Read the kernel log first. journalctl -k had the answer for most of the USB debugging, and it went unread because dmesg was restricted and nobody looked for the alternative.
Measure before optimising, then measure what you eliminated. The three throughput experiments each cost about ten minutes and together they proved that 57% of the missing bandwidth was in neither my firmware nor my host code. Without them I would still be tuning SPI clocks.
The experiment that kills your plan is a good experiment. The 2 Mbps UART result was a success that told me to stop, and stopping saved weeks.
Where this stops, for now
The throughput work is finished as far as I am concerned. 16 fps at full colour is the honest capability of a single bulk endpoint on this chip, and the only upgrade I would still take is the two endpoint version that costs nothing in quality. That is a rewrite of the USB layer rather than an afternoon.
What I still want is the thing I wanted at the start, a desktop companion along the lines of Hey Taby but open. I have not started it. The hardware end is done, and 32 KB of pixels over USB at 16 fps is plenty for a face that blinks, so what is left is all software and design, which is the half I am worse at. I am not a designer or an animator. Taby's creator drew frames one by one in Figma and I know I would quit by the second animation, so if I do build it the approach will be to draw one character, lock it, produce eight or ten key expressions, and rig those rather than redraw a hundred frames.
That is a plan, not a project. If it turns into a project, it gets its own post.
The repo has the firmware, the three host tools, the wiring, and the measurements including the ones that ruled things out.
Discussion 0 comments
Be kind. I read everything but might take a day or two to reply.