code.py

This brings us to code.py, our feature presentation. Much like openweather_graphics.py, this code was originally written for the original PyPortal Weather Station and has been modified for this project's scope.

First, we'll import all of the libraries that we'll need:

import time
from calendar import alarms
from calendar import timers
import board
import displayio
from digitalio import DigitalInOut, Direction, Pull
from adafruit_button import Button
from adafruit_pyportal import PyPortal
import openweather_graphics

You'll see that we're importing the openweather_graphics and calendar files like they're libraries (secrets will be imported shortly using a different method). This is how all four of the files are working together to allow everything to run smoothly. As we just discussed, each of these files provide their own set of puzzle pieces to complete this project.  It's best to think of code.py as the main program and the other three as helpers.

Next we connect to Wi-Fi with the ESP32, which is pulling SSID information from the secrets.py file.

# Get wifi details and more from a secrets.py file
try:
    from secrets import secrets
except ImportError:
    print("WiFi secrets are kept in secrets.py, please add them there!")
    raise

Then we grab the location, also stored in the secrets.py file. This will be used to grab data from OpenWeatherMap.

# Use cityname, country code where countrycode is ISO3166 format.
# E.g. "New York, US" or "London, GB"
LOCATION = secrets['location']

And speaking of, here we are preparing to pull that data. We're creating a variable (DATA_SOURCE) to hold the URL for OpenWeatherMap along with your OpenWeatherMap token from the secrets.py file. DATA_LOCATION is an empty array at the moment, but shortly it will hold the JSON path for the PyPortal to access all of the weather data to display.

# Set up where we'll be fetching data from
DATA_SOURCE = "http://api.openweathermap.org/data/2.5/weather?q="+LOCATION
DATA_SOURCE += "&appid="+secrets['openweather_token']
# You'll need to get a token from openweather.org, looks like 'b6907d289e10d714a6e88b30761fae22'
DATA_LOCATION = []

Here, we define our pyportal object and you can see the DATA_SOURCE and DATA_LOCATION variables in use. By defining a pyportal object you also setup a lot of other items by default, such as audio playback and mounting an SD card file system (if applicable, which for this project it is). This is really handy and saves a lot of lines of code for code.py.

# Initialize the pyportal object and let us know what data to fetch and where
# to display it
pyportal = PyPortal(url=DATA_SOURCE,
                    json_path=DATA_LOCATION,
                    status_neopixel=board.NEOPIXEL,
                    default_bg=0x000000)

Next, we create our display object, which will eventually show our alarm graphics.

display = board.DISPLAY

This project is not only visual- it has audio components too. Every time an alarm goes off, a sound will accompany it; in this case, a friendly robot's voice reminding you to take care of your basic needs.

First, we define the file locations for each sound, of which there are three.

#  the alarm sound file locations
alarm_sound_trash = "/sounds/trash.wav"
alarm_sound_bed = "/sounds/sleep.wav"
alarm_sound_eat = "/sounds/eat.wav"

And then we create an array to hold the sounds called alarm_sounds. The order of the audio files in the array matches the alarm data and alarm graphics arrays that we talked about previously with the calendar.py file. This way we'll be able to use states to initiate all of the alarm components together (audio, visual and control) by calling the same index in the different arrays.

#  the alarm sounds in an array that matches the order of the gfx & alarm check-ins
alarm_sounds = [alarm_sound_trash, alarm_sound_bed, alarm_sound_eat, alarm_sound_eat, alarm_sound_eat]

Before we can make arrays of graphics though, we need to setup the bitmaps. The graphics for the alarms are actually full pre-made bitmaps with text and images baked in, so we can use OnDiskBitmap() to directly load them up. We have a bitmap for sleep, trash and meals. We'll use the same meal alarm for all three meals: breakfast, lunch and dinner. After each bitmap is setup, it's added to its own graphics group.

#  setting up the bitmaps for the alarms

#  sleep alarm
sleep_bitmap = displayio.OnDiskBitmap(open("/sleepBMP.bmp", "rb"))
sleep_tilegrid = displayio.TileGrid(sleep_bitmap, pixel_shader=displayio.ColorConverter())
group_bed = displayio.Group()
group_bed.append(sleep_tilegrid)

