Pwning Time: How I Took Full Control of My EleksTube IPS Clock

Someone gifted me an Atomic Voyage EleksTube IPS GEN3 Pro v4.3.3, a polished six-screen clock designed to resemble a row of Nixie tubes. To most people, it is a finished consumer product. To anyone who enjoys reverse engineering or modifying hardware, six independent displays wrapped around an ESP32 look more like an invitation.

The gift immediately created two reactions: this looks great, and I wonder what else the hardware can do.

The second reaction arrived before I had finished setting it up.

The clock came with a USB-C cable, sat within arm's reach of a computer, and contained an ESP32 capable of driving six independent displays. Yet its more advanced features centered on Wi-Fi, browser-based configuration, internet time, and network weather. USB supplied power and exposed a USB-to-UART bridge, but the stock firmware did not provide a supported local control protocol.

That felt backward. The shortest physical path into the device was already attached, but the intended control path left the clock, crossed a network, and came back.

I did not want to turn a working consumer product into a permanent development board. I wanted it to remain a reliable clock when no host was connected, keep its real-time clock and physical buttons, and recover cleanly from a reset. I also wanted to own the pixels.

That became the real challenge: not simply making an ESP32 display custom artwork, but replacing the product's control plane without breaking the product underneath it.

📖
TL;DR I replaced the clock's network-dependent control path with a deterministic USB protocol, preserved its offline clock behavior, and turned one display, and eventually all six, into a programmable local endpoint for images, GIFs, alerts, and synchronized animations.
Atomic Voyage EleksTube IPS clock switching between its Jellyfish status animation and Matrix mode

You do not need to be an embedded engineer to follow the project. Inside the unusual hardware is a familiar systems problem: establish a recovery path, observe the existing behavior, find the closest trustworthy foundation, separate hardware support from product policy, and change one boundary at a time.

A Good Clock With the Wrong Control Plane

The EleksTube IPS is sold as a decorative clock, but the Nixie-tube appearance is an illusion. Each "tube" contains a 135 × 240 IPS panel. Behind them is an ESP32-PICO-D4 with two Xtensa LX6 cores running at up to 240 MHz, 4 MiB of flash, a DS3231-compatible real-time clock, RGB lighting, physical controls, and Wi-Fi and Bluetooth radios.

My unit identifies itself as an EleksMaker IPS GEN3 Pro and shipped with this firmware package:

ELEKSMAKER-IPS-GEN3-Pro-v4.3.3-v26.0513.0001
ComponentVerified hardware
ProcessorESP32-PICO-D4, revision 1.1
CPUDual-core Xtensa LX6, up to 240 MHz
Storage4 MiB flash
External PSRAMNone
DisplaysSix 135 × 240 IPS/TFT panels
Persistent clockDS3231-compatible RTC over I2C
Host connectionUSB-to-UART bridge
Radios2.4 GHz Wi-Fi, Bluetooth 4.2, and Bluetooth LE

In the stock four-digit layout, four screens render HH:MM, another handles the blinking separator, and the far-right screen is reserved for weather. The firmware also includes selectable clock faces, a slideshow, lighting effects, and a digital-rain Matrix animation.

As a consumer product, it was already good. That distinction mattered. I was not rescuing broken hardware or replacing an unusable clock. I was looking at a capable embedded platform whose software imposed a narrower identity than the hardware required.

The device did not need a second microcontroller or an external display driver to become programmable. It already had the processor, storage, serial interface, and pixels. What it lacked was a control model that matched how I wanted to use it.

Defining Success Before Touching the Flash

It is easy to call a hardware project successful once a custom image appears. That was not my success condition.

I wanted three things to remain true at the same time:

  1. The clock would not need to join my home network.
  2. A host could control it through the USB cable already connected.
  3. If the host disappeared, the device would continue behaving like a clock.

The third requirement shaped almost every decision that followed. A laptop closing, a process crashing, or a USB cable being unplugged could not leave the device frozen in an alert or waiting for time from a service that no longer existed. Temporary states needed a duration and an explicit return path. The firmware, not the host, needed to own recovery.

Before experimenting, I read and verified a complete 4 MiB image of the manufacturer firmware. That gave me a known route back to the shipped product. It also clarified an important distinction: a recovery image is not source code.

