As mentioned earlier, we want to configure and set the DONE signal low as soon as possible after the the runtime hands control to our code.

import digitalio
import board

done = digitalio.DigitalInOut(board.A4)
done.direction = digitalio.Direction.OUTPUT
done.value = False

Once that's done, the usual imports and setup can be done. There are no surprises here: display, sensor, buzzer, and switch are set up as you would expect. The display setup is covered in it's tutorial guide. Likewise, see the guide for the sensor.

import time
import pulseio
import busio
from adafruit_epd.epd import Adafruit_EPD
from adafruit_epd.il0373 import Adafruit_IL0373
import adafruit_si7021
import font

spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO)
ecs = digitalio.DigitalInOut(board.D11)
dc = digitalio.DigitalInOut(board.D10)
srcs = digitalio.DigitalInOut(board.D9)
rst = digitalio.DigitalInOut(board.D6)
busy = digitalio.DigitalInOut(board.D12)
display = Adafruit_IL0373(152, 152, rst, dc, busy, srcs, ecs, spi)

i2c = busio.I2C(board.SCL, board.SDA)
sensor = adafruit_si7021.SI7021(i2c)

ON = 2**15
OFF = 0

buzzer = pulseio.PWMOut(board.D5, variable_frequency=True)
buzzer.duty_cycle = OFF

silence_button = digitalio.DigitalInOut(board.A5)
silence_button.direction = digitalio.Direction.INPUT
silence_button.pull = digitalio.Pull.UP


Various aspects of the system's behavior are controlled by a collection of settings. These are read from the settings.txt file on the CIRCUITPY drive. Once read they are stored in a dictionary which is initialized with default values after the hardware is set up.

settings = {}
settings['temperature_range'] = (15, 30)
settings['humidity_range'] = (60, 70)
settings['title'] = 'Jar Minder'
settings['alarm_frequency'] = 4000
settings['alarm_number_of_beeps'] = 3
settings['alarm_seconds_beep_on'] = 0.5
settings['alarm_seconds_between_beeps'] = 0.5
settings['alarm_seconds_between_alarms'] = 5.0
settings['alarm_timeout'] = 60.0

temperature_range takes the low and high bounds of the acceptable temperature range: two integers separated by a dash.

humidity_range is similar for the humidity range.

title is a string (at most 19 characters with no surrounding quotes) that is shown at the top of the display.

alarm_frequency is the frequency of alarm beeps as an int.

alarm_number_of_beeps is the number of beeps in an alarm as an int.

alarm_seconds_beep_on the amount of time each alarm beep sounds (seconds, a float).

alarm_seconds_between_beeps the amount of time between alarm beeps (seconds, a float).

alarm_seconds_between_alarms the amount of time between groups of alarm beeps (seconds, a float).

alarm_timeout how long (ins seconds) to continue sounding the alarm (float) after which the device will go to sleep.

Here's an example of the setting file:

title:Jar Minder

The code that reads the setting file is pretty simple:

def to_int_tuple(a):
    """Convert an array of strings to a tuple of ints.
    :param [int] a: array of strings to convert

    return tuple([int(x.strip()) for x in a])

with open('settings.txt', 'r') as f:
    for line in f:
        key, value = line.strip().split(':').strip()
        values = value.split('-')
        if key == 'temperature_range':
            setting = to_int_tuple(values)
        elif key == 'humidity_range':
            setting = to_int_tuple(values)
        elif key == 'title':
            setting = value
        elif key == 'alarm_frequency':
            setting = int(value)
        elif key == 'alarm_number_of_beeps':
            setting = int(value)
        elif key == 'alarm_seconds_beep_on':
            setting = float(value)
        elif key == 'alarm_seconds_between_beeps':
            setting = float(value)
        elif key == 'alarm_timeout':
            setting = float(value)
        settings[key] = setting  

Description Text

Along with the settings file, there is a file of text named description.txt which contains informative text to be displayed on the screen. It is limited to 4 lines of 19 characters each. This can be used to identify the contents, when it was put in the jar, etc. It gets read and stored for display:

with open('description.txt', 'r') as f:
    text = [line.strip() for line in f]

Rendering Text

There are a few functions to display text on the display:

def render_character(x, y, ch, color=Adafruit_EPD.BLACK):
    """Render a character.
    :param int x: horizontal position of the left edge of the character
    :param int y: vertical position of the top edge of the character
    :param str ch: a single character string to be displayed
    :param Adafruit_EPD.* color: BLACK or RED, background is always white

    if x < 144 and y < 144:
        bitmap = font.bitmaps[ord(ch)]
        for row_num in range(8):
            row = bitmap[row_num]
            for column_num in range(8):
                if (row & 1) == 0:
                    display.draw_pixel(x + column_num, y + row_num, Adafruit_EPD.WHITE)
                    display.draw_pixel(x + column_num, y + row_num, color)
                row >>= 1

def render_string(x, y, s, color=Adafruit_EPD.BLACK):
    """Render a string.
    :param int x: horizontal position of the left edge of the string
    :param int y: vertical position of the top edge of the string
    :param str ch: a string to be displayed
    :param Adafruit_EPD.* color: BLACK or RED, background is always white

    x_pos = x
    for ch in s:
        render_character(x_pos, y, ch, color)
        x_pos += 8

def centered(s):
    """Computer the X position to center a string.
    :param str s: the string to center
    return 75 - (4 * len(s))

