""" FT5xx6 Capacitive Touch Controller Wrapper. This module wraps the FT5xx6 touch panel controller for use with the DReader HAL. Implements gesture detection from raw touch events using polling mode (not interrupts). Hardware: FT5316 and similar FT5xx6 family capacitive touch controllers """ import asyncio import sys import os import time from typing import Optional from pathlib import Path # Add external PyFTtxx6 to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../external/PyFTtxx6/pyft5xx6')) from pyft5xx6.controller import FT5316, Status, Mode, Gestures as FT5xx6Gestures from ..gesture import GestureDetector, TouchState from ..types import TouchEvent, GestureType from ..calibration import TouchCalibration # Mapping from FT5xx6 hardware gestures to our GestureTypes FT5XX6_GESTURE_MAP = { FT5xx6Gestures.MOVE_UP: GestureType.SWIPE_UP, FT5xx6Gestures.MOVE_DOWN: GestureType.SWIPE_DOWN, FT5xx6Gestures.MOVE_LEFT: GestureType.SWIPE_LEFT, FT5xx6Gestures.MOVE_RIGHT: GestureType.SWIPE_RIGHT, FT5xx6Gestures.ZOOM_IN: GestureType.PINCH_OUT, FT5xx6Gestures.ZOOM_OUT: GestureType.PINCH_IN, } class FT5xx6TouchDriver: """ Wrapper for FT5xx6 capacitive touch controller. Provides async touch event handling with gesture detection for DReader HAL. Uses POLLING mode (not interrupts) as specified by user requirements. Args: i2c_bus: I2C bus number (default 1) i2c_address: I2C device address (default 0x38 for FT5316) width: Display width for coordinate validation height: Display height for coordinate validation polling_interval: Seconds between polls (default 0.01 = 10ms) """ def __init__( self, i2c_bus: int = 1, i2c_address: int = 0x38, width: int = 800, height: int = 1200, polling_interval: float = 0.01, calibration_file: Optional[str] = None, ): self.i2c_bus = i2c_bus self.i2c_address = i2c_address self.width = width self.height = height self.polling_interval = polling_interval self.controller: Optional[FT5316] = None self.gesture_detector = GestureDetector() # Touchscreen calibration self.calibration = TouchCalibration(width, height) self.calibration_file = calibration_file or str( Path.home() / '.config' / 'dreader' / 'touch_calibration.json' ) self._initialized = False self._last_num_touches = 0 self._tracking_touch = False self._last_touch_pos = (0, 0) # Track last known touch position for drag_end async def initialize(self) -> None: """ Initialize the FT5xx6 touch controller. Sets up I2C communication and configures POLLING mode. Loads calibration data if available. """ if self._initialized: return # Run initialization in thread pool loop = asyncio.get_event_loop() success = await loop.run_in_executor(None, self._init_controller) if not success: raise RuntimeError("Failed to initialize FT5xx6 touch controller") # Load calibration if available if Path(self.calibration_file).exists(): loaded = self.calibration.load(self.calibration_file) if loaded: print(f"Touch calibration loaded: {self.calibration.get_calibration_quality()} " f"(RMS error: {self.calibration.calibration_data.rms_error:.2f}px)") else: print("Warning: Failed to load touch calibration") else: print(f"No calibration file found at {self.calibration_file}") print("Touch coordinates will be uncalibrated. Run calibrate_touch.py to calibrate.") self._initialized = True def _init_controller(self) -> bool: """Blocking initialization of touch controller (runs in thread pool).""" self.controller = FT5316() # Initialize in POLLING mode (NO interrupts as per user requirement) result = self.controller.begin(i2c_bus=self.i2c_bus) if result != Status.NOMINAL: return False # Explicitly set polling mode self.controller.set_mode(Mode.POLLING) return True async def cleanup(self) -> None: """ Cleanup touch controller resources. """ if not self._initialized or not self.controller: return self._initialized = False self.controller = None async def get_touch_event(self) -> Optional[TouchEvent]: """ Get the next touch event with gesture classification. This method polls the touch controller and uses a GestureDetector to classify raw touch data into gestures (tap, swipe, long press, etc.). Returns: TouchEvent if a gesture is detected, None otherwise Implementation note: Uses POLLING mode with configurable polling interval. Implements gesture detection per HAL spec section 4.3. """ if not self._initialized or not self.controller: return None # Poll touch controller loop = asyncio.get_event_loop() await loop.run_in_executor(None, self.controller.update) # Check if new touch data available if not self.controller.new_touch: # No touch event - but check for long press timeout if self._tracking_touch and self.gesture_detector.check_long_press(): # Long press detected! event = self._create_touch_event( GestureType.LONG_PRESS, self.gesture_detector.current_pos ) # Reset detector since we consumed the long press self.gesture_detector.reset() self._tracking_touch = False return event # Small delay to avoid busy-waiting await asyncio.sleep(self.polling_interval) return None # Read touch data record = self.controller.read() # Process touch state changes gesture = self._process_touch_record(record) if gesture: # Create TouchEvent from gesture return self._create_touch_event_from_record(gesture, record) # Small delay between polls await asyncio.sleep(self.polling_interval) return None def _process_touch_record(self, record) -> Optional[GestureType]: """ Process raw touch record and update gesture detector. Args: record: TouchRecord from FT5xx6 controller Returns: GestureType if gesture detected, None otherwise """ num_touches = record.num_touches # Check for hardware-detected gestures first if record.gesture in FT5XX6_GESTURE_MAP: # Use hardware gesture detection for pinch/zoom return FT5XX6_GESTURE_MAP[record.gesture] # Handle single-touch gestures with our gesture detector if num_touches == 1: x, y = record.t1x, record.t1y self._last_touch_pos = (x, y) # Track position for drag_end # Touch down if self._last_num_touches == 0: self.gesture_detector.on_touch_down(x, y) self._tracking_touch = True # Touch move else: move_gesture = self.gesture_detector.on_touch_move(x, y) if move_gesture: return move_gesture # Handle two-finger gestures (pinch) elif num_touches == 2: # Primary finger if self._last_num_touches < 2: self.gesture_detector.on_touch_down(record.t1x, record.t1y, finger=0) self.gesture_detector.on_touch_down(record.t2x, record.t2y, finger=1) self._tracking_touch = True else: self.gesture_detector.on_touch_move(record.t1x, record.t1y, finger=0) self.gesture_detector.on_touch_move(record.t2x, record.t2y, finger=1) # Touch up elif num_touches == 0 and self._last_num_touches > 0: # Get last known position if self._last_num_touches == 1: # Single finger up - use last tracked position gesture = self.gesture_detector.on_touch_up( self._last_touch_pos[0], self._last_touch_pos[1], finger=0 ) self._tracking_touch = False self._last_num_touches = 0 return gesture elif self._last_num_touches == 2: # Two fingers - check which one lifted # Assume second finger lifted first gesture = self.gesture_detector.on_touch_up(0, 0, finger=1) self._last_num_touches = 0 self._tracking_touch = False return gesture self._last_num_touches = num_touches return None def _create_touch_event( self, gesture: GestureType, pos: Optional[tuple] ) -> TouchEvent: """ Create TouchEvent from gesture and position. Args: gesture: Detected gesture type pos: Touch position (x, y) or None Returns: TouchEvent object with calibrated coordinates """ x = pos[0] if pos else 0 y = pos[1] if pos else 0 # Apply calibration transformation x, y = self.calibration.transform(x, y) return TouchEvent( gesture=gesture, x=x, y=y, timestamp_ms=time.time() * 1000 ) def _create_touch_event_from_record( self, gesture: GestureType, record ) -> TouchEvent: """ Create TouchEvent from gesture and TouchRecord. Args: gesture: Detected gesture type record: FT5xx6 TouchRecord Returns: TouchEvent object with calibrated coordinates """ # Use primary touch position x = record.t1x y = record.t1y # Apply calibration transformation x, y = self.calibration.transform(x, y) # Check for two-finger gestures (pinch) if gesture in (GestureType.PINCH_IN, GestureType.PINCH_OUT) and record.num_touches >= 2: # Apply calibration to second finger as well x2, y2 = self.calibration.transform(record.t2x, record.t2y) return TouchEvent( gesture=gesture, x=x, y=y, x2=x2, y2=y2, timestamp_ms=record.timestamp ) return TouchEvent( gesture=gesture, x=x, y=y, timestamp_ms=record.timestamp ) async def set_polling_rate(self, rate_hz: int) -> None: """ Set touch polling rate. Args: rate_hz: Polling frequency in Hz (e.g., 100 = 100 times/second) """ if rate_hz <= 0: raise ValueError("Polling rate must be positive") self.polling_interval = 1.0 / rate_hz