The Problem: Losing Track of Time
How often do you find yourself spending time without realizing how quickly it passes? We are surrounded by distractions, and before we know it, valuable moments are gone. I realized that I was not being conscious enough about how I used my time. I wanted a simple way to stay aware of time without constantly checking a screen. The solution was simple: a clock that speaks the time.
The Hidden Concept: A Living Async Tutor
While the device itself is a practical daily tool, I built it with a secondary, hidden purpose: to create a real-world tutorial for MicroPython's async/await.
Learning programming syntax from a textbook can be boring. But when you try to control hardware—syncing Wi-Fi, animating LEDs, playing audio, and scheduling alarms all at once—you quickly realize why asynchronous programming is essential.
This project is designed to show beginners exactly why and how to use async/await in embedded systems instead of relying on blocking functions like MicroPython's time.sleep() or Arduino's delay(), which prevent other tasks from running while they wait.
To make everything run smoothly on a single CPU thread without freezing, I divided the system into independent tasks.
If you use a standard delay(), playing a 3-second audio file means your device cannot check the physical button or update LEDs for 3 seconds.
By using asyncio, we solve this beautifully.
Here is a simplified pseudo-code showing the core architecture of this project. Notice how await asyncio.sleep() allows each task to "yield" control so other tasks can run concurrently:
import asyncio
import time
start_time = time.time()
def t():
return f"[{time.time() - start_time:.2f}s]"
async def worker(name):
print(f"{t()} Task {name} started")
# 'await' waits for the operation to complete.
# It simply yields control back to the event loop,
# meaning the loop keeps running.
# During this time, other tasks can be executed.
# Since Task A and Task B coexist in the same loop,
# they yield to each other and run concurrently.
await asyncio.sleep(1)
print(f"{t()} Task {name} finished")
async def main():
# Run Task A and Task B concurrently in the same event loop.
# asyncio.gather() is used to wait for multiple tasks to complete.
await asyncio.gather(
worker("A"),
worker("B")
)
asyncio.run(main())Here is the output:
[0.00s] Task A started
[0.00s] Task B started
[1.00s] Task A finished
[1.00s] Task B finishedLook at the timestamps! Even though each task has a 1-second delay, the total execution time is only 1 second, not 2 seconds. This is the exact magic we use to keep our Voice Clock responsive—we can animate the LEDs and listen for button presses while waiting for the audio to finish playing.
Here is my blog post about async/await.
https://note.com/ndenki/n/n04cae3352370
Practical Example of async/awaitThe main loop of the actual code is very simple, with fewer than 10 lines of code.
async def loop():
while True:
now = get_shifted_time(+9)
print(format_iso8601_time(now))
duration = audio_mgr.tick(now)
if duration > 0.0:
asyncio.create_task(led_mgr.rotate_flash(duration))
await asyncio.sleep(0.5)First, let me briefly explain what this code does. The main loop fetches the current time and triggers the audio processing. The audio_mgr.tick returns the duration for which the audio will play. If there is audio playing (duration > 0.0), it creates a background task to light up the LED for that exact duration.
This snippet clearly demonstrates the benefit of async/await.
For example, if we used a synchronous sleep to control the LED, it would block the entire thread. As a result, this 0.5-second main loop would freeze, and we wouldn't even be able to print the current time accurately.
Thanks to asyncio.create_task and await, the LED operation runs concurrently in the background, keeping the main loop ticking smoothly.
In general, using async/await tends to make writing concurrent code much simpler. However, as a quick note, it is still important to choose carefully where and when to use it depending on the use case.
Connect the NeoPixel (7 x RGB LEDs) to the Grove port of the M5Stack Atom VoiceS3R and supply power through USB.
Since building this Voice Clock, I’ve become much more conscious of my time—without ever falling into the smartphone distraction trap. Plus, having a device built on MicroPython means that whenever I want to tweak a feature, change a schedule, or add something new, I can easily modify the code and see the results instantly. It is truly a gadget that grows with my ideas.
Beyond just being a handy daily tool, this project turned into exactly what I hoped for: a living, breathing example of MicroPython's async/await. Learning complex programming concepts from a textbook can be dry, but when you apply them to a practical, real-world project, the learning process becomes incredibly enjoyable.
I hope this project not only helps you manage your time better but also makes learning asynchronous programming a lot more fun.
This project also reminded me that asynchronous thinking is not just a programming concept. In both the real world and software, handling everything synchronously becomes difficult when multiple things need to happen at the same time. Learning how to manage timing, interruptions, and multiple processes is a challenge in both areas. Perhaps that is why async/await is such an interesting approach—it offers one way to think about and handle the complexity of timing and multitasking that we encounter every day.






Comments