From 25b20fdbbd70b62a014f994920ca6eb46ab2bef0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 23 Nov 2025 14:24:23 +0100 Subject: [PATCH] Added logging for GPIO --- dreader/book_utils.py | 32 ++++++++++++ dreader/hal_hardware.py | 108 +++++++++++++++++++++++++++++++++++----- dreader/library.py | 35 ++++++++++++- dreader/main.py | 3 +- 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/dreader/book_utils.py b/dreader/book_utils.py index c32bf11..b260c18 100644 --- a/dreader/book_utils.py +++ b/dreader/book_utils.py @@ -2,6 +2,8 @@ Utilities for managing book library, scanning EPUBs, and extracting metadata. """ +import logging +import time from pathlib import Path from typing import List, Dict, Optional from dreader import create_ebook_reader @@ -11,6 +13,8 @@ from PIL import Image import ebooklib from ebooklib import epub +logger = logging.getLogger(__name__) + def scan_book_directory(directory: Path) -> List[Dict[str, str]]: """ @@ -44,10 +48,15 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option Returns: Dictionary with book metadata or None if extraction fails """ + start_time = time.time() try: # Create temporary reader to extract metadata + reader_start = time.time() reader = create_ebook_reader(page_size=(400, 600)) reader.load_epub(str(epub_path)) + reader_elapsed = time.time() - reader_start + + logger.debug(f"[METADATA] Loaded EPUB {epub_path.name} in {reader_elapsed:.2f}s") metadata = { 'filename': epub_path.name, @@ -58,12 +67,19 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option # Extract cover image if requested - use direct EPUB extraction if include_cover: + cover_start = time.time() cover_data = extract_cover_from_epub(epub_path) + cover_elapsed = time.time() - cover_start metadata['cover_data'] = cover_data + logger.debug(f"[METADATA] Extracted cover from {epub_path.name} in {cover_elapsed:.2f}s") + + total_elapsed = time.time() - start_time + logger.info(f"[METADATA] Extracted metadata from '{metadata['title']}' in {total_elapsed:.2f}s") return metadata except Exception as e: + logger.error(f"Error extracting metadata from {epub_path}: {e}") print(f"Error extracting metadata from {epub_path}: {e}") return { 'filename': epub_path.name, @@ -128,15 +144,20 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i """ try: # Read the EPUB + read_start = time.time() book = epub.read_epub(str(epub_path)) + read_elapsed = time.time() - read_start + logger.debug(f"[COVER] Read EPUB {epub_path.name} in {read_elapsed:.2f}s") # Look for cover image cover_image = None + search_start = time.time() # First, try to find item marked as cover for item in book.get_items(): if item.get_type() == ebooklib.ITEM_COVER: cover_image = Image.open(BytesIO(item.get_content())) + logger.debug(f"[COVER] Found cover marked as ITEM_COVER in {epub_path.name}") break # If not found, look for files with 'cover' in the name @@ -146,6 +167,7 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i name = item.get_name().lower() if 'cover' in name: cover_image = Image.open(BytesIO(item.get_content())) + logger.debug(f"[COVER] Found cover by filename in {epub_path.name}") break # If still not found, get the first image @@ -154,26 +176,36 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i if item.get_type() == ebooklib.ITEM_IMAGE: try: cover_image = Image.open(BytesIO(item.get_content())) + logger.debug(f"[COVER] Using first image as cover in {epub_path.name}") break except: continue + search_elapsed = time.time() - search_start + if not cover_image: + logger.debug(f"[COVER] No cover image found in {epub_path.name}") return None # Resize if needed (maintain aspect ratio) + process_start = time.time() if cover_image.width > max_width or cover_image.height > max_height: cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logger.debug(f"[COVER] Resized cover for {epub_path.name}") # Convert to base64 buffer = BytesIO() cover_image.save(buffer, format='PNG') img_bytes = buffer.getvalue() img_base64 = base64.b64encode(img_bytes).decode('utf-8') + process_elapsed = time.time() - process_start + + logger.debug(f"[COVER] Processed cover for {epub_path.name}: search={search_elapsed:.2f}s, encode={process_elapsed:.2f}s") return img_base64 except Exception as e: + logger.error(f"Error extracting cover from EPUB {epub_path}: {e}") print(f"Error extracting cover from EPUB {epub_path}: {e}") return None diff --git a/dreader/hal_hardware.py b/dreader/hal_hardware.py index 884dd74..99dbd0b 100644 --- a/dreader/hal_hardware.py +++ b/dreader/hal_hardware.py @@ -57,6 +57,16 @@ from PIL import Image from .hal import DisplayHAL from .gesture import TouchEvent as AppTouchEvent, GestureType as AppGestureType +logger = logging.getLogger(__name__) + +# Try to import GPIO button support (only available on Raspberry Pi) +try: + from .gpio_buttons import GPIOButtonHandler, load_button_config_from_dict + GPIO_BUTTONS_AVAILABLE = True +except (ImportError, RuntimeError) as e: + GPIO_BUTTONS_AVAILABLE = False + logger.debug(f"GPIO buttons not available: {e}") + # Import dreader-hal components try: from dreader_hal import ( @@ -72,8 +82,6 @@ except ImportError as e: DREADER_HAL_AVAILABLE = False _import_error = e -logger = logging.getLogger(__name__) - # Gesture type mapping between dreader-hal and dreader-application GESTURE_TYPE_MAP = { @@ -139,10 +147,16 @@ class HardwareDisplayHAL(DisplayHAL): enable_power_monitor: bool = True, shunt_ohms: float = 0.1, battery_capacity_mah: float = 3000, + gpio_config: Optional[dict] = None, + config_file: Optional[str] = None, ): """ Initialize hardware HAL. + Args: + gpio_config: GPIO button configuration dict (optional) + config_file: Path to hardware_config.json file (optional, defaults to "hardware_config.json") + Raises: ImportError: If dreader-hal library is not installed """ @@ -178,6 +192,36 @@ class HardwareDisplayHAL(DisplayHAL): battery_capacity_mah=battery_capacity_mah, ) + # GPIO button handler (optional) + self.gpio_handler: Optional[GPIOButtonHandler] = None + + # Load GPIO config from file if specified + if config_file or gpio_config is None: + config_path = Path(config_file or "hardware_config.json") + if config_path.exists(): + try: + with open(config_path, 'r') as f: + full_config = json.load(f) + gpio_config = full_config + logger.info(f"Loaded hardware config from {config_path}") + except Exception as e: + logger.warning(f"Could not load hardware config from {config_path}: {e}") + + # Initialize GPIO buttons if configured + if gpio_config and GPIO_BUTTONS_AVAILABLE: + try: + self.gpio_handler = load_button_config_from_dict( + gpio_config, + screen_width=width, + screen_height=height + ) + if self.gpio_handler: + logger.info("GPIO button handler created") + except Exception as e: + logger.warning(f"Could not initialize GPIO buttons: {e}") + elif gpio_config and not GPIO_BUTTONS_AVAILABLE: + logger.info("GPIO buttons configured but RPi.GPIO not available (not on Raspberry Pi)") + self._initialized = False async def initialize(self): @@ -190,6 +234,7 @@ class HardwareDisplayHAL(DisplayHAL): - Accelerometer (if enabled) - RTC (if enabled) - Power monitor (if enabled) + - GPIO buttons (if configured) """ if self._initialized: logger.warning("Hardware HAL already initialized") @@ -197,6 +242,12 @@ class HardwareDisplayHAL(DisplayHAL): logger.info("Initializing hardware components...") await self.hal.initialize() + + # Initialize GPIO buttons + if self.gpio_handler: + logger.info("Initializing GPIO buttons...") + await self.gpio_handler.initialize() + self._initialized = True logger.info("Hardware HAL initialized successfully") @@ -206,6 +257,12 @@ class HardwareDisplayHAL(DisplayHAL): return logger.info("Cleaning up hardware HAL") + + # Clean up GPIO buttons + if self.gpio_handler: + logger.info("Cleaning up GPIO buttons...") + await self.gpio_handler.cleanup() + await self.hal.cleanup() self._initialized = False logger.info("Hardware HAL cleaned up") @@ -232,7 +289,7 @@ class HardwareDisplayHAL(DisplayHAL): async def get_touch_event(self) -> Optional[AppTouchEvent]: """ - Get the next touch event from hardware. + Get the next touch event from hardware (touch sensor or GPIO buttons). Returns: TouchEvent if available, None if no event @@ -242,11 +299,20 @@ class HardwareDisplayHAL(DisplayHAL): - LONG_PRESS: Hold (< 30px movement, >= 500ms) - SWIPE_*: Directional swipes (>= 30px movement) - PINCH_IN/OUT: Two-finger pinch gestures + + GPIO buttons are also polled and generate TouchEvent objects. """ if not self._initialized: return None - # Get event from dreader-hal + # Check GPIO buttons first (they're more responsive) + if self.gpio_handler: + button_event = await self.gpio_handler.get_button_event() + if button_event: + logger.info(f"GPIO button event: {button_event.gesture.value}") + return button_event + + # Get event from dreader-hal touch sensor hal_event = await self.hal.get_touch_event() if hal_event is None: @@ -564,13 +630,13 @@ class HardwareDisplayHAL(DisplayHAL): async def get_event(self) -> Optional[AppTouchEvent]: """ - Get the next event from any input source (touch or accelerometer). + Get the next event from any input source (GPIO, touch, or accelerometer). - This is a convenience method that polls both touch and accelerometer - in a single call, prioritizing touch events over tilt events. + This is a convenience method that polls all input sources in a single call. + Priority order: GPIO buttons > touch sensor > accelerometer tilt Returns: - TouchEvent from either touch sensor or accelerometer, or None if no event + TouchEvent from GPIO, touch sensor, or accelerometer, or None if no event Usage: while running: @@ -579,12 +645,28 @@ class HardwareDisplayHAL(DisplayHAL): handle_gesture(event) await asyncio.sleep(0.01) """ - # Check touch first (higher priority) - touch_event = await self.get_touch_event() - if touch_event: - return touch_event + # Check GPIO buttons first (most responsive) + if self.gpio_handler: + button_event = await self.gpio_handler.get_button_event() + if button_event: + logger.info(f"GPIO button event: {button_event.gesture.value}") + return button_event - # Check accelerometer tilt + # Check touch sensor (second priority) + # Get event from dreader-hal touch sensor directly + hal_event = await self.hal.get_touch_event() + if hal_event is not None: + # Convert from dreader-hal TouchEvent to application TouchEvent + app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture) + if app_gesture is not None: + logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})") + return AppTouchEvent( + gesture=app_gesture, + x=hal_event.x, + y=hal_event.y + ) + + # Check accelerometer tilt (lowest priority) if hasattr(self, 'accel_up_vector'): tilt_event = await self.get_tilt_gesture() if tilt_event: diff --git a/dreader/library.py b/dreader/library.py index be4c951..9ea7d73 100644 --- a/dreader/library.py +++ b/dreader/library.py @@ -10,6 +10,8 @@ Handles: from __future__ import annotations import os +import time +import logging from pathlib import Path from typing import List, Dict, Optional, Tuple from PIL import Image, ImageDraw @@ -29,6 +31,8 @@ from pyWebLayout.core.query import QueryResult from .book_utils import scan_book_directory, extract_book_metadata from .state import LibraryState +logger = logging.getLogger(__name__) + class LibraryManager: """ @@ -100,19 +104,34 @@ class LibraryManager: Returns: List of book dictionaries with metadata """ + start_time = time.time() + logger.info(f"[LIBRARY] Scanning library: {self.library_path}") print(f"Scanning library: {self.library_path}") if not self.library_path.exists(): + logger.error(f"Library path does not exist: {self.library_path}") print(f"Library path does not exist: {self.library_path}") return [] # Scan directory + scan_start = time.time() self.books = scan_book_directory(self.library_path) + scan_elapsed = time.time() - scan_start + logger.info(f"[LIBRARY] Directory scan completed in {scan_elapsed:.2f}s - found {len(self.books)} books") # Cache covers to disk if not already cached - for book in self.books: + cache_start = time.time() + for i, book in enumerate(self.books, 1): + book_start = time.time() self._cache_book_cover(book) + book_elapsed = time.time() - book_start + if book_elapsed > 0.1: # Only log if caching took significant time + logger.info(f"[LIBRARY] Cached cover {i}/{len(self.books)}: {book['title']} ({book_elapsed:.2f}s)") + cache_elapsed = time.time() - cache_start + logger.info(f"[LIBRARY] Cover caching completed in {cache_elapsed:.2f}s") + total_elapsed = time.time() - start_time + logger.info(f"[LIBRARY] Library scan complete: {len(self.books)} books in {total_elapsed:.2f}s") print(f"Found {len(self.books)} books in library") return self.books @@ -310,15 +329,20 @@ class LibraryManager: Returns: PIL Image of the rendered library """ + start_time = time.time() + if table is None: if self.library_table is None: print("No table to render, creating one first...") + logger.info("[LIBRARY] Creating library table...") self.create_library_table() table = self.library_table print("Rendering library table...") + logger.info("[LIBRARY] Rendering library table...") # Create page + page_start = time.time() page_style = PageStyle( border_width=0, padding=(30, 30, 30, 30), @@ -328,6 +352,8 @@ class LibraryManager: page = Page(size=self.page_size, style=page_style) canvas = page.render() draw = ImageDraw.Draw(canvas) + page_elapsed = time.time() - page_start + logger.info(f"[LIBRARY] Page creation took {page_elapsed:.2f}s") # Table style table_style = TableStyle( @@ -344,6 +370,8 @@ class LibraryManager: table_width = page.size[0] - page_style.padding[1] - page_style.padding[3] # Render table with canvas support for images + render_start = time.time() + logger.info("[LIBRARY] Starting table render (this may load fonts)...") self.table_renderer = TableRenderer( table, table_origin, @@ -353,10 +381,15 @@ class LibraryManager: canvas # Pass canvas to enable image rendering ) self.table_renderer.render() + render_elapsed = time.time() - render_start + logger.info(f"[LIBRARY] Table rendering took {render_elapsed:.2f}s") # Store rendered page for query support self.rendered_page = page + total_elapsed = time.time() - start_time + logger.info(f"[LIBRARY] Total render time: {total_elapsed:.2f}s") + return canvas def handle_library_tap(self, x: int, y: int) -> Optional[str]: diff --git a/dreader/main.py b/dreader/main.py index 84b8e23..f92a920 100644 --- a/dreader/main.py +++ b/dreader/main.py @@ -433,8 +433,9 @@ class DReaderApplication: This method sends the current image to the HAL for display. """ if self.current_image: - logger.debug(f"Updating display: {self.current_image.size}") + logger.info(f"[DISPLAY] Updating display: {self.current_image.size} in {self.state.mode.value} mode") await self.display_hal.show_image(self.current_image) + logger.info("[DISPLAY] Display update complete") else: logger.warning("No image to display")