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=getattr(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=getattr(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=getattr(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)
Page last edited March 08, 2024
Text editor powered by tinymce.