#  trash alarm
trash_bitmap = displayio.OnDiskBitmap(open("/trashBMP.bmp", "rb"))
trash_tilegrid = displayio.TileGrid(trash_bitmap, pixel_shader=displayio.ColorConverter())
group_trash = displayio.Group()
group_trash.append(trash_tilegrid)

#  meal alarm
eat_bitmap = displayio.OnDiskBitmap(open("/eatBMP.bmp", "rb"))
eat_tilegrid = displayio.TileGrid(eat_bitmap, pixel_shader=displayio.ColorConverter())
group_eat = displayio.Group()
group_eat.append(eat_tilegrid)

We're about to get into the controls for the alarms. Once an alarm sounds, we need to have a way to make it stop. In this project we can snooze the alarm to have it come back on later or completely dismiss it. We'll be able to do this with buttons on the PyPortal's touchscreen and physical buttons that are mounted on the top of the retro computer case. First, let's take care of the touchscreen buttons.

In order for the buttons to work across graphics, we're going to set things up a bit uniquely. First, we'll create button objects for the snooze buttons that will be included in each alarm's graphics group. That's three snooze buttons, one to be superimposed over each of our three alarm bitmaps. Notice that the 'color' of each is set to None, that allows the button to be transparent.

#  snooze touch screen buttons
#  one for each alarm bitmap
snooze_controls = [
    {'label': "snooze_trash", 'pos': (4, 222), 'size': (236, 90), 'color': None},
    {'label': "snooze_bed", 'pos': (4, 222), 'size': (236, 90), 'color': None},
    {'label': "snooze_eat", 'pos': (4, 222), 'size': (236, 90), 'color': None},
    ]

These buttons will live in an array called snooze_buttons. We need them to live in this array later once we're in the loop.

#  setting up the snooze buttons as buttons
snooze_buttons = []
for s in snooze_controls:
    snooze_button = Button(x=s['pos'][0], y=s['pos'][1],
                    width=s['size'][0], height=s['size'][1],
                    style=Button.RECT,
                    fill_color=s['color'], outline_color=None,
                    name=s['label'])
    snooze_buttons.append(snooze_button)

Next we'll do the same for the dismiss buttons. Three dismiss buttons, one for each of our three alarm graphics groups. They'll also live in an array called dismiss_buttons.

#  dismiss touch screen buttons
#  one for each alarm bitmap
dismiss_controls = [
    {'label': "dismiss_trash", 'pos': (245, 222), 'size': (230, 90), 'color': None},
    {'label': "dismiss_bed", 'pos': (245, 222), 'size': (230, 90), 'color': None},
    {'label': "dismiss_eat", 'pos': (245, 222), 'size': (230, 90), 'color': None},
    ]

#  setting up the dismiss buttons as buttons
dismiss_buttons = []
for d in dismiss_controls:
    dismiss_button = Button(x=d['pos'][0], y=d['pos'][1],
                    width=d['size'][0], height=d['size'][1],
                    style=Button.RECT,
                    fill_color=d['color'], outline_color=None,
                    name=d['label'])
    dismiss_buttons.append(dismiss_button)

After our buttons are made, we can add them to the graphics group for each alarm. We do this by calling on the array for the respective button type along with the index that corresponds with the alarm. For example, for the trash alarm, the trash buttons are indexed at 0 in both button arrays, so we're adding the trash buttons to the trash graphics group with snooze_buttons[0].group and dismiss_buttons[0].group.

#  adding the touch screen buttons to the different alarm gfx groups
group_trash.append(snooze_buttons[0].group)
group_trash.append(dismiss_buttons[0].group)
group_bed.append(snooze_buttons[1].group)
group_bed.append(dismiss_buttons[1].group)
group_eat.append(snooze_buttons[2].group)
group_eat.append(dismiss_buttons[2].group)

After the touch controls, we can setup the hardware buttons. We're using the STEMMA connectors on the back of the PyPortal to attach two STEMMA buttons via a STEMMA cable. This keeps everything solderless, which is pretty convenient. The pin numbers for each of the connectors are D3 and D4 and are also marked on the back of the PyPortal.

