diff --git a/README.md b/README.md index 70cb988..c0488d2 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,17 @@ https://github.com/Tymotex/InkMemories/assets/54927071/7238f156-209b-4a7c-9688-9 ## Usage Notes -Follow the ['Setup Instructions'](#setup-instructions) to begin running Ink Memories. +Follow the ['Setup Instructions'](#setup-instructions) to set up and begin running Ink Memories on a new Pi zero. + +InkMemories will automatically display a new image to the screen every hour. - Press the top left button (labeled 'A') to force refresh a new image. +- Press the second button from the top (labeled 'B') to enter debug mode which displays some recent logs from the main Python script. + - Pressing 'B' while in debug mode refreshes the displayed logs. Due to the + screen's long refresh rate, the display can't be real-time. + [Faster monochrome refresh is not supported](https://github.com/pimoroni/inky/issues/155) + - Pressing 'A' while in debug mode switches back to showing images + regularly. - Press the bottom left button (labeled 'D') to gracefully shut down the system. - Unplug after several seconds to disconnect power. - The image will persist on the eInk display indefinitely and without power. diff --git a/displayer_service/.gitignore b/displayer_service/.gitignore index d0b20f8..f3518c8 100644 --- a/displayer_service/.gitignore +++ b/displayer_service/.gitignore @@ -3,3 +3,4 @@ __pycache__/ current_image.png .ink-memories-log venv/ +tmp-images/ diff --git a/displayer_service/common/debug_screen.py b/displayer_service/common/debug_screen.py new file mode 100644 index 0000000..fa545fd --- /dev/null +++ b/displayer_service/common/debug_screen.py @@ -0,0 +1,57 @@ +#!/usr/bin/python3 + +"""Module for creating the debug screen.""" + +from PIL import Image, ImageDraw, ImageFont +from PIL.Image import Image as ImageType + +# At font size 20 and a screen size of 600x448, we can fit: +# - 59 characters horizontally. +# - 22 lines vertically. +CHARS_PER_LINE = 59 +MAX_LINES = 22 + +# Font path relative to the root folder, `displayer_service`. +FONT_PATH = "fonts/Mono.ttf" +FONT_SIZE = 20 + + +def transform_logs_to_image(logs_path: str) -> ImageType: + """Returns a PIL Image of the debug screen. + + Writes the most recent few logs to an in-memory PIL image with readable font + style and size. + """ + # Create a blank white image + debug_screen_img = Image.new('RGB', (600, 448), 'white') + draw = ImageDraw.Draw(debug_screen_img) + font = ImageFont.truetype(FONT_PATH, FONT_SIZE) + + # Extract last MAX_LINES lines from the log file and reverse. + lines = [] + with open(logs_path, "r", encoding='utf-8') as logs_file: + lines = logs_file.read().splitlines() + # Get only the last MAX_LINES items and reverse the list so most recent logs + # are first. + lines = lines[-MAX_LINES:] + lines = lines[::-1] + + # Write out each line into a PIL image, accounting for text-wrapping. Stop + # if we exceed the max number of lines permitted on the screen. + line_index = 0 + lines_consumed = 0 + while lines_consumed < MAX_LINES and line_index < len(lines): + curr_line = lines[line_index] + # Truncate logs if they're beyond 3 lines, i.e. 67 * 3 characters. + curr_line = (curr_line[:CHARS_PER_LINE * 3 - 3] + + "...") if len(curr_line) > CHARS_PER_LINE else curr_line + while curr_line and lines_consumed < MAX_LINES: + # Write out the first CHARS_PER_LINE characters, then slice it out. + line_to_write = curr_line[:CHARS_PER_LINE].strip() + draw.text((0, lines_consumed * FONT_SIZE), line_to_write, + font=font, fill=(0, 0, 0)) + curr_line = curr_line[CHARS_PER_LINE:] + lines_consumed += 1 + line_index += 1 + + return debug_screen_img diff --git a/displayer_service/common/display_config.py b/displayer_service/common/display_config.py index 61407ce..72d86e6 100644 --- a/displayer_service/common/display_config.py +++ b/displayer_service/common/display_config.py @@ -10,9 +10,6 @@ # TODO: Required fields like this should be validated to be set to an existent directory on program startup and log on failure. "image_source_dir": "~/Pictures" }, - "logging": { - "log_file_path": ".ink-memories-log" - } } diff --git a/displayer_service/common/screen_manager.py b/displayer_service/common/screen_manager.py index 5c8594a..09d954f 100644 --- a/displayer_service/common/screen_manager.py +++ b/displayer_service/common/screen_manager.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Union -from common import image_processor +from common import image_processor, debug_screen from common.display_config import DisplayConfig from common.image_retriever import ImageRetriever @@ -23,6 +23,7 @@ PATH = os.path.dirname(__file__) DISPLAY_CONFIG_FILE_PATH = './display_config.json' INITIAL_QUEUE_SIZE = 10 +LOG_FILE_PATH = './.ink-memories-log' class ScreenManager: @@ -46,8 +47,8 @@ class ScreenManager: # Utility for retrieving images from the image source. image_retriever: ImageRetriever - # Whether the user is currently in debugging mode. - # The user can enter debugging mode by pressing the 'B' button. + # Whether the user is currently in debugging mode. + # The user can enter debugging mode by pressing the 'B' button. # Debugging mode can be exited via a force image refresh ('A' button). is_debugging = False @@ -64,16 +65,17 @@ def __init__(self): self.image_retriever = ImageRetriever( self.logger, self.display_config) - # Populate the image buffer with some intiial images. - # Keep trying until it is populated. + # Populate the image buffer with some intiial images. + # Keep trying until it is populated. chosen_images = None while chosen_images is None: try: chosen_images = self.image_retriever.get_random_images( INITIAL_QUEUE_SIZE) - except Exception as e: + except Exception as e: self.logger.error(e) - self.logger.info("Initial population of images has failed. Trying again in 300 seconds.") + self.logger.info( + "Initial population of images has failed. Trying again in 300 seconds.") time.sleep(300) for img in chosen_images: @@ -97,9 +99,8 @@ def configure_logger(self): Expects that the display config has already been populated. """ formatter = logging.Formatter( - '%(asctime)s : %(levelname)s : %(name)s : %(message)s') - file_handler = logging.FileHandler( - self.display_config.config['logging']['log_file_path']) + '[%(asctime)s] %(message)s', datefmt="%Y-%m-%d %H:%M") + file_handler = logging.FileHandler(LOG_FILE_PATH) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) @@ -148,7 +149,7 @@ def refresh_in_background(self) -> None: while True: self.logger.info("Automatic image refresh requested.") - if self.is_debugging: + if self.is_debugging: self.logger.info( "Debugging mode is ON. Skipping image refresh." ) @@ -165,25 +166,22 @@ def queue_image(self): self.image_queue.put(self.image_retriever.get_random_image()) except Exception as e: self.logger.error(e) - self.logger.info("Failed to queue image. Size of queue: %s", self.image_queue.qsize()) + self.logger.info( + "Failed to queue image. Size of queue: %s", self.image_queue.qsize()) def output_and_queue_image(self): """Displays the next image in the image queue, and adds a new image to the queue.""" - self.logger.info( - "Showing the contents of the image queue (size=%s)...", self.image_queue.qsize()) - self.logger.info( - [img.filename for img in list(self.image_queue.queue)]) + self.logger.info("Image queue size is %s.", self.image_queue.qsize()) try: next_image = self.image_queue.get() except queue.Empty: - # TODO: handle case where this this is requested multiple times. - self.logger.error("Tried to set the next image, but queue was empty.") + self.logger.error( + "Tried to set the next image, but queue was empty.") self.logger.info("Repopulating the image buffer.") - # TODO: consider consolidating this logic with the initial image population. chosen_images = self.image_retriever.get_random_images( - INITIAL_QUEUE_SIZE) + INITIAL_QUEUE_SIZE) for img in chosen_images: self.image_queue.put(img) @@ -212,9 +210,28 @@ def set_image(self, img): self.logger.info("Done writing image.") def push_debugger_update(self): - self.logger.info("Fetching the latest debugging information.") + """Displays the debug mode screen. + + When the user presses B, debug mode will be flipped on and the + troubleshooting screen will show. + This screen shows some of the most recent logs. + Flipping on debug mode will not pre-empt any in-progress screen + refreshes. + """ + if self.screen_lock.locked(): + self.logger.info( + "Attempted to enter debug mode while screen was busy. Skipping.") + return + + self.logger.info("Already in debug mode. Fetching latest debug logs." if self.is_debugging else "Entering debug mode.") + self.is_debugging = True - # TODO: Fetch and update the display with the latest debugging information + with self.screen_lock: + # Ensure the image fits into the eink display's resolution. + debug_screen_img = debug_screen.transform_logs_to_image(LOG_FILE_PATH) + debug_screen_img = debug_screen_img.resize( + self.eink_display.resolution) + self.set_image(debug_screen_img) def handle_button_press(self, pressed_pin): """Executes specific actions on button presses. @@ -235,9 +252,7 @@ def handle_button_press(self, pressed_pin): self.is_debugging = False self.output_and_queue_image() elif label == 'B': - self.logger.info( - "User pressed B." + "Entering debugging mode." if self.is_debugging else "Refreshing debugger.") - self.is_debugging = True + self.logger.info("User pressed B. Displaying the debug screen.") self.push_debugger_update() elif label == 'C': self.logger.info( diff --git a/displayer_service/fonts/Mono.ttf b/displayer_service/fonts/Mono.ttf new file mode 100644 index 0000000..4977028 Binary files /dev/null and b/displayer_service/fonts/Mono.ttf differ