More Projects
59 in totalWater Turret Alarm Clock
2026
Hardware alarm clock that is meant to aim and fire water at whoever will not get up; the vision front end is what exists so far. An ESP32-CAM (AI-Thinker) sketch drives a GC2145 sensor that has no hardware JPEG encoder, so frames are captured raw and encoded in software — the format preference is JPEG, then YUV422, then RGB565, picking YUV422 where possible to skip a colour-space conversion. Frames stream over USB serial at 1.5 Mbaud behind an 'FRM0' + uint32-length framing protocol, with single-character commands for resolution, JPEG quality and flash duty (capped at 25% so the 5V rail does not brown out). A Tk desktop viewer decodes the stream live with an FPS/throughput readout, alongside a headless script that smoke-tests the link by counting intact frames. A single-frame grabber sits beside them and has fallen behind the firmware's protocol, so it no longer runs as written.
Project Details
2026
Experiment · IoT
The plan is an alarm clock that aims and fires water at whoever will not get up. What exists is the vision front end, and it turned out to be the hard part: this ESP32-CAM board carries a GC2145 sensor with no hardware JPEG encoder, so every frame is compressed in software on the chip that is also driving the camera bus.
That distinction is easy to miss when you buy the board. Most ESP32-CAM tutorials assume an OV2640,
which hands the driver a finished JPEG; you read fb->buf, write it to the wire, and the CPU has
done almost nothing. On a GC2145 the driver reports pixel identifier 0x2145 and the frame arrives
raw. Everything downstream has to be rebuilt around the fact that the expensive part of the loop is
now on the same core as the capture, and that both are competing with the camera driver's own task
for the same 240 MHz.
Working around the missing encoder
Init walks a format preference chain of JPEG, then YUV422, then RGB565, taking the first the driver
accepts. On this board it lands on YUV422, which is the point: JPEG is natively YCbCr, so encoding
from YUV422 skips a colour-space conversion and beats encoding from RGB565. The chain is not walked
blindly. If a format fails with 0x105, ESP_ERR_NOT_FOUND, the loop gives up immediately and
prints the wiring checklist instead, because that error means the sensor was never detected and
trying the other two formats only wastes ten seconds and produces a more confusing failure.
The rest of the camera config is sized to the board: XCLK at 20 MHz with a note to halve it if an
FPC extension cable goes in, CAMERA_GRAB_LATEST so a slow consumer never gets served a stale
frame, and a frame-buffer count and location that both switch on whether PSRAM is present, two
buffers in PSRAM if it is and one in DRAM if it is not.
Two cores and a depth-two queue
The encode costs roughly 95 ms per frame against about 20 ms of serial write, so the two are split
across cores. Capture and encode run on core 1; a FreeRTOS task pinned to core 0 does nothing but
write bytes, because the camera driver's own cam_task already sits on core 0 at higher priority.
A depth-2 queue joins them, dropping frames rather than buffering when the sender falls behind, so
latency does not accumulate.
The handoff is deliberately copy-free on the path that matters. frame2jpg already returns a heap
buffer independent of the frame buffer, so the encoder takes ownership of it, returns the frame
buffer to the driver immediately and pushes the pointer onto the queue. The hardware-JPEG branch is
the one that has to malloc and memcpy, which is the opposite of the intuition and only applies
on a sensor this board does not have. When the queue is full the send times out after 200 ms and the
buffer is freed rather than retried.
Details that took debugging
- Framing:
FRM0plus a little-endian uint32 length, then the JPEG payload, at 1.5 Mbaud. - Resync: the host scans a sliding four-byte window and rejects lengths outside 0 to 400,000.
- LEDC conflict: the camera owns channel 0 and timer 0, so the flash runs on channel 4. Sharing the channel silently breaks the sensor clock.
- Flash capped at 25% duty. Steady 100% browns out the 5V rail and cooks the LED. The three modes are 8-bit duty values of 0, 25 and 64, with a fourth mode that toggles at 400 ms.
- DTR and RTS both released on connect, since RTS drives EN on this FTDI wiring and otherwise holds the ESP32 in reset with no symptom but silence.
- Drain on every mode change. Stopping the stream waits 60 ms before draining so the encoder notices the flag first, and a resolution change waits 120 ms and then drops whatever is still in flight at the old size, because a half-size frame decoded at the new dimensions is a garbled image rather than a visible error.
- A blink code for a dead camera. If init fails the sketch does not return from
setup; it halts in a loop pulsing the flash for 120 ms a second, which is the only diagnostic available on a board with no display and a serial port you may not be watching.
The control surface
Everything is single characters over the same link, so the viewer needs no second channel: v and
x start and stop the stream, 0 through 4 select QQVGA, QVGA, CIF, VGA or SVGA, - and +
walk JPEG quality in steps of five between 10 and 90, f cycles the flash and b toggles blink,
and s prints a status block covering sensor identifier, pixel format, resolution, quality, PSRAM
and free heap. Status is safe to request mid-stream precisely because the host resyncs on the magic
bytes rather than assuming the stream is clean.
The Tk viewer decodes through Pillow with an FPS and KB/s readout over a one-second window,
resolution, flash and quality controls, three zoom modes, and a save button that writes the last raw
JPEG straight to disk without a re-encode. It also carries a watchdog that re-sends the start
command up to five times after four seconds without a frame. Flash control is the fiddly part: the
firmware exposes a cycle, not a setter, so the host tracks its own idea of the current mode, leaves
blink first if it is in blink, and then sends the right number of f presses to land on what was
clicked.
Two smaller tools sit beside it. stream_test.py is the headless version, scanning a growing buffer
for frame boundaries and reporting frames per second, average bytes per frame, throughput, and how
many frames survived intact by checking for FFD8 and FFD9 markers at both ends. When the split
across cores was being tuned, that intact-frame count was the number that mattered.
Honest state
No servos, no relay, no water. The turret does not aim or fire yet, and there is no detection stage either, so nothing yet decides who is still in bed. There is also no WiFi anywhere in the firmware, by choice for now, which means the whole thing is tethered to a USB cable.
grab_frame.py has fallen behind the sketch. It opens the port at 921600 rather than 1.5 Mbaud and
sends a p command expecting a base64 frame between ---BEGIN JPEG and ---END JPEG markers, and
none of that exists in the firmware any more. It is left in the repo because the boot-banner capture
and the JPEG marker validation in it are still the quickest way to confirm a board is alive, but it
does not run as written.
Project Details
2026
Experiment · IoT