I could restore the binary, but I could not maintainably extend it. The stock serial behavior was similarly limited. There was no stable, documented, line-oriented protocol that would let another program treat the clock as a local display endpoint.

Connecting directly to the clock's Wi-Fi access point was not an attractive substitute because the computer would leave its normal network. Joining the clock to my home Wi-Fi would add credentials, radios, and another network service to a device sitting at the end of a USB cable.

I wanted local control, not another small appliance waiting for a cloud-shaped reason to exist.

The Trust Hidden Inside a Wi-Fi Setup Screen

For most buyers, connecting a clock to Wi-Fi looks like an ordinary setup step. Enter a password, wait for a checkmark, and move on. From a systems perspective, something more consequential just happened: opaque firmware received a persistent position inside the household network.

That does not mean this clock was doing anything malicious. I found no evidence of that, and this project was not a forensic investigation into the manufacturer. The concern is the trust model itself.

Consumer IoT devices are frequently sold through long, difficult-to-audit supply chains. Their firmware may be closed, their update lifetime unclear, and their network behavior invisible to the person who bought them. Once connected, the device operates behind the same residential IP address as laptops, phones, cameras, and personal accounts.

That pattern matters beyond clocks. Compromised or deliberately monetized devices can turn ordinary household connections into infrastructure for someone else's traffic. In a residential proxy network, the consumer supplies the electricity, bandwidth, and trusted-looking IP address while another party sells the route. The consumer becomes a pawn in a system they cannot see, and the household becomes part of external infrastructure without meaningfully choosing to participate.

I will examine that ecosystem separately, including the rise of globally distributed IoT hardware, opaque overseas manufacturing and software supply chains, and the way residential proxy services can make consumers unwitting infrastructure. This clock is not evidence of that abuse. It is the object that made the underlying trust question difficult for me to ignore.

More importantly, its actual feature set did not justify the grant. Time could live in the RTC. Configuration could travel over USB. Artwork could remain local. The safest network service is often the one the product never needed to start.

Looking for the Right Open-Source Starting Point

"EleksTube" describes a family of clocks rather than one stable hardware platform. Community projects target different generations, board layouts, display mappings, clones, and flash sizes. Firmware that looks compatible in a README can still produce six blank screens when it meets the wrong board.

The manufacturer image offered exact compatibility but no editable source. EleksTubeIPSHack provided open firmware, media management, and a browser-based menu, but it centered the experience around an access point, browser, and NTP. EleksTubeHAX supported a broad collection of EleksTube hardware and clones, but its target and design choices did not align closely enough with my specific GEN3 Pro.

The closest match was judge2005/EleksTubeIPS. It already knew how to initialize all six displays, coordinate their chip-select lines, store extracted image packs in a flash filesystem called LittleFS, talk to the RTC, preserve settings, read the buttons, drive the RGB lighting, and run the Matrix renderer. It also included explicit V2 build targets.

It was still designed around Wi-Fi, web configuration, NTP, MQTT, and network-delivered weather, so it was not the final product I wanted. It was something more valuable: a tested hardware compatibility layer.

I based my fork on its v1.9.5 release at commit 9bb674d. Instead of rewriting the board support because I could, I kept the code that already understood the physical machine and changed the policy above it.

That is what "rolling my own" means here. I did not begin with an empty main.cpp. I began where proven hardware enablement ended, and product assumptions began.

Choosing it as the baseline did not mean inheriting those network assumptions unchanged. I traced the network-dependent paths through the build, verified what reached the linked firmware, and created a USB-only target that excludes those application features and stops and deinitializes the ESP32 radios at startup. I cover that separation in more detail below.

The First Real Takeover: USB as the Control Plane

The first major change was a line-oriented USB serial protocol.

The USB-to-UART bridge presents the ESP32 as a conventional serial device. At 115200 baud, the host sends one ASCII command per line. Commands begin with @; every successful response begins with @ok, and every rejected command begins with @err.

A short session looks like this:

@ping
@ok pong 1.9.5-usb-only

@get network
@ok network=disabled

@get time
@ok time=2026-07-26T18:00:38-0400

@set time_zone:EST5EDT,M3.2.0,M11.1.0
@ok time_zone="EST5EDT,M3.2.0,M11.1.0"

@set status_pane:jellyfish_stress
@ok status_pane={"preset":"jellyfish_stress","type":"gif"}

