Getting Familiar
CircuitPython is a programming language based on Python, one of the fastest growing programming languages in the world. It is specifically designed to simplify experimenting and learning to code on low-cost microcontroller boards. Here are some guides which cover the basics:
Be sure you have the latest CircuitPython for PyPortal loaded onto your board. This should be from no earlier than the end of Feb 2019. [At this writing 4.0 Beta 3 is recommended]
CircuitPython is easiest to use within the Mu Editor. If you haven't previously used Mu, this guide will get you started.
Download Library Files
Plug your Feather M4 Express board into your computer via a USB cable. Please be sure the cable is a good power+data cable so the computer can talk to the Feather board.
A new disk should appear in your computer's file explorer/finder called CIRCUITPY. This is the place we'll copy the code and code library. If you can only get a drive named CPLAYBOOT, load CircuitPython per the PyPortal guide above.
Create a new directory on the CIRCUITPY drive named lib.
Download the latest CircuitPython driver package to your computer using the green button below. Match the library you get to the version of CircuitPython you are using. Save to your computer's hard drive where you can find it.
With your computer's file explorer/finder, browse to the bundle and open it up. You'll need to copy three libraries to CIRCUITPY/lib:
- the adafruit_bitmap_font directory
- the adafruit_bus_device directory
- the adafruit_display_text directory
- the adafruit_esp32spi directory
- adafruit_pyportal.mpy
- adafruit_touchscreen.mpy
- neopixel.mpy
- adafruit_logging.mpy
The Code
The main code file is below, but you will need to download the entire repo using the Project Zip link above the file.
# SPDX-FileCopyrightText: 2019 Dave Astels for Adafruit Industries # # SPDX-License-Identifier: MIT """ PyPortal based alarm clock. Adafruit invests time and resources providing this open source code. Please support Adafruit and open source hardware by purchasing products from Adafruit! Written by Dave Astels for Adafruit Industries Copyright (c) 2019 Adafruit Industries Licensed under the MIT license. All text above must be included in any redistribution. """ #pylint:disable=redefined-outer-name,no-member,global-statement #pylint:disable=no-self-use,too-many-branches,too-many-statements #pylint:disable=useless-super-delegation, too-many-locals import time import json from secrets import secrets import board from adafruit_pyportal import PyPortal from adafruit_bitmap_font import bitmap_font from adafruit_display_text.label import Label from digitalio import DigitalInOut, Direction, Pull import analogio import displayio import adafruit_logging as logging # Set up where we'll be fetching data from DATA_SOURCE = 'http://api.openweathermap.org/data/2.5/weather?id='+secrets['city_id'] DATA_SOURCE += '&appid='+secrets['openweather_token'] # You'll need to get a token from openweather.org, looks like 'b6907d289e10d714a6e88b30761fae22' DATA_LOCATION = [] #################### # setup hardware pyportal = PyPortal(url=DATA_SOURCE, json_path=DATA_LOCATION, status_neopixel=board.NEOPIXEL) light = analogio.AnalogIn(board.LIGHT) snooze_button = DigitalInOut(board.D3) snooze_button.direction = Direction.INPUT snooze_button.pull = Pull.UP #################### # variables # alarm support alarm_background = 'red_alert.bmp' alarm_file = 'alarm.wav' alarm_enabled = True alarm_armed = True alarm_interval = 10.0 alarm_hour = 9 alarm_minute = 45 snooze_time = None snooze_interval = 600.0 # mugsy support mugsy_background = 'mugsy_background.bmp' # weather support icon_file = None icon_sprite = None celcius = secrets['celcius'] # display/data refresh timers refresh_time = None update_time = None weather_refresh = None # The most recently fetched time current_time = None # track whether we're in low light mode low_light = False #################### # Load the fonts time_font = bitmap_font.load_font('/fonts/Anton-Regular-104.bdf') time_font.load_glyphs(b'0123456789:') # pre-load glyphs for fast printing alarm_font = bitmap_font.load_font('/fonts/Helvetica-Bold-36.bdf') alarm_font.load_glyphs(b'0123456789:') temperature_font = bitmap_font.load_font('/fonts/Arial-16.bdf') temperature_font.load_glyphs(b'0123456789CF') #################### # Set up logging logger = logging.getLogger('alarm_clock') logger.addHandler(logging.StreamHandler()) logger.setLevel(logging.ERROR) # change as desired #################### # Functions def create_text_areas(configs): """Given a list of area specifications, create and return test areas.""" text_areas = [] for cfg in configs: textarea = Label(cfg['font'], text=' '*cfg['size']) textarea.x = cfg['x'] textarea.y = cfg['y'] textarea.color = cfg['color'] text_areas.append(textarea) return text_areas def clear_splash(): for _ in range(len(pyportal.splash) - 1): pyportal.splash.pop() def touch_in_button(t, b): in_horizontal = b['left'] <= t[0] <= b['right'] in_vertical = b['top'] <= t[1] <= b['bottom'] return in_horizontal and in_vertical touched = False #################### # states class State(object): """State abstract base class""" def __init__(self): pass @property def name(self): """Return the name of teh state""" return '' def tick(self, now): """Handle a tick: one pass through the main loop""" pass #pylint:disable=unused-argument def touch(self, t, touched): """Handle a touch event. :param (x, y, z) - t: the touch location/strength""" return bool(t) def enter(self): """Just after the state is entered.""" pass def exit(self): """Just before the state exits.""" clear_splash() class Time_State(State): """This state manages the primary time display screen/mode""" def __init__(self): super().__init__() self.background_day = 'main_background_day.bmp' self.background_night = 'main_background_night.bmp' self.refresh_time = None self.update_time = None self.weather_refresh = None text_area_configs = [dict(x=88, y=170, size=5, color=0xFFFFFF, font=time_font), dict(x=210, y=50, size=5, color=0xFF0000, font=alarm_font), dict(x=88, y=90, size=6, color=0xFFFFFF, font=temperature_font)] self.text_areas = create_text_areas(text_area_configs) self.weather_icon = displayio.Group() self.weather_icon.x = 88 self.weather_icon.y = 20 self.icon_file = None self.snooze_icon = displayio.Group() self.snooze_icon.x = 260 self.snooze_icon.y = 70 self.snooze_file = None # each button has it's edges as well as the state to transition to when touched self.buttons = [dict(left=0, top=50, right=80, bottom=120, next_state='settings'), dict(left=0, top=155, right=80, bottom=220, next_state='mugsy')] @property def name(self): return 'time' def adjust_backlight_based_on_light(self, force=False): """Check light level. Adjust the backlight and background image if it's dark.""" global low_light if light.value <= 1000 and (force or not low_light): pyportal.set_backlight(0.01) pyportal.set_background(self.background_night) low_light = True elif force or (light.value >= 2000 and low_light): pyportal.set_backlight(1.00) pyportal.set_background(self.background_day) low_light = False def tick(self, now): global alarm_armed, snooze_time, update_time, current_time # is the snooze button pushed? Cancel the snooze if so. if not snooze_button.value: if snooze_time: self.snooze_icon.pop() snooze_time = None alarm_armed = False # is snooze active and the snooze time has passed? Transition to alram is so. if snooze_time and ((now - snooze_time) >= snooze_interval): change_to_state('alarm') return # check light level and adjust background & backlight #self.adjust_backlight_based_on_light() # only query the online time once per hour (and on first run) if (not self.refresh_time) or ((now - self.refresh_time) > 3600): logger.debug('Fetching time') try: pyportal.get_local_time(location=secrets['timezone']) self.refresh_time = now except RuntimeError as e: self.refresh_time = now - 3000 # delay 10 minutes before retrying logger.error('Some error occured, retrying! - %s', str(e)) # only query the weather every 10 minutes (and on first run) if (not self.weather_refresh) or (now - self.weather_refresh) > 600: logger.debug('Fetching weather') try: value = pyportal.fetch() weather = json.loads(value) # set the icon/background weather_icon_name = weather['weather'][0]['icon'] try: self.weather_icon.pop() except IndexError: pass filename = "/icons/"+weather_icon_name+".bmp" if filename: # CircuitPython 6 & 7 compatible if self.icon_file: self.icon_file.close() self.icon_file = open(filename, "rb") icon = displayio.OnDiskBitmap(self.icon_file) icon_sprite = displayio.TileGrid(icon, pixel_shader=getattr(icon, 'pixel_shader', displayio.ColorConverter()), x=0, y=0) # # CircuitPython 7+ compatible # icon = displayio.OnDiskBitmap(filename) # icon_sprite = displayio.TileGrid(icon, pixel_shader=icon.pixel_shader) self.weather_icon.append(icon_sprite) temperature = weather['main']['temp'] - 273.15 # its...in kelvin if celcius: temperature_text = '%3d C' % round(temperature) else: temperature_text = '%3d F' % round(((temperature * 9 / 5) + 32)) self.text_areas[2].text = temperature_text self.weather_refresh = now try: board.DISPLAY.refresh(target_frames_per_second=60) except AttributeError: board.DISPLAY.refresh_soon() board.DISPLAY.wait_for_frame() except RuntimeError as e: self.weather_refresh = now - 540 # delay a minute before retrying logger.error("Some error occured, retrying! - %s", str(e)) if (not update_time) or ((now - update_time) > 30): # Update the time update_time = now current_time = time.localtime() time_string = '%02d:%02d' % (current_time.tm_hour,current_time.tm_min) self.text_areas[0].text = time_string try: board.DISPLAY.refresh(target_frames_per_second=60) except AttributeError: board.DISPLAY.refresh_soon() board.DISPLAY.wait_for_frame() # Check if alarm should sound if current_time is not None and not snooze_time: minutes_now = current_time.tm_hour * 60 + current_time.tm_min minutes_alarm = alarm_hour * 60 + alarm_minute if minutes_now == minutes_alarm: if alarm_armed: change_to_state('alarm') else: alarm_armed = alarm_enabled def touch(self, t, touched): if t and not touched: # only process the initial touch for button_index in range(len(self.buttons)): b = self.buttons[button_index] if touch_in_button(t, b): change_to_state(b['next_state']) break return bool(t) def enter(self): self.adjust_backlight_based_on_light(force=True) for ta in self.text_areas: pyportal.splash.append(ta) pyportal.splash.append(self.weather_icon) if snooze_time: # CircuitPython 6 & 7 compatible if self.snooze_file: self.snooze_file.close() self.snooze_file = open('/icons/zzz.bmp', "rb") icon = displayio.OnDiskBitmap(self.snooze_file) icon_sprite = displayio.TileGrid(icon, pixel_shader=getattr(icon, 'pixel_shader', displayio.ColorConverter())) # # CircuitPython 7+ compatible # icon = displayio.OnDiskBitmap("/icons/zzz.bmp") # icon_sprite = displayio.TileGrid(icon, pixel_shader=icon.pixel_shader) self.snooze_icon.append(icon_sprite) pyportal.splash.append(self.snooze_icon) if alarm_enabled: self.text_areas[1].text = '%2d:%02d' % (alarm_hour, alarm_minute) else: self.text_areas[1].text = ' ' try: board.DISPLAY.refresh(target_frames_per_second=60) except AttributeError: board.DISPLAY.refresh_soon() board.DISPLAY.wait_for_frame() def exit(self): super().exit() for _ in range(len(self.snooze_icon)): self.snooze_icon.pop() class Mugsy_State(Time_State): """This state tells Mugsey 'Make me a coffee' """ def __init__(self): super().__init__() @property def name(self): return 'mugsy' def tick(self, now): # Once the job is done, go back to the main screen change_to_state('time') def enter(self): global low_light low_light = False pyportal.set_backlight(1.00) pyportal.set_background(mugsy_background) try: board.DISPLAY.refresh(target_frames_per_second=60) except AttributeError: board.DISPLAY.refresh_soon() board.DISPLAY.wait_for_frame() class Alarm_State(State): """This state shows/sounds the alarm. Touching anywhere on the screen cancells the alarm. Pressing the snooze button turns of the alarm, starting it again in 10 minutes.""" def __init__(self): super().__init__() self.sound_alarm_time = None @property def name(self): return 'alarm' def tick(self, now): global snooze_time # is the snooze button pushed if not snooze_button.value: snooze_time = now change_to_state('time') return # is it time to sound the alarm? if self.sound_alarm_time and (now - self.sound_alarm_time) > alarm_interval: self.sound_alarm_time = now pyportal.play_file(alarm_file) def touch(self, t, touched): global snooze_time if t and not touched: snooze_time = None change_to_state('time') return bool(t) def enter(self): global low_light self.sound_alarm_time = time.monotonic() - alarm_interval pyportal.set_backlight(1.00) pyportal.set_background(alarm_background) low_light = False try: board.DISPLAY.refresh(target_frames_per_second=60) except AttributeError: board.DISPLAY.refresh_soon() board.DISPLAY.wait_for_frame() def exit(self): global alarm_armed super().exit() alarm_armed = bool(snooze_time) class Setting_State(State): """This state lets the user enable/disable the alarm and set its time. Swiping up/down adjusts the hours & miniutes separately.""" def __init__(self): super().__init__() self.previous_touch = None self.background = 'settings_background.bmp' text_area_configs = [dict(x=88, y=120, size=5, color=0xFFFFFF, font=time_font)] self.text_areas = create_text_areas(text_area_configs) self.buttons = [dict(left=0, top=30, right=80, bottom=93), # on dict(left=0, top=98, right=80, bottom=152), # return dict(left=0, top=155, right=80, bottom=220), # off dict(left=100, top=0, right=200, bottom = 240), # hours dict(left=220, top=0, right=320, bottom = 240)] # minutes @property def name(self): return 'settings' def touch(self, t, touched): global alarm_hour, alarm_minute, alarm_enabled if t: if touch_in_button(t, self.buttons[0]): # on logger.debug('ON touched') alarm_enabled = True self.text_areas[0].text = '%02d:%02d' % (alarm_hour, alarm_minute) elif touch_in_button(t, self.buttons[1]): # return logger.debug('RETURN touched') change_to_state('time') elif touch_in_button(t, self.buttons[2]): # off logger.debug('OFF touched') alarm_enabled = False self.text_areas[0].text = ' ' elif alarm_enabled: if not self.previous_touch: self.previous_touch = t else: if touch_in_button(t, self.buttons[3]): # HOURS logger.debug('HOURS touched') if t[1] < (self.previous_touch[1] - 5): # moving up alarm_hour = (alarm_hour + 1) % 24 logger.debug('Alarm hour now: %d', alarm_hour) elif t[1] > (self.previous_touch[1] + 5): # moving down alarm_hour = (alarm_hour - 1) % 24 logger.debug('Alarm hour now: %d', alarm_hour) self.text_areas[0].text = '%02d:%02d' % (alarm_hour, alarm_minute) elif touch_in_button(t, self.buttons[4]): # MINUTES logger.debug('MINUTES touched') if t[1] < (self.previous_touch[1] - 5): # moving up alarm_minute = (alarm_minute + 1) % 60 logger.debug('Alarm minute now: %d', alarm_minute) elif t[1] > (self.previous_touch[1] + 5): # moving down alarm_minute = (alarm_minute - 1) % 60 logger.debug('Alarm minute now: %d', alarm_minute) self.text_areas[0].text = '%02d:%02d' % (alarm_hour, alarm_minute) self.previous_touch = t try: board.DISPLAY.refresh(target_frames_per_second=60) except AttributeError: board.DISPLAY.refresh_soon() board.DISPLAY.wait_for_frame() else: self.previous_touch = None return bool(t) def enter(self): global snooze_time snooze_time = None pyportal.set_background(self.background) for ta in self.text_areas: pyportal.splash.append(ta) if alarm_enabled: self.text_areas[0].text = '%02d:%02d' % (alarm_hour, alarm_minute) # set time textarea else: self.text_areas[0].text = ' ' #################### # State management states = {'time': Time_State(), 'mugsy': Mugsy_State(), 'alarm': Alarm_State(), 'settings': Setting_State()} current_state = None def change_to_state(state_name): global current_state if current_state: logger.debug('Exiting %s', current_state.name) current_state.exit() current_state = states[state_name] logger.debug('Entering %s', current_state.name) current_state.enter() #################### # And... go clear_splash() change_to_state("time") while True: touched = current_state.touch(pyportal.touchscreen.touch_point, touched) current_state.tick(time.monotonic())
Your CIRCUITPY drive should look something like this after you've copied everything over:
Text editor powered by tinymce.