dreader-application/dreader/hal_hardware.py
2025-11-12 18:52:08 +00:00

594 lines
19 KiB
Python

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