Startup
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
Settings
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:
temperature_range:15-30 humidity_range:60-70 title:Jar Minder alarm_frequency:4000 alarm_number_of_beeps:3 alarm_seconds_beep_on:0.5 alarm_seconds_between_beeps:0.5 alarm_timeout:60.0
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]
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) else: 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 font.py 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 https://github.com/dhepper/font8x8 (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 time.sleep(0.1) 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 time.sleep(0.5) buzzer.duty_cycle = OFF display.clear_buffer() render_string(39, 64, 'EDIT MODE') display.display() while not silence_button.value: # wait for button to be released pass while silence_button.value: # wait for button to be pressed pass buzzer.duty_cycle = ON time.sleep(0.5) 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:
display.clear_buffer() render_string(centered(settings['title']), 12, settings['title']) # Display text row_index = 64 for line in text: if row_index > 112: break 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' else: temperature_message = '' if humidity < settings['humidity_range'][0]: humidity_message = 'LOW HUMIDITY' elif humidity > settings['humidity_range'][1]: humidity_message = 'HIGH HUMIDITY' else: 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) display.display()
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(): break if check_for_push(silence_button, settings['alarm_seconds_between_alarms']): break done.value = True
Page last edited March 08, 2024
Text editor powered by tinymce.