There are plenty of countdown projects on Learn but I wanted my own custom, minimalist one. It simply shows the remaining time until a target moment, in the form of weeks, days, and hours, updated about once per hour.
Along the way I learned a few things:
- How to take an Adafruit PCB design in Eagle format and import the board outline all the way into FreeCAD for locating dimensions and features
- How to make my code robust against transient errors like network errors
- How to make my code deep sleep for the right length of time
There are also a few things this code demonstrates that are less common knowledge:
- Using fonts from the font bundle
- Using the datetime module for arithmetic on dates & times
- Increasing reliability by re-trying operations that can fail
- Reducing battery usage by deep sleeping and avoiding connecting to WiFi
This project uses FreeCAD & KiCAD, both of which are Open Source software that are free to download & use.
Parts Needed
The code should adapt to other CircuitPython boards that support WiFi, but this is the one I used.
The code & 3D printed stand are specific to this eInk display
The stand design requires a right angle USB "C" connector at the Feather end. A regular cable won't fit.
I don't yet have a sense of how long this battery lasts between charges, but it does fit in the space between the Feather & eInk display.
M2.5 screws are used for mounting the electronics to the stand. Adafruit has a choice of black or white, pick whatever will go best with your color scheme (or use fasteners you already have!)
Stacking these between the feather & display leaves room for the battery
Code & Installation
I recommend using Adafruit circup to install the needed libraries for projects. Here's what you need to do:
- Install circup on your desktop computer
- Enable the "fonts bundle" by running this command (just once, circup remembers this setting):
circup bundle-add adafruit/circuitpython-fonts
- Copy code.py to your CIRCUITPY drive (Download it via the "raw" link at https://gist.github.com/jepler/b2c020a6caa65a31297053b7216fcc15)
- Run the following command to auto-install required libraries:
circup install -a
You'll also want to configure wifi on your device using settings.toml. For lower power usage, configure WIFI_SSID and WIFI_PASSWORD options. For web workflow but higher power usage, configure CIRCUITPY_WIFI_SSID and CIRCUITPY_WIFI_PASSWORD options.
import os
import math
import sys
import time
import alarm
import board
import displayio
import socketpool
import rtc
import wifi
from fourwire import FourWire
import adafruit_datetime
import adafruit_ntp
import adafruit_ssd1680
import adafruit_lc709203f
from font_orbitron_medium_webfont_54_latin1 import FONT
from adafruit_display_text.bitmap_label import Label
def connect_wifi():
if wifi.radio.ipv4_address is None:
print("connect wifi")
print(os.getenv('WIFI_SSID'), os.getenv('WIFI_PASSWORD'))
for _ in range(5):
try:
wifi.radio.connect(os.getenv('WIFI_SSID'), os.getenv('WIFI_PASSWORD'))
return
except Exception as e:
print(e)
# This pinout works on a Metro M4 and may need to be altered for other boards.
spi = board.SPI() # Uses SCK and MOSI
epd_cs = board.D9
epd_dc = board.D10
epd_reset = None
epd_busy = None
def setup_display():
global display, group
displayio.release_displays()
display_bus = FourWire(
spi, command=epd_dc, chip_select=epd_cs, reset=epd_reset, baudrate=1000000
)
time.sleep(1)
# For issues with display not updating top/bottom rows correctly set colstart to 8
display = adafruit_ssd1680.SSD1680(
display_bus,
height=250,
width=122,
busy_pin=epd_busy,
highlight_color=0x000000,
colstart=0,
rotation=180,
black_bits_inverted=1
)
group = displayio.Group()
display.root_group = group
def main():
battery_monitor = adafruit_lc709203f.LC709203F(board.I2C())
for _ in range(5):
try:
percent = battery_monitor.cell_percent
break
except Exception as e:
print(e)
else:
print("too much battery polling")
percent = 100
if percent < 5: return 86400
target = adafruit_datetime.datetime(2025, 3, 28, 21) # UTC, 4PM local time
sm0 = alarm.sleep_memory[0]
if sm0 == 0:
# Assumes wifi connected via settings.toml before code.py started!
connect_wifi()
pool = socketpool.SocketPool(wifi.radio)
ntp = adafruit_ntp.NTP(pool, tz_offset=0, cache_seconds=3600, socket_timeout=1)
for _ in range(10):
try:
rtc.RTC().datetime = ntp.datetime
alarm.sleep_memory[0] = 24 # Don't set again for about 24h
break
except Exception as e:
print(e)
else:
print("NTP too much")
# Hopefully it's better in a few hundred seconds
return 300
else:
print(f"Don't need to set for {sm0} hours")
alarm.sleep_memory[0] = sm0-1
labels = [
Label(
FONT,
text="",
anchor_point=(0.5, 0.5),
anchored_position=(display.width // 2, display.height * (i + 0.5) // 3),
)
for i in range(4)
]
for l in labels:
group.append(l)
for _ in range(10):
try:
now = adafruit_datetime.datetime.now()
break
except Exception as e:
print(e)
else:
print("NTP too much")
# Hopefully it's better in a few hundred seconds
return 300
delta = target - now
delta = adafruit_datetime.timedelta(
seconds=math.ceil(delta.total_seconds() / 3600) * 3600
)
weeks = delta.days // 7
days = delta.days % 7
hours = delta.seconds // 3600
low_bat = percent < 25
if delta.total_seconds() < 0:
labels[0].text = "GO"
labels[1].text = "GO"
labels[2].text = "GO"
else:
labels[0].text = f"{weeks}"
labels[1].text = f"{days}"
labels[2].text = "bat!" if low_bat else f"{hours}"
t0 = time.time()
display.refresh()
t1 = time.time()
if delta.total_seconds() < 0:
return sys.maxsize
if low_bat:
# Sleep until tomorrow to preserve the battery...
sleep_until = (now + adafruit_datetime.timedelta(seconds=86400)).replace(
hour=6, minute=0, second=0
)
else:
# Sleep a bit into the next hour, it's just fine to be 90 seconds late,
# but avoid waking if somehow we get 90 seconds of clock skew within
# the next hour...
sleep_until = (now + adafruit_datetime.timedelta(seconds=3600)).replace(
minute=1, second=30
)
return (sleep_until - now).total_seconds()
if __name__ == "__main__":
to_sleep = 300
try:
setup_display()
to_sleep = main()
finally:
displayio.release_displays()
print(f"sleeping for {to_sleep} seconds")
alarm.exit_and_deep_sleep_until_alarms(
alarm.time.TimeAlarm(monotonic_time=time.monotonic() + to_sleep)
)
If you're using a battery, solder normal male headers on your Feather. If you're not planning to use a battery, you can use short male feather headers for a slightly slimmer result.
Grab the 3D printing files from printables (the design files are also included!) and print with generic slicer settings in a filament of your choice. I used one of those "hologram" print surfaces, which gives a neat effect—at least until it gets smudged with fingerprints. I also set the first layer infill pattern to "hilbert curve" to give a more organic look to the imperfections of the first layer.
Place the display behind the stand with the ribbon cable at the top, then secure it with the M2.5 screws and nuts. Then, insert the feather in the correct orientation in the display.
If you're using the 100mAh battery, plug it into the feather and tuck it under the pins before plugging the feather into the display. If you do this, the pins will not insert fully into the other board. Make sure you don't push too hard, or you might damage the battery pack. Adding a set of short female headers to the stack (as shown above) ensures clearance for the batteries and allows the connection to be made secure.
It's a bit fiddly but you can power or charge the project with a right angle USB C connector.
Importing PCB Outline in FreeCAD
There's at least one display stand for this display but I had some specific wants:
- Portrait display
- "upside down 7" shape
- PCBs hidden as much as possible behind the stand
- Easy printing with no support required
I went through a couple of iterations of my stand design. Along the way, I learned a process for importing board outlines from Eagle files via KiCAD into FreeCAD, which is something I'll be doing constantly from now on.
PCB Outlines in FreeCAD
Since the eInk Featherwing is an open source design, I started by grabbing the design files from GitHub. Next, I opened KiCAD and imported the Eagle format design file called "Adafruit 2.13in Tri-Color eInk FeatherWing.brd". (yeah, I am using the monochrome version, but this board file seems to be exactly the matching shape). Especially since all I cared about was the board outline, this was a simple process:
- Start KiCAD
- File > Import > Eagle Project...
- Click through the import process, remembering to choose "Auto-Match Layers" when that step arrives
(There are plenty of resources if you want to learn more about importing Eagle boards to KiCAD and then use or modify the board design. This one looks pretty good.)
Now, with the board design in KiCAD, I exported it as a STEP file:
- Export > STEP...
- Select "Board center origin"
- Finish the export process, noting where you saved the STEP file.
Save & close everything in KiCAD, then open up FreeCAD.
- Create a new project
- Using the Import menu, select the STEP file you created in KiCAD. This will become visible in the 3D view in FreeCAD
- Switch to the Part Design workbench
- Create Body and select it
- Select the PCB outline's bottom face
- Create a "Sub-Object Shape(s) Binder" (command in the Part Design pull down menu)
- Hide the PCB file: In the tree view, click the STEP file and then Edit > Toggle Visibility (keyboard shortcut: Space bar)
At this point, you should be left with just the ghostly outline of the PCB sitting on the XY plane. You can reposition it by changing the Placement of the Binder object & you can create as many Binder objects as you like.
As you design the rest of the part, you can use features such as the board edge, mounting holes & thru holes. When you are editing a sketch, add these as external geometry (keyboard shortcut: gx)
In my design, I built the main shape of the bracket in the XZ plane, made a symmetric pad from it big enough to fit the display, and then used the geometry of the PCB to locate the holes & other features which I subtracted using a Pocket. This was highly preferable to (and much less error prone than) taking measurements in KiCAD with the dimension tool & manually entering them in FreeCAD.
My design actually uses a "shape binder", not a "sub-object(s) shape binder". While preparing this tutorial I learned that the latter is preferable, in part because of how you have more control over the placement of the bound shape.
Using fonts from the CircuitPython Font Bundle
This bundle contains hundreds of open source fonts in bitmap format ready to use in CircuitPython with an import statement; since it's a bundle, they can be quickly installed by circup.
I wanted a futuristic look so I picked the "Orbitron" in default weight. Some experimentation determined that the 54 pixel version was right for the display. I didn't need any special characters, just digits, so the "latin1" version of the font (which covers Unicode code points up to 255) was suitable:
from font_orbitron_medium_webfont_54_latin1 import FONT
Later in the program, just use the FONT as an argument to the Label constructor. Here, I create several labels distributed vertically on the screen:
#
labels = [
Label(
FONT,
text="",
anchor_point=(0.5, 0.5),
anchored_position=(display.width // 2, display.height * (i + 0.5) // 3),
)
for i in range(4)
]
Retry Everything!
Everything that communicates with another device, from connecting to WiFi, to getting the battery percentage(!) can fail.
Anything that I identified as failing sometimes I put in a small retry loop. The number of retries is somewhat arbitrary in each case. For instance, I retry checking the battery 5 times and treat it as 100% full if I can't get a reading:
#
for _ in range(5):
try:
percent = battery_monitor.cell_percent
break
except Exception as e:
print(e)
else:
print("too much battery polling")
percent = 100
Ensure code always sleeps
By using a "finally" clause, the code will always sleep even if it encounters an error. During development I have to comment out the "exit_and_deep_sleep_until_alarms" call so that I see errors in my code, but then I put that line back when I'm done:
if __name__ == "__main__":
to_sleep = 300
try:
setup_display()
to_sleep = main()
finally:
displayio.release_displays()
print(f"sleeping for {to_sleep} seconds")
alarm.exit_and_deep_sleep_until_alarms(
alarm.time.TimeAlarm(monotonic_time=time.monotonic() + to_sleep)
)
Using adafruit_datetime
adafruit_datetime is a compatible subset of the standard Python datetime module. It has a useful feature for this project: subtracting two datetime objects results in a timedelta object.
target is the time we're counting down to. I converted it to universal time (UTC, also sometimes called GMT) on my computer, because NTP naturally works in UTC time, and this frees me from thinking about daylight saving time rules.
target = adafruit_datetime.datetime(2025, 3, 28, 21) # UTC, 4PM local time
Once I've established the time using the network time protocol (NTP) I can just use subtraction to find out the time remaining until the target. Because my display is in hours, I then round it UP to the next full hour, and then convert it to weeks, days, and hours. With this info at hand, I can format the display.
There are special cases for when it's after the target time, and when the battery is low (the hours display is replaced by "bat!"
#
delta = target - now
delta = adafruit_datetime.timedelta(
seconds=math.ceil(delta.total_seconds() / 3600) * 3600
)
weeks = delta.days // 7
days = delta.days % 7
hours = delta.seconds // 3600
low_bat = percent < 25
if delta.total_seconds() < 0:
labels[0].text = "GO"
labels[1].text = "GO"
labels[2].text = "GO"
else:
labels[0].text = f"{weeks}"
labels[1].text = f"{days}"
labels[2].text = "bat!" if low_bat else f"{hours}"
Don't connect to WiFi everytime
Connecting to WiFi takes time, and the wifi radio also drains the battery faster. So, I track how long it's been since the device time was updated, using sleep_memory. When successfully set, a counter is initialized to 24. When it's non-zero, the code can use the device time instead of connecting to the network (& subtracts 1 from the counter)
Because sleep memory starts as all zeros when the device powers on, it'll always update the time after being reset or powered on.
Incidentally, this is why I recommend not to use the WiFi workflow: when it is enabled, wifi will connect everytime.
#
sm0 = alarm.sleep_memory[0]
if sm0 == 0:
# Assumes wifi connected via settings.toml before code.py started!
connect_wifi()
pool = socketpool.SocketPool(wifi.radio)
ntp = adafruit_ntp.NTP(pool, tz_offset=0, cache_seconds=3600, socket_timeout=1)
for _ in range(10):
try:
rtc.RTC().datetime = ntp.datetime
alarm.sleep_memory[0] = 24 # Don't set again for about 24h
break
except Exception as e:
print(e)
else:
print("NTP too much")
# Hopefully it's better in a few hundred seconds
return 300
else:
print(f"Don't need to set for {sm0} hours")
alarm.sleep_memory[0] = sm0-1
This page (E-Ink Countdown in CircuitPython with Custom Stand) was last updated on October 02, 2024.