diff --git a/docs/lcd_control.md b/docs/lcd_control.md index 0644889..211f919 100644 --- a/docs/lcd_control.md +++ b/docs/lcd_control.md @@ -2,6 +2,12 @@ The below will setup an Adafruit mini LCD with 2 buttons as a controller for selecting which photo albums are displayed on a PiFrame. This setup also includes the Adafruit stemma/qwiic LUX sensor setup which will be integrated further in the future. +Please note: +- Single button presses need about 1/4 of a second to register +- Holding 'Up' for at least 3 seconds will trigger a prompt to reboot +- Holding 'Down' for at least 3 seconds will trigger a prompt to power off +- If you hold 'Down' on the reboot or power off prompts a debug menu will be displayed + ``` sh apt install -y python3-pip ttf-dejavu python3-pil python3-numpy diff --git a/lcd_control.py b/lcd_control.py index b06be0f..106ec4c 100644 --- a/lcd_control.py +++ b/lcd_control.py @@ -1,28 +1,40 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- -#################### +#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +# IMPORTANT CONSIDERATIONS +# - Button presses may be missed if you push/release too fast +# - Button presses read most reliably after about 0.25 seconds being held down +#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +######################################## # Inspiration / Further Reading -#################### +######################################## # https://circuitpython.readthedocs.io/projects/bh1750/en/latest/ # https://raspi.tv/2013/how-to-use-interrupts-with-python-on-the-raspberry-pi-and-rpi-gpio-part-3 # https://stackoverflow.com/questions/16148735/how-to-implement-a-watchdog-timer-in-python # https://learn.adafruit.com/adafruit-bh1750-ambient-light-sensor # https://learn.adafruit.com/adafruit-mini-pitft-135x240-color-tft-add-on-for-raspberry-pi/overview +######################################## +# Imports and whatnot +######################################## +import adafruit_bh1750 +import adafruit_rgb_display.st7789 as st7789 import board import busio import dbus import digitalio import os -import time -from threading import Timer -from PIL import Image, ImageDraw, ImageFont import RPi.GPIO as GPIO -import adafruit_rgb_display.st7789 as st7789 -import adafruit_bh1750 +import time +from datetime import datetime +from PIL import Image, ImageDraw, ImageFont +from threading import Timer -# Generic watchdog timer used for backlight controls +######################################## +# Generic watchdog timer class +######################################## class Watchdog: def __init__(self, timeout, userHandler=None): # timeout in seconds self.timeout = timeout @@ -43,7 +55,9 @@ class Watchdog: def defaultHandler(self): raise self +######################################## # Cheat for walking only one level deep via os.walk (faster than other options) +######################################## def walklevel(some_dir, level=1): some_dir = some_dir.rstrip(os.path.sep) assert os.path.isdir(some_dir) @@ -54,26 +68,38 @@ def walklevel(some_dir, level=1): if num_sep + level <= num_sep_this: del dirs[:] +######################################## # bh1740 lux sensor +######################################## i2c = busio.I2C(board.SCL, board.SDA) sensor = adafruit_bh1750.BH1750(i2c) +######################################## # Configuration for CS and DC pins (these are FeatherWing defaults on M0/M4): +######################################## cs_pin = digitalio.DigitalInOut(board.CE0) dc_pin = digitalio.DigitalInOut(board.D25) reset_pin = None +######################################## # Setup button pins as inputs +######################################## GPIO.setup(23, GPIO.IN) GPIO.setup(24, GPIO.IN) -# Config for display baudrate (default max is 24mhz): +######################################## +# Config for display baudrate (default max is 24mhz) +######################################## BAUDRATE = 64000000 -# Setup SPI bus using hardware SPI: +######################################## +# Setup SPI bus using hardware SPI +######################################## spi = board.SPI() -# Create the ST7789 display: +######################################## +# Create the ST7789 display +######################################## disp = st7789.ST7789( spi, cs=cs_pin, @@ -86,43 +112,59 @@ disp = st7789.ST7789( y_offset=40, ) +######################################## # Create blank image for drawing. # Make sure to create image with mode 'RGB' for full color. +######################################## height = disp.width # we swap height/width to rotate it to landscape! width = disp.height image = Image.new('RGB', (width, height)) rotation = 90 +######################################## # Get drawing object to draw on image. +######################################## draw = ImageDraw.Draw(image) -# Draw a black filled box to clear the image. +######################################## +# Draw a black filled box to clear the image +######################################## draw.rectangle((0, 0, width, height), outline=0, fill=(0, 0, 0)) disp.image(image, rotation) -# Draw some shapes. + +######################################## +# Draw some shapes # First define some constants to allow easy resizing of shapes. +######################################## padding = -2 top = padding bottom = height - padding + +######################################## # Move left to right keeping track of the current x position for drawing shapes. +######################################## x = 0 -# Alternatively load a TTF font. Make sure the .ttf font file is in the -# same directory as the python script! -# Some other nice fonts to try: http://www.dafont.com/bitmap.php -#font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', 24) -font_small = font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', 24) +######################################## +# Load fonts used for output on screen +######################################## +font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', 24) +font_medium = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', 36) font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', 48) +######################################## # Turn on the backlight +######################################## backlight = digitalio.DigitalInOut(board.D22) backlight.switch_to_output() backlight.value = True +######################################## # Watchdog timer for screen on/off control based on button presses # Turns off screen 10s after last button press # Turns on screen for 10s when starting up # Restart fim slideshow when the screen blanks so there is an indicator when it's going to change the selection +######################################## def backlight_callback(): global backlight backlight.value = False @@ -131,9 +173,14 @@ def backlight_callback(): manager = dbus.Interface(systemd1, 'org.freedesktop.systemd1.Manager') job = manager.RestartUnit('fim.service', 'fail') +######################################## +# Setup screen off watchdog +######################################## watchdog = Watchdog(10.0, backlight_callback) +######################################## # Figure out which albums are present as well as the special 'All' album +######################################## albums = [ 'All' ] selected_album = 0 try: @@ -150,13 +197,17 @@ for root, dirs, files in walklevel('/tank/pictures'): albums.append(folder) albums.sort() -# Method for drawing on the screen -# Called by the button handlers since we don't need anything more than a basic while(1) main for this setup -def refresh_screen(): - global width, height, x, y, top, draw, selected_album, albums, font, font_small +######################################## +# Track which UI screen is shown +######################################## +current_ui = 'main' - # Draw a black filled box to clear the image. - draw.rectangle((0, 0, width, height), outline=0, fill=0) +######################################## +# Draw main UI +######################################## +def draw_main_ui(): + global width, height, x, y, top, draw, selected_album, albums, font, current_ui + current_ui = 'main' # Split the line on spaces and then add a blank line if needed to avoid crashes text_lines = albums[selected_album].split(' ') @@ -168,14 +219,154 @@ def refresh_screen(): draw.text((x, y), text_lines[0], font=font, fill='#DCDCDC') y += font.getsize('0')[1] draw.text((x, y), text_lines[1], font=font, fill='#DCDCDC') - lux = 'Light: %.2f Lux' % sensor.lux - y = height - font_small.getsize(lux)[1] - draw.text((x, y), lux, font=font_small, fill='#DCDCDC') + pos = 'Position: %d/%d' % (selected_album + 1, len(albums)) + y = height - font_small.getsize(pos)[1] + draw.text((x, y), pos, font=font_small, fill='#DCDCDC') # Display image. disp.image(image, rotation) +######################################## +# Draw yes/no labels +######################################## +def draw_yes_no_labels(): + global width, height, x, y, top, draw, font_medium + # Yes + draw.text((x, top+15), 'Yes', font=font_medium, fill='#DCDCDC') + + # No + y = height - font_medium.getsize('No')[1] + draw.text((x, y-15), 'No', font=font_medium, fill='#DCDCDC') + + # Display image. + disp.image(image, rotation) + +######################################## +# Draw rebootING UI +######################################## +def draw_rebooting_ui(): + global width, height, x, y, top, draw, font_medium, current_ui + current_ui = 'rebooting' + + font_size = font_medium.getsize('0') + left = x + font_size[0] + y = (height / 2) - (font_size[1] / 2) + + draw.text((left, y), 'Rebooting', font=font_medium, fill='#DCDCDC') + + disp.image(image, rotation) + +######################################## +# Draw reboot UI +######################################## +def draw_reboot_ui(): + global width, height, x, y, top, draw, font_medium, current_ui + current_ui = 'reboot' + draw_yes_no_labels() + + font_size = font_medium.getsize('Yes ') + left = x + font_size[0] + y = (height / 2) - (font_size[1] / 2) + + draw.text((left, y), 'Reboot', font=font_medium, fill='#DCDCDC') + + disp.image(image, rotation) + +######################################## +# Draw powerING off UI +######################################## +def draw_poweringoff_ui(): + global width, height, x, y, top, draw, font_medium, current_ui + current_ui = 'poweringoff' + + font_size = font_medium.getsize('0') + left = x + font_size[0] + y = (height / 2) - font_size[1] + draw.text((left, y), 'Powering', font=font_medium, fill='#DCDCDC') + y += font_size[1] + left += font_medium.getsize('00')[0] + draw.text((left, y), 'Off', font=font_medium, fill='#DCDCDC') + + disp.image(image, rotation) + +######################################## +# Draw poweroff UI +######################################## +def draw_poweroff_ui(): + global width, height, x, y, top, draw, font_medium, current_ui + current_ui = 'poweroff' + draw_yes_no_labels() + + font_size = font_medium.getsize('Yes ') + left = x + font_size[0] + y = (height / 2) - font_size[1] + draw.text((left, y), 'Power', font=font_medium, fill='#DCDCDC') + y += font_size[1] + left += font_medium.getsize('0')[0] + draw.text((left, y), 'Off', font=font_medium, fill='#DCDCDC') + + disp.image(image, rotation) + +######################################## +# Draw debug UI +######################################## +def draw_debug_ui(): + global width, height, x, y, top, draw, font_small, current_ui + import subprocess + + current_ui = 'debug' + ip_cmd = '/usr/sbin/ip' + + process = subprocess.run([ip_cmd, '-o', 'link', 'show'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + addresses = [] + for line in process.stdout.split('\n'): + if line == '': + continue + values = line.split(':') + adapter = values[1].strip() + if adapter != 'lo' and not 'wg' in adapter: + addr = subprocess.run([ip_cmd, '-f', 'inet', '-o', 'addr', 'show', adapter], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + addr = addr.stdout.split() + if len(addr) >=4: + addresses.append(addr[3].split('/')[0]) + + # Write some text + lux = 'Light: %.2f Lux' % sensor.lux + y = top + draw.text((x, y), lux, font=font_small, fill='#DCDCDC') + for addr in addresses: + y += font_small.getsize('0')[1] + draw.text((x, y), addr, font=font_small, fill='#DCDCDC') + + # Display image. + disp.image(image, rotation) + +######################################## +# Method for drawing on the screen +# Called by the button handlers since we don't need anything more than a basic while(1) main for this setup +######################################## +def refresh_screen(screen): + global draw, width, height + # Draw a black filled box to clear the image. + draw.rectangle((0, 0, width, height), outline=0, fill=0) + + if screen == 'main': + draw_main_ui() + elif screen == 'debug': + draw_debug_ui() + elif screen == 'reboot': + draw_reboot_ui() + elif screen == 'poweroff': + draw_poweroff_ui() + elif screen == 'rebooting': + draw_rebooting_ui() + elif screen == 'poweringoff': + draw_poweringoff_ui() + + +######################################## # Write current album when restarting the slideshow +######################################## def write_selected_album(): with open('/etc/default/fim_album_index', 'w') as f: f.write(str(selected_album)) @@ -185,38 +376,111 @@ def write_selected_album(): else: f.write('/tank/pictures') +######################################## +# Button held tracking date/times +######################################## +up_held_rising = None +dn_held_rising = None +btn_is_prompting = False + +######################################## # Interrupt callback for changing albums (up) +######################################## def up_button(channel): - global selected_album, albums, backlight + global selected_album, albums, backlight, up_held_rising, btn_is_prompting, current_ui backlight.value = True - selected_album += 1 - if selected_album >= len(albums): - selected_album = 0 - refresh_screen() - write_selected_album() - watchdog.reset() - + # Pull ups on the LCD flip the standard logic you're expecting + # Button Released + if not GPIO.input(channel): + up_held_rising = datetime.now() + # Button pressed + else: + if btn_is_prompting: + if current_ui == 'debug': + btn_is_prompting = False + up_held_rising = None + refresh_screen('main') + return + if current_ui == 'reboot': + refresh_screen('rebooting') + os.system('/usr/bin/systemctl reboot') + elif current_ui == 'poweroff': + refresh_screen('poweringoff') + os.system('/usr/bin/systemctl poweroff') + btn_is_prompting = False + return + if up_held_rising is not None: + now = datetime.now() + diff = (now - up_held_rising).total_seconds() + if diff >= 3: + up_held_rising = None + btn_is_prompting = True + refresh_screen('reboot') + return + selected_album += 1 + if selected_album >= len(albums): + selected_album = 0 + refresh_screen('main') + write_selected_album() + watchdog.reset() + +######################################## # Interrupt callback for changing albums (dn) +######################################## def dn_button(channel): - global selected_album, albums, backlight + global selected_album, albums, backlight, dn_held_rising, btn_is_prompting backlight.value = True - selected_album -= 1 - if selected_album < 0: - selected_album = len(albums) - 1 - refresh_screen() - write_selected_album() - watchdog.reset() + # Pull ups on the LCD flip the standard logic you're expecting + # Button released + if not GPIO.input(channel): + dn_held_rising = datetime.now() + # Button pressed + else: + if btn_is_prompting: + # Show debug menu if long pressed dn on reboot/shutdown screens + if dn_held_rising is not None: + now = datetime.now() + diff = (now - dn_held_rising).total_seconds() + if diff >= 3: + dn_held_rising = None + btn_is_prompting = True + refresh_screen('debug') + return + btn_is_prompting = False + refresh_screen('main') + if dn_held_rising is not None: + now = datetime.now() + diff = (now - dn_held_rising).total_seconds() + if diff >= 3: + dn_held_rising = None + btn_is_prompting = True + refresh_screen('poweroff') + return + selected_album -= 1 + if selected_album < 0: + selected_album = len(albums) - 1 + refresh_screen('main') + write_selected_album() + watchdog.reset() +######################################## # Setup button interrupts -GPIO.add_event_detect(23, GPIO.FALLING, callback=up_button, bouncetime=300) -GPIO.add_event_detect(24, GPIO.FALLING, callback=dn_button, bouncetime=300) +######################################## +GPIO.add_event_detect(23, GPIO.BOTH, callback=up_button, bouncetime=250) +GPIO.add_event_detect(24, GPIO.BOTH, callback=dn_button, bouncetime=250) +######################################## # Draw initial screen state -refresh_screen() +######################################## +refresh_screen(current_ui) +######################################## # Kick off watchdog to start the 10s initial 'on' status +######################################## watchdog.reset() -# Work with the LCD / Buttons in an infinate loop +######################################## +# We use watchdogs/interrupts for ALL operations, no need for anything in a main loop other than keeping the app alive +######################################## while True: time.sleep(300)