ESP32 Proximity Alert with an Animated Eye

Animated demo of the proximity sensor: the round TFT eye, LEDs, and buzzer escalating as a hand approaches

An ESP32 wired to an HC-SR04 ultrasonic sensor, four relay channels driving LEDs and a buzzer, and a 1.28" round GC9A01 TFT showing a stylised eye whose iris colour tracks how close the intruder is. Five zones, escalating warnings, and a flashing red eye at touching distance.

The full sketch lives at github.com/cycloarcane/prox-sense.

Hardware

  • ESP32 WROOM-32 dev board
  • HC-SR04 ultrasonic distance sensor — runs on 5V, outputs 5V on ECHO, so a voltage divider is mandatory on the ECHO line going into the 3.3V ESP32 input
  • 4-channel active-low relay module — the relays are the whole point of the modularity here. LEDs and a buzzer are on the channels today, but each relay can switch up to 240V at 10A, so the same circuit could trip a door lock, an alarm siren, a mains-powered lamp, or any other load you'd want to fire on proximity. The ESP32 only ever sees the 3.3V coil-driver side
  • Green, yellow, and red LEDs plus a buzzer, each on its own relay channel (placeholder loads — swap for whatever you actually want to switch)
  • GC9A01 1.28" 240×240 round SPI TFT display

Wiring

Full wiring diagram showing the ESP32 connected to the HC-SR04 with voltage divider, the 4-channel relay module, and the GC9A01 round display

HC-SR04:

  • TRIG → GPIO 5
  • ECHO → GPIO 18 via voltage divider (1k to ECHO, 2k to GND, junction to GPIO 18)

Relay module (active-low — driving the pin LOW closes the relay):

  • IN1 → GPIO 19 (green LED)
  • IN2 → GPIO 21 (yellow LED)
  • IN3 → GPIO 22 (red LED)
  • IN4 → GPIO 23 (buzzer)

GC9A01 display (HSPI pins on the ESP32 — see gotchas):

  • SDA / MOSI → GPIO 13
  • SCL / SCLK → GPIO 14
  • CS → GPIO 15
  • DC → GPIO 2
  • RST → GPIO 4
  • VCC → 3.3V, GND → GND
  • Backlight tied to 3.3V (always on)

How it behaves

The firmware loop triggers the HC-SR04, reads the echo with pulseIn, converts the pulse width to centimetres, and bins the result into one of five zones using thresholds at 50, 40, 30, and 20cm.

The five eye states rendered on the GC9A01 display: idle blue (>50cm), green (<50cm), yellow (<40cm), red (<30cm), and flashing red (<20cm)
  • Zone 0 (>50cm) — idle, blue iris, all relays off
  • Zone 1 (≤50cm) — green LED only, green iris
  • Zone 2 (≤40cm) — green + yellow LEDs, yellow iris
  • Zone 3 (≤30cm) — green + yellow + red LEDs, red iris
  • Zone 4 (≤20cm) — three LED relays chase green→yellow→red at 50ms each via millis()-based timing, buzzer stays solidly on, iris flashes between red and black at 200ms intervals

The eye is only redrawn when the zone changes (the zone-4 flash is the exception). That's important for the next gotcha.

Required User_Setup.h for TFT_eSPI

The display will not work with the stock TFT_eSPI configuration. Edit ~/Arduino/libraries/TFT_eSPI/User_Setup.h so it contains at minimum:

#define GC9A01_DRIVER
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS   15
#define TFT_DC    2
#define TFT_RST   4
#define SPI_FREQUENCY 27000000
#define USE_HSPI_PORT

Each line matters. The reasons are in the gotchas below.

Gotchas

Every one of these came up during the build:

  • Arduino IDE caches a copy of TFT_eSPI. Edits to ~/Arduino/libraries/TFT_eSPI/User_Setup.h are silently ignored if the IDE has already cached the library under ~/.cache/arduino/sketches/*/libraries/TFT_eSPI/. Delete the cache copy after editing the setup file, otherwise you'll be debugging the wrong file.
  • HSPI vs VSPI. GPIO 13 and 14 are HSPI pins on the ESP32. TFT_eSPI defaults to VSPI, so the panel stays black even with everything wired correctly. Uncomment #define USE_HSPI_PORT in User_Setup.h.
  • SPI clock. 40MHz is unreliable for some GC9A01 boards over breadboard. Drop SPI_FREQUENCY to 27MHz.
  • Sprite allocation silently fails. TFT_eSprite::createSprite(240, 240) returns without error on ESP32 but doesn't actually allocate, because a 240×240×16-bit sprite is ~115KB and the ESP32's contiguous heap can't fit it. Either use direct tft.fillCircle / fillRect calls (fine here because the eye only redraws on zone change), or attach PSRAM and allocate from there.
  • Display rotation. The GC9A01 module's silkscreen orientation usually doesn't match setRotation(0). The pupil shine ending up in the wrong corner is the giveaway — setRotation(2) flips 180°.
  • Drawing artifacts on angled lines. Stacked drawLine calls leave 1-pixel gaps when angled (Bresenham doesn't guarantee adjacency between parallel lines). Use fillTriangle for thick angled bands instead.
  • Integer truncation in chord-drawing loops. int dx = sqrt(...) rounds down, leaving 1-pixel rings of unfilled background where curves meet circles. Either pad dx by +1 or avoid the technique entirely.
  • Visual design iteration. The first "angry eye" had eyelids and a brow drawn over the iris. It read as floating black bars on a coloured ball, not a face. A plain coloured eye with iris-colour-as-state was cleaner. Design the minimal version first; only add ornament when it actually reads correctly.

Repo

github.com/cycloarcane/prox-sense — single-file Arduino sketch (prox-sense.ino) plus README. Flash it, wire it up per the diagram above, and edit User_Setup.h as shown.

> End of output.

‎