Once you've finished setting up your Feather ESP32 V2 with CircuitPython, you're ready to load the code and libraries using the Project Bundle.

Click the blue Download Project Bundle button above the code shown below to download the Project Bundle zip.

# SPDX-FileCopyrightText: 2022 Kattni Rembor for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""CircuitPython WiFi Mailbox Notifier"""

import time
import ssl
import alarm
import board
import digitalio
import analogio
import wifi
import socketpool
import supervisor
import microcontroller
import adafruit_requests
from adafruit_io.adafruit_io import IO_HTTP

# Get WiFi/Adafruit IO details from secrets.py
try:
    from secrets import secrets
except ImportError:
    print("Please create secrets.py and add your WiFi and AIO credentials there!")
    raise

# Update to True if you want metadata sent to Adafruit IO. Defaults to False.
METADATA = False

# If the reason the board started up is due to a supervisor.reload()...
if supervisor.runtime.run_reason is supervisor.RunReason.SUPERVISOR_RELOAD:
    alarm.sleep_memory[3] += 1  # Increment reload number by 1.
    print(f"Reload number: {alarm.sleep_memory[3]}")  # Print current supervisor reload number.
    if alarm.sleep_memory[3] > 5:  # If supervisor reload number exceeds 5...
        # Print the following...
        print("Reload is not resolving the issue. \nBoard will hard reset in 20 seconds. ")
        time.sleep(20)  # ...wait 20 seconds...
        microcontroller.reset()  # ...and hard reset the board. This will clear alarm.sleep_memory.

# Initialise metadata.
if alarm.wake_alarm:
    print("Awake! Alarm type:", alarm.wake_alarm)
    # Increment wake count by 1.
    alarm.sleep_memory[0] += 1
else:
    print("Wakeup not caused by alarm.")
    # Set wake count to 0.
    alarm.sleep_memory[0] = 0
    # Set error count to 0.
    alarm.sleep_memory[2] = 0

# Print wake count to serial console.
print("Alarm wake count:", alarm.sleep_memory[0])

# No data has been sent yet, so the send-count is 0.
alarm.sleep_memory[1] = 0

# Set up battery monitoring.
voltage_pin = analogio.AnalogIn(board.VOLTAGE_MONITOR)
# Take the raw voltage pin value, and convert it to voltage.
voltage = (voltage_pin.value / 65536) * 2 * 3.3

# Set up red LED.
led = digitalio.DigitalInOut(board.LED)
led.switch_to_output()

# Set up the alarm pin.
switch_pin = digitalio.DigitalInOut(board.D27)
switch_pin.pull = digitalio.Pull.UP


# Send the data. Requires a feed name and a value to send.
def send_io_data(feed_name, value):
    """
    Send data to Adafruit IO.
    Provide an Adafruit IO feed name, and the value you wish to send.
    """
    feed = io.create_and_get_feed(feed_name)
    return io.send_data(feed["key"], value)


# Connect to WiFi
try:
    wifi.radio.connect(secrets["ssid"], secrets["password"])
    print("Connected to {}!".format(secrets["ssid"]))
    print("IP:", wifi.radio.ipv4_address)

    pool = socketpool.SocketPool(wifi.radio)
    requests = adafruit_requests.Session(pool, ssl.create_default_context())
# WiFi connectivity fails with error messages, not specific errors, so this except is broad.
except Exception as error:  # pylint: disable=broad-except
    print("Failed to connect to WiFi. Error:", error, "\nBoard will reload in 15 seconds.")
    alarm.sleep_memory[2] += 1  # Increment error count by one.
    time.sleep(15)
    supervisor.reload()

# Pull your Adafruit IO username and key from secrets.py
aio_username = secrets["aio_username"]
aio_key = secrets["aio_key"]
# Initialize an Adafruit IO HTTP API object
io = IO_HTTP(aio_username, aio_key, requests)

# Print battery voltage to the serial console and send it to Adafruit IO.
print(f"Current battery voltage: {voltage:.2f}V")
# Adafruit IO can run into issues if the network fails!
# This try/except ensures your code will continue to run.
try:
    led.value = True  # Turn on the LED to indicate data is being sent.
    send_io_data("battery-voltage", f"{voltage:.2f}V")
    led.value = False  # Turn off the LED to indicate data sending is complete.
