301 lines
10 KiB
Python
301 lines
10 KiB
Python
'''
|
|
Simulateur GCode pour tester sans CNC physique.
|
|
|
|
GRBLController (simulé):
|
|
Reproduit fidèlement l'API de grbl.py
|
|
Simule les mouvements (X, Y) avec délai proportionnel au feed rate
|
|
Le mode absolu est retenu
|
|
Aucune dépendance à pyserial
|
|
|
|
Created on 07 mai 2026
|
|
|
|
@author: denis@miraceti.net
|
|
'''
|
|
import logging
|
|
import time
|
|
import threading
|
|
import math
|
|
from typing import Callable, Any
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GRBLController:
|
|
'''
|
|
Simulateur du contrôleur GRBL 1.1f (L2544 Laser Engraving Machine).
|
|
API 100% identique à grbl.py — interchangeable sans modifier le code appelant.
|
|
Les délais de déplacement sont calculés à partir du feed rate et de la distance.
|
|
'''
|
|
X_MAX = 350
|
|
Y_MAX = 250
|
|
X_MIN = 0
|
|
Y_MIN = 0
|
|
|
|
# Facteur de compression du temps simulé (1.0 = temps réel, 0.1 = 10x plus rapide)
|
|
TIME_SCALE = 0.1
|
|
|
|
def __init__(self, port='/dev/ttyUSB0', baudrate=115200, timeout=1, send_callback=None, x_max=None, y_max=None):
|
|
logger.info(f"GRBLController SIMULATOR::init begin {port} device port")
|
|
|
|
self.port = port
|
|
self.baudrate = baudrate
|
|
self.timeout = timeout
|
|
|
|
if x_max is not None:
|
|
self.X_MAX = x_max
|
|
if y_max is not None:
|
|
self.Y_MAX = y_max
|
|
|
|
self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
|
|
|
|
# Position courante simulée
|
|
self.x: float | None = None
|
|
self.y: float | None = None
|
|
|
|
# État interne de la machine simulée
|
|
self._machine_state = 'Idle' # Idle | Run | Alarm
|
|
self._connected = False
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Méthodes utilitaires
|
|
# -------------------------------------------------------------------------
|
|
|
|
def wait_for(self, delay=1.0):
|
|
# Applique le facteur de compression temporelle
|
|
threading.Event().wait(delay * self.TIME_SCALE)
|
|
|
|
def _send_msg(self, **msg):
|
|
# Callback par défaut : simple affichage console
|
|
print(msg)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Simulation de la couche série (pas de port réel)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def clear_buffer(self):
|
|
# Rien à vider : pas de port série physique
|
|
logger.debug("SIMULATOR::clear_buffer (no-op)")
|
|
|
|
def start_connection(self):
|
|
'''Simule l'ouverture de la connexion série et l'initialisation GRBL.'''
|
|
logger.info(f"SIMULATOR::start_connection on {self.port} @ {self.baudrate} baud")
|
|
self._state(state='serial', msg="Grbl 1.1f ['$' for help]")
|
|
self._connected = True
|
|
self._wake_up()
|
|
self._init_machine()
|
|
logger.info("SIMULATOR::start_connection started")
|
|
|
|
def _init_machine(self):
|
|
# Envoie les commandes d'initialisation (simulées)
|
|
self.send("G21") # Unités en mm
|
|
self.send("G90") # Mode absolu
|
|
|
|
def _clamp(self, x, y):
|
|
self.clear_buffer()
|
|
x = max(self.X_MIN, min(self.X_MAX, x))
|
|
y = max(self.Y_MIN, min(self.Y_MAX, y))
|
|
return x, y
|
|
|
|
def _wake_up(self):
|
|
# Simule l'envoi des octets de réveil et la réponse GRBL
|
|
logger.debug("SIMULATOR::_wake_up")
|
|
self.wait_for(1)
|
|
self._state(state='serial', msg="") # ligne vide typique de GRBL au démarrage
|
|
self.clear_buffer()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Envoi de commandes
|
|
# -------------------------------------------------------------------------
|
|
|
|
def send(self, cmd, wait_ok=True, timeout=5):
|
|
try:
|
|
return self._send(cmd, wait_ok, timeout)
|
|
except Exception as e:
|
|
self._state(state='error', msg=f"Error send {cmd} command: {e}")
|
|
self.close()
|
|
self.start_connection()
|
|
|
|
def recover(self):
|
|
self._state(state='recover', msg="Erreur, récupération de GRBL...")
|
|
self.wait_for(1)
|
|
self._wake_up()
|
|
|
|
def _send(self, cmd, wait_ok=True, timeout=5):
|
|
'''Simule l'envoi d'une commande GCode et retourne "ok".'''
|
|
self._state(state='send', msg=f">>> {cmd}")
|
|
logger.debug(f"SIMULATOR::_send {cmd}")
|
|
|
|
# Interprète les commandes de mouvement pour mettre à jour la position interne
|
|
self._interpret_gcode(cmd)
|
|
|
|
if not wait_ok:
|
|
return None
|
|
|
|
# Simule une réponse "ok" immédiate
|
|
return "ok"
|
|
|
|
def _interpret_gcode(self, cmd):
|
|
'''
|
|
Analyse le GCode pour mettre à jour x, y et simuler le délai de déplacement.
|
|
Gère : G0, G1, G53 G1, G92, G21, G90, G91, $X, $H.
|
|
'''
|
|
cmd_upper = cmd.strip().upper()
|
|
|
|
# --- Commandes sans mouvement ---
|
|
if cmd_upper in ("G21", "G90", "G91", "$X"):
|
|
return
|
|
if cmd_upper == "$H":
|
|
# Homing : retour à l'origine avec délai simulé
|
|
self._machine_state = 'Run'
|
|
self._state(state='send', msg="SIMULATOR: homing...")
|
|
distance = math.hypot(self.x or 0.0, self.y or 0.0)
|
|
self._simulate_move_delay(distance, feed=3000)
|
|
self.x, self.y = 0.0, 0.0
|
|
self._machine_state = 'Idle'
|
|
return
|
|
|
|
# --- Extraction des coordonnées X, Y et du feed F ---
|
|
tokens = cmd_upper.replace(',', ' ').split()
|
|
new_x: float = self.x or 0.0
|
|
new_y: float = self.y or 0.0
|
|
feed: float = 1000.0
|
|
|
|
for token in tokens:
|
|
if token.startswith('X'):
|
|
try:
|
|
new_x = float(token[1:])
|
|
except ValueError:
|
|
pass
|
|
elif token.startswith('Y'):
|
|
try:
|
|
new_y = float(token[1:])
|
|
except ValueError:
|
|
pass
|
|
elif token.startswith('F'):
|
|
try:
|
|
feed = float(token[1:])
|
|
except ValueError:
|
|
pass
|
|
|
|
# --- G92 : redéfinit la position courante sans déplacement ---
|
|
if 'G92' in tokens:
|
|
self.x = new_x
|
|
self.y = new_y
|
|
logger.debug(f"SIMULATOR: G92 position set to ({self.x:.2f}, {self.y:.2f})")
|
|
return
|
|
|
|
# --- Mouvement effectif (G0, G1, G53 G1, etc.) ---
|
|
has_move = any(t in tokens for t in ('G0', 'G1', 'G53'))
|
|
cur_x = self.x or 0.0
|
|
cur_y = self.y or 0.0
|
|
if has_move and (new_x != cur_x or new_y != cur_y):
|
|
distance = math.hypot(new_x - cur_x, new_y - cur_y)
|
|
self._machine_state = 'Run'
|
|
self._simulate_move_delay(distance, feed)
|
|
self.x = new_x
|
|
self.y = new_y
|
|
self._machine_state = 'Idle'
|
|
logger.debug(f"SIMULATOR: moved to ({self.x:.2f}, {self.y:.2f})")
|
|
|
|
def _simulate_move_delay(self, distance_mm, feed):
|
|
'''Simule le temps de déplacement : distance / feed (mm/min) → secondes.'''
|
|
if feed <= 0:
|
|
return
|
|
duration = (distance_mm / feed) * 60.0 # feed est en mm/min
|
|
self.wait_for(duration)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Status machine
|
|
# -------------------------------------------------------------------------
|
|
|
|
def get_status(self):
|
|
'''Retourne un status GRBL simulé au format <State|MPos:x,y,z>.'''
|
|
x = self.x or 0.0
|
|
y = self.y or 0.0
|
|
status = f"<{self._machine_state}|MPos:{x:.3f},{y:.3f},0.000|FS:0,0>"
|
|
logger.debug(f"SIMULATOR::get_status → {status}")
|
|
return status
|
|
|
|
def reset_grbl(self):
|
|
self.send("$X") # Réinitialise les alarmes
|
|
self.wait_idle()
|
|
self.send("$H") # Homing
|
|
self.wait_idle()
|
|
|
|
def _mpos(self, status):
|
|
if "MPos" in status:
|
|
mpos = status.split("MPos:")[1].split("|")[0]
|
|
x, y, *_ = mpos.split(",")
|
|
self._state(state='Mpos', msg=f"pos >>> ({x}, {y})")
|
|
return float(x), float(y)
|
|
return None, None
|
|
|
|
def get_mpos(self):
|
|
return self._mpos(self.get_status())
|
|
|
|
def wait_idle(self, timeout=20):
|
|
'''Attend que la machine soit à l'état Idle (immédiat en simulation).'''
|
|
start = time.time()
|
|
while True:
|
|
if time.time() - start > timeout:
|
|
raise TimeoutError("Délai d'attente pour Idle dépassé")
|
|
status = self.get_status()
|
|
self.x, self.y = self._mpos(status)
|
|
self._state(xy=True, x=self.x, y=self.y)
|
|
if status and "Idle" in status:
|
|
break
|
|
self.wait_for(0.1)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Commandes de haut niveau (identiques à grbl.py)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def send_command(self, cmd):
|
|
self.send(cmd)
|
|
self.wait_idle()
|
|
|
|
def move_to(self, x, y, feed=1000):
|
|
x, y = self._clamp(x, y)
|
|
cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
|
|
self.send_command(cmd)
|
|
|
|
def move_relative(self, dx=0, dy=0, feed=1000):
|
|
x, y = self.get_mpos() # Position actuelle
|
|
self.move_to((x or 0.0) + dx, (y or 0.0) + dy, feed=feed)
|
|
|
|
def move_relative__(self, dx=0, dy=0, feed=1000):
|
|
self.send("G91") # Mode relatif
|
|
cmd = f"G0 X{dx} Y{dy} F{feed}"
|
|
self.send(cmd)
|
|
self.send("G90") # Retour en mode absolu
|
|
self.wait_idle()
|
|
|
|
def go_origin(self, feed=1000):
|
|
self.move_to(0, 0, feed=feed)
|
|
self.wait_for(2.0)
|
|
|
|
def set_position(self, x, y):
|
|
x, y = self._clamp(x, y)
|
|
cmd = f"G92 X{x:.2f} Y{y:.2f}"
|
|
self.send(cmd)
|
|
self.wait_for(2.0)
|
|
|
|
def move_up(self, step=10, feed=1000):
|
|
self.move_relative(dy=step, feed=feed)
|
|
|
|
def move_down(self, step=10, feed=1000):
|
|
self.move_relative(dy=-step, feed=feed)
|
|
|
|
def move_left(self, step=10, feed=1000):
|
|
self.move_relative(dx=-step, feed=feed)
|
|
|
|
def move_right(self, step=10, feed=1000):
|
|
self.move_relative(dx=step, feed=feed)
|
|
|
|
def close(self):
|
|
# Simule la fermeture du port série
|
|
self._connected = False
|
|
logger.info("SIMULATOR::close — connexion simulée fermée")
|