""" 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 # 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 logger = logging.getLogger(__name__) # 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, ): """ Initialize hardware HAL. 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, ) 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) """ if self._initialized: logger.warning("Hardware HAL already initialized") return logger.info("Initializing hardware components...") await self.hal.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") 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. 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 """ if not self._initialized: return None # Get event from dreader-hal 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 (touch or accelerometer). This is a convenience method that polls both touch and accelerometer in a single call, prioritizing touch events over tilt events. Returns: TouchEvent from either 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 touch first (higher priority) touch_event = await self.get_touch_event() if touch_event: return touch_event # Check accelerometer tilt if hasattr(self, 'accel_up_vector'): tilt_event = await self.get_tilt_gesture() if tilt_event: return tilt_event return None