594 lines
19 KiB
Python
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
|