The important part is not the command syntax. It is what the protocol does not create.

There is no parallel "USB state" layered beside the normal clock state. Commands update the same configuration objects used by the display renderer and physical controls. Persisted settings remain persisted. Temporary animation state remains temporary. The transport can disappear without taking ownership of the device with it.

The command dispatcher has bounded line buffers, validates names and values, and returns deterministic errors. The time service applies POSIX timezone and daylight-saving rules locally. When the host sets the time, the firmware stores UTC in the DS3231 and reconstructs local time after a reset.

The first satisfying test was not a custom GIF. It was setting the time over USB, disconnecting the host, resetting the clock, and watching the correct local time return. That proved the architecture was preserving the product instead of merely remote-controlling it.

Removing the Network Without Removing the Useful Parts

Adding USB commands was only half the work. If the supported build still initialized Wi-Fi, hosted configuration pages, contacted weather services, started MQTT messaging clients, or relied on the Simple Network Time Protocol, USB would be an additional control path rather than a replacement.

The elekstubev2_usb_only target removes those application paths from the linked firmware. The Wi-Fi manager, web server, MQTT startup, network weather client, and SNTP implementation are excluded. During startup, the firmware also stops and deinitializes the ESP32 radios. Commands that would enable network behavior return a deterministic rejection:

@err network-disabled

The chip still physically contains Wi-Fi and Bluetooth. I did not pretend otherwise. This is a firmware boundary, not a claim that the silicon changed. A future build could intentionally use a radio if the value justified the additional operational and security surface. The current target refuses to let a future feature enable networking by accident.

Meanwhile, the clock keeps time without NTP. The RTC stores UTC, the timezone rules remain local, and the host only needs to reconnect when it wants to change something.

The Sixth Display Was the Obvious Place to Start

Once weather was no longer an embedded service, its dedicated panel became the cleanest extension point in the device.

I replaced the upstream weather renderer with a StatusPane that owns only the far-right screen. The clock renderer continues updating the time on the other panels while the status pane independently displays a blank state, a static BMP, a numbered BMP sequence, or a native animated GIF.

Static images were easy. GIF playback exposed the real hardware limits.

The ESP32-PICO-D4 in this clock has no external PSRAM, the extra memory many ESP32 media projects use for large frame buffers. Decoding an entire animation into memory would compete with the clock for limited resources. I pinned bitbank2/AnimatedGIF at version 2.2.0 and wrote the renderer to decode one horizontal line at a time directly to the selected display.

That kept playback nonblocking, and memory use predictable. The clock could continue advancing while the final panel rendered a full 135 × 240 animation. A 580 KB Jellyfish stress test with 20 ms frame timing ran continuously with correct color and without corrupting the time display.

Jellyfish GIF

That test also revealed the difference between a format limit and a useful-media limit.

The decoder could handle detailed, high-rate artwork, but the screen remained only 135 pixels wide. Fine photographic motion became visual noise. Small objects were difficult to follow. Long delays encoded into a final frame looked like firmware stalls, and GIFs whose first and last frames didn't align appeared to hesitate even when the decoder honored their timing exactly.

The animations that looked best shared a few traits: one strong focal object, high contrast, deliberate motion, consistent frame delays, and a seamless loop. The hardware supported more complexity than the display could communicate.

That is a useful constraint. It shifted the work from asking "Can this GIF play?" to asking "Does this motion belong on this screen?"

From One Status Panel to a Six-Screen Display

The far-right panel proved that the clock could render host-selected media without giving up its primary job. Once that boundary worked, stopping at one panel felt arbitrary.

The normal clock now has multiple visual states. It can show face-matched AM/PM artwork, or use the far-right display for a sun or moon animation selected from the current local time. The sun and moon assets are packaged with each face rather than imposed as one generic style, so a neon face receives neon indicators while Gingerbread and Christmas keep their own visual language.

Button B continues to move through the clock's primary modes: the AM/PM clock, the automatic day-or-night animation clock, the date, and a six-panel image layout. The remaining physical controls keep Matrix and Pac-Man available without requiring a host.

The six-panel mode changes the device's visual grammar. Instead of treating each screen as an isolated digit, the firmware can arrange six coordinated images across the entire clock. A Discord alert can occupy every panel. A Pac-Man sequence can place Pac-Man and the pursuing ghosts in a fixed order so the chase reads across the physical row of displays. A broadcast animation can send the same synchronized GIF to all six panels.

