diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 3873942..f16f25a 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.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 @@ -38,6 +39,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 +86,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 @@ -92,6 +95,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. 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 @@ -99,4 +103,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 dd39a4c..a99f837 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() @@ -196,6 +201,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 +210,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): @@ -276,7 +283,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 @@ -287,8 +294,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 @@ -311,13 +319,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/image_cache.py b/picframe/image_cache.py index 37d8630..b640c85 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 @@ -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: 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() 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_mqtt.py b/picframe/interface_mqtt.py index e5b053a..10886ab 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)) @@ -78,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) @@ -99,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, @@ -128,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) @@ -161,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 + "}}", @@ -177,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, @@ -190,22 +189,27 @@ 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, - "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, + "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) client.subscribe(command_topic , qos=0) client.publish(config_topic, config_payload, qos=0, retain=True) diff --git a/picframe/interface_peripherals.py b/picframe/interface_peripherals.py new file mode 100644 index 0000000..3490b84 --- /dev/null +++ b/picframe/interface_peripherals.py @@ -0,0 +1,404 @@ +import inspect +import logging +import subprocess +import sys +import time +import typing + +import numpy as np +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. + + 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: + 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: + logger.info("peripheral input is disabled") + return + valid_input_types = {"keyboard", "touch", "mouse"} + if self.__input_type not in valid_input_types: + 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.__clock_is_suspended = False + 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 + if self.__viewer.clock_is_on: + self.__clock_is_suspended = True + self.__viewer.clock_is_on = False + self.__menu.show() + else: + 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": + 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: + 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: + logger.info("navigation: previous picture") + self.controller.back() + + def __go_next(self, position) -> None: + logger.info("navigation: next picture") + self.controller.next() + + +class IPMenuItem(pi3d.MenuItem): + """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"/"Resume"). + + 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: + """ + 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 + + +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 action(self): + 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 action(self): + self.ip.controller.display_is_on = False + + +class LocationMenuItem(IPMenuItem): + """Shows or hides location information.""" + + config_name = "location" + + def action(self): + 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 action(self): + self.ip.controller.keep_looping = False + + +class PowerDownMenuItem(IPMenuItem): + """Exits the program and shuts down the device. Uses sudo.""" + + config_name = "power_down" + + def action(self): + self.ip.controller.keep_looping = False + subprocess.check_call(["sudo", "poweroff"]) diff --git a/picframe/model.py b/picframe/model.py index d315119..4987a46 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, @@ -48,6 +49,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 +74,6 @@ 'deleted_pictures': '~/DeletedPictures', 'log_level': 'WARNING', 'log_file': '', - 'use_kbd': False, }, 'mqtt': { 'use_mqtt': False, # Set tue true, to enable mqtt @@ -81,6 +83,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, @@ -89,7 +92,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 +149,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 +206,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 033328e..3dd3be7 100644 --- a/picframe/viewer_display.py +++ b/picframe/viewer_display.py @@ -58,6 +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 0 <= config['text_bkg_hgt'] <= 1 else 0.25 self.__fit = config['fit'] #self.__auto_resize = config['auto_resize'] self.__kenburns = config['kenburns'] @@ -77,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 @@ -175,7 +177,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 @@ -369,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": @@ -409,6 +411,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 @@ -424,30 +434,32 @@ def slideshow_start(self): self.__slide.unif[54] = float(self.__blend_type) 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) - 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.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): + if self.clock_is_on: + self.__draw_clock() + 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: @@ -481,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 @@ -507,25 +515,28 @@ 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) - 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 + + # 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) self.__text_bkg.draw() for block in self.__textblocks: 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): - self.__display.destroy() \ No newline at end of file + self.__display.destroy() diff --git a/setup.py b/setup.py index dccf415..7ab3719 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',