secubox-deb/remote-ui/round/agent/touch_handler.py
CyberMind-FR dd384da66a feat(eye-remote): Add display state machine with splash and fallback modes
Display system for Pi Zero Eye Remote (HyperPixel 2.1 Round 480x480):

Splash Screen (splash.py):
- Animated phoenix logo for boot/halt/start/reboot states
- Pulsing glow effects with fire colors
- Progress indicator ring with rotating dots
- Fallback phoenix symbol if logo image missing

Fallback Display Manager (fallback_manager.py):
- Connection state detection (OTG 10.55.0.1, WiFi secubox.local)
- Four modes: OFFLINE, CONNECTING, ONLINE, COMMUNICATING
- Local metrics radar with 6 concentric rings
- 3D rotating cube with module icons when connected
- Rainbow sweep line animation

Touch Analysis Tools:
- touch_analyzer.py: Noise pattern analysis (Y-axis oscillation at stable X)
- touch_calibrate.py: Corner target display for manual calibration
- touch_filter.py: X-stable noise filtering

Radar Variants:
- radar_flashy.py: Vibrant colors with 3D cube
- radar_concentric.py: Balanced metric arcs centered at 12 o'clock
- radar_rainbow.py: Rainbow colorization with sweep
- radar_full.py: Complete feature set

Also includes:
- Hardware Smart-Strip module specs (SBX-STR-01)
- Host configuration for USB OTG network
- Systemd service for USB auto-mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-28 14:48:00 +02:00

967 lines
34 KiB
Python

