521 lines
21 KiB
Python
521 lines
21 KiB
Python
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
|
||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||
|
||
class UpdateWorker(QThread):
|
||
update_signal = pyqtSignal(bool, float, float, float, float)
|
||
data_received = pyqtSignal(str)
|
||
stop_signal = pyqtSignal()
|
||
mode_signal = pyqtSignal(int)
|
||
offset_signal = pyqtSignal(int)
|
||
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_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.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):
|
||
#FIXME: set name
|
||
#FIXME: add file header
|
||
#FIXME: compress file
|
||
#FIXME: upload to API
|
||
file_path = os.path.join("data", f"{self.bench_id}_data.txt")
|
||
|
||
# Ensure the directory exists
|
||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||
|
||
# Write the data to the file
|
||
with open(file_path, "w") as file:
|
||
for data in self.data_list:
|
||
file.write(data + "\n")
|
||
|
||
def parse_data(self, data):
|
||
self.data_list.append(data)
|
||
match = re.match(r"^[#d]:(-?[\d\.]+),p:(-?[\d\.]+)$", data)
|
||
if match:
|
||
self.diameter_value = float(match.group(1))
|
||
self.position_value = float(match.group(2))
|
||
self.update_signal.emit(self.is_running, self.diameter_value, self.position_value, self.diameter_min, self.diameter_max)
|
||
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.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.timer.start()
|
||
|
||
self.block.data_label.setText(
|
||
f"Job done, processing..."
|
||
)
|
||
|
||
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.line.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("<i>Average diameter</i> in <b>mm</b>")
|
||
max_label.setToolTip("<i>Maximum diameter</i> in <b>mm</b>")
|
||
cur_label.setToolTip("<i>Current diameter</i> in <b>mm</b>")
|
||
len_label.setToolTip("<i>Length</i> in <b>m</b>")
|
||
wht_label.setToolTip("<i>Estimated mass</i> in <b>kg</b>")
|
||
min_label.setToolTip("<i>Minimum diameter</i> in <b>mm</b>")
|
||
|
||
maxL_label.setToolTip("<i>Average diameter</i> in <b>mm</b>")
|
||
avgL_label.setToolTip("<i>Maximum diameter</i> in <b>mm</b>")
|
||
curL_label.setToolTip("<i>Current diameter</i> in <b>mm</b>")
|
||
lenL_label.setToolTip("<i>Length</i> in <b>m</b>")
|
||
whtL_label.setToolTip("<i>Estimated mass</i> in <b>kg</b>")
|
||
minL_label.setToolTip("<i>Minimum diameter</i> in <b>mm</b>")
|
||
|
||
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'))
|
||
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.line, = ax.plot([], [], '#ff5000', linewidth=0.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
|
||
|
||
if 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']}<hr>"
|
||
f"Fabricant: {api_job['vendor']}<br>"
|
||
f"Matière: {api_job['material']}<br>"
|
||
f"Lot: {api_job['batch']}<br>"
|
||
f"Identifiant: {api_job['description']}<hr>"
|
||
f"ρ = {api_job['density']}<br>"
|
||
)
|
||
self.density = float(api_job['density'])
|
||
self.can_start = True
|
||
self.timer.stop()
|
||
else:
|
||
print(f"id{self.bench_id}:\tno job from API")
|
||
self.block.data_label.setText(
|
||
f"API responded with no job"
|
||
)
|
||
self.can_start = False
|
||
except requests.RequestException as e:
|
||
print(f"Error querying API: {e}") |