Water Turret Alarm Clock
Prototype
Experiment · IoT

Water 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.

Built with
C++C++
ArduinoArduino
ESP32-CAMESP32-CAM
PythonPython
pyserialpyserial
PillowPillow
TkinterTkinter
Project Details

STATUS
Prototype
YEAR

2026

TYPE

Experiment · IoT

TAGS
Embedded
Computer Vision
Hardware
Serial

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: FRM0 plus 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

STATUS
Prototype
YEAR

2026

TYPE

Experiment · IoT

TAGS
Embedded
Computer Vision
Hardware
Serial