# Adafruit IO can fail with multiple errors depending on the situation, so this except is broad.
except Exception as error:  # pylint: disable=broad-except
    print("Failed to send to Adafruit IO. Error:", error, "\nBoard will reload in 15 seconds.")
    alarm.sleep_memory[2] += 1  # Increment error count by one.
    time.sleep(15)
    supervisor.reload()

# While the door is open...
while not switch_pin.value:
    # Adafruit IO sending can run into various issues which cause errors.
    # This try/except ensures the code will continue to run.
    try:
        led.value = True  # Turn on the LED to indicate data is being sent.
        # Send data to Adafruit IO
        print("Sending new mail alert to Adafruit IO.")
        send_io_data("new-mail", "New mail!")
        print("Data sent!")
        # If METADATA = True at the beginning of the code, send more data.
        if METADATA:
            print("Sending metadata to Adafruit IO.")
            # The number of times the board has awakened by an alarm since the last reset.
            send_io_data("wake-count", alarm.sleep_memory[0])
            # The number of times the mailbox data has been sent.
            send_io_data("send-count", alarm.sleep_memory[1])
            # The number of WiFi or Adafruit IO errors that have occurred.
            send_io_data("error-count", alarm.sleep_memory[2])
            print("Metadata sent!")
        time.sleep(30)  # Delay included to avoid data limit throttling on Adafruit IO.
        alarm.sleep_memory[1] += 1  # Increment data send count by 1.
        led.value = False  # Turn off the LED to indicate data sending is complete.

    # Adafruit IO can fail with multiple errors depending on the situation, so this except is broad.
    except Exception as error:  # pylint: disable=broad-except
        print("Failed to send to Adafruit IO. Error:", error, "\nBoard will reload in 15 seconds.")
        alarm.sleep_memory[2] += 1  # Increment error count by one.
        time.sleep(15)
        supervisor.reload()

# Deinitialise the alarm pin.
switch_pin.deinit()

# Turn off the NeoPixel/I2C power for deep sleep.
power_pin = digitalio.DigitalInOut(board.NEOPIXEL_I2C_POWER)
power_pin.switch_to_output(False)

# Turn off LED for deep sleep.
led.value = False

# Create a timer alarm to be triggered every 12 hours (43200 seconds).
time_alarm = alarm.time.TimeAlarm(monotonic_time=(time.monotonic() + 43200))

# Create a pin alarm on pin D27.
pin_alarm = alarm.pin.PinAlarm(pin=board.D27, value=False, pull=True)

print("Entering deep sleep.")

# Exit and set the alarm.
alarm.exit_and_deep_sleep_until_alarms(pin_alarm, time_alarm)

Upload the Code and Libraries to the Feather ESP32 V2

Unzip the folder and upload the following items to your Feather.

  • lib/ folder
  • code.py

Create and/or Update secrets.py

CircuitPython uses information in a secrets.py file for WiFi and Adafruit IO credentials. You must create a secrets.py file that contains code resembling the following.

Once updated, save the file to your ESP32 Feather V2.

secrets = {
    "ssid": "wifi_ssid",
    "password": "wifi_password",
    "aio_username": "adafruit_io_username",
    "aio_key": "adafruit_io_key",
}
Do not share your secrets.py file once it has been updated with your credentials!

Code Walkthrough

Here's a walkthrough of the code.py file used with this project.

Choose Whether to Send Metadata to Adafruit IO

After the imports, you'll find a METADATA variable set to False. Update this to True if you would also like to send metadata to Adafruit IO. The metadata is generated by the code, and is regarding the Feather and the data being sent.

METADATA = False

When set to True, the code will also include the following feeds in your Adafruit IO account.

  • wake-count: The number of times the board has been awakened by an alarm since the last reset.
  • send-count: The number of times the mailbox data has been sent since the last time the board started up. The mailbox data is sent every 30 seconds as long as the mailbox door is open. This feed tracks that number.
  • error-count: The number of WiFi or Adafruit IO errors that have occurred since the last reset.

Reset the Feather based on the Number of Error Reloads

The next section is verifying why the Feather started up. If it's due to anything other than a standard CircuitPython reset-type start up, e.g. caused by a command found later in the code (supervisor.reload()), then it runs the rest of the code in this block.

