inkybot/inkybot.py

393 lines
13 KiB
Python
Executable file

#!/usr/bin/env python3
import sys, os, random, time, signal
import numpy as np
import RPi.GPIO as GPIO
from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageOps, ImagePalette
from inky.inky_uc8159 import Inky
class Inkybot:
# FIXME: a bunch of this needs to be parameterized
palette = [
(0, 0, 0), # black
(255, 255, 255), # white
(0, 255, 0), # green
(0, 0, 255), # blue
(255, 0, 0), # red
(255, 255, 0), # yellow
(255, 140, 0), # orange
(255, 255, 255) # white again???
]
font_size = 60
BUTTONS = [5,6,16,24]
BUTTON_LABELS = ['A', 'B', 'C', 'D']
inky = Inky()
state = None
states = {}
def __init__(self):
self.font = ImageFont.truetype("fonts/3270NerdFontMono-Regular.ttf", size=self.font_size)
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP)
for pin in self.BUTTONS:
GPIO.add_event_detect(pin, GPIO.FALLING, self.handle_button, bouncetime=250)
def color_similarity(self, color1, color2):
return np.sqrt(np.sum((np.array(color1) - np.array(color2)) ** 2))
def least_similar_color(self, color):
return max(self.palette, key=lambda ref_color: self.color_similarity(ref_color, color))
def average_outer_perimeter_color(self,image):
# Get image dimensions
width, height = image.size
# Extract the outer 1-pixel perimeter
outer_perimeter_pixels = []
for x in range(width):
outer_perimeter_pixels.append(image.getpixel((x, 0))) # Top row
outer_perimeter_pixels.append(image.getpixel((x, height - 1))) # Bottom row
for y in range(1, height - 1):
outer_perimeter_pixels.append(image.getpixel((0, y))) # Left column
outer_perimeter_pixels.append(image.getpixel((width - 1, y))) # Right column
# Calculate average color
total_pixels = len(outer_perimeter_pixels)
total_red = sum(pixel[0] for pixel in outer_perimeter_pixels)
total_green = sum(pixel[1] for pixel in outer_perimeter_pixels)
total_blue = sum(pixel[2] for pixel in outer_perimeter_pixels)
average_red = total_red // total_pixels
average_green = total_green // total_pixels
average_blue = total_blue // total_pixels
return (average_red, average_green, average_blue)
def resize_with_letterbox(self, image, resolution, letterbox_color=(0, 0, 0)):
target_width = resolution[0]
target_height = resolution[1]
# Get original width and height
original_width, original_height = image.size
# Calculate the aspect ratios
original_aspect_ratio = original_width / original_height
target_aspect_ratio = target_width / target_height
# Calculate resizing factors
if original_aspect_ratio < target_aspect_ratio:
# Image is narrower than target, resize based on height
new_width = int(target_height * original_aspect_ratio)
new_height = target_height
else:
# Image is taller than target, resize based on width
new_width = target_width
new_height = int(target_width / original_aspect_ratio)
# Resize the image
resized_image = image.resize((new_width, new_height), Image.ANTIALIAS)
# Create a new image with letterbox bars
x_max = target_width - new_width
if x_max // 2 > self.font_size:
x = x_max // 2
else:
x = min(x_max, x_max // 2 + self.font_size)
letterbox_image = Image.new(image.mode, (target_width, target_height), letterbox_color)
letterbox_image.paste(resized_image, (x, (target_height - new_height) // 2))
return letterbox_image
def handle_button(self,pin):
label = self.BUTTON_LABELS[self.BUTTONS.index(pin)]
if label == 'A':
self.state.button_a()
elif label == 'B':
self.state.button_b()
elif label == 'C':
self.state.button_c()
elif label == 'D':
self.state.button_d()
else:
raise Exception("Unhandled button press!")
class StateClass:
button_text = [ "?", "?", "?", "?" ]
button_colors = [ None, None, None, None ]
button_positions = [
(5,49),
(5,161),
(5,273),
(5,385)
]
font_size = 60
saturation = 0.7
def __init__(self, parent):
self.parent = parent
# enter and exit functions can be overridden by the child class
def enter(self):
pass
def exit(self):
pass
def change_state(self, state):
self.parent.change_state(state)
# button_x functions should be overridden by the child class too
def button_a(self):
print("Button A")
def button_b(self):
print("Button B")
def button_c(self):
print("Button C")
def button_d(self):
print("Button D")
# same applies to the loop
def loop(self):
pass
def set_image(self, image):
draw = ImageDraw.Draw(image)
# text colors use the nearest undithered color to what's in the letterbox
color_border = self.parent.least_similar_color(self.parent.average_outer_perimeter_color(image))
color = self.parent.least_similar_color(color_border)
for i in range(4):
if self.button_colors[i] is not None:
c = self.button_colors[i]
cb = self.parent.least_similar_color(c)
else:
c = color
cb = color_border
txt = self.button_text[i]
x,y = self.button_positions[i]
text_width, text_height = draw.textsize(txt, font=self.parent.font)
dy = int(text_height / 2)
for xx in range(x - 1, x + 2, 1):
for yy in range(y - 1 - dy, y + 2 - dy, 1):
draw.text((xx,yy), txt, fill=cb, font=self.parent.font)
draw.text((x,y - dy), txt, fill=c, font=self.parent.font)
self.parent.inky.set_image(image, saturation=self.saturation)
self.parent.inky.show()
def clear(self, color="white"):
w,h = self.parent.inky.resolution
clear_image = Image.new('RGB', (w, h), color)
self.parent.inky.set_image(clear_image, saturation=1.0)
self.parent.inky.show()
def State(self, name):
def decorator(c):
self.states[name] = c(parent = self)
return c
return decorator
def change_state(self, state):
print(f"Changing state to: {state}")
if self.state:
self.state.exit()
self.state = self.states[state]
self.state.enter()
def start(self, state):
self.state = self.states[state]
self.state.enter()
while True:
self.state.loop()
time.sleep(0.1)
inkybot = Inkybot()
@inkybot.State('picture')
class PictureMode(inkybot.StateClass):
button_text = [
"󰸋",
"",
"",
"󱧾"
]
font_size = 60
picpath = '/srv/inkybot/pictures'
saturation = 0.7
pic_time = 60.0
def enter(self):
self.imagelist = []
self.next_img = True
self.time_target = 0.0
def button_b(self):
self.change_state('hass')
def button_d(self):
print("changing image...")
self.next_img = True
self.time_target = time.time() + self.pic_time
def loop(self):
if self.time_target <= time.time():
self.next_img = True
if self.next_img:
self.next_img = False
self.time_target = time.time() + self.pic_time
if len(self.imagelist) == 0:
self.imagelist = os.listdir(self.picpath)
random.shuffle(self.imagelist)
fn = self.imagelist.pop()
print(f"Displaying {fn}")
image = Image.open(f"{self.picpath}/{fn}") # XXX FIXME: os join function instead
resizedimage = self.parent.resize_with_letterbox(
image,
self.parent.inky.resolution,
self.parent.average_outer_perimeter_color(image)
)
self.set_image(resizedimage)
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException as selenium_TimeoutException
from io import BytesIO
@inkybot.State('hass')
class HassMode(inkybot.StateClass):
button_text = [
" ",
"",
" ",
""
]
button_colors = [
None,
(0,0,255),
None,
(0,255,0)
]
driver_path = "/usr/bin/chromedriver"
driver=None
refresh_target = 0.0
screen_scale = 1.0
def button_b(self):
self.change_state('picture')
def button_d(self):
self.driver.refresh()
self.update()
self.refresh_target = time.time() + 5
def enter(self):
#self.clear("blue")
url = "http://localhost:8123/lovelace/1?kiosk"
print(f"launching selenium and loading {url}")
service = Service(self.driver_path)
options = webdriver.ChromeOptions()
options.add_argument('--headless')
options.add_argument('--disable-gpu') # GPU hardware acceleration isn't needed for headless
options.add_argument('--no-sandbox') # Disable the sandbox for all software features
options.add_argument('--disable-dev-shm-usage') # Overcome limited resource problems
options.add_argument('--disable-extensions') # Disabling extensions can save resources
options.add_argument('--disable-plugins') # Disable plugins
self.driver = webdriver.Chrome(service=service, options=options)
width,height = [self.screen_scale * x for x in self.parent.inky.resolution]
#width -= self.font_size
self.driver.set_window_size(width,height)
self.driver.get(url)
print("waiting for login button...")
login_button = WebDriverWait(self.driver, 5).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "mwc-button")))
#self.driver.execute_script("document.body.style.fontSize = 'larger';") # font scaling
login_button.click()
self.update()
self.refresh_target = time.time() + 5
def update(self):
if self.driver is not None:
print("Updating display...")
screenshot_bytes = self.driver.get_screenshot_as_png()
screenshot_image = Image.open(BytesIO(screenshot_bytes)).convert('RGB')
width, height = screenshot_image.size
cropped_image = screenshot_image.crop((5,5, width - 5, height - 5))
resizedimage = self.parent.resize_with_letterbox(
cropped_image,
self.parent.inky.resolution,
self.parent.average_outer_perimeter_color(screenshot_image)
)
#enhancer = ImageEnhance.Contrast(resizedimage)
enhancer = ImageEnhance.Color(resizedimage)
adjustedimage = enhancer.enhance(4.0)
enhancer = ImageEnhance.Contrast(adjustedimage)
adjustedimage = enhancer.enhance(5.0)
#print(resizedimage.mode)
#adjustedimage = ImageOps.autocontrast(resizedimage)
self.set_image(adjustedimage)
self.driver.refresh() # reloading the page at every refresh will get us any UI updates in the background
self.refresh_target = time.time() + 60
def exit(self):
print("Quitting chrome driver...")
d = self.driver
self.driver = None
d.close()
d.quit()
def loop(self):
if self.driver is not None:
try:
refresh_button = WebDriverWait(self.driver, 0.5).until(
EC.visibility_of_element_located((By.XPATH, '//*[text()="Refresh"]'))
)
refresh_button.click()
self.refresh_target = time.time() + 2.5
except selenium_TimeoutException:
if self.refresh_target <= time.time():
self.update()
if __name__ == "__main__":
inkybot.start('picture')
#inkybot.start('hass')