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.
"""
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

View File

@ -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:

View File

@ -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]:

View File

@ -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")