if supervisor.runtime.run_reason is supervisor.RunReason.SUPERVISOR_RELOAD:
    alarm.sleep_memory[3] += 1
    print(f"Reload number: {alarm.sleep_memory[3]}")
    if alarm.sleep_memory[3] > 5:
        print("Reload is not resolving the issue. \nBoard will hard reset in 20 seconds. ")
        time.sleep(20)
        microcontroller.reset()

If the Feather restarted due to a reload, begin tracking the number of reloads. First, increment the reload number tracking by 1, then print the current number of reloads. If the number of reloads exceeds 5, then reloading is obviously not resolving the issue, and so, instead, hard reset the board.

Reload vs. Reset

You may have noticed that the actions "reload" and "reset" are both mentioned above. It's important to understand the difference.

A reload is what happens when you hit CTRL+D from the REPL, as well as when the board reloads and the serial console shows a "soft reboot" before beginning to run your code again. It is also what happens when you call supervisor.reload() in your code, as shown above. Data stored in alarm.sleep_memory will persist through a reload.

A reset is what happens when you press the reset button on your board. It can also happen automatically for a variety of reasons, including a low voltage brown out or a watchdog timer. It is what happens when you call microcontroller.reset() in your code, as shown above. Data stored in alarm.sleep_memory will not persist through a reset.

Initialise the Metadata and Various Pins

Here is where the metadata you opted in or out of above is initialised.

If the Feather start up was being awakened by an alarm (the mailbox door opening or the twelve hour battery voltage check-in), it will print to the serial console the type of alarm that caused it. It then increments the wake count by 1.

If the Feather startup was caused by something other than an alarm, it will print to the serial console, "Wakeup not caused by alarm.", and set the wake count and the error count to 0.

if alarm.wake_alarm:
    print("Awake! Alarm type:", alarm.wake_alarm)
    alarm.sleep_memory[0] += 1
else:
    print("Wakeup not caused by alarm.")
    alarm.sleep_memory[0] = 0
    alarm.sleep_memory[2] = 0

Then, the code prints the wake count to the serial console. This is the number of times the Feather has awaken due to an alarm since the last time the Feather was reset.

print("Alarm wake count:", alarm.sleep_memory[0])

Following that, as no data has been sent yet, the send count is set to 0.

alarm.sleep_memory[1] = 0

The next section of code sets up the various pins needed for this project.

First, you set up the voltage pin. The VOLTAGE_MONITOR pin returns a raw value.

To obtain a useful voltage value from that raw value, you must apply some math, which happens on the next line.

The raw pin value varies between 0-65535, a full-scale value for an analog input. Dividing the raw pin value by 65536 converts the raw value to a value between 0.0 and 1.0. Note that CircuitPython begins counting at 0, so while the values are between 0 and 65535, there are 65536 values, which is why the pin value is divided by 65536.

Next, it is multiplied by 2 to compensate for the hardware voltage divider on the Feather, which divides the value in half.

Finally, it is multiplied by 3.3 which is the reference voltage value.

This math results in a useful voltage value that you can use and track to determine when you need to charge or swap your battery!

voltage_pin = analogio.AnalogIn(board.VOLTAGE_MONITOR)
voltage = (voltage_pin.value / 65536) * 2 * 3.3

The little red LED is used to indicate that data is being sent to Adafruit IO. This code creates the LED object and sets it to an output.

led = digitalio.DigitalInOut(board.LED)
led.switch_to_output()

A majority project is based around using the reed switch to wake up the Feather and send data to Adafruit IO. The reed switch is connected physically to pin D27, but to use it in code, you are required tell CircuitPython where to look for it and how to manage it. This code creates the switch_pin on pin D27, and sets it to a pullup.

switch_pin = digitalio.DigitalInOut(board.D27)
switch_pin.pull = digitalio.Pull.UP

Adafruit IO Data Sending Helper Function

The next section is a helper function designed to send data to Adafruit IO.

def send_io_data(feed_name, value):
    feed = io.create_and_get_feed(feed_name)
    return io.send_data(feed["key"], value)

You might be wondering why a helper function is needed here. Basically, there are a number of places in the code where you send data to Adafruit IO. This function takes what would be longer and noisier code, and simplifies it. It keeps your code cleaner, and more importantly, easier to read.

