#!/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()