from PyQt5.QtCore import QThread, pyqtSignal, QSize, QTimer from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QPushButton, QComboBox from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont import serial import re import time import matplotlib.pyplot as plt import os import struct import requests import subprocess from datetime import datetime from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas class UpdateWorker(QThread): update_signal = pyqtSignal(bool, int, float, float) data_received = pyqtSignal(str) stop_signal = pyqtSignal() mode_signal = pyqtSignal(int) offset_signal = pyqtSignal(int) apitimer_signal = pyqtSignal() change_color_signal = pyqtSignal(str, str, str) def __init__(self, serial_number, bench_id, port, block_id, main_app): super().__init__() print(f"id{bench_id}:\tport {port}") print(f"id{bench_id}:\tserial {serial_number}") self.block = self.add_block(serial_number, bench_id, port, block_id, main_app) self.main_app = main_app # Store the reference to the BlockStackApp instance self.serial_number = serial_number self.bench_id = bench_id self.port = port self.block_id = block_id self.running = True self.position_value = 0.0 self.diameter_total = 0.0 # for average calculation self.diameter_count = 0 self.diameter_value = 0.0 self.diameter_value1 = 0.0 self.diameter_value2 = 0.0 self.diameter_value3 = 0.0 self.diameter_min = 0.0 self.diameter_max = 0.0 self.density = 1.0 self.prepare_flag = False self.prepare_ok = False self.reset_flag = False self.ignore_stop = False self.is_running = False self.reset_data = False self.homed = False self.can_start = False self.current_job = None self.current_job = None self.data_list = [] self.profile_data = {} # Dictionary to store profile data # run at startup self.update_profiles() self.timer = QTimer() self.timer.timeout.connect(self.query_api) self.timer.start(3000) def run(self): try: with serial.Serial(self.port, 1000000, timeout=1) as ser: ser.write(b'a') ser.write(b'F') ser.write(b'i') ser.write(b' ') while self.running: if ser.in_waiting > 0: data = ser.readline().decode('utf-8').strip() if data: self.parse_data(data) if self.reset_flag and self.prepare_flag: if self.position_value > 100 and self.reset_flag: self.ignore_stop = True self.change_color_signal.emit(self.block_id, 'btn_prepare', "#5A5A5A") ser.write(b'R') self.prepare_flag = False if ((1.65 < self.diameter_value) and (self.diameter_value < 1.85)) or ((2.70 < self.diameter_value) and (self.diameter_value < 2.95)): self.prepare_ok = True else: self.prepare_ok = False if self.prepare_ok: self.change_color_signal.emit(self.block_id, 'btn_prepare', "#00A000") self.change_color_signal.emit(self.block_id, 'btn_start', "#A0A000") else: self.change_color_signal.emit(self.block_id, 'btn_prepare', "#A00000") else: self.change_color_signal.emit(self.block_id, 'btn_prepare', "#0000FF") else: if data == '%STOP': self.stop_running() # if not self.ignore_stop: # self.change_color_signal.emit(self.block_id, 'btn_start', "#A00000") # self.is_running = False # self.save_data_to_file() # else: # self.ignore_stop = False elif data == '$NOTHOMED': self.homed = False self.change_color_signal.emit(self.block_id, 'btn_home', "#a0a000") elif data == '$HOMED': # print(f"id{self.bench_id} :\tHoming ok") self.homed = True self.change_color_signal.emit(self.block_id, 'btn_home', "#5a5a5a") except serial.SerialException as e: print(f"Error with port {self.port}: {e}") def save_data_to_file(self): # format: # # #bench: FDR1-proto0 # #date: 20250224_232750 # #spool: R587 # #material: Enky PP Ortho V1-2106 20251007-00 N°7 # d:1.730,dm:1.730,dM:1.730,p:0.000,s:0.0,m:1500.0000,q:3,ap:0/3598 # d:1.730,dm:1.730,dM:1.730,p:2.291,s:0.0,m:1504.5000,q:3,ap:0/3598 # d:1.750,dm:1.730,dM:1.750,p:6.021,s:0.0,m:1504.5000,q:3,ap:0/3598 # d:1.730,dm:1.730,dM:1.750,p:11.192,s:0.0,m:1504.5000,q:3,ap:0/3598 # d:1.730,dm:1.730,dM:1.750,p:17.999,s:0.0,m:1504.5000,q:3,ap:0/3598 # d:1.730,dm:1.730,dM:1.750,p:26.049,s:0.0,m:1504.5000,q:3,ap:0/3598 # # [...] # # d:1.750,dm:1.690,dM:1.820,p:1614324.750,s:49.6,m:969.1023,q:2,ap:1180/3598 # d:1.820,dm:1.690,dM:1.820,p:1614324.250,s:1.1,m:972.0096,q:2,ap:1180/3598 # MOTOR DISABLED: FILAMENT OUT OF RANGE # #end: 20250224_232750 print(f"id{self.bench_id}:\tsaving job: {self.current_job}") spool, vendor, material, batch, description, startstamp = self.current_job endstamp = datetime.now().strftime("%Y%m%d_%H%M%S") outfilename = os.path.join("data", f"{spool}_{startstamp}.fslp") with open(outfilename, "w") as outfile: outfile.write(F"#bench: {self.serial_number} (#{self.bench_id})\n") outfile.write(F"#date: {startstamp}\n") outfile.write(F"#spool: {spool}\n") outfile.write(F"#material: {vendor} {material} {batch} {description}\n") for ts, position, diameter in self.data_list: outfile.write(f"t:{ts},d:{diameter},p:{position}\n") outfile.write(F"#end: {endstamp}\n") subprocess.run(["gzip", "-9", outfilename]) files = {'fsl': open(outfilename+'.gz', 'rb')} url = f"http://462filament/api.php?data=upload" r = requests.post(url, files=files) def parse_data(self, data): match = re.match(r"^[#d](\d):(-?[\d]+),p:(-?[\d\.]+)$", data) if match: diameter_int = int(match.group(2)) if diameter_int<65000: dial_number = int(match.group(1)) position_value = float(match.group(3)) diameter_value = float(diameter_int)/1000 if diameter_value < self.diameter_min: # process min/max self.diameter_min = diameter_value if diameter_value > self.diameter_max: self.diameter_max = diameter_value self.update_signal.emit(self.is_running, dial_number, diameter_value, position_value) self.reset_flag = True offset_match = re.match(r"^#h:(\d+)", data) if offset_match: home_offset = int(offset_match.group(1)) # print(f"id{self.bench_id}:\tOffset: {home_offset:.2f}") self.block.btn_offset.setText(f"Offset: {home_offset:.2f}") mode_match = re.match(r"^#o:(\d+)", data) if mode_match: mode = int(mode_match.group(1)) self.mode_signal.emit(mode) match mode: case 1: self.change_color_signal.emit(self.block_id, 'btn_go_left', "#0000A0") self.change_color_signal.emit(self.block_id, 'btn_go_right', "#5a5a5a") self.change_color_signal.emit(self.block_id, 'btn_reset', "#5a5a5a") case 2: self.change_color_signal.emit(self.block_id, 'btn_go_left', "#5a5a5a") self.change_color_signal.emit(self.block_id, 'btn_go_right', "#0000A0") self.change_color_signal.emit(self.block_id, 'btn_reset', "#5a5a5a") case 3: self.change_color_signal.emit(self.block_id, 'btn_go_left', "#5a5a5a") self.change_color_signal.emit(self.block_id, 'btn_go_right', "#5a5a5a") self.change_color_signal.emit(self.block_id, 'btn_reset', "#00A0A0") else: self.mode_signal.emit(65535) self.change_color_signal.emit(self.block_id, 'btn_go_left', "#5a5a5a") self.change_color_signal.emit(self.block_id, 'btn_go_right', "#5a5a5a") self.change_color_signal.emit(self.block_id, 'btn_reset', "#5A5A5A") def send_command(self, command): try: with serial.Serial(self.port, 1000000, timeout=1) as ser: ser.write(command.encode()) except serial.SerialException as e: print(f"Could not send command to port {self.port}: {e}") def int_to_two_bytes(self, value): if not (0 <= value <= 0xFFFF): raise ValueError("Value out of range for two bytes") two_bytes = struct.pack('>H', value) return two_bytes def set_limits(self, left, right): print(f"id{self.bench_id}:\tupdate limits to {left}+{right}") try: with serial.Serial(self.port, 1000000, timeout=1) as ser: ser.write(b'\x04') time.sleep(0.001) ser.write(self.int_to_two_bytes(left)) time.sleep(0.003) ser.write(self.int_to_two_bytes(right)) time.sleep(0.003) ser.write(b'\x04') time.sleep(0.001) ser.write(b'\x00') time.sleep(0.001) except serial.SerialException as e: print(f"Could not send command to port {self.port}: {e}") def set_prepare(self, prepare): self.position_value = 0 self.prepare_flag = True self.reset_flag = False def start_running(self): if self.prepare_ok and self.can_start: self.reset_data = True self.is_running = True self.current_job = ( self.api_job['spool'], self.api_job['vendor'], self.api_job['material'], self.api_job['batch'], self.api_job['description'], datetime.now().strftime("%Y%m%d_%H%M%S") ) self.change_color_signal.emit(self.block_id, 'btn_start', "#00a000") self.change_color_signal.emit(self.block_id, 'btn_prepare', "#5A5A5A") self.send_command('S') def stop_running(self): if self.is_running: self.send_command('R') self.block.data_label.setText( f"Job done, processing..." ) # for position, diameter in self.data_list: # print(f"{position} -> {diameter}") self.change_color_signal.emit(self.block_id, 'btn_start', "#a00000") self.change_color_signal.emit(self.block_id, 'btn_prepare', "#5A5A5A") self.is_running = False self.save_data_to_file() self.prepare_ok = False def clear_graph(self): self.line1.set_data([], []) self.line2.set_data([], []) self.line3.set_data([], []) self.canvas.draw() def add_block(self, serial_number, bench_id, port, block_id, main_app): block_widget = QWidget() block_layout = QHBoxLayout() block_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) LABEL_SIZE = QSize(250, 20) BUTTON_SIZE = QSize(100, 25) VALUE_SIZE = QSize(80, 25) VALUEL_SIZE = QSize(25, 25) monospaced_font = QFont("unexistent", 10) monospaced_font.setStyleHint(QFont.Monospace) monospaced_bold_font = QFont("unexistent", 10) monospaced_bold_font.setStyleHint(QFont.Monospace) monospaced_bold_font.setBold(True) monospaced_italics_font = QFont("unexistent", 10) monospaced_italics_font.setStyleHint(QFont.Monospace) monospaced_italics_font.setItalic(True) left_layout = QVBoxLayout() data_label = QLabel("No API data") data_label.setTextFormat(Qt.RichText) data_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) bench_label = QLabel(f"Bench ID: {bench_id}") bench_label.setToolTip(f"Serial number: {serial_number}\nCPU: Microchip PIC18F4431 @ 32 MHz\nPort: {port}\nBandwidth: 1 MBps\nPosition maxspd: 638 mm/s\nDiameter freq: ≈10.5 Hz") bench_label.setAlignment(Qt.AlignCenter) bench_label.setFixedSize(LABEL_SIZE) left_layout.addWidget(bench_label) left_layout.addWidget(data_label) left2_layout = QHBoxLayout() left2L_layout = QVBoxLayout() left2V_layout = QVBoxLayout() max_label = QLabel("2.000 ") max_label.setFont(monospaced_bold_font) avg_label = QLabel("2.0000") avg_label.setFont(monospaced_font) cur_label = QLabel("2.000 ") cur_label.setFont(monospaced_bold_font) len_label = QLabel("12345.00") len_label.setFont(monospaced_font) wht_label = QLabel("12.345 ") wht_label.setFont(monospaced_italics_font) min_label = QLabel("2.000 ") min_label.setFont(monospaced_bold_font) maxL_label = QLabel("∨") avgL_label = QLabel("⌀̄") curL_label = QLabel("⌀") lenL_label = QLabel("L") whtL_label = QLabel("M") minL_label = QLabel("∧") avg_label.setAlignment(Qt.AlignRight) max_label.setAlignment(Qt.AlignRight) cur_label.setAlignment(Qt.AlignRight) len_label.setAlignment(Qt.AlignRight) wht_label.setAlignment(Qt.AlignRight) min_label.setAlignment(Qt.AlignRight) avg_label.setToolTip("Average diameter in mm") max_label.setToolTip("Maximum diameter in mm") cur_label.setToolTip("Current diameter in mm") len_label.setToolTip("Length in m") wht_label.setToolTip("Estimated mass in kg") min_label.setToolTip("Minimum diameter in mm") maxL_label.setToolTip("Average diameter in mm") avgL_label.setToolTip("Maximum diameter in mm") curL_label.setToolTip("Current diameter in mm") lenL_label.setToolTip("Length in m") whtL_label.setToolTip("Estimated mass in kg") minL_label.setToolTip("Minimum diameter in mm") max_label.setFixedSize(VALUE_SIZE) avg_label.setFixedSize(VALUE_SIZE) cur_label.setFixedSize(VALUE_SIZE) len_label.setFixedSize(VALUE_SIZE) wht_label.setFixedSize(VALUE_SIZE) min_label.setFixedSize(VALUE_SIZE) maxL_label.setFixedSize(VALUEL_SIZE) avgL_label.setFixedSize(VALUEL_SIZE) curL_label.setFixedSize(VALUEL_SIZE) lenL_label.setFixedSize(VALUEL_SIZE) whtL_label.setFixedSize(VALUEL_SIZE) minL_label.setFixedSize(VALUEL_SIZE) left2V_layout.addWidget(max_label) left2V_layout.addWidget(avg_label) left2V_layout.addWidget(cur_label) left2V_layout.addWidget(len_label) left2V_layout.addWidget(wht_label) left2V_layout.addWidget(min_label) left2L_layout.addWidget(maxL_label) left2L_layout.addWidget(avgL_label) left2L_layout.addWidget(curL_label) left2L_layout.addWidget(lenL_label) left2L_layout.addWidget(whtL_label) left2L_layout.addWidget(minL_label) left2_layout.addLayout(left2L_layout) left2_layout.addLayout(left2V_layout) center_layout = QVBoxLayout() # graph_placeholder = QLabel("Graph Placeholder") # graph_placeholder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) # center_layout.addWidget(graph_placeholder) right_layout = QHBoxLayout() right_layout_1 = QVBoxLayout() right_layout_2 = QVBoxLayout() right_layout_3 = QVBoxLayout() btn_prepare = QPushButton("Prepare") btn_prepare.clicked.connect(lambda: self.send_command('P')) time.sleep(0.1) btn_prepare.clicked.connect(lambda: self.set_prepare(True)) right_layout_1.addWidget(btn_prepare) btn_start = QPushButton("Start") btn_start.clicked.connect(self.start_running) right_layout_1.addWidget(btn_start) btn_reset = QPushButton("Reset/Zero") btn_reset.clicked.connect(lambda: self.send_command('r')) btn_reset.clicked.connect(lambda: self.change_color_signal.emit(self.block_id, 'btn_start', "#5A5A5A")) right_layout_1.addWidget(btn_reset) btn_stop = QPushButton("Stop") btn_stop.clicked.connect(self.stop_running) right_layout_1.addWidget(btn_stop) btn_spare1 = QPushButton() right_layout_1.addWidget(btn_spare1) btn_sleep = QPushButton("Sleep") btn_sleep.clicked.connect(lambda: self.send_command('a')) right_layout_2.addWidget(btn_sleep) btn_home = QPushButton("Home") btn_home.clicked.connect(lambda: self.send_command(' ')) right_layout_2.addWidget(btn_home) btn_go_left = QPushButton("Go Left") btn_go_left.clicked.connect(lambda: self.send_command('1')) right_layout_2.addWidget(btn_go_left) btn_go_right = QPushButton("Go Right") btn_go_right.clicked.connect(lambda: self.send_command('2')) right_layout_2.addWidget(btn_go_right) btn_offset = QPushButton() right_layout_2.addWidget(btn_offset) btn_down_left = QPushButton("Offset +") btn_down_left.clicked.connect(lambda: self.send_command('b')) right_layout_3.addWidget(btn_down_left) btn_up_left = QPushButton("Offset -") btn_up_left.clicked.connect(lambda: self.send_command('n')) right_layout_3.addWidget(btn_up_left) btn_save_offset = QPushButton("Save Offset") btn_save_offset.clicked.connect(lambda: self.send_command('M')) right_layout_3.addWidget(btn_save_offset) dropd_profile = QComboBox() dropd_profile.addItem("Select a profile...") dropd_profile.model().item(0).setEnabled(False) dropd_profile.currentIndexChanged.connect(self.on_profile_changed) dropd_profile.setFixedWidth(120) view = dropd_profile.view() view.setMinimumWidth(350) # Set the minimum width of the dropdown list right_layout_3.addWidget(dropd_profile) btn_update_profiles = QPushButton("Reload profiles") btn_update_profiles.clicked.connect(self.update_profiles) right_layout_3.addWidget(btn_update_profiles) right_layout.addLayout(right_layout_1) right_layout.addLayout(right_layout_2) right_layout.addLayout(right_layout_3) block_layout.addLayout(left_layout) block_layout.addLayout(left2_layout) block_layout.addLayout(center_layout) block_layout.addLayout(right_layout) block_widget.setLayout(block_layout) block_widget.btn_start = btn_start block_widget.btn_home = btn_home block_widget.btn_go_left = btn_go_left block_widget.btn_go_right = btn_go_right block_widget.btn_reset = btn_reset block_widget.btn_prepare = btn_prepare block_widget.data_label = data_label block_widget.bench_label = bench_label block_widget.max_label = max_label block_widget.avg_label = avg_label block_widget.dia_label = cur_label block_widget.len_label = len_label block_widget.wht_label = wht_label block_widget.min_label = min_label block_widget.btn_offset = btn_offset block_widget.dropd_profile = dropd_profile # Create a Matplotlib figure and canvas for the graph plt.style.use('dark_background') fig, ax = plt.subplots() plt.subplots_adjust(left=-0.01, right=1, top=1.01, bottom=0) ax.set_facecolor('#3a3a3a') ax.set_xticks([]) # Hide x-axis values ax.set_frame_on(True) # ax.set_ylabel('Diameter (mm)') # Show y-axis label self.line2, = ax.plot([], [], '#00ff50', linewidth=0.5) self.line3, = ax.plot([], [], '#fffb00', linewidth=0.5) self.line1, = ax.plot([], [], '#ff5000', linewidth=1.5) self.canvas = FigureCanvas(fig) self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) center_layout.addWidget(self.canvas) return block_widget def on_profile_changed(self, index): # Get the selected ID selected_id = self.sender().itemData(index) if selected_id in self.profile_data: start_value = self.profile_data[selected_id]['start'] width_value = self.profile_data[selected_id]['width'] self.set_limits(start_value, width_value) def update_profiles(self): url = f"http://462filament/api.php?data=profiles" try: response = requests.get(url) if response.status_code == 200: profiles = response.json() if profiles: self.block.dropd_profile.clear() self.block.dropd_profile.addItem("Select a profile...") self.block.dropd_profile.model().item(0).setEnabled(False) for profile in profiles: profile_id = profile["id"] profile_name = profile["show_name"] start_value = int(profile["start"]) width_value = int(profile["width"]) self.block.dropd_profile.addItem(profile_name, profile_id) self.profile_data[profile_id] = {'start': start_value, 'width': width_value} except requests.RequestException as e: print(f"Error making request to API: {e}") return None def query_api(self): try: url = f"http://462filament/api.php?data=bench&benchid={self.bench_id}" response = requests.get(url) response.raise_for_status() # Raise an exception for HTTP errors api_job = response.json() # Parse the JSON response self.api_job = api_job if self.api_job is not None: # print(f"id{self.bench_id}:\tgot job {api_job['spool']} from API") self.block.data_label.setText( f"Job/Spool: {api_job['spool']}