"""
SecuBox Eye Remote - Touch Gesture Handler (HyperPixel2r Edition)
Gestionnaire de gestes tactiles utilisant la bibliotheque Pimoroni hyperpixel2r.
Architecture:
- Lecture des evenements tactiles via I2C direct (bus 11, addr 0x15)
- Detection des gestes: swipe, tap, long press
- Integration avec device_manager et menu_navigator pour les actions
Prerequis:
- pip install hyperpixel2r
- dtoverlay=hyperpixel2r:disable-touch dans /boot/config.txt
CyberMind - https://cybermind.fr
Author: Gerald Kerma <gandalf@gk2.net>
License: Proprietary / ANSSI CSPN candidate
"""
from __future__ import annotations
import asyncio
import logging
import math
import time
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any, Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from device_manager import DeviceManager
from command_handler import WebSocketClient
log = logging.getLogger(__name__)
# =============================================================================
# Touch Enable/Disable Flag
# =============================================================================
TOUCH_ENABLED = True # Now enabled with direct I2C polling
# =============================================================================
# HyperPixel 2r Configuration (I2C direct, no GPIO interrupt)
# =============================================================================
HYPERPIXEL_I2C_BUS = 11
HYPERPIXEL_I2C_ADDR = 0x15
HYPERPIXEL_INT_PIN = 27 # Not used - polling mode preferred
# =============================================================================
# Y-Stability Touch Filter (noise has erratic Y, real touch is stable)
# =============================================================================
Y_STABILITY_WINDOW = 0.15 # 150ms window for stability check
Y_MAX_RANGE = 50 # Maximum Y variation for stable touch
X_MAX_RANGE = 60 # Maximum X variation
MIN_STABLE_SAMPLES = 3 # Minimum samples for confirmation
TOUCH_DEBOUNCE = 0.08 # 80ms between touch confirmations
POLL_INTERVAL = 0.025 # 25ms polling interval (40Hz)
# =============================================================================
# Configuration de l'ecran HyperPixel 2.1 Round
# =============================================================================
# Dimensions de l'ecran circulaire (pixels)
DISPLAY_WIDTH = 480
DISPLAY_HEIGHT = 480
CENTER_X = DISPLAY_WIDTH // 2 # 240
CENTER_Y = DISPLAY_HEIGHT // 2 # 240
# Seuils de detection des gestes (pixels et millisecondes)
SWIPE_THRESHOLD = 50 # Distance minimale pour un swipe
LONG_PRESS_MS = 500 # Duree minimale pour un long press
TAP_MAX_MS = 300 # Duree maximale pour un tap simple
SWIPE_TOP_ZONE = 60 # Zone superieure pour swipe down (overlay)
SWIPE_BOTTOM_ZONE = 420 # Zone inferieure pour swipe up (menu)
# Zone centrale (pour long press / menu toggle)
CENTER_RADIUS = 80 # Zone centrale de 80px de rayon
# Rayons des anneaux de modules
MODULE_RINGS = {
"AUTH": {"radius": 214, "color": "#C04E24", "inner": 207, "outer": 221},
"WALL": {"radius": 201, "color": "#9A6010", "inner": 194, "outer": 207},
"BOOT": {"radius": 188, "color": "#803018", "inner": 181, "outer": 194},
"MIND": {"radius": 175, "color": "#3D35A0", "inner": 168, "outer": 181},
"ROOT": {"radius": 162, "color": "#0A5840", "inner": 155, "outer": 168},
"MESH": {"radius": 149, "color": "#104A88", "inner": 142, "outer": 155},
}
# =============================================================================
# Slice Detection for Radial Menu
# =============================================================================
CENTER_ZONE_RADIUS = 60 # Center area (not a slice)
OUTER_ZONE_RADIUS = 220 # Outside visible circle
def get_slice_from_touch(x: int, y: int) -> Optional[int]:
"""
Convertir des coordonnees tactiles en index de tranche (0-5).
La tranche 0 est en haut (12h), puis les tranches tournent
dans le sens horaire: 1=2h, 2=4h, 3=6h, 4=8h, 5=10h.
Args:
x: Coordonnee X du touch
y: Coordonnee Y du touch
Returns:
Index de tranche (0-5) ou None si hors zone
"""
dx = x - CENTER_X
dy = y - CENTER_Y
distance = math.sqrt(dx * dx + dy * dy)
if distance < CENTER_ZONE_RADIUS:
return None
if distance > OUTER_ZONE_RADIUS:
return None
angle = math.degrees(math.atan2(dx, -dy))
if angle < 0:
angle += 360
slice_index = int((angle + 30) % 360 // 60)
return slice_index
# =============================================================================
# Types de gestes detectes
# =============================================================================
class Gesture(Enum):
"""Types de gestes tactiles reconnus."""
NONE = auto()
TAP = auto()
LONG_PRESS = auto()
SWIPE_LEFT = auto()
SWIPE_RIGHT = auto()
SWIPE_UP = auto()
SWIPE_DOWN = auto()
# =============================================================================
# Etat du geste en cours
# =============================================================================
@dataclass
class TouchState:
"""Etat d'un touch en cours."""
touch_id: int = 0
start_x: int = 0
start_y: int = 0
current_x: int = 0
current_y: int = 0
start_time: float = 0.0
active: bool = False
# =============================================================================
# Gestionnaire de gestes tactiles (Pimoroni Edition)
# =============================================================================
@dataclass
class TouchHandler:
"""
Gestionnaire de gestes tactiles pour HyperPixel 2.1 Round.
Utilise la bibliotheque Pimoroni hyperpixel2r pour l'acces I2C direct.
Attributes:
device_manager: Gestionnaire des connexions SecuBox
ws_client: Client WebSocket pour les commandes
"""
device_manager: Optional[Any] = None
ws_client: Optional[Any] = None
# Touch device (Pimoroni)
_touch: Optional[Any] = field(default=None, repr=False)
# Etat du touch
_state: TouchState = field(default_factory=TouchState, repr=False)
# Etat de l'UI
_info_overlay_visible: bool = field(default=False, repr=False)
_device_list_visible: bool = field(default=False, repr=False)
# Controle
_running: bool = field(default=False, repr=False)
_event_loop: Optional[asyncio.AbstractEventLoop] = field(default=None, repr=False)
# Callbacks externes
_on_overlay_toggle: Optional[Callable[[bool], None]] = field(default=None, repr=False)
_on_device_list_toggle: Optional[Callable[[bool], None]] = field(default=None, repr=False)
_on_secubox_switch: Optional[Callable[[str], None]] = field(default=None, repr=False)
_on_gesture: Optional[Callable[[Gesture, dict], None]] = field(default=None, repr=False)
# Menu integration
_menu_navigator: Optional[Any] = field(default=None, repr=False)
_action_executor: Optional[Any] = field(default=None, repr=False)
_on_menu_render: Optional[Callable[[Any], None]] = field(default=None, repr=False)
# Long press detection
_long_press_task: Optional[asyncio.Task] = field(default=None, repr=False)
_long_press_triggered: bool = field(default=False, repr=False)
# ==========================================================================
# Initialisation
# ==========================================================================
async def start(self) -> bool:
"""
Demarrer le gestionnaire de gestes.
Utilise I2C polling direct avec filtre Y-stability (plus fiable
que les interrupts GPIO qui captent du bruit).
"""
if not TOUCH_ENABLED:
log.warning("Touch disabled via TOUCH_ENABLED flag")
return False
try:
import smbus2
self._i2c_bus = smbus2.SMBus(HYPERPIXEL_I2C_BUS)
except ImportError:
log.error("Module smbus2 non installe: pip install smbus2")
return await self._start_evdev_fallback()
except Exception as e:
log.error("Erreur ouverture I2C bus %d: %s", HYPERPIXEL_I2C_BUS, e)
return await self._start_evdev_fallback()
# Initialize Y-stability filter state
self._touch_samples = [] # [(x, y, timestamp), ...]
self._last_confirm_time = 0
self._last_confirmed_pos = None
self._touch_active = False
self._no_touch_count = 0
self._running = True
self._event_loop = asyncio.get_event_loop()
# Start polling task
asyncio.create_task(self._i2c_polling_loop())
log.info("TouchHandler (I2C polling + Y-stability) demarre - bus=%d addr=0x%02X",
HYPERPIXEL_I2C_BUS, HYPERPIXEL_I2C_ADDR)
return True
async def _i2c_polling_loop(self):
"""
Boucle de polling I2C avec filtre Y-stability.
Le bruit du touchpad HyperPixel2r se caracterise par des sauts
erratiques sur l'axe Y. Les vrais touches sont stables en Y.
"""
import struct
while self._running:
try:
# Read touch count from register 0x02
count = self._i2c_bus.read_byte_data(HYPERPIXEL_I2C_ADDR, 0x02)
if count > 0:
self._no_touch_count = 0
# Read touch data (6 bytes) from register 0x03
data = self._i2c_bus.read_i2c_block_data(HYPERPIXEL_I2C_ADDR, 0x03, 6)
# Parse touch event and coordinates
touch_event = data[0] & 0xf0
data[0] &= 0x0f
data[2] &= 0x0f
tx, ty, p1, p2 = struct.unpack(">HHBB", bytes(data))
now = time.time()
# Check for release event (0x40)
if touch_event & 0x40:
if self._touch_active:
log.debug("Touch RELEASE at (%d, %d)", tx, ty)
self._on_touch_event(0, tx, ty, False)
self._touch_active = False
self._touch_samples.clear()
self._last_confirmed_pos = None
continue
# Add sample for stability checking
self._touch_samples.append((tx, ty, now))
# Remove old samples outside window
self._touch_samples = [
s for s in self._touch_samples
if now - s[2] < Y_STABILITY_WINDOW
]
# Check stability if enough samples
if len(self._touch_samples) >= MIN_STABLE_SAMPLES:
y_values = [s[1] for s in self._touch_samples]
x_values = [s[0] for s in self._touch_samples]
y_range = max(y_values) - min(y_values)
x_range = max(x_values) - min(x_values)
# Stable if Y and X don't jump around
if y_range < Y_MAX_RANGE and x_range < X_MAX_RANGE:
avg_x = sum(x_values) // len(x_values)
avg_y = sum(y_values) // len(y_values)
# Debounce check
if now - self._last_confirm_time >= TOUCH_DEBOUNCE:
# Check if position changed significantly
if (self._last_confirmed_pos is None or
abs(avg_x - self._last_confirmed_pos[0]) > 25 or
abs(avg_y - self._last_confirmed_pos[1]) > 25):
log.debug("Touch CONFIRMED at (%d, %d) y_range=%d",
avg_x, avg_y, y_range)
if not self._touch_active:
# New touch - trigger start
self._on_touch_event(0, avg_x, avg_y, True)
self._last_confirmed_pos = (avg_x, avg_y)
self._last_confirm_time = now
self._touch_active = True
else:
# No touch detected
self._no_touch_count += 1
if self._no_touch_count > 5 and self._touch_active:
log.debug("Touch RELEASE (timeout)")
if self._last_confirmed_pos:
self._on_touch_event(0, self._last_confirmed_pos[0],
self._last_confirmed_pos[1], False)
self._touch_active = False
self._touch_samples.clear()
self._last_confirmed_pos = None
except Exception as e:
if "Remote I/O error" not in str(e):
log.warning("I2C read error: %s", e)
await asyncio.sleep(POLL_INTERVAL)
async def _start_evdev_fallback(self) -> bool:
"""Fallback vers evdev si Pimoroni echoue."""
try:
import evdev
except ImportError:
log.error("Ni hyperpixel2r ni evdev disponible - touch desactive")
return False
# Find touch device
from pathlib import Path
input_dir = Path("/dev/input")
for event_path in sorted(input_dir.glob("event*")):
try:
device = evdev.InputDevice(str(event_path))
caps = device.capabilities()
if evdev.ecodes.EV_ABS in caps:
abs_caps = dict(caps[evdev.ecodes.EV_ABS])
if evdev.ecodes.ABS_MT_SLOT in abs_caps:
name = device.name.lower()
if any(x in name for x in ['goodix', 'ft5', 'hyperpixel', 'touch']):
self._touch = device
self._running = True
self._event_loop = asyncio.get_event_loop()
# Start evdev event loop
asyncio.create_task(self._evdev_event_loop())
log.info("TouchHandler (evdev fallback) demarre: %s", device.name)
return True
device.close()
except (OSError, PermissionError):
continue
log.warning("Aucun peripherique tactile trouve")
return False
async def _evdev_event_loop(self):
"""Boucle evdev de fallback."""
import evdev
current_slot = 0
tracking_id = -1
try:
async for event in self._touch.async_read_loop():
if not self._running:
break
if event.type == evdev.ecodes.EV_ABS:
if event.code == evdev.ecodes.ABS_MT_SLOT:
current_slot = event.value
elif event.code == evdev.ecodes.ABS_MT_TRACKING_ID:
if event.value >= 0:
# Touch start
tracking_id = event.value
self._state.touch_id = current_slot
self._state.start_time = time.time()
self._state.active = True
else:
# Touch end
if self._state.active:
self._on_touch_event(
self._state.touch_id,
self._state.current_x,
self._state.current_y,
False
)
self._state.active = False
elif event.code == evdev.ecodes.ABS_MT_POSITION_X:
if self._state.start_x == 0:
self._state.start_x = event.value
self._state.current_x = event.value
if self._state.active and self._state.start_time > 0:
# First position - trigger touch start
self._on_touch_event(
self._state.touch_id,
event.value,
self._state.current_y,
True
)
elif event.code == evdev.ecodes.ABS_MT_POSITION_Y:
if self._state.start_y == 0:
self._state.start_y = event.value
self._state.current_y = event.value
except asyncio.CancelledError:
pass
except Exception as e:
log.error("Erreur evdev: %s", e)
async def stop(self):
"""Arreter le gestionnaire de gestes."""
self._running = False
if self._long_press_task:
self._long_press_task.cancel()
self._long_press_task = None
if self._touch:
try:
# Pimoroni Touch doesn't have explicit close
if hasattr(self._touch, 'close'):
self._touch.close()
except Exception:
pass
self._touch = None
log.info("TouchHandler arrete")
# ==========================================================================
# Touch Event Handler
# ==========================================================================
def _on_touch_event(self, touch_id: int, x: int, y: int, state: bool):
"""
Callback appele par Pimoroni pour chaque evenement touch.
Args:
touch_id: ID du point de contact (0 ou 1)
x: Position X (0-479)
y: Position Y (0-479)
state: True=pressed, False=released
"""
now = time.time()
if state:
# Touch pressed
log.debug("Touch START id=%d at (%d, %d)", touch_id, x, y)
self._state = TouchState(
touch_id=touch_id,
start_x=x,
start_y=y,
current_x=x,
current_y=y,
start_time=now,
active=True
)
self._long_press_triggered = False
# Start long press timer
if self._event_loop:
self._long_press_task = self._event_loop.create_task(
self._check_long_press()
)
else:
# Touch released
if not self._state.active:
return
log.debug("Touch END id=%d at (%d, %d)", touch_id, x, y)
# Cancel long press timer
if self._long_press_task:
self._long_press_task.cancel()
self._long_press_task = None
# Update final position
self._state.current_x = x
self._state.current_y = y
self._state.active = False
# Don't process gesture if long press was triggered
if self._long_press_triggered:
return
# Detect and execute gesture
gesture = self._detect_gesture(now)
if gesture != Gesture.NONE and self._event_loop:
self._event_loop.create_task(self._execute_gesture(gesture))
async def _check_long_press(self):
"""Verifier si le touch devient un long press."""
try:
await asyncio.sleep(LONG_PRESS_MS / 1000.0)
if self._state.active and not self._long_press_triggered:
# Check if still near start position (not a swipe)
dx = abs(self._state.current_x - self._state.start_x)
dy = abs(self._state.current_y - self._state.start_y)
if dx < SWIPE_THRESHOLD / 2 and dy < SWIPE_THRESHOLD / 2:
# Check if in center zone
dist = math.sqrt(
(self._state.current_x - CENTER_X) ** 2 +
(self._state.current_y - CENTER_Y) ** 2
)
if dist < CENTER_RADIUS:
self._long_press_triggered = True
log.info("Geste detecte: LONG_PRESS at center")
await self._execute_gesture(Gesture.LONG_PRESS)
except asyncio.CancelledError:
pass
def _detect_gesture(self, end_time: float) -> Gesture:
"""
Analyser le touch et determiner le type de geste.
Args:
end_time: Timestamp de fin du touch
Returns:
Type de geste detecte
"""
duration_ms = (end_time - self._state.start_time) * 1000
dx = self._state.current_x - self._state.start_x
dy = self._state.current_y - self._state.start_y
distance = math.sqrt(dx * dx + dy * dy)
log.debug(
"Analyse geste: duration=%.0fms dx=%d dy=%d dist=%.0f",
duration_ms, dx, dy, distance
)
# Swipe horizontal
if abs(dx) > SWIPE_THRESHOLD and abs(dx) > abs(dy) * 1.5:
if dx < 0:
log.info("Geste detecte: SWIPE_LEFT")
return Gesture.SWIPE_LEFT
else:
log.info("Geste detecte: SWIPE_RIGHT")
return Gesture.SWIPE_RIGHT
# Swipe down from top
if (dy > SWIPE_THRESHOLD and
self._state.start_y < SWIPE_TOP_ZONE and
abs(dy) > abs(dx) * 1.5):
log.info("Geste detecte: SWIPE_DOWN")
return Gesture.SWIPE_DOWN
# Swipe up from bottom
if (dy < -SWIPE_THRESHOLD and
self._state.start_y > SWIPE_BOTTOM_ZONE and
abs(dy) > abs(dx) * 1.5):
log.info("Geste detecte: SWIPE_UP")
return Gesture.SWIPE_UP
# Tap (short touch with minimal movement)
if duration_ms < TAP_MAX_MS and distance < SWIPE_THRESHOLD / 2:
log.info("Geste detecte: TAP at (%d, %d)",
self._state.current_x, self._state.current_y)
return Gesture.TAP
log.debug("Geste non reconnu")
return Gesture.NONE
# ==========================================================================
# Callbacks pour l'integration UI
# ==========================================================================
def on_overlay_toggle(self, callback: Callable[[bool], None]):
"""Callback quand l'overlay info est toggle."""
self._on_overlay_toggle = callback
def on_device_list_toggle(self, callback: Callable[[bool], None]):
"""Callback quand la liste des devices est toggle."""
self._on_device_list_toggle = callback
def on_secubox_switch(self, callback: Callable[[str], None]):
"""Callback quand on change de SecuBox."""
self._on_secubox_switch = callback
def on_gesture(self, callback: Callable[[Gesture, dict], None]):
"""Callback generique pour tous les gestes."""
self._on_gesture = callback
def set_menu_navigator(self, navigator: Any):
"""Definir le navigateur de menu."""
self._menu_navigator = navigator
def set_action_executor(self, executor: Any):
"""Definir l'executeur d'actions."""
self._action_executor = executor
def on_menu_render(self, callback: Callable[[Any], None]):
"""Callback pour notifier que le menu doit etre rendu."""
self._on_menu_render = callback
# ==========================================================================
# Execution des gestes
# ==========================================================================
async def _execute_gesture(self, gesture: Gesture):
"""
Executer l'action correspondant au geste detecte.
Args:
gesture: Type de geste detecte
"""
# Callback generique
if self._on_gesture:
try:
self._on_gesture(gesture, {
"x": self._state.current_x,
"y": self._state.current_y,
})
except Exception as e:
log.warning("Erreur callback on_gesture: %s", e)
# Verifier si on est en mode menu
in_menu_mode = False
if self._menu_navigator:
try:
from menu_navigator import MenuMode
in_menu_mode = self._menu_navigator.state.mode == MenuMode.MENU
except ImportError:
pass
# Long press centre: toggle menu
if gesture == Gesture.LONG_PRESS:
await self._on_long_press_center()
return
# Swipe up from bottom: enter menu (alternative)
if gesture == Gesture.SWIPE_UP:
log.info("SWIPE_UP - entering menu")
await self._on_long_press_center()
return
# Si en mode menu
if in_menu_mode:
if gesture == Gesture.TAP:
self._handle_slice_tap(self._state.current_x, self._state.current_y)
elif gesture == Gesture.SWIPE_LEFT:
if self._menu_navigator:
current = self._menu_navigator.state.selected_index
self._menu_navigator.state.selected_index = (current - 1) % 6
if self._on_menu_render:
self._on_menu_render(self._menu_navigator.state)
elif gesture == Gesture.SWIPE_RIGHT:
if self._menu_navigator:
current = self._menu_navigator.state.selected_index
self._menu_navigator.state.selected_index = (current + 1) % 6
if self._on_menu_render:
self._on_menu_render(self._menu_navigator.state)
else:
# Mode dashboard
if gesture == Gesture.SWIPE_LEFT:
await self._on_swipe_left()
elif gesture == Gesture.SWIPE_RIGHT:
await self._on_swipe_right()
elif gesture == Gesture.TAP:
module = self._detect_module_from_position(
self._state.current_x,
self._state.current_y
)
if module:
await self._on_module_tap(module)
elif gesture == Gesture.SWIPE_DOWN:
await self._on_swipe_down()
def _detect_module_from_position(self, x: int, y: int) -> Optional[str]:
"""Determiner le module touche en fonction de la position."""
dist = math.sqrt((x - CENTER_X) ** 2 + (y - CENTER_Y) ** 2)
for module_name, ring in MODULE_RINGS.items():
if ring["inner"] <= dist <= ring["outer"]:
log.debug("Module detecte: %s (dist=%.0f)", module_name, dist)
return module_name
return None
# ==========================================================================
# Actions de gestes
# ==========================================================================
async def _on_swipe_left(self):
"""Swipe gauche: SecuBox precedent."""
if not self.device_manager:
return
boxes = self.device_manager.list_secuboxes()
if len(boxes) < 2:
return
current_idx = next(
(i for i, b in enumerate(boxes) if b.get("active")),
0
)
prev_idx = (current_idx - 1) % len(boxes)
prev_name = boxes[prev_idx]["name"]
log.info("Switch vers SecuBox: %s", prev_name)
try:
success = await self.device_manager.switch_to(prev_name)
if success and self._on_secubox_switch:
self._on_secubox_switch(prev_name)
except Exception as e:
log.error("Erreur switch: %s", e)
async def _on_swipe_right(self):
"""Swipe droite: SecuBox suivant."""
if not self.device_manager:
return
boxes = self.device_manager.list_secuboxes()
if len(boxes) < 2:
return
current_idx = next(
(i for i, b in enumerate(boxes) if b.get("active")),
0
)
next_idx = (current_idx + 1) % len(boxes)
next_name = boxes[next_idx]["name"]
log.info("Switch vers SecuBox: %s", next_name)
try:
success = await self.device_manager.switch_to(next_name)
if success and self._on_secubox_switch:
self._on_secubox_switch(next_name)
except Exception as e:
log.error("Erreur switch: %s", e)
async def _on_module_tap(self, module: str):
"""Tap sur anneau module: redemarrer le service."""
log.info("Tap sur module: %s", module)
if not self.ws_client:
return
service_name = f"secubox-{module.lower()}"
try:
await self.ws_client.send_message(
"command",
cmd="service_restart",
params={"service": service_name}
)
except Exception as e:
log.error("Erreur commande: %s", e)
async def _on_swipe_down(self):
"""Swipe down: toggle overlay info."""
self._info_overlay_visible = not self._info_overlay_visible
log.info("Toggle overlay: %s",
"visible" if self._info_overlay_visible else "cache")
if self._on_overlay_toggle:
try:
self._on_overlay_toggle(self._info_overlay_visible)
except Exception as e:
log.warning("Erreur callback: %s", e)
async def _on_long_press_center(self):
"""Long press centre: toggle menu."""
if self._menu_navigator:
self._handle_menu_toggle()
return
# Fallback: device list
self._device_list_visible = not self._device_list_visible
log.info("Toggle device list: %s",
"visible" if self._device_list_visible else "cache")
if self._on_device_list_toggle:
try:
self._on_device_list_toggle(self._device_list_visible)
except Exception as e:
log.warning("Erreur callback: %s", e)
# ==========================================================================
# Menu Integration
# ==========================================================================
def _handle_menu_toggle(self):
"""Toggle entre mode menu et dashboard."""
if not self._menu_navigator:
return
from menu_navigator import MenuMode
if self._menu_navigator.state.mode == MenuMode.DASHBOARD:
log.info("Entree en mode menu")
self._menu_navigator.enter_menu()
else:
log.info("Sortie vers dashboard")
self._menu_navigator.exit_to_dashboard()
if self._on_menu_render:
try:
self._on_menu_render(self._menu_navigator.state)
except Exception as e:
log.warning("Erreur callback: %s", e)
def _handle_slice_tap(self, x: int, y: int) -> Optional[str]:
"""Gerer un tap sur une tranche du menu radial."""
if not self._menu_navigator:
return None
slice_index = get_slice_from_touch(x, y)
if slice_index is None:
# Tap in center - go back
if self._menu_navigator.state.breadcrumb:
self._menu_navigator.go_back()
else:
self._menu_navigator.exit_to_dashboard()
if self._on_menu_render:
self._on_menu_render(self._menu_navigator.state)
return None
log.info("Tap sur tranche menu: %d", slice_index)
self._menu_navigator.state.selected_index = slice_index
action = self._menu_navigator.select_current()
if action and self._action_executor:
try:
asyncio.create_task(self._action_executor.execute(action))
except Exception as e:
log.error("Erreur execution: %s", e)
if self._on_menu_render:
try:
self._on_menu_render(self._menu_navigator.state)
except Exception as e:
log.warning("Erreur callback: %s", e)
return action
# ==========================================================================
# Proprietes
# ==========================================================================
@property
def is_running(self) -> bool:
"""Verifier si le handler est actif."""
return self._running
@property
def info_overlay_visible(self) -> bool:
"""Etat de l'overlay info."""
return self._info_overlay_visible
@property
def device_list_visible(self) -> bool:
"""Etat de la liste des devices."""
return self._device_list_visible
@property
def touch_device_name(self) -> Optional[str]:
"""Nom du peripherique tactile."""
if self._touch:
if hasattr(self._touch, 'name'):
return self._touch.name
return "HyperPixel2r I2C Touch"
return None
# =============================================================================
# Factory function
# =============================================================================
def create_touch_handler(
device_manager: Optional[Any] = None,
ws_client: Optional[Any] = None
) -> TouchHandler:
"""
Creer un gestionnaire de gestes tactiles.
Args:
device_manager: Gestionnaire des connexions SecuBox
ws_client: Client WebSocket pour les commandes
Returns:
Instance de TouchHandler configuree
"""
return TouchHandler(
device_manager=device_manager,
ws_client=ws_client
)
# =============================================================================
# Test standalone
# =============================================================================
async def main():
"""Test du TouchHandler avec Pimoroni."""
import sys
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
log.info("Test TouchHandler (Pimoroni hyperpixel2r)")
handler = create_touch_handler()
def on_gesture_demo(gesture: Gesture, data: dict):
print(f">>> Geste: {gesture.name} - {data}")
handler.on_gesture(on_gesture_demo)
success = await handler.start()
if not success:
log.error("Echec demarrage touch handler")
sys.exit(1)
log.info("TouchHandler actif: %s", handler.touch_device_name)
log.info("Testez les gestes. Ctrl+C pour quitter.")
try:
while handler.is_running:
await asyncio.sleep(1)
except KeyboardInterrupt:
log.info("Arret")
finally:
await handler.stop()
if __name__ == "__main__":
asyncio.run(main())