The centered function determines where to start a line to center it horizontally. render_string iterates through a string, rendering each character by using render_character, moving across the screen as it goes. A character is rendered by setting each pixel in its 8x8 grid to white (the background, represented by a 0 bit in the font data) or the foreground color that is passed in (black or red).

Where do these character bitmaps come from? At the start we imported which contains the bitmaps for the 127 ASCII characters. It's done as Python code that defines an array of arrays. Each inner array contains 8 bytes: each bit is a pixel in the character bitmap. A small section is shown below, and is based on font8x8_basic.h from (which is in the public domain).

bitmaps = [
    [ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00],   # U+0041 (A)
    [ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00],   # U+0042 (B)
    [ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00],   # U+0043 (C)
    [ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00],   # U+0044 (D)
    [ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00],   # U+0045 (E)

Other Support Functions

There are three other functions.

First is check_for_push which monitors the pushbutton for a specified period of time. Every 100mS during that time the button is checked. If it is pushed, True is immediately returned. If the time expires False is returned.

def check_for_push(button, duration):
    """Wait for a time, regularly checking for a button push.
    :param DigitalInOut button: the button input to check
    :param float duration: seconds to wait
    Return True if the button is pushed, False if the time passes

    stop_at = time.monotonic() + duration
    while time.monotonic() < stop_at:
        if not button.value:
            return True
    return False

Next is sound_alarm which is responsible for generating a series of alarm beeps as specified in the settings. The check_for_push function is called during each time of the beeps and the time between beeps. As soon as it returns True, the alarm sequence is terminated and sound_alarm returns True.

def sound_alarm():
    """Sound the alarm based on the settings."""

    buzzer.frequency = settings['alarm_frequency']
    for _ in range(settings['alarm_number_of_beeps']):
        buzzer.duty_cycle = ON
        if check_for_push(silence_button, settings['alarm_seconds_beep_on']):
            buzzer.duty_cycle = OFF
            return True
        buzzer.duty_cycle = OFF
        if check_for_push(silence_button, settings['alarm_seconds_between_beeps']):
            return True
    return False

Finally, there is out_of_range which takes temperature and humidity readings and checks them against the appropriate range. True is returned if either reading is out of range, False otherwise.

def out_of_range(t, h):
    """Check if either temperature and humidity is out of range.
    :param float t: temperature reading
    :param float h: humidity reading

    if t < settings['temperature_range'][0]:
        return True
    if t > settings['temperature_range'][1]:
        return True
    if h < settings['humidity_range'][0]:
        return True
    if h > settings['humidity_range'][1]:
        return True
    return False

Edit Mode

When the system starts up the switch is checked. If it is pressed edit mode is entered. This is indicated by a low beep and the message EDIT MODE on the display. The system stays in edit mode until the switch is pressed again. Another low beep is sounded and the normal mode is entered. The purpose of this is to keep the power on while the user edits the setting and description files/

if not silence_button.value:
    buzzer.frequency = 440
    buzzer.duty_cycle = ON
    buzzer.duty_cycle = OFF
    render_string(39, 64, 'EDIT MODE')
    while not silence_button.value:       # wait for button to be released
    while silence_button.value:           # wait for button to be pressed
    buzzer.duty_cycle = ON
    buzzer.duty_cycle = OFF

Main Script

After settings and description are loaded as described above, the display is updated based on readings. First the title and description:

render_string(centered(settings['title']), 12, settings['title'])

# Display text
row_index = 64
for line in text:
    if row_index > 112:
    render_string(centered(line), row_index, line)
    row_index += 10

Next the sensor is read and the values displayed.

temperature = int(sensor.temperature)
humidity = int(sensor.relative_humidity)
render_string(8, 32, '{0:2d} C'.format(temperature))
render_string(112, 32, '{0:2d} %'.format(humidity))

Once we have the readings, they are checked against their respective ranges and explanatory text assigned. If a reading is in range, this will be an empty string.

if temperature < settings['temperature_range'][0]:
    temperature_message = 'LOW TEMPERATURE'
elif temperature > settings['temperature_range'][1]:
    temperature_message = 'HIGH TEMPERATURE'
    temperature_message = ''

if humidity < settings['humidity_range'][0]:
    humidity_message = 'LOW HUMIDITY'
elif humidity > settings['humidity_range'][1]:
    humidity_message = 'HIGH HUMIDITY'
    humidity_message = ''

If a reading is out of range, the associated text is displayed in red and red bars are displayed at the top and bottom of the screen.

if temperature_message:
    render_string(centered(temperature_message), 122, temperature_message, Adafruit_EPD.RED)
if humidity_message:
    render_string(centered(humidity_message), 132, humidity_message, Adafruit_EPD.RED)

if temperature_message or humidity_message:
    display.fill_rect(0, 0, 152, 10, Adafruit_EPD.RED)
    display.fill_rect(0, 142, 152, 10, Adafruit_EPD.RED)


Finally, we handle sounding the alarm. While either reading is out of range and the alarm timeout hasn't expired, the series of alarm beeps is generated each interval specified in the settings. Once the timeout expires or the switch is pressed, the alarm stops and the device powers down by setting the DONE signal True/high.

timeout = time.monotonic() + settings['alarm_timeout']

while out_of_range(sensor.temperature, sensor.humidity) and time.monotonic() < timeout:
    if sound_alarm():
    if check_for_push(silence_button, settings['alarm_seconds_between_alarms']):

done.value = True

This guide was first published on Feb 13, 2019. It was last updated on Jun 14, 2024.

This page (Code Operation) was last updated on Mar 08, 2024.

Text editor powered by tinymce.