#  setting up the hardware snooze/dismiss buttons
switch_snooze = DigitalInOut(board.D3)
switch_snooze.direction = Direction.INPUT
switch_snooze.pull = Pull.UP

switch_dismiss = DigitalInOut(board.D4)
switch_dismiss.direction = Direction.INPUT
switch_dismiss.pull = Pull.UP

Our buttons, both physical and virtual, are all setup, along with our graphics groups, so we can get into our arrays and state machines. These will be running the show in the loop.

We're going to create an array that is pulling data from our calendar.py file; specifically all of our daily alarms that we have setup. This array is called alarm_checks and begins with None, which probably seems a little odd. This is to basically hold an index space of 0 for our weekly trash alarm. We still want trash to hold the index of 0 for graphics and audio, but we don't want to check for it everyday. This allows for the rest of the alarms to remain in their respective indexes without having to have a bunch of different arrays floating around.

#  grabbing the alarm times from the calendar file
#  'None' is the placeholder for trash, which is weekly rather than daily
alarm_checks = [None, alarms['bed'],alarms['breakfast'],alarms['lunch'],alarms['dinner']]

You can see this playing out with our next array, alarm_gfx. This has our alarm graphics that will match-up with our alarm_checks array. By keeping group_trash in the same array, it means that we can reference a single array for all graphics and integrate the trash alarm parameters with the other daily alarms. This keeps the code a bit neater. Notice that group_eat is called three times to coordinate with the three meal alarm check-ins.

#  all of the alarm graphics
alarm_gfx = [group_trash, group_bed, group_eat, group_eat, group_eat]

This line of code is probably one of the most important lines in the entire code.py file. By defining gfx here, we are bringing in the OpenWeather_Graphics class from the openweather_graphics.py file. By bringing this into code.py, we are able to display all of the weather data being pulled. We'll go over the openweather_graphics.py file in a bit, but for now just know that all of the graphics that you see displayed by default in this project are basically running from there because of a series of functions. Later when we call gfx.display_weather(value), gfx.update_time() and gfx.update_date() in the loop we're updating the weather and date and time data live to display on the PyPortal.

#  allows for the openweather_graphics to show
gfx = openweather_graphics.OpenWeather_Graphics(pyportal.splash, am_pm=True, celsius=False)

Oh the fun stuff: state machines. We've got quite a few here, so let's do a quick roll call with some comments in the code:

#  state machines
localtile_refresh = None #  a time_monotonic() device used to grab the time from the internet

weather_refresh = None #  a time_monotonic() device used to update the weather data

dismissed = None #  checks whether or not an alarm has been dismissed by either a physical or virtual button

touched = None #  a time_monotonic() device used to track whether a button, physical or virtual, has been pressed

start = None #  a time_monotonic() device used to update the time, which as a result check if any alarms should be activated

alarm = None #  checks whether an alarm is active or not

snoozed = None #  checks whether an alarm has been snoozed or not

touch_button_snooze = None #  a debounce device for the virtual snooze button

touch_button_dismiss = None #  a debounce device for the virtual dismiss button

phys_dismiss = None #  a debounce device for the physical dismiss button

phys_snooze = None #  a debounce device for the physical snooze button

mode = 0 #  a counter to see which alarm state is active. This will correspond with our alarm arrays

button_mode = 0 #  a counter to correspond with our button arrays. Remember that our button arrays only have three indexes so we'll do some math to make them match up with our various alarm arrays

This array is a bit stringy. The weekday array is holding, well, the days of the week. We'll use this when checking for our weekly alarm to compare it to the current day of the week being pulled in by the time.localtime() functio. This is how all of the code knows the date and time.

#  weekday array
weekday = ["Mon.", "Tues.", "Wed.", "Thurs.", "Fri.", "Sat.", "Sun."]

