609 lines
18 KiB
Python
609 lines
18 KiB
Python
"""
|
|
Utilitaires d'entrée pour exécuter des actions UI (souris, clavier, etc.).
|
|
Support du clavier AZERTY et gestion du rollback d'actions.
|
|
"""
|
|
|
|
import time
|
|
import pyautogui
|
|
from typing import Dict, Any, Optional, Tuple
|
|
from enum import Enum
|
|
|
|
from ..logger import Logger
|
|
|
|
|
|
class ActionType(Enum):
|
|
"""Types d'actions UI supportées."""
|
|
CLICK = "click"
|
|
TYPE = "type"
|
|
SCROLL = "scroll"
|
|
WAIT = "wait"
|
|
MOVE = "move"
|
|
DRAG = "drag"
|
|
|
|
|
|
class InputUtils:
|
|
"""
|
|
Gestionnaire d'entrées utilisateur pour exécuter des actions UI.
|
|
Support du clavier AZERTY et rollback d'actions.
|
|
"""
|
|
|
|
def __init__(self, logger: Logger, config: Dict[str, Any]):
|
|
"""
|
|
Initialise les utilitaires d'entrée.
|
|
|
|
Args:
|
|
logger: Logger pour journalisation
|
|
config: Configuration globale
|
|
"""
|
|
self.logger = logger
|
|
self.config = config
|
|
|
|
# Configuration PyAutoGUI
|
|
pyautogui.FAILSAFE = True # Déplacer souris dans coin = arrêt
|
|
pyautogui.PAUSE = config.get("input", {}).get("pause_between_actions", 0.1)
|
|
|
|
# Historique des actions pour rollback
|
|
self.action_history = []
|
|
|
|
# Mapping AZERTY pour caractères spéciaux
|
|
self.azerty_mapping = {
|
|
'0': 'à',
|
|
'1': '&',
|
|
'2': 'é',
|
|
'3': '"',
|
|
'4': "'",
|
|
'5': '(',
|
|
'6': '-',
|
|
'7': 'è',
|
|
'8': '_',
|
|
'9': 'ç',
|
|
'.': ':',
|
|
'/': '!',
|
|
',': ';',
|
|
';': ',',
|
|
':': '.',
|
|
'!': '/',
|
|
'?': 'M', # Shift + ,
|
|
}
|
|
|
|
self.logger.log_action({
|
|
"action": "input_utils_initialized",
|
|
"failsafe": True,
|
|
"pause": pyautogui.PAUSE
|
|
})
|
|
|
|
def click(
|
|
self,
|
|
x: int,
|
|
y: int,
|
|
button: str = "left",
|
|
clicks: int = 1,
|
|
interval: float = 0.0
|
|
) -> bool:
|
|
"""
|
|
Effectue un clic souris à la position spécifiée.
|
|
|
|
Args:
|
|
x: Coordonnée X
|
|
y: Coordonnée Y
|
|
button: Bouton souris ("left", "right", "middle")
|
|
clicks: Nombre de clics
|
|
interval: Intervalle entre clics multiples
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
try:
|
|
# Enregistrer position actuelle pour rollback
|
|
current_pos = pyautogui.position()
|
|
|
|
# Effectuer le clic
|
|
pyautogui.click(x, y, clicks=clicks, interval=interval, button=button)
|
|
|
|
# Enregistrer dans l'historique
|
|
action_record = {
|
|
"type": ActionType.CLICK.value,
|
|
"x": x,
|
|
"y": y,
|
|
"button": button,
|
|
"clicks": clicks,
|
|
"previous_position": current_pos,
|
|
"timestamp": time.time()
|
|
}
|
|
self.action_history.append(action_record)
|
|
|
|
self.logger.log_action({
|
|
"action": "click_executed",
|
|
**action_record
|
|
})
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.log_action({
|
|
"action": "click_failed",
|
|
"x": x,
|
|
"y": y,
|
|
"error": str(e)
|
|
})
|
|
return False
|
|
|
|
def type_text(
|
|
self,
|
|
text: str,
|
|
interval: float = 0.0,
|
|
use_azerty: bool = True
|
|
) -> bool:
|
|
"""
|
|
Saisit du texte au clavier.
|
|
|
|
Args:
|
|
text: Texte à saisir
|
|
interval: Intervalle entre chaque caractère
|
|
use_azerty: Utiliser le mapping AZERTY
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
try:
|
|
# Convertir pour AZERTY si nécessaire
|
|
if use_azerty:
|
|
converted_text = self._convert_to_azerty(text)
|
|
else:
|
|
converted_text = text
|
|
|
|
# Saisir le texte
|
|
pyautogui.write(converted_text, interval=interval)
|
|
|
|
# Enregistrer dans l'historique
|
|
action_record = {
|
|
"type": ActionType.TYPE.value,
|
|
"text": text,
|
|
"converted_text": converted_text,
|
|
"length": len(text),
|
|
"timestamp": time.time()
|
|
}
|
|
self.action_history.append(action_record)
|
|
|
|
self.logger.log_action({
|
|
"action": "text_typed",
|
|
**action_record
|
|
})
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.log_action({
|
|
"action": "type_text_failed",
|
|
"text": text[:50], # Limiter pour logs
|
|
"error": str(e)
|
|
})
|
|
return False
|
|
|
|
def scroll(
|
|
self,
|
|
direction: str,
|
|
amount: int = 3,
|
|
x: Optional[int] = None,
|
|
y: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Effectue un défilement.
|
|
|
|
Args:
|
|
direction: Direction ("up", "down", "left", "right")
|
|
amount: Quantité de défilement (nombre de "clics" de molette)
|
|
x: Position X optionnelle
|
|
y: Position Y optionnelle
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
try:
|
|
# Calculer le montant de défilement
|
|
if direction in ["up", "right"]:
|
|
scroll_amount = amount
|
|
elif direction in ["down", "left"]:
|
|
scroll_amount = -amount
|
|
else:
|
|
raise ValueError(f"Direction invalide: {direction}")
|
|
|
|
# Déplacer la souris si position spécifiée
|
|
if x is not None and y is not None:
|
|
pyautogui.moveTo(x, y)
|
|
|
|
# Effectuer le défilement
|
|
if direction in ["up", "down"]:
|
|
pyautogui.scroll(scroll_amount)
|
|
else:
|
|
pyautogui.hscroll(scroll_amount)
|
|
|
|
# Enregistrer dans l'historique
|
|
action_record = {
|
|
"type": ActionType.SCROLL.value,
|
|
"direction": direction,
|
|
"amount": amount,
|
|
"x": x,
|
|
"y": y,
|
|
"timestamp": time.time()
|
|
}
|
|
self.action_history.append(action_record)
|
|
|
|
self.logger.log_action({
|
|
"action": "scroll_executed",
|
|
**action_record
|
|
})
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.log_action({
|
|
"action": "scroll_failed",
|
|
"direction": direction,
|
|
"amount": amount,
|
|
"error": str(e)
|
|
})
|
|
return False
|
|
|
|
def wait(self, duration: float) -> bool:
|
|
"""
|
|
Attend pendant une durée spécifiée.
|
|
|
|
Args:
|
|
duration: Durée en secondes
|
|
|
|
Returns:
|
|
True
|
|
"""
|
|
try:
|
|
time.sleep(duration)
|
|
|
|
action_record = {
|
|
"type": ActionType.WAIT.value,
|
|
"duration": duration,
|
|
"timestamp": time.time()
|
|
}
|
|
self.action_history.append(action_record)
|
|
|
|
self.logger.log_action({
|
|
"action": "wait_executed",
|
|
**action_record
|
|
})
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.log_action({
|
|
"action": "wait_failed",
|
|
"duration": duration,
|
|
"error": str(e)
|
|
})
|
|
return False
|
|
|
|
def move(self, x: int, y: int, duration: float = 0.2) -> bool:
|
|
"""
|
|
Déplace la souris vers une position.
|
|
|
|
Args:
|
|
x: Coordonnée X
|
|
y: Coordonnée Y
|
|
duration: Durée du mouvement en secondes
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
try:
|
|
current_pos = pyautogui.position()
|
|
|
|
pyautogui.moveTo(x, y, duration=duration)
|
|
|
|
action_record = {
|
|
"type": ActionType.MOVE.value,
|
|
"x": x,
|
|
"y": y,
|
|
"previous_position": current_pos,
|
|
"duration": duration,
|
|
"timestamp": time.time()
|
|
}
|
|
self.action_history.append(action_record)
|
|
|
|
self.logger.log_action({
|
|
"action": "move_executed",
|
|
**action_record
|
|
})
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.log_action({
|
|
"action": "move_failed",
|
|
"x": x,
|
|
"y": y,
|
|
"error": str(e)
|
|
})
|
|
return False
|
|
|
|
def drag(
|
|
self,
|
|
start_x: int,
|
|
start_y: int,
|
|
end_x: int,
|
|
end_y: int,
|
|
duration: float = 0.5,
|
|
button: str = "left"
|
|
) -> bool:
|
|
"""
|
|
Effectue un glisser-déposer.
|
|
|
|
Args:
|
|
start_x: X de départ
|
|
start_y: Y de départ
|
|
end_x: X d'arrivée
|
|
end_y: Y d'arrivée
|
|
duration: Durée du glissement
|
|
button: Bouton souris
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
try:
|
|
current_pos = pyautogui.position()
|
|
|
|
pyautogui.moveTo(start_x, start_y)
|
|
pyautogui.drag(end_x - start_x, end_y - start_y, duration=duration, button=button)
|
|
|
|
action_record = {
|
|
"type": ActionType.DRAG.value,
|
|
"start_x": start_x,
|
|
"start_y": start_y,
|
|
"end_x": end_x,
|
|
"end_y": end_y,
|
|
"previous_position": current_pos,
|
|
"duration": duration,
|
|
"button": button,
|
|
"timestamp": time.time()
|
|
}
|
|
self.action_history.append(action_record)
|
|
|
|
self.logger.log_action({
|
|
"action": "drag_executed",
|
|
**action_record
|
|
})
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.log_action({
|
|
"action": "drag_failed",
|
|
"start": (start_x, start_y),
|
|
"end": (end_x, end_y),
|
|
"error": str(e)
|
|
})
|
|
return False
|
|
|
|
def execute_inverse_action(self, action: Dict[str, Any]) -> bool:
|
|
"""
|
|
Exécute l'action inverse pour rollback.
|
|
|
|
Args:
|
|
action: Action à inverser
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
inverse = self.get_inverse_action(action)
|
|
if not inverse:
|
|
return False
|
|
|
|
action_type = inverse.get("type")
|
|
|
|
if action_type == ActionType.MOVE.value:
|
|
return self.move(inverse["x"], inverse["y"], inverse.get("duration", 0.2))
|
|
elif action_type == ActionType.SCROLL.value:
|
|
return self.scroll(
|
|
inverse["direction"],
|
|
inverse["amount"],
|
|
inverse.get("x"),
|
|
inverse.get("y")
|
|
)
|
|
elif action_type == ActionType.DRAG.value:
|
|
return self.drag(
|
|
inverse["start_x"],
|
|
inverse["start_y"],
|
|
inverse["end_x"],
|
|
inverse["end_y"],
|
|
inverse.get("duration", 0.5),
|
|
inverse.get("button", "left")
|
|
)
|
|
elif action_type == "press_key":
|
|
# Exécuter les suppressions
|
|
for _ in range(inverse.get("presses", 0)):
|
|
pyautogui.press("backspace")
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_inverse_action(self, action: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Génère l'action inverse pour rollback.
|
|
|
|
Args:
|
|
action: Action à inverser
|
|
|
|
Returns:
|
|
Action inverse ou None si non inversible
|
|
"""
|
|
action_type = action.get("type")
|
|
|
|
if action_type == ActionType.CLICK.value:
|
|
# Un clic n'est pas vraiment inversible
|
|
# On peut retourner à la position précédente
|
|
prev_pos = action.get("previous_position")
|
|
if prev_pos:
|
|
return {
|
|
"type": ActionType.MOVE.value,
|
|
"x": prev_pos[0],
|
|
"y": prev_pos[1],
|
|
"duration": 0.2
|
|
}
|
|
|
|
elif action_type == ActionType.TYPE.value:
|
|
# Inverser la saisie = supprimer le texte
|
|
text_length = action.get("length", 0)
|
|
return {
|
|
"type": "press_key",
|
|
"key": "backspace",
|
|
"presses": text_length
|
|
}
|
|
|
|
elif action_type == ActionType.SCROLL.value:
|
|
# Inverser le défilement
|
|
direction = action.get("direction")
|
|
amount = action.get("amount")
|
|
inverse_direction = {
|
|
"up": "down",
|
|
"down": "up",
|
|
"left": "right",
|
|
"right": "left"
|
|
}.get(direction)
|
|
|
|
return {
|
|
"type": ActionType.SCROLL.value,
|
|
"direction": inverse_direction,
|
|
"amount": amount,
|
|
"x": action.get("x"),
|
|
"y": action.get("y")
|
|
}
|
|
|
|
elif action_type == ActionType.MOVE.value:
|
|
# Retourner à la position précédente
|
|
prev_pos = action.get("previous_position")
|
|
if prev_pos:
|
|
return {
|
|
"type": ActionType.MOVE.value,
|
|
"x": prev_pos[0],
|
|
"y": prev_pos[1],
|
|
"duration": 0.2
|
|
}
|
|
|
|
elif action_type == ActionType.DRAG.value:
|
|
# Inverser le glissement
|
|
return {
|
|
"type": ActionType.DRAG.value,
|
|
"start_x": action.get("end_x"),
|
|
"start_y": action.get("end_y"),
|
|
"end_x": action.get("start_x"),
|
|
"end_y": action.get("start_y"),
|
|
"duration": action.get("duration", 0.5),
|
|
"button": action.get("button", "left")
|
|
}
|
|
|
|
elif action_type == ActionType.WAIT.value:
|
|
# L'attente n'a pas d'inverse
|
|
return None
|
|
|
|
return None
|
|
|
|
def _convert_to_azerty(self, text: str) -> str:
|
|
"""
|
|
Convertit du texte pour clavier AZERTY.
|
|
|
|
Args:
|
|
text: Texte à convertir
|
|
|
|
Returns:
|
|
Texte converti
|
|
"""
|
|
# Pour l'instant, retourner tel quel
|
|
# PyAutoGUI gère déjà le layout clavier du système
|
|
# Cette méthode peut être étendue si nécessaire
|
|
return text
|
|
|
|
def get_action_history(self, limit: int = 50) -> list:
|
|
"""
|
|
Retourne l'historique des actions.
|
|
|
|
Args:
|
|
limit: Nombre maximum d'actions à retourner
|
|
|
|
Returns:
|
|
Liste des dernières actions
|
|
"""
|
|
return self.action_history[-limit:]
|
|
|
|
def clear_history(self):
|
|
"""Efface l'historique des actions."""
|
|
self.action_history = []
|
|
self.logger.log_action({
|
|
"action": "action_history_cleared"
|
|
})
|
|
|
|
def execute_action(self, action_data: Dict[str, Any]) -> bool:
|
|
"""
|
|
Exécute une action depuis un dictionnaire de données.
|
|
|
|
Args:
|
|
action_data: Données de l'action à exécuter
|
|
{
|
|
"action_type": str,
|
|
"bbox": (x, y, w, h),
|
|
"parameters": dict
|
|
}
|
|
|
|
Returns:
|
|
True si succès, False sinon
|
|
"""
|
|
action_type = action_data.get("action_type", "").lower()
|
|
bbox = action_data.get("bbox", (0, 0, 0, 0))
|
|
params = action_data.get("parameters", {})
|
|
|
|
# Calculer le centre de la bbox pour les actions de clic
|
|
x, y, w, h = bbox
|
|
center_x = x + w // 2
|
|
center_y = y + h // 2
|
|
|
|
if action_type == "click":
|
|
button = params.get("button", "left")
|
|
clicks = params.get("clicks", 1)
|
|
return self.click(center_x, center_y, button=button, clicks=clicks)
|
|
|
|
elif action_type == "double_click":
|
|
return self.click(center_x, center_y, clicks=2)
|
|
|
|
elif action_type == "right_click":
|
|
return self.click(center_x, center_y, button="right")
|
|
|
|
elif action_type == "type":
|
|
text = params.get("text", "")
|
|
interval = params.get("interval", 0.0)
|
|
return self.type_text(text, interval=interval)
|
|
|
|
elif action_type == "scroll":
|
|
direction = params.get("direction", "down")
|
|
amount = params.get("amount", 3)
|
|
return self.scroll(direction, amount, center_x, center_y)
|
|
|
|
elif action_type == "wait":
|
|
duration = params.get("duration", 1.0)
|
|
return self.wait(duration)
|
|
|
|
elif action_type == "move":
|
|
duration = params.get("duration", 0.2)
|
|
return self.move(center_x, center_y, duration=duration)
|
|
|
|
elif action_type == "drag":
|
|
end_bbox = params.get("end_bbox", bbox)
|
|
end_x, end_y, end_w, end_h = end_bbox
|
|
end_center_x = end_x + end_w // 2
|
|
end_center_y = end_y + end_h // 2
|
|
duration = params.get("duration", 0.5)
|
|
button = params.get("button", "left")
|
|
return self.drag(center_x, center_y, end_center_x, end_center_y, duration, button)
|
|
|
|
else:
|
|
self.logger.log_action({
|
|
"action": "unknown_action_type",
|
|
"action_type": action_type
|
|
})
|
|
return False
|