""" Hardware HAL implementation using dreader-hal library. This module provides the HardwareDisplayHAL class that bridges the DReader application HAL interface with the dreader-hal hardware abstraction layer. The dreader-hal library provides complete e-ink display integration with: - IT8951 e-ink display driver - FT5xx6 capacitive touch sensor - BMA400 accelerometer (orientation) - PCF8523 RTC (timekeeping) - INA219 power monitor (battery) Usage: from dreader.hal_hardware import HardwareDisplayHAL from dreader.main import DReaderApplication, AppConfig # For real hardware hal = HardwareDisplayHAL(width=800, height=1200, vcom=-2.0) # For testing without hardware hal = HardwareDisplayHAL( width=800, height=1200, virtual_display=True, enable_orientation=False, enable_rtc=False, enable_power_monitor=False ) config = AppConfig(display_hal=hal, library_path="~/Books") app = DReaderApplication(config) await hal.initialize() await app.start() # Main loop while app.is_running(): event = await hal.get_touch_event() if event: await app.handle_touch(event) await asyncio.sleep(0.01) await app.shutdown() await hal.cleanup() """ import asyncio import logging import json import math import time from typing import Optional from pathlib import Path 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 ( EReaderDisplayHAL, TouchEvent as HalTouchEvent, GestureType as HalGestureType, RefreshMode, PowerStats, Orientation ) DREADER_HAL_AVAILABLE = True except ImportError as e: DREADER_HAL_AVAILABLE = False _import_error = e # Gesture type mapping between dreader-hal and dreader-application GESTURE_TYPE_MAP = { HalGestureType.TAP: AppGestureType.TAP, HalGestureType.LONG_PRESS: AppGestureType.LONG_PRESS, HalGestureType.SWIPE_LEFT: AppGestureType.SWIPE_LEFT, HalGestureType.SWIPE_RIGHT: AppGestureType.SWIPE_RIGHT, HalGestureType.SWIPE_UP: AppGestureType.SWIPE_UP, HalGestureType.SWIPE_DOWN: AppGestureType.SWIPE_DOWN, HalGestureType.PINCH_IN: AppGestureType.PINCH_IN, HalGestureType.PINCH_OUT: AppGestureType.PINCH_OUT, HalGestureType.DRAG_START: AppGestureType.DRAG_START, HalGestureType.DRAG_MOVE: AppGestureType.DRAG_MOVE, HalGestureType.DRAG_END: AppGestureType.DRAG_END, } class HardwareDisplayHAL(DisplayHAL): """ Hardware HAL implementation using dreader-hal library. This class adapts the dreader-hal EReaderDisplayHAL to work with the DReader application's DisplayHAL interface. Args: width: Display width in pixels (default 1872) height: Display height in pixels (default 1404) vcom: E-ink VCOM voltage (default -2.0, check device label!) spi_hz: SPI clock frequency (default 24MHz) virtual_display: Use virtual display for testing (default False) auto_sleep_display: Auto-sleep display after updates (default True) enable_orientation: Enable orientation sensing (default True) enable_rtc: Enable RTC timekeeping (default True) enable_power_monitor: Enable battery monitoring (default True) shunt_ohms: Power monitor shunt resistor (default 0.1) battery_capacity_mah: Battery capacity in mAh (default 3000) Example: # For real hardware (Raspberry Pi with e-ink display) hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0) # For testing on development machine hal = HardwareDisplayHAL( width=1872, height=1404, virtual_display=True, enable_orientation=False, enable_rtc=False, enable_power_monitor=False ) """ def __init__( self, width: int = 1872, height: int = 1404, vcom: float = -2.0, spi_hz: int = 24_000_000, virtual_display: bool = False, auto_sleep_display: bool = True, enable_orientation: bool = True, enable_rtc: bool = True, 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 """ if not DREADER_HAL_AVAILABLE: raise ImportError( f"dreader-hal library is required for HardwareDisplayHAL.\n" f"Install with: pip install -e external/dreader-hal\n" f"Original error: {_import_error}" ) self.width = width self.height = height logger.info(f"Initializing HardwareDisplayHAL: {width}x{height}") logger.info(f" VCOM: {vcom}V") logger.info(f" Virtual display: {virtual_display}") logger.info(f" Orientation: {enable_orientation}") logger.info(f" RTC: {enable_rtc}") logger.info(f" Power monitor: {enable_power_monitor}") # Create the underlying dreader-hal implementation self.hal = EReaderDisplayHAL( width=width, height=height, vcom=vcom, spi_hz=spi_hz, virtual_display=virtual_display, auto_sleep_display=auto_sleep_display, enable_orientation=enable_orientation, enable_rtc=enable_rtc, enable_power_monitor=enable_power_monitor, shunt_ohms=shunt_ohms, 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): """ Initialize all hardware components. This initializes: - E-ink display controller - Touch sensor - Accelerometer (if enabled) - RTC (if enabled) - Power monitor (if enabled) - GPIO buttons (if configured) """ if self._initialized: logger.warning("Hardware HAL already initialized") return 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") async def cleanup(self): """Clean up all hardware resources.""" if not self._initialized: 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") async def show_image(self, image: Image.Image): """ Display a PIL Image on the e-ink screen. Args: image: PIL Image to display The dreader-hal library handles: - Format conversion (RGB -> grayscale) - Dithering for e-ink - Refresh mode selection (auto, fast, quality, full) - Orientation rotation (if enabled) """ if not self._initialized: logger.warning("Hardware HAL not initialized, initializing now...") await self.initialize() logger.debug(f"Displaying image: {image.size} {image.mode}") await self.hal.show_image(image) async def get_touch_event(self) -> Optional[AppTouchEvent]: """ Get the next touch event from hardware (touch sensor or GPIO buttons). Returns: TouchEvent if available, None if no event The dreader-hal library handles gesture classification: - TAP: Quick tap (< 30px movement, < 300ms) - 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 # 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: return None # Convert from dreader-hal TouchEvent to application TouchEvent app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture) if app_gesture is None: logger.warning(f"Unknown gesture type from HAL: {hal_event.gesture}") return 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 ) async def set_brightness(self, level: int): """ Set display brightness. Args: level: Brightness level (0-10) Note: Basic IT8951 e-ink displays don't have brightness control. This is a no-op unless frontlight hardware is connected. """ if not 0 <= level <= 10: raise ValueError("Brightness must be 0-10") logger.debug(f"Setting brightness to {level}") await self.hal.set_brightness(level) # ========== Extended Methods (Hardware-Specific Features) ========== async def get_battery_level(self) -> float: """ Get battery percentage. Returns: Battery level 0-100%, or 0.0 if power monitor unavailable """ if not self._initialized: return 0.0 return await self.hal.get_battery_level() async def get_power_stats(self) -> PowerStats: """ Get detailed power statistics. Returns: PowerStats with voltage, current, power, battery %, etc. Raises: RuntimeError: If power monitor not enabled """ if not self._initialized: raise RuntimeError("Hardware HAL not initialized") return await self.hal.get_power_stats() async def is_low_battery(self, threshold: float = 20.0) -> bool: """ Check if battery is low. Args: threshold: Battery percentage threshold (default 20%) Returns: True if battery below threshold, False otherwise """ if not self._initialized: return False return await self.hal.is_low_battery(threshold) async def set_low_power_mode(self, enabled: bool): """ Enable/disable low power mode. In low power mode: - Display goes to sleep - Touch polling rate reduced - Sensors put to low power Args: enabled: True to enable low power mode """ if not self._initialized: return logger.info(f"Setting low power mode: {enabled}") await self.hal.set_low_power_mode(enabled) async def enable_orientation_monitoring(self): """ Start monitoring device orientation changes. When orientation changes, display auto-rotates. """ if not self._initialized: return logger.info("Enabling orientation monitoring") await self.hal.enable_orientation_monitoring() async def disable_orientation_monitoring(self): """Stop monitoring orientation changes.""" if not self._initialized: return logger.info("Disabling orientation monitoring") await self.hal.disable_orientation_monitoring() @property def current_orientation(self) -> Optional[Orientation]: """Get current device orientation.""" if not self._initialized: return None return self.hal.current_orientation @property def refresh_count(self) -> int: """Get number of display refreshes since initialization.""" if not self._initialized: return 0 return self.hal.refresh_count async def get_datetime(self): """ Get current date/time from RTC. Returns: struct_time with current date and time, or None if RTC unavailable """ if not self._initialized: return None return await self.hal.get_datetime() async def set_datetime(self, dt): """ Set the RTC date/time. Args: dt: time.struct_time object with date and time to set Raises: RuntimeError: If RTC not enabled """ if not self._initialized: raise RuntimeError("Hardware HAL not initialized") await self.hal.set_datetime(dt) # ========== Accelerometer Tilt Detection ========== def load_accelerometer_calibration(self, config_path: str = "accelerometer_config.json") -> bool: """ Load accelerometer calibration from file. Args: config_path: Path to calibration JSON file Returns: True if calibration loaded successfully, False otherwise """ config_file = Path(config_path) if not config_file.exists(): logger.warning(f"Accelerometer calibration file not found: {config_path}") return False try: with open(config_file, 'r') as f: config = json.load(f) # Load up vector up = config.get("up_vector", {}) self.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0)) # Load thresholds self.accel_tilt_threshold = config.get("tilt_threshold", 0.3) self.accel_debounce_time = config.get("debounce_time", 0.5) # State tracking self.accel_last_tilt_time = 0 logger.info(f"Accelerometer calibration loaded: up_vector={self.accel_up_vector}") logger.info(f" Tilt threshold: {self.accel_tilt_threshold:.2f} rad (~{math.degrees(self.accel_tilt_threshold):.1f}°)") logger.info(f" Debounce time: {self.accel_debounce_time:.2f}s") return True except Exception as e: logger.error(f"Error loading accelerometer calibration: {e}") return False async def get_tilt_gesture(self) -> Optional[AppTouchEvent]: """ Check accelerometer for tilt gestures. Returns: TouchEvent with TILT_FORWARD or TILT_BACKWARD gesture if detected, None otherwise Note: Requires accelerometer calibration to be loaded first via load_accelerometer_calibration() """ if not self._initialized: return None if not self.hal.orientation: return None if not hasattr(self, 'accel_up_vector'): return None # Get current acceleration try: ax, ay, az = await self.hal.orientation.get_acceleration() except Exception as e: logger.debug(f"Error reading accelerometer: {e}") return None # Check debounce current_time = time.time() if current_time - self.accel_last_tilt_time < self.accel_debounce_time: return None # Calculate angle between current gravity and calibrated "up" vector # Gravity vector is the acceleration (pointing down) gx, gy, gz = ax, ay, az # Normalize gravity g_mag = math.sqrt(gx**2 + gy**2 + gz**2) if g_mag < 0.1: return None gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag # Normalize up vector ux, uy, uz = self.accel_up_vector u_mag = math.sqrt(ux**2 + uy**2 + uz**2) if u_mag < 0.1: return None ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag # Calculate tilt: project gravity onto the "forward/backward" axis # Forward/backward axis is perpendicular to up vector # We'll use the component of gravity that's perpendicular to the up vector # Dot product: component of gravity along up vector dot_up = gx * ux + gy * uy + gz * uz # Component of gravity perpendicular to up vector perp_x = gx - dot_up * ux perp_y = gy - dot_up * uy perp_z = gz - dot_up * uz perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2) # Angle from vertical (in radians) tilt_angle = math.atan2(perp_mag, abs(dot_up)) logger.debug(f"Tilt angle: {math.degrees(tilt_angle):.1f}° (threshold: {math.degrees(self.accel_tilt_threshold):.1f}°)") # Check if tilted beyond threshold if tilt_angle < self.accel_tilt_threshold: return None # Determine direction: forward or backward # We need to determine which direction the device is tilted # Use the sign of the perpendicular component along a reference axis # For simplicity, we'll use the projection onto the original up vector's # perpendicular plane. If we tilt "forward", the gravity vector should # rotate in a specific direction. # Calculate which direction: check if tilting away from or toward the up vector # If dot_up is decreasing (device tilting away from up), that's "forward" # If dot_up is increasing (device tilting back toward up), that's "backward" # Actually, a simpler approach: check the direction of the perpendicular component # relative to a reference direction in the plane # Let's define forward as tilting in the direction that increases the # y-component of acceleration (assuming standard orientation) # This is device-specific and may need adjustment # For now, use a simple heuristic: forward = positive perpendicular y component if perp_y > 0: gesture = AppGestureType.TILT_FORWARD else: gesture = AppGestureType.TILT_BACKWARD # Update debounce timer self.accel_last_tilt_time = current_time logger.info(f"Tilt gesture detected: {gesture.value} (angle: {math.degrees(tilt_angle):.1f}°)") # Return gesture at center of screen (x, y don't matter for tilt) return AppTouchEvent( gesture=gesture, x=self.width // 2, y=self.height // 2, timestamp_ms=current_time * 1000 ) async def get_event(self) -> Optional[AppTouchEvent]: """ Get the next event from any input source (GPIO, touch, or accelerometer). 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 GPIO, touch sensor, or accelerometer, or None if no event Usage: while running: event = await hal.get_event() if event: handle_gesture(event) await asyncio.sleep(0.01) """ # 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 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: return tilt_event return None