In this project we're going to upgrade a small Christmas tree with battery-powered Neopixels! In the process, we're going to learn to use CircuitPython's Deep Sleep feature and the real-time clock to turn the lights on and off on a schedule. We'll also use the Feather's own boot button to create a simple UI for configuring the tree, and an LCD FeatherWing to act as a low-power control panel, letting us set a time for the tree to turn on and off.
![IMG_1348.jpeg](https://cdn-learn.adafruit.com/user_assets/assets/000/000/038/large1024/IMG_1348.jpeg?1670720716)
Why an LCD FeatherWing? It's a low-power, always-on display technology that's going to keep the tree's current draw to just a couple of milliamperes while the tree is off. Saving power in deep sleep means the tree can run for days to over a week, on a single battery charge!
What you'll need:
We're going to use an ESP32-S2 Feather here, plus a Prop-Maker FeatherWing to drive the Neopixel strip, and an LCD FeatherWing for the display. We'll plug it all into a FeatherWing tripler, and power it with a beefy 4400 mAh battery.
You'll also need a small (20-30 inch) Christmas tree to string your lights around! I got this one from Target.
![Angled shot of rectangular microcontroller.](https://cdn-shop.adafruit.com/640x480/5000-12.jpg)
![Angled shot of a Adafruit Prop-Maker FeatherWing.](https://cdn-shop.adafruit.com/640x480/3988-00.jpg)
![Overhead video of an assembled monochrome LCD screen breakout displaying the time 11:43 A.M.](https://cdn-shop.adafruit.com/product-videos/640x480/5581-03.jpg)
![Triple prototyping feather wing PCB with socket headers installed](https://cdn-shop.adafruit.com/640x480/3417-05.jpg)
![Adafruit NeoPixel LED Strip with 3-pin JST Connector lit up rainbow](https://cdn-shop.adafruit.com/640x480/4801-05.jpg)
![Lithium Ion Battery Pack with two round cells 3.7V 4400mAh with JST PH connector](https://cdn-shop.adafruit.com/640x480/354-02.jpg)
Assemble the ESP32-S2 Feather and the Prop-Maker Wing
Your ESP32-S3 Feather and Prop-Maker wing will come with plain 0.1 inch pin header. Follow the instructions in this guide to solder the pin header into the Feather and FeatherWing.
Assemble the LCD FeatherWing
Soldering the LCD FeatherWing is very similar; the main difference is that you'll need to solder the LCD glass into the LCD pins first, then solder the Feather headers second.
![lcd_wing_soldering_for_learn_guide.jpg](https://cdn-learn.adafruit.com/user_assets/assets/000/000/034/large1024/lcd_wing_soldering_for_learn_guide.jpg?1670703172)
Assemble the FeatherWing Tripler
For this step, follow the instructions in the FeatherWing Tripler guide to solder plain female socket headers into the top of the FeatherWing tripler board.
Putting it all together
From top to bottom, plug the ESP32-S2 Feather, the Prop-Maker FeatherWing and the LCD FeatherWing in to the FeatherWing tripler. Then plug the Neopixel strip into the three-pin JST-PH port on the Prop Maker wing:
![board_trio_for_learn_guide.jpg](https://cdn-learn.adafruit.com/user_assets/assets/000/000/035/large1024/board_trio_for_learn_guide.jpg?1670703216)
Install CircuitPython and required libraries
Download the latest CircuitPython for your board from circuitpython.org, along with the matching library bundle. In addition, download the LCD FeatherWing library. You'll need to copy the following libraries to the lib folder on your CircuitPython board:
- adafruit_debouncer
- adafruit_ticks
- neopixel
- oso_lcd
CircuitPython Setup
Before we show off the script, it's worth talking through what we plan to do. First, we're going to set up four peripherals:
- A button input on pin
BOOT0
- The LCD FeatherWing on the I²C bus (
board.I2C
) - The Prop-Maker Wing enable on pin
D10
, which will power up the LED strip. - The Neopixel strip on pin
D5
We'll use the single button input to set the both the time for the tree to turn on, and the duration for it to stay on. The LCD FeatherWing will display these values, giving us a responsive, low-power user interface. When it's time for the tree to turn on, we'll pull the Prop-Maker Wing's enable pin high, and animate our sparkling lights. Then, when it's time for it to turn off, we'll power down the Prop-Maker Wing and enter deep sleep.
That's a lot! Let's break it up into a few files, just to keep ourselves organized.
Single-button input with adafruit_debouncer
First, let's create button.py
and code up our button input.
import board from digitalio import DigitalInOut, Direction, Pull from adafruit_debouncer import Debouncer boot0 = DigitalInOut(board.BOOT0) boot0.direction = Direction.INPUT boot0.pull = Pull.UP button = Debouncer(boot0) def update(): press = False long_press = False button.update() if button.rose: if button.last_duration < 0.5: press = True else: long_press = True return (press, long_press) def prepare_for_sleep(): boot0.deinit()
Here, we're importing out digital IO inputs, along with the adafruit_debouncer
library. The BOOT0
button is the button on your Feather board, next to the RESET button.
Rather than read the button input directly, our update
function determines when the button's signal rose, or went from low to high. Since the button is pulled up, this detects when the button is released.
The debouncer gives us a helpful property called last_duration
, which tells us how long the button was held down. We're using this to return two types of button event: a press
, and a long_press
.
Finally, the prepare_for_sleep
function deinitializes the boot0
GPIO, so we can use it for something else (which we will).
That was easy! Next, let's use the ESP32-S2's wifi to set the clock.
Setting the clock with Adafruit IO
First, you're going to need a secrets.py
file to hold your wifi password and Adafruit IO credentials:
secrets = { "ssid" : "YOUR_WIFI_NETWORK", "password" : "YOUR_WIFI_PASSWORD", "aio_username" : "ADAFRUIT_IO_USERNAME", "aio_key" : "ADAFRUIT_IO_KEY", "timezone" : "America/Chicago", # http://worldtimeapi.org/timezones }
Replace the values in this dict with your wifi network details and Adafruit IO username and API key. You should also set your time zone so the clock is synced to your location.
Next, we'll create the network.py
file to handle all our networking needs:
import rtc import time import ssl import wifi import socketpool import adafruit_requests try: from secrets import secrets except ImportError: print("WiFi secrets are kept in secrets.py, please add them there!") raise def set_time_if_needed(): aio_username = secrets["aio_username"] aio_key = secrets["aio_key"] location = secrets.get("timezone", None) TIME_URL = f"https://io.adafruit.com/api/v2/{aio_username}/integrations/time/strftime?x-aio-key={aio_key}&tz={location}&fmt=%25Y-%25m-%25d+%25H%3A%25M%3A%25S" wifi.radio.connect(secrets["ssid"], secrets["password"]) pool = socketpool.SocketPool(wifi.radio) requests = adafruit_requests.Session(pool, ssl.create_default_context()) response = requests.get(TIME_URL) components = response.text.split(' ') (year, month, day) = components[0].split('-') (hour, minute, second) = components[1].split(':') rtc.RTC().datetime = time.struct_time((int(year), int(month), int(day), int(hour), int(minute), int(second), 0, -1, -1))
The set_time
function calls out to Adafruit IO's time service to automatically set the time. We'll use this function when the tree first boots to make sure it's synced up.
Now we should create our twinkling lights animation!
Twinkling the Neopixels
Let's create a file called lights.py
and put this code in it:
import random import board import neopixel import time from digitalio import DigitalInOut, Direction, Pull NUM_PIXELS = 30 # The number of Neopixels in our Neopixel strip NUM_TWINKLES = 15 # The number of active twinkles MAX_TWINKLE_FRAMES = 30 MIN_TWINKLE_FRAMES = 15 TWINKLE_COLORS = [[255, 0, 0], # Red [255, 180, 100], # Warm White ] strip = neopixel.NeoPixel(board.D5, NUM_PIXELS, brightness=1, auto_write=False) enable_lights = DigitalInOut(board.D10) enable_lights.direction = Direction.OUTPUT enable_lights.value = False twinkles = list() class Twinkle: def __init__(self, location, color, frames): self.location = location # pixel number on strand self.color = color # color of the twinkle self.frames = frames # number of frames to animate self.current_frame = 0 # current frame in animation self.state = 1 # 1 for starting / running, -1 for ending def advance(self): if self.current_frame == self.frames: self.state = -1 # mark twinkle as ending if self.current_frame == 0 and self.state == -1: # if twinkle has ended, pick a new color / location and start again self.location = random.randint(0, NUM_PIXELS - 1) self.color = TWINKLE_COLORS[random.randint(0, len(TWINKLE_COLORS) - 1)] self.frames = random.randint(MIN_TWINKLE_FRAMES, MAX_TWINKLE_FRAMES) self.current_frame = 0 self.state = 1 self.current_frame = self.current_frame + self.state # advance to next frame for i in range(NUM_TWINKLES): location = random.randint(0, NUM_PIXELS - 1) color = random.randint(1, len(TWINKLE_COLORS) - 1) flash_len = random.randint(MIN_TWINKLE_FRAMES, MAX_TWINKLE_FRAMES) twinkles.append(Twinkle(location, TWINKLE_COLORS[color], flash_len)) def turn_on(): enable_lights.value = True def prepare_for_sleep(): strip.deinit() enable_lights.value = False enable_lights.deinit() def animate(): for i in range(NUM_TWINKLES): location = twinkles[i].location brightness = (twinkles[i].current_frame/twinkles[i].frames) scaled_color = (int(twinkles[i].color[0]*brightness), int(twinkles[i].color[1]*brightness), int(twinkles[i].color[2]*brightness)) strip[location] = scaled_color twinkles[i].advance() strip.show()
First we have some parameters related to our lights: NUM_PIXELS
is the number of pixels in our Neopixel strip, NUM_TWINKLES
is the number of twinkles that will be active, and the MIN_
and MAX_TWINKLE_FRAMES
specify how long the twinkles should glow before fading away. TWINKLE_COLORS
is an array of colors for the twinkling lights; I chose red and white, but you can add as many as you like!
Next we configure the Neopixel strip and the enable pin. Since every Neopixel draws almost a milliampere even when off, the Prop-Maker FeatherWing has a pin that will cut all power to the strip when we're not using it. This will save significant power!
Next we create a Twinkle
class, which represents one twinkling light on the Neopixel strip, and a list of Twinkles NUM_TWINKLES
long. Finally, we create three functions for managing the lights:
- The
turn_on
function enables power to the strip - The
animate
function animates one frame of the twinkling animation - The
prepare_for_sleep
function, much like the one in button.py, releases the Neopixel and power pins.
That's all our support files! Now let's bring it all together in code.py.
Bringing It All Together
In our main code.py file, we're going to read the button input and put information on the LCD. We're also going to keep track of the time, and turn the lights on and off as scheduled. Finally, when the lights are off, we're going to enter CircuitPython's Deep Sleep mode, and only wake once a minute to update the display and check whether the lights need to be on.
import board from digitalio import DigitalInOut, Direction, Pull from oso_lcd.lcdwing_lite import LCDWingLite, Indicator import time import alarm import rtc import lights import network import button display = LCDWingLite(board.I2C()) clock = rtc.RTC() if clock.datetime.tm_year < 2020: display.print("5tClk") display.set_indicator(Indicator.WIFI) network.set_time_if_needed() display.clear_indicator(Indicator.WIFI) alarm.sleep_memory[0] = 10 # The default hour for the lights to turn on, 10 AM alarm.sleep_memory[1] = 0 # The default minute for the lights to turn on (10:00 AM) alarm.sleep_memory[2] = 1 # The default duration for the lights to remain on (1 hour) def display_time(hour, minute): hour_12 = hour % 12 display.print("{:2d}:{:02d}".format(hour_12 if hour_12 else 12, minute)) if hour < 12: display.clear_indicator(Indicator.PM) display.set_indicator(Indicator.AM) else: display.clear_indicator(Indicator.AM) display.set_indicator(Indicator.PM) MODE_SHOW_TIME = 0 MODE_SET_ON_TIME = 1 MODE_SET_DURATION = 2 NUM_MODES = 3 mode = 0 time_needs_display = True deep_sleep_at = time.monotonic() + 120 lights_on = False
In addition to our usual imports, we have three extra imports at the top of the file:
import button
import network
import lights
These import the files we wrote above, and give us access to functions for reading the button, setting the time with wifi and managing the lights.
We create our display and our clock, and then we set the clock, if necessary. We check if the date is set to a year before 2020, and if it is, we display “St Clk” or Set Clock while we fetch the time. The LCD FeatherWing has a limited number of characters, so we have to be creative with it!
After we're done setting the time, we're going to store some default values in a special place: alarm.sleep_memory
. Later on, we're going to put the board into deep sleep, and when we wake up from it, our code.py will start running from the beginning. The only way we can store our settings between restarts is by stashing them in sleep memory, which is just an array of bytes. We use the first three bytes of sleep memory to store the hour and minute when we want the lights to turn on, and the number of hours we want them to stay on.
Next, we create a simple function, display_time
, to display a time on the LCD.
After that, we define the modes for our UI: MODE_SHOW_TIME
is the default mode, which shows the current time; MODE_SET_ON_TIME
is where we set the time for the tree to turn on, and MODE_SET_DURATION
is where we set the length of time we want the lights to stay on.
We also need to set up some global state for our program:
-
mode
tracks the current mode; it will always be one of the values defined in the previous paragraph. -
time_needs_display
will be True whenever we need to update the time displayed on the LCD. It defaults toTrue
because we'll always need to update the display the first time through. -
deep_sleep_at
is the time when we'll need to enter deep sleep mode. It defaults to two minutes from first boot, but we'll reset it at various points. -
lights_on
tracks whether the lights should be on and twinkling!
Before we implement our run loop, we should talk a little bit about how our one-button UI is going to work.
Building a user interface with one button
The key to building a useful interface here lies in the press vs long press distinction we made earlier. A long press of the button serves to switch between modes, whereas a short press changes values. In this diagram of the UI, the long press cycles through modes 0, 1 and 2. The short presses change the time in 15 minute increments, and the duration in one hour increments:
![christmas_tree_UI_for_learn_guide.png](https://cdn-learn.adafruit.com/user_assets/assets/000/000/039/large1024/christmas_tree_UI_for_learn_guide.png?1670724511)
The Mode 0 display in the bottom row is the same Mode 0 as in the second row, but it shows how certain events affect the UI: at 10:30 AM — the time we set — the lights turn on, and the DATA
indicator appears to indicate that we're sending data to the Neopixels. By 1:35 PM, the lights will have turned off; the MOON
indicator appears to indicate that we're in deep sleep.
Now we can write our run loop!
while True: dt = clock.datetime (press, long_press) = button.update() if press or long_press: deep_sleep_at = max(deep_sleep_at, time.monotonic() + 120) if long_press: mode = (mode + 1) % NUM_MODES display.clear_indicator(Indicator.ALL) time_needs_display = True if mode == MODE_SHOW_TIME: if time_needs_display or dt.tm_sec == 0: display_time(dt.tm_hour, dt.tm_min) time_needs_display = False elif mode == MODE_SET_ON_TIME: display.set_indicator(Indicator.BELL) if press: alarm.sleep_memory[1] += 15 if alarm.sleep_memory[1] == 60: alarm.sleep_memory[1] = 0 alarm.sleep_memory[0] = (alarm.sleep_memory[0] + 1) % 24 display_time(alarm.sleep_memory[0], alarm.sleep_memory[1]) elif mode == MODE_SET_DURATION: if press: if alarm.sleep_memory[2] == 12: alarm.sleep_memory[2] = 1 else: alarm.sleep_memory[2] += 1 display.print("{:2d} hr".format(alarm.sleep_memory[2])) if lights_on: display.set_indicator(Indicator.DATA) lights.animate() elif dt.tm_hour == alarm.sleep_memory[0] and dt.tm_min >= alarm.sleep_memory[1] and mode == 0: lights.turn_on() lights_on = True deep_sleep_at = 1 + time.monotonic() + alarm.sleep_memory[2] * 60 * 60 if isinstance(alarm.wake_alarm, alarm.time.TimeAlarm) and not lights_on: deep_sleep_at = time.monotonic() if time.monotonic() >= deep_sleep_at: button.prepare_for_sleep() lights.prepare_for_sleep() display.clear_indicator(Indicator.DATA) display.set_indicator(Indicator.MOON) pin_alarm = alarm.pin.PinAlarm(pin=board.BOOT0, value=False, pull=True) time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + 60) alarm.exit_and_deep_sleep_until_alarms(pin_alarm, time_alarm) time.sleep(0.01667)
First, we fetch the the current time and the state of the buttons: whether they were pressed, or long pressed.
If we got either press, we reset the inactivity timeout.
If we got a long press, we clear all indicators on the LCD and move to the next mode. (we also set time_needs_display
since the time may no longer be on screen.
What happens next depends on the mode we're in:
- If we're in
MODE_SHOW_TIME
, we display the time if necessary. - If we're in
MODE_SET_ON_TIME
, we advance the time if the button was pressed, and either way, we update the LCD to display the current on time (which is stored in sleep memory). - If we're in
MODE_SET_DURATION
, we advance the duration if the button was pressed, and either way we display the current duration.
That's it for the user interface! Now we can move on to the lights:
- If
lights_on
isTrue
, we set the DATA indicator on the LCD, and animate a frame in the Neopixel animation. - Otherwise, we check if the lights should be on: if the current time is after the on time, we set
lights_on
toTrue
, enable the lights and setdeep_sleep_at
to the current time plus the duration for the lights to remain on.
The last line in this block won't make sense until we talk about the last thing. We're asking if isinstance(alarm.wake_alarm, alarm.time.TimeAlarm)
, but what is alarm.wake_alarm
? As it turns out, right now, it's None
. This variable contains the alarm that woke us from deep sleep — but when we first boot, we didn't wake from deep sleep! So let's put ourselves into deep sleep first.
The last conditional asks if time.monotonic() >= deep_sleep_at
. When that comes up True
, we put the board into deep sleep:
- We call
button.prepare_for_sleep()
andlights.prepare_for_sleep()
. We covered those earlier: they release the pins for the button and the Neopixels. - We clear the
DATA
indicator, since we're not communicating with the Neopixel anymore. - We also set the
MOON
indicator, which will indicate that we're in deep sleep. - Next, we set two different
alarm
s:- a
PinAlarm
wakes the board from deep sleep when a button press is detected. That same BOOT0 button will now wake the board from deep sleep when you press it. - a
TimeAlarm
wakes the board at a specified time. Every time we go into deep sleep, we set the board to wake up one minute later.
- a
- Finally, we call
alarm.exit_and_deep_sleep_until_alarms(pin_alarm, time_alarm)
. This puts the board to sleep until either the button is pressed or one minute passes.
Now we can go back to that conditional! isinstance(alarm.wake_alarm, alarm.time.TimeAlarm)
returns True
if the alarm that woke us was that 60 second check. If it was, and the lights aren't on, we can go right back to sleep by setting deep_sleep_at
to the current time!
Decorating the Tree
From here, you can plug the battery into the Feather board, and thread the Neopixel strip through your tree as you see fit. Put some ornaments on there too while you're at it!
![tree_vertical_for_learn_guide.jpeg](https://cdn-learn.adafruit.com/user_assets/assets/000/000/042/large1024/tree_vertical_for_learn_guide.jpeg?1670727986)
Stash the board and the battery under the tree (this one includes a little pot that's just right sized for it), and use the user interface to configure the times you want it on.
![tree_lcd_for_learn_guide.jpeg](https://cdn-learn.adafruit.com/user_assets/assets/000/000/043/large1024/tree_lcd_for_learn_guide.jpeg?1670728218)
Measuring the impact of Deep Sleep
After reading about all the complexity involved in deep sleep, you might be wondering: why did we go to all this trouble anyway? How much of an impact could it really have? This guide is already too long, so for a short addendum, follow me to the epilogue: in which we measure our current consumption, and estimate how many days of Christmas this tree will last!