Added logging for GPIO
Some checks failed
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled

This commit is contained in:
Duncan Tourolle 2025-11-23 14:24:23 +01:00
parent a1775baa76
commit 25b20fdbbd
4 changed files with 163 additions and 15 deletions

View File

@ -2,6 +2,8 @@
Utilities for managing book library, scanning EPUBs, and extracting metadata. Utilities for managing book library, scanning EPUBs, and extracting metadata.
""" """
import logging
import time
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
from dreader import create_ebook_reader from dreader import create_ebook_reader
@ -11,6 +13,8 @@ from PIL import Image
import ebooklib import ebooklib
from ebooklib import epub from ebooklib import epub
logger = logging.getLogger(__name__)
def scan_book_directory(directory: Path) -> List[Dict[str, str]]: 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: Returns:
Dictionary with book metadata or None if extraction fails Dictionary with book metadata or None if extraction fails
""" """
start_time = time.time()
try: try:
# Create temporary reader to extract metadata # Create temporary reader to extract metadata
reader_start = time.time()
reader = create_ebook_reader(page_size=(400, 600)) reader = create_ebook_reader(page_size=(400, 600))
reader.load_epub(str(epub_path)) 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 = { metadata = {
'filename': epub_path.name, '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 # Extract cover image if requested - use direct EPUB extraction
if include_cover: if include_cover:
cover_start = time.time()
cover_data = extract_cover_from_epub(epub_path) cover_data = extract_cover_from_epub(epub_path)
cover_elapsed = time.time() - cover_start
metadata['cover_data'] = cover_data 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 return metadata
except Exception as e: except Exception as e:
logger.error(f"Error extracting metadata from {epub_path}: {e}")
print(f"Error extracting metadata from {epub_path}: {e}") print(f"Error extracting metadata from {epub_path}: {e}")
return { return {
'filename': epub_path.name, 'filename': epub_path.name,
@ -128,15 +144,20 @@ def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: i
""" """
try: try:
# Read the EPUB # Read the EPUB
read_start = time.time()
book = epub.read_epub(str(epub_path)) 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 # Look for cover image
cover_image = None cover_image = None
search_start = time.time()
# First, try to find item marked as cover # First, try to find item marked as cover
for item in book.get_items(): for item in book.get_items():
if item.get_type() == ebooklib.ITEM_COVER: if item.get_type() == ebooklib.ITEM_COVER:
cover_image = Image.open(BytesIO(item.get_content())) cover_image = Image.open(BytesIO(item.get_content()))
logger.debug(f"[COVER] Found cover marked as ITEM_COVER in {epub_path.name}")
break break
# If not found, look for files with 'cover' in the name # 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() name = item.get_name().lower()
if 'cover' in name: if 'cover' in name:
cover_image = Image.open(BytesIO(item.get_content())) cover_image = Image.open(BytesIO(item.get_content()))
logger.debug(f"[COVER] Found cover by filename in {epub_path.name}")
break break
# If still not found, get the first image # 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: if item.get_type() == ebooklib.ITEM_IMAGE:
try: try:
cover_image = Image.open(BytesIO(item.get_content())) cover_image = Image.open(BytesIO(item.get_content()))
logger.debug(f"[COVER] Using first image as cover in {epub_path.name}")
break break
except: except:
continue continue
search_elapsed = time.time() - search_start
if not cover_image: if not cover_image:
logger.debug(f"[COVER] No cover image found in {epub_path.name}")
return None return None
# Resize if needed (maintain aspect ratio) # Resize if needed (maintain aspect ratio)
process_start = time.time()
if cover_image.width > max_width or cover_image.height > max_height: if cover_image.width > max_width or cover_image.height > max_height:
cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
logger.debug(f"[COVER] Resized cover for {epub_path.name}")
# Convert to base64 # Convert to base64
buffer = BytesIO() buffer = BytesIO()
cover_image.save(buffer, format='PNG') cover_image.save(buffer, format='PNG')
img_bytes = buffer.getvalue() img_bytes = buffer.getvalue()
img_base64 = base64.b64encode(img_bytes).decode('utf-8') 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 return img_base64
except Exception as e: 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}") print(f"Error extracting cover from EPUB {epub_path}: {e}")
return None return None

View File

@ -57,6 +57,16 @@ from PIL import Image
from .hal import DisplayHAL from .hal import DisplayHAL
from .gesture import TouchEvent as AppTouchEvent, GestureType as AppGestureType 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 # Import dreader-hal components
try: try:
from dreader_hal import ( from dreader_hal import (
@ -72,8 +82,6 @@ except ImportError as e:
DREADER_HAL_AVAILABLE = False DREADER_HAL_AVAILABLE = False
_import_error = e _import_error = e
logger = logging.getLogger(__name__)
# Gesture type mapping between dreader-hal and dreader-application # Gesture type mapping between dreader-hal and dreader-application
GESTURE_TYPE_MAP = { GESTURE_TYPE_MAP = {
@ -139,10 +147,16 @@ class HardwareDisplayHAL(DisplayHAL):
enable_power_monitor: bool = True, enable_power_monitor: bool = True,
shunt_ohms: float = 0.1, shunt_ohms: float = 0.1,
battery_capacity_mah: float = 3000, battery_capacity_mah: float = 3000,
gpio_config: Optional[dict] = None,
config_file: Optional[str] = None,
): ):
""" """
Initialize hardware HAL. 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: Raises:
ImportError: If dreader-hal library is not installed ImportError: If dreader-hal library is not installed
""" """
@ -178,6 +192,36 @@ class HardwareDisplayHAL(DisplayHAL):
battery_capacity_mah=battery_capacity_mah, 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 self._initialized = False
async def initialize(self): async def initialize(self):
@ -190,6 +234,7 @@ class HardwareDisplayHAL(DisplayHAL):
- Accelerometer (if enabled) - Accelerometer (if enabled)
- RTC (if enabled) - RTC (if enabled)
- Power monitor (if enabled) - Power monitor (if enabled)
- GPIO buttons (if configured)
""" """
if self._initialized: if self._initialized:
logger.warning("Hardware HAL already initialized") logger.warning("Hardware HAL already initialized")
@ -197,6 +242,12 @@ class HardwareDisplayHAL(DisplayHAL):
logger.info("Initializing hardware components...") logger.info("Initializing hardware components...")
await self.hal.initialize() await self.hal.initialize()
# Initialize GPIO buttons
if self.gpio_handler:
logger.info("Initializing GPIO buttons...")
await self.gpio_handler.initialize()
self._initialized = True self._initialized = True
logger.info("Hardware HAL initialized successfully") logger.info("Hardware HAL initialized successfully")
@ -206,6 +257,12 @@ class HardwareDisplayHAL(DisplayHAL):
return return
logger.info("Cleaning up hardware HAL") 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() await self.hal.cleanup()
self._initialized = False self._initialized = False
logger.info("Hardware HAL cleaned up") logger.info("Hardware HAL cleaned up")
@ -232,7 +289,7 @@ class HardwareDisplayHAL(DisplayHAL):
async def get_touch_event(self) -> Optional[AppTouchEvent]: 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: Returns:
TouchEvent if available, None if no event TouchEvent if available, None if no event
@ -242,11 +299,20 @@ class HardwareDisplayHAL(DisplayHAL):
- LONG_PRESS: Hold (< 30px movement, >= 500ms) - LONG_PRESS: Hold (< 30px movement, >= 500ms)
- SWIPE_*: Directional swipes (>= 30px movement) - SWIPE_*: Directional swipes (>= 30px movement)
- PINCH_IN/OUT: Two-finger pinch gestures - PINCH_IN/OUT: Two-finger pinch gestures
GPIO buttons are also polled and generate TouchEvent objects.
""" """
if not self._initialized: if not self._initialized:
return None 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() hal_event = await self.hal.get_touch_event()
if hal_event is None: if hal_event is None:
@ -564,13 +630,13 @@ class HardwareDisplayHAL(DisplayHAL):
async def get_event(self) -> Optional[AppTouchEvent]: 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 This is a convenience method that polls all input sources in a single call.
in a single call, prioritizing touch events over tilt events. Priority order: GPIO buttons > touch sensor > accelerometer tilt
Returns: 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: Usage:
while running: while running:
@ -579,12 +645,28 @@ class HardwareDisplayHAL(DisplayHAL):
handle_gesture(event) handle_gesture(event)
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
""" """
# Check touch first (higher priority) # Check GPIO buttons first (most responsive)
touch_event = await self.get_touch_event() if self.gpio_handler:
if touch_event: button_event = await self.gpio_handler.get_button_event()
return touch_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'): if hasattr(self, 'accel_up_vector'):
tilt_event = await self.get_tilt_gesture() tilt_event = await self.get_tilt_gesture()
if tilt_event: if tilt_event:

View File

@ -10,6 +10,8 @@ Handles:
from __future__ import annotations from __future__ import annotations
import os import os
import time
import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from PIL import Image, ImageDraw 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 .book_utils import scan_book_directory, extract_book_metadata
from .state import LibraryState from .state import LibraryState
logger = logging.getLogger(__name__)
class LibraryManager: class LibraryManager:
""" """
@ -100,19 +104,34 @@ class LibraryManager:
Returns: Returns:
List of book dictionaries with metadata 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}") print(f"Scanning library: {self.library_path}")
if not self.library_path.exists(): 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}") print(f"Library path does not exist: {self.library_path}")
return [] return []
# Scan directory # Scan directory
scan_start = time.time()
self.books = scan_book_directory(self.library_path) 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 # 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) 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") print(f"Found {len(self.books)} books in library")
return self.books return self.books
@ -310,15 +329,20 @@ class LibraryManager:
Returns: Returns:
PIL Image of the rendered library PIL Image of the rendered library
""" """
start_time = time.time()
if table is None: if table is None:
if self.library_table is None: if self.library_table is None:
print("No table to render, creating one first...") print("No table to render, creating one first...")
logger.info("[LIBRARY] Creating library table...")
self.create_library_table() self.create_library_table()
table = self.library_table table = self.library_table
print("Rendering library table...") print("Rendering library table...")
logger.info("[LIBRARY] Rendering library table...")
# Create page # Create page
page_start = time.time()
page_style = PageStyle( page_style = PageStyle(
border_width=0, border_width=0,
padding=(30, 30, 30, 30), padding=(30, 30, 30, 30),
@ -328,6 +352,8 @@ class LibraryManager:
page = Page(size=self.page_size, style=page_style) page = Page(size=self.page_size, style=page_style)
canvas = page.render() canvas = page.render()
draw = ImageDraw.Draw(canvas) 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
table_style = TableStyle( table_style = TableStyle(
@ -344,6 +370,8 @@ class LibraryManager:
table_width = page.size[0] - page_style.padding[1] - page_style.padding[3] table_width = page.size[0] - page_style.padding[1] - page_style.padding[3]
# Render table with canvas support for images # 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( self.table_renderer = TableRenderer(
table, table,
table_origin, table_origin,
@ -353,10 +381,15 @@ class LibraryManager:
canvas # Pass canvas to enable image rendering canvas # Pass canvas to enable image rendering
) )
self.table_renderer.render() 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 # Store rendered page for query support
self.rendered_page = page self.rendered_page = page
total_elapsed = time.time() - start_time
logger.info(f"[LIBRARY] Total render time: {total_elapsed:.2f}s")
return canvas return canvas
def handle_library_tap(self, x: int, y: int) -> Optional[str]: def handle_library_tap(self, x: int, y: int) -> Optional[str]:

View File

@ -433,8 +433,9 @@ class DReaderApplication:
This method sends the current image to the HAL for display. This method sends the current image to the HAL for display.
""" """
if self.current_image: 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) await self.display_hal.show_image(self.current_image)
logger.info("[DISPLAY] Display update complete")
else: else:
logger.warning("No image to display") logger.warning("No image to display")