Hardware C/C++ The emulator

There's never been a better time to play with an interesting piece of hardware. There's a wide variety of cheap and capable devices about, and with AI one of these no longer has to be something that sits in a drawer gathering dust while you wait for the day you'll work out the specifics and get a few pixels on the screen. You can get something running almost straight away, and turn the device into something useful or fun.

Here I take an ESP32 development board with a 1.75 inch round screen, battery power and a fully enclosed case, and turn it into a clock. The clock above is that program, compiled to run here in the browser.

The device

The board is Waveshare's ESP32-S3-Touch-AMOLED-1.75C: a round 1.75 inch AMOLED on an ESP32-S3, small enough to sit in the palm of your hand, in a machined aluminium case with a battery, so it stands on a desk on its own. The screen is a real AMOLED, so the black of the case and the black of the screen are the same black, and a dark face looks like it's floating.

The device, running the wooden clock face
The device, running the wooden clock face
SpecDetail
BoardWaveshare ESP32-S3-Touch-AMOLED-1.75C (SKU 33691)
ProcessorESP32-S3, dual-core Xtensa LX7, up to 240MHz
Wireless2.4GHz Wi-Fi, Bluetooth 5 LE
Display1.75 inch round AMOLED, 466x466, CO5300 driver over QSPI
TouchCST9217 capacitive, over I2C
Memory8MB octal PSRAM, 32MB flash
SensorsQMI8658 6-axis accelerometer and gyroscope
AudioES8311 and ES7210 codec, speaker and microphones
PowerAXP2101 power management, USB-C, 3.7V lithium battery
CaseCNC aluminium, with BOOT and PWR buttons and a lanyard
The board's specifications

Setup

I installed the battery, screwed the back of the case on and plugged the board in over USB-C.

Before changing anything on it I wanted a way back. I asked Claude Code to examine the device and pull a copy of its existing flash storage, so that I could restore it to the factory image later if I needed to. It worked out the layout, talked to the board over its serial bootloader and pulled the whole 32MB image off it.

A first clock

The board sat plugged in on my desk while I worked, so I could look at it as well as read the code. I went straight in and asked Claude to make a clock: a wooden background, a chrome edge, and numbers around the face. With a small amount of tweaking I had a fairly good clock.

The first clock face, with a wooden background and chrome edge
The first clock

There was some tweaking to do to get everything antialiased correctly, but most of the requests from there were just layout changes: adding the ticks around the edge of the face and positioning the numbers the way I wanted.

After that I asked it to make a set of clock faces that you could switch between by pressing on the screen, and in no time I had a set of different faces without even having said what each one should be. I threw away the ones I didn't like and kept seven, again with some visual tweaks and a bit of customisation. There's a wooden one, a Casio-style digital, a neon ring, a plain Bauhaus face, a red LED display, a little solar system, and a glowing green vector face.

Wooden clock face
Wood
Casio-style digital clock face
Casio
Neon ring clock face
Neon
Bauhaus clock face
Bauhaus
Red LED display clock face
LED
Solar-system clock face
Orbit
Glowing green vector clock face
Vector
The seven clock faces

Performance

I wanted everything to feel smooth, at a full frame rate, and the board isn't quite fast enough to send a whole frame to the screen 60 times a second. Some of the face backgrounds also took a noticeable moment to draw when you switched to them, and I wanted the switch to feel instant.

The screen has its own memory. To draw, you set a window on the panel - a rectangle of columns and rows - then stream the pixels for it over a fast serial bus, and the panel shows what's in its memory. On this board the panel is a CO5300 driven over QSPI: you send a command to set the column range, another to set the row range, then a command followed by the pixels, two bytes each, in RGB565. The cost is roughly proportional to the number of pixels you send. The round face is 466 by 466, which at two bytes a pixel is about 420KB for the whole screen, and pushing that over the bus 60 times a second is more than there's time for. So redrawing the entire face every frame won't hold 60 frames a second.

The fix was to divide the screen into a grid of 16-pixel squares, a 30 by 30 grid, and keep track of which squares are dirty, then send only those to the panel. For the second hand sweeping round, only the few squares it passes through change each frame, so that's all that gets sent, and the hand moves smoothly. Each square is a tiny window of its own on the panel.

The dirty squares, drawn inverted so you can see what is being sent to the panel
The dirty squares, inverted so you can see what's being sent each frame

For switching faces, the backgrounds that take time to draw are rendered once when the program starts and kept in buffers in PSRAM. All seven fit, so switching to a face is just a copy, and then only the hands and digits are drawn on top.

Structure of the code

The program is an ESP-IDF app in C and C++. After it brings up the display, the touch and the power button, it sits in a loop: work out the time, compose the next frame, send the squares that changed, then wait for the next frame.

Composing a frame draws into a plain RGB888 buffer and, as it draws, marks every 16-pixel square it touches as dirty. A small drawing library does the lines, circles, text and the antialiasing; it's all software, drawing into the buffer a pixel at a time.

Sending the frame walks the dirty squares and sends each one. The dirty squares for a row are held as the bits of a 32-bit word, so finding the next one is a count-trailing-zeros:

static void send_rect(esp_lcd_panel_handle_t panel, const uint8_t *rgb,
                      uint16_t *scratch, int x0, int y0, int x1, int y1)
{
    hud_pack_rect(rgb, scratch, x0, y0, x1, y1);          //  RGB888 -> RGB565
    esp_lcd_panel_draw_bitmap(panel, x0, y0, x1, y1, scratch);
}

for (int ty = 0; ty < HUD_TY; ty++) {
    uint32_t bits = dirty[ty];
    while (bits) {
        int tx = __builtin_ctz(bits);    //  next dirty square in this row
        bits &= bits - 1u;               //  clear it
        int x0, y0, x1, y1;
        hud_tile_rect(tx, ty, &x0, &y0, &x1, &y1);
        send_rect(panel, rgb, scratch, x0, y0, x1, y1);
    }
}
Sending only the dirty squares (trimmed)

The pixels have to be packed from the buffer's RGB888 into the RGB565 the panel wants, and the CO5300 over QSPI wants the two bytes of each pixel swapped, so the packer does both at once:

static uint16_t pack565(uint8_t r, uint8_t g, uint8_t b)
{
    uint8_t r5 = r >> 3;
    uint8_t g6 = g >> 2;
    uint8_t b5 = b >> 3;
    uint16_t c = (r5 << 11) | (g6 << 5) | b5;
    return (c << 8) | (c >> 8);   //  CO5300 over QSPI wants the bytes swapped
}
Packing a pixel into byte-swapped RGB565

The faces that are expensive to draw are prerendered once into one slab of PSRAM, one background per face, so a switch is a copy rather than a redraw:

//  All face backgrounds are drawn once at startup into one PSRAM slab,
//  so switching to a face is a copy, not a redraw.
uint8_t *slab = heap_caps_malloc(k_fb * CLOCK_FACES, MALLOC_CAP_SPIRAM);
for (int i = 0; i < CLOCK_FACES; i++) {
    s_static[i] = slab + k_fb * i;
    draw_static_face(s_static[i], i);
}
Prerendering the face backgrounds (trimmed)

The clock at the top of the page is this same program. The drawing code and the dirty-square logic are compiled to WebAssembly unchanged; the only thing swapped out is the panel, which here is a small model of the CO5300's frame memory drawn onto a canvas, so the pixels reach the screen the same way they do on the device. If you want to go a step further, I also wrote an emulator for the whole board that runs the real firmware binary, down to the chip and the QSPI bus.