#!/usr/bin/env python3 import re import time import json import os import serial STATUS_FILE = "/tmp/status.json" class Plotter: def __init__(self, port="/dev/ttyUSB0", baud=115200, unlock=True): """Initialise and open a GRBL serial connection, restore state from file if present.""" if not os.path.exists(port): raise FileNotFoundError(f"Serial device {port} not found") self.ser = serial.Serial(port, baudrate=baud, timeout=1, write_timeout=1) # Wake up GRBL self.ser.write(b"\r\n\r\n") time.sleep(2) self.ser.reset_input_buffer() if unlock: try: self.send_line("$X") # unlock if in alarm state except Exception as e: raise RuntimeError(f"Failed to unlock GRBL on {port}: {e}") # Initialise state self.state = {"x": 0.0, "y": 0.0, "z": 0.0} if os.path.exists(STATUS_FILE): try: with open(STATUS_FILE, "r") as f: self.state = json.load(f) except Exception: pass # fallback to default if file corrupt def save_state(self): """Persist current state to status.json.""" with open(STATUS_FILE, "w") as f: json.dump(self.state, f) def send_line(self, line, wait_ok=True, max_lines=100): """Send a single G-code line to GRBL and optionally wait for ok.""" self.ser.write((line + "\n").encode()) if not wait_ok: return [] lines = [] for _ in range(max_lines): response = self.ser.readline().decode(errors="ignore").strip() if not response: continue lines.append(response) if response.lower() == "ok": return lines if response.lower().startswith(("error", "alarm")): raise RuntimeError(f"GRBL error: {response}") raise TimeoutError("No valid response from GRBL") def get_position(self): """Return current position, using persisted state if available.""" return self.state.copy() def move(self, x=None, y=None, feed=1000): """Move relatively by given x, y offsets in mm and update stored position.""" self.send_line("G91") # relative mode cmd = "G1" if x is not None: cmd += f" X{x}" self.state["x"] += x if y is not None: cmd += f" Y{y}" self.state["y"] += y cmd += f" F{feed}" self.send_line(cmd) self.send_line("G90") # back to absolute mode self.save_state() def move_absolute(self, x=None, y=None, feed=1000): """Move to absolute coordinates in mm and update stored position.""" cmd = "G90" # ensure absolute mode self.send_line(cmd) move_cmd = "G1" if x is not None: move_cmd += f" X{x}" self.state["x"] = float(x) if y is not None: move_cmd += f" Y{y}" self.state["y"] = float(y) move_cmd += f" F{feed}" self.send_line(move_cmd) self.save_state() def set_spindle(self, speed=0, clockwise=True): """Set spindle speed and update stored 'z' value.""" self._spindle_speed = speed if speed < 0: self.send_line("M5") # spindle stop self.state["z"] = 0.0 else: if clockwise: self.send_line(f"M3 S{int(speed)}") else: self.send_line(f"M4 S{int(speed)}") self.state["z"] = float(speed) self.save_state() def set_home(self): """Set current machine X/Y to 0 and update persisted state.""" self.state["x"] = 0.0 self.state["y"] = 0.0 self.save_state() def close(self): """Close the serial connection.""" self.ser.close()