Without this function, every time you wanted to send data to an Adafruit IO feed, you would include something like the following line. This example sends the string "New mail!" to the new-mail feed.

io.send_data(io.create_and_get_feed("new-mail")["key"], "New mail!")

The helper function simplifies the sending data code to the following.

send_io_data("new-mail", "New mail!")

Much simpler, right?

In general, you may find you're using a line or block of code repeatedly in your code. There are times when it makes sense to factor the repeated code into a helper function, and use that instead. It's not always the best option, but in many cases it can be super helpful.

Connect to WiFi

This section connects your Feather to WiFi. As WiFi is fraught with peril, the code is contained within a try and except block. This ensures your code will continue running if an error occurs.

The first part is inside the try. It pulls your WiFi credentials out of your secrets.py file, and uses them to connect to your WiFi network. It also prints that you are connected to your SSID, and the IP address assigned to the Feather. The next two lines create the socketpool and begins the requests session.

try:
    wifi.radio.connect(secrets["ssid"], secrets["password"])
    print("Connected to {}!".format(secrets["ssid"]))
    print("IP:", wifi.radio.ipv4_address)

    pool = socketpool.SocketPool(wifi.radio)
    requests = adafruit_requests.Session(pool, ssl.create_default_context())

The next part handles any potential errors. Without the try and except included, any error thrown by the code would halt, and your program would stop running. There is no way to notify you of this, other than the serial console, so unless it's plugged into your computer and you're viewing the console, you might now know your code is no longer running.

The except block begins with except Exception as error:. You could simply begin with except: and the code would run, however it's good Python practice to specify the error you're attempting to except. In this case, WiFi can fail for such a wide variety of reasons, it's not reasonable to include them all. So, this code excepts Exception, which while considered not quite up to good Python practice, allows the code to catch every possible error.

When an error occurs, it prints to the serial console that there's been an error, what the exact error text is, and that the board will reload in 15 seconds. It increments the error count by 1. Then there is a 15 second delay before reloading the Feather, which will restart the code.

except Exception as error:
    print("Failed to connect to WiFi. Error:", error, "\nBoard will reload in 15 seconds.")
    alarm.sleep_memory[2] += 1
    time.sleep(15)
    supervisor.reload()

Adafruit IO Credentials and HTTP Initialization

This section gets your connection to Adafruit IO set up so you can send data.

Your Adafruit IO username and key are pulled from secrets.py.

Your Adafruit IO credentials are then used to initialize the Adafruit IO HTTP API object.

aio_username = secrets["aio_username"]
aio_key = secrets["aio_key"]

io = IO_HTTP(aio_username, aio_key, requests)

Battery Voltage Data

You created the voltage pin earlier in your code, and applied the math necessary to obtain a useful voltage value. Now it's time to do something with that data!

First the code prints the data to the serial console.

print(f"Current battery voltage: {voltage:.2f}V")

Next you'll find another try and except block, for the same basic reason as described above in the WiFi section.

First, the red LED is powered on to indicate data is being sent.

The code then tries to send the battery voltage data to Adafruit IO. This is the first time you get to use the helper function. You provide the helper function with the feed name, battery-voltage, and the content to send to the feed, the voltage value.

The content sent is presented in what's called an f-string, which is a way to format strings in CircuitPython. In this example, you want to limit the number of decimal places to two, and include a V after the value so it's clear that it is a voltage value. The entire string begins with f, followed by code in quotes. Inside the quotes, the formatted value is inside brackets, and the rest of the desired text is included after the closing bracket.

Finally, the LED is turned off to indicate data sending is complete.

try:
    led.value = True
    send_io_data("battery-voltage", f"{voltage:.2f}V")
    led.value = False

This section should look very familiar; it is nearly identical to the WiFi except block above. The only difference is the text printed to the serial console. This time it refers to an Adafruit IO failure instead of WiFi. The rest of the content is the same and functions in the same way.

except Exception as error:
    print("Failed to send to Adafruit IO. Error:", error, "\nBoard will reload in 15 seconds.")
    alarm.sleep_memory[2] += 1
    time.sleep(15)
    supervisor.reload()

The Mailbox Door is Opened

This next section is only run when the switch is separated from its magnet, which is to say, the mailbox door is opened. In CircuitPython, when the switch used in this project is separated from the magnet, it returns False. Therefore, this block runs as long as the switch_pin.value is False. It will continue to repeat until the mailbox door is closed, and the magnet is reintroduced to the switch.

