341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""
|
|
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
|