Skip to content
Open
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions displayer_service/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
current_image.png
.ink-memories-log
venv/
tmp-images/
57 changes: 57 additions & 0 deletions displayer_service/common/debug_screen.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 0 additions & 3 deletions displayer_service/common/display_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}


Expand Down
65 changes: 40 additions & 25 deletions displayer_service/common/screen_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
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


PATH = os.path.dirname(__file__)
DISPLAY_CONFIG_FILE_PATH = './display_config.json'
INITIAL_QUEUE_SIZE = 10
LOG_FILE_PATH = './.ink-memories-log'


class ScreenManager:
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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."
)
Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
Binary file added displayer_service/fonts/Mono.ttf
Binary file not shown.