# SPDX-FileCopyrightText: 2019 Dave Astels for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
PyPortal based alarm clock.
Adafruit invests time and resources providing this open source code.
Please support Adafruit and open source hardware by purchasing
products from Adafruit!
Written by Dave Astels for Adafruit Industries
Copyright (c) 2019 Adafruit Industries
Licensed under the MIT license.
All text above must be included in any redistribution.
"""
# pylint:disable=redefined-outer-name,no-member,global-statement, line-too-long, invalid-name
# pylint:disable=no-self-use,too-many-branches,too-many-statements, import-error
# pylint:disable=useless-super-delegation, too-many-locals, no-name-in-module
import math
import time
import board
from adafruit_bitmap_font import bitmap_font # type: ignore
from adafruit_display_text.label import Label # pyright: ignore[reportMissingImports]
import adafruit_sgp40 # pyright: ignore[reportMissingImports]
import analogio
# import displayio
import adafruit_logging as logging # pyright: ignore[reportMissingImports]
import bitbangio
import rtc
import adafruit_gps # pyright: ignore[reportMissingImports]
import neopixel # pyright: ignore[reportMissingImports]
from microcontroller import nvm
from adafruit_bme280 import basic as bmE280 # pyright: ignore[reportMissingImports]TZ_CUR = nvm[0] # type: ignore
import adafruit_sht31d
import adafruit_lps2x
from adafruit_pyportal import PyPortal # type: ignore
# read time zone
if nvm[0] > 23 or nvm[0] < 0: # pyright: ignore[reportOptionalSubscript]
TZ_CUR = 4
nvm[0] = 4 # pyright: ignore[reportOptionalSubscript]
else:
TZ_CUR = nvm[0] # type: ignore
####################
# setup hardware
# i2c = board.I2C()
# i2c = busio.I2C(board.SCL, board.SDA, frequency=100_000)
# bitbangio used due to its implementation working better (less i2c errors)
i2c = bitbangio.I2C(board.SCL, board.SDA, frequency=100_000) # type: ignore
gps = adafruit_gps.GPS_GtopI2C(i2c, debug=False) # type: ignore # Use I2C interface
gps.send_command(b"PMTK314,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0")
gps.send_command(b"PMTK220,1000")
pixels = neopixel.NeoPixel(board.NEOPIXEL, 1) # type: ignore
bmE280.sea_level_pressure = 1013.25 # type: ignore
outside = adafruit_sht31d.SHT31D(i2c)
inside = bmE280.Adafruit_BME280_I2C(i2c) # type: ignore
lps = adafruit_lps2x.LPS22(i2c)
sgp = adafruit_sgp40.SGP40(i2c)
while gps.timestamp_utc is None:
print("No time data from GPS yet")
pixels[0] = (0, 0, 0)
time.sleep(.7)
pixels[0] = (128, 0, 0)
time.sleep(.7)
gps.update()
print(gps.readline())
pixels[0] = (0, 0, 0)
the_rtc = rtc.RTC()
the_rtc.datetime = time.struct_time(gps.timestamp_utc)
pyportal = PyPortal()
light = analogio.AnalogIn(board.LIGHT) # type: ignore
# init logfile
# t_stamp = "{0:x}".format(time.time())
DELIMETER = "\t"
LOG_FILE = f"/sd/{time.time()}gps.txt"
LOG_MODE = "a"
with open(LOG_FILE, LOG_MODE, encoding="utf-8") as outfile: # type: ignore
print("UTC",DELIMETER,"lat",DELIMETER,"long",DELIMETER,"alt",DELIMETER,"Speed",DELIMETER,"intemp",DELIMETER,"intemp2",DELIMETER,"inh",DELIMETER,"otemp",DELIMETER,"outh",DELIMETER,"barr1",DELIMETER,"barr2",DELIMETER,"airQ", file=outfile)
print("envlogfile created")
####################
# variables
Speed = 0
# alarm support
"""
alarm_background = 'red_alertl.bmp'
alarm_file = 'alarm.wav'
alarm_enabled = False
alarm_armed = False
alarm_interval = 10.0
alarm_hour = 9
alarm_minute = 45
snooze_time = None
snooze_interval = 600.0
"""
# pressure support
# mugsy_background = 'mugsy_backgroundl.bmp'
# weather support
icon_file = None
icon_sprite = None
celcius = None
# display/data refresh timers
refresh_time = None
update_time = None
weather_refresh = None
# The most recently fetched time
current_time = None
# track whether we're in low light mode
low_light = False
####################
# Load the fonts
print("loading fonts")
time_font = bitmap_font.load_font('/fonts/Asimov-104.pcf')
time_font.load_glyphs(b'0123456789:%') # pre-load glyphs for fast printing # type: ignore
alarm_font = bitmap_font.load_font('/fonts/AsimovNar-45.pcf')
alarm_font.load_glyphs(b'0123456789mph.:%') # type: ignore
title_font = bitmap_font.load_font('/fonts/Asimov-25.pcf')
title_font.load_glyphs(b'INSIDEOUT') # type: ignore
temperature_font = bitmap_font.load_font('/fonts/AsimovNar-40.pcf')
temperature_font.load_glyphs(b'0123456789INSIDEOUTFft.%') # type: ignore
print("fonts loaded")
####################
# Set up logging
logger = logging.getLogger('alarm_clock')
logger.addHandler(logging.FileHandler("/sd/syslogz.txt", "a"))
logger.setLevel(logging.INFO) # change as desired # type: ignore
print("logger loaded")
####################
# Functions
# def debug(*args, **kwargs):
# lets both print and write to the file
# print(*args, **kwargs)
# print(*args, **kwargs, file=outfile) # type: ignore
def create_text_areas(configs):
"""Given a list of area specifications, create and return text areas."""
print("creating text areas")
text_areas = []
for cfg in configs:
textarea = Label(cfg['font'], text=' '*cfg['size'])
textarea.x = cfg['x']
textarea.y = cfg['y']
textarea.color = cfg['color']
text_areas.append(textarea)
return text_areas
def calc_dewpoint(humidity, temp_c):
"""Calculate dew point given relative humidity and temperature in Celsius."""
a = 17.625
b = 243.04
alpha = math.log(humidity/100.0) + ((a * temp_c) / (b + temp_c))
return (b * alpha) / (a - alpha)
def clear_splash():
"""clear the screen"""
print("clear_splash")
for _ in range(len(pyportal.root_group) - 1):
pyportal.root_group.pop()
def touch_in_button(t, b):
"""touch"""
in_horizontal = b['left'] <= t[0] <= b['right']
in_vertical = b['top'] <= t[1] <= b['bottom']
return in_horizontal and in_vertical
touched = False
####################
# states
class State(object):
"""State abstract base class"""
def __init__(self):
pass
@property
def name(self):
"""Return the name of teh state"""
return ''
def tick(self, now):
"""Handle a tick: one pass through the main loop"""
# pylint:disable=unused-argument
def touch(self, t, touched):
"""Handle a touch event.
:param (x, y, z) - t: the touch location/strength"""
return bool(t)
def enter(self):
"""Just after the state is entered."""
print(State)
def exit(self):
"""Just before the state exits."""
clear_splash()
class Time_State(State):
"""This state manages the primary time display screen/mode"""
def __init__(self):
super().__init__()
self.background_day = 'main_background_dayl.bmp'
self.background_night = 'main_background_nightl.bmp'
self.refresh_time = None
self.update_time = None
self.weather_refresh = None
text_area_configs = [dict(x=160, y=235, size=5, color=0xFFFFFF, font=time_font), # TIME
dict(x=325, y=100, size=5, color=0xE09941, font=alarm_font), # SPEED
dict(x=120, y=33, size=16, color=0xCFFC03, font=title_font), # screen heading
dict(x=125, y=70, size=6, color=0xFFFFFF, font=temperature_font), # INSIDE temp
dict(x=125, y=115, size=6, color=0xFFFFFF, font=temperature_font), # INSIDE humid
dict(x=215, y=70, size=6, color=0xFFFFFF, font=temperature_font), # OUTSIDE temp
dict(x=215, y=115, size=6, color=0xFFFFFF, font=temperature_font), # OUTSIDE humid
dict(x=145, y=170, size=12, color=0x32A89E, font=temperature_font), # Altitude
dict(x=310, y=180, size=5, color=0xCFFC03, font=title_font)] # Air Quality
self.text_areas = create_text_areas(text_area_configs)
# self.weather_icon = displayio.Group()
# self.weather_icon.x = 125
# self.weather_icon.y = 25
# self.icon_file = None
print("creating buttons")
# each button has it's edges as well as the state to transition to when touched
self.buttons = [dict(left=0, top=72, right=118, bottom=158, next_state='settings'),
dict(left=0, top=208, right=118, bottom=294, next_state='pressurel')]
@property
def name(self):
return 'time'
def adjust_backlight_based_on_light(self, force=False):
"""Check light level. Adjust the backlight and background image if it's dark."""
global low_light
if light.value <= 800 and (force or not low_light):
pyportal.set_backlight(0.01)
pyportal.set_background(self.background_night)
low_light = True
elif force or (light.value >= 2000 and low_light):
pyportal.set_backlight(1.00)
pyportal.set_background(self.background_day)
low_light = False
def tick(self, now):
global update_time, outfile
# check light level and adjust background & backlight
self.adjust_backlight_based_on_light()
# only query the online time once per hour (and on first run)
if (not self.refresh_time) or ((now - self.refresh_time) > 3600):
logger.debug('Fetching time')
try:
gps.update()
the_rtc.datetime = time.struct_time(gps.timestamp_utc) # type: ignore
self.refresh_time = now
except RuntimeError as e:
self.refresh_time = now - 3000 # delay 10 minutes before retrying
logger.error('Some error occured, retrying! - %s', str(e))
# only query the weather every 10 minutes (and on first run)
# if (not self.weather_refresh) or (now - self.weather_refresh) > 1:
print("reading sensors")
logger.debug('Fetching weather')
try:
gps.update()
if gps.speed_knots is not None:
Speed = '%5d mph' % round(gps.speed_knots * 1.151) # type: ignore
else:
Speed = "0"
self.text_areas[1].text = Speed
i1 = (inside.temperature * 9 / 5) + 32
i2 = (lps.temperature * 9 / 5) + 32
intemp = '%3d F' % round(i1)
intemp2 = '%3d F' % round(i2)
it_text = '%3d F' % round((i1 + i2) / 2)
self.text_areas[2].text = "INSIDE OUTSIDE"
self.text_areas[3].text = it_text
inrh = inside.relative_humidity
humi_text = '%3d' % round((calc_dewpoint(inside.relative_humidity, inside.temperature)* 9 / 5) + 32 )
self.text_areas[4].text = humi_text
outtemp = outside.temperature
ot_text = '%3d F' % round(((outtemp * 9 / 5) + 32)) # type: ignore
self.text_areas[5].text = ot_text
humo_text = '%3d' % round((calc_dewpoint(outside.relative_humidity, outside.temperature)* 9 / 5) + 32 ) # type: ignore
print("humo_text:", (calc_dewpoint(outside.relative_humidity, outside.temperature)* 9 / 5) + 32)
self.text_areas[6].text = humo_text
VOCIdx = sgp.measure_index(temperature = inside.temperature, relative_humidity = inrh)
self.text_areas[8].text = '%3d' % round(VOCIdx)
if gps.altitude_m is not None:
altftxt = '%6.2d ft' % round(gps.altitude_m * 3.28084, 2)
else:
altftxt = "0"
self.text_areas[7].text = altftxt
self.weather_refresh = now
t_stamp = time.time()
with open(LOG_FILE, LOG_MODE, encoding="utf-8") as outfile:
print(t_stamp, DELIMETER, gps.latitude, DELIMETER, gps.longitude, DELIMETER, altftxt, DELIMETER, Speed,
DELIMETER, intemp, DELIMETER, intemp2, DELIMETER, humi_text, DELIMETER, ot_text, humo_text, " ", inside.pressure, DELIMETER, lps.pressure, " ", VOCIdx, file=outfile)
try:
board.DISPLAY.refresh(target_frames_per_second=60) # type: ignore
except AttributeError:
board.DISPLAY.refresh_soon() # type: ignore
board.DISPLAY.wait_for_frame() # type: ignore
except RuntimeError as e:
self.weather_refresh = now - 540 # delay a minute before retrying
logger.error("Some error occured, retrying! - %s", str(e))
if (not update_time) or ((now - update_time) > 30):
# Update the time
update_time = now
current_time = time.localtime()
time_string = '%02d:%02d' % (((current_time.tm_hour-TZ_CUR) % 24), current_time.tm_min)
self.text_areas[0].text = time_string
try:
board.DISPLAY.refresh(target_frames_per_second=60) # type: ignore
except AttributeError:
board.DISPLAY.refresh_soon() # type: ignore
board.DISPLAY.wait_for_frame() # type: ignore
def touch(self, t, touched):
if t and not touched: # only process the initial touch
for button_index in enumerate(self.buttons):
b = self.buttons[button_index]
if touch_in_button(t, b):
change_to_state(b['next_state']) # type: ignore
break
return bool(t)
def enter(self):
self.adjust_backlight_based_on_light(force=True)
for ta in self.text_areas:
pyportal.root_group.append(ta)
# pyportal.root_group.append(self.weather_icon)
try:
board.DISPLAY.refresh(target_frames_per_second=60) # type: ignore
except AttributeError:
board.DISPLAY.refresh_soon() # type: ignore
board.DISPLAY.wait_for_frame() # type: ignore
def exit(self):
super().exit()
class Pressure_State(Time_State):
"""This state records a pressure log """
def __init__(self):
super().__init__()
text_area_configs = [dict(x=160, y=220, size=3, color=0xFFAAFF, font=time_font)]
self.text_areas = create_text_areas(text_area_configs)
@property
def name(self):
return 'pressurel'
def tick(self, now):
# Once the job is done, go back to the main screen
change_to_state('time') # type: ignore
def enter(self):
global low_light
for ta in self.text_areas:
pyportal.root_group.append(ta)
low_light = False
plog_path = "/sd/"
# pyportal.set_backlight(1.00)
# pyportal.set_background(mugsy_background)
lps.data_rate = adafruit_lps2x.Rate.LPS22_RATE_75_HZ # type: ignore
gps.update()
t_stamp = time.time()
PLOG_FILE = f"{t_stamp}pressure.txt"
start = time.monotonic_ns() / 1e9
with open((plog_path+PLOG_FILE), LOG_MODE, encoding="utf-8") as pressfile:
print("timestamp, latitude, longitude, altitude_m, \n", file=pressfile)
print(t_stamp, gps.latitude, gps.longitude, gps.altitude_m, file=pressfile)
curr_time = 0
while curr_time < 60:
curr_time = (time.monotonic_ns() / 1e9) - start
print(curr_time, lps.pressure, file=pressfile)
cntdwn = '%3d' % round((60 - curr_time))
self.text_areas[0].text = cntdwn
try:
board.DISPLAY.refresh(target_frames_per_second=60) # type: ignore
except AttributeError:
board.DISPLAY.refresh_soon() # type: ignore
board.DISPLAY.wait_for_frame() # type: ignore
lps.data_rate = adafruit_lps2x.Rate.LPS22_RATE_1_HZ # type: ignore
class Setting_State(State):
"""This state lets the user adjust UTC offset."""
def __init__(self):
super().__init__()
self.previous_touch = None
self.background = 'settings_backgroundl.bmp'
text_area_configs = [dict(x=160, y=220, size=5, color=0xFFFFFF, font=time_font)]
self.text_areas = create_text_areas(text_area_configs)
self.buttons = [dict(left=0, top=40, right=115, bottom=123), # increase
dict(left=0, top=130, right=115, bottom=150), # return
dict(left=0, top=160, right=115, bottom=295)] # decrease
# dict(left=150, top=0, right=300, bottom=320), # hours
# dict(left=310, top=0, right=480, bottom=320)] # minutes
time_string = '%02d' % TZ_CUR
self.text_areas[0].text = time_string
@property
def name(self):
return 'settings'
def touch(self, t, touched):
global TZ_CUR
if t:
if touch_in_button(t, self.buttons[0]): # increase offset
logger.debug('increase offset')
TZ_CUR += 1
time_string = '%02d' % TZ_CUR
self.text_areas[0].text = time_string
elif touch_in_button(t, self.buttons[1]): # return
logger.debug('RETURN touched')
nvm[0] = TZ_CUR # type: ignore
change_to_state('time')
elif touch_in_button(t, self.buttons[2]): # decrease offset
logger.debug('decrease offset')
TZ_CUR -= 1
time_string = '%02d' % TZ_CUR
self.text_areas[0].text = time_string
try:
board.DISPLAY.refresh(target_frames_per_second=60) # type: ignore
except AttributeError:
board.DISPLAY.refresh_soon() # type: ignore
board.DISPLAY.wait_for_frame() # type: ignore
else:
self.previous_touch = None
return bool(t)
def enter(self):
pyportal.set_background(self.background)
for ta in self.text_areas:
pyportal.root_group.append(ta)
# if alarm_enabled:
# self.text_areas[0].text = '%02d:%02d' % (alarm_hour, alarm_minute) # set time textarea
# else:
# self.text_areas[0].text = ' '
####################
# State management
states = {'time': Time_State(),
'pressurel': Pressure_State(),
'settings': Setting_State()}
current_state = None
def change_to_state(state_name):
"""."""
global current_state
if current_state:
logger.debug('Exiting %s', current_state.name)
current_state.exit()
current_state = states[state_name]
logger.debug('Entering %s', current_state.name)
current_state.enter()
####################
# And... go
clear_splash()
change_to_state("time")
while True:
touched = current_state.touch(pyportal.touchscreen.touch_point, touched) # type: ignore
current_state.tick(time.monotonic()) # type: ignore