We can't forget about our weekly alarm, especially in this case, since it will remind us to take out the trash. Here we're making single index arrays. First in weekly_alarms we're pulling in the name of the trash alarm from the calendar.py file. This is why we kept all of the other trash-related stuff in index 0 in the other arrays - here in its own array. It lives by itself at index 0 and as a result will match up properly when we call up the other data. Then in weekly_day we pull in the weekday parameter and in weekly_time we pull in the time parameter that are both being stored as indexes in the trash array.

#  weekly alarm setup. checks for weekday and time
weekly_alarms = [alarms['trash']]
weekly_day = [alarms['trash'][0]]
weekly_time = [alarms['trash'][1]]

And we've done it: after all that setup and planning we can finally dive into the loop.

This first if statement grabs the time from the internet and is the first thing that runs when you power up this code on your PyPortal. Nothing else can happen until it knows what time it is. get_local_time() is a function from the PyPortal library and is essentially running time_struct(). It's pretty powerful function and there's a lot of information on it out there since it's native to full Python.

You'll notice that our first state machine is in use here: localtile_refresh. This allows the time check to only occur once every hour without it causing any delays in the code, which is the beauty of time.monotonic() and why it's used heavily throughout this project.

while True:
  	# while esp.is_connected:
    # only query the online time once per hour (and on first run)
    if (not localtile_refresh) or (time.monotonic() - localtile_refresh) > 3600:
        try:
            print("Getting time from internet!")
            pyportal.get_local_time()
            localtile_refresh = time.monotonic()
        except RuntimeError as e:
            print("Some error occured, retrying! -", e)
            continue

Once we have the time we can try displaying some data. We begin with the if statement if not alarm, meaning that as long as an alarm is not active we can proceed.

This is followed by an if statement that relies on time.monotonic(), this time running every ten minutes, to update the weather data, including the temperature, weather description, weather icon, etc. This is done by refreshing the JSON feed and then pulling in the display_weather() function from the openweather_graphics.py file, which takes the JSON data and slots it into the display. At the end, time.monotonic() is refreshed and stored in weather_refresh to start the count again. There is an exception for this function in case JSON data can't be accessed.

if not alarm:
    # only query the weather every 10 minutes (and on first run)
    #  only updates if an alarm is not active
        if (not weather_refresh) or (time.monotonic() - weather_refresh) > 600:
            try:
                value = pyportal.fetch()
                print("Response is", value)
                gfx.display_weather(value)
                weather_refresh = time.monotonic()
            except RuntimeError as e:
                print("Some error occured, retrying! -", e)
                continue

Again we're greeted with an if statement that is dependent on time_monotonic(), but a different time interval again, this time every 30 seconds. This portion of the code updates the time for the purposes of checking the alarms. An almost identical version of this is in the openweather_graphics.py file in the update_time() and update_date() functions, however their function is to update the date and time shown on the weather display.

We start by calling on time.localtime(), a Python function that pulls down time data in an array that can then be accessed. We're storing this array in the variable clock and then creating other variables to hold the different indexes in the accessed array; as seen with date, hour, minute, etc.

#  updates time to check alarms
    #  checks every 30 seconds
    #  identical to def(update_time) in openweather_graphics.py
    if (not start) or (time.monotonic() - start) > 30:
        #  grabs all the time data
        clock = time.localtime()
        date = clock[2]
        hour = clock[3]
        minute = clock[4]
        day = clock[6]

We then setup another variable called today, which is accessing the weekday array index that we setup before the loop. That array was formatted to match how the days of the week are indexed in time.localtime() so we can use the day variable to access the correct index in weekday. This is followed by some string formatting that we'll be using shortly, along with formatting to access the time in 12-hour time.

today = weekday[day]
        format_str = "%d:%02d"
        date_format_str = " %d, %d"
        if hour >= 12:
            hour -= 12
            format_str = format_str+" PM"
        else:
            format_str = format_str+" AM"
        if hour == 0:
            hour = 12
        #  formats date display
        today_str = today
        time_str = format_str % (hour, minute)

Next we get into the actual alarm checking, beginning first with our weekly alarms. We begin with a for statement so that we can iterate through the weekly_alarms array (which only has one index). This is followed by a nested if statement that checks to see if our formatted strings for the weekday and time both match-up with the information being pulled into the weekly_time and weekly_day arrays from the calendar.py file. If they do, then our alarm state is set to True.

