From 50664e86f102bc12202d2ec36cd1c9a1430b808a Mon Sep 17 00:00:00 2001 From: Glen Vorel Date: Tue, 19 Oct 2021 16:23:30 +0200 Subject: [PATCH 01/15] Implement InterfacePeripherals --- picframe/config/configuration_example.yaml | 30 +- picframe/controller.py | 17 +- picframe/interface_kbd.py | 51 --- picframe/interface_peripherals.py | 387 +++++++++++++++++++++ picframe/model.py | 20 +- picframe/start.py | 5 +- picframe/viewer_display.py | 14 +- 7 files changed, 457 insertions(+), 67 deletions(-) delete mode 100644 picframe/interface_kbd.py create mode 100644 picframe/interface_peripherals.py diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 3873942..23542cc 100644 --- a/picframe/config/configuration_example.yaml +++ b/picframe/config/configuration_example.yaml @@ -38,6 +38,9 @@ viewer: clock_text_sz: 120 # default=120, clock character size clock_format: "%I:%M" # default="%I:%M", strftime format for clock string + menu_text_sz: 40 # default=40, menu character size + menu_autohide_tm: 10.0 # default=10.0, time in seconds to show menu before auto hiding (0 disables auto hiding) + model: pic_dir: "~/Pictures" # default="~/Pictures", root folder for images deleted_pictures: "~/DeletedPictures" # move deleted pictures here @@ -82,7 +85,6 @@ model: log_level: "WARNING" # default=WARNING, could beDEBUG, INFO, WARNING, ERROR, CRITICAL log_file: "" # default="" for debugging set this to the path to a file. NB logging messages will # appended indefinitely so don't forget this. You will need to tidy it up later - use_kbd: False # default=False, just for debug or console start. Crashing when started with systemd mqtt: use_mqtt: False # default=False. Set True true, to enable mqtt @@ -99,4 +101,28 @@ http: port: 9000 # port used to serve pages by http server < 1024 requires root which is *bad* idea use_ssl: False keyfile: "path/to/key.pem" # private-key - certfile: "path/to/cert.pem" # server certificate \ No newline at end of file + certfile: "path/to/cert.pem" # server certificate + +peripherals: + input_type: null # default=null, valid options: {null, "keyboard", "touch", "mouse"} + buttons: + pause: # pause/unpause the show + enable: True # default=True + label: "Pause" # default="Pause" + shortcut: " " # default=" " + display_off: # turn off the display (when off, any input from selected peripheral will turn it back on) + enable: True # default=True + label: "Display off" # default="Display off" + shortcut: "o" # default="o" + location: # shows or hides location information + enable: False # default=False + label: "Location" # default="Location" + shortcut: "l" # default="l" + exit: # exit PictureFrame + enable: False # default=False + label: "Exit" # default="Exit" + shortcut: "e" # default="e" + power_down: # power down the device, uses sudo + enable: False # default=False + label: "Power down" # default="Power down" + shortcut: "p" # default="p" \ No newline at end of file diff --git a/picframe/controller.py b/picframe/controller.py index 646384e..929a0a0 100644 --- a/picframe/controller.py +++ b/picframe/controller.py @@ -6,6 +6,7 @@ import os import signal import sys +from picframe.interface_peripherals import InterfacePeripherals def make_date(txt): dt = txt.replace('/',':').replace('-',':').replace(',',':').replace('.',':').split(':') @@ -42,6 +43,7 @@ def __init__(self, model, viewer): self.__model = model self.__viewer = viewer self.__paused = False + self.__force_navigate = False self.__next_tm = 0 self.__date_from = make_date('1901/12/15') # TODO This seems to be the minimum date to be handled by date functions self.__date_to = make_date('2038/1/1') @@ -49,9 +51,10 @@ def __init__(self, model, viewer): self.__where_clauses = {} self.__sort_clause = "exif_datetime ASC" self.publish_state = lambda x, y: None - self.__keep_looping = True + self.keep_looping = True self.__location_filter = '' self.__tags_filter = '' + self.__interface_peripherals = None self.__shutdown_complete = False @property @@ -70,11 +73,13 @@ def paused(self, val:bool): def next(self): self.__next_tm = 0 self.__viewer.reset_name_tm() + self.__force_navigate = True def back(self): self.__model.set_next_file_to_previous_file() self.__next_tm = 0 self.__viewer.reset_name_tm() + self.__force_navigate = True def delete(self): self.__model.delete_file() @@ -268,7 +273,7 @@ def loop(self): #TODO exit loop gracefully and call image_cache.stop() signal.signal(signal.SIGINT, self.__signal_handler) #next_check_tm = time.time() + self.__model.get_model_config()['check_dir_tm'] - while self.__keep_looping: + while self.keep_looping: #if self.__next_tm == 0: #TODO double check why these were set when next_tm == 0 # time_delay = 1 # must not be 0 @@ -279,8 +284,9 @@ def loop(self): #TODO exit loop gracefully and call image_cache.stop() tm = time.time() pics = None #get_next_file returns a tuple of two in case paired portraits have been specified - if not self.paused and tm > self.__next_tm: + if not self.paused and tm > self.__next_tm or self.__force_navigate: self.__next_tm = tm + self.__model.time_delay + self.__force_navigate = False pics = self.__model.get_next_file() if pics[0] is None: self.__next_tm = 0 # skip this image file moved or otherwise not on db @@ -303,13 +309,16 @@ def loop(self): #TODO exit loop gracefully and call image_cache.stop() break if skip_image: self.__next_tm = 0 + self.__interface_peripherals.check_input() self.__shutdown_complete = True def start(self): self.__viewer.slideshow_start() + self.__interface_peripherals = InterfacePeripherals(self.__model, self.__viewer, self) def stop(self): - self.__keep_looping = False + self.keep_looping = False + self.__interface_peripherals.stop() while not self.__shutdown_complete: time.sleep(0.05) # block until main loop has stopped self.__model.stop_image_chache() # close db tidily (blocks till closed) diff --git a/picframe/interface_kbd.py b/picframe/interface_kbd.py deleted file mode 100644 index da3e771..0000000 --- a/picframe/interface_kbd.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Keyboard interface of picframe.""" - -import logging -import threading -import time -import pi3d - - -class InterfaceKbd: - """Keyboard interface of picframe. - - This interface interacts via keyboard with the user to steer the image display. - - Attributes - ---------- - controller : Controler - Controller for picframe - - - Methods - ------- - - """ - - def __init__(self, controller): - self.__logger = logging.getLogger("interface_kbd.InterfaceKbd") - self.__logger.info('creating an instance of InterfaceKbd') - self.__controller = controller - self.__keyboard = pi3d.Keyboard() - self.__keep_looping = True - t = threading.Thread(target=self.__loop) - t.start() - - def __loop(self): - while self.__keep_looping: - key = self.__keyboard.read() - if key == 27: - self.__keep_looping = False - elif key == ord('a'): - self.__controller.back() - elif key == ord('d'): - self.__controller.next() - elif key == ord('l'): - if self.__controller.text_is_on("location"): - self.__controller.set_show_text("location", "OFF") - else: - self.__controller.set_show_text("location", "ON") - time.sleep(0.025) - self.__keyboard.close() # contains references to Display instance - self.__controller.stop() - \ No newline at end of file diff --git a/picframe/interface_peripherals.py b/picframe/interface_peripherals.py new file mode 100644 index 0000000..18c130d --- /dev/null +++ b/picframe/interface_peripherals.py @@ -0,0 +1,387 @@ +import inspect +import logging +import os +import sys +import time +import typing + +import numpy as np +import pi3d + + +class InterfacePeripherals: + """Opens connections to peripheral interfaces and reacts to their state to handle user input. + Controls playback (navigation), device display, device power and other things. + + Args: + model: Model of picframe containing config and business logic. + viewer: Viewer of picframe representing the display. + controller: Controller of picframe steering image display. + """ + + def __init__( + self, + model: "picframe.model.Model", + viewer: "picframe.viewer_display.ViewerDisplay", + controller: "picframe.controller.Controller", + ) -> None: + self.__logger = logging.getLogger(__name__) + self.__logger.info("creating an instance of InterfacePeripherals") + self.__model = model + self.__viewer = viewer + self.controller = controller + + self.__input_type = self.__model.get_peripherals_config()["input_type"] + if not self.__input_type: + self.__logger.info("no peripheral input is enabled") + return + valid_input_types = {"keyboard", "touch", "mouse"} + if self.__input_type not in valid_input_types: + self.__logger.warning( + "input type '%s' is invalid, valid options are: %s", + self.__input_type, + valid_input_types, + ) + return + + self.__menu_autohide_tm = self.__model.get_viewer_config()["menu_autohide_tm"] + self.__buttons = self.__model.get_peripherals_config()["buttons"] + + self.__gui = self.__get_gui() + self.__mouse = self.__get_mouse() + self.__keyboard = self.__get_keyboard() + + self.__menu_buttons = self.__get_menu_buttons() + self.__menu_height = ( + min(self.__viewer.display_width, self.__viewer.display_height) // 4 if self.__menu_buttons else 0 + ) + self.__menu = self.__get_menu() + self.__menu_bg_widget = self.__get_menu_bg_widget() + self.__back_area, self.__next_area = self.__get_navigation_areas() + self.__menu_bg = self.__get_menu_bg() + self.__menu_is_on = False + self.__mouse_is_down = False + self.__last_touch_position = None + self.__last_menu_show_at = 0 + self.__pointer_position = (0, 0) + self.__timestamp = 0 + + def check_input(self) -> None: + """Checks for any input from the selected peripheral device and handles it.""" + if not self.__input_type: + return + + if self.__input_type == "keyboard": + self.__handle_keyboard_input() + + elif self.__input_type in ["touch", "mouse"]: + self.__timestamp = time.time() + self.__update_pointer_position() + + if self.__input_type == "touch": + self.__handle_touch_input() + + elif self.__input_type == "mouse": + self.__handle_mouse_input() + + # Autohide menu + if self.menu_is_on: + if self.__menu_autohide_tm and self.__timestamp - self.__last_menu_show_at > self.__menu_autohide_tm: + self.menu_is_on = False + else: + self.__menu_bg.draw() + + self.__gui.draw(*self.__pointer_position) + + def stop(self) -> None: + """Gracefully stops any active peripheral device.""" + if hasattr(self, "__mouse") and self.__mouse: + self.__mouse.stop() + if hasattr(self, "__keyboard") and self.__keyboard: + self.__keyboard.close() + + @property + def menu_is_on(self) -> None: + return self.__menu_is_on + + @menu_is_on.setter + def menu_is_on(self, val: bool) -> None: + self.__menu_is_on = val + if val: + self.__last_menu_show_at = self.__timestamp + self.__viewer.clock_is_on = False + self.__menu.show() + else: + self.__viewer.clock_is_on = True + self.__menu.hide() + + def __get_gui(self) -> "pi3d.Gui": + font = pi3d.Font( + self.__model.get_viewer_config()["font_file"], + color=(255, 255, 255, 255), + font_size=self.__model.get_viewer_config()["menu_text_sz"], + shadow_radius=3, + spacing=0, + ) + return pi3d.Gui(font, show_pointer=self.__input_type == "mouse") + + def __get_mouse(self) -> typing.Optional["pi3d.Mouse"]: + if self.__input_type in ["touch", "mouse"]: + mouse = pi3d.Mouse( + restrict=self.__input_type == "mouse", + width=self.__viewer.display_width, + height=self.__viewer.display_height, + ) + mouse.start() + return mouse + + def __get_keyboard(self) -> typing.Optional["pi3d.Keyboard"]: + if self.__input_type == "keyboard": + return pi3d.Keyboard() + + def __get_menu_buttons(self) -> typing.List["IPMenuItem"]: + btns = [] + for name, props in self.__buttons.items(): + if not props["enable"]: + continue + for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + if issubclass(cls, IPMenuItem) and cls is not IPMenuItem and cls.config_name == name: + btn = cls(self, self.__gui, props["label"], shortcut=props["shortcut"]) + btns.append(btn) + return btns + + def __get_menu(self) -> "pi3d.Menu": + x = -self.__viewer.display_width // 2 + if self.__input_type == "keyboard": + # When keyboard is enabled, menu must be constantly shown to allow menu items + # register `shortcut` keys - instead of hiding it, it is rendered out of view + x *= -1 + menu = pi3d.Menu(menuitems=self.__menu_buttons, x=x, y=self.__viewer.display_height // 2) + if self.__input_type != "keyboard": + menu.hide() + return menu + + def __get_menu_bg_widget(self) -> "pi3d.util.Gui.Widget": + """This widget lies between navigation areas and menu buttons. + It intercepts clicks into the empty menu area which would otherwise trigger navigation. + """ + array = np.zeros((1, 1, 4), dtype=np.uint8) + texture = pi3d.Texture(array, blend=True, mipmap=False, free_after_load=True) + sprite = pi3d.ImageSprite( + texture, + self.__gui.shader, + w=self.__viewer.display_width, + h=self.__menu_height, + x=0, + y=0, + z=4.0, + ) + return pi3d.util.Gui.Widget( + self.__gui, + sprite, + x=-self.__viewer.display_width // 2, + y=self.__viewer.display_height // 2, + ) + + def __get_navigation_areas( + self, + ) -> typing.Tuple["pi3d.util.Gui.Widget", "pi3d.util.Gui.Widget"]: + array = np.array([[[0, 0, 255, 0]]], dtype=np.uint8) + texture = pi3d.Texture(array, blend=True, mipmap=False, free_after_load=True) + back_sprite = pi3d.ImageSprite( + texture, + self.__gui.shader, + w=self.__viewer.display_width // 2, + h=self.__viewer.display_height, + x=0, + y=0, + z=4.0, + ) + next_sprite = pi3d.ImageSprite( + texture, + self.__gui.shader, + w=self.__viewer.display_width // 2, + h=self.__viewer.display_height, + x=0, + y=0, + z=4.0, + ) + # Move left and down by 1 px to register clicks on the screen edges + back_area = pi3d.util.Gui.Widget( + self.__gui, + back_sprite, + x=-self.__viewer.display_width // 2 - 1, + y=self.__viewer.display_height // 2 - 1, + callback=self.__go_back, + shortcut="a", + ) + next_area = pi3d.util.Gui.Widget( + self.__gui, + next_sprite, + x=0, + y=self.__viewer.display_height // 2 - 1, + callback=self.__go_next, + shortcut="d", + ) + return back_area, next_area + + def __get_menu_bg(self) -> "pi3d.ImageSprite": + array = np.zeros((self.__menu_height, 1, 4), dtype=np.uint8) + array[:, :, 3] = np.linspace(120, 0, self.__menu_height).reshape(-1, 1) + texture = pi3d.Texture(array, blend=True, mipmap=False, free_after_load=True) + return pi3d.ImageSprite( + texture, + self.__gui.shader, + w=self.__viewer.display_width, + h=self.__menu_height, + x=0, + y=int(self.__viewer.display_height // 2 - self.__menu_height // 2), + z=4.0, + ) + + def __handle_keyboard_input(self) -> None: + code = self.__keyboard.read_code() + if len(code) > 0: + if not self.controller.display_is_on: + self.controller.display_is_on = True + else: + self.__gui.checkkey(code) + + def __handle_touch_input(self) -> None: + """Due to pi3d not reliably detecting touch as Mouse.LEFT_BUTTON event + when a touch happens at any position with x or y lower than previous touch, + any pointer movement is considered a click event. + """ + if self.__pointer_moved(): + if not self.controller.display_is_on: + self.controller.display_is_on = True + elif self.__pointer_position[1] < self.__viewer.display_height // 2 - self.__menu_height: + # Touch in main area + if self.menu_is_on: + self.menu_is_on = False + else: + self.__handle_click() + else: + # Touch in menu area + if self.menu_is_on: + self.__handle_click() + self.menu_is_on = True # Reset clock for autohide + + def __handle_mouse_input(self) -> None: + if self.__pointer_moved() and not self.controller.display_is_on: + self.controller.display_is_on = True + + # Show or hide menu + self.menu_is_on = self.__pointer_position[1] > self.__viewer.display_height // 2 - self.__menu_height + + # Detect click + if self.__mouse.button_status() == self.__mouse.LEFT_BUTTON and not self.__mouse_is_down: + self.__mouse_is_down = True + self.__handle_click() + elif self.__mouse.button_status() != self.__mouse.LEFT_BUTTON and self.__mouse_is_down: + self.__mouse_is_down = False + + def __update_pointer_position(self) -> None: + position_x, position_y = self.__mouse.position() + if self.__input_type == "mouse": + position_x -= self.__viewer.display_width // 2 + position_y -= self.__viewer.display_height // 2 + elif self.__input_type == "touch": + # Workaround, pi3d seems to always assume screen ratio 4:3 so touch is incorrectly translated + # to x, y on screens with a different ratio + position_y *= self.__viewer.display_height / (self.__viewer.display_width * 3 / 4) + self.__pointer_position = (position_x, position_y) + + def __pointer_moved(self) -> bool: + if not self.__last_touch_position: + self.__last_touch_position = self.__pointer_position + + if self.__last_touch_position != self.__pointer_position: + self.__last_touch_position = self.__pointer_position + return True + return False + + def __handle_click(self) -> None: + self.__logger.debug("handling click at position x: %s, y: %s", *self.__pointer_position) + self.__gui.check(*self.__pointer_position) + + def __go_back(self, position) -> None: + self.__logger.debug("previous picture") + self.controller.back() + + def __go_next(self, position) -> None: + self.__logger.debug("next picture") + self.controller.next() + + +class IPMenuItem(pi3d.MenuItem): + """Wrapper around pi3d.MenuItem that implements `callback` method. + In the future, this class can be extended to support toggling of multiple text labels + (e.g., "Pause"/"Unpause"). + + A subclass must imlement class variable `config_name` that matches its name in the configuration. + """ + + config_name = "" + + def __init__(self, ip: "InterfacePeripherals", gui: "pi3d.Gui", text: str, shortcut: str) -> None: + self.ip = ip + text = " " + text + " " + super().__init__(gui, text=text, callback=self.callback, shortcut=shortcut) + + def callback(self, *args) -> None: + """A subclass must override this method to define its business logic.""" + raise NotImplementedError + + +class PauseMenuItem(IPMenuItem): + """Pauses or unpauses the playback. + Navigation to previous or next picture is possible also when the playback is paused. + """ + + config_name = "pause" + + def callback(self, *args): + self.ip.controller.paused = not self.ip.controller.paused + + +class DisplayOffMenuItem(IPMenuItem): + """Turns off the display. When the display is off, + any input from the selected peripheral device will turn it back on. + """ + + config_name = "display_off" + + def callback(self, *args): + self.ip.controller.display_is_on = False + + +class LocationMenuItem(IPMenuItem): + """Shows or hides location information.""" + + config_name = "location" + + def callback(self, *args): + if self.ip.controller.text_is_on("location"): + self.ip.controller.set_show_text("location", "OFF") + else: + self.ip.controller.set_show_text("location", "ON") + + +class ExitMenuItem(IPMenuItem): + """Exits the program.""" + + config_name = "exit" + + def callback(self, *args): + self.ip.controller.keep_looping = False + + +class PowerDownMenuItem(IPMenuItem): + """Exits the program and shuts down the device. Uses sudo.""" + + config_name = "power_down" + + def callback(self, *args): + self.ip.controller.keep_looping = False + os.system("sudo shutdown now") diff --git a/picframe/model.py b/picframe/model.py index d315119..49826ae 100644 --- a/picframe/model.py +++ b/picframe/model.py @@ -48,6 +48,8 @@ 'clock_text_sz': 120, 'clock_format': "%I:%M", #'codepoints': "1234567890AÄÀÆÅÃBCÇDÈÉÊEËFGHIÏÍJKLMNÑOÓÖÔŌØPQRSTUÚÙÜVWXYZaáàãæåäbcçdeéèêëfghiíïjklmnñoóôōøöpqrsßtuúüvwxyz., _-+*()&/`´'•" # limit to 121 ie 11x11 grid_size + 'menu_text_sz': 40, + 'menu_autohide_tm': 10.0, }, 'model': { @@ -71,7 +73,6 @@ 'deleted_pictures': '~/DeletedPictures', 'log_level': 'WARNING', 'log_file': '', - 'use_kbd': False, }, 'mqtt': { 'use_mqtt': False, # Set tue true, to enable mqtt @@ -89,7 +90,17 @@ 'use_ssl': False, 'keyfile': "/path/to/key.pem", 'certfile': "/path/to/fullchain.pem" - } + }, + 'peripherals': { + 'input_type': None, # valid options: {None, "keyboard", "touch", "mouse"} + 'buttons': { + 'pause': {'enable': True, 'label': 'Pause', 'shortcut': ' '}, + 'display_off': {'enable': True, 'label': 'Display off', 'shortcut': 'o'}, + 'location': {'enable': False, 'label': 'Location', 'shortcut': 'l'}, + 'exit': {'enable': False, 'label': 'Exit', 'shortcut': 'e'}, + 'power_down': {'enable': False, 'label': 'Power down', 'shortcut': 'p'} + }, + }, } @@ -136,7 +147,7 @@ def __init__(self, configfile = DEFAULT_CONFIGFILE): with open(configfile, 'r') as stream: try: conf = yaml.safe_load(stream) - for section in ['viewer', 'model', 'mqtt', 'http']: + for section in ['viewer', 'model', 'mqtt', 'http', 'peripherals']: self.__config[section] = {**DEFAULT_CONFIG[section], **conf[section]} self.__logger.debug('config data = %s', self.__config) @@ -193,6 +204,9 @@ def get_mqtt_config(self): def get_http_config(self): return self.__config['http'] + def get_peripherals_config(self): + return self.__config['peripherals'] + @property def fade_time(self): return self.__config['model']['fade_time'] diff --git a/picframe/start.py b/picframe/start.py index 2544520..8926e48 100644 --- a/picframe/start.py +++ b/picframe/start.py @@ -6,7 +6,7 @@ import locale from distutils.dir_util import copy_tree -from picframe import model, viewer_display, controller, interface_kbd, interface_http, __version__ +from picframe import model, viewer_display, controller, interface_http, __version__ PICFRAME_DATA_DIR = 'picframe_data' @@ -135,9 +135,6 @@ def main(): c = controller.Controller(m, v) c.start() - if m.get_model_config()['use_kbd']: - interface_kbd.InterfaceKbd(c) # TODO make kbd failsafe - mqtt_config = m.get_mqtt_config() if mqtt_config['use_mqtt']: from picframe import interface_mqtt diff --git a/picframe/viewer_display.py b/picframe/viewer_display.py index d200b42..17c7ea1 100644 --- a/picframe/viewer_display.py +++ b/picframe/viewer_display.py @@ -394,6 +394,14 @@ def __draw_clock(self): if self.__clock_overlay: self.__clock_overlay.sprite.draw() + @property + def display_width(self): + return self.__display.width + + @property + def display_height(self): + return self.__display.height + def is_in_transition(self): return self.__in_transition @@ -421,6 +429,9 @@ def slideshow_start(self): def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, paused=False): + if self.clock_is_on: + self.__draw_clock() + loop_running = self.__display.loop_running() tm = time.time() if pics is not None: @@ -505,9 +516,6 @@ def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, if block is not None: block.sprite.draw() - if self.__show_clock: - self.__draw_clock() - return (loop_running, False) # now returns tuple with skip image flag added def slideshow_stop(self): From 42290d88a0f383f3607c1b0d6c5b1bd808bd22c5 Mon Sep 17 00:00:00 2001 From: Glen Vorel Date: Tue, 19 Oct 2021 17:45:11 +0200 Subject: [PATCH 02/15] Fix clock hiding --- picframe/interface_peripherals.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/picframe/interface_peripherals.py b/picframe/interface_peripherals.py index 18c130d..99e63c6 100644 --- a/picframe/interface_peripherals.py +++ b/picframe/interface_peripherals.py @@ -63,6 +63,7 @@ def __init__( self.__mouse_is_down = False self.__last_touch_position = None self.__last_menu_show_at = 0 + self.__clock_is_suspended = False self.__pointer_position = (0, 0) self.__timestamp = 0 @@ -109,10 +110,14 @@ def menu_is_on(self, val: bool) -> None: self.__menu_is_on = val if val: self.__last_menu_show_at = self.__timestamp - self.__viewer.clock_is_on = False + if self.__viewer.clock_is_on: + self.__clock_is_suspended = True + self.__viewer.clock_is_on = False self.__menu.show() else: - self.__viewer.clock_is_on = True + if self.__clock_is_suspended: + self.__clock_is_suspended = False + self.__viewer.clock_is_on = True self.__menu.hide() def __get_gui(self) -> "pi3d.Gui": From a8f0f82ada2deb268588d3cd85ddb2ae6a41f0a7 Mon Sep 17 00:00:00 2001 From: Jeff Godfrey Date: Sun, 14 Nov 2021 22:41:40 -0600 Subject: [PATCH 03/15] Issue 185, make text scrim height configurable - Add new "text_bkg_hgt" config value to control the text scrim height as a percentage of screen height. (0 < text_bkg_hgt <= 1, default = 0.25) --- picframe/config/configuration_example.yaml | 1 + picframe/model.py | 1 + picframe/viewer_display.py | 13 ++++++++----- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 3873942..fa74709 100644 --- a/picframe/config/configuration_example.yaml +++ b/picframe/config/configuration_example.yaml @@ -13,6 +13,7 @@ viewer: show_text_sz: 40 # default=40, text character size show_text: "title caption name date folder location" # default="title caption name date folder location", show text, include combination of words: title, caption name, date, location, folder text_justify: "L" # text justification L, C or R + text_bkg_hgt: 0.25 # default=0.25 (0.01-1.0), percentage of screen height for text background texture fit: False # default=False, True => scale image so all visible and leave 'gaps' # False => crop image so no 'gaps' kenburns: False # default=False, will set fit->False and blur_edges->False diff --git a/picframe/model.py b/picframe/model.py index d315119..6334def 100644 --- a/picframe/model.py +++ b/picframe/model.py @@ -25,6 +25,7 @@ 'show_text_sz': 40, 'show_text': "name location", 'text_justify': 'L', + 'text_bkg_hgt': 0.25, 'fit': False, #'auto_resize': True, 'kenburns': False, diff --git a/picframe/viewer_display.py b/picframe/viewer_display.py index 033328e..3aa49e1 100644 --- a/picframe/viewer_display.py +++ b/picframe/viewer_display.py @@ -58,6 +58,9 @@ def __init__(self, config): self.__show_text_sz = config['show_text_sz'] self.__show_text = parse_show_text(config['show_text']) self.__text_justify = config['text_justify'].upper() + self.__text_bkg_hgt = config['text_bkg_hgt'] + if self.__text_bkg_hgt <= 0 or self.__text_bkg_hgt > 1.0: + self.__text_bkg_hgt = 0.25 self.__fit = config['fit'] #self.__auto_resize = config['auto_resize'] self.__kenburns = config['kenburns'] @@ -175,7 +178,7 @@ def set_matting_images(self, val): # needs to cope with "true", "ON", 0, "0.2" e except: # ignore exceptions, error handling is done in following function pass self.__mat_images, self.__mat_images_tol = self.__get_mat_image_control_values(val) - + def get_matting_images(self): if self.__mat_images and self.__mat_images_tol > 0: return self.__mat_images_tol @@ -425,13 +428,13 @@ def slideshow_start(self): self.__slide.unif[55] = 1.0 #brightness self.__textblocks = [None, None] - bkg_ht = min(self.__display.width, self.__display.height) // 4 - text_bkg_array = np.zeros((bkg_ht, 1, 4), dtype=np.uint8) - text_bkg_array[:,:,3] = np.linspace(0, 120, bkg_ht).reshape(-1, 1) + bkg_hgt = int(min(self.__display.width, self.__display.height) * self.__text_bkg_hgt) + text_bkg_array = np.zeros((bkg_hgt, 1, 4), dtype=np.uint8) + text_bkg_array[:,:,3] = np.linspace(0, 120, bkg_hgt).reshape(-1, 1) text_bkg_tex = pi3d.Texture(text_bkg_array, blend=True, mipmap=False, free_after_load=True) self.__flat_shader = pi3d.Shader("uv_flat") - self.__text_bkg = pi3d.Sprite(w=self.__display.width, h=bkg_ht, y=-int(self.__display.height) // 2 + bkg_ht // 2, z=4.0) + self.__text_bkg = pi3d.Sprite(w=self.__display.width, h=bkg_hgt, y=-int(self.__display.height) // 2 + bkg_hgt // 2, z=4.0) self.__text_bkg.set_draw_details(self.__flat_shader, [text_bkg_tex]) From c7b42d4d589d9cebf44f16eccb9bb9ac087396fc Mon Sep 17 00:00:00 2001 From: Helge Date: Mon, 15 Nov 2021 19:29:10 +0100 Subject: [PATCH 04/15] load next image, if changing brightness, matting_images so state attributes are refresched --- picframe/controller.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/picframe/controller.py b/picframe/controller.py index dd39a4c..bb409e5 100644 --- a/picframe/controller.py +++ b/picframe/controller.py @@ -196,6 +196,7 @@ def brightness(self): @brightness.setter def brightness(self, val): self.__viewer.set_brightness(float(val)) + self.__next_tm = 0 @property def matting_images(self): @@ -204,6 +205,7 @@ def matting_images(self): @matting_images.setter def matting_images(self, val): self.__viewer.set_matting_images(float(val)) + self.__next_tm = 0 @property def location_filter(self): From 3f6d72840bfac8977e829572d60db620d386338f Mon Sep 17 00:00:00 2001 From: Helge Date: Mon, 15 Nov 2021 20:10:25 +0100 Subject: [PATCH 05/15] hass: add device config url --- picframe/config/configuration_example.yaml | 1 + picframe/interface_mqtt.py | 2 ++ picframe/model.py | 1 + 3 files changed, 4 insertions(+) diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 3873942..80a2fd6 100644 --- a/picframe/config/configuration_example.yaml +++ b/picframe/config/configuration_example.yaml @@ -92,6 +92,7 @@ mqtt: password: "your_password" # password for mqtt user tls: "/path/to/your/ca.crt" # filename including path to your ca.crt. If not used, must be set to "" !!!! device_id: "picframe" # default="picframe" unique id of device. change if there is more than one PictureFrame + device_url: "" # if use_http==True, set url to picframe config page http: use_http: False # default=False. Set True to enable http NB THIS SERVER IS FOR LOCAL NETWORK AND SHOULD NOT BE EXPOSED TO EXTERNAL ACCESS diff --git a/picframe/interface_mqtt.py b/picframe/interface_mqtt.py index e5b053a..d39364f 100644 --- a/picframe/interface_mqtt.py +++ b/picframe/interface_mqtt.py @@ -44,6 +44,7 @@ def __init__(self, controller, mqtt_config): self.__client.on_connect = self.on_connect self.__client.on_message = self.on_message self.__device_id = mqtt_config['device_id'] + self.__device_url = mqtt_config['device_url'] except Exception as e: self.__logger.info("MQTT not set up because of: {}".format(e)) @@ -203,6 +204,7 @@ def __setup_switch(self, client, switch_topic_head, topic, icon, "dev": { "ids": [self.__device_id], "name": self.__device_id, + "cu": self.__device_url, "mdl": "PictureFrame", "sw": __version__, "mf": "pi3d PictureFrame project"}}) diff --git a/picframe/model.py b/picframe/model.py index d315119..2bd4b68 100644 --- a/picframe/model.py +++ b/picframe/model.py @@ -81,6 +81,7 @@ 'password': '', 'tls': '', 'device_id': 'picframe', # unique id of device. change if there is more than one picture frame + 'device_url': '', }, 'http': { 'use_http': False, From 33ddc8ee71f83831613ffdfbafd3c3b07c551b49 Mon Sep 17 00:00:00 2001 From: Helge Date: Mon, 15 Nov 2021 21:42:59 +0100 Subject: [PATCH 06/15] hass: support of entity categories --- picframe/interface_mqtt.py | 93 +++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/picframe/interface_mqtt.py b/picframe/interface_mqtt.py index d39364f..7e0a75d 100644 --- a/picframe/interface_mqtt.py +++ b/picframe/interface_mqtt.py @@ -79,12 +79,12 @@ def on_connect(self, client, userdata, flags, rc): client.publish(available_topic, "online", qos=0, retain=True) ## sensors - self.__setup_sensor(client, sensor_topic_head, "date_from", "mdi:calendar-arrow-left", available_topic) - self.__setup_sensor(client, sensor_topic_head, "date_to", "mdi:calendar-arrow-right", available_topic) - self.__setup_sensor(client, sensor_topic_head, "location_filter", "mdi:map-search", available_topic) - self.__setup_sensor(client, sensor_topic_head, "tags_filter", "mdi:image-search", available_topic) - self.__setup_sensor(client, sensor_topic_head, "image_counter", "mdi:camera-burst", available_topic) - self.__setup_sensor(client, sensor_topic_head, "image", "mdi:file-image", available_topic, has_attributes=True) + self.__setup_sensor(client, sensor_topic_head, "date_from", "mdi:calendar-arrow-left", available_topic, entity_category="config") + self.__setup_sensor(client, sensor_topic_head, "date_to", "mdi:calendar-arrow-right", available_topic, entity_category="config") + self.__setup_sensor(client, sensor_topic_head, "location_filter", "mdi:map-search", available_topic, entity_category="config") + self.__setup_sensor(client, sensor_topic_head, "tags_filter", "mdi:image-search", available_topic, entity_category="config") + self.__setup_sensor(client, sensor_topic_head, "image_counter", "mdi:camera-burst", available_topic, entity_category="diagnostic") + self.__setup_sensor(client, sensor_topic_head, "image", "mdi:file-image", available_topic, has_attributes=True, entity_category="diagnostic") ## numbers self.__setup_number(client, number_topic_head, "brightness", 0.0, 1.0, 0.1, "mdi:brightness-6", available_topic) @@ -100,25 +100,25 @@ def on_connect(self, client, userdata, flags, rc): client.subscribe(command_topic, qos=0) ## switches - self.__setup_switch(client, switch_topic_head, "_text_refresh", "mdi:refresh", available_topic) + self.__setup_switch(client, switch_topic_head, "_text_refresh", "mdi:refresh", available_topic, entity_category="config") self.__setup_switch(client, switch_topic_head, "_delete", "mdi:delete", available_topic) self.__setup_switch(client, switch_topic_head, "_name_toggle", "mdi:subtitles", available_topic, - self.__controller.text_is_on("name")) + self.__controller.text_is_on("name"), entity_category="config") self.__setup_switch(client, switch_topic_head, "_title_toggle", "mdi:subtitles", available_topic, - self.__controller.text_is_on("title")) + self.__controller.text_is_on("title"), entity_category="config") self.__setup_switch(client, switch_topic_head, "_caption_toggle", "mdi:subtitles", available_topic, - self.__controller.text_is_on("caption")) + self.__controller.text_is_on("caption"), entity_category="config") self.__setup_switch(client, switch_topic_head, "_date_toggle", "mdi:calendar-today", available_topic, - self.__controller.text_is_on("date")) + self.__controller.text_is_on("date"), entity_category="config") self.__setup_switch(client, switch_topic_head, "_location_toggle", "mdi:crosshairs-gps", available_topic, - self.__controller.text_is_on("location")) + self.__controller.text_is_on("location"), entity_category="config") self.__setup_switch(client, switch_topic_head, "_directory_toggle", "mdi:folder", available_topic, - self.__controller.text_is_on("directory")) - self.__setup_switch(client, switch_topic_head, "_text_off", "mdi:badge-account-horizontal-outline", available_topic) + self.__controller.text_is_on("directory"), entity_category="config") + self.__setup_switch(client, switch_topic_head, "_text_off", "mdi:badge-account-horizontal-outline", available_topic, entity_category="config") self.__setup_switch(client, switch_topic_head, "_display", "mdi:panorama", available_topic, self.__controller.display_is_on) self.__setup_switch(client, switch_topic_head, "_clock", "mdi:clock-outline", available_topic, - self.__controller.clock_is_on) + self.__controller.clock_is_on, entity_category="config") self.__setup_switch(client, switch_topic_head, "_shuffle", "mdi:shuffle-variant", available_topic, self.__controller.shuffle) self.__setup_switch(client, switch_topic_head, "_paused", "mdi:pause", available_topic, @@ -129,26 +129,22 @@ def on_connect(self, client, userdata, flags, rc): client.subscribe(self.__device_id + "/purge_files", qos=0) # close down without killing! client.subscribe(self.__device_id + "/stop", qos=0) # close down without killing! - def __setup_sensor(self, client, sensor_topic_head, topic, icon, available_topic, has_attributes=False): + def __setup_sensor(self, client, sensor_topic_head, topic, icon, available_topic, has_attributes=False, entity_category=None): config_topic = sensor_topic_head + "_" + topic + "/config" name = self.__device_id + "_" + topic + dict = {"name": name, + "icon": icon, + "state_topic": sensor_topic_head + "/state", + "value_template": "{{ value_json." + topic + "}}", + "avty_t": available_topic, + "uniq_id": name, + "dev":{"ids":[self.__device_id]}} if has_attributes == True: - config_payload = json.dumps({"name": name, - "icon": icon, - "state_topic": sensor_topic_head + "/state", - "value_template": "{{ value_json." + topic + "}}", - "avty_t": available_topic, - "json_attributes_topic": sensor_topic_head + "_" + topic + "/attributes", - "uniq_id": name, - "dev":{"ids":[self.__device_id]}}) - else: - config_payload = json.dumps({"name": name, - "icon": icon, - "state_topic": sensor_topic_head + "/state", - "value_template": "{{ value_json." + topic + "}}", - "avty_t": available_topic, - "uniq_id": name, - "dev":{"ids":[self.__device_id]}}) + dict["json_attributes_topic"] = sensor_topic_head + "_" + topic + "/attributes" + if entity_category: + dict["entity_category"] = entity_category + + config_payload = json.dumps(dict) client.publish(config_topic, config_payload, qos=0, retain=True) client.subscribe(self.__device_id + "/" + topic, qos=0) @@ -162,6 +158,7 @@ def __setup_number(self, client, number_topic_head, topic, min, max, step, icon, "max": max, "step": step, "icon": icon, + "entity_category": "config", "state_topic": state_topic, "command_topic": command_topic, "value_template": "{{ value_json." + topic + "}}", @@ -178,6 +175,7 @@ def __setup_select(self, client, select_topic_head, topic, options, icon, availa name = self.__device_id + "_" + topic config_payload = json.dumps({"name": name, + "entity_category": "config", "icon": icon, "options": options, "state_topic": state_topic, @@ -191,23 +189,26 @@ def __setup_select(self, client, select_topic_head, topic, options, icon, availa client.subscribe(command_topic, qos=0) def __setup_switch(self, client, switch_topic_head, topic, icon, - available_topic, is_on=False): + available_topic, is_on=False, entity_category=None): config_topic = switch_topic_head + topic + "/config" command_topic = switch_topic_head + topic + "/set" state_topic = switch_topic_head + topic + "/state" - config_payload = json.dumps({"name": self.__device_id + topic, - "icon": icon, - "command_topic": command_topic, - "state_topic": state_topic, - "avty_t": available_topic, - "uniq_id": self.__device_id + topic, - "dev": { - "ids": [self.__device_id], - "name": self.__device_id, - "cu": self.__device_url, - "mdl": "PictureFrame", - "sw": __version__, - "mf": "pi3d PictureFrame project"}}) + dict = {"name": self.__device_id + topic, + "icon": icon, + "command_topic": command_topic, + "state_topic": state_topic, + "avty_t": available_topic, + "uniq_id": self.__device_id + topic, + "dev": { + "ids": [self.__device_id], + "name": self.__device_id, + "cu": self.__device_url, + "mdl": "PictureFrame", + "sw": __version__, + "mf": "pi3d PictureFrame project"}} + if entity_category: + dict["entity_category"] = entity_category + config_payload = json.dumps(dict) client.subscribe(command_topic , qos=0) client.publish(config_topic, config_payload, qos=0, retain=True) From 84c7441ffe5d9b8cf1716c1ec508a21b5f66b14b Mon Sep 17 00:00:00 2001 From: Helge Date: Mon, 15 Nov 2021 23:45:11 +0100 Subject: [PATCH 07/15] hatt: device_url must be a valid url or empty ("") --- picframe/config/configuration_example.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 80a2fd6..db4c41d 100644 --- a/picframe/config/configuration_example.yaml +++ b/picframe/config/configuration_example.yaml @@ -92,7 +92,7 @@ mqtt: password: "your_password" # password for mqtt user tls: "/path/to/your/ca.crt" # filename including path to your ca.crt. If not used, must be set to "" !!!! device_id: "picframe" # default="picframe" unique id of device. change if there is more than one PictureFrame - device_url: "" # if use_http==True, set url to picframe config page + device_url: "" # if use_http==True, set url to picframe config page. Must be a valid url, or "" otherwise home assistant runs in an error. http: use_http: False # default=False. Set True to enable http NB THIS SERVER IS FOR LOCAL NETWORK AND SHOULD NOT BE EXPOSED TO EXTERNAL ACCESS From 4443caf4eaab79bcc7a3c392a6cd77b466a8b1ae Mon Sep 17 00:00:00 2001 From: Helge Date: Mon, 15 Nov 2021 23:46:03 +0100 Subject: [PATCH 08/15] hass: device_url must be a valid url or empty("") --- picframe/interface_mqtt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/picframe/interface_mqtt.py b/picframe/interface_mqtt.py index 7e0a75d..10886ab 100644 --- a/picframe/interface_mqtt.py +++ b/picframe/interface_mqtt.py @@ -202,10 +202,11 @@ def __setup_switch(self, client, switch_topic_head, topic, icon, "dev": { "ids": [self.__device_id], "name": self.__device_id, - "cu": self.__device_url, "mdl": "PictureFrame", "sw": __version__, "mf": "pi3d PictureFrame project"}} + if self.__device_url : + dict["dev"]["cu"] = self.__device_url if entity_category: dict["entity_category"] = entity_category config_payload = json.dumps(dict) From 9ff24322b0f0b2a0a2b425c135bd083a3a93ecce Mon Sep 17 00:00:00 2001 From: Glen Vorel Date: Sun, 12 Dec 2021 21:39:55 +0100 Subject: [PATCH 09/15] Fix HTTP interface --- picframe/interface_http.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/picframe/interface_http.py b/picframe/interface_http.py index 33d55b9..39d6424 100644 --- a/picframe/interface_http.py +++ b/picframe/interface_http.py @@ -81,8 +81,6 @@ def do_GET(self): for subkey in self.server._setters: message[subkey] = getattr(self.server._controller, subkey) elif key in dir(self.server._controller): - if key in self.server._setters: # can get info back from controller TODO - message[key] = getattr(self.server._controller, key) if value != "": # parse_qsl can return empty string for value when just querying lwr_val = value.lower() if lwr_val in ("true", "on", "yes"): # this only works for simple values *not* json style kwargs @@ -98,6 +96,8 @@ def do_GET(self): getattr(self.server._controller, key)(**json.loads(value)) except Exception as e: message['ERROR'] = 'Excepton:{}>{};'.format(key, e) + if key in self.server._setters: # can get info back from controller TODO + message[key] = getattr(self.server._controller, key) self.wfile.write(bytes(json.dumps(message), "utf8")) self.connection.close() @@ -148,18 +148,8 @@ def __init__(self, controller, html_path, pic_dir, no_files_img, port=9000): controller_class = controller.__class__ self._setters = [method for method in dir(controller_class) if 'setter' in dir(getattr(controller_class, method))] - self.__keep_looping = True - self.__shutdown_completed = False - t = threading.Thread(target=self.__loop) + t = threading.Thread(target=self.serve_forever) t.start() - def __loop(self): - while self.__keep_looping: - self.handle_request() - time.sleep(0.1) - self.__shutdown_completed = True - def stop(self): - self.__keep_looping = False - while not self.__shutdown_completed: - time.sleep(0.05) # function blocking until loop stopped + self.shutdown() From 668d675f4e19e799886143ae781efb0582f87092 Mon Sep 17 00:00:00 2001 From: Glen Vorel Date: Tue, 14 Dec 2021 11:42:14 +0100 Subject: [PATCH 10/15] Log menu item invocation --- picframe/interface_peripherals.py | 46 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/picframe/interface_peripherals.py b/picframe/interface_peripherals.py index 99e63c6..3490b84 100644 --- a/picframe/interface_peripherals.py +++ b/picframe/interface_peripherals.py @@ -1,6 +1,6 @@ import inspect import logging -import os +import subprocess import sys import time import typing @@ -9,6 +9,9 @@ import pi3d +logger = logging.getLogger(__name__) + + class InterfacePeripherals: """Opens connections to peripheral interfaces and reacts to their state to handle user input. Controls playback (navigation), device display, device power and other things. @@ -25,19 +28,19 @@ def __init__( viewer: "picframe.viewer_display.ViewerDisplay", controller: "picframe.controller.Controller", ) -> None: - self.__logger = logging.getLogger(__name__) - self.__logger.info("creating an instance of InterfacePeripherals") + logger.info("creating an instance of InterfacePeripherals") + self.__model = model self.__viewer = viewer self.controller = controller self.__input_type = self.__model.get_peripherals_config()["input_type"] if not self.__input_type: - self.__logger.info("no peripheral input is enabled") + logger.info("peripheral input is disabled") return valid_input_types = {"keyboard", "touch", "mouse"} if self.__input_type not in valid_input_types: - self.__logger.warning( + logger.warning( "input type '%s' is invalid, valid options are: %s", self.__input_type, valid_input_types, @@ -307,22 +310,22 @@ def __pointer_moved(self) -> bool: return False def __handle_click(self) -> None: - self.__logger.debug("handling click at position x: %s, y: %s", *self.__pointer_position) + logger.debug("handling click at position x: %s, y: %s", *self.__pointer_position) self.__gui.check(*self.__pointer_position) def __go_back(self, position) -> None: - self.__logger.debug("previous picture") + logger.info("navigation: previous picture") self.controller.back() def __go_next(self, position) -> None: - self.__logger.debug("next picture") + logger.info("navigation: next picture") self.controller.next() class IPMenuItem(pi3d.MenuItem): - """Wrapper around pi3d.MenuItem that implements `callback` method. + """Wrapper around pi3d.MenuItem that implements `action` method. In the future, this class can be extended to support toggling of multiple text labels - (e.g., "Pause"/"Unpause"). + (e.g., "Pause"/"Resume"). A subclass must imlement class variable `config_name` that matches its name in the configuration. """ @@ -335,7 +338,16 @@ def __init__(self, ip: "InterfacePeripherals", gui: "pi3d.Gui", text: str, short super().__init__(gui, text=text, callback=self.callback, shortcut=shortcut) def callback(self, *args) -> None: - """A subclass must override this method to define its business logic.""" + """ + Logs each action. + """ + logger.info("invoked menu item: %s", self.config_name) + self.action() + + def action(self) -> None: + """ + A subclass must override this method to define its business logic. + """ raise NotImplementedError @@ -346,7 +358,7 @@ class PauseMenuItem(IPMenuItem): config_name = "pause" - def callback(self, *args): + def action(self): self.ip.controller.paused = not self.ip.controller.paused @@ -357,7 +369,7 @@ class DisplayOffMenuItem(IPMenuItem): config_name = "display_off" - def callback(self, *args): + def action(self): self.ip.controller.display_is_on = False @@ -366,7 +378,7 @@ class LocationMenuItem(IPMenuItem): config_name = "location" - def callback(self, *args): + def action(self): if self.ip.controller.text_is_on("location"): self.ip.controller.set_show_text("location", "OFF") else: @@ -378,7 +390,7 @@ class ExitMenuItem(IPMenuItem): config_name = "exit" - def callback(self, *args): + def action(self): self.ip.controller.keep_looping = False @@ -387,6 +399,6 @@ class PowerDownMenuItem(IPMenuItem): config_name = "power_down" - def callback(self, *args): + def action(self): self.ip.controller.keep_looping = False - os.system("sudo shutdown now") + subprocess.check_call(["sudo", "poweroff"]) From adfb3b0a97eb1c35fa5b0ecdc8498efa8b8a0560 Mon Sep 17 00:00:00 2001 From: Glen Vorel Date: Tue, 14 Dec 2021 20:56:09 +0100 Subject: [PATCH 11/15] Allow text_bkg_hgt: 0 --- picframe/config/configuration_example.yaml | 2 +- picframe/viewer_display.py | 31 +++++++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 51244e0..6cd0cd9 100644 --- a/picframe/config/configuration_example.yaml +++ b/picframe/config/configuration_example.yaml @@ -13,7 +13,7 @@ viewer: show_text_sz: 40 # default=40, text character size show_text: "title caption name date folder location" # default="title caption name date folder location", show text, include combination of words: title, caption name, date, location, folder text_justify: "L" # text justification L, C or R - text_bkg_hgt: 0.25 # default=0.25 (0.01-1.0), percentage of screen height for text background texture + text_bkg_hgt: 0.25 # default=0.25 (0.0-1.0), percentage of screen height for text background texture fit: False # default=False, True => scale image so all visible and leave 'gaps' # False => crop image so no 'gaps' kenburns: False # default=False, will set fit->False and blur_edges->False diff --git a/picframe/viewer_display.py b/picframe/viewer_display.py index 3aa49e1..70d8dca 100644 --- a/picframe/viewer_display.py +++ b/picframe/viewer_display.py @@ -58,9 +58,7 @@ def __init__(self, config): self.__show_text_sz = config['show_text_sz'] self.__show_text = parse_show_text(config['show_text']) self.__text_justify = config['text_justify'].upper() - self.__text_bkg_hgt = config['text_bkg_hgt'] - if self.__text_bkg_hgt <= 0 or self.__text_bkg_hgt > 1.0: - self.__text_bkg_hgt = 0.25 + self.__text_bkg_hgt = config['text_bkg_hgt'] if 0 <= config['text_bkg_hgt'] <= 1 else 0.25 self.__fit = config['fit'] #self.__auto_resize = config['auto_resize'] self.__kenburns = config['kenburns'] @@ -80,6 +78,7 @@ def __init__(self, config): self.__delta_alpha = 1.0 self.__display = None self.__slide = None + self.__flat_shader = None self.__xstep = None self.__ystep = None #self.__text = None @@ -372,7 +371,7 @@ def __make_text(self, pic, paused, side=0, pair=False): c_rng = self.__display.width - 100 # range for x loc from L to R justified else: c_rng = self.__display.width * 0.5 - 100 # range for x loc from L to R justified - block = pi3d.FixedString(self.__font_file, final_string, font_size=self.__show_text_sz, + block = pi3d.FixedString(self.__font_file, final_string, shadow_radius=3, font_size=self.__show_text_sz, shader=self.__flat_shader, justify=self.__text_justify, width=c_rng) adj_x = (c_rng - block.sprite.width) // 2 # half amount of space outside sprite if self.__text_justify == "L": @@ -427,15 +426,15 @@ def slideshow_start(self): self.__slide.unif[54] = float(self.__blend_type) self.__slide.unif[55] = 1.0 #brightness self.__textblocks = [None, None] - - bkg_hgt = int(min(self.__display.width, self.__display.height) * self.__text_bkg_hgt) - text_bkg_array = np.zeros((bkg_hgt, 1, 4), dtype=np.uint8) - text_bkg_array[:,:,3] = np.linspace(0, 120, bkg_hgt).reshape(-1, 1) - text_bkg_tex = pi3d.Texture(text_bkg_array, blend=True, mipmap=False, free_after_load=True) - self.__flat_shader = pi3d.Shader("uv_flat") - self.__text_bkg = pi3d.Sprite(w=self.__display.width, h=bkg_hgt, y=-int(self.__display.height) // 2 + bkg_hgt // 2, z=4.0) - self.__text_bkg.set_draw_details(self.__flat_shader, [text_bkg_tex]) + + if self.__text_bkg_hgt: + bkg_hgt = int(min(self.__display.width, self.__display.height) * self.__text_bkg_hgt) + text_bkg_array = np.zeros((bkg_hgt, 1, 4), dtype=np.uint8) + text_bkg_array[:, :, 3] = np.linspace(0, 120, bkg_hgt).reshape(-1, 1) + text_bkg_tex = pi3d.Texture(text_bkg_array, blend=True, mipmap=False, free_after_load=True) + self.__text_bkg = pi3d.Sprite(w=self.__display.width, h=bkg_hgt, y=-int(self.__display.height) // 2 + bkg_hgt // 2, z=4.0) + self.__text_bkg.set_draw_details(self.__flat_shader, [text_bkg_tex]) def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, paused=False): @@ -517,9 +516,11 @@ def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, for block in self.__textblocks: if block is not None: block.sprite.set_alpha(alpha) - self.__text_bkg.set_alpha(alpha) - if any(block is not None for block in self.__textblocks): #txt_len > 0: #only draw background if text there - self.__text_bkg.draw() + + if self.__text_bkg_hgt: + self.__text_bkg.set_alpha(alpha) + if any(block is not None for block in self.__textblocks): #txt_len > 0: #only draw background if text there + self.__text_bkg.draw() for block in self.__textblocks: if block is not None: From 6db81ba754e16ac711f12a8b5c1e15fb9d715463 Mon Sep 17 00:00:00 2001 From: helgeerbe <59169507+helgeerbe@users.noreply.github.com> Date: Wed, 5 Jan 2022 10:23:25 +0100 Subject: [PATCH 12/15] Fix #182 Will fix #182 on new install, as a workaround upgrade Pillow to release 9.0.0 or later --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f43f6a4..18c48d6 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ 'config/*', 'config/**/*', 'html/*', 'html/**/*']}, install_requires=[ - 'Pillow', + 'Pillow>=9.0.0', 'ExifRead', 'pi3d>=2.49', 'PyYAML', From ce7483fd2013c1650681152c5057b5415bfea400 Mon Sep 17 00:00:00 2001 From: Jeff Godfrey Date: Fri, 7 Jan 2022 14:00:06 -0600 Subject: [PATCH 13/15] Commit schema updates - Specifically, intentionally commit any schema updates --- picframe/image_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picframe/image_cache.py b/picframe/image_cache.py index 37d8630..ee3db12 100644 --- a/picframe/image_cache.py +++ b/picframe/image_cache.py @@ -345,7 +345,7 @@ def __update_schema(self, required_db_schema_version): # Finally, update the db's schema version stamp to the app's requested version self.__db.execute('DELETE FROM db_info') self.__db.execute('INSERT INTO db_info VALUES(?)', (required_db_schema_version,)) - + self.__db.commit() # --- Returns a set of folders matching any of # - Found on disk, but not currently in the 'folder' table From b47960ed447a3a5cae768d8175a049c428b18d25 Mon Sep 17 00:00:00 2001 From: Jeff Godfrey Date: Sat, 15 Jan 2022 11:58:02 -0600 Subject: [PATCH 14/15] Fix issue 203 - Text fade isn't smooth - Change calculation of "dt" to ensure proper numeric range of text alpha - Ensure text alpha actually reaches zero to prevent still-visible ghosted text - Reorder some code to ensure slow image processing doesn't impact text fade time - Remove some commented-out code --- picframe/viewer_display.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/picframe/viewer_display.py b/picframe/viewer_display.py index 0e2a9f0..3dd3be7 100644 --- a/picframe/viewer_display.py +++ b/picframe/viewer_display.py @@ -452,15 +452,14 @@ def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, loop_running = self.__display.loop_running() tm = time.time() if pics is not None: - #self.__sbg = self.__sfg # if the first tex_load fails then __sfg might be Null TODO should fn return if None? + new_sfg = self.__tex_load(pics, (self.__display.width, self.__display.height)) + tm = time.time() self.__next_tm = tm + time_delay self.__name_tm = tm + fade_time + self.__show_text_tm # text starts after slide transition - new_sfg = self.__tex_load(pics, (self.__display.width, self.__display.height)) if new_sfg is not None: # this is a possible return value which needs to be caught self.__sbg = self.__sfg self.__sfg = new_sfg else: - #return (True, False) # return early (self.__sbg, self.__sfg) = (self.__sfg, self.__sbg) # swap existing images over self.__alpha = 0.0 if fade_time > 0.5: @@ -494,13 +493,9 @@ def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, self.__xstep, self.__ystep = (self.__slide.unif[i] * 2.0 / (time_delay - fade_time) for i in (48, 49)) self.__slide.unif[48] = 0.0 self.__slide.unif[49] = 0.0 - #self.__kb_up = not self.__kb_up # just go in one direction if self.__kenburns and self.__alpha >= 1.0: t_factor = time_delay - fade_time - self.__next_tm + tm - #t_factor = self.__next_tm - tm - #if self.__kb_up: - # t_factor = time_delay - t_factor # add exponentially smoothed tweening in case of timing delays etc. to avoid 'jumps' self.__slide.unif[48] = self.__slide.unif[48] * 0.95 + self.__xstep * t_factor * 0.05 self.__slide.unif[49] = self.__slide.unif[49] * 0.95 + self.__ystep * t_factor * 0.05 @@ -520,18 +515,22 @@ def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, if self.__alpha >= 1.0 and tm < self.__name_tm: # this sets alpha for the TextBlock from 0 to 1 then back to 0 - dt = 1.1 - (self.__name_tm - tm) / self.__show_text_tm # i.e. dt from 0.1 to 1.1 + dt = 1.0 - (self.__name_tm - tm) / self.__show_text_tm + if dt > 0.995: dt = 1 # ensure that calculated alpha value fully reaches 0 (TODO: Improve!) ramp_pt = max(4.0, self.__show_text_tm / 4.0) # always > 4 so text fade will always < 4s + # create single saw tooth over 0 to __show_text_tm alpha = max(0.0, min(1.0, ramp_pt * (1.0 - abs(1.0 - 2.0 * dt)))) # function only run if image alpha is 1.0 so can use 1.0 - abs... + + # if we have text, set it's current alpha value to fade in/out for block in self.__textblocks: if block is not None: block.sprite.set_alpha(alpha) - if self.__text_bkg_hgt: + # if we have a text background to render (and we currently have text), set its alpha and draw it + if self.__text_bkg_hgt and any(block is not None for block in self.__textblocks): #txt_len > 0: #only draw background if text there self.__text_bkg.set_alpha(alpha) - if any(block is not None for block in self.__textblocks): #txt_len > 0: #only draw background if text there - self.__text_bkg.draw() + self.__text_bkg.draw() for block in self.__textblocks: if block is not None: @@ -540,4 +539,4 @@ def slideshow_is_running(self, pics=None, time_delay = 200.0, fade_time = 10.0, return (loop_running, False) # now returns tuple with skip image flag added def slideshow_stop(self): - self.__display.destroy() \ No newline at end of file + self.__display.destroy() From f4bdb4d6e172d2d1d23cf42b0d27d9b851b6a980 Mon Sep 17 00:00:00 2001 From: Jeff Godfrey Date: Sat, 15 Jan 2022 17:34:18 -0600 Subject: [PATCH 15/15] Ignore hidden folders when loading images - While building the image cache, ignore hidden folders --- picframe/image_cache.py | 1 + 1 file changed, 1 insertion(+) diff --git a/picframe/image_cache.py b/picframe/image_cache.py index ee3db12..b640c85 100644 --- a/picframe/image_cache.py +++ b/picframe/image_cache.py @@ -356,6 +356,7 @@ def __get_modified_folders(self): out_of_date_folders = [] sql_select = "SELECT * FROM folder WHERE name = ?" for dir in [d[0] for d in os.walk(self.__picture_dir, followlinks=self.__follow_links)]: + if os.path.basename(dir)[0] == '.': continue # ignore hidden folders mod_tm = int(os.stat(dir).st_mtime) found = self.__db.execute(sql_select, (dir,)).fetchone() if not found or found['last_modified'] < mod_tm or found['missing'] == 1: