Newer
Older
Hardware / Plotter / plotter.py
#!/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()