This is followed by another if statement that depends on a few of our state machines from earlier. If alarm is True and dismissed and snoozed are both False, meaning that if the alarm is still active and you haven't pressed snooze or dismiss, then the display will show the alarm graphic that matches up with our index in the alarm_gfx array. The .wav file that matches the index in alarm_sounds will also play.

We then set the mode state to equal the variable w, which was holding our alarm index. This way we can refer to mode later in the loop to still refer to the current active alarm.

#  checks for weekly alarms
        for i in weekly_alarms:
            w = weekly_alarms.index(i)
            if time_str == weekly_time[w] and today == weekly_day[w]:
                print("trash time")
                alarm = True
                if alarm and not dismissed and not snoozed:
                    display.show(alarm_gfx[w])
                    pyportal.play_file(alarm_sounds[w])
                mode = w
                print("mode is:", mode)

Up next are the daily alarms, the alarms that are only dependent on the time of day rather than both the time of day and day of the week. It follows the same formatting that we saw in the if statement for weekly_alarms. We iterate through the alarm_checks index and then check to see if our time_str matches with any of the times in alarm_checks, which are being pulled in from calendar.py, then the alarm state is set to True and again, if our alarm state is True and it has not been dismissed nor snoozed, then the corresponding alarm graphic will show along with the matching alarm sound. Our mode state is also set to hold the alarm index value that had been stored in a.

#  checks for daily alarms
        for i in alarm_checks:
            a = alarm_checks.index(i)
            if time_str == alarm_checks[a]:
                alarm = True
                if alarm and not dismissed and not snoozed:
                    display.show(alarm_gfx[a])
                    pyportal.play_file(alarm_sounds[a])
                mode = a
                print(mode)

Once our alarms have been checked, then we can update our default weather display to show the correct date and time. This is done by called update_time() and update_date() from the openweather_graphics.py file. By doing this, the entire weather display doesn't have to be completely refreshed just to show that the clock has progressed one minute.

Now this doesn't make for a 100% accurate clock since we're only checking every 30 seconds, but it's fairly close for basic timekeeping.

We also reset time.monotonic() to be held in start to begin the whole process all over again 30 seconds from now.

#  calls update_time() and update_date() from openweather_graphics to update
        #  clock display
        gfx.update_time()
        gfx.update_date()
        #  resets time counter
        start = time.monotonic()

We're almost at the control section of the loop, but first we have a slight workaround so that everything will play well together. If you remember back to our array of touchscreen buttons, we only have three indexes in each touchscreen button array (snooze_buttons and dismiss_buttons) since we only have three alarm graphics. However, there are five total alarms with the meal alarms are all utilizing the same graphics and sounds. This means that the mode state, which holds our alarm position, could be equal to anything in the range of 0-4. Since we're using mode to access the alarm index outside of our alarm check portion of the code, this would throw an error if we tried to access our smaller array of touchscreen buttons.

To workaround this, we create an if statement that if mode is greater than 1, then a new state, button_mode, will equal 2, aka if any of the meal alarms are going off, then the meal alarm touchscreen buttons will be active. Otherwise button_mode will be the same as mode (0 or 1) aka the trash alarm or sleep alarm.

#  allows for the touchscreen buttons to work
    if mode > 1:
        button_mode = 2
    else:
        button_mode = mode
        #  print("button mode is", button_mode)

Before we take care of our touchscreen buttons though, we have our two physical buttons to take care of. First, we have some quick debouncing with each physical button's state (phys_dismiss and phys_snooze) and then we get into what each button controls when it's pressed.

#  hardware snooze/dismiss button setup
    if switch_dismiss.value and phys_dismiss:
        phys_dismiss = False
    if switch_snooze.value and phys_snooze:
        phys_snooze = False

For the dismiss button, it sets the dismissed state to True, the alarm state to False and takes away the alarm graphic to bring back the default weather graphic with all of our weather data. It also logs a time.monotonic() device (touched) and sets mode to equal mode as a back-up to our alarm index tracking.

