Inspiration
This project was born from two desires. One, I'm sick of having to pull out my phone to operate workout timers. They technically work, but reaching for my phone means looking at my phone, and looking at my phone means thirty seconds of rest becomes a four-minute scroll through who-knows-what. I wanted a single-purpose gadget had one button so I could always use it correctly even when exhausted.
Two, I had a Particle Argon (an nRF52840 board) gathering dust ever since I got it on a massive sale. It came with a breakout board and a bunch of hardware add-ons that could be connected quickly with Grove connector cables, so I figured I'd see if CircuitPython could give it a second life as something gloriously, defiantly offline.
Spoiler: it can, and the result is perhaps one of the least connected IoT devices I've ever built. Which, after the gym-phone problem above, is exactly the point. ;-)
Materials
-
Particle Argon (nRF52840)
- Salvaged from my own drawer of abandoned IoT ambitions
-
Grove Shield for Particle Mesh (this would also work well if you don't like Grove connectors)
- Saves me a breadboard and a fistful of jumper wires
-
TM1637 4-digit 7-segment display
- From the parts bin; the cheap red clock-style module
-
Piezo buzzer
- Parts bin
-
Potentiometer
- Parts bin
-
Momentary push button
- Parts bin
-
Lipo Battery
- From a junk swap table at a conference
-
Enclosure
- LOLOL, enclosure? Nah, I just wrapped the thing in some electrical tape
Salvage percentage ~10%. I'll be honest, this is my *least* salvage-y build (I did by everything but the battery at some point), but a useful gym gadget built in under an hour from an orphaned dev board and the contents of a parts bin?! Cool!
A dumb timer is a good timer
Let me get ahead of the obvious question: why a whole nRF52840 for a countdown timer? Easy, because I already had it, and because the whole appeal is that it does one thing. No Wi-Fi, no app, no account, no firmware-update nag, no notification ever. You turn a knob to dial in a rest interval, press a button, and it counts down. When time's up, it beeps three times. That's the entire feature set, and I love it for that. Also, this project was an exercise in rapid development. I wanted to spend as little time on this as possible so I grabed something that would require zero soldering and could be one-shot vibe coded.
The interface is exactly two controls:
-
The knob (potentiometer) picks the rest interval, anywhere from 0:15 to 2:00 in 5-second steps, and shows it on the display as
MM:SSwhile you're idle. - The button starts the countdown. Press it again mid-countdown to pause/unpause (the display blinks while paused so you know it's not just frozen). Need to bail entirely? Tap the board's RESET.
When it hits 0:00, the buzzer fires three short beeps over about a second and it drops back to idle, ready for the next set.
Bit-banging the TM1637 (it looks like I2C, but it lies)
The one genuinely fiddly bit is the display. The TM1637 has two data pins labeled CLK and DIO, which makes it look exactly like an I2C device, and it is absolutely not I2C. It's a custom two-wire protocol with its own timing, and if you try to talk to it with a hardware I2C peripheral you'll get nothing but confusion.
If you're also using this part, check out the tiny, self-contained driver (tm1637.py) that bit-bangs the protocol directly with two GPIO pins, no external library to install. The upside of doing it by hand is there's nothing to go wrong in a dependency you didn't write; the downside is you get to learn the TM1637's data sheet whether you wanted to or not. (You didn't want to. It's fine.)
Setup Challenges
Honestly, the hardware is forgiving, but here are the things most likely to trip you up. The hardest part is getting CircuitPython onto a Particle board (if you're using one of those):
Getting CircuitPython onto the Argon (pain in the... advanced)
The Argon doesn't ship with CircuitPython, it ships expecting Particle's world. You have to use a special programmer dongle like a J-LINK (edu is fine) to flash the CircuitPython .uf2 bootloader. (See this Adafruit guide) Annoying, but from then on it shows up as a CIRCUITPY USB drive. The lovely part: this whole project uses only built-in modules (board, analogio, pwmio, keypad, digitalio), there's no lib/ folder to populate. Deploy = drag three files onto the drive. The board reboots and runs. That's it.
"My digits are scrambled" (beginner)
You wired CLK and DIO backwards. Swap A4 and A5. I left a comment in the code pointing right at this because I guarantee somebody (possibly future-me) does it.
"The buzzer is silent" / "the knob does nothing" (beginner)
Pin assignments live in one CONFIG block at the top of code.py. If the buzzer's quiet, try moving it from D4 to D5; if the pot's unresponsive, try A1 instead of A0. Two-line fixes.
"The button is backwards" (intermediate)
This one's sneaky. A plain push button wired between the pin and ground reads LOW when pressed (internal pull-up). But some Grove button modules drive the line HIGH when pressed, the opposite polarity. So instead of hard-coding it, the firmware reads a BUTTON_ACTIVE_HIGH flag from settings.toml. If your button does the opposite of what you expect, flip that flag instead of rewiring anything.
Code
The whole program is a small, readable state machine, IDLE → RUNNING → PAUSED, that fits comfortably on one screen. Here it is in full, because half the joy of a project this size is that you can show the whole thing:
# code.py ## Workout Rest-Interval Helper (CircuitPython, Particle Argon)
import time
import os
import board
import analogio
import pwmio
import keypad
from tm1637 import TM1637
# CONFIG — adjust pins here if your wiring differs
CLK_PIN = board.A4 # TM1637 clock
DIO_PIN = board.A5 # TM1637 data (swap A4/A5 if digits look scrambled)
POT_PIN = board.A0 # potentiometer wiper (try board.A1 if no response)
BUZZER_PIN = board.D4 # piezo buzzer (try board.D5 if silent)
BUTTON_PIN = board.D2 # push button
# Some Grove button modules drive the line HIGH when pressed — flip this if so.
BUTTON_ACTIVE_HIGH = os.getenv("BUTTON_ACTIVE_HIGH", "0") == "1"
# Interval range: 0:15 ... 2:00 in 5s steps
MIN_SECONDS = 15
MAX_SECONDS = 120
STEP_SECONDS = 5
NUM_STEPS = (MAX_SECONDS - MIN_SECONDS) // STEP_SECONDS
BRIGHTNESS = int(os.getenv("DISPLAY_BRIGHTNESS", "5")) # 0-7
BEEP_HZ = int(os.getenv("BEEP_HZ", "2300"))
display = TM1637(CLK_PIN, DIO_PIN, brightness=BRIGHTNESS)
pot = analogio.AnalogIn(POT_PIN)
buzzer = pwmio.PWMOut(BUZZER_PIN, frequency=BEEP_HZ, duty_cycle=0,
variable_frequency=True)
keys = keypad.Keys((BUTTON_PIN,), value_when_pressed=BUTTON_ACTIVE_HIGH,
pull=True)
def read_selected_seconds():
"""Average a few ADC samples and quantise to the nearest 5s step."""
total = 0
for _ in range(8):
total += pot.value
raw = total // 8
step = (raw * NUM_STEPS + 32768) // 65535 # round to nearest step
step = max(0, min(NUM_STEPS, step))
return MIN_SECONDS + step * STEP_SECONDS
def beep_done():
"""Three short beeps spanning ~1 second."""
for i in range(3):
buzzer.frequency = BEEP_HZ
buzzer.duty_cycle = 32768 # 50% -> tone on
time.sleep(0.15)
buzzer.duty_cycle = 0 # tone off
if i < 2:
time.sleep(0.20)
IDLE, RUNNING, PAUSED = range(3)
state = IDLE
remaining = 0
last_tick = 0.0
colon = True
last_pause_blink = 0.0
pause_visible = True
print("Workout helper ready. Turn the pot to set a rest interval, press to start.")
while True:
event = keys.events.get()
pressed = bool(event and event.pressed)
now = time.monotonic()
if state == IDLE:
display.show_time(read_selected_seconds(), colon=True)
if pressed:
remaining = read_selected_seconds()
last_tick = now
colon = True
display.show_time(remaining, colon=colon)
state = RUNNING
elif state == RUNNING:
if now - last_tick >= 1.0:
last_tick += 1.0
remaining -= 1
colon = not colon # blink the colon each second
if remaining <= 0:
display.show_time(0, colon=True)
beep_done()
state = IDLE
continue
display.show_time(remaining, colon=colon)
if pressed:
display.show_time(remaining, colon=True) # steady colon = paused
last_pause_blink = now
pause_visible = True
state = PAUSED
elif state == PAUSED:
if now - last_pause_blink >= 0.5:
last_pause_blink = now
pause_visible = not pause_visible
if pause_visible:
display.show_time(remaining, colon=True)
else:
display.clear()
if pressed:
display.show_time(remaining, colon=True)
last_tick = now # resume cleanly
state = RUNNING
time.sleep(0.01)
A couple of small touches I'm fond of: the knob reading is the average of 8 ADC samples so the displayed time doesn't jitter while your hand hovers near a step boundary, and the colon blinks once per second while running — a subtle "yes, it's actually counting" heartbeat that costs nothing.
Other Code Files (tm1637.py and settings.toml)
# tm1637.py — minimal bit-banged driver for the TM1637 4-digit 7-segment display.
#
# The TM1637 uses a 2-wire protocol that *looks* like I2C but is not: there is
# no device address and the data line is read on a fixed clock edge. So we
# bit-bang it on two ordinary GPIO pins (CLK + DIO) with digitalio.
#
# Adapted for CircuitPython from the widely used MicroPython tm1637 driver
# (Mike Causer, MIT). Push-pull outputs are used and the ACK bit is clocked but
# ignored, which is reliable on the Grove TM1637 modules (they have pull-ups).
import time
from digitalio import DigitalInOut, Direction
# Command bytes
_CMD_DATA = const(0x40) # data command: automatic address increment
_CMD_ADDR = const(0xC0) # address command: 0xC0 | position (0-3)
_CMD_DISP = const(0x80) # display control: 0x80 | on-bit | brightness
_DISP_ON = const(0x08) # display-on bit (OR with brightness 0-7)
_COLON = const(0x80) # high bit of digit 1 drives the center colon
# 7-segment patterns for 0-9 then A-F (gfedcba bit order)
_SEGMENTS = (
0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07,
0x7F, 0x6F, 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71,
)
# Short delay so we stay under the TM1637's ~250 kHz clock limit. The call
# overhead alone is usually enough, but the explicit sleep keeps it safe.
def _tick():
time.sleep(5e-6)
class TM1637:
def __init__(self, clk, dio, brightness=5):
self.clk = DigitalInOut(clk)
self.dio = DigitalInOut(dio)
self.clk.direction = Direction.OUTPUT
self.dio.direction = Direction.OUTPUT
self.clk.value = True
self.dio.value = True
self.brightness = max(0, min(7, brightness))
self.clear()
# --- low-level protocol ---------------------------------------------
def _start(self):
self.dio.value = False
_tick()
self.clk.value = False
_tick()
def _stop(self):
self.clk.value = False
_tick()
self.dio.value = False
_tick()
self.clk.value = True
_tick()
self.dio.value = True
_tick()
def _write_byte(self, b):
for _ in range(8):
self.clk.value = False
_tick()
self.dio.value = bool(b & 1)
_tick()
self.clk.value = True
_tick()
b >>= 1
# 9th clock = ACK (we clock it but don't read it back)
self.clk.value = False
_tick()
self.clk.value = True
_tick()
self.clk.value = False
_tick()
# --- public API ------------------------------------------------------
def write(self, segments, pos=0):
"""Write raw segment bytes (a list of up to 4 ints) starting at pos."""
self._start()
self._write_byte(_CMD_DATA)
self._stop()
self._start()
self._write_byte(_CMD_ADDR | (pos & 0x03))
for seg in segments:
self._write_byte(seg)
self._stop()
self._start()
self._write_byte(_CMD_DISP | _DISP_ON | self.brightness)
self._stop()
def clear(self):
self.write([0, 0, 0, 0])
def show_time(self, total_seconds, colon=True):
"""Render seconds as MM:SS (zero-padded) across the four digits."""
if total_seconds < 0:
total_seconds = 0
mm = total_seconds // 60
ss = total_seconds % 60
segs = [
_SEGMENTS[(mm // 10) % 10],
_SEGMENTS[mm % 10] | (_COLON if colon else 0),
_SEGMENTS[(ss // 10) % 10],
_SEGMENTS[ss % 10],
]
self.write(segs)
# Workout helper runtime configuration.
# Read at boot with os.getenv("KEY", "default"); all keys are optional.
DISPLAY_BRIGHTNESS = "5" # 0 (dim) .. 7 (brightest)
BEEP_HZ = "2300" # buzzer tone frequency in Hz
# Set to "1" only if your button drives the line HIGH when pressed
# (e.g. a Grove button module). Leave at "0" for a button wired to GND.
BUTTON_ACTIVE_HIGH = "0"
End Result
It's not winning any industrial-design awards, but it's earned a permanent spot in my gym bag. Dial in the rest, do your set, slap the button, repeat. And my phone stays in my pocket the entire time, which was the whole point.
If you've got an orphaned dev board and a parts bin, CircuitPython is a great way to resurrect it as something single-purpose and genuinely useful. No cloud, no app, no account. Just a knob, a button, and three little beeps. Sometimes that's the entire spec, and it's a great one.
This page (Workout Rest Helper) was last updated on June 26, 2026.
Text editor powered by tinymce.