diff --git a/Plotter/ConfigPlotterGen.py b/Plotter/ConfigPlotterGen.py new file mode 100644 index 0000000..5c7a696 --- /dev/null +++ b/Plotter/ConfigPlotterGen.py @@ -0,0 +1,546 @@ +###### +# LEAVE THESE IMPORTS! +###### +import functions +import random +import requests +import threading +import time +from datetime import datetime +import os +import re +import json +import csv +from textual.widgets import Log + +import sys +sys.path.append("/tmp") + +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend + +import matplotlib +matplotlib.use("Agg") # ensures headless backend (no GUI needed) +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import matplotlib.colors as mcolors + +###### +# config values (you can edit these to fit your environment and use case) +###### + +# Serial port settings +SERIAL_PORT = "/dev/ttyUSB1" +BAUD_RATE = 9800 + +# plotter config file +plotter_port="/dev/ttyUSB0" +plotter_save_file = f"/tmp/plotter_test.json" +plotter = None # Global plotter variable +plotterBackend = None +plotter_total_steps = None + +# faultyCat variables +REPEAT = 1 +DELAY = 0 + +# output file variable +results_file = f"/tmp/glitching_results.csv" +results_png = "/tmp/glitching_results.png" + +### +# name, enabled, string to match in output, function to run +# if string is blank ("") doesnt show toggle, just run button +### +conditions = [ + ["TgOff", False, "", "control_device_off"], + ["TgOn", False, "", "control_device_on"], + ["Pass", False, "", "send_password"], + ["RdBuf", False, "", "read_buffer"], + ["CtSts", False, "WillNeverMatch01", "faulty_arm_disarm"], + ["CtZap", False, "", "faulty_pulse"], + ["Plt01", False, "", "setup_plotter"], + ["PlSta", False, "", "plotter_move_start"], + ["PlEnd", False, "", "plotter_move_end"], + ["Run", False, "", "start_testing"], +] + +results = [] + +### +# Generic Functions +### + +def send_password(): + functions.send_uart_message("test") + functions.flush_uart_buffer() + functions.add_text("[sending password]") + +def read_buffer(): + buffer = functions.read_uart_buffer() + functions.add_text("[buffer]: " + buffer) + +def check_buffer(): + buf = functions.read_uart_buffer() + if "Incorrect password: test" in buf: + return 0 + if re.search(r"Generated password: [A-Za-z0-9]{8}\b", buf): + return 1 + return 2 + +### +# ESP32 Power Supply Relay +### + +def control_device_on(): + functions.add_text("[turning on device]") + try: + return requests.get(f"http://192.168.0.122/off", timeout=5).text + except Exception as e: + return f"Error: {e}" + +def control_device_off(): + functions.add_text("[turning off device]") + try: + return requests.get(f"http://192.168.0.122/on", timeout=5).text + except Exception as e: + return f"Error: {e}" + +### +# faultyCat +### + +def faulty_arm_disarm(): + TriggersStatus = functions.get_condition_value(4) + if TriggersStatus is True: + functions.add_text("[FaultyCat disarming]") + functions.faulty_disarm() + functions.set_condition_value(4, False) + else: + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + +def faulty_pulse(): + functions.faulty_send_pulse() + functions.add_text("[FaultyCat sending pulse]") + + # Start a background thread that waits 1 second before disarming + def delayed_disarm(): + time.sleep(1) + faulty_arm_disarm() + + threading.Thread(target=delayed_disarm, daemon=True).start() + +def delayed_faulty_pulse(delay_ms): + time.sleep(delay_ms / 1000.0) # convert milliseconds to seconds + faulty_pulse() + +### +# plotter +### + +def setup_plotter(): + global plotter, plotterBackend, plotter_port, plotter_total_steps + plotter = Plotter(port=plotter_port) + plotterBackend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(plotter_save_file): + with open(plotter_save_file, "r") as f: + state = json.load(f) + plotterBackend.load_state(state) + plotter_total_steps = plotterBackend.get_total_steps() + functions.add_text(f"[total steps] - {plotterBackend.get_total_steps()}") + functions.add_text(f"[first place] - {plotterBackend.get_step_coords(1)}") + functions.add_text(f"[last place] - {plotterBackend.get_step_coords(plotter_total_steps)}") # {'x': 2.0, 'y': 2.0, 'z': 40.0} + functions.add_text(f"[cur place] - {plotterBackend.get_position()}") + +def plotter_move_start(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + cur = plotterBackend.get_position() + target = plotterBackend.get_step_coords(1) + functions.add_text(f"[moving to] - {target}") + if target: + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_start failed: {e}") + +def plotter_move_end(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + total_steps = plotterBackend.get_total_steps() + target = plotterBackend.get_step_coords(total_steps) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_end failed: {e}") + +def plotter_move_loc(position=1): + global plotter, plotterBackend, plotter_total_steps + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + target = plotterBackend.get_step_coords(position) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_loc failed: {e}") + +### +# main program +### +def start_testing(): + def worker(): + try: + global plotter_total_steps, results + setup_plotter() + loop = int(functions.get_config_value("repeat")) + time.sleep(1) + + # Initialise results array with a dummy at index 0 + results = [None] + [{"nothing": 0, "crash": 0, "glitch": 0, "x": 0.0, "y": 0.0, "z": 0.0} for _ in range(plotter_total_steps)] + + start_time = datetime.now() + functions.add_text(f"[start time] {start_time.strftime('%d/%m/%Y %H:%M')}") + + for i in range(loop): + for currentStep in range(1, plotter_total_steps + 1): + functions.add_text(f"[stats] loop {i +1}/{loop}, place {currentStep}/{plotter_total_steps}") + + # Get target coordinates + target = plotterBackend.get_step_coords(currentStep) + + # Store coordinates in results + results[currentStep]["x"] = target.get("x", 0.0) + results[currentStep]["y"] = target.get("y", 0.0) + results[currentStep]["z"] = target.get("z", 0.0) + + plotter_move_loc(currentStep) + + control_device_off() + functions.flush_uart_buffer() + time.sleep(1) + + control_device_on() + time.sleep(1) + + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + + time.sleep(1) + curDelay = functions.get_config_value("delay") + + functions.flush_uart_buffer() + threading.Thread(target=send_password, daemon=True).start() + threading.Thread(target=delayed_faulty_pulse, args=(curDelay,), daemon=True).start() + + time.sleep(2) + result = check_buffer() + functions.add_text(f"[result] {result}") + + # Update result counters + if result == 0: + results[currentStep]["nothing"] += 1 + elif result == 1: + results[currentStep]["crash"] += 1 + else: + results[currentStep]["glitch"] += 1 + + end_time = datetime.now() + functions.add_text(f"[end time] {end_time.strftime('%d/%m/%Y %H:%M')}") + + elapsed = end_time - start_time + # Format elapsed time including days + total_seconds = int(elapsed.total_seconds()) + days, remainder = divmod(total_seconds, 86400) # 86400 seconds in a day + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + functions.add_text(f"[time elapsed] {days}d {hours:02d}:{minutes:02d}:{seconds:02d}") + else: + functions.add_text(f"[time elapsed] {hours:02d}:{minutes:02d}:{seconds:02d}") + + save_results_to_csv() + print_results() + generate_ascii_map() + #find_best_glitch_spot() + save_results_map_png() + + except Exception as e: + functions.add_text(f"[error] Exception in start_testing: {e}") + + threading.Thread(target=worker, daemon=True).start() + +### +# results manipulation +### +def save_results_to_csv(): + global results, results_file + + if not results or len(results) <= 1: + functions.add_text("[error] No results to save.") + return + + try: + with open(results_file, mode="w", newline="") as csvfile: + fieldnames = ["Step", "Nothing", "Crash", "Glitch", "X", "Y", "Z"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for step in range(1, len(results)): + data = results[step] + writer.writerow({ + "Step": step, + "Nothing": data["nothing"], + "Crash": data["crash"], + "Glitch": data["glitch"], + "X": data["x"], + "Y": data["y"], + "Z": data["z"] + }) + functions.add_text(f"[results saved] {results_file}") + except Exception as e: + functions.add_text(f"[error] Failed to save results: {e}") + +def print_results(): + global results + for step in range(1, len(results)): + data = results[step] + functions.add_text( + f"[step {step}] nothing={data['nothing']} crash={data['crash']} glitch={data['glitch']} " + f"x={data['x']} y={data['y']} z={data['z']}" + ) + +def generate_ascii_map(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to map.") + return + + # Collect unique sorted coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) # top to bottom + + width = len(xs) + height = len(ys) + + # Map coordinates to grid indices + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + # Create empty grid + grid = [["" for _ in range(width)] for _ in range(height)] + + # Fill grid + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + symbol = str(min(data["glitch"], 9)) + elif data["crash"] > 0: + symbol = "-" + elif data["nothing"] > 0: + symbol = "." + else: + symbol = " " + + grid[gy][gx] = symbol + + # Convert grid to string + ascii_map = "\n".join("".join(row) for row in grid) + functions.add_text("[ASCII map]") + functions.add_text(ascii_map) + +def find_best_glitch_spot(): + """ + Return the step number most likely to cause a glitch. + Scoring includes: + - Weighted glitches (positive) + - Crashes (penalty) + - Nothing results (penalty) + - Edge/out-of-bounds treated as nothing (penalty) + """ + + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results available.") + return None + + # Collect unique coordinates + xs = sorted(set(float(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(float(results[i]["y"]) for i in range(1, len(results)))) + + # Compute minimal spacing + def _min_spacing(arr): + if len(arr) < 2: + return 1.0 + diffs = [round(arr[i+1] - arr[i], 8) for i in range(len(arr) - 1)] + diffs = [d for d in diffs if d > 1e-8] + return min(diffs) if diffs else 1.0 + + base = min(_min_spacing(xs), _min_spacing(ys)) or 1.0 + scale = int(round(1.0 / base)) if base > 0 else 1 + + # Map coordinates to step index + coord_to_step = {} + for i in range(1, len(results)): + gx = int(round(float(results[i]["x"]) * scale)) + gy = int(round(float(results[i]["y"]) * scale)) + coord_to_step[(gx, gy)] = i + + # Neighbourhood weights + centre_w = 4 + orth_w = 2 + diag_w = 1 + + # Penalties + crash_penalty = 2 + nothing_penalty = 1 + + best_step = None + best_score = -9999 + + for step in range(1, len(results)): + centre_glitches = int(results[step]["glitch"]) + if centre_glitches == 0: + continue # only consider glitch spots + + gx = int(round(float(results[step]["x"]) * scale)) + gy = int(round(float(results[step]["y"]) * scale)) + + score = 0 + + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + nx, ny = gx + dx, gy + dy + w = centre_w if dx == 0 and dy == 0 else (orth_w if dx == 0 or dy == 0 else diag_w) + idx = coord_to_step.get((nx, ny)) + + if idx is not None: + glitches = int(results[idx]["glitch"]) + crashes = int(results[idx]["crash"]) + nothing = int(results[idx]["nothing"]) + score += glitches * w + score -= crashes * crash_penalty + score -= nothing * nothing_penalty + else: + # Edge: treat as "nothing" + score -= nothing_penalty * w + + # Tie breaking rules + if (score > best_score or + (score == best_score and centre_glitches > int(results[best_step]["glitch"])) or + (score == best_score and centre_glitches == int(results[best_step]["glitch"]) and step < best_step)): + best_score = score + best_step = step + + if best_step: + functions.add_text( + f"[best glitch] position {best_step} glitches={results[best_step]['glitch']} " + f"crashes={results[best_step]['crash']} nothing={results[best_step]['nothing']} " + f"x={results[best_step]['x']} y={results[best_step]['y']} z={results[best_step]['z']} score={best_score}" + ) + return best_step + + functions.add_text("[best glitch] No glitch-prone positions found.") + return None + +def save_results_map_png(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to plot.") + return + + try: + # Collect sorted unique coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) + + width = len(xs) + height = len(ys) + + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + fig, ax = plt.subplots(figsize=(width, height), dpi=100) + ax.set_xlim(0, width) + ax.set_ylim(0, height) + ax.set_aspect("equal") + ax.axis("off") + + # Flip y-axis so that top-left in ASCII corresponds to bottom-left in PNG + ax.invert_yaxis() + + # Colour map for glitches (from #025218 to #6eff96) + glitch_cmap = mcolors.LinearSegmentedColormap.from_list("glitch_cmap", ["#025218", "#6eff96"]) + + # Draw squares + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + level = min(data["glitch"], 9) / 9.0 + color = glitch_cmap(level) + elif data["crash"] > 0: + color = "#ff0000" # red + elif data["nothing"] > 0: + color = "#3d3d3d" # grey + else: + color = "#000000" + + rect = patches.Rectangle( + (gx, gy), 1, 1, facecolor=color, edgecolor="black", linewidth=0.2 + ) + ax.add_patch(rect) + + # Highlight the best glitch spot with X + best_step = find_best_glitch_spot() + if best_step: + bx = x_to_idx[int(results[best_step]["x"])] + by = y_to_idx[int(results[best_step]["y"])] + + ax.plot([bx, bx + 1], [by, by + 1], color="black", linewidth=1.5) + ax.plot([bx, bx + 1], [by + 1, by], color="black", linewidth=1.5) + + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + plt.savefig(results_png, bbox_inches="tight", pad_inches=0) + plt.close(fig) # ensure figure is released + + functions.add_text(f"[results map saved] {results_png}") + + except Exception as e: + functions.add_text(f"[error] Failed to generate map: {e}") \ No newline at end of file diff --git a/Plotter/ConfigPlotterGen.py b/Plotter/ConfigPlotterGen.py new file mode 100644 index 0000000..5c7a696 --- /dev/null +++ b/Plotter/ConfigPlotterGen.py @@ -0,0 +1,546 @@ +###### +# LEAVE THESE IMPORTS! +###### +import functions +import random +import requests +import threading +import time +from datetime import datetime +import os +import re +import json +import csv +from textual.widgets import Log + +import sys +sys.path.append("/tmp") + +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend + +import matplotlib +matplotlib.use("Agg") # ensures headless backend (no GUI needed) +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import matplotlib.colors as mcolors + +###### +# config values (you can edit these to fit your environment and use case) +###### + +# Serial port settings +SERIAL_PORT = "/dev/ttyUSB1" +BAUD_RATE = 9800 + +# plotter config file +plotter_port="/dev/ttyUSB0" +plotter_save_file = f"/tmp/plotter_test.json" +plotter = None # Global plotter variable +plotterBackend = None +plotter_total_steps = None + +# faultyCat variables +REPEAT = 1 +DELAY = 0 + +# output file variable +results_file = f"/tmp/glitching_results.csv" +results_png = "/tmp/glitching_results.png" + +### +# name, enabled, string to match in output, function to run +# if string is blank ("") doesnt show toggle, just run button +### +conditions = [ + ["TgOff", False, "", "control_device_off"], + ["TgOn", False, "", "control_device_on"], + ["Pass", False, "", "send_password"], + ["RdBuf", False, "", "read_buffer"], + ["CtSts", False, "WillNeverMatch01", "faulty_arm_disarm"], + ["CtZap", False, "", "faulty_pulse"], + ["Plt01", False, "", "setup_plotter"], + ["PlSta", False, "", "plotter_move_start"], + ["PlEnd", False, "", "plotter_move_end"], + ["Run", False, "", "start_testing"], +] + +results = [] + +### +# Generic Functions +### + +def send_password(): + functions.send_uart_message("test") + functions.flush_uart_buffer() + functions.add_text("[sending password]") + +def read_buffer(): + buffer = functions.read_uart_buffer() + functions.add_text("[buffer]: " + buffer) + +def check_buffer(): + buf = functions.read_uart_buffer() + if "Incorrect password: test" in buf: + return 0 + if re.search(r"Generated password: [A-Za-z0-9]{8}\b", buf): + return 1 + return 2 + +### +# ESP32 Power Supply Relay +### + +def control_device_on(): + functions.add_text("[turning on device]") + try: + return requests.get(f"http://192.168.0.122/off", timeout=5).text + except Exception as e: + return f"Error: {e}" + +def control_device_off(): + functions.add_text("[turning off device]") + try: + return requests.get(f"http://192.168.0.122/on", timeout=5).text + except Exception as e: + return f"Error: {e}" + +### +# faultyCat +### + +def faulty_arm_disarm(): + TriggersStatus = functions.get_condition_value(4) + if TriggersStatus is True: + functions.add_text("[FaultyCat disarming]") + functions.faulty_disarm() + functions.set_condition_value(4, False) + else: + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + +def faulty_pulse(): + functions.faulty_send_pulse() + functions.add_text("[FaultyCat sending pulse]") + + # Start a background thread that waits 1 second before disarming + def delayed_disarm(): + time.sleep(1) + faulty_arm_disarm() + + threading.Thread(target=delayed_disarm, daemon=True).start() + +def delayed_faulty_pulse(delay_ms): + time.sleep(delay_ms / 1000.0) # convert milliseconds to seconds + faulty_pulse() + +### +# plotter +### + +def setup_plotter(): + global plotter, plotterBackend, plotter_port, plotter_total_steps + plotter = Plotter(port=plotter_port) + plotterBackend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(plotter_save_file): + with open(plotter_save_file, "r") as f: + state = json.load(f) + plotterBackend.load_state(state) + plotter_total_steps = plotterBackend.get_total_steps() + functions.add_text(f"[total steps] - {plotterBackend.get_total_steps()}") + functions.add_text(f"[first place] - {plotterBackend.get_step_coords(1)}") + functions.add_text(f"[last place] - {plotterBackend.get_step_coords(plotter_total_steps)}") # {'x': 2.0, 'y': 2.0, 'z': 40.0} + functions.add_text(f"[cur place] - {plotterBackend.get_position()}") + +def plotter_move_start(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + cur = plotterBackend.get_position() + target = plotterBackend.get_step_coords(1) + functions.add_text(f"[moving to] - {target}") + if target: + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_start failed: {e}") + +def plotter_move_end(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + total_steps = plotterBackend.get_total_steps() + target = plotterBackend.get_step_coords(total_steps) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_end failed: {e}") + +def plotter_move_loc(position=1): + global plotter, plotterBackend, plotter_total_steps + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + target = plotterBackend.get_step_coords(position) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_loc failed: {e}") + +### +# main program +### +def start_testing(): + def worker(): + try: + global plotter_total_steps, results + setup_plotter() + loop = int(functions.get_config_value("repeat")) + time.sleep(1) + + # Initialise results array with a dummy at index 0 + results = [None] + [{"nothing": 0, "crash": 0, "glitch": 0, "x": 0.0, "y": 0.0, "z": 0.0} for _ in range(plotter_total_steps)] + + start_time = datetime.now() + functions.add_text(f"[start time] {start_time.strftime('%d/%m/%Y %H:%M')}") + + for i in range(loop): + for currentStep in range(1, plotter_total_steps + 1): + functions.add_text(f"[stats] loop {i +1}/{loop}, place {currentStep}/{plotter_total_steps}") + + # Get target coordinates + target = plotterBackend.get_step_coords(currentStep) + + # Store coordinates in results + results[currentStep]["x"] = target.get("x", 0.0) + results[currentStep]["y"] = target.get("y", 0.0) + results[currentStep]["z"] = target.get("z", 0.0) + + plotter_move_loc(currentStep) + + control_device_off() + functions.flush_uart_buffer() + time.sleep(1) + + control_device_on() + time.sleep(1) + + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + + time.sleep(1) + curDelay = functions.get_config_value("delay") + + functions.flush_uart_buffer() + threading.Thread(target=send_password, daemon=True).start() + threading.Thread(target=delayed_faulty_pulse, args=(curDelay,), daemon=True).start() + + time.sleep(2) + result = check_buffer() + functions.add_text(f"[result] {result}") + + # Update result counters + if result == 0: + results[currentStep]["nothing"] += 1 + elif result == 1: + results[currentStep]["crash"] += 1 + else: + results[currentStep]["glitch"] += 1 + + end_time = datetime.now() + functions.add_text(f"[end time] {end_time.strftime('%d/%m/%Y %H:%M')}") + + elapsed = end_time - start_time + # Format elapsed time including days + total_seconds = int(elapsed.total_seconds()) + days, remainder = divmod(total_seconds, 86400) # 86400 seconds in a day + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + functions.add_text(f"[time elapsed] {days}d {hours:02d}:{minutes:02d}:{seconds:02d}") + else: + functions.add_text(f"[time elapsed] {hours:02d}:{minutes:02d}:{seconds:02d}") + + save_results_to_csv() + print_results() + generate_ascii_map() + #find_best_glitch_spot() + save_results_map_png() + + except Exception as e: + functions.add_text(f"[error] Exception in start_testing: {e}") + + threading.Thread(target=worker, daemon=True).start() + +### +# results manipulation +### +def save_results_to_csv(): + global results, results_file + + if not results or len(results) <= 1: + functions.add_text("[error] No results to save.") + return + + try: + with open(results_file, mode="w", newline="") as csvfile: + fieldnames = ["Step", "Nothing", "Crash", "Glitch", "X", "Y", "Z"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for step in range(1, len(results)): + data = results[step] + writer.writerow({ + "Step": step, + "Nothing": data["nothing"], + "Crash": data["crash"], + "Glitch": data["glitch"], + "X": data["x"], + "Y": data["y"], + "Z": data["z"] + }) + functions.add_text(f"[results saved] {results_file}") + except Exception as e: + functions.add_text(f"[error] Failed to save results: {e}") + +def print_results(): + global results + for step in range(1, len(results)): + data = results[step] + functions.add_text( + f"[step {step}] nothing={data['nothing']} crash={data['crash']} glitch={data['glitch']} " + f"x={data['x']} y={data['y']} z={data['z']}" + ) + +def generate_ascii_map(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to map.") + return + + # Collect unique sorted coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) # top to bottom + + width = len(xs) + height = len(ys) + + # Map coordinates to grid indices + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + # Create empty grid + grid = [["" for _ in range(width)] for _ in range(height)] + + # Fill grid + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + symbol = str(min(data["glitch"], 9)) + elif data["crash"] > 0: + symbol = "-" + elif data["nothing"] > 0: + symbol = "." + else: + symbol = " " + + grid[gy][gx] = symbol + + # Convert grid to string + ascii_map = "\n".join("".join(row) for row in grid) + functions.add_text("[ASCII map]") + functions.add_text(ascii_map) + +def find_best_glitch_spot(): + """ + Return the step number most likely to cause a glitch. + Scoring includes: + - Weighted glitches (positive) + - Crashes (penalty) + - Nothing results (penalty) + - Edge/out-of-bounds treated as nothing (penalty) + """ + + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results available.") + return None + + # Collect unique coordinates + xs = sorted(set(float(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(float(results[i]["y"]) for i in range(1, len(results)))) + + # Compute minimal spacing + def _min_spacing(arr): + if len(arr) < 2: + return 1.0 + diffs = [round(arr[i+1] - arr[i], 8) for i in range(len(arr) - 1)] + diffs = [d for d in diffs if d > 1e-8] + return min(diffs) if diffs else 1.0 + + base = min(_min_spacing(xs), _min_spacing(ys)) or 1.0 + scale = int(round(1.0 / base)) if base > 0 else 1 + + # Map coordinates to step index + coord_to_step = {} + for i in range(1, len(results)): + gx = int(round(float(results[i]["x"]) * scale)) + gy = int(round(float(results[i]["y"]) * scale)) + coord_to_step[(gx, gy)] = i + + # Neighbourhood weights + centre_w = 4 + orth_w = 2 + diag_w = 1 + + # Penalties + crash_penalty = 2 + nothing_penalty = 1 + + best_step = None + best_score = -9999 + + for step in range(1, len(results)): + centre_glitches = int(results[step]["glitch"]) + if centre_glitches == 0: + continue # only consider glitch spots + + gx = int(round(float(results[step]["x"]) * scale)) + gy = int(round(float(results[step]["y"]) * scale)) + + score = 0 + + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + nx, ny = gx + dx, gy + dy + w = centre_w if dx == 0 and dy == 0 else (orth_w if dx == 0 or dy == 0 else diag_w) + idx = coord_to_step.get((nx, ny)) + + if idx is not None: + glitches = int(results[idx]["glitch"]) + crashes = int(results[idx]["crash"]) + nothing = int(results[idx]["nothing"]) + score += glitches * w + score -= crashes * crash_penalty + score -= nothing * nothing_penalty + else: + # Edge: treat as "nothing" + score -= nothing_penalty * w + + # Tie breaking rules + if (score > best_score or + (score == best_score and centre_glitches > int(results[best_step]["glitch"])) or + (score == best_score and centre_glitches == int(results[best_step]["glitch"]) and step < best_step)): + best_score = score + best_step = step + + if best_step: + functions.add_text( + f"[best glitch] position {best_step} glitches={results[best_step]['glitch']} " + f"crashes={results[best_step]['crash']} nothing={results[best_step]['nothing']} " + f"x={results[best_step]['x']} y={results[best_step]['y']} z={results[best_step]['z']} score={best_score}" + ) + return best_step + + functions.add_text("[best glitch] No glitch-prone positions found.") + return None + +def save_results_map_png(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to plot.") + return + + try: + # Collect sorted unique coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) + + width = len(xs) + height = len(ys) + + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + fig, ax = plt.subplots(figsize=(width, height), dpi=100) + ax.set_xlim(0, width) + ax.set_ylim(0, height) + ax.set_aspect("equal") + ax.axis("off") + + # Flip y-axis so that top-left in ASCII corresponds to bottom-left in PNG + ax.invert_yaxis() + + # Colour map for glitches (from #025218 to #6eff96) + glitch_cmap = mcolors.LinearSegmentedColormap.from_list("glitch_cmap", ["#025218", "#6eff96"]) + + # Draw squares + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + level = min(data["glitch"], 9) / 9.0 + color = glitch_cmap(level) + elif data["crash"] > 0: + color = "#ff0000" # red + elif data["nothing"] > 0: + color = "#3d3d3d" # grey + else: + color = "#000000" + + rect = patches.Rectangle( + (gx, gy), 1, 1, facecolor=color, edgecolor="black", linewidth=0.2 + ) + ax.add_patch(rect) + + # Highlight the best glitch spot with X + best_step = find_best_glitch_spot() + if best_step: + bx = x_to_idx[int(results[best_step]["x"])] + by = y_to_idx[int(results[best_step]["y"])] + + ax.plot([bx, bx + 1], [by, by + 1], color="black", linewidth=1.5) + ax.plot([bx, bx + 1], [by + 1, by], color="black", linewidth=1.5) + + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + plt.savefig(results_png, bbox_inches="tight", pad_inches=0) + plt.close(fig) # ensure figure is released + + functions.add_text(f"[results map saved] {results_png}") + + except Exception as e: + functions.add_text(f"[error] Failed to generate map: {e}") \ No newline at end of file diff --git a/Plotter/plotter.py b/Plotter/plotter.py new file mode 100644 index 0000000..6bd9eae --- /dev/null +++ b/Plotter/plotter.py @@ -0,0 +1,120 @@ +#!/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() diff --git a/Plotter/ConfigPlotterGen.py b/Plotter/ConfigPlotterGen.py new file mode 100644 index 0000000..5c7a696 --- /dev/null +++ b/Plotter/ConfigPlotterGen.py @@ -0,0 +1,546 @@ +###### +# LEAVE THESE IMPORTS! +###### +import functions +import random +import requests +import threading +import time +from datetime import datetime +import os +import re +import json +import csv +from textual.widgets import Log + +import sys +sys.path.append("/tmp") + +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend + +import matplotlib +matplotlib.use("Agg") # ensures headless backend (no GUI needed) +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import matplotlib.colors as mcolors + +###### +# config values (you can edit these to fit your environment and use case) +###### + +# Serial port settings +SERIAL_PORT = "/dev/ttyUSB1" +BAUD_RATE = 9800 + +# plotter config file +plotter_port="/dev/ttyUSB0" +plotter_save_file = f"/tmp/plotter_test.json" +plotter = None # Global plotter variable +plotterBackend = None +plotter_total_steps = None + +# faultyCat variables +REPEAT = 1 +DELAY = 0 + +# output file variable +results_file = f"/tmp/glitching_results.csv" +results_png = "/tmp/glitching_results.png" + +### +# name, enabled, string to match in output, function to run +# if string is blank ("") doesnt show toggle, just run button +### +conditions = [ + ["TgOff", False, "", "control_device_off"], + ["TgOn", False, "", "control_device_on"], + ["Pass", False, "", "send_password"], + ["RdBuf", False, "", "read_buffer"], + ["CtSts", False, "WillNeverMatch01", "faulty_arm_disarm"], + ["CtZap", False, "", "faulty_pulse"], + ["Plt01", False, "", "setup_plotter"], + ["PlSta", False, "", "plotter_move_start"], + ["PlEnd", False, "", "plotter_move_end"], + ["Run", False, "", "start_testing"], +] + +results = [] + +### +# Generic Functions +### + +def send_password(): + functions.send_uart_message("test") + functions.flush_uart_buffer() + functions.add_text("[sending password]") + +def read_buffer(): + buffer = functions.read_uart_buffer() + functions.add_text("[buffer]: " + buffer) + +def check_buffer(): + buf = functions.read_uart_buffer() + if "Incorrect password: test" in buf: + return 0 + if re.search(r"Generated password: [A-Za-z0-9]{8}\b", buf): + return 1 + return 2 + +### +# ESP32 Power Supply Relay +### + +def control_device_on(): + functions.add_text("[turning on device]") + try: + return requests.get(f"http://192.168.0.122/off", timeout=5).text + except Exception as e: + return f"Error: {e}" + +def control_device_off(): + functions.add_text("[turning off device]") + try: + return requests.get(f"http://192.168.0.122/on", timeout=5).text + except Exception as e: + return f"Error: {e}" + +### +# faultyCat +### + +def faulty_arm_disarm(): + TriggersStatus = functions.get_condition_value(4) + if TriggersStatus is True: + functions.add_text("[FaultyCat disarming]") + functions.faulty_disarm() + functions.set_condition_value(4, False) + else: + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + +def faulty_pulse(): + functions.faulty_send_pulse() + functions.add_text("[FaultyCat sending pulse]") + + # Start a background thread that waits 1 second before disarming + def delayed_disarm(): + time.sleep(1) + faulty_arm_disarm() + + threading.Thread(target=delayed_disarm, daemon=True).start() + +def delayed_faulty_pulse(delay_ms): + time.sleep(delay_ms / 1000.0) # convert milliseconds to seconds + faulty_pulse() + +### +# plotter +### + +def setup_plotter(): + global plotter, plotterBackend, plotter_port, plotter_total_steps + plotter = Plotter(port=plotter_port) + plotterBackend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(plotter_save_file): + with open(plotter_save_file, "r") as f: + state = json.load(f) + plotterBackend.load_state(state) + plotter_total_steps = plotterBackend.get_total_steps() + functions.add_text(f"[total steps] - {plotterBackend.get_total_steps()}") + functions.add_text(f"[first place] - {plotterBackend.get_step_coords(1)}") + functions.add_text(f"[last place] - {plotterBackend.get_step_coords(plotter_total_steps)}") # {'x': 2.0, 'y': 2.0, 'z': 40.0} + functions.add_text(f"[cur place] - {plotterBackend.get_position()}") + +def plotter_move_start(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + cur = plotterBackend.get_position() + target = plotterBackend.get_step_coords(1) + functions.add_text(f"[moving to] - {target}") + if target: + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_start failed: {e}") + +def plotter_move_end(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + total_steps = plotterBackend.get_total_steps() + target = plotterBackend.get_step_coords(total_steps) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_end failed: {e}") + +def plotter_move_loc(position=1): + global plotter, plotterBackend, plotter_total_steps + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + target = plotterBackend.get_step_coords(position) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_loc failed: {e}") + +### +# main program +### +def start_testing(): + def worker(): + try: + global plotter_total_steps, results + setup_plotter() + loop = int(functions.get_config_value("repeat")) + time.sleep(1) + + # Initialise results array with a dummy at index 0 + results = [None] + [{"nothing": 0, "crash": 0, "glitch": 0, "x": 0.0, "y": 0.0, "z": 0.0} for _ in range(plotter_total_steps)] + + start_time = datetime.now() + functions.add_text(f"[start time] {start_time.strftime('%d/%m/%Y %H:%M')}") + + for i in range(loop): + for currentStep in range(1, plotter_total_steps + 1): + functions.add_text(f"[stats] loop {i +1}/{loop}, place {currentStep}/{plotter_total_steps}") + + # Get target coordinates + target = plotterBackend.get_step_coords(currentStep) + + # Store coordinates in results + results[currentStep]["x"] = target.get("x", 0.0) + results[currentStep]["y"] = target.get("y", 0.0) + results[currentStep]["z"] = target.get("z", 0.0) + + plotter_move_loc(currentStep) + + control_device_off() + functions.flush_uart_buffer() + time.sleep(1) + + control_device_on() + time.sleep(1) + + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + + time.sleep(1) + curDelay = functions.get_config_value("delay") + + functions.flush_uart_buffer() + threading.Thread(target=send_password, daemon=True).start() + threading.Thread(target=delayed_faulty_pulse, args=(curDelay,), daemon=True).start() + + time.sleep(2) + result = check_buffer() + functions.add_text(f"[result] {result}") + + # Update result counters + if result == 0: + results[currentStep]["nothing"] += 1 + elif result == 1: + results[currentStep]["crash"] += 1 + else: + results[currentStep]["glitch"] += 1 + + end_time = datetime.now() + functions.add_text(f"[end time] {end_time.strftime('%d/%m/%Y %H:%M')}") + + elapsed = end_time - start_time + # Format elapsed time including days + total_seconds = int(elapsed.total_seconds()) + days, remainder = divmod(total_seconds, 86400) # 86400 seconds in a day + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + functions.add_text(f"[time elapsed] {days}d {hours:02d}:{minutes:02d}:{seconds:02d}") + else: + functions.add_text(f"[time elapsed] {hours:02d}:{minutes:02d}:{seconds:02d}") + + save_results_to_csv() + print_results() + generate_ascii_map() + #find_best_glitch_spot() + save_results_map_png() + + except Exception as e: + functions.add_text(f"[error] Exception in start_testing: {e}") + + threading.Thread(target=worker, daemon=True).start() + +### +# results manipulation +### +def save_results_to_csv(): + global results, results_file + + if not results or len(results) <= 1: + functions.add_text("[error] No results to save.") + return + + try: + with open(results_file, mode="w", newline="") as csvfile: + fieldnames = ["Step", "Nothing", "Crash", "Glitch", "X", "Y", "Z"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for step in range(1, len(results)): + data = results[step] + writer.writerow({ + "Step": step, + "Nothing": data["nothing"], + "Crash": data["crash"], + "Glitch": data["glitch"], + "X": data["x"], + "Y": data["y"], + "Z": data["z"] + }) + functions.add_text(f"[results saved] {results_file}") + except Exception as e: + functions.add_text(f"[error] Failed to save results: {e}") + +def print_results(): + global results + for step in range(1, len(results)): + data = results[step] + functions.add_text( + f"[step {step}] nothing={data['nothing']} crash={data['crash']} glitch={data['glitch']} " + f"x={data['x']} y={data['y']} z={data['z']}" + ) + +def generate_ascii_map(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to map.") + return + + # Collect unique sorted coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) # top to bottom + + width = len(xs) + height = len(ys) + + # Map coordinates to grid indices + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + # Create empty grid + grid = [["" for _ in range(width)] for _ in range(height)] + + # Fill grid + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + symbol = str(min(data["glitch"], 9)) + elif data["crash"] > 0: + symbol = "-" + elif data["nothing"] > 0: + symbol = "." + else: + symbol = " " + + grid[gy][gx] = symbol + + # Convert grid to string + ascii_map = "\n".join("".join(row) for row in grid) + functions.add_text("[ASCII map]") + functions.add_text(ascii_map) + +def find_best_glitch_spot(): + """ + Return the step number most likely to cause a glitch. + Scoring includes: + - Weighted glitches (positive) + - Crashes (penalty) + - Nothing results (penalty) + - Edge/out-of-bounds treated as nothing (penalty) + """ + + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results available.") + return None + + # Collect unique coordinates + xs = sorted(set(float(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(float(results[i]["y"]) for i in range(1, len(results)))) + + # Compute minimal spacing + def _min_spacing(arr): + if len(arr) < 2: + return 1.0 + diffs = [round(arr[i+1] - arr[i], 8) for i in range(len(arr) - 1)] + diffs = [d for d in diffs if d > 1e-8] + return min(diffs) if diffs else 1.0 + + base = min(_min_spacing(xs), _min_spacing(ys)) or 1.0 + scale = int(round(1.0 / base)) if base > 0 else 1 + + # Map coordinates to step index + coord_to_step = {} + for i in range(1, len(results)): + gx = int(round(float(results[i]["x"]) * scale)) + gy = int(round(float(results[i]["y"]) * scale)) + coord_to_step[(gx, gy)] = i + + # Neighbourhood weights + centre_w = 4 + orth_w = 2 + diag_w = 1 + + # Penalties + crash_penalty = 2 + nothing_penalty = 1 + + best_step = None + best_score = -9999 + + for step in range(1, len(results)): + centre_glitches = int(results[step]["glitch"]) + if centre_glitches == 0: + continue # only consider glitch spots + + gx = int(round(float(results[step]["x"]) * scale)) + gy = int(round(float(results[step]["y"]) * scale)) + + score = 0 + + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + nx, ny = gx + dx, gy + dy + w = centre_w if dx == 0 and dy == 0 else (orth_w if dx == 0 or dy == 0 else diag_w) + idx = coord_to_step.get((nx, ny)) + + if idx is not None: + glitches = int(results[idx]["glitch"]) + crashes = int(results[idx]["crash"]) + nothing = int(results[idx]["nothing"]) + score += glitches * w + score -= crashes * crash_penalty + score -= nothing * nothing_penalty + else: + # Edge: treat as "nothing" + score -= nothing_penalty * w + + # Tie breaking rules + if (score > best_score or + (score == best_score and centre_glitches > int(results[best_step]["glitch"])) or + (score == best_score and centre_glitches == int(results[best_step]["glitch"]) and step < best_step)): + best_score = score + best_step = step + + if best_step: + functions.add_text( + f"[best glitch] position {best_step} glitches={results[best_step]['glitch']} " + f"crashes={results[best_step]['crash']} nothing={results[best_step]['nothing']} " + f"x={results[best_step]['x']} y={results[best_step]['y']} z={results[best_step]['z']} score={best_score}" + ) + return best_step + + functions.add_text("[best glitch] No glitch-prone positions found.") + return None + +def save_results_map_png(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to plot.") + return + + try: + # Collect sorted unique coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) + + width = len(xs) + height = len(ys) + + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + fig, ax = plt.subplots(figsize=(width, height), dpi=100) + ax.set_xlim(0, width) + ax.set_ylim(0, height) + ax.set_aspect("equal") + ax.axis("off") + + # Flip y-axis so that top-left in ASCII corresponds to bottom-left in PNG + ax.invert_yaxis() + + # Colour map for glitches (from #025218 to #6eff96) + glitch_cmap = mcolors.LinearSegmentedColormap.from_list("glitch_cmap", ["#025218", "#6eff96"]) + + # Draw squares + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + level = min(data["glitch"], 9) / 9.0 + color = glitch_cmap(level) + elif data["crash"] > 0: + color = "#ff0000" # red + elif data["nothing"] > 0: + color = "#3d3d3d" # grey + else: + color = "#000000" + + rect = patches.Rectangle( + (gx, gy), 1, 1, facecolor=color, edgecolor="black", linewidth=0.2 + ) + ax.add_patch(rect) + + # Highlight the best glitch spot with X + best_step = find_best_glitch_spot() + if best_step: + bx = x_to_idx[int(results[best_step]["x"])] + by = y_to_idx[int(results[best_step]["y"])] + + ax.plot([bx, bx + 1], [by, by + 1], color="black", linewidth=1.5) + ax.plot([bx, bx + 1], [by + 1, by], color="black", linewidth=1.5) + + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + plt.savefig(results_png, bbox_inches="tight", pad_inches=0) + plt.close(fig) # ensure figure is released + + functions.add_text(f"[results map saved] {results_png}") + + except Exception as e: + functions.add_text(f"[error] Failed to generate map: {e}") \ No newline at end of file diff --git a/Plotter/plotter.py b/Plotter/plotter.py new file mode 100644 index 0000000..6bd9eae --- /dev/null +++ b/Plotter/plotter.py @@ -0,0 +1,120 @@ +#!/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() diff --git a/Plotter/plotterStepperMaker.py b/Plotter/plotterStepperMaker.py new file mode 100644 index 0000000..917f209 --- /dev/null +++ b/Plotter/plotterStepperMaker.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 + +import tkinter as tk +from tkinter import ttk +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend +import argparse +import time +import os +import json +import sys + + +class PlotterGUI: + def __init__(self, root, save_file): + self.root = root + self.root.title("Plotter Stepper Maker") + self.root.resizable(False, False) + + self.save_file = save_file + + # Backend connection + self.backend = PlotterBackend(Plotter()) + pos = self.backend.get_position() + + # Variables + self.step_size = tk.DoubleVar(value=1.0) + self.z_value = tk.DoubleVar(value=pos.get("z", 0.0)) + + self.x_step = tk.DoubleVar(value=1.0) + self.y_step = tk.DoubleVar(value=1.0) + self.z_step = tk.DoubleVar(value=1.0) + + self.point_a = {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + self.point_b = {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + + # --- GUI Layout --- + self._build_gui(pos) + self._center_window() + + # Load saved state if available + self._load_state() + + # Periodic updates + self._schedule_update() + + + + def _build_gui(self, pos): + top_frame = ttk.Frame(self.root, padding=8) + top_frame.grid(row=0, column=0, columnspan=3, sticky="ew") + self.coord_label = ttk.Label( + top_frame, + text=self._fmt_pos(pos), + font=("Arial", 12, "bold") + ) + self.coord_label.pack(side="left", padx=(0, 15)) + + self._build_move_controls() + self._build_z_controls() + self._build_step_controls() + self._build_ab_controls() + self._build_save_controls() + + # --- Build Frames --- + def _build_move_controls(self): + main_frame = ttk.Frame(self.root, padding=10) + main_frame.grid(row=1, column=0, sticky="nw") + move_frame = ttk.Frame(main_frame, padding=5, relief="groove") + move_frame.grid(row=0, column=0, padx=5, pady=5) + + ttk.Button(move_frame, text="↑", width=5, + command=lambda: self.backend.move(0, self.step_size.get())).grid(row=0, column=1, pady=3) + ttk.Button(move_frame, text="←", width=5, + command=lambda: self.backend.move(-self.step_size.get(), 0)).grid(row=1, column=0, padx=3) + ttk.Button(move_frame, text="Home", width=7, + command=self.backend.set_home).grid(row=1, column=1, padx=3) + ttk.Button(move_frame, text="→", width=5, + command=lambda: self.backend.move(self.step_size.get(), 0)).grid(row=1, column=2, padx=3) + ttk.Button(move_frame, text="↓", width=5, + command=lambda: self.backend.move(0, -self.step_size.get())).grid(row=2, column=1, pady=3) + + tk.Scale(main_frame, from_=0.1, to=10, resolution=0.1, + orient="horizontal", variable=self.step_size, + length=260, label="Step size (mm)", font=("Arial", 10)).grid(row=1, column=0, pady=(10, 0)) + + def _build_z_controls(self): + z_frame = ttk.Frame(self.root, padding=8, relief="groove") + z_frame.grid(row=0, rowspan=3, column=1, sticky="n", padx=5, pady=5) + + ttk.Label(z_frame, text="", font=("Arial", 10, "bold")).pack(side="top", pady=(0, 2)) + self.z_slider = tk.Scale( + z_frame, from_=0, to=40, orient="vertical", + variable=self.z_value, length=180, font=("Arial", 9) + ) + self.z_slider.pack(side="top", fill="y", padx=2, pady=2) + + ttk.Button( + z_frame, text="Set Z", + command=lambda: self.backend.set_z(self.z_value.get()) + ).pack(side="top", pady=(5, 2)) + + def _build_step_controls(self): + step_frame = ttk.Frame(self.root, padding=8, relief="groove") + step_frame.grid(row=0, rowspan=3, column=2, sticky="n", padx=5, pady=5) + + ttk.Label(step_frame, text="X", font=("Arial", 10, "bold")).grid(row=0, column=0) + ttk.Label(step_frame, text="Y", font=("Arial", 10, "bold")).grid(row=0, column=1) + ttk.Label(step_frame, text="Z", font=("Arial", 10, "bold")).grid(row=0, column=2) + + self.x_step_slider = tk.Scale( + step_frame, from_=0.1, to=10, resolution=0.1, + orient="vertical", variable=self.x_step, + length=180, font=("Arial", 9) + ) + self.x_step_slider.grid(row=1, column=0, padx=2, pady=2) + + self.y_step_slider = tk.Scale( + step_frame, from_=0.1, to=10, resolution=0.1, + orient="vertical", variable=self.y_step, + length=180, font=("Arial", 9) + ) + self.y_step_slider.grid(row=1, column=1, padx=2, pady=2) + + self.z_step_slider = tk.Scale( + step_frame, from_=0, to=40, resolution=0.1, + orient="vertical", variable=self.z_step, + length=180, font=("Arial", 9) + ) + self.z_step_slider.grid(row=1, column=2, padx=2, pady=2) + + ttk.Button(step_frame, text="Save Step Values", command=self.save_steps).grid( + row=2, column=0, columnspan=3, pady=(5, 0) + ) + + def _build_ab_controls(self): + ab_frame = ttk.Frame(self.root, padding=10, relief="groove") + ab_frame.grid(row=3, column=0, columnspan=3, sticky="ew", pady=10) + + ttk.Button(ab_frame, text="Set A", width=8, command=self.set_a).grid(row=0, column=0, padx=5) + ttk.Button(ab_frame, text="Go A", width=8, command=self.backend.go_a).grid(row=0, column=1, padx=5) + ttk.Button(ab_frame, text="Set B", width=8, command=self.set_b).grid(row=0, column=2, padx=5) + ttk.Button(ab_frame, text="Go B", width=8, command=self.backend.go_b).grid(row=0, column=4, padx=5) + ttk.Button(ab_frame, text="Go 1", width=8, command=self.backend.go_one).grid(row=0, column=5, padx=5) + + tk.Label(ab_frame, text="", font=("Arial", 10, "bold")).grid(row=2, column=0, sticky="w") + self.point_a_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + + tk.Label(ab_frame, text="A:", font=("Arial", 10, "bold")).grid(row=3, column=0, sticky="w") + self.point_a_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + self.point_a_value.grid(row=3, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="B:", font=("Arial", 10, "bold")).grid(row=4, column=0, sticky="w") + self.point_b_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + self.point_b_value.grid(row=4, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Steps:", font=("Arial", 10, "bold")).grid(row=5, column=0, sticky="w") + self.step_values_label = tk.Label(ab_frame, text="X=0.0, Y=0.0, Z=0.0", font=("Arial", 10)) + self.step_values_label.grid(row=5, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Step count:", font=("Arial", 10, "bold")).grid(row=6, column=0, sticky="w") + self.step_count_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.step_count_label.grid(row=6, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Current step:", font=("Arial", 10, "bold")).grid(row=7, column=0, sticky="w") + self.current_step_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.current_step_label.grid(row=7, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Next step:", font=("Arial", 10, "bold")).grid(row=8, column=0, sticky="w") + self.next_step_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.next_step_label.grid(row=8, column=1, columnspan=3, sticky="w") + + def _build_save_controls(self): + save_frame = ttk.Frame(self.root, padding=8) + save_frame.grid(row=4, column=0, columnspan=3, sticky="ew") + + tk.Label(save_frame, text="Save file:", font=("Arial", 10, "bold")).pack(side="left", padx=(0, 5)) + self.file_entry = ttk.Entry(save_frame, width=50) + self.file_entry.insert(0, self.save_file) + self.file_entry.pack(side="left", fill="x", expand=True) + ttk.Button(save_frame, text="Save", command=self._save_state).pack(side="left", padx=(5, 0)) + + # --- State management --- + def _save_state(self): + # prefer GUI-stored values, fall back to backend values, then sensible defaults + point_a = getattr(self, "point_a", None) or getattr(self.backend, "point_a", None) or {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + point_b = getattr(self, "point_b", None) or getattr(self.backend, "point_b", None) or {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + + state = { + "point_a": point_a, + "point_b": point_b, + "steps": { + "x": float(self.x_step.get()), + "y": float(self.y_step.get()), + "z": float(self.z_step.get()) + } + } + + with open(self.file_entry.get(), "w") as f: + json.dump(state, f, indent=2) + + def _load_state(self, filename=None): + if filename is None: + filename = self.save_file + + try: + with open(filename, "r") as f: + state = json.load(f) + except (OSError, json.JSONDecodeError): + return + + # Restore A + if "point_a" in state: + self.point_a = state["point_a"] + self.point_a_value.config( + text=f"X={self.point_a['x']:+07.2f}, Y={self.point_a['y']:+07.2f}, Z={self.point_a['z']:+07.2f}" + ) + # Restore in backend + self.backend.point_a = dict(self.point_a) + + # Restore B + if "point_b" in state: + self.point_b = state["point_b"] + self.point_b_value.config( + text=f"X={self.point_b['x']:+07.2f}, Y={self.point_b['y']:+07.2f}, Z={self.point_b['z']:+07.2f}" + ) + # Restore in backend + self.backend.point_b = dict(self.point_b) + + # Restore step sliders + if "steps" in state: + self.x_step.set(state["steps"].get("x", 1.0)) + self.y_step.set(state["steps"].get("y", 1.0)) + self.z_step.set(state["steps"].get("z", 1.0)) + # Restore in backend + self.backend.set_step_sizes(self.x_step.get(), self.y_step.get(), self.z_step.get()) + + # Update frontend step display + self.step_values_label.config( + text=f"X={self.x_step.get():.1f}, Y={self.y_step.get():.1f}, Z={self.z_step.get():.1f}" + ) + # Update step info (current/next step) + self.update_step_info() + + + + + # --- Other GUI logic (unchanged from earlier split) --- + def save_steps(self): + self.backend.set_step_sizes(self.x_step.get(), self.y_step.get(), self.z_step.get()) + self.step_values_label.config( + text=f"X={self.x_step.get():.1f}, Y={self.y_step.get():.1f}, Z={self.z_step.get():.1f}" + ) + self.update_step_info() + + def set_a(self): + pos = self.backend.set_a() + if pos: + # store the coordinates in the GUI instance for saving later + self.point_a = {"x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos["z"])} + self.point_a_value.config(text=f"X={self.point_a['x']:+07.2f}, Y={self.point_a['y']:+07.2f}, Z={self.point_a['z']:+07.2f}") + self.update_step_info() + return pos + + def set_b(self): + pos = self.backend.set_b() + if pos: + self.point_b = {"x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos["z"])} + self.point_b_value.config(text=f"X={self.point_b['x']:+07.2f}, Y={self.point_b['y']:+07.2f}, Z={self.point_b['z']:+07.2f}") + self.update_step_info() + return pos + + def go_one(self): + first_coords = self.backend.go_one() + if first_coords: + self.update_step_info() + + def update_step_info(self): + current_step, total_steps, next_step_number, next_pos = self.backend.next_raster_step() + if current_step is None: + self.step_count_label.config(text="0") + self.current_step_label.config(text="N/A") + self.next_step_label.config(text="N/A") + return + self.step_count_label.config(text=f"{total_steps}") + self.current_step_label.config(text=f"{current_step}") + if current_step >= total_steps: + self.next_step_label.config(text="End reached") + else: + self.next_step_label.config( + text=f"{next_step_number}, X={next_pos['x']:+07.2f}, Y={next_pos['y']:+07.2f}, Z={next_pos['z']:+07.2f}" + ) + + def _schedule_update(self): + try: + pos = self.backend.get_position() + self.coord_label.config(text=self._fmt_pos(pos)) + self.update_step_info() + except Exception: + pass + self.root.after(500, self._schedule_update) + + def _fmt_pos(self, pos): + try: + return f"X: {pos['x']:+07.2f} Y: {pos['y']:+07.2f} Z: {pos['z']:+07.2f}" + except Exception: + return "X: +000.00 Y: +000.00 Z: +000.00" + + def _center_window(self): + self.root.update_idletasks() + w, h = self.root.winfo_width(), self.root.winfo_height() + sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight() + x, y = (sw // 2) - (w // 2), (sh // 2) - (h // 2) + self.root.geometry(f"+{x}+{y}") + + +class DummyPlotter: + def move_to(self, x, y, z=None): + return {"x": x, "y": y, "z": z} + def set_z(self, z): + return {"z": z} + def move_absolute(self, x=None, y=None, feed=1000): + # Simulate absolute move by updating state only + if x is not None: + self.state["x"] = float(x) + if y is not None: + self.state["y"] = float(y) + # Feed rate ignored in dummy + print(f"[DummyPlotter] move_absolute -> {self.state}") + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--file", help="Save file path") + parser.add_argument("-xyz", help="JSON position to check step number") + parser.add_argument("-n", type=int, help="Step number to get coordinates for") + parser.add_argument("-count", action="store_true", help="Return total number of steps in grid") + args = parser.parse_args() + + # Save file + if args.file: + save_file = args.file + else: + timestamp = int(time.time()) + save_file = f"/tmp/plotter_{timestamp}.json" + + # Use dummy if not starting GUI + plotter = DummyPlotter() + backend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(save_file): + with open(save_file, "r") as f: + state = json.load(f) + backend.load_state(state) + + if args.count and args.file: + with open(args.file, "r") as f: + state = json.load(f) + backend.load_state(state) + print(backend.get_total_steps()) + sys.exit(0) + + if args.xyz: + pos = json.loads(args.xyz) + step_no = backend.get_step_number(pos) + print(step_no) + sys.exit(0) + + if args.n is not None: + coords = backend.get_step_coords(args.n) + print(json.dumps(coords)) + sys.exit(0) + + # GUI mode + root = tk.Tk() + from plotterStepperMaker import PlotterGUI + app = PlotterGUI(root, save_file) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/Plotter/ConfigPlotterGen.py b/Plotter/ConfigPlotterGen.py new file mode 100644 index 0000000..5c7a696 --- /dev/null +++ b/Plotter/ConfigPlotterGen.py @@ -0,0 +1,546 @@ +###### +# LEAVE THESE IMPORTS! +###### +import functions +import random +import requests +import threading +import time +from datetime import datetime +import os +import re +import json +import csv +from textual.widgets import Log + +import sys +sys.path.append("/tmp") + +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend + +import matplotlib +matplotlib.use("Agg") # ensures headless backend (no GUI needed) +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import matplotlib.colors as mcolors + +###### +# config values (you can edit these to fit your environment and use case) +###### + +# Serial port settings +SERIAL_PORT = "/dev/ttyUSB1" +BAUD_RATE = 9800 + +# plotter config file +plotter_port="/dev/ttyUSB0" +plotter_save_file = f"/tmp/plotter_test.json" +plotter = None # Global plotter variable +plotterBackend = None +plotter_total_steps = None + +# faultyCat variables +REPEAT = 1 +DELAY = 0 + +# output file variable +results_file = f"/tmp/glitching_results.csv" +results_png = "/tmp/glitching_results.png" + +### +# name, enabled, string to match in output, function to run +# if string is blank ("") doesnt show toggle, just run button +### +conditions = [ + ["TgOff", False, "", "control_device_off"], + ["TgOn", False, "", "control_device_on"], + ["Pass", False, "", "send_password"], + ["RdBuf", False, "", "read_buffer"], + ["CtSts", False, "WillNeverMatch01", "faulty_arm_disarm"], + ["CtZap", False, "", "faulty_pulse"], + ["Plt01", False, "", "setup_plotter"], + ["PlSta", False, "", "plotter_move_start"], + ["PlEnd", False, "", "plotter_move_end"], + ["Run", False, "", "start_testing"], +] + +results = [] + +### +# Generic Functions +### + +def send_password(): + functions.send_uart_message("test") + functions.flush_uart_buffer() + functions.add_text("[sending password]") + +def read_buffer(): + buffer = functions.read_uart_buffer() + functions.add_text("[buffer]: " + buffer) + +def check_buffer(): + buf = functions.read_uart_buffer() + if "Incorrect password: test" in buf: + return 0 + if re.search(r"Generated password: [A-Za-z0-9]{8}\b", buf): + return 1 + return 2 + +### +# ESP32 Power Supply Relay +### + +def control_device_on(): + functions.add_text("[turning on device]") + try: + return requests.get(f"http://192.168.0.122/off", timeout=5).text + except Exception as e: + return f"Error: {e}" + +def control_device_off(): + functions.add_text("[turning off device]") + try: + return requests.get(f"http://192.168.0.122/on", timeout=5).text + except Exception as e: + return f"Error: {e}" + +### +# faultyCat +### + +def faulty_arm_disarm(): + TriggersStatus = functions.get_condition_value(4) + if TriggersStatus is True: + functions.add_text("[FaultyCat disarming]") + functions.faulty_disarm() + functions.set_condition_value(4, False) + else: + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + +def faulty_pulse(): + functions.faulty_send_pulse() + functions.add_text("[FaultyCat sending pulse]") + + # Start a background thread that waits 1 second before disarming + def delayed_disarm(): + time.sleep(1) + faulty_arm_disarm() + + threading.Thread(target=delayed_disarm, daemon=True).start() + +def delayed_faulty_pulse(delay_ms): + time.sleep(delay_ms / 1000.0) # convert milliseconds to seconds + faulty_pulse() + +### +# plotter +### + +def setup_plotter(): + global plotter, plotterBackend, plotter_port, plotter_total_steps + plotter = Plotter(port=plotter_port) + plotterBackend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(plotter_save_file): + with open(plotter_save_file, "r") as f: + state = json.load(f) + plotterBackend.load_state(state) + plotter_total_steps = plotterBackend.get_total_steps() + functions.add_text(f"[total steps] - {plotterBackend.get_total_steps()}") + functions.add_text(f"[first place] - {plotterBackend.get_step_coords(1)}") + functions.add_text(f"[last place] - {plotterBackend.get_step_coords(plotter_total_steps)}") # {'x': 2.0, 'y': 2.0, 'z': 40.0} + functions.add_text(f"[cur place] - {plotterBackend.get_position()}") + +def plotter_move_start(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + cur = plotterBackend.get_position() + target = plotterBackend.get_step_coords(1) + functions.add_text(f"[moving to] - {target}") + if target: + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_start failed: {e}") + +def plotter_move_end(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + total_steps = plotterBackend.get_total_steps() + target = plotterBackend.get_step_coords(total_steps) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_end failed: {e}") + +def plotter_move_loc(position=1): + global plotter, plotterBackend, plotter_total_steps + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + target = plotterBackend.get_step_coords(position) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_loc failed: {e}") + +### +# main program +### +def start_testing(): + def worker(): + try: + global plotter_total_steps, results + setup_plotter() + loop = int(functions.get_config_value("repeat")) + time.sleep(1) + + # Initialise results array with a dummy at index 0 + results = [None] + [{"nothing": 0, "crash": 0, "glitch": 0, "x": 0.0, "y": 0.0, "z": 0.0} for _ in range(plotter_total_steps)] + + start_time = datetime.now() + functions.add_text(f"[start time] {start_time.strftime('%d/%m/%Y %H:%M')}") + + for i in range(loop): + for currentStep in range(1, plotter_total_steps + 1): + functions.add_text(f"[stats] loop {i +1}/{loop}, place {currentStep}/{plotter_total_steps}") + + # Get target coordinates + target = plotterBackend.get_step_coords(currentStep) + + # Store coordinates in results + results[currentStep]["x"] = target.get("x", 0.0) + results[currentStep]["y"] = target.get("y", 0.0) + results[currentStep]["z"] = target.get("z", 0.0) + + plotter_move_loc(currentStep) + + control_device_off() + functions.flush_uart_buffer() + time.sleep(1) + + control_device_on() + time.sleep(1) + + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + + time.sleep(1) + curDelay = functions.get_config_value("delay") + + functions.flush_uart_buffer() + threading.Thread(target=send_password, daemon=True).start() + threading.Thread(target=delayed_faulty_pulse, args=(curDelay,), daemon=True).start() + + time.sleep(2) + result = check_buffer() + functions.add_text(f"[result] {result}") + + # Update result counters + if result == 0: + results[currentStep]["nothing"] += 1 + elif result == 1: + results[currentStep]["crash"] += 1 + else: + results[currentStep]["glitch"] += 1 + + end_time = datetime.now() + functions.add_text(f"[end time] {end_time.strftime('%d/%m/%Y %H:%M')}") + + elapsed = end_time - start_time + # Format elapsed time including days + total_seconds = int(elapsed.total_seconds()) + days, remainder = divmod(total_seconds, 86400) # 86400 seconds in a day + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + functions.add_text(f"[time elapsed] {days}d {hours:02d}:{minutes:02d}:{seconds:02d}") + else: + functions.add_text(f"[time elapsed] {hours:02d}:{minutes:02d}:{seconds:02d}") + + save_results_to_csv() + print_results() + generate_ascii_map() + #find_best_glitch_spot() + save_results_map_png() + + except Exception as e: + functions.add_text(f"[error] Exception in start_testing: {e}") + + threading.Thread(target=worker, daemon=True).start() + +### +# results manipulation +### +def save_results_to_csv(): + global results, results_file + + if not results or len(results) <= 1: + functions.add_text("[error] No results to save.") + return + + try: + with open(results_file, mode="w", newline="") as csvfile: + fieldnames = ["Step", "Nothing", "Crash", "Glitch", "X", "Y", "Z"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for step in range(1, len(results)): + data = results[step] + writer.writerow({ + "Step": step, + "Nothing": data["nothing"], + "Crash": data["crash"], + "Glitch": data["glitch"], + "X": data["x"], + "Y": data["y"], + "Z": data["z"] + }) + functions.add_text(f"[results saved] {results_file}") + except Exception as e: + functions.add_text(f"[error] Failed to save results: {e}") + +def print_results(): + global results + for step in range(1, len(results)): + data = results[step] + functions.add_text( + f"[step {step}] nothing={data['nothing']} crash={data['crash']} glitch={data['glitch']} " + f"x={data['x']} y={data['y']} z={data['z']}" + ) + +def generate_ascii_map(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to map.") + return + + # Collect unique sorted coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) # top to bottom + + width = len(xs) + height = len(ys) + + # Map coordinates to grid indices + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + # Create empty grid + grid = [["" for _ in range(width)] for _ in range(height)] + + # Fill grid + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + symbol = str(min(data["glitch"], 9)) + elif data["crash"] > 0: + symbol = "-" + elif data["nothing"] > 0: + symbol = "." + else: + symbol = " " + + grid[gy][gx] = symbol + + # Convert grid to string + ascii_map = "\n".join("".join(row) for row in grid) + functions.add_text("[ASCII map]") + functions.add_text(ascii_map) + +def find_best_glitch_spot(): + """ + Return the step number most likely to cause a glitch. + Scoring includes: + - Weighted glitches (positive) + - Crashes (penalty) + - Nothing results (penalty) + - Edge/out-of-bounds treated as nothing (penalty) + """ + + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results available.") + return None + + # Collect unique coordinates + xs = sorted(set(float(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(float(results[i]["y"]) for i in range(1, len(results)))) + + # Compute minimal spacing + def _min_spacing(arr): + if len(arr) < 2: + return 1.0 + diffs = [round(arr[i+1] - arr[i], 8) for i in range(len(arr) - 1)] + diffs = [d for d in diffs if d > 1e-8] + return min(diffs) if diffs else 1.0 + + base = min(_min_spacing(xs), _min_spacing(ys)) or 1.0 + scale = int(round(1.0 / base)) if base > 0 else 1 + + # Map coordinates to step index + coord_to_step = {} + for i in range(1, len(results)): + gx = int(round(float(results[i]["x"]) * scale)) + gy = int(round(float(results[i]["y"]) * scale)) + coord_to_step[(gx, gy)] = i + + # Neighbourhood weights + centre_w = 4 + orth_w = 2 + diag_w = 1 + + # Penalties + crash_penalty = 2 + nothing_penalty = 1 + + best_step = None + best_score = -9999 + + for step in range(1, len(results)): + centre_glitches = int(results[step]["glitch"]) + if centre_glitches == 0: + continue # only consider glitch spots + + gx = int(round(float(results[step]["x"]) * scale)) + gy = int(round(float(results[step]["y"]) * scale)) + + score = 0 + + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + nx, ny = gx + dx, gy + dy + w = centre_w if dx == 0 and dy == 0 else (orth_w if dx == 0 or dy == 0 else diag_w) + idx = coord_to_step.get((nx, ny)) + + if idx is not None: + glitches = int(results[idx]["glitch"]) + crashes = int(results[idx]["crash"]) + nothing = int(results[idx]["nothing"]) + score += glitches * w + score -= crashes * crash_penalty + score -= nothing * nothing_penalty + else: + # Edge: treat as "nothing" + score -= nothing_penalty * w + + # Tie breaking rules + if (score > best_score or + (score == best_score and centre_glitches > int(results[best_step]["glitch"])) or + (score == best_score and centre_glitches == int(results[best_step]["glitch"]) and step < best_step)): + best_score = score + best_step = step + + if best_step: + functions.add_text( + f"[best glitch] position {best_step} glitches={results[best_step]['glitch']} " + f"crashes={results[best_step]['crash']} nothing={results[best_step]['nothing']} " + f"x={results[best_step]['x']} y={results[best_step]['y']} z={results[best_step]['z']} score={best_score}" + ) + return best_step + + functions.add_text("[best glitch] No glitch-prone positions found.") + return None + +def save_results_map_png(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to plot.") + return + + try: + # Collect sorted unique coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) + + width = len(xs) + height = len(ys) + + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + fig, ax = plt.subplots(figsize=(width, height), dpi=100) + ax.set_xlim(0, width) + ax.set_ylim(0, height) + ax.set_aspect("equal") + ax.axis("off") + + # Flip y-axis so that top-left in ASCII corresponds to bottom-left in PNG + ax.invert_yaxis() + + # Colour map for glitches (from #025218 to #6eff96) + glitch_cmap = mcolors.LinearSegmentedColormap.from_list("glitch_cmap", ["#025218", "#6eff96"]) + + # Draw squares + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + level = min(data["glitch"], 9) / 9.0 + color = glitch_cmap(level) + elif data["crash"] > 0: + color = "#ff0000" # red + elif data["nothing"] > 0: + color = "#3d3d3d" # grey + else: + color = "#000000" + + rect = patches.Rectangle( + (gx, gy), 1, 1, facecolor=color, edgecolor="black", linewidth=0.2 + ) + ax.add_patch(rect) + + # Highlight the best glitch spot with X + best_step = find_best_glitch_spot() + if best_step: + bx = x_to_idx[int(results[best_step]["x"])] + by = y_to_idx[int(results[best_step]["y"])] + + ax.plot([bx, bx + 1], [by, by + 1], color="black", linewidth=1.5) + ax.plot([bx, bx + 1], [by + 1, by], color="black", linewidth=1.5) + + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + plt.savefig(results_png, bbox_inches="tight", pad_inches=0) + plt.close(fig) # ensure figure is released + + functions.add_text(f"[results map saved] {results_png}") + + except Exception as e: + functions.add_text(f"[error] Failed to generate map: {e}") \ No newline at end of file diff --git a/Plotter/plotter.py b/Plotter/plotter.py new file mode 100644 index 0000000..6bd9eae --- /dev/null +++ b/Plotter/plotter.py @@ -0,0 +1,120 @@ +#!/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() diff --git a/Plotter/plotterStepperMaker.py b/Plotter/plotterStepperMaker.py new file mode 100644 index 0000000..917f209 --- /dev/null +++ b/Plotter/plotterStepperMaker.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 + +import tkinter as tk +from tkinter import ttk +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend +import argparse +import time +import os +import json +import sys + + +class PlotterGUI: + def __init__(self, root, save_file): + self.root = root + self.root.title("Plotter Stepper Maker") + self.root.resizable(False, False) + + self.save_file = save_file + + # Backend connection + self.backend = PlotterBackend(Plotter()) + pos = self.backend.get_position() + + # Variables + self.step_size = tk.DoubleVar(value=1.0) + self.z_value = tk.DoubleVar(value=pos.get("z", 0.0)) + + self.x_step = tk.DoubleVar(value=1.0) + self.y_step = tk.DoubleVar(value=1.0) + self.z_step = tk.DoubleVar(value=1.0) + + self.point_a = {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + self.point_b = {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + + # --- GUI Layout --- + self._build_gui(pos) + self._center_window() + + # Load saved state if available + self._load_state() + + # Periodic updates + self._schedule_update() + + + + def _build_gui(self, pos): + top_frame = ttk.Frame(self.root, padding=8) + top_frame.grid(row=0, column=0, columnspan=3, sticky="ew") + self.coord_label = ttk.Label( + top_frame, + text=self._fmt_pos(pos), + font=("Arial", 12, "bold") + ) + self.coord_label.pack(side="left", padx=(0, 15)) + + self._build_move_controls() + self._build_z_controls() + self._build_step_controls() + self._build_ab_controls() + self._build_save_controls() + + # --- Build Frames --- + def _build_move_controls(self): + main_frame = ttk.Frame(self.root, padding=10) + main_frame.grid(row=1, column=0, sticky="nw") + move_frame = ttk.Frame(main_frame, padding=5, relief="groove") + move_frame.grid(row=0, column=0, padx=5, pady=5) + + ttk.Button(move_frame, text="↑", width=5, + command=lambda: self.backend.move(0, self.step_size.get())).grid(row=0, column=1, pady=3) + ttk.Button(move_frame, text="←", width=5, + command=lambda: self.backend.move(-self.step_size.get(), 0)).grid(row=1, column=0, padx=3) + ttk.Button(move_frame, text="Home", width=7, + command=self.backend.set_home).grid(row=1, column=1, padx=3) + ttk.Button(move_frame, text="→", width=5, + command=lambda: self.backend.move(self.step_size.get(), 0)).grid(row=1, column=2, padx=3) + ttk.Button(move_frame, text="↓", width=5, + command=lambda: self.backend.move(0, -self.step_size.get())).grid(row=2, column=1, pady=3) + + tk.Scale(main_frame, from_=0.1, to=10, resolution=0.1, + orient="horizontal", variable=self.step_size, + length=260, label="Step size (mm)", font=("Arial", 10)).grid(row=1, column=0, pady=(10, 0)) + + def _build_z_controls(self): + z_frame = ttk.Frame(self.root, padding=8, relief="groove") + z_frame.grid(row=0, rowspan=3, column=1, sticky="n", padx=5, pady=5) + + ttk.Label(z_frame, text="", font=("Arial", 10, "bold")).pack(side="top", pady=(0, 2)) + self.z_slider = tk.Scale( + z_frame, from_=0, to=40, orient="vertical", + variable=self.z_value, length=180, font=("Arial", 9) + ) + self.z_slider.pack(side="top", fill="y", padx=2, pady=2) + + ttk.Button( + z_frame, text="Set Z", + command=lambda: self.backend.set_z(self.z_value.get()) + ).pack(side="top", pady=(5, 2)) + + def _build_step_controls(self): + step_frame = ttk.Frame(self.root, padding=8, relief="groove") + step_frame.grid(row=0, rowspan=3, column=2, sticky="n", padx=5, pady=5) + + ttk.Label(step_frame, text="X", font=("Arial", 10, "bold")).grid(row=0, column=0) + ttk.Label(step_frame, text="Y", font=("Arial", 10, "bold")).grid(row=0, column=1) + ttk.Label(step_frame, text="Z", font=("Arial", 10, "bold")).grid(row=0, column=2) + + self.x_step_slider = tk.Scale( + step_frame, from_=0.1, to=10, resolution=0.1, + orient="vertical", variable=self.x_step, + length=180, font=("Arial", 9) + ) + self.x_step_slider.grid(row=1, column=0, padx=2, pady=2) + + self.y_step_slider = tk.Scale( + step_frame, from_=0.1, to=10, resolution=0.1, + orient="vertical", variable=self.y_step, + length=180, font=("Arial", 9) + ) + self.y_step_slider.grid(row=1, column=1, padx=2, pady=2) + + self.z_step_slider = tk.Scale( + step_frame, from_=0, to=40, resolution=0.1, + orient="vertical", variable=self.z_step, + length=180, font=("Arial", 9) + ) + self.z_step_slider.grid(row=1, column=2, padx=2, pady=2) + + ttk.Button(step_frame, text="Save Step Values", command=self.save_steps).grid( + row=2, column=0, columnspan=3, pady=(5, 0) + ) + + def _build_ab_controls(self): + ab_frame = ttk.Frame(self.root, padding=10, relief="groove") + ab_frame.grid(row=3, column=0, columnspan=3, sticky="ew", pady=10) + + ttk.Button(ab_frame, text="Set A", width=8, command=self.set_a).grid(row=0, column=0, padx=5) + ttk.Button(ab_frame, text="Go A", width=8, command=self.backend.go_a).grid(row=0, column=1, padx=5) + ttk.Button(ab_frame, text="Set B", width=8, command=self.set_b).grid(row=0, column=2, padx=5) + ttk.Button(ab_frame, text="Go B", width=8, command=self.backend.go_b).grid(row=0, column=4, padx=5) + ttk.Button(ab_frame, text="Go 1", width=8, command=self.backend.go_one).grid(row=0, column=5, padx=5) + + tk.Label(ab_frame, text="", font=("Arial", 10, "bold")).grid(row=2, column=0, sticky="w") + self.point_a_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + + tk.Label(ab_frame, text="A:", font=("Arial", 10, "bold")).grid(row=3, column=0, sticky="w") + self.point_a_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + self.point_a_value.grid(row=3, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="B:", font=("Arial", 10, "bold")).grid(row=4, column=0, sticky="w") + self.point_b_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + self.point_b_value.grid(row=4, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Steps:", font=("Arial", 10, "bold")).grid(row=5, column=0, sticky="w") + self.step_values_label = tk.Label(ab_frame, text="X=0.0, Y=0.0, Z=0.0", font=("Arial", 10)) + self.step_values_label.grid(row=5, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Step count:", font=("Arial", 10, "bold")).grid(row=6, column=0, sticky="w") + self.step_count_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.step_count_label.grid(row=6, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Current step:", font=("Arial", 10, "bold")).grid(row=7, column=0, sticky="w") + self.current_step_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.current_step_label.grid(row=7, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Next step:", font=("Arial", 10, "bold")).grid(row=8, column=0, sticky="w") + self.next_step_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.next_step_label.grid(row=8, column=1, columnspan=3, sticky="w") + + def _build_save_controls(self): + save_frame = ttk.Frame(self.root, padding=8) + save_frame.grid(row=4, column=0, columnspan=3, sticky="ew") + + tk.Label(save_frame, text="Save file:", font=("Arial", 10, "bold")).pack(side="left", padx=(0, 5)) + self.file_entry = ttk.Entry(save_frame, width=50) + self.file_entry.insert(0, self.save_file) + self.file_entry.pack(side="left", fill="x", expand=True) + ttk.Button(save_frame, text="Save", command=self._save_state).pack(side="left", padx=(5, 0)) + + # --- State management --- + def _save_state(self): + # prefer GUI-stored values, fall back to backend values, then sensible defaults + point_a = getattr(self, "point_a", None) or getattr(self.backend, "point_a", None) or {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + point_b = getattr(self, "point_b", None) or getattr(self.backend, "point_b", None) or {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + + state = { + "point_a": point_a, + "point_b": point_b, + "steps": { + "x": float(self.x_step.get()), + "y": float(self.y_step.get()), + "z": float(self.z_step.get()) + } + } + + with open(self.file_entry.get(), "w") as f: + json.dump(state, f, indent=2) + + def _load_state(self, filename=None): + if filename is None: + filename = self.save_file + + try: + with open(filename, "r") as f: + state = json.load(f) + except (OSError, json.JSONDecodeError): + return + + # Restore A + if "point_a" in state: + self.point_a = state["point_a"] + self.point_a_value.config( + text=f"X={self.point_a['x']:+07.2f}, Y={self.point_a['y']:+07.2f}, Z={self.point_a['z']:+07.2f}" + ) + # Restore in backend + self.backend.point_a = dict(self.point_a) + + # Restore B + if "point_b" in state: + self.point_b = state["point_b"] + self.point_b_value.config( + text=f"X={self.point_b['x']:+07.2f}, Y={self.point_b['y']:+07.2f}, Z={self.point_b['z']:+07.2f}" + ) + # Restore in backend + self.backend.point_b = dict(self.point_b) + + # Restore step sliders + if "steps" in state: + self.x_step.set(state["steps"].get("x", 1.0)) + self.y_step.set(state["steps"].get("y", 1.0)) + self.z_step.set(state["steps"].get("z", 1.0)) + # Restore in backend + self.backend.set_step_sizes(self.x_step.get(), self.y_step.get(), self.z_step.get()) + + # Update frontend step display + self.step_values_label.config( + text=f"X={self.x_step.get():.1f}, Y={self.y_step.get():.1f}, Z={self.z_step.get():.1f}" + ) + # Update step info (current/next step) + self.update_step_info() + + + + + # --- Other GUI logic (unchanged from earlier split) --- + def save_steps(self): + self.backend.set_step_sizes(self.x_step.get(), self.y_step.get(), self.z_step.get()) + self.step_values_label.config( + text=f"X={self.x_step.get():.1f}, Y={self.y_step.get():.1f}, Z={self.z_step.get():.1f}" + ) + self.update_step_info() + + def set_a(self): + pos = self.backend.set_a() + if pos: + # store the coordinates in the GUI instance for saving later + self.point_a = {"x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos["z"])} + self.point_a_value.config(text=f"X={self.point_a['x']:+07.2f}, Y={self.point_a['y']:+07.2f}, Z={self.point_a['z']:+07.2f}") + self.update_step_info() + return pos + + def set_b(self): + pos = self.backend.set_b() + if pos: + self.point_b = {"x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos["z"])} + self.point_b_value.config(text=f"X={self.point_b['x']:+07.2f}, Y={self.point_b['y']:+07.2f}, Z={self.point_b['z']:+07.2f}") + self.update_step_info() + return pos + + def go_one(self): + first_coords = self.backend.go_one() + if first_coords: + self.update_step_info() + + def update_step_info(self): + current_step, total_steps, next_step_number, next_pos = self.backend.next_raster_step() + if current_step is None: + self.step_count_label.config(text="0") + self.current_step_label.config(text="N/A") + self.next_step_label.config(text="N/A") + return + self.step_count_label.config(text=f"{total_steps}") + self.current_step_label.config(text=f"{current_step}") + if current_step >= total_steps: + self.next_step_label.config(text="End reached") + else: + self.next_step_label.config( + text=f"{next_step_number}, X={next_pos['x']:+07.2f}, Y={next_pos['y']:+07.2f}, Z={next_pos['z']:+07.2f}" + ) + + def _schedule_update(self): + try: + pos = self.backend.get_position() + self.coord_label.config(text=self._fmt_pos(pos)) + self.update_step_info() + except Exception: + pass + self.root.after(500, self._schedule_update) + + def _fmt_pos(self, pos): + try: + return f"X: {pos['x']:+07.2f} Y: {pos['y']:+07.2f} Z: {pos['z']:+07.2f}" + except Exception: + return "X: +000.00 Y: +000.00 Z: +000.00" + + def _center_window(self): + self.root.update_idletasks() + w, h = self.root.winfo_width(), self.root.winfo_height() + sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight() + x, y = (sw // 2) - (w // 2), (sh // 2) - (h // 2) + self.root.geometry(f"+{x}+{y}") + + +class DummyPlotter: + def move_to(self, x, y, z=None): + return {"x": x, "y": y, "z": z} + def set_z(self, z): + return {"z": z} + def move_absolute(self, x=None, y=None, feed=1000): + # Simulate absolute move by updating state only + if x is not None: + self.state["x"] = float(x) + if y is not None: + self.state["y"] = float(y) + # Feed rate ignored in dummy + print(f"[DummyPlotter] move_absolute -> {self.state}") + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--file", help="Save file path") + parser.add_argument("-xyz", help="JSON position to check step number") + parser.add_argument("-n", type=int, help="Step number to get coordinates for") + parser.add_argument("-count", action="store_true", help="Return total number of steps in grid") + args = parser.parse_args() + + # Save file + if args.file: + save_file = args.file + else: + timestamp = int(time.time()) + save_file = f"/tmp/plotter_{timestamp}.json" + + # Use dummy if not starting GUI + plotter = DummyPlotter() + backend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(save_file): + with open(save_file, "r") as f: + state = json.load(f) + backend.load_state(state) + + if args.count and args.file: + with open(args.file, "r") as f: + state = json.load(f) + backend.load_state(state) + print(backend.get_total_steps()) + sys.exit(0) + + if args.xyz: + pos = json.loads(args.xyz) + step_no = backend.get_step_number(pos) + print(step_no) + sys.exit(0) + + if args.n is not None: + coords = backend.get_step_coords(args.n) + print(json.dumps(coords)) + sys.exit(0) + + # GUI mode + root = tk.Tk() + from plotterStepperMaker import PlotterGUI + app = PlotterGUI(root, save_file) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/Plotter/plotterStepperMakerBackend.py b/Plotter/plotterStepperMakerBackend.py new file mode 100644 index 0000000..7f3a60e --- /dev/null +++ b/Plotter/plotterStepperMakerBackend.py @@ -0,0 +1,240 @@ +import json + +class PlotterBackend: + def __init__(self, plotter): + self.steps = {"x": 1.0, "y": 1.0, "z": 40.0} + self.step_count = 0 + self.current_step = 0 + + self.plotter = plotter + self.point_a = None + self.point_b = None + + # --- Plotter Actions --- + def move(self, x, y): + self.plotter.move(x=x, y=y) + + def set_home(self): + self.plotter.set_home() + + def set_z(self, z_value): + self.plotter.set_spindle(z_value) + + def get_position(self): + return self.plotter.get_position() + + # --- A/B Point Management --- + def set_a(self): + self.point_a = self.plotter.get_position() + return self.point_a + + def go_a(self): + if not self.point_a: + return + cur = self.plotter.get_position() + self.plotter.move( + x=self.point_a["x"] - cur["x"], + y=self.point_a["y"] - cur["y"] + ) + self.plotter.set_spindle(self.point_a["z"]) + + def set_b(self): + self.point_b = self.plotter.get_position() + return self.point_b + + def go_b(self): + if not self.point_b: + return + cur = self.plotter.get_position() + self.plotter.move( + x=self.point_b["x"] - cur["x"], + y=self.point_b["y"] - cur["y"] + ) + self.plotter.set_spindle(self.point_b["z"]) + + def go_one(self): + """ + Move the plotter to the first raster step (step 1) based on point A/B + and the step sizes. + """ + if not self.point_a or not self.point_b: + return None # Cannot move, points not defined + + # Compute first raster step coordinates + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + first_coords = { + "x": x_min, + "y": y_min, + "z": z_min + } + + # Move relative to current position + current = self.get_position() + dx = first_coords["x"] - current["x"] + dy = first_coords["y"] - current["y"] + dz = first_coords["z"] - current["z"] + + self.move(x=dx, y=dy) + self.plotter.set_spindle(first_coords["z"]) + + return first_coords + + # --- Step Management --- + def set_step_sizes(self, x, y, z): + self.steps["x"] = float(x) + self.steps["y"] = float(y) + self.steps["z"] = float(z) + + def update_step_count(self): + if not self.point_a or not self.point_b: + return 0 + + sx = max(self.steps["x"], 0.0001) + sy = max(self.steps["y"], 0.0001) + sz = max(self.steps["z"], 0.0001) + + dx = abs(self.point_b["x"] - self.point_a["x"]) + dy = abs(self.point_b["y"] - self.point_a["y"]) + dz = abs(self.point_b["z"] - self.point_a["z"]) + + nx = int(dx / sx) + 1 + ny = int(dy / sy) + 1 + nz = int(dz / sz) + 1 + + return nx * ny * nz + + def next_raster_step(self, current_pos=None): + if self.point_a is None or self.point_b is None: + return None, None, None, None + + sx, sy, sz = self.steps["x"], self.steps["y"], self.steps["z"] + + if current_pos is None: + current_pos = self.plotter.get_position() + cx, cy, cz = current_pos["x"], current_pos["y"], current_pos["z"] + + # Axis min/max + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + # Discrete positions + nx = max(1, int(round((x_max - x_min)/sx)) + 1) + ny = max(1, int(round((y_max - y_min)/sy)) + 1) + nz = max(1, int(round((z_max - z_min)/sz)) + 1) + total_steps = nx * ny * nz + + x_list = [x_min + i*sx for i in range(nx)] + y_list = [y_min + i*sy for i in range(ny)] + z_list = [z_min + i*sz for i in range(nz)] + + # Clamp current position to nearest grid index + ix = min(range(nx), key=lambda i: abs(x_list[i]-cx)) + iy = min(range(ny), key=lambda i: abs(y_list[i]-cy)) + iz = min(range(nz), key=lambda i: abs(z_list[i]-cz)) + + # Current step number + current_step = iz * (nx * ny) + iy * nx + ix + 1 + + # Compute next indices + next_ix = ix + 1 + next_iy = iy + next_iz = iz + + if next_ix >= nx: + next_ix = 0 + next_iy += 1 + if next_iy >= ny: + next_iy = 0 + next_iz += 1 + if next_iz >= nz: + next_iz = 0 # wrap to start + + # Next step number + next_step_number = next_iz * (nx * ny) + next_iy * nx + next_ix + 1 + + next_coords = { + "x": x_list[next_ix], + "y": y_list[next_iy], + "z": z_list[next_iz] + } + + return current_step, total_steps, next_step_number, next_coords + + def get_step_number(self, pos): + if self.point_a is None or self.point_b is None: + return None + + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + steps_x = int((x_max - x_min) / self.steps["x"]) + 1 + steps_y = int((y_max - y_min) / self.steps["y"]) + 1 + + ix = int(round((pos["x"] - x_min) / self.steps["x"])) + iy = int(round((pos["y"] - y_min) / self.steps["y"])) + iz = int(round((pos["z"] - z_min) / self.steps["z"])) + + return iz * (steps_x * steps_y) + iy * steps_x + ix + 1 + + def get_step_coords(self, step_no): + if self.point_a is None or self.point_b is None: + return None + + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + steps_x = int((x_max - x_min) / self.steps["x"]) + 1 + steps_y = int((y_max - y_min) / self.steps["y"]) + 1 + steps_z = int((z_max - z_min) / self.steps["z"]) + 1 + + total_steps = steps_x * steps_y * steps_z + if step_no < 1 or step_no > total_steps: + return None + + # Convert step number (1-based) to 3D indices + idx = step_no - 1 + ix = idx % steps_x + iy = (idx // steps_x) % steps_y + iz = idx // (steps_x * steps_y) + + x = x_min + ix * self.steps["x"] + y = y_min + iy * self.steps["y"] + z = z_min + iz * self.steps["z"] + + return {"x": x, "y": y, "z": z} + + def get_total_steps(self): + if self.point_a is None or self.point_b is None: + return 0 + + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + steps_x = int((x_max - x_min) / self.steps["x"]) + 1 + steps_y = int((y_max - y_min) / self.steps["y"]) + 1 + steps_z = int((z_max - z_min) / self.steps["z"]) + 1 + + return steps_x * steps_y * steps_z + + def save_state(self): + return { + "point_a": self.point_a, + "point_b": self.point_b, + "steps": self.steps, + "step_count": self.step_count, + "current_step": self.current_step, + } + + def load_state(self, state): + self.point_a = state.get("point_a") + self.point_b = state.get("point_b") + self.steps = state.get("steps", {"x": 1.0, "y": 1.0, "z": 40.0}) + self.step_count = state.get("step_count", 0) + self.current_step = state.get("current_step", 0) \ No newline at end of file diff --git a/Plotter/ConfigPlotterGen.py b/Plotter/ConfigPlotterGen.py new file mode 100644 index 0000000..5c7a696 --- /dev/null +++ b/Plotter/ConfigPlotterGen.py @@ -0,0 +1,546 @@ +###### +# LEAVE THESE IMPORTS! +###### +import functions +import random +import requests +import threading +import time +from datetime import datetime +import os +import re +import json +import csv +from textual.widgets import Log + +import sys +sys.path.append("/tmp") + +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend + +import matplotlib +matplotlib.use("Agg") # ensures headless backend (no GUI needed) +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import matplotlib.colors as mcolors + +###### +# config values (you can edit these to fit your environment and use case) +###### + +# Serial port settings +SERIAL_PORT = "/dev/ttyUSB1" +BAUD_RATE = 9800 + +# plotter config file +plotter_port="/dev/ttyUSB0" +plotter_save_file = f"/tmp/plotter_test.json" +plotter = None # Global plotter variable +plotterBackend = None +plotter_total_steps = None + +# faultyCat variables +REPEAT = 1 +DELAY = 0 + +# output file variable +results_file = f"/tmp/glitching_results.csv" +results_png = "/tmp/glitching_results.png" + +### +# name, enabled, string to match in output, function to run +# if string is blank ("") doesnt show toggle, just run button +### +conditions = [ + ["TgOff", False, "", "control_device_off"], + ["TgOn", False, "", "control_device_on"], + ["Pass", False, "", "send_password"], + ["RdBuf", False, "", "read_buffer"], + ["CtSts", False, "WillNeverMatch01", "faulty_arm_disarm"], + ["CtZap", False, "", "faulty_pulse"], + ["Plt01", False, "", "setup_plotter"], + ["PlSta", False, "", "plotter_move_start"], + ["PlEnd", False, "", "plotter_move_end"], + ["Run", False, "", "start_testing"], +] + +results = [] + +### +# Generic Functions +### + +def send_password(): + functions.send_uart_message("test") + functions.flush_uart_buffer() + functions.add_text("[sending password]") + +def read_buffer(): + buffer = functions.read_uart_buffer() + functions.add_text("[buffer]: " + buffer) + +def check_buffer(): + buf = functions.read_uart_buffer() + if "Incorrect password: test" in buf: + return 0 + if re.search(r"Generated password: [A-Za-z0-9]{8}\b", buf): + return 1 + return 2 + +### +# ESP32 Power Supply Relay +### + +def control_device_on(): + functions.add_text("[turning on device]") + try: + return requests.get(f"http://192.168.0.122/off", timeout=5).text + except Exception as e: + return f"Error: {e}" + +def control_device_off(): + functions.add_text("[turning off device]") + try: + return requests.get(f"http://192.168.0.122/on", timeout=5).text + except Exception as e: + return f"Error: {e}" + +### +# faultyCat +### + +def faulty_arm_disarm(): + TriggersStatus = functions.get_condition_value(4) + if TriggersStatus is True: + functions.add_text("[FaultyCat disarming]") + functions.faulty_disarm() + functions.set_condition_value(4, False) + else: + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + +def faulty_pulse(): + functions.faulty_send_pulse() + functions.add_text("[FaultyCat sending pulse]") + + # Start a background thread that waits 1 second before disarming + def delayed_disarm(): + time.sleep(1) + faulty_arm_disarm() + + threading.Thread(target=delayed_disarm, daemon=True).start() + +def delayed_faulty_pulse(delay_ms): + time.sleep(delay_ms / 1000.0) # convert milliseconds to seconds + faulty_pulse() + +### +# plotter +### + +def setup_plotter(): + global plotter, plotterBackend, plotter_port, plotter_total_steps + plotter = Plotter(port=plotter_port) + plotterBackend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(plotter_save_file): + with open(plotter_save_file, "r") as f: + state = json.load(f) + plotterBackend.load_state(state) + plotter_total_steps = plotterBackend.get_total_steps() + functions.add_text(f"[total steps] - {plotterBackend.get_total_steps()}") + functions.add_text(f"[first place] - {plotterBackend.get_step_coords(1)}") + functions.add_text(f"[last place] - {plotterBackend.get_step_coords(plotter_total_steps)}") # {'x': 2.0, 'y': 2.0, 'z': 40.0} + functions.add_text(f"[cur place] - {plotterBackend.get_position()}") + +def plotter_move_start(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + cur = plotterBackend.get_position() + target = plotterBackend.get_step_coords(1) + functions.add_text(f"[moving to] - {target}") + if target: + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_start failed: {e}") + +def plotter_move_end(): + global plotter, plotterBackend + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + total_steps = plotterBackend.get_total_steps() + target = plotterBackend.get_step_coords(total_steps) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_end failed: {e}") + +def plotter_move_loc(position=1): + global plotter, plotterBackend, plotter_total_steps + if not plotter: + functions.add_text("[ERROR] Plotter not initialised.") + return + try: + target = plotterBackend.get_step_coords(position) + functions.add_text(f"[moving to] - {target}") + if target: + cur = plotterBackend.get_position() + dx = float(target["x"]) - float(cur["x"]) + dy = float(target["y"]) - float(cur["y"]) + if abs(dx) > 1e-9 or abs(dy) > 1e-9: + plotter.move(x=dx, y=dy, feed=1000) + plotter.set_spindle(speed=target.get("z", 0.0)) + except Exception as e: + functions.add_text(f"[ERROR] plotter_move_loc failed: {e}") + +### +# main program +### +def start_testing(): + def worker(): + try: + global plotter_total_steps, results + setup_plotter() + loop = int(functions.get_config_value("repeat")) + time.sleep(1) + + # Initialise results array with a dummy at index 0 + results = [None] + [{"nothing": 0, "crash": 0, "glitch": 0, "x": 0.0, "y": 0.0, "z": 0.0} for _ in range(plotter_total_steps)] + + start_time = datetime.now() + functions.add_text(f"[start time] {start_time.strftime('%d/%m/%Y %H:%M')}") + + for i in range(loop): + for currentStep in range(1, plotter_total_steps + 1): + functions.add_text(f"[stats] loop {i +1}/{loop}, place {currentStep}/{plotter_total_steps}") + + # Get target coordinates + target = plotterBackend.get_step_coords(currentStep) + + # Store coordinates in results + results[currentStep]["x"] = target.get("x", 0.0) + results[currentStep]["y"] = target.get("y", 0.0) + results[currentStep]["z"] = target.get("z", 0.0) + + plotter_move_loc(currentStep) + + control_device_off() + functions.flush_uart_buffer() + time.sleep(1) + + control_device_on() + time.sleep(1) + + functions.add_text("[FaultyCat arming]") + if functions.faulty_connect() and functions.faulty_arm(): + functions.set_condition_value(4, True) + + time.sleep(1) + curDelay = functions.get_config_value("delay") + + functions.flush_uart_buffer() + threading.Thread(target=send_password, daemon=True).start() + threading.Thread(target=delayed_faulty_pulse, args=(curDelay,), daemon=True).start() + + time.sleep(2) + result = check_buffer() + functions.add_text(f"[result] {result}") + + # Update result counters + if result == 0: + results[currentStep]["nothing"] += 1 + elif result == 1: + results[currentStep]["crash"] += 1 + else: + results[currentStep]["glitch"] += 1 + + end_time = datetime.now() + functions.add_text(f"[end time] {end_time.strftime('%d/%m/%Y %H:%M')}") + + elapsed = end_time - start_time + # Format elapsed time including days + total_seconds = int(elapsed.total_seconds()) + days, remainder = divmod(total_seconds, 86400) # 86400 seconds in a day + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + functions.add_text(f"[time elapsed] {days}d {hours:02d}:{minutes:02d}:{seconds:02d}") + else: + functions.add_text(f"[time elapsed] {hours:02d}:{minutes:02d}:{seconds:02d}") + + save_results_to_csv() + print_results() + generate_ascii_map() + #find_best_glitch_spot() + save_results_map_png() + + except Exception as e: + functions.add_text(f"[error] Exception in start_testing: {e}") + + threading.Thread(target=worker, daemon=True).start() + +### +# results manipulation +### +def save_results_to_csv(): + global results, results_file + + if not results or len(results) <= 1: + functions.add_text("[error] No results to save.") + return + + try: + with open(results_file, mode="w", newline="") as csvfile: + fieldnames = ["Step", "Nothing", "Crash", "Glitch", "X", "Y", "Z"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for step in range(1, len(results)): + data = results[step] + writer.writerow({ + "Step": step, + "Nothing": data["nothing"], + "Crash": data["crash"], + "Glitch": data["glitch"], + "X": data["x"], + "Y": data["y"], + "Z": data["z"] + }) + functions.add_text(f"[results saved] {results_file}") + except Exception as e: + functions.add_text(f"[error] Failed to save results: {e}") + +def print_results(): + global results + for step in range(1, len(results)): + data = results[step] + functions.add_text( + f"[step {step}] nothing={data['nothing']} crash={data['crash']} glitch={data['glitch']} " + f"x={data['x']} y={data['y']} z={data['z']}" + ) + +def generate_ascii_map(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to map.") + return + + # Collect unique sorted coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) # top to bottom + + width = len(xs) + height = len(ys) + + # Map coordinates to grid indices + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + # Create empty grid + grid = [["" for _ in range(width)] for _ in range(height)] + + # Fill grid + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + symbol = str(min(data["glitch"], 9)) + elif data["crash"] > 0: + symbol = "-" + elif data["nothing"] > 0: + symbol = "." + else: + symbol = " " + + grid[gy][gx] = symbol + + # Convert grid to string + ascii_map = "\n".join("".join(row) for row in grid) + functions.add_text("[ASCII map]") + functions.add_text(ascii_map) + +def find_best_glitch_spot(): + """ + Return the step number most likely to cause a glitch. + Scoring includes: + - Weighted glitches (positive) + - Crashes (penalty) + - Nothing results (penalty) + - Edge/out-of-bounds treated as nothing (penalty) + """ + + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results available.") + return None + + # Collect unique coordinates + xs = sorted(set(float(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(float(results[i]["y"]) for i in range(1, len(results)))) + + # Compute minimal spacing + def _min_spacing(arr): + if len(arr) < 2: + return 1.0 + diffs = [round(arr[i+1] - arr[i], 8) for i in range(len(arr) - 1)] + diffs = [d for d in diffs if d > 1e-8] + return min(diffs) if diffs else 1.0 + + base = min(_min_spacing(xs), _min_spacing(ys)) or 1.0 + scale = int(round(1.0 / base)) if base > 0 else 1 + + # Map coordinates to step index + coord_to_step = {} + for i in range(1, len(results)): + gx = int(round(float(results[i]["x"]) * scale)) + gy = int(round(float(results[i]["y"]) * scale)) + coord_to_step[(gx, gy)] = i + + # Neighbourhood weights + centre_w = 4 + orth_w = 2 + diag_w = 1 + + # Penalties + crash_penalty = 2 + nothing_penalty = 1 + + best_step = None + best_score = -9999 + + for step in range(1, len(results)): + centre_glitches = int(results[step]["glitch"]) + if centre_glitches == 0: + continue # only consider glitch spots + + gx = int(round(float(results[step]["x"]) * scale)) + gy = int(round(float(results[step]["y"]) * scale)) + + score = 0 + + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + nx, ny = gx + dx, gy + dy + w = centre_w if dx == 0 and dy == 0 else (orth_w if dx == 0 or dy == 0 else diag_w) + idx = coord_to_step.get((nx, ny)) + + if idx is not None: + glitches = int(results[idx]["glitch"]) + crashes = int(results[idx]["crash"]) + nothing = int(results[idx]["nothing"]) + score += glitches * w + score -= crashes * crash_penalty + score -= nothing * nothing_penalty + else: + # Edge: treat as "nothing" + score -= nothing_penalty * w + + # Tie breaking rules + if (score > best_score or + (score == best_score and centre_glitches > int(results[best_step]["glitch"])) or + (score == best_score and centre_glitches == int(results[best_step]["glitch"]) and step < best_step)): + best_score = score + best_step = step + + if best_step: + functions.add_text( + f"[best glitch] position {best_step} glitches={results[best_step]['glitch']} " + f"crashes={results[best_step]['crash']} nothing={results[best_step]['nothing']} " + f"x={results[best_step]['x']} y={results[best_step]['y']} z={results[best_step]['z']} score={best_score}" + ) + return best_step + + functions.add_text("[best glitch] No glitch-prone positions found.") + return None + +def save_results_map_png(): + global results + + if not results or len(results) <= 1: + functions.add_text("[error] No results to plot.") + return + + try: + # Collect sorted unique coordinates + xs = sorted(set(int(results[i]["x"]) for i in range(1, len(results)))) + ys = sorted(set(int(results[i]["y"]) for i in range(1, len(results))), reverse=True) + + width = len(xs) + height = len(ys) + + x_to_idx = {x: idx for idx, x in enumerate(xs)} + y_to_idx = {y: idx for idx, y in enumerate(ys)} + + fig, ax = plt.subplots(figsize=(width, height), dpi=100) + ax.set_xlim(0, width) + ax.set_ylim(0, height) + ax.set_aspect("equal") + ax.axis("off") + + # Flip y-axis so that top-left in ASCII corresponds to bottom-left in PNG + ax.invert_yaxis() + + # Colour map for glitches (from #025218 to #6eff96) + glitch_cmap = mcolors.LinearSegmentedColormap.from_list("glitch_cmap", ["#025218", "#6eff96"]) + + # Draw squares + for step in range(1, len(results)): + data = results[step] + gx = x_to_idx[int(data["x"])] + gy = y_to_idx[int(data["y"])] + + if data["glitch"] > 0: + level = min(data["glitch"], 9) / 9.0 + color = glitch_cmap(level) + elif data["crash"] > 0: + color = "#ff0000" # red + elif data["nothing"] > 0: + color = "#3d3d3d" # grey + else: + color = "#000000" + + rect = patches.Rectangle( + (gx, gy), 1, 1, facecolor=color, edgecolor="black", linewidth=0.2 + ) + ax.add_patch(rect) + + # Highlight the best glitch spot with X + best_step = find_best_glitch_spot() + if best_step: + bx = x_to_idx[int(results[best_step]["x"])] + by = y_to_idx[int(results[best_step]["y"])] + + ax.plot([bx, bx + 1], [by, by + 1], color="black", linewidth=1.5) + ax.plot([bx, bx + 1], [by + 1, by], color="black", linewidth=1.5) + + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + plt.savefig(results_png, bbox_inches="tight", pad_inches=0) + plt.close(fig) # ensure figure is released + + functions.add_text(f"[results map saved] {results_png}") + + except Exception as e: + functions.add_text(f"[error] Failed to generate map: {e}") \ No newline at end of file diff --git a/Plotter/plotter.py b/Plotter/plotter.py new file mode 100644 index 0000000..6bd9eae --- /dev/null +++ b/Plotter/plotter.py @@ -0,0 +1,120 @@ +#!/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() diff --git a/Plotter/plotterStepperMaker.py b/Plotter/plotterStepperMaker.py new file mode 100644 index 0000000..917f209 --- /dev/null +++ b/Plotter/plotterStepperMaker.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 + +import tkinter as tk +from tkinter import ttk +from plotter import Plotter +from plotterStepperMakerBackend import PlotterBackend +import argparse +import time +import os +import json +import sys + + +class PlotterGUI: + def __init__(self, root, save_file): + self.root = root + self.root.title("Plotter Stepper Maker") + self.root.resizable(False, False) + + self.save_file = save_file + + # Backend connection + self.backend = PlotterBackend(Plotter()) + pos = self.backend.get_position() + + # Variables + self.step_size = tk.DoubleVar(value=1.0) + self.z_value = tk.DoubleVar(value=pos.get("z", 0.0)) + + self.x_step = tk.DoubleVar(value=1.0) + self.y_step = tk.DoubleVar(value=1.0) + self.z_step = tk.DoubleVar(value=1.0) + + self.point_a = {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + self.point_b = {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + + # --- GUI Layout --- + self._build_gui(pos) + self._center_window() + + # Load saved state if available + self._load_state() + + # Periodic updates + self._schedule_update() + + + + def _build_gui(self, pos): + top_frame = ttk.Frame(self.root, padding=8) + top_frame.grid(row=0, column=0, columnspan=3, sticky="ew") + self.coord_label = ttk.Label( + top_frame, + text=self._fmt_pos(pos), + font=("Arial", 12, "bold") + ) + self.coord_label.pack(side="left", padx=(0, 15)) + + self._build_move_controls() + self._build_z_controls() + self._build_step_controls() + self._build_ab_controls() + self._build_save_controls() + + # --- Build Frames --- + def _build_move_controls(self): + main_frame = ttk.Frame(self.root, padding=10) + main_frame.grid(row=1, column=0, sticky="nw") + move_frame = ttk.Frame(main_frame, padding=5, relief="groove") + move_frame.grid(row=0, column=0, padx=5, pady=5) + + ttk.Button(move_frame, text="↑", width=5, + command=lambda: self.backend.move(0, self.step_size.get())).grid(row=0, column=1, pady=3) + ttk.Button(move_frame, text="←", width=5, + command=lambda: self.backend.move(-self.step_size.get(), 0)).grid(row=1, column=0, padx=3) + ttk.Button(move_frame, text="Home", width=7, + command=self.backend.set_home).grid(row=1, column=1, padx=3) + ttk.Button(move_frame, text="→", width=5, + command=lambda: self.backend.move(self.step_size.get(), 0)).grid(row=1, column=2, padx=3) + ttk.Button(move_frame, text="↓", width=5, + command=lambda: self.backend.move(0, -self.step_size.get())).grid(row=2, column=1, pady=3) + + tk.Scale(main_frame, from_=0.1, to=10, resolution=0.1, + orient="horizontal", variable=self.step_size, + length=260, label="Step size (mm)", font=("Arial", 10)).grid(row=1, column=0, pady=(10, 0)) + + def _build_z_controls(self): + z_frame = ttk.Frame(self.root, padding=8, relief="groove") + z_frame.grid(row=0, rowspan=3, column=1, sticky="n", padx=5, pady=5) + + ttk.Label(z_frame, text="", font=("Arial", 10, "bold")).pack(side="top", pady=(0, 2)) + self.z_slider = tk.Scale( + z_frame, from_=0, to=40, orient="vertical", + variable=self.z_value, length=180, font=("Arial", 9) + ) + self.z_slider.pack(side="top", fill="y", padx=2, pady=2) + + ttk.Button( + z_frame, text="Set Z", + command=lambda: self.backend.set_z(self.z_value.get()) + ).pack(side="top", pady=(5, 2)) + + def _build_step_controls(self): + step_frame = ttk.Frame(self.root, padding=8, relief="groove") + step_frame.grid(row=0, rowspan=3, column=2, sticky="n", padx=5, pady=5) + + ttk.Label(step_frame, text="X", font=("Arial", 10, "bold")).grid(row=0, column=0) + ttk.Label(step_frame, text="Y", font=("Arial", 10, "bold")).grid(row=0, column=1) + ttk.Label(step_frame, text="Z", font=("Arial", 10, "bold")).grid(row=0, column=2) + + self.x_step_slider = tk.Scale( + step_frame, from_=0.1, to=10, resolution=0.1, + orient="vertical", variable=self.x_step, + length=180, font=("Arial", 9) + ) + self.x_step_slider.grid(row=1, column=0, padx=2, pady=2) + + self.y_step_slider = tk.Scale( + step_frame, from_=0.1, to=10, resolution=0.1, + orient="vertical", variable=self.y_step, + length=180, font=("Arial", 9) + ) + self.y_step_slider.grid(row=1, column=1, padx=2, pady=2) + + self.z_step_slider = tk.Scale( + step_frame, from_=0, to=40, resolution=0.1, + orient="vertical", variable=self.z_step, + length=180, font=("Arial", 9) + ) + self.z_step_slider.grid(row=1, column=2, padx=2, pady=2) + + ttk.Button(step_frame, text="Save Step Values", command=self.save_steps).grid( + row=2, column=0, columnspan=3, pady=(5, 0) + ) + + def _build_ab_controls(self): + ab_frame = ttk.Frame(self.root, padding=10, relief="groove") + ab_frame.grid(row=3, column=0, columnspan=3, sticky="ew", pady=10) + + ttk.Button(ab_frame, text="Set A", width=8, command=self.set_a).grid(row=0, column=0, padx=5) + ttk.Button(ab_frame, text="Go A", width=8, command=self.backend.go_a).grid(row=0, column=1, padx=5) + ttk.Button(ab_frame, text="Set B", width=8, command=self.set_b).grid(row=0, column=2, padx=5) + ttk.Button(ab_frame, text="Go B", width=8, command=self.backend.go_b).grid(row=0, column=4, padx=5) + ttk.Button(ab_frame, text="Go 1", width=8, command=self.backend.go_one).grid(row=0, column=5, padx=5) + + tk.Label(ab_frame, text="", font=("Arial", 10, "bold")).grid(row=2, column=0, sticky="w") + self.point_a_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + + tk.Label(ab_frame, text="A:", font=("Arial", 10, "bold")).grid(row=3, column=0, sticky="w") + self.point_a_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + self.point_a_value.grid(row=3, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="B:", font=("Arial", 10, "bold")).grid(row=4, column=0, sticky="w") + self.point_b_value = tk.Label(ab_frame, text="X=+000.00, Y=+000.00, Z=+000.00", font=("Arial", 10)) + self.point_b_value.grid(row=4, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Steps:", font=("Arial", 10, "bold")).grid(row=5, column=0, sticky="w") + self.step_values_label = tk.Label(ab_frame, text="X=0.0, Y=0.0, Z=0.0", font=("Arial", 10)) + self.step_values_label.grid(row=5, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Step count:", font=("Arial", 10, "bold")).grid(row=6, column=0, sticky="w") + self.step_count_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.step_count_label.grid(row=6, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Current step:", font=("Arial", 10, "bold")).grid(row=7, column=0, sticky="w") + self.current_step_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.current_step_label.grid(row=7, column=1, columnspan=3, sticky="w") + + tk.Label(ab_frame, text="Next step:", font=("Arial", 10, "bold")).grid(row=8, column=0, sticky="w") + self.next_step_label = tk.Label(ab_frame, text="0", font=("Arial", 10)) + self.next_step_label.grid(row=8, column=1, columnspan=3, sticky="w") + + def _build_save_controls(self): + save_frame = ttk.Frame(self.root, padding=8) + save_frame.grid(row=4, column=0, columnspan=3, sticky="ew") + + tk.Label(save_frame, text="Save file:", font=("Arial", 10, "bold")).pack(side="left", padx=(0, 5)) + self.file_entry = ttk.Entry(save_frame, width=50) + self.file_entry.insert(0, self.save_file) + self.file_entry.pack(side="left", fill="x", expand=True) + ttk.Button(save_frame, text="Save", command=self._save_state).pack(side="left", padx=(5, 0)) + + # --- State management --- + def _save_state(self): + # prefer GUI-stored values, fall back to backend values, then sensible defaults + point_a = getattr(self, "point_a", None) or getattr(self.backend, "point_a", None) or {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + point_b = getattr(self, "point_b", None) or getattr(self.backend, "point_b", None) or {"x": 0.0, "y": 0.0, "z": self.z_value.get()} + + state = { + "point_a": point_a, + "point_b": point_b, + "steps": { + "x": float(self.x_step.get()), + "y": float(self.y_step.get()), + "z": float(self.z_step.get()) + } + } + + with open(self.file_entry.get(), "w") as f: + json.dump(state, f, indent=2) + + def _load_state(self, filename=None): + if filename is None: + filename = self.save_file + + try: + with open(filename, "r") as f: + state = json.load(f) + except (OSError, json.JSONDecodeError): + return + + # Restore A + if "point_a" in state: + self.point_a = state["point_a"] + self.point_a_value.config( + text=f"X={self.point_a['x']:+07.2f}, Y={self.point_a['y']:+07.2f}, Z={self.point_a['z']:+07.2f}" + ) + # Restore in backend + self.backend.point_a = dict(self.point_a) + + # Restore B + if "point_b" in state: + self.point_b = state["point_b"] + self.point_b_value.config( + text=f"X={self.point_b['x']:+07.2f}, Y={self.point_b['y']:+07.2f}, Z={self.point_b['z']:+07.2f}" + ) + # Restore in backend + self.backend.point_b = dict(self.point_b) + + # Restore step sliders + if "steps" in state: + self.x_step.set(state["steps"].get("x", 1.0)) + self.y_step.set(state["steps"].get("y", 1.0)) + self.z_step.set(state["steps"].get("z", 1.0)) + # Restore in backend + self.backend.set_step_sizes(self.x_step.get(), self.y_step.get(), self.z_step.get()) + + # Update frontend step display + self.step_values_label.config( + text=f"X={self.x_step.get():.1f}, Y={self.y_step.get():.1f}, Z={self.z_step.get():.1f}" + ) + # Update step info (current/next step) + self.update_step_info() + + + + + # --- Other GUI logic (unchanged from earlier split) --- + def save_steps(self): + self.backend.set_step_sizes(self.x_step.get(), self.y_step.get(), self.z_step.get()) + self.step_values_label.config( + text=f"X={self.x_step.get():.1f}, Y={self.y_step.get():.1f}, Z={self.z_step.get():.1f}" + ) + self.update_step_info() + + def set_a(self): + pos = self.backend.set_a() + if pos: + # store the coordinates in the GUI instance for saving later + self.point_a = {"x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos["z"])} + self.point_a_value.config(text=f"X={self.point_a['x']:+07.2f}, Y={self.point_a['y']:+07.2f}, Z={self.point_a['z']:+07.2f}") + self.update_step_info() + return pos + + def set_b(self): + pos = self.backend.set_b() + if pos: + self.point_b = {"x": float(pos["x"]), "y": float(pos["y"]), "z": float(pos["z"])} + self.point_b_value.config(text=f"X={self.point_b['x']:+07.2f}, Y={self.point_b['y']:+07.2f}, Z={self.point_b['z']:+07.2f}") + self.update_step_info() + return pos + + def go_one(self): + first_coords = self.backend.go_one() + if first_coords: + self.update_step_info() + + def update_step_info(self): + current_step, total_steps, next_step_number, next_pos = self.backend.next_raster_step() + if current_step is None: + self.step_count_label.config(text="0") + self.current_step_label.config(text="N/A") + self.next_step_label.config(text="N/A") + return + self.step_count_label.config(text=f"{total_steps}") + self.current_step_label.config(text=f"{current_step}") + if current_step >= total_steps: + self.next_step_label.config(text="End reached") + else: + self.next_step_label.config( + text=f"{next_step_number}, X={next_pos['x']:+07.2f}, Y={next_pos['y']:+07.2f}, Z={next_pos['z']:+07.2f}" + ) + + def _schedule_update(self): + try: + pos = self.backend.get_position() + self.coord_label.config(text=self._fmt_pos(pos)) + self.update_step_info() + except Exception: + pass + self.root.after(500, self._schedule_update) + + def _fmt_pos(self, pos): + try: + return f"X: {pos['x']:+07.2f} Y: {pos['y']:+07.2f} Z: {pos['z']:+07.2f}" + except Exception: + return "X: +000.00 Y: +000.00 Z: +000.00" + + def _center_window(self): + self.root.update_idletasks() + w, h = self.root.winfo_width(), self.root.winfo_height() + sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight() + x, y = (sw // 2) - (w // 2), (sh // 2) - (h // 2) + self.root.geometry(f"+{x}+{y}") + + +class DummyPlotter: + def move_to(self, x, y, z=None): + return {"x": x, "y": y, "z": z} + def set_z(self, z): + return {"z": z} + def move_absolute(self, x=None, y=None, feed=1000): + # Simulate absolute move by updating state only + if x is not None: + self.state["x"] = float(x) + if y is not None: + self.state["y"] = float(y) + # Feed rate ignored in dummy + print(f"[DummyPlotter] move_absolute -> {self.state}") + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--file", help="Save file path") + parser.add_argument("-xyz", help="JSON position to check step number") + parser.add_argument("-n", type=int, help="Step number to get coordinates for") + parser.add_argument("-count", action="store_true", help="Return total number of steps in grid") + args = parser.parse_args() + + # Save file + if args.file: + save_file = args.file + else: + timestamp = int(time.time()) + save_file = f"/tmp/plotter_{timestamp}.json" + + # Use dummy if not starting GUI + plotter = DummyPlotter() + backend = PlotterBackend(plotter) + + # Load state if available + if os.path.exists(save_file): + with open(save_file, "r") as f: + state = json.load(f) + backend.load_state(state) + + if args.count and args.file: + with open(args.file, "r") as f: + state = json.load(f) + backend.load_state(state) + print(backend.get_total_steps()) + sys.exit(0) + + if args.xyz: + pos = json.loads(args.xyz) + step_no = backend.get_step_number(pos) + print(step_no) + sys.exit(0) + + if args.n is not None: + coords = backend.get_step_coords(args.n) + print(json.dumps(coords)) + sys.exit(0) + + # GUI mode + root = tk.Tk() + from plotterStepperMaker import PlotterGUI + app = PlotterGUI(root, save_file) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/Plotter/plotterStepperMakerBackend.py b/Plotter/plotterStepperMakerBackend.py new file mode 100644 index 0000000..7f3a60e --- /dev/null +++ b/Plotter/plotterStepperMakerBackend.py @@ -0,0 +1,240 @@ +import json + +class PlotterBackend: + def __init__(self, plotter): + self.steps = {"x": 1.0, "y": 1.0, "z": 40.0} + self.step_count = 0 + self.current_step = 0 + + self.plotter = plotter + self.point_a = None + self.point_b = None + + # --- Plotter Actions --- + def move(self, x, y): + self.plotter.move(x=x, y=y) + + def set_home(self): + self.plotter.set_home() + + def set_z(self, z_value): + self.plotter.set_spindle(z_value) + + def get_position(self): + return self.plotter.get_position() + + # --- A/B Point Management --- + def set_a(self): + self.point_a = self.plotter.get_position() + return self.point_a + + def go_a(self): + if not self.point_a: + return + cur = self.plotter.get_position() + self.plotter.move( + x=self.point_a["x"] - cur["x"], + y=self.point_a["y"] - cur["y"] + ) + self.plotter.set_spindle(self.point_a["z"]) + + def set_b(self): + self.point_b = self.plotter.get_position() + return self.point_b + + def go_b(self): + if not self.point_b: + return + cur = self.plotter.get_position() + self.plotter.move( + x=self.point_b["x"] - cur["x"], + y=self.point_b["y"] - cur["y"] + ) + self.plotter.set_spindle(self.point_b["z"]) + + def go_one(self): + """ + Move the plotter to the first raster step (step 1) based on point A/B + and the step sizes. + """ + if not self.point_a or not self.point_b: + return None # Cannot move, points not defined + + # Compute first raster step coordinates + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + first_coords = { + "x": x_min, + "y": y_min, + "z": z_min + } + + # Move relative to current position + current = self.get_position() + dx = first_coords["x"] - current["x"] + dy = first_coords["y"] - current["y"] + dz = first_coords["z"] - current["z"] + + self.move(x=dx, y=dy) + self.plotter.set_spindle(first_coords["z"]) + + return first_coords + + # --- Step Management --- + def set_step_sizes(self, x, y, z): + self.steps["x"] = float(x) + self.steps["y"] = float(y) + self.steps["z"] = float(z) + + def update_step_count(self): + if not self.point_a or not self.point_b: + return 0 + + sx = max(self.steps["x"], 0.0001) + sy = max(self.steps["y"], 0.0001) + sz = max(self.steps["z"], 0.0001) + + dx = abs(self.point_b["x"] - self.point_a["x"]) + dy = abs(self.point_b["y"] - self.point_a["y"]) + dz = abs(self.point_b["z"] - self.point_a["z"]) + + nx = int(dx / sx) + 1 + ny = int(dy / sy) + 1 + nz = int(dz / sz) + 1 + + return nx * ny * nz + + def next_raster_step(self, current_pos=None): + if self.point_a is None or self.point_b is None: + return None, None, None, None + + sx, sy, sz = self.steps["x"], self.steps["y"], self.steps["z"] + + if current_pos is None: + current_pos = self.plotter.get_position() + cx, cy, cz = current_pos["x"], current_pos["y"], current_pos["z"] + + # Axis min/max + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + # Discrete positions + nx = max(1, int(round((x_max - x_min)/sx)) + 1) + ny = max(1, int(round((y_max - y_min)/sy)) + 1) + nz = max(1, int(round((z_max - z_min)/sz)) + 1) + total_steps = nx * ny * nz + + x_list = [x_min + i*sx for i in range(nx)] + y_list = [y_min + i*sy for i in range(ny)] + z_list = [z_min + i*sz for i in range(nz)] + + # Clamp current position to nearest grid index + ix = min(range(nx), key=lambda i: abs(x_list[i]-cx)) + iy = min(range(ny), key=lambda i: abs(y_list[i]-cy)) + iz = min(range(nz), key=lambda i: abs(z_list[i]-cz)) + + # Current step number + current_step = iz * (nx * ny) + iy * nx + ix + 1 + + # Compute next indices + next_ix = ix + 1 + next_iy = iy + next_iz = iz + + if next_ix >= nx: + next_ix = 0 + next_iy += 1 + if next_iy >= ny: + next_iy = 0 + next_iz += 1 + if next_iz >= nz: + next_iz = 0 # wrap to start + + # Next step number + next_step_number = next_iz * (nx * ny) + next_iy * nx + next_ix + 1 + + next_coords = { + "x": x_list[next_ix], + "y": y_list[next_iy], + "z": z_list[next_iz] + } + + return current_step, total_steps, next_step_number, next_coords + + def get_step_number(self, pos): + if self.point_a is None or self.point_b is None: + return None + + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + steps_x = int((x_max - x_min) / self.steps["x"]) + 1 + steps_y = int((y_max - y_min) / self.steps["y"]) + 1 + + ix = int(round((pos["x"] - x_min) / self.steps["x"])) + iy = int(round((pos["y"] - y_min) / self.steps["y"])) + iz = int(round((pos["z"] - z_min) / self.steps["z"])) + + return iz * (steps_x * steps_y) + iy * steps_x + ix + 1 + + def get_step_coords(self, step_no): + if self.point_a is None or self.point_b is None: + return None + + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + steps_x = int((x_max - x_min) / self.steps["x"]) + 1 + steps_y = int((y_max - y_min) / self.steps["y"]) + 1 + steps_z = int((z_max - z_min) / self.steps["z"]) + 1 + + total_steps = steps_x * steps_y * steps_z + if step_no < 1 or step_no > total_steps: + return None + + # Convert step number (1-based) to 3D indices + idx = step_no - 1 + ix = idx % steps_x + iy = (idx // steps_x) % steps_y + iz = idx // (steps_x * steps_y) + + x = x_min + ix * self.steps["x"] + y = y_min + iy * self.steps["y"] + z = z_min + iz * self.steps["z"] + + return {"x": x, "y": y, "z": z} + + def get_total_steps(self): + if self.point_a is None or self.point_b is None: + return 0 + + x_min, x_max = sorted([self.point_a["x"], self.point_b["x"]]) + y_min, y_max = sorted([self.point_a["y"], self.point_b["y"]]) + z_min, z_max = sorted([self.point_a["z"], self.point_b["z"]]) + + steps_x = int((x_max - x_min) / self.steps["x"]) + 1 + steps_y = int((y_max - y_min) / self.steps["y"]) + 1 + steps_z = int((z_max - z_min) / self.steps["z"]) + 1 + + return steps_x * steps_y * steps_z + + def save_state(self): + return { + "point_a": self.point_a, + "point_b": self.point_b, + "steps": self.steps, + "step_count": self.step_count, + "current_step": self.current_step, + } + + def load_state(self, state): + self.point_a = state.get("point_a") + self.point_b = state.get("point_b") + self.steps = state.get("steps", {"x": 1.0, "y": 1.0, "z": 40.0}) + self.step_count = state.get("step_count", 0) + self.current_step = state.get("current_step", 0) \ No newline at end of file diff --git a/Plotter/plotter_gui.py b/Plotter/plotter_gui.py new file mode 100644 index 0000000..c30bdbd --- /dev/null +++ b/Plotter/plotter_gui.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +import tkinter as tk +from tkinter import ttk +import json +import os +import pyperclip +from plotter import Plotter, STATUS_FILE + +POS_FILE = os.path.expanduser("~/.plotter_gui_state.json") + + +class PlotterGUI: + def __init__(self, root): + self.root = root + self.root.title("Plotter Control") + self.root.resizable(False, False) + + # Add global padding + for i in range(7): + self.root.grid_rowconfigure(i, pad=5) + for i in range(5): + self.root.grid_columnconfigure(i, pad=5) + + self.plotter = Plotter() + + # Load saved state if available + saved_state = self.load_state() + + # Load saved positions + pos = self.plotter.get_position() + + # Step size slider value + self.step_size = tk.DoubleVar(value=saved_state.get("step_size", 1.0)) + + # Z slider value (spindle speed), fall back to saved state if available + self.z_value = tk.DoubleVar(value=saved_state.get("z_value", pos.get("z", 0.0))) + + # Coordinates display + self.coord_label = ttk.Label(root, text=self.format_position(pos)) + self.coord_label.grid(row=0, column=0, columnspan=4, pady=5) + + # Copy JSON button + self.copy_button = ttk.Button(root, text="Copy JSON", command=self.copy_position) + self.copy_button.grid(row=0, column=4, padx=5) + + # Clipboard feedback directly under Copy JSON button + self.feedback_label = ttk.Label(root, text="", foreground="green") + self.feedback_label.grid(row=1, column=4, pady=2) + + # Directional buttons + self.up_btn = ttk.Button(root, text="^", command=lambda: self.move(0, self.step_size.get())) + self.up_btn.grid(row=2, column=1) + + self.left_btn = ttk.Button(root, text="<", command=lambda: self.move(-self.step_size.get(), 0)) + self.left_btn.grid(row=3, column=0) + + self.home_btn = ttk.Button(root, text="Set Home", command=self.set_home) + self.home_btn.grid(row=3, column=1) + + self.right_btn = ttk.Button(root, text=">", command=lambda: self.move(self.step_size.get(), 0)) + self.right_btn.grid(row=3, column=2) + + self.down_btn = ttk.Button(root, text="V", command=lambda: self.move(0, -self.step_size.get())) + self.down_btn.grid(row=4, column=1) + + # Step size slider spans across the movement button area + self.step_slider = tk.Scale(root, from_=0.1, to=10, resolution=0.1, + orient="horizontal", variable=self.step_size, length=250) + self.step_slider.grid(row=5, column=0, columnspan=3, pady=5) + self.step_label = ttk.Label(root, text="Step size (mm)") + self.step_label.grid(row=6, column=0, columnspan=3) + + # Z slider (spindle speed) with 40 at bottom and 0 at top + self.z_slider = tk.Scale(root, from_=0, to=40, orient="vertical", variable=self.z_value) + self.z_slider.grid(row=2, column=4, rowspan=3, padx=10) + + self.set_z_btn = ttk.Button(root, text="Set Z", command=self.set_z) + self.set_z_btn.grid(row=5, column=4) + + # Restore last window position or centre + self.restore_window_position(saved_state) + self.root.protocol("WM_DELETE_WINDOW", self.on_close) + + # Update display + self.update_position() + + def format_position(self, pos): + return f"X: {pos['x']:.2f} Y: {pos['y']:.2f} Z: {pos['z']:.2f}" + + def update_position(self): + pos = self.plotter.get_position() + self.coord_label.config(text=self.format_position(pos)) + self.root.after(500, self.update_position) + + def move(self, x, y): + self.plotter.move(x=x, y=y) + self.update_position() + + def set_z(self): + z = self.z_value.get() + self.plotter.set_spindle(z) + self.update_position() + + def set_home(self): + self.plotter.set_home() + self.update_position() + + def copy_position(self): + pos = self.plotter.get_position() + json_str = json.dumps(pos) + pyperclip.copy(json_str) + self.feedback_label.config(text="Copied JSON") + self.root.after(2000, lambda: self.feedback_label.config(text="")) + + def load_state(self): + if os.path.exists(POS_FILE): + try: + with open(POS_FILE, "r") as f: + return json.load(f) + except Exception: + return {} + return {} + + def restore_window_position(self, state): + geom = state.get("geometry") + if geom: + self.root.geometry(geom) + else: + self.center_window() + + def center_window(self): + self.root.update_idletasks() + w = self.root.winfo_width() + h = self.root.winfo_height() + ws = self.root.winfo_screenwidth() + hs = self.root.winfo_screenheight() + x = (ws // 2) - (w // 2) + y = (hs // 2) - (h // 2) + self.root.geometry(f"+{x}+{y}") + + def on_close(self): + try: + state = { + "geometry": self.root.geometry(), + "step_size": self.step_size.get(), + "z_value": self.z_value.get() + } + with open(POS_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + self.root.destroy() + + +if __name__ == "__main__": + root = tk.Tk() + app = PlotterGUI(root) + root.mainloop()