0:00
/0:12

USB-triggered animations are deliberately temporary. A host can choose a preset and duration, allow it to take over one or all six displays, and rely on the firmware to restore the clock when the timer expires. Physical mode changes also have defined exit behavior. The clock should always know what state comes next.

That restoration logic is less visible than the animation, but it is the feature that turns a demo into a product. Drawing six GIFs is entertaining. Knowing the device will become a clock again is what makes the feature usable.

The Go CLI Made It Feel Like a Device, Not a Firmware Project

Typing raw serial commands was useful while developing the protocol, but it was not how I wanted other programs to interact with the clock.

I built a Go CLI that can discover and query the device, synchronize time, change faces and clock styles, control brightness and power, select status artwork, upload assets, and launch timed animations. The CLI applies timeouts, validates responses, and keeps serial communication bounded so a failed command cannot wait forever.

Asset upload was an important step. Clock faces, indicators, and animations originally had to be compiled into the LittleFS image and installed with a full firmware flash. The USB upload protocol makes the display surface extensible without turning every artwork change into a firmware release.

I am extracting that same client into a reusable Go dependency. The goal is for projects such as my Go scheduler, a Discord integration, a build monitor, or a home-automation bridge to treat the clock as a local output device rather than reimplementing serial behavior.

That division of responsibility is intentional. The ESP32 does not need calendar credentials, Discord tokens, vendor SDKs, weather API logic, or knowledge of a build pipeline. Those systems change frequently and belong on a host that is easier to update and secure.

The host handles external systems and decides that an event matters. The clock receives a bounded command, renders a local asset, and safely returns to time.

What the Clock Has Become

The finished device still wakes up as a clock. That sounds modest after discussing custom protocols and synchronized animations, but it is the outcome I protected throughout the project.

It keeps time from its RTC when disconnected. Its timezone logic works without SNTP. Its physical buttons still matter. Matrix and Pac-Man remain available without a computer. Clock faces, AM/PM indicators, sun and moon animations, static six-panel scenes, and uploaded GIFs all live within one local display system. A USB host can temporarily take control without becoming a permanent dependency.

Networking is disabled in the supported build. The automatic screensaver is also disabled because a decorative effect should not unexpectedly replace the device's primary function. Matrix appears when selected, not because an idle timer decided the time was less important.

The result is no longer just a clock with a custom face. It is a small ambient display that happens to have an excellent default state.

Where I Want to Take It Next

The next work is less about adding another animation and more about deciding how events compete for the displays.

A useful alert needs a preset, a duration, a priority, an expiration time, and an explicit return state. A failed deployment may deserve to interrupt a decorative animation. A stale calendar reminder should probably be discarded instead of appearing ten minutes late. Two events arriving together need arbitration rather than whichever serial write wins last.

The same model can support an idle summary pane for a timer, the next calendar event, system health, or a background job. Weather can return as host-supplied state without restoring an embedded weather client or internet dependency. Seasonal clock faces can change on a host schedule while still allowing manual selection to take precedence.

The broader goal is a small event bridge. External services normalize their events into deterministic clock commands; the firmware remains focused on time, state, pixels, and recovery.

Taking Ownership Without Erasing the Foundation

The most important part of this project was not making an ESP32 draw a GIF.

It was recognizing which parts of the existing product deserved to survive.

The manufacturer's firmware proved what the consumer device could do and gave me a recoverable baseline. The open-source community supplied the hardware knowledge the manufacturer did not publish. My fork kept that foundation, replaced its network assumptions, built a local control plane, and extended the display model without giving the clock a permanent host dependency.

This is the kind of open-source divergence I find most interesting. The upstream project did not need to be wrong for my product goals to be different. Its display and board support became the stable layer beneath a new set of decisions.

The result is still a clock.

It just no longer belongs entirely to the firmware it shipped with.

Taking it off the network answered the immediate engineering problem. It also left me with a larger one: how many other consumer devices receive network trust simply because their setup screens ask for it?

That is where the next story begins.

The Hidden Backdoors Inside Millions of Smart Devices | WSJ

Sources:

You've successfully subscribed to Amitk.io
Great! Next, complete checkout for full access to Amitk.io
Welcome back! You've successfully signed in.
Unable to sign you in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.