An orientation-aware digital hourglass built with an ESP32-C3, two daisy-chained MAX7219 8x8 LED matrices, and an MPU6050 accelerometer. The 128 LEDs form two logical diamond-shaped chambers joined by a one-cell neck; each illuminated LED represents one grain of sand.
The firmware provides two modes:
- Hourglass — sand responds to the device's physical orientation in real time.
- Minute timer — a fixed-orientation, repeating 60-second timer that meters one grain through the neck per second and counts completed minutes.
The project targets the PlatformIO Arduino framework and includes a Wokwi circuit for simulation. The enclosure and original hardware concept come from Electronic Hourglass Timer by 意昂老师的模型之家 (@paradise); the firmware in this repository is an independent, from-scratch implementation.
- What is new in this version?
- Hardware
- Power system and Li-ion safety
- Wiring
- Assembly notes
- Interface and modes
- Build, upload, and simulation
- How the sand simulation works
- Credits, licenses, and reuse
This repository reuses the physical project idea, not its firmware. The software was rewritten from scratch around a new particle simulation and currently provides two modes:
- Sand clock simulator: 60 persistent particles respond to measured gravity, with momentum, damping, collisions, and a configurable angle of repose.
- Minute count: one grain is metered through the neck per second; every completed 60-grain cycle increments the displayed minute count.
Mode changes are gesture-controlled, the main loop is non-blocking, and display/sensor mounting corrections live in one hardware configuration file.
- 60 independently tracked grains on a 128-cell logical grid
- Gravity derived from the MPU6050 accelerometer
- Momentum, damping, collisions, and an angle-of-repose threshold
- Randomized update order to prevent persistent directional bias
- Exact repeating one-minute schedule and animated chamber reset
- Gesture-only mode switching
- Configurable display rotation, module order, sensor correction, brightness, and timing
- Upright fallback gravity if the MPU6050 is unavailable
The MakerWorld enclosure was designed around the compact modules below. Before ordering, compare the seller's dimension drawings and connector locations with the enclosure files—the name printed in a listing is not enough to guarantee a mechanical fit.
| Qty | Part | Specification / role | Notes |
|---|---|---|---|
| 1 | ESP32-C3 Mini development board | Main controller | The original build specifies a compact “ESP32 C3 Mini.” This PlatformIO project targets esp32-c3-devkitm-1; verify the pin labels and physical outline if substituting another C3 board. |
| 2 | MAX7219 8×8 LED matrix module | 128 display pixels | FC-16-style modules with DIN, CLK, CS, DOUT, VCC, and GND; the modules are daisy-chained. |
| 1 | GY-521 / MPU6050 module | 3-axis accelerometer used for gravity and gestures | The firmware uses acceleration only; gyro and temperature data are unused. Default I²C address is 0x68. |
| 1 | TP4056 USB-C charger/protection module | Charges one 3.7 V Li-ion/LiPo cell | Use the protected-board variant with B+, B−, OUT+, and OUT−; confirm charge-current configuration is suitable for the chosen cell. |
| 1 | Li-ion/LiPo pouch cell | Nominal 3.7 V, approximately 8 × 25 × 35 mm | Capacity varies by supplier. Use only an undamaged cell with known polarity and protection appropriate to the build. |
| 1 | 3-pin, 2-position slide switch | Main power switch | Confirm body, actuator, and mounting dimensions against the printed enclosure. Only the common and one switched terminal are normally required. |
| 4 | M3 × 15 mm flat-head screws | Final enclosure assembly | Use the head style specified by the original designer so the screws seat correctly. |
| — | Hook-up wire, solder, insulation | Internal interconnects | Flexible, appropriately sized wire; heat-shrink or equivalent insulation is strongly recommended. |
| 1 | 3D-printed enclosure set | Mechanical body | Obtain the licensed files and print profile directly from the original MakerWorld model. The published profile is 0.2 mm layers, 2 walls, and 15% infill. |
- MAX7219: the IC is specified for a 4.0–5.5 V supply. Its data-sheet logic-high threshold is 3.5 V, so a 3.3 V ESP32 signal is outside the guaranteed high-level specification even though many hobby modules work directly. If the display is unreliable, add a 3.3-to-5 V logic-level buffer on
DIN,CLK, andCS. See the MAX7219 data sheet. - GY-521: common GY-521 breakouts include an onboard regulator and I²C pull-ups, but clone layouts vary. This build powers it from 3.3 V so its I²C lines are safe for the ESP32-C3. Never assume an unknown breakout's pull-ups are 3.3 V-safe when powered from 5 V.
- ESP32-C3: GPIO8 and GPIO9 are strapping pins on the official DevKitM-1, and GPIO8 is also connected to its onboard RGB LED. The present hardware has been tested with this mapping, but external circuits must not force invalid levels during reset. See Espressif's ESP32-C3-DevKitM-1 guide.
The source build includes a TP4056 USB-C charger/protection board and a single-cell Li-ion/LiPo battery. Treat this part of the assembly as a real battery-powered product, not as breadboard-only wiring.
Important: a TP4056 is a single-cell linear charger; it is not a 5 V boost converter and it is not a complete load-sharing/power-path controller. A Li-ion cell varies from roughly 3 V to 4.2 V, while the MAX7219 is specified from 4.0 V. The original compact build may operate directly over part of the battery range, but brightness and stability are not guaranteed across the full discharge curve. For a robust portable build, use a correctly rated 5 V boost/power-path solution and validate the complete power budget.
- Connect the cell only to the charger's battery terminals (
B+/B−) and the load only to the protected output (OUT+/OUT−) when those terminals are present. - Put the slide switch in series with the load, not in a way that disconnects the cell from its protection circuit while charging.
- Do not charge an unknown, swollen, punctured, hot, or reverse-connected cell.
- Generic “1 A” TP4056 boards may charge too aggressively for a small pouch cell. Confirm the cell manufacturer's maximum charge current and change the module's programming resistor or use a lower-current charger when necessary.
- Do not power the development board simultaneously from USB and an externally driven power pin unless the exact board provides documented source isolation. Espressif lists USB, 5 V pin, and 3.3 V pin powering as mutually exclusive options for the DevKitM-1.
- Insulate every exposed joint, add strain relief, and test current draw and temperatures before closing the printed enclosure.
The table below is authoritative for this firmware and matches include/HardwareConfig.h and diagram.json.
| Peripheral | Signal | ESP32-C3 |
|---|---|---|
| MAX7219 chain | DIN | GPIO 8 |
| MAX7219 chain | CLK | GPIO 9 |
| MAX7219 chain | CS | GPIO 10 |
| MPU6050 | SDA | GPIO 4 |
| MPU6050 | SCL | GPIO 5 |
The upper MAX7219 is first in the chain. Connect its DOUT to the lower module's DIN; both modules share CLK and CS. The matrices use 5 V in the Wokwi/reference wiring, while the MPU6050 uses 3.3 V. All grounds must be common.
flowchart LR
ESP[ESP32-C3] -->|GPIO 8 → DIN| TOP[MAX7219 upper]
ESP -->|GPIO 9 → CLK| TOP
ESP -->|GPIO 10 → CS| TOP
TOP -->|DOUT → DIN| BOTTOM[MAX7219 lower]
ESP -->|shared CLK + CS| BOTTOM
ESP -->|GPIO 4 → SDA| IMU[GY-521 / MPU6050]
ESP -->|GPIO 5 → SCL| IMU
P5[regulated 5 V] --> TOP
P5 --> BOTTOM
P33[3.3 V] --> IMU
GND[common ground] --- ESP
GND --- TOP
GND --- BOTTOM
GND --- IMU
Verify power and logic-level requirements for the exact modules used. Do not draw more matrix current through the development board than it is designed to supply.
- Connect ESP32 GPIO8 to only the first/upper matrix
DIN. - Connect first/upper matrix
DOUTto second/lower matrixDIN. - Wire
CLKandCSto both modules in parallel. - Wire both matrix
VCCandGNDin parallel with short, adequately sized conductors. - Power the MPU6050 from 3.3 V and connect SDA/SCL without swapping them.
- Check continuity and polarity before fitting the battery.
- Download and print the enclosure through the original MakerWorld page. Its Standard Digital File License applies to those files; this repository intentionally does not redistribute them.
- Dry-fit the two matrices, ESP32-C3 board, GY-521, TP4056, switch, and battery before soldering. Module revisions can move connectors by several millimetres.
- Orient the two matrices and MPU6050 exactly as shown in the original assembly guide. Orientation matters: this firmware's tested calibration assumes both displays are rotated 90° clockwise relative to the library's native mapping, and the MPU vector is rotated 180° into logical grid coordinates.
- Keep the first matrix physically nearest the ESP32 in the chain and place it in the upper chamber.
- Route wires away from screw bosses, the switch actuator, sharp printed edges, and the LiPo pouch. Do not compress the cell between enclosure halves.
- Bench-test USB power, sensor response, both matrix modules, mode switching, and charging before final closure.
- Close the enclosure with the four M3×15 flat-head screws; tighten only enough to seat the parts without cracking the print.
The device starts in mode 1 and cycles Hourglass → Minute timer → Hourglass.
- Place the device flat, display-up, for at least 1.2 seconds.
- Two short flashes confirm the change.
- The new mode number (
1or2) appears for 800 ms. - Return the device to its normal vertical position.
The gesture cannot repeat until the device remains vertical for 500 ms. “Flat” means |az| > 8 m/s² and in-plane acceleration below 4 m/s²; “vertical” uses the inverse thresholds. A missing accelerometer disables mode switching.
All 60 grains begin in the upper chamber. Every 160 ms, each grain can move to one free neighbouring cell under the latest in-plane gravity vector. Rotating the enclosure changes gravity, allowing the sand to reverse or slide sideways.
The MPU6050 is sampled every 40 ms. Its (ax, ay) vector is rotated into the display's logical coordinates according to HardwareConfig.h, then normalized:
m = sqrt(ax² + ay²)
g = (ax / m, ay / m)
For m < 0.25 m/s², the firmware substitutes upright gravity g = (0, 1). It uses the same fallback continuously if sensor initialization fails. See Main simulation model for the complete algorithm.
This is a repeating 60-second timer. It ignores physical orientation and uses fixed logical gravity g = (0, 1).
- Normal particle physics continues every 160 ms.
- The neck is metered so that after
Nseconds, no more thanNgrains have crossed. - During the final 6 seconds, the upper display shows the minute about to complete while preserving remaining sand pixels.
- At exactly 60 seconds, the counter advances and the next minute begins.
- A 1.6-second animation clears the lower chamber from neck to base and restores the upper chamber from neck to top across 15 diagonal lines.
- The next minute is already running during this animation, keeping boundaries exactly 60 seconds apart.
- The minute counter resets whenever this mode is selected.
- Numbers wrap modulo 100 because the renderer supports two digits.
At a physics tick, the cumulative crossing allowance is
B(t) = min(60, 1 + floor(60t / 60000))
where t is milliseconds elapsed in the current minute. Only the unused allowance is passed to the simulation. This meters the neck without replacing physical movement elsewhere.
This is a discrete grid simulation inspired by acceleration, damping, momentum, and granular angle of repose—not a continuous rigid-body solver. Values are updated once per 160 ms tick, and a grain moves by at most one grid edge per tick.
For matrix-local row r and column c, the logical diamond coordinate is
x = c - r
y = c + r + offset
offset = -15 for the upper matrix
offset = 1 for the lower matrix
This logically rotates each square matrix by 45 degrees. The neck connects upper cell (0, -1) to lower cell (0, 1) and is the only route between chambers.
Candidates include diagonal offsets (±1, ±1) and straight offsets (±2, 0) and (0, ±2) where the intermediate grid geometry exists. A candidate must exist and be unoccupied.
Upper cells are sorted by logical y, then x, and the first 60 are filled. All particles start with zero velocity and residual displacement. Four empty upper cells leave room for immediate movement.
Each particle's velocity is updated per tick using normalized gravity:
vx' = (vx + gx × 0.22) × 0.72
vy' = (vy + gy × 0.22) × 0.72
0.22 is acceleration scale and 0.72 is damping/friction. They are visual tuning constants, not SI coefficients. Updated velocity is also accumulated in residual displacement fields. Those fields are adjusted after movement, but current neighbour selection uses gravity and velocity alignment rather than a residual threshold.
For candidate displacement d = (dx, dy), gravity alignment is
Agravity = (d · g) / |d|
= (dx × gx + dy × gy) / sqrt(dx² + dy²)
Since g is normalized, this is cosine-like directional alignment. The candidate is accepted only above a speed-dependent threshold:
|v| = sqrt(vx'² + vy'²)
minimum alignment = 0.32 when |v| < 0.30
0.12 when |v| ≥ 0.30
The higher static threshold prevents settled sand from shuffling sideways and filters small sensor fluctuations. The lower moving threshold maintains a continuous fall. These correspond approximately to maximum deviations of acos(0.32) ≈ 71.3° and acos(0.12) ≈ 83.1° from gravity; grid shape and occupied cells add further constraints.
For a moving particle, velocity alignment is
Avelocity = (d · v') / (|d| |v'|)
Each valid candidate receives
score = 10 × Agravity + 2 × Avelocity + ε
where ε is a random tie-breaker of approximately [-0.001, +0.001]. Gravity dominates, while momentum favours continuous trajectories. The highest-scoring candidate wins.
The 60 particle indices are shuffled every tick so the same particles do not always claim empty cells first. Occupancy changes immediately after a move, and later particles see the new state. Only one particle can occupy a cell.
If no candidate is valid, bounce is 0.0: velocity and residual displacement reset to zero, modelling an inelastic stop. SandSimulation::step() can limit crossings of the special neck edge. Hourglass mode has no practical limit; timer mode supplies its remaining allowance.
- Shuffle all particle indices.
- Apply gravity acceleration and damping.
- Enumerate empty neighbours.
- Apply any neck-crossing limit.
- Reject candidates below the repose threshold.
- Score survivors using gravity, momentum, and a tiny random tie-breaker.
- Move to the best cell, or stop if none qualifies.
- Render the 128-cell occupancy map.
MatrixDisplay maps logical occupancy back to MAX7219 pixels. The supplied physical calibration is:
- Native daisy-chain order
- Each matrix rotated 90° clockwise
- No extra row or column reflection
- MPU vector rotated 180° into grid coordinates
- Intensity
4on the0..15range
If mounting or wiring changes, adjust include/HardwareConfig.h rather than rewriting physics code.
| Component | Responsibility |
|---|---|
src/main.cpp |
Initialization, scheduling, mode selection, and feedback |
HourglassGrid |
Diamond coordinates, chamber topology, and neighbours |
SandSimulation |
Particles, equations, candidate scoring, collisions, and neck limits |
OrientationSensor |
MPU6050 setup, mounting rotation, and normalized gravity |
MatrixDisplay |
MAX7219 mapping, flashes, and 3x5 number rendering |
ModeGesture |
Flat-hold gesture and vertical re-arm state machine |
MinuteTimerMode |
60-second schedule, crossing budget, count, and reset animation |
HardwareConfig.h |
Pins, mounting corrections, intensity, grain count, and tick period |
The main loop is non-blocking after startup and schedules work with millis(). Mode-change feedback temporarily pauses normal rendering; minute mode catches up against its absolute time base afterward.
Key constants in include/HardwareConfig.h are:
| Constant | Default | Purpose |
|---|---|---|
kMatrixDataPin |
8 | MAX7219 DIN |
kMatrixClockPin |
9 | MAX7219 CLK |
kMatrixChipSelectPin |
10 | MAX7219 CS |
kI2cSdaPin |
4 | MPU6050 SDA |
kI2cSclPin |
5 | MPU6050 SCL |
kMpu6050Address |
0x68 |
I²C address |
kDisplayIntensity |
4 | Brightness, 0..15 |
kPhysicsPeriodMs |
160 ms | Physics interval |
kParticleCount |
60 | Active grains |
SandSimulation currently allocates exactly 60 particle records. If changing kParticleCount, update that capacity too.
Install PlatformIO, then run:
pio run
pio run --target upload
pio device monitorThe commands build, upload, and open the 115200-baud monitor respectively. PlatformIO installs MD_MAX72XX 3.5.x, Adafruit MPU6050 2.2.x, and Adafruit Unified Sensor 1.1.x. Startup status and mode changes appear over serial.
Build first so the artifacts referenced by wokwi.toml exist:
pio runThen launch with the Wokwi VS Code extension or an authenticated CLI:
wokwi-cli .The circuit is in diagram.json. The method used to manipulate the simulated MPU6050 depends on the Wokwi interface or automation in use.
| Activity | Interval/duration |
|---|---|
| Sensor sampling and gesture update | 40 ms |
| Particle physics | 160 ms |
| Minute animation rendering cap | 32 ms |
| Flat hold to change mode | 1,200 ms |
| Vertical hold to re-arm | 500 ms |
| Feedback flashes | 2 × 120 ms on, with 120 ms off intervals |
| Mode number | 800 ms |
| Timer period | 60,000 ms |
| Minute-number lead | 6,000 ms |
| Chamber reset | 1,600 ms |
- Gyroscope and temperature readings are not used.
- Gravity magnitude is normalized: tilt changes direction, not fall speed.
- A grain moves at most one grid edge per tick.
- The number display wraps modulo 100.
- Mode switching requires a valid MPU6050.
- Timing uses the ESP32 runtime clock, not a battery-backed real-time clock.
- Software and documentation: Copyright © 2026 Juanjov (
jvalver1). The original firmware, simulation, documentation, and repository-owned media are released under the permissive MIT License. You may use, modify, distribute, sublicense, and sell them provided the copyright and license notice are retained. - Original enclosure and hardware concept: Electronic Hourglass Timer by 意昂老师的模型之家 (
@paradise). The physical design, bill of materials, assembly guide, and original project concept deserve full credit to their creator. - Enclosure/CAD license: MakerWorld identifies the creator's files as covered by its Standard Digital File License. Obtain them from MakerWorld and follow that license. They are not part of this repository, and this repository does not grant rights to them.
- Firmware: written from scratch for this project. It does not contain or claim authorship of the original creator's firmware.
- Libraries: MD_MAX72XX, Adafruit MPU6050, and Adafruit Unified Sensor retain their respective licenses.
The MIT License applies only to material owned by this repository's software author. It does not replace or weaken the MakerWorld creator's license for the enclosure/CAD files, nor the licenses of third-party libraries. See NOTICE for the concise attribution record.
