Valerii Kozhevets about work study certificatescerts projects tools

Handheld radio controller

A one-hand ESP32 transmitter in a shell I modelled around its parts, sending the joystick over ESP-NOW about 33 times a second and running programs from a menu.

2024–2026 embedded3D prototyping

Handheld radio controller
Board ESP32 T-Display, 135×240 ST7789 screen, two buttons
Input HW-504 two-axis joystick with push switch
Link ESP-NOW broadcast, one JSON packet per frame (35 FPS cap)
Power 500 mAh LiPo behind a rocker switch, charged over the board's USB-C, voltage read through the board's divider
Firmware MicroPython, Russ Hughes' st7789 display driver
Shell two PLA halves, about 60 × 108 × 19 mm, modelled in Blender

A one-hand wireless controller: an ESP32 T-Display, a thumb joystick and a LiPo in a two-part shell I modelled in Blender around the real parts and printed. The MicroPython firmware sends the stick, its push switch and both buttons over ESP-NOW once per frame, 32 to 34 times a second, and the controller boots into a menu that runs any program copied to its flash. It drives the RC car: on the bench it ran the car’s soldered receiver, motor driver and all four motors live over ESP-NOW. The sensor display becomes its remote screen when it hears the controller at boot.

A bare-board prototype and the car receiver, November 2024 to February 2025

The first version ran in November 2024 on a bare ESP32 board, with the joystick on two ADC pins and a loop that sends x, y and the stick switch as JSON over ESP-NOW ten times a second. On 9 February 2025 I wrote the car’s receiver for that packet.

Shell and first firmware, February 2025

In February 2025 the board became a T-Display, with its own screen and two buttons. The shape took ideas from PS4 and PS5 controller models I had downloaded to see how gamepads are built, made smaller and for one hand. I imported ready-made models of that board and of the HW-504 joystick module into Blender and built the shell around them: the screen window, the rocker cut-out, the stick opening and four screw bosses. A small joystick fit test came off the Elegoo Neptune 4 first, then both halves in PLA at 0.2 mm on 18 February, and it was assembled that night.

four panels: the shell model in Blender, the printed halves on the print bed, the model with the board and joystick inside, and the opened print with the joystick, LiPo and board wired
17 to 18 February 2025. Left, the Blender model, and below it with imported models of the T-Display and the HW-504 joystick in place; right, the printed halves and the first fit of the real parts.

The first firmware, committed on 19 February, drew a text readout of both axes and the buttons with the display driver built into LoBo MicroPython, plus a battery bar that runs from red at 3.2 V to green at 4.2 V. It had no radio. Five days later the controller was running the graphic view it still has, on Russ Hughes’ st7789 C driver: a square that shows raw stick travel, a circle that shows the same vector limited to the unit circle, cursors that turn green on a stick click, and a side bar for each button.

On 26 February I wrote the receiving end on the sensor display, which draws the same view from each packet. The next version, committed on 8 April 2025 in the same commit as the graphic view, erases and redraws only the two cursors instead of the whole square and circle, and the frame cap went from 22 to 35 FPS. Its transmitting build sends the stick, its switch and both buttons as JSON every frame to one receiver’s address, and by 13 April the sensor display was mirroring its view.

13 April 2025. The stick moves a dot on the controller's screen, and the sensor display behind it draws the same dot from each packet it receives.

That transmitting build went into the repository on 2 December 2025, by then with 30-sample smoothing of the voltage and frame-rate readouts and an SPI clock lowered from 40 to 26 MHz.

A program menu, December 2025 to January 2026

Since 3 December 2025 the controller boots into a menu. main.py lists /programs, sorts the files and runs the chosen one with exec, handing it the display, the inputs and the drawing helpers from shared.py. The stick or the first button moves the selection, the second button or a stick click launches. If a program raises, the menu prints the error on screen until any input or ten seconds pass, then collects garbage and re-initialises the panel before drawing the list again. A new program is one more .py file copied to the board.

The transmitter itself became one of those programs, next to a Matrix-style rain effect. On 9 January 2026 I added paint, pong, a maze runner, an arcade game of shooting and dodging falling rocks, a flashlight that takes its hue and brightness from the stick, a Wi-Fi scanner, a battery logger and an input tester.

15 January 2026. The menu lists the files in the board's /programs folder; here it starts the transmitter.

A rocker switch sits in the battery line, the LiPo charges over the board’s USB-C, and in use a charge lasts several hours. The battery program from 14 January samples the cell ten times a second, stores two-second means in a 90-slot ring buffer (three minutes), and compares the first fifth of that window with the last fifth. After 20 seconds of data it shows the discharge rate as minutes per percent and an estimate of time to empty. A 15-reading average at or above 4.18 V counts as a charger connected, which hides the estimate, and every switch between the two states clears the buffer.

The same day the packet gained a shared tag, and the sensor display learned to listen for the controller at boot and become its remote screen when a packet with the right tag arrives. On 15 January the controller switched from one receiver’s MAC address to broadcast. Each packet is about 80 bytes of JSON: the tag, x and y rounded to two decimals, the stick switch and both buttons. The loop holds 32 to 34 FPS on screen and sends once per pass.

The controller in the showcase video Watch on YouTube from 8:34