if not switch_dismiss.value and not phys_dismiss:
        phys_dismiss = True
        print("pressed dismiss button")
        dismissed = True
        alarm = False
        display.show(pyportal.splash)
        touched = time.monotonic()
        mode = mode

Similar to dismiss, the snooze button sets the snoozed state to True, the alarm state to False and shows our default weather display. It also logs a time.monotonic() device (touched) and sets mode to equal itself.

if not switch_snooze.value and not phys_snooze:
        phys_snooze = True
        print("pressed snooze button")
        display.show(pyportal.splash)
        snoozed = True
        alarm = False
        touched = time.monotonic()
        mode = mode

Now we've reached the touchscreen buttons. First, we setup a touch object from the PyPortal library and then just like with our physical buttons we setup some debouncing. This is followed by an if statement to see if a touch point is detected on the PyPortal's screen. There are then two if statements for each of our touchscreen buttons called out with the arrays' index that corresponds with our button_mode state.

Essentially with touchscreen buttons, the button acts as this boundary setup by the coordinates of its location and size. If a touch point lands inside those coordinates, then an action can be triggered. In this case, the actions are identical to the touchscreen buttons' physical counterparts.

#  touchscreen button setup
    touch = pyportal.touchscreen.touch_point
    if not touch and touch_button_snooze:
        touch_button_snooze = False
    if not touch and touch_button_dismiss:
        touch_button_dismiss = False
    if touch:
        if snooze_buttons[button_mode].contains(touch) and not touch_button_snooze:
            print("Touched snooze")
            display.show(pyportal.splash)
            touch_button_snooze = True
            snoozed = True
            alarm = False
            touched = time.monotonic()
            mode = mode
        if dismiss_buttons[button_mode].contains(touch) and not touch_button_dismiss:
            print("Touched dismiss")
            dismissed = True
            alarm = False
            display.show(pyportal.splash)
            touch_button_dismiss = True
            touched = time.monotonic()
            mode = mode

The next if statement is a little bit of a workaround. Since the time, and as a result alarms, are checked every 30 seconds there is a chance that you could dismiss or snooze the alarm before the next 30 second check-in. This means that the time would be the same and the alarm could trigger again, despite you having already dismissed or snoozed it. This is why there's that caveat of if alarm and not dismissed and not snoozed: in order to display the alarm graphic and play the alarm sound earlier in the loop. However, we don't want our dismissed and snoozed states to stay active forever, they need to reset themselves for the next alarm.

For the dismissed state, we can use the touched time.monotonic() counter that begins counting when a button is pressed and waiting until over a minute has passed (70 seconds) to reset the dismissed state to False. This avoids any issues with alarms becoming active again and resets everything for the next alarm.

#  this is a little delay so that the dismissed state
    #  doesn't collide with the alarm if it's dismissed
    #  during the same time that the alarm activates
    if (not touched) or (time.monotonic() - touched) > 70:
        dismissed = False

The snooze portion is handled in a similar way. This if statement checks that the snoozed state is active and takes our touched time.monotonic() device to count up to the snooze_time that we've set in our calendar.py file. When the snooze_time has passed, the snoozed state becomes False, alarm becomes True, mode still equals mode and the PyPortal displays the alarm graphic and plays the alarm sound, basically reenacting the original alarm event. This brings us back to a place where an action needs to be received by one of the buttons to either snooze again or dismiss the alarm.

#  snooze portion
    #  pulls snooze_time from calendar and then when it's up
    #  splashes the snoozed alarm's graphic, plays the alarm sound and goes back into
    #  alarm state
    if (snoozed) and (time.monotonic() - touched) > timers['snooze_time']:
        print("snooze over")
        snoozed = False
        alarm = True
        mode = mode
        display.show(alarm_gfx[mode])
        pyportal.play_file(alarm_sounds[mode])
        print(mode)

This guide was first published on Jan 29, 2020. It was last updated on Jan 29, 2020.

This page (Code Walkthrough - code.py) was last updated on Apr 30, 2021.

Text editor powered by tinymce.