while not switch_pin.value:

As you're going to be sending data to Adafruit IO, the following sections of code are contained within a try.

First, turn on the red LED to indicate data is about to be sent.

The code prints to the serial console that it is sending the new mail alert to Adafruit IO. It then sends the phrase New mail! to the new-mail IO feed. Finally, it prints to the serial console that the data has been sent.

[...]
		led.value = True
		print("Sending new mail alert to Adafruit IO.")
        send_io_data("new-mail", "New mail!")
        print("Data sent!")

The next section of the code only runs if you enabled metadata at the beginning of the program by setting METADATA = True. If metadata is enabled, the code prints to the serial console that it is sending metadata to Adafruit IO. It then sends the wake count, send count, and error count to their respective feeds. Finally, it prints to the console that metadata has been sent.

[...]
		if METADATA:
            print("Sending metadata to Adafruit IO.")
            send_io_data("wake-count", alarm.sleep_memory[0])
            send_io_data("send-count", alarm.sleep_memory[1])
            send_io_data("error-count", alarm.sleep_memory[2])
            print("Metadata sent!")

This section ends with the following. First, there is a 30 second delay. This creates a delay in how often the data can be sent to Adafruit IO, which is included to avoid the data limit throttling built into Adafruit IO.

As data has been sent, the code increments the data send count by 1.

And finally, since the data is done being sent for now, turn off the LED.

[...]
		time.sleep(30)
        alarm.sleep_memory[1] += 1
        led.value = False

This section should look completely familiar; it is identical to the previous Adafruit IO except block. It looks the same and functions in the same way.

[...]
    except Exception as error:
        print("Failed to send to Adafruit IO. Error:", error, "\nBoard will reload in 15 seconds.")
        alarm.sleep_memory[2] += 1
        time.sleep(15)
        supervisor.reload()

Prepare for Deep Sleep and Set Up Alarms

This is the last section of the code. In it, you prepare for deep sleep, set up the wake alarms, and finally, enter deep sleep until one of the alarms awakens the Feather.

First, the code deinitialises the switch pin. This is so you can use it to create the pin alarm in a bit.

switch_pin.deinit()

Next, the code turns off the NeoPixel and I2C power pin, and the red LED, to conserve power draw while in deep sleep. Even if the NeoPixel is off and no I2C devices are plugged in, leaving the power pin on results in an increased power draw. The red LED should already be off, but on the off chance it's not, this is good to include.

power_pin = digitalio.DigitalInOut(board.NEOPIXEL_I2C_POWER)
power_pin.switch_to_output(False)

led.value = False

Then, you create the two alarms that can wake up the Feather from deep sleep.

First, you create a time alarm. A time alarm wakes the microcontroller at a specified time in the future. time.monotonic() is the number of seconds since the microcontroller was last reset. It is always increasing and will be different at any point in time in your code. You choose the length of time you would like to pass before the alarm is triggered, convert it to seconds, and add it to time.monotonic(). In this case, you want to send the battery voltage every 12 hours, both to keep track of it and have a heartbeat from the Feather to keep an eye on. To convert 12 hours to seconds, you multiply 12 * 60 minutes per hour * 60 seconds per minute which is 43200. Therefore, you add 43200 seconds. 

time_alarm = alarm.time.TimeAlarm(monotonic_time=(time.monotonic() + 43200))

Next you create the pin alarm. A pin alarm wakes up the microcontroller when a specified pin changes state. While the mailbox door is closed, the switch is open. When the mailbox door opens, the switch closes. This change in state triggers the pin alarm. As the switch is connected to pin D27, you'll use that pin.

pin_alarm = alarm.pin.PinAlarm(pin=board.D27, value=False, pull=True)

The code then prints to the serial console that the Feather is entering deep sleep.

print("Entering deep sleep.")

Finally, the code exits and deep sleeps until the either of the specified alarms are triggered, in this case, the pin alarm or the time alarm created above.

alarm.exit_and_deep_sleep_until_alarms(pin_alarm, time_alarm)

This guide was first published on Sep 14, 2022. It was last updated on May 25, 2024.

This page (Code the WiFi Mailbox Notifier) was last updated on May 25, 2024.

Text editor powered by tinymce.