diff --git a/.gitignore b/.gitignore index 26bea3fa..b0c7afc4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/* .vscode/* .qt_for_python/* +.clinerules profiles.json* /.venv diff --git a/errors.py b/about_ui.py similarity index 58% rename from errors.py rename to about_ui.py index cb71a692..e645c990 100644 --- a/errors.py +++ b/about_ui.py @@ -1,18 +1,10 @@ -from base_ui import WidgetUI -from PyQt6.QtWidgets import QDialog, QTableWidgetItem, QHeaderView -from PyQt6.QtWidgets import ( - QMessageBox, - QVBoxLayout, - QCheckBox, - QButtonGroup, - QPushButton, - QLabel, - QSpinBox, - QComboBox, -) +import PyQt6.QtWidgets +from PyQt6 import uic from PyQt6.QtCore import QAbstractTableModel, Qt, QModelIndex -from base_ui import CommunicationHandler - +import base_ui +import helper +import activelist +import activetasks class ErrorsModel(QAbstractTableModel): def __init__(self, parent): @@ -69,31 +61,32 @@ def setErrors(self, errors): self.errors = errors self.endResetModel() +class AboutUI(base_ui.WidgetUI, base_ui.CommunicationHandler): + """ + About page widget + """ + + def __init__(self, main_ui, version_str, fw_version_str): + base_ui.WidgetUI.__init__(self, main_ui, "about_page.ui") + base_ui.CommunicationHandler.__init__(self) + self.main = main_ui + + verstr = "Version: " + version_str + if fw_version_str: + verstr += " / Firmware: " + fw_version_str + self.version.setText(verstr) + + try: + with open("LICENSE", "r") as f: + self.textBrowser_license.setText(f.read()) + except FileNotFoundError: + self.textBrowser_license.setText("LICENSE file not found.") + + # Setup logging for all apps to get event here + self.logger.register_to_logger(self.append_log) -class ErrorsDialog(QDialog): - def __init__(self, main=None): - QDialog.__init__(self, main) - self.main = main - self.ui = ErrorsUI(main, self) - self.layout = QVBoxLayout() - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.addWidget(self.ui) - self.setLayout(self.layout) - self.setWindowTitle("Errors") - - def registerCallbacks(self): - self.ui.registerCallbacks() - - def connected_cb(self,connected): - if connected: - self.ui.clear_stored_errors() - -class ErrorsUI(WidgetUI, CommunicationHandler): - def __init__(self, main=None, parent=None): - WidgetUI.__init__(self, parent, "errors.ui") - CommunicationHandler.__init__(self) - self.main = main - self.parent = parent + + # Setup logic for Errors tab self.pushButton_refresh.clicked.connect(self.readErrors) self.pushButton_clearErrors.clicked.connect(self.clear_errors) self.pushButton_clearLogs.clicked.connect(self.clear_logs) @@ -101,9 +94,41 @@ def __init__(self, main=None, parent=None): self.tableView.setModel(self.errors) header = self.tableView.horizontalHeader() header.setStretchLastSection(True) - self.registerCallbacks() - self.logger.register_to_logger(self.append_log) + + # Setup Modules tab + self.active_class_ui = activelist.ActiveClassUI(self) + self.active_task_ui = activetasks.ActiveTaskUI(self) + + # Push content in groupBox_features + layout = self.groupBox_features.layout() + for i in reversed(range(layout.count())): + widgetToRemove = layout.itemAt(i).widget() + layout.removeWidget(widgetToRemove) + widgetToRemove.setParent(None) + layout.addWidget(self.active_class_ui) + + # Replace content in groupBox_thread + layout = self.groupBox_thread.layout() + for i in reversed(range(layout.count())): + widgetToRemove = layout.itemAt(i).widget() + layout.removeWidget(widgetToRemove) + widgetToRemove.setParent(None) + layout.addWidget(self.active_task_ui) + + def showEvent(self, a0): + self.tableView.resizeColumnsToContents() + + def set_connected(self, connected): + self.tab_log.setEnabled(connected) + self.tab_module.setEnabled(connected) + self.active_task_ui.connect(connected) + if connected: + self.registerCallbacks() + else: + # remove the handler on disconnect + self.remove_callbacks() + def append_log(self, message): """Display the log message.""" self.logBox_1.append(message) @@ -125,17 +150,13 @@ def clear_logs(self): def registerCallbacks(self): self.register_callback("sys", "errors", self.errorCallback, 0, typechar="?") - def showEvent(self, a0): - self.tableView.resizeColumnsToContents() - # self.readErrors() - def readErrors(self): - if self.isEnabled: + if self.isEnabled(): self.send_command("sys", "errors", 0) def errorCallback(self, errorstring): - if errorstring != "None": - self.parent.show() + if errorstring != "None" and not self.main.isVisible(): + self.main.show() errors = [] for errorline in errorstring.split("\n"): e = errorline.split(":", 2) @@ -144,4 +165,4 @@ def errorCallback(self, errorstring): error = {"code": e[0], "level": e[1], "info": e[2]} errors.append(error) self.errors.setErrors(errors) - self.tableView.resizeColumnsToContents() + self.tableView.resizeColumnsToContents() \ No newline at end of file diff --git a/activelist.py b/activelist.py index c17e0df6..b542e14f 100644 --- a/activelist.py +++ b/activelist.py @@ -64,16 +64,6 @@ def setItems(self,items): self.items = items self.endResetModel() -class ActiveClassDialog(PyQt6.QtWidgets.QDialog): - def __init__(self, parent = None): - PyQt6.QtWidgets.QDialog.__init__(self, parent) - self.active_class_ui = ActiveClassUI(parent) - self.layout = PyQt6.QtWidgets.QVBoxLayout() - self.layout.setContentsMargins(0,0,0,0) - self.layout.addWidget(self.active_class_ui) - self.setLayout(self.layout) - self.setWindowTitle("Active modules") - class ActiveClassUI(WidgetUI, CommunicationHandler): def __init__(self, parent = None): WidgetUI.__init__(self, parent, 'activelist.ui') @@ -84,7 +74,7 @@ def __init__(self, parent = None): self.tableView.setModel(self.items) header = self.tableView.horizontalHeader() header.setSectionResizeMode(0,PyQt6.QtWidgets.QHeaderView.ResizeMode.Stretch) # Stretch first section - + def showEvent(self, a0): self.tableView.resizeColumnsToContents() self.read() @@ -93,7 +83,6 @@ def read(self): self.get_value_async("sys","lsactive",self.updateCb) def updateCb(self,string): - self.parent.show() items = [] for line in string.split("\n"): e = line.split(":") diff --git a/activetasks.py b/activetasks.py index 5ec0f213..9b8c1f0c 100644 --- a/activetasks.py +++ b/activetasks.py @@ -66,22 +66,6 @@ def setItems(self,items): self.items = items self.endResetModel() -class ActiveTaskDialog(PyQt6.QtWidgets.QDialog): - def __init__(self, parent = None): - PyQt6.QtWidgets.QDialog.__init__(self, parent) - self.active_class_ui = ActiveTaskUI(parent) - self.layout = PyQt6.QtWidgets.QVBoxLayout() - self.layout.setContentsMargins(0,0,0,0) - self.layout.addWidget(self.active_class_ui) - self.setLayout(self.layout) - self.setWindowTitle("Active threads") - - def set_taskstats_enabled(self,enabled): - self.active_class_ui.taskstats_enabled = enabled - - def set_tasklist_enabled(self,enabled): - self.active_class_ui.tasklist_enabled = enabled - class ActiveTaskUI(WidgetUI, CommunicationHandler): def __init__(self, parent = None): WidgetUI.__init__(self, parent, 'activelist.ui') @@ -96,27 +80,58 @@ def __init__(self, parent = None): header = self.tableView.horizontalHeader() header.setSectionResizeMode(0,PyQt6.QtWidgets.QHeaderView.ResizeMode.Stretch) # Stretch first section self.tableView.setSortingEnabled(True) - self.timer = QTimer(self) - self.timer.timeout.connect(self.read) + + self.isConnected = False self.items_list = [] self.items_stats = [] self.taskstats_enabled = False self.tasklist_enabled = False - + + self.timer = QTimer(self) + self.timer.timeout.connect(self.timer_cb) + + def connect(self, is_connected): + self.isConnected = is_connected + if is_connected: + self.check_taskstats() + if self.isVisible(): + self.timer.start(1000) + else: + self.timer.stop() def showEvent(self, a0): self.tableView.resizeColumnsToContents() self.read() - self.timer.start(1000) - return super().showEvent(a0) + if self.isConnected: + self.timer.start(1000) def hideEvent(self, a0: QHideEvent) -> None: self.timer.stop() - return super().hideEvent(a0) + + def timer_cb(self): + if self.isConnected: + self.read() + + def set_taskstats_enabled(self, enabled): + self.taskstats_enabled = enabled + + def set_tasklist_enabled(self, enabled): + self.tasklist_enabled = enabled + + def check_taskstats(self): + # Check if the taskstats and tasklist command are available + self.get_value_async("sys", "cmdinfo", adr=18, conversion=int, callback=lambda x: self.set_taskstats_enabled(x==1)) + # Vérifier tasklist + self.get_value_async("sys", "cmdinfo", adr=25, conversion=int, callback=lambda x: self.set_tasklist_enabled(x==1)) + def read(self): + # Don't refresh if connection is down + if not self.isConnected: + return + self.items_stats.clear() self.items_list.clear() if self.taskstats_enabled: @@ -138,8 +153,6 @@ def updateItems(self): items.setdefault(item["name"], {}) items[item["name"]].update(item) self.items.setItems(list(items.values())) - self.parent.show() - def updateStatsCb(self,string): for line in string.split("\n"): @@ -163,4 +176,3 @@ def updateListCb(self,string): self.items_list.append(item) self.updateItems() - diff --git a/axis_ui.py b/axis_ui.py index 1ed08985..6bf896a4 100644 --- a/axis_ui.py +++ b/axis_ui.py @@ -1,7 +1,7 @@ from PyQt6.QtWidgets import QMainWindow from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QWidget,QToolButton -from PyQt6.QtWidgets import QMessageBox,QVBoxLayout,QCheckBox,QButtonGroup,QGridLayout +from PyQt6.QtWidgets import QMessageBox from PyQt6 import uic from helper import res_path,classlistToIds,updateClassComboBox,qtBlockAndCall,throttle from PyQt6.QtCore import QTimer,QEvent @@ -11,6 +11,88 @@ import encoder_tuning_ui import expo_ui +from PyQt6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis +from PyQt6.QtCore import Qt, QPointF, QMargins +from PyQt6.QtGui import QColor, QPainter, QPen +from PyQt6.QtWidgets import QApplication +import math +from biquad import Biquad + +def calc_freq_response(b, a, freqs, fs): + resp = [] + for f in freqs: + w = 2 * math.pi * f / fs + cos_w = math.cos(w) + sin_w = -math.sin(w) + cos_2w = math.cos(2*w) + sin_2w = -math.sin(2*w) + + num_real = b[0] + b[1]*cos_w + b[2]*cos_2w + num_imag = b[1]*sin_w + b[2]*sin_2w + + den_real = 1 + a[1]*cos_w + a[2]*cos_2w + den_imag = a[1]*sin_w + a[2]*sin_2w + + mag_num = math.sqrt(num_real**2 + num_imag**2) + mag_den = math.sqrt(den_real**2 + den_imag**2) + + mag = mag_num / (mag_den + 1e-10) + resp.append(20 * math.log10(mag + 1e-10)) + return resp + +class EqChartView(QChartView): + def __init__(self, chart, parent=None): + super().__init__(chart, parent) + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.dragged_index = -1 + self.scatter_series = None + self.frequencies = [] + self.on_gain_changed = None + + def set_data_series(self, scatter_series, frequencies): + self.scatter_series = scatter_series + self.frequencies = frequencies + + def mousePressEvent(self, event): + if self.scatter_series: + for i, p in enumerate(self.scatter_series.points()): + p_pixel = self.chart().mapToPosition(p) + dist = math.hypot(p_pixel.x() - event.position().x(), p_pixel.y() - event.position().y()) + if dist < 15: + if event.button() == Qt.MouseButton.LeftButton: + self.dragged_index = i + event.accept() + return + elif event.button() == Qt.MouseButton.RightButton: + self.scatter_series.replace(i, self.frequencies[i], 0.0) + if self.on_gain_changed: + self.on_gain_changed(i, 0) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.dragged_index >= 0 and self.scatter_series: + val_point = self.chart().mapToValue(event.position()) + y = max(-120.0, min(120.0, val_point.y())) + self.scatter_series.replace(self.dragged_index, self.frequencies[self.dragged_index], y) + if self.on_gain_changed: + self.on_gain_changed(self.dragged_index, int(y)) + event.accept() + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton and self.dragged_index >= 0: + val_point = self.chart().mapToValue(event.position()) + y = max(-120.0, min(120.0, val_point.y())) + self.scatter_series.replace(self.dragged_index, self.frequencies[self.dragged_index], y) + if self.on_gain_changed: + self.on_gain_changed(self.dragged_index, int(y)) + self.dragged_index = -1 + event.accept() + else: + super().mouseReleaseEvent(event) class AxisUI(WidgetUI,CommunicationHandler): @@ -54,22 +136,20 @@ def __init__(self, main: 'main.MainUi'=None, unique=0): self.horizontalSlider_inertia.valueChanged.connect(lambda val : self.send_value("axis","axisinertia",val,instance=self.axis)) self.pushButton_center.clicked.connect(lambda : self.send_command("axis","zeroenc",instance=self.axis)) - #self.checkBox_invert.stateChanged.connect(lambda val : self.send_value("axis","invert",(0 if val == 0 else 1),instance=self.axis)) self.checkBox_speedlimit.stateChanged.connect(self.setSpeedLimitEnabled) self.spinBox_speedlimit.valueChanged.connect(lambda val : self.send_value("axis","maxspeed",val,instance=self.axis)) - self.spinBox_reduction_numerator.valueChanged.connect(self.updateReductionText) - self.spinBox_reduction_denominator.valueChanged.connect(self.updateReductionText) self.pushButton_apply_options.clicked.connect(self.applyOptions) self.pushButton_submit_hw.clicked.connect(self.submitHw) self.pushButton_submit_enc.clicked.connect(self.submitEnc) - tabId = self.main.add_tab(self,"FFB Axis") + # VMA Remove it is's also done in the tab processing tabId = self.main.add_tab(self,"FFB Axis") # Callbacks must prevent sending a value change command self.register_callback("axis","power",self.updatePowerSlider,self.axis,int) self.register_callback("axis","degrees",lambda val : self.updateRange(val),self.axis,int) + self.register_callback("axis","maxspeed",self.speedLimitCb,self.axis,int) self.register_callback("axis","invert",lambda val : qtBlockAndCall(self.checkBox_invert,self.checkBox_invert.setChecked,val),self.axis,int) self.register_callback("axis","fxratio",lambda val : self.updateFxratio(val),self.axis,int) @@ -81,9 +161,9 @@ def __init__(self, main: 'main.MainUi'=None, unique=0): self.register_callback("axis","reduction",lambda val : self.updateReduction(val),self.axis,lambda x : tuple(map(int,x.split(":")))) - self.register_callback("axis","cmdinfo",self.reductionAvailable,self.axis,int,adr = 17) + # Check if reduction command is available + self.register_callback("axis","cmdinfo",self.reductionAvailable,self.axis,int,adr = 19) - self.register_callback("axis","maxspeed",self.speedLimitCb,self.axis,int) self.register_callback("axis","pos",self.enc_pos_cb,self.axis,int) self.register_callback("axis","cpr",self.cpr_cb,self.axis,int) @@ -93,10 +173,107 @@ def __init__(self, main: 'main.MainUi'=None, unique=0): # Check if expo is available self.register_callback("axis","cmdinfo",self.expoAvailable,self.axis,int,adr = 24) - + + # manage display self.pushButton_encoderTuning.clicked.connect(self.encoder_tuning_dlg.display) self.pushButton_expo.clicked.connect(self.expo_dlg.display) - + + + + + # --- Equalizer Controls --- + + self.eq_freqs = [10, 15, 25, 40, 60, 100] + self.eq_gains = [0] * len(self.eq_freqs) + self.eq_q = 2.14 + self.eq_fs = 1000.0 + + self.chart_eq = QChart() + self.chart_eq.legend().hide() + self.chart_eq.setBackgroundRoundness(5) + self.chart_eq.setBackgroundBrush(QApplication.instance().palette().window()) + + self.axis_x_eq = QValueAxis(self.chart_eq) + self.axis_x_eq.setRange(0, 120) + self.axis_x_eq.setTitleText("Frequency (Hz)") + grid_color = QColor(QApplication.instance().palette().text().color()) + grid_color.setAlpha(40) + self.axis_x_eq.setGridLineColor(grid_color) + self.chart_eq.addAxis(self.axis_x_eq, Qt.AlignmentFlag.AlignBottom) + + self.axis_y_eq = QValueAxis(self.chart_eq) + self.axis_y_eq.setRange(-120, 120) + self.axis_y_eq.setTitleText("Gain (%)") + self.axis_y_eq.setTickCount(7) + self.axis_y_eq.setGridLineColor(grid_color) + self.chart_eq.addAxis(self.axis_y_eq, Qt.AlignmentFlag.AlignLeft) + + self.line_series_eq = QLineSeries() + pen = QPen(QColor("cornflowerblue")) + pen.setWidth(3) + self.line_series_eq.setPen(pen) + self.line_series_eq.setUseOpenGL(True) + self.chart_eq.addSeries(self.line_series_eq) + self.line_series_eq.attachAxis(self.axis_x_eq) + self.line_series_eq.attachAxis(self.axis_y_eq) + + self.axis_y_eq_right = QValueAxis(self.chart_eq) + self.axis_y_eq_right.setRange(-120, 120) + self.axis_y_eq_right.setTickCount(7) + self.axis_y_eq_right.setGridLineVisible(False) + self.chart_eq.addAxis(self.axis_y_eq_right, Qt.AlignmentFlag.AlignRight) + self.line_series_eq.attachAxis(self.axis_y_eq_right) + + self.zero_line_eq = QLineSeries() + pen_zero = QPen(QApplication.instance().palette().text().color()) + pen_zero.setWidth(1) + pen_zero.setDashPattern([4, 4]) + self.zero_line_eq.setPen(pen_zero) + self.zero_line_eq.setUseOpenGL(True) + self.zero_line_eq.append(0, 0) + self.zero_line_eq.append(120, 0) + self.chart_eq.addSeries(self.zero_line_eq) + self.zero_line_eq.attachAxis(self.axis_x_eq) + self.zero_line_eq.attachAxis(self.axis_y_eq) + self.zero_line_eq.attachAxis(self.axis_y_eq_right) + + self.scatter_series_eq = QScatterSeries() + self.scatter_series_eq.setMarkerShape(QScatterSeries.MarkerShape.MarkerShapeCircle) + self.scatter_series_eq.setMarkerSize(12.0) + self.scatter_series_eq.setColor(QColor("white")) + self.scatter_series_eq.setBorderColor(QColor("cornflowerblue")) + + for f in self.eq_freqs: + self.scatter_series_eq.append(f, 0) + + self.chart_eq.addSeries(self.scatter_series_eq) + self.scatter_series_eq.attachAxis(self.axis_x_eq) + self.scatter_series_eq.attachAxis(self.axis_y_eq) + self.scatter_series_eq.attachAxis(self.axis_y_eq_right) + + idx = self.gridLayout_9.indexOf(self.graphWidget_eq) + row, col, rowSpan, colSpan = self.gridLayout_9.getItemPosition(idx) + self.graphWidget_eq.deleteLater() + + self.graphWidget_eq = EqChartView(self.chart_eq) + self.graphWidget_eq.set_data_series(self.scatter_series_eq, self.eq_freqs) + self.graphWidget_eq.on_gain_changed = self.on_eq_point_moved + self.gridLayout_9.addWidget(self.graphWidget_eq, row, col, rowSpan, colSpan) + + for ax in self.chart_eq.axes(): + ax.setLabelsBrush(QApplication.instance().palette().text()) + ax.setTitleBrush(QApplication.instance().palette().text()) + + self.update_eq_curve() + + self.checkBox_eq.stateChanged.connect(self.send_eq_enabled) + + self.pushButton_resetEq.clicked.connect(self.reset_eq) + + self.register_callback("axis","equalizer",self.update_eq_enabled,self.axis,int) + for i in range(6): + self.register_callback("axis",f"eqb{i+1}",lambda val, index=i: self.update_eq_band(val, index),self.axis,int) + def setSpeedLimit(self,val): if self.checkBox_speedlimit.isChecked(): self.send_value("axis","maxspeed",self.spinBox_speedlimit.value(),instance=self.axis) @@ -113,33 +290,28 @@ def speedLimitCb(self,val): self.checkBox_speedlimit.setChecked(True) self.spinBox_speedlimit.setEnabled(True) - - def setSpeedLimitEnabled(self,val): - self.spinBox_speedlimit.setEnabled(val) - if self.checkBox_speedlimit.isChecked(): - self.send_value("axis","maxspeed",self.spinBox_speedlimit.value(),instance=self.axis) - else: - self.send_value("axis","maxspeed",0,instance=self.axis) - - def updateReduction(self,val): numerator,denominator = val self.spinBox_reduction_numerator.setValue(numerator) self.spinBox_reduction_denominator.setValue(denominator) - self.updateReductionText() def reductionAvailable(self,available): - self.frame_reduction.setVisible(available>0) + self.groupBox_reduction.setVisible(available>0) if available > 0: self.send_command("axis","reduction",self.axis) - def updateReductionText(self): - self.label_gear_reduction_value.setText(f"Prescaler: {round(self.spinBox_reduction_numerator.value()/self.spinBox_reduction_denominator.value(),5)}") - def applyOptions(self): self.send_value("axis","invert",(0 if self.checkBox_invert.isChecked() == 0 else 1),instance=self.axis) - if(self.frame_reduction.isVisible()): - self.send_value("axis","reduction",self.spinBox_reduction_numerator.value(),self.spinBox_reduction_denominator.value(),self.axis) + self.send_value("axis","reduction",self.spinBox_reduction_numerator.value(),self.spinBox_reduction_denominator.value(),self.axis) + + # check if speed is required + if self.checkBox_speedlimit.isChecked() : + self.send_value("axis","maxspeed",self.spinBox_speedlimit.value(),instance=self.axis) + else: + self.send_value("axis","maxspeed",0,instance=self.axis) + + def setSpeedLimitEnabled(self,val): + self.spinBox_speedlimit.setEnabled(val) def updateEsgain(self,val): qtBlockAndCall(self.spinBox_esgain,self.spinBox_esgain.setValue,val) @@ -168,17 +340,82 @@ def updateInertia(self,val): def expoAvailable(self,available): self.pushButton_expo.setEnabled(available>0) self.expo_dlg.setEnabled(available>0) - # if available > 0: - # self.send_commands("axis",["expo","exposcale"],self.axis) + if available > 0: + self.send_commands("axis",["expo","exposcale"],self.axis) + + + + + # --- Equalizer Methods --- + + # Called when the 'Effect equalizer' checkbox is toggled + def send_eq_enabled(self, state): + """Sends the command to enable or disable the equalizer on the firmware.""" + self.send_value("axis", "equalizer", 1 if state else 0, instance=self.axis) + + def on_eq_point_moved(self, band_index, value): + self.eq_gains[band_index] = value + self.update_eq_curve() + self.send_eq_band_value(value, band_index + 1) + + def send_eq_band_value(self, value, band): + """Sends the gain value for a specific equalizer band to the firmware.""" + self.send_value("axis", f"eqb{band}", value, instance=self.axis) + + def reset_eq(self): + """Asks for user confirmation and then resets all equalizer bands to 0.""" + reply = QMessageBox.question(self, 'Reset Equalizer', "Are you sure you want to reset all equalizer bands to 0?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) + if reply == QMessageBox.StandardButton.Yes: + for band in range(len(self.eq_freqs)): + self.update_eq_band(0, band) + self.send_eq_band_value(0, band + 1) + + def update_eq_enabled(self, value): + """Updates the 'Effect equalizer' checkbox state based on data from the firmware.""" + qtBlockAndCall(self.checkBox_eq, self.checkBox_eq.setChecked, value) + + def update_eq_band(self, value, band): + """Updates an equalizer gain value based on data from the firmware.""" + self.eq_gains[band] = value + self.scatter_series_eq.replace(band, self.eq_freqs[band], value) + self.update_eq_curve() + + def update_eq_curve(self): + """Calculates and draws the frequency response curve of the equalizer.""" + freqs = [f for f in range(5, 121)] + resp_total = [0] * len(freqs) + + for i, f0 in enumerate(self.eq_freqs): + gain = self.eq_gains[i] / 10.0 + if abs(gain) > 0.01: + if i == 0: + b_type = 5 # lowshelf + elif i == len(self.eq_freqs) - 1: + b_type = 6 # highshelf + else: + b_type = 4 # peak + + bq = Biquad(b_type, f0 / self.eq_fs, self.eq_q, gain) + b = [bq.a0, bq.a1, bq.a2] + a = [1.0, bq.b1, bq.b2] + + resp = calc_freq_response(b, a, freqs, self.eq_fs) + for j in range(len(freqs)): + resp_total[j] += resp[j] + + points = [QPointF(f, r * 10.0) for f, r in zip(freqs, resp_total)] + self.line_series_eq.replace(points) def init_ui(self): try: - self.getMotorDriver() + self.getMotorDriver() # Call the motor driver and update the slicer when received message (updateSliders) self.getEncoder() - #self.updateSliders() self.send_commands("axis",["invert","cpr"],self.axis) - self.send_command("axis","cmdinfo",self.axis,adr=17) + self.send_command("axis","cmdinfo",self.axis,adr=19) # reduction self.send_command("axis","cmdinfo",self.axis,adr=24) # Expo + # Request initial equalizer status and all band gains from the firmware + self.send_commands("axis",["equalizer","eqb1","eqb2","eqb3","eqb4","eqb5","eqb6"],self.axis) except: self.main.log("Error initializing Axis tab") @@ -351,10 +588,18 @@ def f(dat): def encid_f(id): if(id == 255): - self.groupBox_encoder.setVisible(False) + #self.groupBox_encoder.setVisible(False) + self.label_encoderSource.setVisible(False) + self.comboBox_encoder.setVisible(False) + self.pushButton_submit_enc.setVisible(False) + self.stackedWidget_encoder.setVisible(False) return else: - self.groupBox_encoder.setVisible(True) + #self.groupBox_encoder.setVisible(True) + self.label_encoderSource.setVisible(True) + self.comboBox_encoder.setVisible(True) + self.pushButton_submit_enc.setVisible(True) + self.stackedWidget_encoder.setVisible(True) if(id == None): self.main.log("Error getting encoder") return diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 00000000..b84a2cfb --- /dev/null +++ b/dashboard.py @@ -0,0 +1,128 @@ +import PyQt6.QtWidgets +from PyQt6 import uic +from PyQt6.QtCore import QAbstractTableModel, Qt, QModelIndex +import base_ui +import hid_comms +import effects_monitor + + +class DashboardUI(base_ui.WidgetUI, base_ui.CommunicationHandler): + """ + Dashboard page widget + """ + + def __init__(self, main_ui): + base_ui.WidgetUI.__init__(self, main_ui, "dashboard.ui") + base_ui.CommunicationHandler.__init__(self) + self.main = main_ui + + self.ffbeffect_ui = effects_monitor.EffectStatsUI(main=self.main, parent=self) + self.ffbeffect_ui.setEnabled(False) + + # Push content in groupBox_features + layout = self.groupBox_ffbeffect.layout() + for i in reversed(range(layout.count())): + widgetToRemove = layout.itemAt(i).widget() + if widgetToRemove: + layout.removeWidget(widgetToRemove) + widgetToRemove.setParent(None) + layout.addWidget(self.ffbeffect_ui) + + # start Thread HID + self.worker = hid_comms.HIDWorker() + self.worker.data_received.connect(self.update_dashboard) + self.worker.connection_status.connect(self.handle_connection_status) + + self.serial_connected = False + + + def showEvent(self, a0): + # Start the worker when the dashboard is shown + self.worker.start_thread() + return super().showEvent(a0) + + def hideEvent(self, a0): + self.worker.stop() + return super().hideEvent(a0) + + def set_connected(self, connected): + self.serial_connected = connected + self.groupBox_ffbeffect.setEnabled(connected) + self.ffbeffect_ui.setEnabled(connected) + if connected: + self.registerCallbacks() + else: + # remove the handler on disconnect + self.remove_callbacks() + + def setEffectAvailable(self, a0: bool) -> None: + self.ffbeffect_ui.setEffectAvailable(a0) + + def registerCallbacks(self): + pass + + def handle_connection_status(self, connected, message): + """Handle connection status updates from HID worker""" + if connected: + # Hide status label when connected successfully + self.lbl_status.setVisible(False) + self.groupBox_FFBAxes.setVisible(True) + self.groupBox_ffbeffect.setVisible(True) + self.groupBox_secAxis.setVisible(True) + self.groupBox_matrixButton.setVisible(True) + self.worker.sendCommand(1,0x000,0,0x7) # ask the main class without CDC connexion + + else: + # Show error message in status label + self.lbl_status.setVisible(True) + self.lbl_status.setText(f"{message}") + self.groupBox_FFBAxes.setVisible(False) + self.groupBox_ffbeffect.setVisible(False) + self.groupBox_secAxis.setVisible(False) + self.groupBox_matrixButton.setVisible(False) + + def update_dashboard(self, report): + + if isinstance(report, hid_comms.CMDReport): + rep: hid_comms.CMDReport = report + if (rep.cls == 0 and rep.command == 0x7): # sys.0.main response + if rep.value==1 : + self.progressBar_Y_pos.setEnabled(False) + self.progressBar_Y_neg.setEnabled(False) + return + + """Update dashboard with HID report data""" + # Hide status label when data is received + self.lbl_status.setVisible(False) + + # Update axis values + if (report.axis_x > 0) : + self.progressBar_X_pos.setValue(report.axis_x) + self.progressBar_X_neg.setValue(0) + else : + self.progressBar_X_pos.setValue(0) + self.progressBar_X_neg.setValue(-report.axis_x) + + if (report.axis_y > 0) : + self.progressBar_Y_pos.setValue(report.axis_y) + self.progressBar_Y_neg.setValue(0) + else : + self.progressBar_Y_pos.setValue(0) + self.progressBar_Y_neg.setValue(-report.axis_y) + + + self.progressBar_Z.setValue(report.axis_z) + self.progressBar_RX.setValue(report.axis_rx) + self.progressBar_RY.setValue(report.axis_ry) + self.progressBar_RZ.setValue(report.axis_rz) + self.progressBar_dial.setValue(report.dial) + self.progressBar_slider.setValue(report.slider) + + # Update buttons + self.lbl_buttons.setText(f"{bin(report.buttons)}") + # Update individual button checkboxes + for i in range(64): + radiobox_name = f"radioButton_{i}" + if hasattr(self, radiobox_name): + radiobox = getattr(self, radiobox_name) + radiobox.setChecked(report.is_button_pressed(i)) diff --git a/effects_graph_ui.py b/effects_graph_ui.py index 65396346..bd91aa56 100644 --- a/effects_graph_ui.py +++ b/effects_graph_ui.py @@ -35,6 +35,9 @@ def __init__(self, dlg=None): # Chart setup self.chart = PyQt6.QtCharts.QChart() + is_light = PyQt6.QtWidgets.QApplication.instance().property("is_light_theme") + chart_theme = PyQt6.QtCharts.QChart.ChartTheme.ChartThemeLight if is_light else PyQt6.QtCharts.QChart.ChartTheme.ChartThemeDark + self.chart.setTheme(chart_theme) self.chart.setBackgroundRoundness(5) self.chart.setMargins(PyQt6.QtCore.QMargins(0, 0, 0, 0)) @@ -70,15 +73,6 @@ def __init__(self, dlg=None): self.chart_yaxis_output.setMin(-10) self.chart_yaxis_output.setMax(10) - self.chart.legend().setLabelBrush( - PyQt6.QtWidgets.QApplication.instance().palette().text() - ) - for ax in self.chart.axes(): - ax.setLabelsBrush( - PyQt6.QtWidgets.QApplication.instance().palette().text() - ) - - self.chart.setBackgroundBrush(PyQt6.QtWidgets.QApplication.instance().palette().window()) self.graphWidget_Forces.setChart(self.chart) # Setup the Qline for the the effect diff --git a/effects_monitor.py b/effects_monitor.py index fe23ba21..863fd947 100644 --- a/effects_monitor.py +++ b/effects_monitor.py @@ -1,130 +1,154 @@ -import json -from base_ui import WidgetUI,CommunicationHandler -from PyQt6 import QtGui,QtWidgets -from PyQt6.QtCore import QTimer -from PyQt6.QtWidgets import QDialog, QVBoxLayout - -class EffectStatsUI(WidgetUI, CommunicationHandler): - def __init__(self, main=None, parent = None): - WidgetUI.__init__(self, main, 'effects_stats.ui') - CommunicationHandler.__init__(self) - self.main = main #type: main.MainUi - self.parent = parent - - self.pushButton_ResetData.clicked.connect(self.resetData) - self.spinBox_axis.valueChanged.connect(self.setAxis) - - self.timer = QTimer(self) - self.timer.timeout.connect(self.refreshUi) - icon_ok = QtGui.QIcon( - self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogYesButton) - ) - icon_ko = QtGui.QIcon( - self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogNoButton) - ) - self.icon_ok = icon_ok.pixmap(18, 18) - self.icon_ko = icon_ko.pixmap(18, 18) - self.setActiveState_cb(0) - self.axis = None - - - def cmdflags(self,flags): - if flags & CommunicationHandler.CMDFLAG_GETADR: - # enable axis selection - self.spinBox_axis.setEnabled(True) - - def setEnabled(self, a0: bool) -> None: - self.pushButton_ResetData.setEnabled(a0) - if not a0 and self.isVisible() and self.timer.isActive : - self.parent.hide() - return super().setEnabled(a0) - - def showEvent(self, a0: QtGui.QShowEvent) -> None: - self.timer.start(1000) - if self.axis == None: - self.get_value_async("fx", "cmdinfo", self.cmdflags,adr=16,conversion=int) # TODO remove in later version - return super().showEvent(a0) - - def hideEvent(self, a0) -> None: - if self.timer.isActive : self.timer.stop() - self.parent.hide() - return super().hideEvent(a0) - - def resetData(self): - self.send_value("fx","effectsDetails",0) - - def setAxis(self,axis): - self.axis = axis - - def refreshUi(self): - self.get_value_async("fx","effectsDetails",self.decodeData_cb,adr=self.axis) - self.get_value_async("fx","effects",self.setActiveState_cb,conversion=int) - - def setLabelPixmapState(self,label,state): - label.setPixmap(self.icon_ok if state else self.icon_ko) - - def setActiveState_cb(self,state): - self.setLabelPixmapState(self.label_used_1,state & 0x1 != 0) - self.setLabelPixmapState(self.label_used_2,state & 0x2 != 0) - self.setLabelPixmapState(self.label_used_3,state & 0x4 != 0) - self.setLabelPixmapState(self.label_used_4,state & 0x8 != 0) - self.setLabelPixmapState(self.label_used_5,state & 0x10 != 0) - self.setLabelPixmapState(self.label_used_6,state & 0x20 != 0) - self.setLabelPixmapState(self.label_used_7,state & 0x40 != 0) - self.setLabelPixmapState(self.label_used_8,state & 0x80 != 0) - self.setLabelPixmapState(self.label_used_9,state & 0x100 != 0) - self.setLabelPixmapState(self.label_used_10,state & 0x200 != 0) - self.setLabelPixmapState(self.label_used_11,state & 0x400 != 0) - self.setLabelPixmapState(self.label_used_12,state & 0x800 != 0) - - def decodeData_cb(self, data): - json_data = json.loads( '[' + data + ']' ) - if len(json_data) == 12: - self.label.setEnabled(json_data[0]["nb"] != 0) - self.label_2.setEnabled(json_data[1]["nb"] != 0) - self.label_3.setEnabled(json_data[2]["nb"] != 0) - self.label_4.setEnabled(json_data[3]["nb"] != 0) - self.label_5.setEnabled(json_data[4]["nb"] != 0) - self.label_6.setEnabled(json_data[5]["nb"] != 0) - self.label_7.setEnabled(json_data[6]["nb"] != 0) - self.label_8.setEnabled(json_data[7]["nb"] != 0) - self.label_9.setEnabled(json_data[8]["nb"] != 0) - self.label_10.setEnabled(json_data[9]["nb"] != 0) - self.label_11.setEnabled(json_data[10]["nb"] != 0) - self.label_12.setEnabled(json_data[11]["nb"] != 0) - self.progressBar.setValue(json_data[0]["max"]) - self.progressBar_2.setValue(json_data[1]["max"]) - self.progressBar_3.setValue(json_data[2]["max"]) - self.progressBar_4.setValue(json_data[3]["max"]) - self.progressBar_5.setValue(json_data[4]["max"]) - self.progressBar_6.setValue(json_data[5]["max"]) - self.progressBar_7.setValue(json_data[6]["max"]) - self.progressBar_8.setValue(json_data[7]["max"]) - self.progressBar_9.setValue(json_data[8]["max"]) - self.progressBar_10.setValue(json_data[9]["max"]) - self.progressBar_11.setValue(json_data[10]["max"]) - self.progressBar_12.setValue(json_data[11]["max"]) - -class EffectsMonitorDialog(QDialog): - def __init__(self,main=None): - QDialog.__init__(self, main) - self.ui = EffectStatsUI(main,self) - self.layout = QVBoxLayout() - self.layout.setContentsMargins(0,0,0,0) - self.layout.addWidget(self.ui) - self.setLayout(self.layout) - self.setWindowTitle("Effects statistics") - - def set_max_axes(self,axes): - self.ui.spinBox_axis.setMaximum(axes) - - def setEnabled(self, a0: bool) -> None: - self.ui.setEnabled(a0) - if not a0: - self.close() - return super().setEnabled(a0) - - def display(self): - self.show() - self.raise_() - self.activateWindow() +import json +from base_ui import WidgetUI,CommunicationHandler +from PyQt6 import QtGui,QtWidgets +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QDialog, QVBoxLayout +import effects_graph_ui + +class EffectStatsUI(WidgetUI, CommunicationHandler): + def __init__(self, main=None, parent = None): + WidgetUI.__init__(self, main, 'effects_stats.ui') + CommunicationHandler.__init__(self) + self.main = main #type: main.MainUi + self.parent = parent + self.pending_updates = 0 + + self.effects_graph_dlg = effects_graph_ui.EffectsGraphDialog(self) + + self.main.maxaxischanged.connect(self.spinBox_axis.setMaximum) + self.main.maxaxischanged.connect(self.effects_graph_dlg.set_max_axes) + + self.pushButton_ResetData.clicked.connect(self.resetData) + self.pushButton_graph.clicked.connect(self.openGraph) + self.spinBox_axis.valueChanged.connect(self.setAxis) + self.pushButton_reload.clicked.connect(self.reloadUi) + + self.monitoring_available = False + + self.timer = QTimer(self) + self.timer.timeout.connect(self.refreshUi) + icon_ok = QtGui.QIcon( + self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogYesButton) + ) + icon_ko = QtGui.QIcon( + self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogNoButton) + ) + self.icon_ok = icon_ok.pixmap(18, 18) + self.icon_ko = icon_ko.pixmap(18, 18) + self.setActiveState_cb(0) + self.axis = None + + + def cmdflags(self,flags): + if flags & CommunicationHandler.CMDFLAG_GETADR: + # enable axis selection + self.spinBox_axis.setEnabled(True) + + def setEnabled(self, enable: bool) -> None: + if not self.monitoring_available : + enable = False + self.pushButton_ResetData.setEnabled(enable) + + # Stop or start the timer based on connection/enabled state + if enable: + if not self.timer.isActive(): + self.timer.start(10000) + else: + if self.timer.isActive(): + self.timer.stop() + + return super().setEnabled(enable) + + def setEffectAvailable(self, available: bool) -> None: + self.monitoring_available = available + if available: + self.setEnabled(available) + + def showEvent(self, a0: QtGui.QShowEvent) -> None: + if self.isEnabled(): + self.timer.start(10000) + if self.axis == None: + self.get_value_async("fx", "cmdinfo", self.cmdflags,adr=16,conversion=int) # TODO remove in later version + return super().showEvent(a0) + + def hideEvent(self, a0) -> None: + if self.timer.isActive : self.timer.stop() + self.parent.hide() + return super().hideEvent(a0) + + def resetData(self): + self.send_value("fx","effectsDetails",0) + + def setAxis(self,axis): + self.axis = axis + + def refreshUi(self): + self.pushButton_reload.setEnabled(False) + self.pending_updates = 2 + self.get_value_async("fx","effectsDetails",self.decodeData_cb,adr=self.axis) + self.get_value_async("fx","effects",self.setActiveState_cb,conversion=int) + + def setLabelPixmapState(self,label,state): + label.setPixmap(self.icon_ok if state else self.icon_ko) + + def setActiveState_cb(self,state): + try: + self.setLabelPixmapState(self.label_used_1,state & 0x1 != 0) + self.setLabelPixmapState(self.label_used_2,state & 0x2 != 0) + self.setLabelPixmapState(self.label_used_3,state & 0x4 != 0) + self.setLabelPixmapState(self.label_used_4,state & 0x8 != 0) + self.setLabelPixmapState(self.label_used_5,state & 0x10 != 0) + self.setLabelPixmapState(self.label_used_6,state & 0x20 != 0) + self.setLabelPixmapState(self.label_used_7,state & 0x40 != 0) + self.setLabelPixmapState(self.label_used_8,state & 0x80 != 0) + self.setLabelPixmapState(self.label_used_9,state & 0x100 != 0) + self.setLabelPixmapState(self.label_used_10,state & 0x200 != 0) + self.setLabelPixmapState(self.label_used_11,state & 0x400 != 0) + self.setLabelPixmapState(self.label_used_12,state & 0x800 != 0) + finally: + self.check_update_finished() + + def decodeData_cb(self, data): + try: + json_data = json.loads( '[' + data + ']' ) + if len(json_data) == 12: + self.label.setEnabled(json_data[0]["nb"] != 0) + self.label_2.setEnabled(json_data[1]["nb"] != 0) + self.label_3.setEnabled(json_data[2]["nb"] != 0) + self.label_4.setEnabled(json_data[3]["nb"] != 0) + self.label_5.setEnabled(json_data[4]["nb"] != 0) + self.label_6.setEnabled(json_data[5]["nb"] != 0) + self.label_7.setEnabled(json_data[6]["nb"] != 0) + self.label_8.setEnabled(json_data[7]["nb"] != 0) + self.label_9.setEnabled(json_data[8]["nb"] != 0) + self.label_10.setEnabled(json_data[9]["nb"] != 0) + self.label_11.setEnabled(json_data[10]["nb"] != 0) + self.label_12.setEnabled(json_data[11]["nb"] != 0) + self.progressBar.setValue(json_data[0]["max"]) + self.progressBar_2.setValue(json_data[1]["max"]) + self.progressBar_3.setValue(json_data[2]["max"]) + self.progressBar_4.setValue(json_data[3]["max"]) + self.progressBar_5.setValue(json_data[4]["max"]) + self.progressBar_6.setValue(json_data[5]["max"]) + self.progressBar_7.setValue(json_data[6]["max"]) + self.progressBar_8.setValue(json_data[7]["max"]) + self.progressBar_9.setValue(json_data[8]["max"]) + self.progressBar_10.setValue(json_data[9]["max"]) + self.progressBar_11.setValue(json_data[10]["max"]) + self.progressBar_12.setValue(json_data[11]["max"]) + finally: + self.check_update_finished() + + def openGraph(self): + self.effects_graph_dlg.display() + self.effects_graph_dlg.set_total_output_display(False) # come from the main + + def check_update_finished(self): + self.pending_updates -= 1 + if self.pending_updates <= 0: + self.pushButton_reload.setEnabled(True) + + def reloadUi(self): + self.timer.stop() + self.refreshUi() + self.timer.start(10000) \ No newline at end of file diff --git a/effects_tuning_ui.py b/effects_tuning_ui.py index 18affd22..a24ff341 100644 --- a/effects_tuning_ui.py +++ b/effects_tuning_ui.py @@ -346,11 +346,13 @@ def draw_effect_conditional(self, x_min, x_max, x_label, start_data, end_data, y """Draw a generic effect in a qwidget : the effect response on the metrics range.""" # Chart setup full range chart = PyQt6.QtCharts.QChart() + is_light = PyQt6.QtWidgets.QApplication.instance().property("is_light_theme") + chart_theme = PyQt6.QtCharts.QChart.ChartTheme.ChartThemeLight if is_light else PyQt6.QtCharts.QChart.ChartTheme.ChartThemeDark + chart.setTheme(chart_theme) chart.setBackgroundRoundness(5) chart.setMargins(PyQt6.QtCore.QMargins(0, 0, 0, 0)) chart.legend().hide() - chart.setBackgroundBrush(PyQt6.QtWidgets.QApplication.instance().palette().window()) - + font = PyQt6.QtGui.QFont() font.setPixelSize(10) @@ -384,14 +386,6 @@ def draw_effect_conditional(self, x_min, x_max, x_label, start_data, end_data, y ) chart.addAxis(chart_y_axis_forces, PyQt6.QtCore.Qt.AlignmentFlag.AlignLeft) - for axe in chart.axes(): - axe.setTitleBrush( - PyQt6.QtWidgets.QApplication.instance().palette().text() - ) - axe.setLabelsBrush( - PyQt6.QtWidgets.QApplication.instance().palette().text() - ) - qwidget.setChart(chart) q_line = PyQt6.QtCharts.QLineSeries() diff --git a/encoder_tuning_ui.py b/encoder_tuning_ui.py index 7fa197d2..e31fdbb0 100644 --- a/encoder_tuning_ui.py +++ b/encoder_tuning_ui.py @@ -226,12 +226,12 @@ def draw_simulation_min(self): # Chart setup chart = PyQt6.QtCharts.QChart() + is_light = PyQt6.QtWidgets.QApplication.instance().property("is_light_theme") + chart_theme = PyQt6.QtCharts.QChart.ChartTheme.ChartThemeLight if is_light else PyQt6.QtCharts.QChart.ChartTheme.ChartThemeDark + chart.setTheme(chart_theme) chart.setBackgroundRoundness(5) chart.setMargins(PyQt6.QtCore.QMargins(0, 0, 0, 0)) chart.legend().hide() - chart.setBackgroundBrush( - PyQt6.QtWidgets.QApplication.instance().palette().window() - ) font = PyQt6.QtGui.QFont() font.setPixelSize(7) @@ -272,10 +272,6 @@ def draw_simulation_min(self): ) chart.addAxis(chart_y_axis_forces, PyQt6.QtCore.Qt.AlignmentFlag.AlignLeft) - for axe in chart.axes(): - axe.setTitleBrush(PyQt6.QtWidgets.QApplication.instance().palette().text()) - axe.setLabelsBrush(PyQt6.QtWidgets.QApplication.instance().palette().text()) - self.graph_min.setChart(chart) q_line3 = PyQt6.QtCharts.QLineSeries() @@ -350,12 +346,12 @@ def draw_speed_random(self): # Chart setup chart = PyQt6.QtCharts.QChart() + is_light = PyQt6.QtWidgets.QApplication.instance().property("is_light_theme") + chart_theme = PyQt6.QtCharts.QChart.ChartTheme.ChartThemeLight if is_light else PyQt6.QtCharts.QChart.ChartTheme.ChartThemeDark + chart.setTheme(chart_theme) chart.setBackgroundRoundness(5) chart.setMargins(PyQt6.QtCore.QMargins(0, 0, 0, 0)) chart.legend().hide() - chart.setBackgroundBrush( - PyQt6.QtWidgets.QApplication.instance().palette().window() - ) font = PyQt6.QtGui.QFont() font.setPixelSize(7) @@ -397,10 +393,6 @@ def draw_speed_random(self): ) chart.addAxis(chart_y_axis_forces, PyQt6.QtCore.Qt.AlignmentFlag.AlignLeft) - for axe in chart.axes(): - axe.setTitleBrush(PyQt6.QtWidgets.QApplication.instance().palette().text()) - axe.setLabelsBrush(PyQt6.QtWidgets.QApplication.instance().palette().text()) - self.graph_random.setChart(chart) q_line = PyQt6.QtCharts.QLineSeries() @@ -457,13 +449,13 @@ def draw_accel_factor(self): # Chart setup chart = PyQt6.QtCharts.QChart() + is_light = PyQt6.QtWidgets.QApplication.instance().property("is_light_theme") + chart_theme = PyQt6.QtCharts.QChart.ChartTheme.ChartThemeLight if is_light else PyQt6.QtCharts.QChart.ChartTheme.ChartThemeDark + chart.setTheme(chart_theme) chart.setBackgroundRoundness(5) chart.setMargins(PyQt6.QtCore.QMargins(0, 0, 0, 0)) chart.legend().hide() - chart.setBackgroundBrush( - PyQt6.QtWidgets.QApplication.instance().palette().window() - ) - + font = PyQt6.QtGui.QFont() font.setPixelSize(7) @@ -505,10 +497,6 @@ def draw_accel_factor(self): ) chart.addAxis(chart_y_axis_forces, PyQt6.QtCore.Qt.AlignmentFlag.AlignLeft) - for axe in chart.axes(): - axe.setTitleBrush(PyQt6.QtWidgets.QApplication.instance().palette().text()) - axe.setLabelsBrush(PyQt6.QtWidgets.QApplication.instance().palette().text()) - self.graph_accel.setChart(chart) q_line = PyQt6.QtCharts.QLineSeries() diff --git a/encoderconf_ui.py b/encoderconf_ui.py index c0757076..179bae35 100644 --- a/encoderconf_ui.py +++ b/encoderconf_ui.py @@ -29,20 +29,25 @@ def setType(self,id): found = False elif(id == 4): # MT SPI self.widget = (MtEncoderConf(self,self.main)) - self.setTitle("SPI Settings") + self.setTitle("SPI Settings (port SPI3)") elif(id == 5): # BISS-C self.widget = (BissEncoderConf(self,self.main)) - self.setTitle("BISS Settings") + self.setTitle("BISS Settings (port SPI3 - exclusif usage)") elif(id == 6): # SSI self.widget = (SsiEncoderConf(self,self.main)) - self.setTitle("SSI Settings") + self.setTitle("SSI Settings (port SPI3 - exclusif usage)") else: layout.addWidget(QLabel("No settings")) + found = False + if found: + layout.setContentsMargins(5,0,5,0) + layout.setStretch(0,0) + if self.widget: + layout.addWidget(self.widget) + self.setLayout(layout) - layout.setContentsMargins(0,0,0,0) - if self.widget: - layout.addWidget(self.widget) + if self.widget: self.applyBtn = QPushButton("Apply") self.applyBtn.clicked.connect(self.widget.apply) footer = QHBoxLayout() @@ -52,7 +57,6 @@ def setType(self,id): footer.addStretch(5) layout.addLayout(footer) - self.setLayout(layout) class EncoderOption(QWidget): def __init__(self,parent): @@ -89,7 +93,7 @@ def initUI(self): self.spinBox_cpr = QSpinBox() self.spinBox_cpr.setRange(0,0xffff) - layout.addWidget(QLabel("CPR = 4x PPR")) + layout.addRow(QLabel("CPR = 4x PPR")) layout.addRow(QLabel("CPR:"),self.spinBox_cpr) self.checkBox_index = QCheckBox("Use index homing") @@ -121,7 +125,6 @@ def initUI(self): self.spinBox_cs = QSpinBox() self.spinBox_cs.setRange(1,3) - layout.addWidget(QLabel("SPI3 extension port")) layout.addRow(QLabel("CS pin"),self.spinBox_cs) self.comboBox_mode = QComboBox() @@ -169,10 +172,8 @@ def initUI(self): self.checkBox_direction = QCheckBox("Reverse direction (default)") self.spinBox_bits = QSpinBox() self.spinBox_bits.setRange(1,32) - layout.addWidget(QLabel("SPI3 extension port")) layout.addRow(QLabel("Bits"),self.spinBox_bits) layout.addWidget(self.checkBox_direction) - layout.addWidget(QLabel("Port is used exclusively!")) self.setLayout(layout) def onshown(self): @@ -202,11 +203,9 @@ def initUI(self): self.comboBox_mode = QComboBox() self.comboBox_speed = QComboBox() - layout.addWidget(QLabel("SPI3 extension port")) layout.addRow(QLabel("Bits"),self.spinBox_bits) layout.addRow(QLabel("Mode"),self.comboBox_mode) layout.addRow(QLabel("SPI speed"),self.comboBox_speed) - layout.addWidget(QLabel("Port is used exclusively!")) self.setLayout(layout) def updateSpeeds(self,reply): diff --git a/expo_ui.py b/expo_ui.py index 18c36ad8..4f36ad5a 100644 --- a/expo_ui.py +++ b/expo_ui.py @@ -68,12 +68,13 @@ def updateExponent(self,val): def makeGraph(self): - + is_light = PyQt6.QtWidgets.QApplication.instance().property("is_light_theme") + chart_theme = PyQt6.QtCharts.QChart.ChartTheme.ChartThemeLight if is_light else PyQt6.QtCharts.QChart.ChartTheme.ChartThemeDark + self.chart.setTheme(chart_theme) self.chart.setBackgroundRoundness(5) self.chart.setMargins(PyQt6.QtCore.QMargins(0, 0, 0, 0)) self.chart.legend().hide() - self.chart.setBackgroundBrush(PyQt6.QtWidgets.QApplication.instance().palette().window()) - + font = PyQt6.QtGui.QFont() font.setPixelSize(10) @@ -105,13 +106,6 @@ def makeGraph(self): ) ) self.chart.addAxis(self.chart_y_axis, PyQt6.QtCore.Qt.AlignmentFlag.AlignLeft) - for axe in self.chart.axes(): - axe.setTitleBrush( - PyQt6.QtWidgets.QApplication.instance().palette().text() - ) - axe.setLabelsBrush( - PyQt6.QtWidgets.QApplication.instance().palette().text() - ) self.graph_expo.setChart(self.chart) def updateCurve(self,exponent): diff --git a/ffb_ui.py b/ffb_ui.py index 2ff871cf..ba74cf0b 100644 --- a/ffb_ui.py +++ b/ffb_ui.py @@ -3,7 +3,7 @@ from PyQt6.QtWidgets import QWidget,QToolButton from PyQt6.QtWidgets import QMessageBox,QVBoxLayout,QCheckBox,QButtonGroup,QGridLayout,QSpinBox from PyQt6 import uic -from helper import res_path,classlistToIds,splitListReply,throttle +from helper import res_path,classlistToIds,splitListReply,throttle,qtBlockAndCall from PyQt6.QtCore import QTimer,QEvent, pyqtSignal import main import buttonconf_ui @@ -111,10 +111,14 @@ def __init__(self, main : 'main.MainUi'=None, title = "FFB main"): self.register_callback("fx", "frictionPctSpeedToRampup", self.set_friction_pct_speed_rampup,0,int) - if(self.init_ui()): - tabId = self.main.add_tab(self,title) - self.main.select_tab(tabId) - self.timer.start(500) # timer always updates + # --- Smoothing Controls --- + self.radioButton_reconfilter_0.toggled.connect(lambda checked: self.send_recon_filter(0) if checked else None) + self.radioButton_reconfilter_1.toggled.connect(lambda checked: self.send_recon_filter(1) if checked else None) + self.radioButton_reconfilter_2.toggled.connect(lambda checked: self.send_recon_filter(2) if checked else None) + self.radioButton_reconfilter_3.toggled.connect(lambda checked: self.send_recon_filter(3) if checked else None) + self.register_callback("fx", "reconFilterMode", self.update_recon_filter_ui, 0, int) + + self.init_ui() self.buttonbtns.buttonClicked.connect(self.buttonsChanged) self.axisbtns.buttonClicked.connect(self.axesChanged) @@ -131,6 +135,7 @@ def init_ui(self): self.send_command("main","lsain",0,'?') # get analog types self.send_command("main","aintypes",0,'?') # get active analog + self.send_commands("fx", ["reconFilterMode"]) self.updateSliders() self.send_command("main","hidsendspd",0,'!') # get speed @@ -141,12 +146,13 @@ def init_ui(self): return True # Tab is currently shown - # def showEvent(self,event): - # self.timer.start(500) + def showEvent(self,event): + self.init_ui() + self.startTimer() # # Tab is hidden - # def hideEvent(self,event): - # self.timer.stop() + def hideEvent(self,event): + self.stopTimer() def startTimer(self): self.timer.start(500) @@ -337,6 +343,28 @@ def updateAnalogSources(self,types): self.groupBox_analogaxes.setLayout(layout) + # Called when the reconstruction filter slider is moved + def send_recon_filter(self, value): + """Sends the selected reconstruction filter mode to the firmware.""" + self.send_value("fx", "reconFilterMode", value) + + # Callback to update the reconstruction filter UI from firmware data + def update_recon_filter_ui(self, value): + """Updates the reconstruction filter slider and label.""" + button_radio = None + if (value < 0 or value >= 4): + self.main.log(f"Warning: Received unknown reconstruction filter mode value: {value}") + elif value == 0: + button_radio = self.radioButton_reconfilter_0 + elif value == 1: + button_radio = self.radioButton_reconfilter_1 + elif value == 2: + button_radio = self.radioButton_reconfilter_2 + elif value == 3: + button_radio = self.radioButton_reconfilter_3 + qtBlockAndCall(button_radio, button_radio.setChecked, True) + button_radio.setChecked(True) + @throttle(50) def cffilter_changed(self,v,send=True): self.tech_log.debug("Freq %s send %d", v, send) diff --git a/helper.py b/helper.py index 8f25f211..5367a214 100644 --- a/helper.py +++ b/helper.py @@ -109,4 +109,26 @@ def call(): # Splits a reply in the format "name:value,name2:value2"... into a dict def map_infostring(repl,type=float): - return{key:type(value) for (key,value) in [entry.split(":") for entry in repl.split(",")]} \ No newline at end of file + return{key:type(value) for (key,value) in [entry.split(":") for entry in repl.split(",")]} + + +def windows_theme_is_light(): + """Detect if the user is using Light Mode in Windows. + + Returns True if Light Mode is enabled, False if Dark Mode is enabled, + and None if the detection is not applicable or fails. + """ + import sys + if sys.platform != "win32": + return None + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + ) + subkey, regtype = winreg.QueryValueEx(key, "AppsUseLightTheme") + winreg.CloseKey(key) + return subkey == 1 + except Exception: + return None \ No newline at end of file diff --git a/hid_comms.py b/hid_comms.py new file mode 100644 index 00000000..129746a6 --- /dev/null +++ b/hid_comms.py @@ -0,0 +1,273 @@ +import sys +import hid +import struct +from dataclasses import dataclass +from PyQt6.QtCore import QThread, pyqtSignal + +VID = 0x1209 +PID = 0xFFB0 + +@dataclass +class ObjectReport: + report_id: int + +@dataclass +class HIDReport(ObjectReport): + buttons: int + axis_x: int + axis_y: int + axis_z: int + axis_rx: int + axis_ry: int + axis_rz: int + dial: int + slider: int + + def is_button_pressed(self, button_index): + """Helper to check if button N (0-63) is pressed""" + return (self.buttons >> button_index) & 1 + +@dataclass +class CMDReport(ObjectReport): + report_id: int + type: int + cls: int + instance: int + command: int + value: int + address: int + +class HIDWorker(QThread): + """ + Dedicated thread for HID reading to avoid blocking the UI. + """ + # Signal to send HIDReport data + data_received = pyqtSignal(ObjectReport) + # Signal to notify connection status + connection_status = pyqtSignal(bool, str) # connected, message + + def __init__(self): + super().__init__() + self.running = False + self.device_joy = None # Joysttick + self.device_cmd = None # Commands + self.is_connected = False + + self.unpack_format = None + self.axis_size = 0 # 16 or 32 + self.expected_len = 0 + + def connect_device(self): + """Connect to HID device""" + try: + # Open hid device + + interfaces = hid.enumerate(VID, PID) + path_joy = None + path_cmd = None + + for intf in interfaces: + usage_page = intf['usage_page'] + path = intf['path'] + + # NOTE: Sous Windows usage_page est fiable. + # Sous Linux, parfois hidapi retourne 0, on peut se fier à 'interface_number' + # (souvent 0=Joy, 1 ou 2=Config) + + if usage_page == 0x01: # Generic Desktop -> Joystick + print(f" -> Trouvé Joystick (Gaming View) sur {path}") + path_joy = path + + elif usage_page == 0xFF00: # Vendor Defined -> Config + print(f" -> Trouvé Config (Commandes) sur {path}") + path_cmd = path + + # --- OUVERTURE DES CONNEXIONS --- + + # 1. Connexion Joystick (Lecture seule, non bloquante) + if path_joy: + self.device_joy = hid.device() + self.device_joy.open_path(path_joy) + self.device_joy.set_nonblocking(1) # Important pour le polling rapide + print("✅ Connecté au Joystick") + + + # 2. Connexion Commandes (Lecture/Ecriture) + if path_cmd: + self.device_cmd = hid.device() + self.device_cmd.open_path(path_cmd) + # On peut le laisser bloquant ou non selon ta logique d'envoi + self.device_cmd.set_nonblocking(1) + print("✅ Connecté aux Commandes") + + + except OSError as e: + print(f"Error opening device: {e}") + self.is_connected = False + self.connection_status.emit(False, f"FFBoard Device not found on USB: {e}") + return False + except Exception as e: + print(f"Unexpected error: {e}") + self.is_connected = False + self.connection_status.emit(False, f"Unexpected error during connexion: {e}") + + return (self.device_joy is not None) or (self.device_cmd is not None) + + def disconnect_device(self): + """Disconnect from HID device""" + if self.device_joy: + try: + self.device_joy.close() + self.is_connected = False + print("Device disconnected") + except Exception as e: + print(f"Error disconnecting device: {e}") + + def run(self): + # Connect to device when thread starts + if not self.connect_device(): + # If connection fails, don't continue with the thread + return + + try: + + self.connection_status.emit(True, "Successfull") + print(" [Thread Joystick] Démarré") + while self.running and self.device_joy: + # Lecture brute (taille max 64) + data = self.device_joy.read(64) + if data: + parsed_report = self.parse_report(data) + if parsed_report is not None: + self.data_received.emit(parsed_report) + self.msleep(10) # ~100Hz + #self.sendCommand(1,0xA01,0,0x00,0) # get power + + except Exception as e: + print(f"Error HID: {e}") + self.connection_status.emit(False, f"Communication error: {e}") + # In case of error, stop the thread + self.running = False + finally: + self.disconnect_device() + + def start_thread(self): + """Start the thread and set running flag to True""" + self.running = True + self.start() + + def stop(self): + """Stop the thread and disconnect device""" + self.running = False + self.wait() + if self.device_joy: self.device_joy.close() + if self.device_cmd: self.device_cmd.close() + + def detect_format(self, data_len): + """ + Determines the format based on the size of the first received packet. + """ + # Note : We accept >= because sometimes USB adds padding up to 64 bytes + if data_len >= 41: + # 1B ID + 8B Buttons + 8*4B Axes = 41 bytes min + print("Detection: Firmware 32-bit (High Precision)") + self.axis_size = 32 + # '<' little endian, 'B' uchar, 'Q' ulonglong, 'i' int (4 bytes) * 8 + self.unpack_format = '= 25: + # 1B ID + 8B Buttons + 8*2B Axes = 25 bytes min + print("Detection: Firmware 16-bit (Standard)") + self.axis_size = 16 + # '<' little endian, 'B' uchar, 'Q' ulonglong, 'h' short (2 bytes) * 8 + self.unpack_format = ' bytes + raw = bytes(data) + + # Vérification du Report ID (0xA1 selon ton code) + if raw[0] == 0xA1: + try: + + return CMDReport( + report_id=raw[0], + type=raw[1], + cls = struct.unpack(' bool: + # Change la page dans le QStackedWidget + self.content_stack.setCurrentWidget(selected_tab_data["page"]) + + def has_tab(self, class_name) -> bool: """Check if the tab "name" exist in the tab list.""" - names = [ - self.tabWidget_main.tabText(i) for i in range(self.tabWidget_main.count()) - ] - return name in names + return class_name in self.TAB_MAPPING - def reset_tabs(self): + def reset_tabs(self, force=False): """Remove all the tab and unregister the callBack.""" self.active_classes = {} self.profile_ui.set_save_btn(False) - for i in range(self.tabWidget_main.count() - 1, 0, -1): - self.del_tab(self.tabWidget_main.widget(i)) + + # Remove tabs from board, keep settings/support/serial + tab_to_delete = [] + for item in self.TAB_MAPPING: + if self.TAB_MAPPING[item]["from_board"] or force: + tab_to_delete.append(item) + + for tab_id in tab_to_delete: + self.del_tab(tab_id) + self.remove_callbacks() self.tabsinitialized.emit(False) - self.effects_monitor_dlg.setEnabled(False) - self.effects_graph_dlg.setEnabled(False) - self.effects_graph_dlg.set_total_output_display(False) - self.actionActive_threads.setEnabled(False) - self.actionEffectsMonitor.setEnabled(False) - self.actionEffects_forces.setEnabled(False) + #TODO VMA remove self.effects_monitor_dlg.setEnabled(False) + #TODO VMA remove self.effects_graph_dlg.setEnabled(False) + #TODO VMA remove self.effects_graph_dlg.set_total_output_display(False) + #TODO VMA remove self.actionEffectsMonitor.setEnabled(False) + #TODO VMA remove self.actionEffects_forces.setEnabled(False) self.axes = 0 self.maxaxischanged.emit(self.axes) @@ -457,7 +508,7 @@ def update_tabs_cb(active): #print(new_active_classes) for classe, name in delete_classes: - self.del_tab(classe) + self.del_tab(name) del self.active_classes[name] for name, classe_active in new_active_classes.items(): if name in self.active_classes: @@ -465,11 +516,19 @@ def update_tabs_cb(active): classname = classe_active["name"] if classe_active["id"] == 1 or classe_active["id"] == 2 or classe_active["id"] == 3: self.main_class_ui = ffb_ui.FfbUI(main=self, title=classname) + name_main = classname + if classe_active["id"] == 1 : + name_main = "FFB Wheel" + elif classe_active["id"] == 2 : + name_main = "FFB Joystick" + self.add_tab(self.main_class_ui, classname, name_main, icon_path=helper.res_path("ffb.png","res/img")) + # self.select_tab(classname) + self.active_classes[name] = self.main_class_ui self.profile_ui.set_save_btn(True) self.tab_connections.append(self.main_class_ui.ffb_rate_event.connect(self.wrapper_status_bar.update_ffb_rate)) # Start ffb timer - + self.tab_connections.append(self.serialchooser.hidden.connect(self.main_class_ui.startTimer)) self.tab_connections.append(self.serialchooser.shown.connect(self.main_class_ui.stopTimer)) self.tab_connections.append(self.serialchooser.shown.connect(lambda : self.wrapper_status_bar.update_ffb_block_display(False))) @@ -478,82 +537,78 @@ def update_tabs_cb(active): elif classe_active["id"] == 0xA01: classe = axis_ui.AxisUI(main=self, unique=classe_active["unique"]) - name_axis = classe_active["name"] + ":" + chr(classe.axis + ord("0")) self.active_classes[name] = classe - self.add_tab(classe, name_axis) + name_axis = classe_active["name"] + " " + chr(classe.axis + ord("X")) + self.add_tab(classe, name, name_axis, icon_path=helper.res_path("axis.png","res/img")) self.profile_ui.set_save_btn(True) self.axes = max(self.axes,classe.axis) self.maxaxischanged.emit(self.axes) - self.effects_graph_dlg.set_total_output_display(True) + #TODO VMA A analyser self.effects_graph_dlg.set_total_output_display(True) elif classe_active["id"] == 0x81 or classe_active["id"] == 0x82 or \ classe_active["id"] == 0x83: classe = tmc4671_ui.TMC4671Ui(main=self, unique=classe_active["unique"]) - name_axis = classe_active["name"] + ":" + chr(classe.axis + ord("0")) self.active_classes[name] = classe - self.add_tab(classe, name_axis) + name_axis = classe_active["name"] + " " + chr(classe.axis + ord("X")) + self.add_tab(classe, name, name_axis, icon_path=helper.res_path("motordriver.png","res/img")) self.profile_ui.set_save_btn(True) elif classe_active["id"] == 0x84: classe = pwmdriver_ui.PwmDriverUI(main=self) self.active_classes[name] = classe - self.add_tab(classe, classe_active["name"]) + self.add_tab(classe, name, classe_active["name"], icon_path=helper.res_path("motordriver.png","res/img")) self.profile_ui.set_save_btn(True) elif classe_active["id"] == 0xD: classe = midi_ui.MidiUI(main=self) self.active_classes[name] = classe - self.add_tab(classe, classe_active["name"]) + self.add_tab(classe, classe_active["name"], icon_path=helper.res_path("motordriver.png","res/img")) elif classe_active["id"] == 0xB: classe = tmcdebug_ui.TMCDebugUI(main=self) self.active_classes[name] = classe - self.add_tab(classe, classe_active["name"]) + self.add_tab(classe, name, classe_active["name"], icon_path=helper.res_path("motordriver.png","res/img")) elif classe_active["id"] == 0x85 or classe_active["id"] == 0x86: classe = odrive_ui.OdriveUI(main=self, unique=classe_active["unique"]) name_axis = classe_active["name"] self.active_classes[name] = classe - self.add_tab(classe, name_axis) + self.add_tab(classe, name, name_axis, icon_path=helper.res_path("motordriver.png","res/img")) self.profile_ui.set_save_btn(True) elif classe_active["id"] == 0x87 or classe_active["id"] == 0x88: classe = vesc_ui.VescUI(main=self, unique=classe_active["unique"]) name_axis = classe_active["name"] self.active_classes[name] = classe - self.add_tab(classe, name_axis) + self.add_tab(classe, name, name_axis, icon_path=helper.res_path("motordriver.png","res/img")) self.profile_ui.set_save_btn(True) elif classe_active["id"] == 0x89 or classe_active["id"] == 0x8A: classe = simplemotion_ui.SimplemotionUI(main=self, unique=classe_active["unique"]) name_axis = classe_active["name"] self.active_classes[name] = classe - self.add_tab(classe, name_axis) + self.add_tab(classe, name, name_axis, icon_path=helper.res_path("motordriver.png","res/img")) self.profile_ui.set_save_btn(True) elif classe_active["id"] == 0xA02: # Effects manager - self.effects_monitor_dlg.setEnabled(True) - self.effects_graph_dlg.setEnabled(True) - self.actionEffectsMonitor.setEnabled(True) - self.actionEffects_forces.setEnabled(True) + self.dashboard.setEffectAvailable(True) + #TODO VMA enabled the effects self.effects_monitor_dlg.setEnabled(True) + #TODO VMA enabled self.effects_graph_dlg.setEnabled(True) + #TODO VMA remove self.actionEffectsMonitor.setEnabled(True) + #TODO VMA remove self.actionEffects_forces.setEnabled(True) + pass elif classe_active["id"] == 0x8B or classe_active["id"] == 0x8C: classe = rmd_ui.RmdUI(main=self, unique=classe_active["unique"]) name_axis = classe_active["name"] self.active_classes[name] = classe - self.add_tab(classe, name_axis) + self.add_tab(classe, name, name_axis, icon_path=helper.res_path("motordriver.png","res/img")) self.profile_ui.set_save_btn(True) elif classe_active["id"] == 0x5: classe = canremote_ui.CanRemoteUi(main=self) name_axis = classe_active["name"] self.active_classes[name] = classe - self.add_tab(classe, name_axis) + self.add_tab(classe, name, name_axis) self.profile_ui.set_save_btn(True) - - self.tabsinitialized.emit(True) self.get_value_async("sys", "lsactive", update_tabs_cb, delete=True) self.get_value_async( "sys", "heapfree", self.wrapper_status_bar.update_ram_used, delete=True ) - def cmdinfo18_cb(x): - self.actionActive_threads.setEnabled(x==1) - self.active_threads_dlg.set_taskstats_enabled(x==1) - self.get_value_async("sys","cmdinfo",adr=18,conversion=int,callback=cmdinfo18_cb) # Check taskstats - self.get_value_async("sys","cmdinfo",adr=25,conversion=int,callback=lambda x:self.active_threads_dlg.set_tasklist_enabled(x==1) ) # Check taskstats + def reconnect(self): """Reconnect the board : re-open the serial link, and check it.""" @@ -579,9 +634,6 @@ def close(): self.serial.flush() # Immediately send PyQt6.QtCore.QTimer.singleShot(250, close) # Close port after 250ms because no signal is currently working. Should ensure data has been sent. - else: - close() # Close port - def version_check(self, ver): """Check if the UI is compatible with this board firmware.""" self.fw_version_str = ver.replace("\n", "") @@ -597,12 +649,17 @@ def version_check(self, ver): fw_outdated = False gui_outdated = False + # Simplified version comparison logic fw_outdated = ( - min_fw_split[0] > fw_ver_split[0] \ - or min_fw_split[1] > fw_ver_split[1] and min_fw_split[0] == fw_ver_split[0] \ - or min_fw_split[2] > fw_ver_split[2] and min_fw_split[1] == fw_ver_split[1] and min_fw_split[0] == fw_ver_split[0] + min_fw_split[0] > fw_ver_split[0] or + (min_fw_split[0] == fw_ver_split[0] and min_fw_split[1] > fw_ver_split[1]) or + (min_fw_split[0] == fw_ver_split[0] and min_fw_split[1] == fw_ver_split[1] and min_fw_split[2] > fw_ver_split[2]) + ) + gui_outdated = ( + min_fw_split[0] < fw_ver_split[0] or + (min_fw_split[0] == fw_ver_split[0] and min_fw_split[1] < fw_ver_split[1]) or + (min_fw_split[0] == fw_ver_split[0] and min_fw_split[1] == fw_ver_split[1] and min_fw_split[2] < fw_ver_split[2]) ) - gui_outdated = min_fw_split[0] < fw_ver_split[0] or min_fw_split[1] < fw_ver_split[1] and min_fw_split[0] == fw_ver_split[0] if gui_outdated: msg = PyQt6.QtWidgets.QMessageBox( @@ -614,6 +671,9 @@ def version_check(self, ver): "firmware and GUI are up to date if you encounter errors.") ) msg.exec() + if hasattr(self, 'serialchooser') and self.serialchooser: + self.serialchooser.update_port_name_outdated() + elif fw_outdated: msg = PyQt6.QtWidgets.QMessageBox( PyQt6.QtWidgets.QMessageBox.Icon.Information, @@ -626,7 +686,10 @@ def version_check(self, ver): "and GUI are up to date if you encounter errors.") ) msg.exec() - # Check github + if hasattr(self, 'serialchooser') and self.serialchooser: + self.serialchooser.update_port_name_outdated() + + # Check github for firmware updates mainreporelease = updater.GithubRelease.get_latest_release(updater.MAINREPO) releaseversion,_ = updater.GithubRelease.get_version(mainreporelease) if updater.UpdateChecker.compare_versions(self.fw_version_str,releaseversion): @@ -636,58 +699,12 @@ def version_check(self, ver): msg = self.tr( "New firmware available") notification = updater.UpdateNotification(mainreporelease,self,msg,self.fw_version_str) notification.exec() - - def signature_check(self,signature,uid,suffix=""): - """Checks if the chip signature matches its UID""" - key = ECSignature.key_from_bytes_public(PUBKEY) - sigres = ECSignature.check(uid,signature,key,bytes(suffix,"utf-8")) - self.signature_valid = sigres - if(sigres): - self.wrapper_status_bar.set_board_text(self.hwtype + " ✅") - else: - self.wrapper_status_bar.set_board_text(self.hwtype + " ❓") - return sigres - - def update_signature(self,sig): - try: - sigbytes = bytes.fromhex(sig) - ints = ECSignature.signature_to_ints(sigbytes) - # Write into chip - for a,v in enumerate(ints): - self.send_value("sys","signature",v,a) - except Exception as e: - return - self.hwsignature = sigbytes - self.signature_check(self.hwsignature,self.hwuid) + + # Check for updates and show button in serial UI + if hasattr(self, 'serialchooser') and self.serialchooser: + # Schedule the check for updates after a short delay to ensure UI is ready + PyQt6.QtCore.QTimer.singleShot(100, self.serialchooser.check_for_updates) - - def connected_callback_late(self,connected): - """Called after the tabs have been initialized to speed up initial connection""" - if not connected: - return - def sig_cb(signature): - if(signature == "0" or len(signature) % 2 != 0): - self.hwsignature = bytes(0) - else: - self.hwsignature = bytes.fromhex(signature) - if(self.hwsignature != None and self.hwuid != None): - self.signature_check(self.hwsignature,self.hwuid) - - def uid_cb(uid): - self.hwuid = ECSignature.processuid(uid) - if(self.hwsignature != None and self.hwuid != None): - self.signature_check(self.hwsignature,self.hwuid) - - def hwtype_cb(hwtype): - self.hwtype = hwtype - self.wrapper_status_bar.set_board_text(hwtype) - - self.get_value_async("sys", "swver", self.version_check) - self.get_value_async("sys", "hwtype", hwtype_cb) - self.get_value_async("sys", "debug", self.actionDebug_mode.setChecked,0,int) - self.get_value_async("sys", "uid", uid_cb,typechar="?") - self.get_value_async("sys", "signature", sig_cb,typechar="!") - def serial_connected(self, connected): """Check the release when a board is connected.""" @@ -703,7 +720,8 @@ def id_cb(identifier): if connected: self.get_value_async("main", "id", id_cb, 0) - self.errors_dlg.registerCallbacks() + self.get_value_async("sys", "swver", self.version_check) + self.get_value_async("sys", "hwtype", self.wrapper_status_bar.set_board_text) if (self.serial_timer is None) : self.serial_timer = PyQt6.QtCore.QTimer(singleShot=True, timeout=timer_cb) @@ -715,26 +733,6 @@ def id_cb(identifier): self.reset_tabs() self.comms.removeAllCallbacks() # Ensure everything is cleared - def reset_factory(self, btn): - """Send a async message to reset factory settings.""" - cmd = btn.text() - if cmd == "OK": - self.send_value("sys", "format", 1) - self.send_command("sys", "reboot") - self.reset_port() - - def reset_factory_btn(self): - """Prompt a confirmation to the user when he click on reset factory.""" - msg = PyQt6.QtWidgets.QMessageBox() - msg.setIcon(PyQt6.QtWidgets.QMessageBox.Icon.Warning) - msg.setText(self.tr("Format flash and reset?")) - msg.setStandardButtons( - PyQt6.QtWidgets.QMessageBox.StandardButton.Ok - | PyQt6.QtWidgets.QMessageBox.StandardButton.Cancel - ) - msg.buttonClicked.connect(self.reset_factory) # pylint: disable=no-value-for-parameter - msg.exec() - def change_profile(self, profilename: str): """Change the current profile by this one selected.""" if self.connected: @@ -758,7 +756,7 @@ def __init__(self, main: MainUi): self._submenu_profiles: PyQt6.QtWidgets.QMenu = None # Adding an icon - icon = PyQt6.QtGui.QIcon("app.png") + icon = PyQt6.QtGui.QIcon(helper.res_path('app.ico')) # Adding item on the menu bar tray = PyQt6.QtWidgets.QSystemTrayIcon(main) @@ -921,151 +919,45 @@ def append_log(self, message): """Display the last log message in the status bar.""" self.label_log.setText(message) -class AboutDialog(PyQt6.QtWidgets.QDialog): - """Display the about dialog box.""" - - def __init__(self, parent : MainUi = None ): - """Display the about box with the release number updated.""" - PyQt6.QtWidgets.QDialog.__init__(self, parent) - PyQt6.uic.loadUi(helper.res_path("about.ui"), self) - verstr = "Version: " + VERSION - self.hwuid = parent.hwuid - self.sig_dialog = SignatureInputDialog(self,parent.hwuid) - if parent.fw_version_str: - verstr += " / Firmware: " + parent.fw_version_str - - self.version.setText(verstr) - self.pushButton_signature.clicked.connect(self.signature_dialog) - - if parent.hwuid: - self.label_hwid.setText("HW UID: "+parent.hwuid.hex()) - self.frame_signature.setVisible(parent.hwuid != None) - self.pushButton_signature.setEnabled(False) - - signaturestr = self.tr("Signature: ") - if not parent.hwsignature: - signaturestr += self.tr("Missing") - self.pushButton_signature.setEnabled(True) - else: - signaturestr += self.tr("Valid ✅" if parent.signature_valid else "Invalid ❌") - self.label_signature.setText(signaturestr) - - self.sig_dialog.signature_result.connect(parent.update_signature) - - def signature_dialog(self): - self.sig_dialog.open() - -class SignatureInputDialog(PyQt6.QtWidgets.QDialog): - signature_result = PyQt6.QtCore.pyqtSignal(str) - def __init__(self, parent=None,hwid = None): - super().__init__(parent) - self.parent = parent - self.uidstring = hwid.hex() if hwid else "" - self.setWindowTitle("Enter Signature") - self.setModal(True) - self.setup_ui() - - def check_signature(self,text): - result = False - try: - sig = bytes.fromhex(text) - key = ECSignature.key_from_bytes_public(PUBKEY) - result = ECSignature.check(self.parent.hwuid,sig,key) - except Exception as e: - result = False - self.submit_button.setEnabled(result) - return result - - def submit(self): - if self.check_signature(self.hex_input.text()): - self.signature_result.emit(self.hex_input.text()) - self.accept() - else: - self.reject() - - def setup_ui(self): - layout = PyQt6.QtWidgets.QVBoxLayout() - # Header - uidlayout = PyQt6.QtWidgets.QHBoxLayout() - layout.addWidget(PyQt6.QtWidgets.QLabel(self.tr("Enter signature and click \"submit\" to write to chip.\nTHIS CAN NOT BE UNDONE"))) - uidlabel = PyQt6.QtWidgets.QLabel("UID: "+self.uidstring) - uidlabel.setTextInteractionFlags(PyQt6.QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) - uidlayout.addWidget(uidlabel) - uidcopybutton = PyQt6.QtWidgets.QPushButton("Copy to clipboard") - uidlayout.addWidget(uidcopybutton) - layout.addLayout(uidlayout) - uidcopybutton.clicked.connect(lambda: app.clipboard().setText(self.uidstring)) - # Text input - self.hex_input = PyQt6.QtWidgets.QLineEdit("") - self.hex_input.setPlaceholderText("AABB112233...") - layout.addWidget(self.hex_input) - # Buttons - button_layout = PyQt6.QtWidgets.QHBoxLayout() - self.submit_button = PyQt6.QtWidgets.QPushButton("Submit") - self.hex_input.textChanged.connect(self.check_signature) - self.submit_button.clicked.connect(self.submit) - self.submit_button.setEnabled(False) - cancel_button = PyQt6.QtWidgets.QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - button_layout.addWidget(self.submit_button) - button_layout.addWidget(cancel_button) - layout.addLayout(button_layout) - self.setLayout(layout) - -def windows_theme_is_light(): - """Detect if the user is using Dark Mode in Windows. - - Registry will return 0 if Windows is in Dark Mode and 1 - if Windows is in Light Mode. This dictionary converts that - output into the text that the program is expecting. - 0 = Dark, 1 = Light - In HKEY_CURRENT_USER, get the Personalisation Key. - """ - try: - key = getKey( hkey, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize" ) - # In the Personalisation Key, get the AppsUseLightTheme subkey. This returns a tuple. - # The first item in the tuple is the result we want - # (0 or 1 indicating Dark Mode or Light Mode); the other value - # is the type of subkey e.g. DWORD, QWORD, String, etc. - subkey = getSubkeyValue(key, "AppsUseLightTheme")[0] - except FileNotFoundError: - # some headless Windows instances (e.g. GitHub Actions or Docker images) - # do not have this key - return None - return subkey - - def process_events(): """Function to force processing background events. Do NOT call from any slots in the main thread without blocking as that may lead to recursion""" app.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents,50) - - - +def qt_message_handler(mode, context, message): + if "Unexpected nullptr parameter" in message: + logging.getLogger("main").warning("Silencing benign 'Unexpected nullptr parameter' warning caused by PyQt6 QChart destruction bug.") + return + sys.stderr.write(message + "\n") if __name__ == "__main__": + PyQt6.QtCore.qInstallMessageHandler(qt_message_handler) logging.config.fileConfig(helper.res_path('logger.conf')) + if sys.platform == "win32" or "Windows" in sys.platform: + import ctypes + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("openffboard.configurator.app") + except Exception: + pass restart = True exit_code = -1 app = PyQt6.QtWidgets.QApplication(sys.argv) + + # Detect system theme and store on application instance + is_light = helper.windows_theme_is_light() + is_light_bool = (is_light is True) + app.setProperty("is_light_theme", is_light_bool) + + if is_light_bool: + app.setStyleSheet(qdarktheme.load_stylesheet("light")) + else: + app.setStyleSheet(qdarktheme.load_stylesheet("dark")) + translator = PyQt6.QtCore.QTranslator(app) # Translator must be created before UI loaded while(restart): restart = False - window = MainUi() - if (sys.platform == "win32" or "Windows" in sys.platform): - # only on windows, for macOS and linux use system palette. - # windows server is not called win32 - # pylint: disable=import-error - from winreg import ( - HKEY_CURRENT_USER as hkey, - QueryValueEx as getSubkeyValue, - OpenKey as getKey, - ) - # Check if is not using windows 11 style(windows 11 style is dark mode compatible) - if windows_theme_is_light() == 0 and app.style().objectName() != "windows11": - app.setStyle("Fusion") - app.setPalette(dark_palette.PALETTE_DARK) - window.menubar.setStyleSheet("QMenu::item {color: white; }") # Menu item text ignores palette setting and stays black. Force to white. + + window = MainUi(translator = translator) + # Force dark theme on all platforms window.setWindowTitle(PyQt6.QtCore.QCoreApplication.translate("MainUi", "Open FFBoard Configurator")) window.setWindowIcon(PyQt6.QtGui.QIcon(helper.res_path('app.ico'))) diff --git a/profile_ui.py b/profile_ui.py index c69f50b3..a57e5f99 100644 --- a/profile_ui.py +++ b/profile_ui.py @@ -10,6 +10,7 @@ import json import copy import sys +import shutil import PyQt6.QtCore import PyQt6.QtWidgets @@ -17,20 +18,16 @@ import base_ui import helper -def get_config_dir_path(profiles_filename): - if sys.platform == "linux" and not os.path.exists(profiles_filename): - defaultConfigDir = os.path.expandvars("$HOME/.config") - return os.path.join(os.getenv("XDG_CONFIG_HOME", defaultConfigDir), "openffboard") - return os.getcwd() class ProfileUI(base_ui.WidgetUI, base_ui.CommunicationHandler): """Manage the Profile selector and the board communication about them.""" - __RELEASE = 2 + __RELEASE = 3 __PROFILES_FILENAME = "profiles.json" - __PROFILES_FILEPATH = os.path.join(get_config_dir_path(__PROFILES_FILENAME), __PROFILES_FILENAME) + __APP_NAME = "openffboard" + __PROFILES_FILEPATH = None __PROFILESSETUP_FILENAME = helper.res_path("profile.cfg") __PROFILES_TEMPLATE = { "release": __RELEASE, @@ -56,8 +53,11 @@ def __init__(self, main=None): self._map_class_running = [] self._running_profile = [] self._profilename_tosave: str = None + self._is_communicating = False self.ui_initialized = False + + self.__PROFILES_FILEPATH = self.setup_and_migrate_config(self.__PROFILES_FILENAME, self.__APP_NAME) self.load_profile_settings() self.load_profiles() @@ -92,9 +92,76 @@ def initialize_ui(self): ) self.setEnabled(False) + + def get_system_config_dir(self, app_name): + """ + Returns the system-specific configuration directory. + """ + home = os.path.expanduser("~") + + if sys.platform == "win32": + # Windows: User requested specific path: C:\Users\Name\.appname + # We add a dot prefix for Windows to match the request + return os.path.join(home, f".{app_name}") + + elif sys.platform == "darwin": + # macOS Standard: ~/Library/Application Support/appname + return os.path.join(home, "Library", "Application Support", app_name) + + else: + # Linux Standard: ~/.config/appname (XDG standard) + base = os.getenv("XDG_CONFIG_HOME", os.path.join(home, ".config")) + return os.path.join(base, app_name) + + def setup_and_migrate_config(self, filename, app_name): + """ + Prepares the configuration directory and migrates the old file if it exists locally. + Returns the final path of the file to use. + """ + system_dir = self.get_system_config_dir(app_name) + system_file_path = os.path.join(system_dir, filename) + + # Old location (current working directory) + local_file_path = os.path.join(os.getcwd(), filename) + + # Create system directory if it doesn't exist + if not os.path.exists(system_dir): + try: + os.makedirs(system_dir) + self.log(f"[Info] Config directory created: {system_dir}") + except OSError as e: + self.log(f"[Error] Could not create directory: {e}") + return local_file_path + + # Migration Logic, ff a file exists in the current directory (old method)... + if os.path.exists(local_file_path): + + # and NO file exists in the new system folder yet: + if not os.path.exists(system_file_path): + self.log("[Info] Migration in progress: Moving local config to system folder...") + try: + shutil.move(local_file_path, system_file_path) + self.log("[Success] Migration complete.") + except OSError as e: + self.log(f"[Error] Migration failed: {e}") + return local_file_path # Fallback to local on failure + + # if a file exists in BOTH locations (Conflict): + else: + self.log("[Warning] Config file exists in both locations.") + self.log("[Info] Renaming the local (old) file to .bak to avoid confusion.") + try: + shutil.move(local_file_path, local_file_path + ".bak") + except Exception as e: + self.log(f"[Error] Could not rename local file: {e}") + + # 4. Return the final path (System path) + return system_file_path def save_clicked(self): """Save current seeting in Flash and replace the 'Flash profile' settings by the new one.""" + if self._is_communicating: + return def log_save_cb(res): """Display the confirmation in log.""" @@ -109,11 +176,11 @@ def onclose(self): """Remove the serial call backs on close.""" self.remove_callbacks() - def setEnabled(self, a0: bool) -> None: # pylint: disable=invalid-name + def setEnabled(self, enabled: bool) -> None: """Refresh the combo box content with profile when connection is up.""" - if a0 and self.comboBox_profiles.count() == 0: + if enabled and self.comboBox_profiles.count() == 0: self.refresh_combox_list() - return super().setEnabled(a0) + return super().setEnabled(enabled) def set_save_btn(self, status): """Enable the save button based on status.""" @@ -126,9 +193,9 @@ def open_profile_manager(self): def close_profile_manager(self, profile_name:str = ''): """Close the profile list manager.""" - self.comboBox_profiles.currentIndexChanged.disconnect(self.apply_config) + self.comboBox_profiles.blockSignals(True) self.refresh_combox_list() - self.comboBox_profiles.currentIndexChanged.connect(self.apply_config) + self.comboBox_profiles.blockSignals(False) if (profile_name): self.select_profile(profile_name) self.create_or_update_profile_file() @@ -154,7 +221,13 @@ def load_profiles_from_file(self): with open(self.__PROFILES_FILEPATH, "r", encoding="utf_8") as profile_file: self.profiles = json.load(profile_file) if self.profiles['release'] < self.__RELEASE : - os.rename(self.__PROFILES_FILEPATH, self.__PROFILES_FILEPATH + '.' + str(self.profiles['release']) + '.old') + backup_path = self.__PROFILES_FILEPATH + '.' + str(self.profiles['release']) + '.old' + if os.path.exists(backup_path): + try: + os.remove(backup_path) + except OSError: + pass + os.rename(self.__PROFILES_FILEPATH, backup_path) self.create_or_update_profile_file(create=True) self.log("Profile: profiles are not compatible, need to redo them") else: @@ -188,7 +261,7 @@ def create_or_update_profile_file(self, create: bool = False) -> bool: self.log("Profile: profile file created") # Ensure the parent directory exists - os.makedirs(get_config_dir_path(self.__PROFILES_FILENAME), exist_ok=True) + os.makedirs(self.get_system_config_dir(self.__APP_NAME), exist_ok=True) try: with open(self.__PROFILES_FILEPATH, "w", encoding="utf_8") as f: @@ -200,7 +273,7 @@ def create_or_update_profile_file(self, create: bool = False) -> bool: def get_global_setting(self,key : str, default = None): """Returns an entry of the global section or saves a default if set and not found""" if "global" in self.profiles: - if (key not in self.profiles['global']) and default != None: + if (key not in self.profiles['global']) and default is not None: self.set_global_setting(key,default) return self.profiles['global'].get(key,None) return None @@ -220,11 +293,8 @@ def refresh_combox_list(self): for profilename in data: listprofile.append(profilename["name"]) - try: - listprofile.index(ProfileUI.FLASH_PROFILE_NAME) + if ProfileUI.FLASH_PROFILE_NAME in listprofile and ProfileUI.NONE_PROFILE_NAME in listprofile: listprofile.remove(ProfileUI.NONE_PROFILE_NAME) - except ValueError: - pass self.comboBox_profiles.addItems(listprofile) self.profiles_updated_event.emit(listprofile) @@ -251,7 +321,7 @@ def apply_config(self): Send the paramter for active class to the board. """ # if not enabled, don't select profile - if not self.isEnabled(): + if not self.isEnabled() or self._is_communicating: return # read the selected profile name, if profile is None, remove the last message @@ -275,6 +345,9 @@ def save_config_in_profile(self, profile_name: str = ""): When the profile_name is pass, we don't check if "None or Flash Profile", else we have recursivity with method 'save_clicked'. """ + if self._is_communicating: + return + if not(profile_name) or profile_name == "": self._profilename_tosave = str(self.comboBox_profiles.currentText()) @@ -296,6 +369,9 @@ def save_config_in_profile(self, profile_name: str = ""): def _read_running_class_and_go_cb(self, call_back): """Get the running class from board, and process the call_back when board respond.""" # refresh the global var when starting to read value from board + self._is_communicating = True + self.comboBox_profiles.setEnabled(False) + self.set_save_btn(False) self._current_class = -1 self._current_command = -1 self._current_instance = -1 @@ -308,28 +384,27 @@ def _read_running_class_and_go_cb(self, call_back): def _build_running_map(self, buffer: str): self._map_class_running = [] - # Split the string buffer into array - splitted_running_class = [x.split(":") for x in buffer.split("\n")] - # Format the arrray in array of object {classname, fullname, instance} - formated_iterator = list( - map( - lambda tab: { - "classname": tab[1], - "fullname": tab[0], - "instance": int(tab[2]), - }, - splitted_running_class, - ) - ) + formated_iterator = [] + for line in buffer.splitlines(): + tab = line.split(":") + if len(tab) >= 3: + try: + formated_iterator.append({ + "classname": tab[1], + "fullname": tab[0], + "instance": int(tab[2]) + }) + except ValueError: + pass + # For each class to saved declared in the cfg file, # we filter the running instance to keep only those for call_order in self.profile_setup["callOrder"]: - filtered_iterator = filter( - lambda x, call=call_order: x["classname"] == call["classname"] - and x["fullname"] == call["fullname"], - formated_iterator, - ) - self._map_class_running.extend(list(filtered_iterator)) + filtered_iterator = [ + x for x in formated_iterator + if x["classname"] == call_order["classname"] and x["fullname"] == call_order["fullname"] + ] + self._map_class_running.extend(filtered_iterator) def _get_instance_running(self, indexclass: int, indexinstance: int): if indexclass > len(self.profile_setup["callOrder"]): @@ -485,74 +560,110 @@ def _read_profile_cb(self, buffer: str): self._read_value( self._current_class, self._current_command, self._current_instance ) - elif self._profilename_tosave is not False: + elif self._profilename_tosave is not None: self._save_profile_in_file(self._running_profile, self._profilename_tosave) + self._is_communicating = False + self.comboBox_profiles.setEnabled(True) + self.set_save_btn(True) else: self.log("Profiles: profile read from board") + self._is_communicating = False + self.comboBox_profiles.setEnabled(True) + self.set_save_btn(True) def _write_profile_cb(self, buffer: str): - # process the incoming buffer - if self._current_class == -1: - # first call is sys.lsactive to get all active class - # that running and we extract a map of class/instances - self._build_running_map(buffer) - else: - return + try: + # process the incoming buffer + if self._current_class == -1: + # first call is sys.lsactive to get all active class + # that running and we extract a map of class/instances + self._build_running_map(buffer) + else: + return - # read the selected profile name - profilename = str(self.comboBox_profiles.currentText()) - if profilename == "": - return + # read the selected profile name + profilename = str(self.comboBox_profiles.currentText()) + if profilename == "": + return - # From the profile, filter parameters that are running - parameters_running = [] - profile_json_entry = next( - filter(lambda x: x["name"] == profilename, self.profiles["profiles"]), None - ) - for running_class in self._map_class_running: - parameters_running.extend( - list( - filter( - lambda x, running=running_class: x["fullname"] - == running["fullname"] - and x["cls"] == running["classname"] - and x["instance"] == running["instance"], - profile_json_entry["data"], - ) - ) + # From the profile, filter parameters that are running + parameters_running = [] + profile_json_entry = next( + filter(lambda x: x["name"] == profilename, self.profiles["profiles"]), None ) - # sent the filter running parameter to the board - # and after, read a new time values to refresh UI - if len(parameters_running) > 0: - for pararmeter in parameters_running: - self.send_value( - cls=pararmeter["cls"], - cmd=pararmeter["cmd"], - val=pararmeter["value"], - instance=pararmeter["instance"], + if not profile_json_entry: + self.log(f"Profile: '{profilename}' not found in configuration data.") + return + + for running_class in self._map_class_running: + parameters_running.extend( + list( + filter( + lambda x, running=running_class: x["fullname"] + == running["fullname"] + and x["cls"] == running["classname"] + and x["instance"] == running["instance"], + profile_json_entry["data"], + ) + ) ) - # axis.0.degrees?|900 - replytext = ( - "[" - + pararmeter["cls"] - + "." - + str(pararmeter["instance"]) - + "." - + pararmeter["cmd"] - + "?|" - + str(pararmeter["value"]) - + "]" - ) - self.process_virtual_comms_buffer(replytext) + # sent the filter running parameter to the board + # and after, read a new time values to refresh UI + if len(parameters_running) > 0: + for pararmeter in parameters_running: + self.send_value( + cls=pararmeter["cls"], + cmd=pararmeter["cmd"], + val=pararmeter["value"], + instance=pararmeter["instance"], + ) - # self.sendCommand(cls=pararmeter["cls"], - # cmd=pararmeter["cmd"], instance=pararmeter["instance"]) + # axis.0.degrees?|900 + replytext = f"[{pararmeter['cls']}.{pararmeter['instance']}.{pararmeter['cmd']}?|{pararmeter['value']}]" + self.process_virtual_comms_buffer(replytext) - # send message that announce new profile is selected - self.profile_selected_event.emit(profilename) - self.log("Profile: '" + profilename + "' is active") + # self.sendCommand(cls=pararmeter["cls"], + # cmd=pararmeter["cmd"], instance=pararmeter["instance"]) + + # send message that announce new profile is selected + self.profile_selected_event.emit(profilename) + self.log("Profile: '" + profilename + "' is active") + finally: + self._is_communicating = False + self.comboBox_profiles.setEnabled(True) + self.set_save_btn(True) + + def delete_profile(self, item_name: str): + """Remove the specified profile from the list.""" + for i in range(len(self.profiles["profiles"])): + if self.profiles["profiles"][i]["name"] == item_name: + self.profiles["profiles"].pop(i) + break + self.create_or_update_profile_file() + + def copy_profile(self, item_name: str, new_name: str): + """Copy the specified profile under a new name.""" + profile_json_entry = next( + filter(lambda x: x["name"] == item_name, self.profiles["profiles"]), + None, + ) + if profile_json_entry is not None: + new_profile = copy.deepcopy(profile_json_entry) + new_profile["name"] = new_name + self.profiles["profiles"].append(new_profile) + self.create_or_update_profile_file() + + def rename_profile(self, item_name: str, new_name: str): + """Rename the specified profile.""" + profile_json_entry = next( + filter(lambda x: x["name"] == item_name, self.profiles["profiles"]), + None, + ) + if profile_json_entry is not None: + profile_json_entry["name"] = new_name + self.create_or_update_profile_file() class ProfilesDialog(PyQt6.QtWidgets.QDialog): @@ -640,10 +751,7 @@ def delete(self): if len(self.selection_model.selection().indexes()) <= 0: return item_name = self.selection_model.selection().indexes()[0].data() - for i in range(len(self.profile_dlg.profiles["profiles"])): - if self.profile_dlg.profiles["profiles"][i]["name"] == item_name: - self.profile_dlg.profiles["profiles"].pop(i) - break + self.profile_dlg.parent().delete_profile(item_name) self.read_profiles() def copy_as(self): @@ -653,17 +761,7 @@ def copy_as(self): item_name = self.selection_model.selection().indexes()[0].data() name, status = PyQt6.QtWidgets.QInputDialog.getText(self, "Copy as", "new name") if status and (name != "") and (name not in self.get_profiles_name()): - profile_json_entry = next( - filter( - lambda x: x["name"] == item_name, - self.profile_dlg.profiles["profiles"], - ), - None, - ) - new_profile = copy.deepcopy(profile_json_entry) - if profile_json_entry is not None: - new_profile["name"] = name - self.profile_dlg.profiles["profiles"].append(new_profile) + self.profile_dlg.parent().copy_profile(item_name, name) self.read_profiles() def rename(self): @@ -675,15 +773,7 @@ def rename(self): self, "Copy as", "new name", text=item_name ) if status and (name != "") and (name not in self.get_profiles_name()): - profile_json_entry = next( - filter( - lambda x: x["name"] == item_name, - self.profile_dlg.profiles["profiles"], - ), - None, - ) - if profile_json_entry is not None: - profile_json_entry["name"] = name + self.profile_dlg.parent().rename_profile(item_name, name) self.read_profiles() def read_profiles(self): diff --git a/requirements.txt b/requirements.txt index bf6a0c11..04180b51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,5 @@ pyusb intelhex requests cryptography +hidapi +pyqtdarktheme diff --git a/res/MainWindow.ui b/res/MainWindow.ui index 71a55a52..5df9b013 100644 --- a/res/MainWindow.ui +++ b/res/MainWindow.ui @@ -6,8 +6,8 @@ 0 0 - 644 - 489 + 778 + 468 @@ -15,230 +15,140 @@ - + 0 0 - - - + + + 10 + + + 10 + + + 10 + + + 0 + + + 20 + + + + + + + + 0 + 0 + + + + Icon + + + + + + + + 0 + 0 + + + + + + + + + + 0 + 0 + + + + Profiles Manager + + + + + + + - + 0 - 20 + 0 - 0 - 200 + 250 + 0 - - -1 + + Qt::LayoutDirection::LeftToRight + + + 0 + + + 0 + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + - - + + - + 0 0 - - Profiles Manager + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + 1 - - - - - - - 0 - 0 - 644 - 21 - - - - - Help - - - - Language - - - - - - - - - - - - - - Hardware - - - - - - - - - - - - Monitoring - - - - - - - - - - - About - - FFB Wheel TMC - - - true - - - Firmware Update... - - - - - false - - - Save chip config - - - - - false - - - Load chip config - - - - - true - - - Logs, Errors... - - - Logs, Errors - - - Logs, Errors - - - - - false - - - Active features - - toggle dark mode - - - false - - - Reboot - - - - - false - - - Reset factory config - - - - - false - - - Effect stats - - - - - false - - - Effect live forces - - - - - Encoder tuning... - - - - - Updates - - - - - true - - - false - - - false - - - Debug mode - - - - - Default - - - - - false - - - Active threads - - diff --git a/res/about_page.ui b/res/about_page.ui new file mode 100644 index 00000000..63d9e1c1 --- /dev/null +++ b/res/about_page.ui @@ -0,0 +1,293 @@ + + + AboutPage + + + + 0 + 0 + 723 + 362 + + + + About + + + + 15 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + Logs, errors + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + + 0 + 0 + + + + Logs + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + 0 + + + + Errors + + + + + + + 0 + 0 + + + + + + + + + + + + + Clear Logs + + + + + + + Refresh + + + + + + + Clear Errors + + + + + + + + + + Modules + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + Feature + + + + + + + + Thread + + + + + + + + + About + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + Information + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + <html><head/><body><p>OpenFFBoard configurator </p><p>Source: <a href="https://github.com/Ultrawipf/OpenFFBoard-configurator"><span style=" text-decoration: underline; color:#007af4;">Github - Configurator</span></a> (Ultrawipf/OpenFFBoard-configurator)</p><p>Documentation: <a href="https://github.com/Ultrawipf/OpenFFBoard/wiki/Configurator-guide"><span style=" text-decoration: underline; color:#007af4;">Configurator guide</span></a></p><p>Made with <a href="https://www.qt.io/"><span style=" text-decoration: underline; color:#007af4;">Qt</span></a></p><p>Licensed as GPLv3</p></body></html> + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + true + + + + + + + + 0 + 0 + + + + + 11 + + + + Version + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + License + + + + + + + 0 + 0 + + + + Qt::LayoutDirection::LeftToRight + + + + + + + + + + + + + + + diff --git a/res/axis_ui.ui b/res/axis_ui.ui index 3de7ad74..b4f58aea 100644 --- a/res/axis_ui.ui +++ b/res/axis_ui.ui @@ -6,913 +6,1022 @@ 0 0 - 941 - 907 + 823 + 588 Form - - - - - Axis settings - - - - 5 - - - 5 - - - - - Qt::Orientation::Vertical - - - QSizePolicy::Policy::Preferred - - - - 20 - 5 - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 1 - - - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Expanding - - - - 10 - 10 - - - - - - - - Qt::Orientation::Vertical - - - QSizePolicy::Policy::Minimum - - - - 20 - 0 - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 0 - - - - - - - - - 0 - 0 - - - - Motor driver - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Minimum - - - - 40 - 20 - - - - - - - - Change driver - - - - - - - - 2 - 0 - - - - - - - - Motor driver - - - - - - - - - - - 1 - 0 - - - - Encoder selection - - - - 5 - - - 5 - - - 5 - - - - - - 1 - 1 - - - - 0 - - - 1 - - - - - - - - - Encoder source - - - - - - - - 1 - 0 - - - - QComboBox::SizeAdjustPolicy::AdjustToContents - - - - - - - Change enc - - - - - - - - - - - 0 - 0 - - - - Actions - - - - - - <html><head/><body><p>Zero the axis (Center)</p></body></html> - - - Set center position - - - - - - - Encoder tuning... - - - - - - - false - - - Torque curve... - - - - - - - - - - Options - - - - 5 - - - 5 - - - - - - 0 - 0 - - - - QFrame::Shape::StyledPanel - - - QFrame::Shadow::Sunken - - - 1 - - - - 4 - - - 4 - - - 4 - - - 4 - - - - - - 0 - 0 - - - - - 100 - 0 - - - - 1 - - - 256 - - - - - - - - 0 - 0 - - - - : - - - - - - - - 0 - 0 - - - - - 100 - 0 - - - - 1 - - - 256 - - - - - - - 0.0 - - - - - - - <html><head/><body><p>Encoder reduction <br/>prescaler for <br/>range selection.</p></body></html> - - - - - - - - - - Apply - - - - - - - <html><head/><body><p>Reverses the axis.<br/>Also reverses the forces so they keep pointing in the correct direction.</p></body></html> - - - Invert axis - - - - - - - - - - Limits - - - - 9 - - - - - false - - - false - - - °/s - - - 0 - - - 65535 - - - 10 - - - 0 - - - - - - - Speed limit - - - - - - - - - - Info - - - - - - true - - - false - - - true - - - QAbstractSpinBox::ButtonSymbols::NoButtons - - - false - - - - - - ° - - - -9223372036854775808.000000000000000 - - - 9223372036854775808.000000000000000 - - - 0.000000000000000 - - - - - - - Pos - - - - - - - false - - - true - - - QAbstractSpinBox::ButtonSymbols::NoButtons - - - false - - - -1999999999 - - - 1999999999 - - - 0 - - - - - - - Raw - - - - - - - - - - - - - Mechanical settings (Game independent) - - - - 10 - - - 5 - - - - - <html><head/><body><p>Centering force when FFB is inactive.</p><p>Turned off when game effects takes place</p></body></html> - - - Desktop spring (No FFB) - - - - - - - <html><head/><body><p>Independent damper is an always on damper effect to smooth out acceleration spikes</p></body></html> - - - 255 - - - Qt::Orientation::Horizontal - - - - - - - <html><head/><body><p>Independent damper is an always on damper effect to smooth out acceleration spikes.</p></body></html> - - - Permanent damper - - - - - - - - 100 - 0 - - - - false - - - - - - - - - 255 - - - - - - - <html><head/><body><p>Degrees of rotation</p></body></html> - - - Range (deg) - - - - - - - <html><head/><body><p>Centering force when FFB is inactive</p></body></html> - - - 255 - - - 0 - - - 0 - - - Qt::Orientation::Horizontal - - - - - - - - 100 - 0 - - - - false - - - ° - - - 10 - - - 32767 - - - 10 - - - - - - - - 100 - 0 - - - - false - - - 255 - - - - - - - 10 - - - 1440 - - - 10 - - - 10 - - - Qt::Orientation::Horizontal - - - - - - - Permanent inertia - - - - - - - Permanent friction - - - - - - - 255 - - - Qt::Orientation::Horizontal - - - - - - - 255 - - - Qt::Orientation::Horizontal - - - - - - - - 100 - 0 - - - - 255 - - - - - - - - 100 - 0 - - - - 255 - - - - - - - - + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 0 - - FFB Power + + 0 - - - 10 - - - 5 + + + + 0 + 0 + - - - - - 0 - 0 - - - - <html><head/><body><p>How stiff the endstop should be (Tune to taste while keeping noise low)</p></body></html> - - - 255 - - - Qt::Orientation::Horizontal - - - - - - - <html><head/><body><p>High = Effects and endstop have the same peak torque. Low = effect peak is reduced to make endstop more prominent</p></body></html> - - - 102 - - - 255 - - - 102 - - - 102 - - - Qt::Orientation::Horizontal - - - - - - - - 30 - 0 - - - - 0 + + Force settings + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + Smoothing + + + + 10 + + + + + Effect equalizer + + + true + + + + + + + + + 0 + 2 + + + + + 0 + 0 + + + + + + + + Reset gain + + + + + + + + + + + 0 + 0 + + + + Position + + + + + + Pos + + + + + + + <html><head/><body><p>Zero the axis (Center)</p></body></html> + + + Set center position + + + + + + + Raw + + + + + + + true + + + false + + + true + + + QAbstractSpinBox::ButtonSymbols::NoButtons + + + false + + + + + + ° + + + -9223372036854775808.000000000000000 + + + 9223372036854775808.000000000000000 + + + 0.000000000000000 + + + + + + + false + + + true + + + QAbstractSpinBox::ButtonSymbols::NoButtons + + + false + + + -1999999999 + + + 1999999999 + + + 0 + + + + + + + + + + + 0 + 0 + + + + FFB Power + + + + 10 + + + 5 + + + + + + 0 + 0 + + + + <html><head/><body><p>How stiff the endstop should be (Tune to taste while keeping noise low)</p></body></html> + + + 255 + + + Qt::Orientation::Horizontal + + + + + + + <html><head/><body><p>High = Effects and endstop have the same peak torque. Low = effect peak is reduced to make endstop more prominent</p></body></html> + + + 102 + + + 255 + + + 102 + + + 102 + + + Qt::Orientation::Horizontal + + + + + + + 0 + + + + + + + <html><head/><body><p>Intensity of the effects used by the force feedback effects from the game.</p><p>It defines the peak torque of the motor.</p></body></html> + + + Effects intensity + + + + + + + <html><head/><body><p>How much torque going to be used from the motor. <br/><br/>More power means more current being used from the motor as well as stronger ffb.</p></body></html> + + + Overall Power + + + + + + + <html><head/><body><p>How much torque will be reserved to stop the motor for endstop effect.</p></body></html> + + + Endstop intensity + + + + + + + 32767 + + + 100 + + + Qt::Orientation::Horizontal + + + 100 + + + + + + + + 30 + 0 + + + + 0 (0%) - - - - - - - - 100 - 0 - - - - false - - - - - - 255 - - - - - - - <html><head/><body><p>How much torque going to be used from the motor. <br/><br/>More power means more current being used from the motor as well as stronger ffb.</p></body></html> - - - Power - - - - - - - 32767 - - - 100 - - - Qt::Orientation::Horizontal - - - 100 - - - - - - - <html><head/><body><p>How much torque will be reserved to stop the motor for endstop effect.</p></body></html> - - - Endstop gain - - - - - - - 0 - - - - - - - <html><head/><body><p>Intensity of the effects used by the force feedback effects from the game.</p><p>It defines the peak torque of the motor.</p></body></html> - - - Effects intensity - - - - + + + + + + + + 100 + 0 + + + + false + + + + + + 255 + + + + + + + + + + + 0 + 0 + + + + Mechanical settings (Game independent) + + + + 10 + + + 5 + + + + + Permanent friction + + + + + + + 255 + + + Qt::Orientation::Horizontal + + + + + + + + 100 + 0 + + + + 255 + + + + + + + 255 + + + Qt::Orientation::Horizontal + + + + + + + <html><head/><body><p>Independent damper is an always on damper effect to smooth out acceleration spikes.</p></body></html> + + + Permanent damper + + + + + + + <html><head/><body><p>Centering force when FFB is inactive.</p><p>Turned off when game effects takes place</p></body></html> + + + Desktop spring (No FFB) + + + + + + + + 100 + 0 + + + + 255 + + + + + + + <html><head/><body><p>Degrees of rotation</p></body></html> + + + Range (deg) + + + + + + + 10 + + + 1440 + + + 10 + + + 10 + + + Qt::Orientation::Horizontal + + + + + + + + 100 + 0 + + + + false + + + 255 + + + + + + + <html><head/><body><p>Independent damper is an always on damper effect to smooth out acceleration spikes</p></body></html> + + + 255 + + + Qt::Orientation::Horizontal + + + + + + + <html><head/><body><p>Centering force when FFB is inactive</p></body></html> + + + 255 + + + 0 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + + 100 + 0 + + + + false + + + + + + + + + 255 + + + + + + + Permanent inertia + + + + + + + + 100 + 0 + + + + false + + + ° + + + 10 + + + 32767 + + + 10 + + + + + + + + + + + false + + + Hardware + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + + 0 + 0 + + + + Motor driver + + + + + + + 2 + 0 + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Preferred + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + Change driver + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Preferred + + + + 20 + 40 + + + + + + + + Motor driver + + + + + + + + + + Axis Options + + + + 5 + + + 5 + + + + + false + + + false + + + °/s + + + 0 + + + 65535 + + + 10 + + + 0 + + + + + + + Speed limit + + + + + + + false + + + Torque curve... + + + + + + + 1.0 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + <html><head/><body><p>Reverses the axis.<br/>Also reverses the forces so they keep pointing in the correct direction.</p></body></html> + + + Invert axis + + + + + + + false + + + Auto Detect + + + + + + + Expo torque + + + + + + + Apply + + + + + + + Qt::Orientation::Horizontal + + + + + + + + + + + 0 + 0 + + + + Encoder + + + + 5 + + + 5 + + + 15 + + + + + + 1 + 0 + + + + QComboBox::SizeAdjustPolicy::AdjustToContents + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Encoder reduction (prescaler) + + + false + + + + 4 + + + 4 + + + 4 + + + 4 + + + + + + 0 + 0 + + + + : + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + 1 + + + 256 + + + + + + + Apply + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + 1 + + + 256 + + + + + + + + + + Change enc + + + + + + + Encoder source + + + + + + + + 0 + 0 + + + + 0 + + + 1 + + + + + + 0 + 0 + + + + + + + + + Encoder tuning... + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Expanding + + + + 20 + 40 + + + + + + - - - - Qt::Orientation::Vertical - - - - 20 - 10 - - - - + + + QChartView + QWidget +
PyQt6/QtCharts
+ 1 +
+
@@ -922,12 +1031,12 @@ setValue(int) - 512 - 505 + 673 + 397 - 539 - 504 + 930 + 402 @@ -938,12 +1047,12 @@ setValue(int) - 551 - 510 + 930 + 402 - 520 - 509 + 681 + 397 @@ -954,12 +1063,12 @@ setValue(int) - 309 - 291 + 470 + 587 - 553 - 291 + 930 + 592 @@ -970,12 +1079,12 @@ setValue(int) - 553 - 291 + 930 + 592 - 309 - 291 + 470 + 587 @@ -986,12 +1095,12 @@ setValue(int) - 309 - 319 + 470 + 617 - 553 - 319 + 930 + 622 @@ -1002,12 +1111,12 @@ setValue(int) - 553 - 319 + 930 + 622 - 309 - 319 + 470 + 617 @@ -1018,12 +1127,12 @@ setValue(int) - 469 - 573 + 630 + 677 - 840 - 573 + 930 + 682 @@ -1034,12 +1143,12 @@ setValue(int) - 469 - 546 + 630 + 647 - 840 - 546 + 930 + 652 @@ -1050,12 +1159,12 @@ setValue(int) - 840 - 573 + 930 + 682 - 469 - 573 + 630 + 677 @@ -1066,12 +1175,12 @@ setValue(int) - 840 - 546 + 930 + 652 - 469 - 546 + 630 + 647 diff --git a/res/dashboard.ui b/res/dashboard.ui new file mode 100644 index 00000000..6148bc79 --- /dev/null +++ b/res/dashboard.ui @@ -0,0 +1,1817 @@ + + + Dashboard + + + + 0 + 0 + 800 + 538 + + + + Dashboard + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + + 0 + 0 + + + + Waiting for connection... + + + Qt::AlignmentFlag::AlignCenter + + + true + + + + + + + FFB Axis + + + + 20 + + + 0 + + + + + + 0 + 0 + + + + X Axis + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + 60 + 16777215 + + + + 0 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 60 + 16777215 + + + + 0 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + 0 + 0 + + + + Y Axis + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + 60 + 16777215 + + + + 0 + + + 32767 + + + 0 + + + Qt::Orientation::Vertical + + + true + + + + + + + + 0 + 0 + + + + 32768 + + + 0 + + + Qt::Orientation::Vertical + + + + + + + + + + Secondary Axis + + + + QLayout::SizeConstraint::SetNoConstraint + + + + + Rot. Z + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + -32767 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + Dial + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Rot. Y + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Slider + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Z Axis + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Rot.X + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + -32767 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + -32767 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + -32767 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + -32767 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + 0 + 0 + + + + + 40 + 16777215 + + + + -32767 + + + 32767 + + + 0 + + + Qt::AlignmentFlag::AlignCenter + + + Qt::Orientation::Vertical + + + + + + + + + + + 0 + 0 + + + + Matrix Button + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 30 + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 8 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 10 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 40 + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 20 + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 4 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 7 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 50 + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 3 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 6 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 10 + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 9 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 0 + + + + + + + + 0 + 0 + + + + 1 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + 60 + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + + + + false + + + + + + + + 0 + 0 + + + + 5 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + 2 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + 0 + + + + + + + + 0 + 0 + + + + Buttons (binary): + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + 0 + 0 + + + + FFB Effect + + + + + + + + + diff --git a/res/dfu.ui b/res/dfu.ui deleted file mode 100644 index 6cdcbe16..00000000 --- a/res/dfu.ui +++ /dev/null @@ -1,141 +0,0 @@ - - - DFUControl - - - - 0 - 0 - 592 - 420 - - - - DFU Mode - - - - - - Starting Update Mode - - - - - - false - - - - 0 - 0 - - - - Switch update mode - - - - - - - - 0 - 0 - - - - <html><head/><body><p>Activate &quot;update mode&quot; (<span style=" font-weight:700;">D</span>evice <span style=" font-weight:700;">F</span>irmware <span style=" font-weight:700;">U</span>pdate) to start</p></body></html> - - - - - - - - - - Firmware Update - - - - - - false - - - Choose file - - - - - - - No file - - - - - - - false - - - Upload firmware - - - - - - - Full erase (Resets all settings) - - - - - - - - - - Hardware action - - - - - - false - - - Full chip erase - - - - - - - Erases all flash sectors. Can help if an upgrade causes issues - - - - - - - - - - - - - true - - - 0 - - - - - - - - diff --git a/res/effects_stats.ui b/res/effects_stats.ui index e6180aba..66bc44b4 100644 --- a/res/effects_stats.ui +++ b/res/effects_stats.ui @@ -6,8 +6,8 @@ 0 0 - 501 - 468 + 468 + 430 @@ -18,7 +18,7 @@ - 500 + 0 0 @@ -31,425 +31,541 @@ Statistics - - - + + + 0 + + + 0 + + + 0 + + + 0 + + + 4 + + + + + Qt::Orientation::Horizontal + + + + + + + Triangle + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + + + + + + + Inertia + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Saw Tooth Down + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Friction + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Damper + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + false + + + + + + + + + + + + + + <html><head/><body><p><span style=" font-weight:696; font-style:italic;">Max force generated</span></p></body></html> + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + Saw Tooth Up + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 0 + 0 + + + + false + + + <html><head/><body><p><span style=" font-weight:696; font-style:italic;">Used</span></p></body></html> + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + + + + + + + + + + + + + + Custom + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Sine + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Spring + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + 0 + 0 + + + + 32767 + + + 0 + + + + + + + + + + + + + + Square + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Constant + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + false + + + <html><head/><body><p><span style=" font-weight:696; font-style:italic;">Effect name</span></p></body></html> + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Ramp + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + + + + + 0 + 0 + + - Effect statistics + Actions - - - - - 32767 - - - 0 - - - - - + + + - Inertia - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Graph real-time - - + + - + 0 0 - - false - - - <html><head/><body><p><span style=" font-weight:696; font-style:italic;">Used</span></p></body></html> - - - Qt::AlignCenter - - - - - - - 32767 - - - 0 - - - - - - - Friction - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - Saw Tooth Down - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 32767 - - - 0 - - - - - - - Saw Tooth Up - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 32767 - - - 0 - - - - - - - Square - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Constant - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 32767 - - - 0 - - - - - - - Damper - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 32767 - - - 0 - - - - - - - Sine - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - <html><head/><body><p><span style=" font-weight:696; font-style:italic;">Max force generated</span></p></body></html> - - - Qt::AlignCenter - - - - - - - false - - - <html><head/><body><p><span style=" font-weight:696; font-style:italic;">Effect name</span></p></body></html> + Axis: - Qt::AlignCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - 32767 - - - 0 - - - - - - - 32767 - - - 0 - - + + + false - - - - - - Spring - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 32767 - - - 0 - - - - - - - 32767 - - - 0 - - - - - - - 32767 + + + 0 + 0 + - - 0 + + + 100 + 0 + - - - - - 32767 - - 0 - - - - Ramp - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Custom - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Triangle - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Actions - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + false - + 0 0 @@ -459,29 +575,26 @@ - - - - false + + + + Qt::Orientation::Horizontal - + + QSizePolicy::Policy::Expanding + + - 100 - 0 + 40 + 20 - - 0 - - + - - + + - Axis: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Reload diff --git a/res/errors.ui b/res/errors.ui deleted file mode 100644 index 0e7e82f5..00000000 --- a/res/errors.ui +++ /dev/null @@ -1,91 +0,0 @@ - - - Form - - - - 0 - 0 - 371 - 468 - - - - - 0 - 366 - - - - Form - - - - - - Clear Errors - - - - - - - Refresh - - - - - - - Clear Logs - - - - - - - - 0 - 0 - - - - Errors - - - - - - - 0 - 0 - - - - - - - - - - - - 0 - 0 - - - - Logs - - - - - - - - - - - - - diff --git a/res/ffbclass.ui b/res/ffbclass.ui index 8d3fa786..bf333457 100644 --- a/res/ffbclass.ui +++ b/res/ffbclass.ui @@ -7,612 +7,714 @@ 0 0 543 - 469 + 458 Form - - 10 + + 0 - - - - - 0 - 1 - - - - Gamepad + + 0 + + + 0 + + + 0 + + + 15 + + + + + 0 - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 20 - 20 - - - - - - - - - 1 - 0 - - - - Settings - - - - 9 + + + FFB Effects + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + + 1 + 0 + + + + Static && Periodic Filter (Game effects) + + + false + + + + 10 + + + 5 + + + + + + 100 + 0 + + + + false + + + 0.010000000000000 + + + 0.800000000000000 + + + 0.010000000000000 + + + + + + + Reconstruction Filter + + + + + + + <html><head/><body><p>Lowpass filter frequency for constant force effect.</p><p>Applies only to the CF effect (usually covers all physics based forces for most games) and interpolated between updates.</p><p>Use this filter to tune between sharp and smooth forces and to reduce noise. <br/>Should be lower than games FFB update speed.</p></body></html> + + + Freq. + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + <html><head/><body><p>Disable the reconstruction filter.</p></body></html> + + + None + + + true + + + + + + + <html><head/><body><p>The linear reconstruction filter takes the last two points sent by the game and calculates the linear line between the two points. </p><p>The FFB engine calculates subsamples every time on this straight line. </p><p>The game signal is slowed by 1 sample before being sent to the driver.</p></body></html> + + + Linear + + + + + + + <html><head/><body><p>The &quot;High Fidelity&quot; reconstruction filter takes the last four points sent by the game and calculates a spline between the four points. </p><p>The FFB engine calculates subsamples between the last two points by following this spline most faithfully. </p><p>The signal coming from the game is slowed down by 1.5 samples before being sent to the driver.</p></body></html> + + + High Fidelity + + + + + + + <html><head/><body><p>The &quot;Mixed&quot; reconstruction filter takes the last three points sent by the game and calculates a spline between these points, less faithfully than &quot;HiFi&quot; mode, but more finely than &quot;Linear&quot; mode. </p><p>The FFB engine calculates the subsamples between the last three points following this curve. The signal from the game is slowed down by 1 sample, like linear mode, before being sent to the driver.</p><p>This solution combines the advantage of spline precision and the responsiveness of linear mode, it is recommended by default.</p></body></html> + + + Mixed (optimum) + + + + + + + + + <html><head/><body><p>Q Factor for constant force low pass filter.<br/>Controls how sharp the response drops at the frequency limit.</p><p>Be careful to prevent oscillations by not limiting the bandwidth too much.</p></body></html> + + + Q + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Qt::Orientation::Vertical + + + + + + + <html><head/><body><p>Q Factor for constant force low pass filter.<br/>Controls how sharp the response drops at the frequency limit.</p><p>Be careful to prevent oscillations by not limiting the bandwidth too much.</p></body></html> + + + 0 + + + 80 + + + 1 + + + 4 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + <html><head/><body><p>Lowpass filter for constant force effect.</p><p>Applies only to the CF effect (usually covers all physics based forces for most games) and interpolated between updates.</p><p>Use this filter to tune between sharp and smooth forces and to reduce noise. <br/>Frequency should be lower than games FFB update speed.</p></body></html> + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + 1 + + + 1 + + + <html><head/><body><p>Constant<br/>Force<br/>Filter</p></body></html> + + + true + + + Qt::AlignmentFlag::AlignCenter + + + + + + + 0 + + + + + + + <html><head/><body><p>Lowpass filter frequency for constant force effect.</p><p>Applies only to the CF effect (usually covers all physics based forces for most games) and interpolated between updates.</p><p>Use this filter to tune between sharp and smooth forces and to reduce noise. <br/>Should be lower than games FFB update speed.</p></body></html> + + + 10 + + + 500 + + + 4 + + + 10 + + + 10 + + + Qt::Orientation::Horizontal + + + + + + + + + + Conditional effects settings (Game effects) + + + + 10 + + + 5 + + + + + rpm + + + + + + + <html><head/><body><p>Intensity of inertia effect</p></body></html> + + + 255 + + + 4 + + + 0 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + <html><head/><body><p>Intensity of friction effect</p></body></html> + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + Friction Gain + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + °/s2 + + + + + + + <html><head/><body><p>Intensity of friction effects</p></body></html> + + + 0 + + + 255 + + + 4 + + + 0 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + + 100 + 0 + + + + false + + + 2.000000000000000 + + + 0.010000000000000 + + + + + + + <html><head/><body><p>Intensity of spring effects</p></body></html> + + + 255 + + + 4 + + + 0 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + Qt::Orientation::Vertical + + + + + + + <html><head/><body><p>Intensity of spring effects</p></body></html> + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + Spring Gain + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + 0 + + + + + + + Max effect at + + + + + + + + 100 + 0 + + + + false + + + 2.000000000000000 + + + 0.010000000000000 + + + + + + + + 100 + 0 + + + + false + + + 2 + + + 4.000000000000000 + + + 0.010000000000000 + + + + + + + 0 + + + + + + + <html><head/><body><p>Intensity of damper effect to smooth out acceleration spikes.</p></body></html> + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + Damper Gain + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + <html><head/><body><p>Centering force when FFB is inactive</p></body></html> + + + 255 + + + 4 + + + 0 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + <html><head/><body><p>Intensity of inertia effects</p></body></html> + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Raised + + + Inertia Gain + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + rpm + + + + + + + 0 + + + + + + + + 100 + 0 + + + + false + + + 2.000000000000000 + + + 0.010000000000000 + + + + + + + Advanced Tuning + + + + + + + + + + + Gamepad + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + + 1 + 0 + + + + Settings + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Rate + + + + + + + + 0 + 0 + + + + <html><head/><body><p>Changes the speed at which reports are sent. Slower values skip more packets if the system can't keep up.</p></body></html> + + + + + + + + + + Qt::Orientation::Vertical - - 9 + + QSizePolicy::Policy::MinimumExpanding - - 9 + + + 20 + 20 + - - 9 + + + + + + + 1 + 0 + - - - - Rate - - - - - - - - 0 - 0 - - - - <html><head/><body><p>Changes the speed at which reports are sent. Slower values skip more packets if the system can't keep up.</p></body></html> - - - - - - - - - - - 1 - 0 - - - - Button sources - - - - - - - - 1 - 0 - - - - Analog axes - - - - + + Button sources + + + + + + + + 1 + 0 + + + + Analog axes + + + + + - - - Conditional effects settings (Game effects) - - - - 10 - - - 5 - - - - - rpm - - - - - - - <html><head/><body><p>Intensity of inertia effect</p></body></html> - - - 255 - - - 4 - - - 0 - - - 0 - - - Qt::Horizontal - - - - - - - <html><head/><body><p>Intensity of friction effect</p></body></html> - - - QFrame::NoFrame - - - QFrame::Raised - - - Friction Gain - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - °/s2 - - - - - - - <html><head/><body><p>Intensity of friction effects</p></body></html> - - - 0 - - - 255 - - - 4 - - - 0 - - - 0 - - - Qt::Horizontal - - - - - - - - 100 - 0 - - - - false - - - 2.000000000000000 - - - 0.010000000000000 - - - - - - - <html><head/><body><p>Intensity of spring effects</p></body></html> - - - 255 - - - 4 - - - 0 - - - 0 - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - - - - <html><head/><body><p>Intensity of spring effects</p></body></html> - - - QFrame::NoFrame - - - QFrame::Raised - - - Spring Gain - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 0 - - - - - - - Max effect at - - - - - - - - 100 - 0 - - - - false - - - 2.000000000000000 - - - 0.010000000000000 - - - - - - - - 100 - 0 - - - - false - - - 2 - - - 4.000000000000000 - - - 0.010000000000000 - - - - - - - 0 - - - - - - - <html><head/><body><p>Intensity of damper effect to smooth out acceleration spikes.</p></body></html> - - - QFrame::NoFrame - - - QFrame::Raised - - - Damper Gain - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - <html><head/><body><p>Centering force when FFB is inactive</p></body></html> - - - 255 - - - 4 - - - 0 - - - 0 - - - Qt::Horizontal - - - - - - - <html><head/><body><p>Intensity of inertia effects</p></body></html> - - - QFrame::NoFrame - - - QFrame::Raised - - - Inertia Gain - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - rpm - - - - - - - 0 - - - - - - - - 100 - 0 - - - - false - - - 2.000000000000000 - - - 0.010000000000000 - - - - - - - Advanced Tuning - - - - - - - - - - - 1 - 0 - - - - Static effect settings (Game effects) - - - false - - - - 10 - - - 5 - - - - - <html><head/><body><p>Lowpass filter frequency for constant force effect.</p><p>Applies only to the CF effect (usually covers all physics based forces for most games) and interpolated between updates.</p><p>Use this filter to tune between sharp and smooth forces and to reduce noise. <br/>Should be lower than games FFB update speed.</p></body></html> - - - 10 - - - 500 - - - 4 - - - 10 - - - 10 - - - Qt::Horizontal - - - - - - - 0 - - - - - - - - 100 - 0 - - - - false - - - 0.010000000000000 - - - 0.800000000000000 - - - 0.010000000000000 - - - - - - - <html><head/><body><p>Lowpass filter frequency for constant force effect.</p><p>Applies only to the CF effect (usually covers all physics based forces for most games) and interpolated between updates.</p><p>Use this filter to tune between sharp and smooth forces and to reduce noise. <br/>Should be lower than games FFB update speed.</p></body></html> - - - Freq. - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - <html><head/><body><p>Lowpass filter for constant force effect.</p><p>Applies only to the CF effect (usually covers all physics based forces for most games) and interpolated between updates.</p><p>Use this filter to tune between sharp and smooth forces and to reduce noise. <br/>Frequency should be lower than games FFB update speed.</p></body></html> - - - QFrame::NoFrame - - - QFrame::Raised - - - 1 - - - 1 - - - <html><head/><body><p>Constant<br/>Force<br/>Filter</p></body></html> - - - true - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - - - - - <html><head/><body><p>Q Factor for constant force low pass filter.<br/>Controls how sharp the response drops at the frequency limit.</p><p>Be careful to prevent oscillations by not limiting the bandwidth too much.</p></body></html> - - - 0 - - - 80 - - - 1 - - - 4 - - - 0 - - - Qt::Horizontal - - - - - - - <html><head/><body><p>Q Factor for constant force low pass filter.<br/>Controls how sharp the response drops at the frequency limit.</p><p>Be careful to prevent oscillations by not limiting the bandwidth too much.</p></body></html> - - - Q - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - Qt::Vertical + Qt::Orientation::Vertical @@ -624,6 +726,12 @@ + + radioButton_reconfilter_0 + radioButton_reconfilter_1 + radioButton_reconfilter_2 + radioButton_reconfilter_3 + diff --git a/res/img/axis.png b/res/img/axis.png new file mode 100644 index 00000000..a9754047 Binary files /dev/null and b/res/img/axis.png differ diff --git a/res/img/background.png b/res/img/background.png new file mode 100644 index 00000000..9183f7bb Binary files /dev/null and b/res/img/background.png differ diff --git a/res/img/background_dark.png b/res/img/background_dark.png new file mode 100644 index 00000000..6b911498 Binary files /dev/null and b/res/img/background_dark.png differ diff --git a/res/img/dashboard.png b/res/img/dashboard.png new file mode 100644 index 00000000..9d4629f6 Binary files /dev/null and b/res/img/dashboard.png differ diff --git a/res/img/ffb.png b/res/img/ffb.png new file mode 100644 index 00000000..0e0058df Binary files /dev/null and b/res/img/ffb.png differ diff --git a/res/img/hardware.png b/res/img/hardware.png new file mode 100644 index 00000000..c816d10f Binary files /dev/null and b/res/img/hardware.png differ diff --git a/res/img/motordriver.png b/res/img/motordriver.png new file mode 100644 index 00000000..cc522214 Binary files /dev/null and b/res/img/motordriver.png differ diff --git a/res/img/openffboard.png b/res/img/openffboard.png new file mode 100644 index 00000000..1dcf3f24 Binary files /dev/null and b/res/img/openffboard.png differ diff --git a/res/img/serial.png b/res/img/serial.png new file mode 100644 index 00000000..03800d69 Binary files /dev/null and b/res/img/serial.png differ diff --git a/res/img/settings.png b/res/img/settings.png new file mode 100644 index 00000000..8ceabc6d Binary files /dev/null and b/res/img/settings.png differ diff --git a/res/img/support.png b/res/img/support.png new file mode 100644 index 00000000..c99a5e0c Binary files /dev/null and b/res/img/support.png differ diff --git a/res/logger.conf b/res/logger.conf index d4019f46..c9852613 100644 --- a/res/logger.conf +++ b/res/logger.conf @@ -17,7 +17,7 @@ format=%(asctime)s - %(name)s - %(levelname)s - %(message)s # ******************** LOGGER BELOW **********************/ [loggers] -keys=root,ffbui,serial_comms +keys=root,ffbui,serial_comms,main,qt [logger_root] level=WARNING @@ -33,4 +33,16 @@ propagate=0 level=WARNING handlers=consoleHandler qualname=serial_comms +propagate=0 + +[logger_main] +level=WARNING +handlers=consoleHandler +qualname=main +propagate=0 + +[logger_qt] +level=WARNING +handlers=consoleHandler +qualname=Qt propagate=0 \ No newline at end of file diff --git a/res/profile.cfg b/res/profile.cfg index 596a7884..cd04e7b5 100644 --- a/res/profile.cfg +++ b/res/profile.cfg @@ -1,8 +1,8 @@ { - "release":1, + "release":2, "callOrder": [ {"fullname":"FFB Wheel" ,"classname":"main", "key":["hidsendspd"]}, - {"fullname":"Effects", "classname":"fx", "key":["filterCfFreq","filterCfQ","spring","friction","damper","inertia"]}, - {"fullname":"Axis", "classname":"axis", "key":["power","degrees","fxratio","esgain","idlespring","axisdamper","axisfriction","axisinertia","maxspeed"]} + {"fullname":"Effects", "classname":"fx", "key":["filterCfFreq","filterCfQ","spring","friction","damper","inertia","reconFilterMode"]}, + {"fullname":"Axis", "classname":"axis", "key":["power","degrees","fxratio","esgain","idlespring","axisdamper","axisfriction","axisinertia","maxspeed","equalizer","eqb1","eqb2","eqb3","eqb4","eqb5","eqb6"]} ] } \ No newline at end of file diff --git a/res/profile.ui b/res/profile.ui index 5b6bea76..6234efbc 100644 --- a/res/profile.ui +++ b/res/profile.ui @@ -11,7 +11,7 @@ - + 0 0 diff --git a/res/serialchooser.ui b/res/serialchooser.ui index 1e7c82e3..8317dfc0 100644 --- a/res/serialchooser.ui +++ b/res/serialchooser.ui @@ -6,124 +6,83 @@ 0 0 - 588 - 545 + 878 + 86 Form - - - - - manual command (help) - - + + + 15 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 0 + 20 + + + + + + + + + 0 + 0 + + + + TextLabel + + + + - - - - Refresh - - - - - - - Configuration - - - - - - Main Class - - - - - - - - 0 - 0 - - - - - - - - Change - - - - - - - - - - - 0 - 20 - - - - - - - - - 0 - 2 - - - - true - - - - - - - Connect - - - - - - - Send - - - - - - - Clear log - - + + + + + + Refresh + + + + + + + Connect + + + + + + + Update available + + + + - - - pushButton_clearLog - clicked() - serialLogBox - clear() - - - 541 - 49 - - - 293 - 270 - - - - + diff --git a/res/settings_serial.ui b/res/settings_serial.ui new file mode 100644 index 00000000..d0918bb2 --- /dev/null +++ b/res/settings_serial.ui @@ -0,0 +1,646 @@ + + + Form + + + + 0 + 0 + 1055 + 478 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + 0 + + + + FFBoard + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + + 0 + 0 + + + + Hardware + + + + + + + 0 + 0 + + + + + + + + Change + + + + + + + + 0 + 0 + + + + Main Class + + + + + + + Reboot + + + + + + + + + Reset Factory config + + + + + + + Load chip config + + + + + + + Save chip config + + + + + + + + + Debug mode + + + + + + + + + + Terminal + + + + + + + 0 + 0 + + + + Send + + + + + + + + 0 + 0 + + + + Clear log + + + + + + + + 0 + 0 + + + + manual command (help) + + + + + + + + 0 + 2 + + + + true + + + + + + + + + + Advanced + + + + + + Interface Language + + + + + + + + + + + + + + Firmware + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + Firmware Update (DFU) + + + + + + + 0 + 0 + + + + Hardware actions + + + + + + false + + + + 0 + 0 + + + + Switch update + + + + + + + + 0 + 0 + + + + Activate "update mode" Device Firmware Update to start + + + + + + + false + + + + 0 + 0 + + + + Full chip erase + + + + + + + Erases all flash sectors. Can help if an upgrade causes issues + + + + + + + + + + + 0 + 0 + + + + Firmware Update + + + + + + false + + + + 0 + 0 + + + + Choose file + + + + + + + No file + + + + + + + false + + + + 0 + 0 + + + + Upload firmware + + + + + + + + 0 + 0 + + + + Full erase (Resets all settings) + + + + + + + + + + + 0 + 0 + + + + + + + + true + + + + 0 + 0 + + + + 0 + + + + + + + + + + Update Browser + + + + + + + 0 + 0 + + + + Repository + + + + + + + 0 + 0 + + + + Ultrawipf/OpenFFBoard + + + OpenFFBoard (Firmware + Configurator) + + + true + + + buttonGroup_repo + + + + + + + + 0 + 0 + + + + Ultrawipf/OpenFFBoard-configurator + + + Configurator only (Only use if newer than main repo) + + + buttonGroup_repo + + + + + + + + + + + 0 + 0 + + + + Notify about releases at startup + + + + + + + + 0 + 2 + + + + Release notes + + + + + + Info + + + + + + + + 0 + 0 + + + + + + + + + + + Info: Use the main repo for firmware updates and always use matching configurator release + + + + + + + + 0 + 1 + + + + Releases + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + true + + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + true + + + + + + + + + + + + + + + + + + + + + pushButton_clearLog + clicked() + serialLogBox + clear() + + + 541 + 49 + + + 293 + 270 + + + + + + + + diff --git a/res/style.qss b/res/style.qss new file mode 100644 index 00000000..df33f62a --- /dev/null +++ b/res/style.qss @@ -0,0 +1,324 @@ +/*********************************************************************************************************************************/ +/* 1. GLOBAL WINDOW & CONTAINER STYLES */ +/*********************************************************************************************************************************/ + +/* Main window background image */ +QMainWindow { + background-image: url("./res/img/background_dark.png"); + background-repeat: no-repeat; + background-position: center; + background-attachment: fixed; +} + +/* Semi-transparent overlay to control contrast and darken background image */ +#centralwidget { + background-color: rgba(0, 0, 0, 40); + border: none; +} + +/* Make standard container elements transparent to let the background show through */ +QLabel#icon, +QWidget#serial_panel, +QWidget#serial_panel > QWidget { + background-color: transparent; + border: none; +} + +/* Make text labels, radio buttons, and checkboxes transparent by default */ +QLabel, QRadioButton, QCheckBox { + background-color: transparent; +} + +/* Remove borders and background from scroll areas */ +QScrollArea, QScrollArea > QWidget, QScrollArea > QWidget > QWidget { + background-color: transparent; + border: none; +} + +/*********************************************************************************************************************************/ +/* 2. GLOBAL COMPONENT STYLES */ +/*********************************************************************************************************************************/ + +/* Global Push Button styles (Configurator Blue) */ +QPushButton { + background-color: #0097c9; + color: #E0E0E0; + font-size: 12px; + font-weight: bold; + border-radius: 5px; + padding: 5px 10px; + min-width: 100px; + border: none; +} + +QPushButton:hover { + background-color: #007ca5; +} + +QPushButton:disabled { + background-color: #002e3d; + color: #5c6a6f; +} + +/* Global Group Box styles (Configurator Blue Border & Dark Glass Background) */ +QGroupBox { + background-color: rgba(45, 48, 53, 160); + border: 3px solid #005e7d; + border-radius: 8px; + padding: 25px 10px 5px 10px; + font-size: 14px; + text-transform: uppercase; + font-weight: bold; + color: #E0E0E0; +} + +QGroupBox:disabled { + background-color: rgba(31, 33, 36, 160); + border: 3px solid #32393c; + color: #5c6a6f; +} + +QGroupBox::title { + subcontrol-origin: padding; + subcontrol-position: top left; + padding: 5px 10px; +} + +/*********************************************************************************************************************************/ +/* 3. NORTH ZONE: HEADER & CONNECTIVITY */ +/*********************************************************************************************************************************/ + +/* Serial Port connection indicator */ +QLabel#label_port { + color: #45c145; + font-size: 24px; + font-weight: bold; +} + +QLabel#label_port:disabled { + color: #a03b00; +} + +/* Serial connection panel action buttons (Orange Theme) */ +QWidget#serial_panel QPushButton { + background-color: #d68c02; + color: #E0E0E0; + border: none; + border-radius: 5px; + padding: 5px 10px; + font-size: 14px; +} + +QWidget#serial_panel QPushButton#pushButton_settings { + min-width: 30px; +} + +QWidget#serial_panel QPushButton:hover { + background-color: #a26900; +} + +QWidget#serial_panel QPushButton:disabled { + background-color: #523600; + color: #5c6a6f; +} + +/* Update firmware button inside serial panel */ +QWidget#serial_panel QPushButton#pushButton_update { + background-color: #00BFFF; + color: #E0E0E0; + border: none; + border-radius: 5px; + padding: 5px 10px; + font-size: 14px; + font-weight: bold; +} + +QWidget#serial_panel QPushButton#pushButton_update:hover { + background-color: #0099CC; +} + +/* Settings button in the status bar */ +QPushButton#statusBarSettingsButton { + background-color: transparent; + border: none; +} + +QPushButton#statusBarSettingsButton QIcon { + qproperty-iconSize: 24px; +} + +/* Profile Manager groupbox and controls (Green Theme) */ +QGroupBox#groupBox_profilemanager { + border: 3px solid #2f7c2f; +} + +QGroupBox#groupBox_profilemanager:disabled { + border: 3px solid #2f7c2f; +} + +QGroupBox#groupBox_profilemanager QPushButton { + background-color: #00AA00; + font-size: 14px; + font-weight: bold; + border-radius: 5px; + padding: 5px 20px; + min-width: 120px; +} + +QGroupBox#groupBox_profilemanager QPushButton:hover { + background-color: #008800; +} + +QGroupBox#groupBox_profilemanager QPushButton:disabled { + background-color: #334633; + color: #5c6a6f; +} + +/*********************************************************************************************************************************/ +/* 4. WEST ZONE & NAVIGATION */ +/*********************************************************************************************************************************/ + +/* Side Menu Panel (Left Navigation) */ +QFrame#sideMenuFrame { + background-color: #202124; + border-radius: 8px; + border: 3px solid #005e7d; + padding: 5px; +} + +QFrame#sideMenuFrame QPushButton { + background-color: rgba(0, 151, 201, 32); + border: none; + border-radius: 0px; + min-height: 40px; + margin: 5px 0px; + padding: 0px 15px; + icon-size: 24px; + color: #E0E0E0; + font-size: 18px; + font-weight: bold; + text-align: left; +} + +QFrame#sideMenuFrame QPushButton:hover { + background-color: rgba(0, 151, 201, 64); + border: 1px solid #0097c9; +} + +QFrame#sideMenuFrame QPushButton[active="true"] { + background-color: #0097c9; + border: none; +} + +/* Make main content stack and its immediate child pages transparent */ +#content_stack, +#content_stack > QWidget, +#content_stack > QWidget > QWidget { + background-color: transparent; + border: none; +} + +/* Override transparency for Group Boxes inside the content stack */ +#content_stack > QWidget > QGroupBox { + background-color: rgba(45, 48, 53, 160); + border: 3px solid #005e7d; +} + +#content_stack > QWidget > QGroupBox:disabled { + background-color: rgba(31, 33, 36, 160); + border: 3px solid #32393c; +} + +/* Top Navigation Tabs inside the pages of the content stack */ +#content_stack > QWidget > QTabWidget::pane { + background-color: transparent; + border: none; +} + +#content_stack > QWidget > QTabWidget > QTabBar { + background-color: transparent; + border: none; +} + +#content_stack > QWidget > QTabWidget, +#content_stack > QWidget > QTabWidget QStackedWidget, +#content_stack > QWidget > QTabWidget QStackedWidget > QWidget { + background-color: transparent; +} + +/* Style top navigation tabs to look like buttons matching the sidebar */ +#content_stack > QWidget > QTabWidget > QTabBar::tab { + background-color: #1c3039; + border: 1px solid transparent; + border-radius: 0px; + min-height: 32px; + min-width: 120px; + margin: 6px 8px 6px 0px; + padding: 0px 10px; + icon-size: 16px; + color: #E0E0E0; + font-size: 13px; + font-weight: bold; + text-align: center; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:last { + margin-right: 0px; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:only-one { + margin-right: 0px; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:hover { + background-color: #183f4d; + border: 1px solid #0097c9; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:selected { + background-color: #0097c9; + border: 1px solid transparent; + color: #E0E0E0; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:disabled { + background: rgba(45, 48, 53, 160); + border: none; + color: #5c6a6f; +} + +/*********************************************************************************************************************************/ +/* 5. EAST ZONE: CONTENT COMPONENTS */ +/*********************************************************************************************************************************/ + +/* Progress Bars inside Axis and FFB tuning pages */ +QGroupBox#groupBox_FFBAxes QProgressBar, +QGroupBox#groupBox_secAxis QProgressBar, +QGroupBox#groupBox_ffbeffect QProgressBar { + background-color: #31363b; + border: 1px solid #76797C; + border-radius: 2px; + text-align: center; +} + +QGroupBox#groupBox_FFBAxes QProgressBar::chunk, +QGroupBox#groupBox_secAxis QProgressBar::chunk, +QGroupBox#groupBox_ffbeffect QProgressBar::chunk { + background-color: #3daee9; +} + +/* Status / Error label indicator */ +QLabel#lbl_status { + font-size: 24px; + font-weight: bold; + color: #a40000; +} + +/*********************************************************************************************************************************/ +/* 6. SOUTH ZONE: FOOTER & STATUS BAR */ +/*********************************************************************************************************************************/ + +/* Status bar backdrop */ +QStatusBar { + background-color: rgba(45, 48, 53, 240); +} diff --git a/res/style_light.qss b/res/style_light.qss new file mode 100644 index 00000000..822c72b2 --- /dev/null +++ b/res/style_light.qss @@ -0,0 +1,330 @@ +/*********************************************************************************************************************************/ +/* 1. GLOBAL WINDOW & CONTAINER STYLES */ +/*********************************************************************************************************************************/ + +/* Main window background image */ +QMainWindow { + background-image: url("./res/img/background.png"); + background-repeat: no-repeat; + background-position: center; + background-attachment: fixed; +} + +/* Semi-transparent overlay to control contrast and lighten background image */ +#centralwidget { + background-color: rgba(255, 255, 255, 40); + border: none; +} + +/* Make standard container elements transparent to let the background show through */ +QLabel#icon, +QWidget#serial_panel, +QWidget#serial_panel > QWidget { + background-color: transparent; + border: none; +} + +/* Make text labels, radio buttons, and checkboxes transparent by default */ +QLabel, QRadioButton, QCheckBox { + background-color: transparent; +} + +/* Remove borders and background from scroll areas */ +QScrollArea, QScrollArea > QWidget, QScrollArea > QWidget > QWidget { + background-color: transparent; + border: none; +} + +/*********************************************************************************************************************************/ +/* 2. GLOBAL COMPONENT STYLES */ +/*********************************************************************************************************************************/ + +/* Global Push Button styles (Configurator Blue) */ +QPushButton { + background-color: #0097c9; + color: #FFFFFF; + font-size: 12px; + font-weight: bold; + border-radius: 5px; + padding: 5px 10px; + min-width: 100px; + border: none; +} + +QPushButton:hover { + background-color: #007ca5; +} + +QPushButton:disabled { + background-color: #cceaf4; + color: #8ba3ab; +} + +/* Global Group Box styles (Configurator Blue Border & Light Glass Background) */ +QGroupBox { + background-color: rgba(255, 255, 255, 200); + border: 3px solid #005e7d; + border-radius: 8px; + padding: 25px 10px 5px 10px; + font-size: 14px; + text-transform: uppercase; + font-weight: bold; + color: #1E1E1E; +} + +QGroupBox:disabled { + background-color: rgba(240, 240, 240, 200); + border: 3px solid #d0d0d0; + color: #888888; +} + +QGroupBox::title { + subcontrol-origin: padding; + subcontrol-position: top left; + padding: 5px 10px; +} + +/*********************************************************************************************************************************/ +/* 3. NORTH ZONE: HEADER & CONNECTIVITY */ +/*********************************************************************************************************************************/ + +/* Serial Port connection indicator */ +QLabel#label_port { + color: #2e8b2e; + font-size: 24px; + font-weight: bold; +} + +QLabel#label_port:disabled { + color: #a03b00; +} + +/* Serial connection panel action buttons (Orange Theme) */ +QWidget#serial_panel QPushButton { + background-color: #d68c02; + color: #FFFFFF; + border: none; + border-radius: 5px; + padding: 5px 10px; + font-size: 14px; +} + +QWidget#serial_panel QPushButton#pushButton_settings { + min-width: 30px; +} + +QWidget#serial_panel QPushButton:hover { + background-color: #a26900; +} + +QWidget#serial_panel QPushButton:disabled { + background-color: #f5e2bf; + color: #b38f54; +} + +/* Update firmware button inside serial panel */ +QWidget#serial_panel QPushButton#pushButton_update { + background-color: #00BFFF; + color: #FFFFFF; + border: none; + border-radius: 5px; + padding: 5px 10px; + font-size: 14px; + font-weight: bold; +} + +QWidget#serial_panel QPushButton#pushButton_update:hover { + background-color: #0099CC; +} + +/* Settings button in the status bar */ +QPushButton#statusBarSettingsButton { + background-color: transparent; + border: none; +} + +QPushButton#statusBarSettingsButton QIcon { + qproperty-iconSize: 24px; +} + +/* Profile Manager groupbox and controls (Green Theme) */ +QGroupBox#groupBox_profilemanager { + border: 3px solid #2f7c2f; +} + +QGroupBox#groupBox_profilemanager:disabled { + border: 3px solid #2f7c2f; +} + +QGroupBox#groupBox_profilemanager QPushButton { + background-color: #00AA00; + color: #FFFFFF; + font-size: 14px; + font-weight: bold; + border-radius: 5px; + padding: 5px 20px; + min-width: 120px; +} + +QGroupBox#groupBox_profilemanager QPushButton:hover { + background-color: #008800; +} + +QGroupBox#groupBox_profilemanager QPushButton:disabled { + background-color: #c3eec3; + color: #79a679; +} + +/*********************************************************************************************************************************/ +/* 4. WEST ZONE & NAVIGATION */ +/*********************************************************************************************************************************/ + +/* Side Menu Panel (Left Navigation) */ +QFrame#sideMenuFrame { + background-color: #f8f9fa; + border-radius: 8px; + border: 3px solid #005e7d; + padding: 5px; +} + +QFrame#sideMenuFrame QPushButton { + background-color: rgba(0, 151, 201, 32); + border: none; + border-radius: 0px; + min-height: 40px; + margin: 5px 0px; + padding: 0px 15px; + icon-size: 24px; + color: #1E1E1E; + font-size: 18px; + font-weight: bold; + text-align: left; +} + +QFrame#sideMenuFrame QPushButton:hover { + background-color: rgba(0, 151, 201, 64); + border: 1px solid #0097c9; + color: #1E1E1E; +} + +QFrame#sideMenuFrame QPushButton[active="true"] { + background-color: #0097c9; + border: none; + color: #FFFFFF; +} + +/* Make main content stack and its immediate child pages transparent */ +#content_stack, +#content_stack > QWidget, +#content_stack > QWidget > QWidget { + background-color: transparent; + border: none; +} + +/* Override transparency for Group Boxes inside the content stack */ +#content_stack > QWidget > QGroupBox { + background-color: rgba(255, 255, 255, 200); + border: 3px solid #005e7d; +} + +#content_stack > QWidget > QGroupBox:disabled { + background-color: rgba(240, 240, 240, 200); + border: 3px solid #d0d0d0; +} + +/* Top Navigation Tabs inside the pages of the content stack */ +#content_stack > QWidget > QTabWidget::pane { + background-color: transparent; + border: none; +} + +#content_stack > QWidget > QTabWidget > QTabBar { + background-color: transparent; + border: none; +} + +#content_stack > QWidget > QTabWidget, +#content_stack > QWidget > QTabWidget QStackedWidget, +#content_stack > QWidget > QTabWidget QStackedWidget > QWidget { + background-color: transparent; +} + +/* Style top navigation tabs to look like buttons matching the sidebar */ +#content_stack > QWidget > QTabWidget > QTabBar::tab { + background-color: #d9edf4; + border: 1px solid transparent; + border-radius: 0px; + min-height: 32px; + min-width: 120px; + margin: 6px 8px 6px 0px; + padding: 0px 15px; + icon-size: 16px; + color: #1E1E1E; + font-size: 13px; + font-weight: bold; + text-align: center; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:last { + margin-right: 0px; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:only-one { + margin-right: 0px; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:hover { + background-color: #bae0ee; + border: 1px solid #0097c9; + color: #1E1E1E; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:selected { + background-color: #0097c9; + border: 1px solid transparent; + color: #FFFFFF; +} + +#content_stack > QWidget > QTabWidget > QTabBar::tab:disabled { + background: rgba(240, 240, 240, 160); + border: none; + color: #888888; +} + +/*********************************************************************************************************************************/ +/* 5. EAST ZONE: CONTENT COMPONENTS */ +/*********************************************************************************************************************************/ + +/* Progress Bars inside Axis and FFB tuning pages */ +QGroupBox#groupBox_FFBAxes QProgressBar, +QGroupBox#groupBox_secAxis QProgressBar, +QGroupBox#groupBox_ffbeffect QProgressBar { + background-color: #e5e5e5; + border: 1px solid #B0B0B0; + border-radius: 2px; + text-align: center; + color: #1E1E1E; +} + +QGroupBox#groupBox_FFBAxes QProgressBar::chunk, +QGroupBox#groupBox_secAxis QProgressBar::chunk, +QGroupBox#groupBox_ffbeffect QProgressBar::chunk { + background-color: #3daee9; +} + +/* Status / Error label indicator */ +QLabel#lbl_status { + font-size: 24px; + font-weight: bold; + color: #a40000; +} + +/*********************************************************************************************************************************/ +/* 6. SOUTH ZONE: FOOTER & STATUS BAR */ +/*********************************************************************************************************************************/ + +/* Status bar backdrop */ +QStatusBar { + background-color: rgba(255, 255, 255, 240); + color: #1E1E1E; +} diff --git a/res/tmc4671_ui.ui b/res/tmc4671_ui.ui index 06edf6a2..bf191e2b 100644 --- a/res/tmc4671_ui.ui +++ b/res/tmc4671_ui.ui @@ -6,685 +6,924 @@ 0 0 - 744 - 731 + 697 + 487 Form + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + - - - TMC4671 + + + 0 - - - - - - 1 - 0 - - - - Motor - - - - - - Motor Type - - - - - - - - None - - - - - - - - Pole Pairs - - - - - - - false - - - 50 - - - - - - - <html><head/><body><p>Inverts forces received by the driver when combine with ext. encoder is active if the internal and external encoder directions don't match</p></body></html> - - - Invert forces (ext. encoder) - - - - - - - - - - QFrame::NoFrame - - - QFrame::Raised - - - - 9 + + + Monitoring + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + TMC4671 - - 3 + + + + + + 0 + 0 + + + + + 500 + 16777215 + + + + More + + + + + + Torque Filter + + + + + + + + + + Torque Filter freq. + + + + + + + false + + + + 100 + 0 + + + + Hz + + + 10 + + + 8192 + + + 1000 + + + + + + + <html><head/><body><p>Caution: can increase max speed but can also cause steppers to accelerate exponentially if too high</p></body></html> + + + Flux field weakening + + + + + + + false + + + + 100 + 0 + + + + <html><head/><body><p>Caution: can increase max speed but can also cause steppers to accelerate exponentially if too high</p></body></html> + + + false + + + A + + + 5.000000000000000 + + + 0.100000000000000 + + + + + + + Qt::Orientation::Horizontal + + + + + + + HW Version + + + true + + + + + + + Select hardware version + + + + + + + Force encoder alignment + + + + + + + Start full calibration + + + + + + + Qt::Orientation::Horizontal + + + + + + + State: + + + + + + + State + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Motor current: + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + 1 + + + 0A + + + Qt::TextFormat::PlainText + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Temperature: + + + + + + + + 0 + 0 + + + + 0°C + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Voltage: + + + + + + + 0V + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + <html><head/><body><p>Use flux component to dissipate energy before enabling the brake resistor. </p><p>Heats up the motor slightly more. Brake resistor will still operate normally.</p><p>Only disable if suitable brake resistor is installed.</p></body></html> + + + Prefer energy dissipation in motor + + + + + + + Cogging calibration + + + + + + + + + + 0 + + + + Current + + + + + + + 0 + 1 + + + + + + + + + Cogging + + + + + + + 0 + 1 + + + + + + + + + + Reload table + + + + + + + Reset table + + + + + + + + + + + + + + 0 + 0 + + + + Qt::LayoutDirection::LeftToRight + + + 28000 + + + 0 + + + true + + + %p% Power + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + Settings + + + + 0 + + + 0 + + + 0 + + + 0 + + + 15 + + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + + + + + 0 + 0 + + + + + 500 + 16777215 + - - 9 + + PIDs - - - - Apply motor settings - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - 0 - 0 - - - - - 500 - 16777215 - - - - PIDs - - - - - - Torque P - - - - - - - - 100 - 0 - - - - false - - - 32767 - - - - - - - Torque I - - - - - - - - 100 - 0 - - - - false - - - 32767 - - - - - - - Flux P - - - - - - - - 100 - 0 - - - - false - - - 32767 - - - - - - - Flux I - - - - - - - - 100 - 0 - - - - false - - - 32767 - - - - - - - Apply PID settings - - - - - - - false - - - P Precision (1/16) - - - buttonGroup_precision - - - - - - - false - - - I Precision (1/16) - - - buttonGroup_precision - - - - - - - Autotune - - - - - - - <html><head/><body><p>Toggles between parallel and sequential PI modes. Requires retuning.<br/>Advanced (sequential) can improve impulse response.</p></body></html> - - - Sequential PI - - - - - - - <html><head/><body><p>Space vector PWM increases the speed of BLDC motors by allowing higher phase voltage.</p><p>Disable if the star point is not isolated.</p></body></html> - - - SV-PWM - - - - - - - - - - - 1 - 0 - - - - Encoder - - - - - - Encoder Type - - - - - - + + + + + Torque P + + + + + + + + 100 + 0 + + + + false + + + 32767 + + + + + + + Torque I + + + + + + + + 100 + 0 + + + + false + + + 32767 + + + + + + + Flux P + + + + + + + + 100 + 0 + + + + false + + + 32767 + + + + + - NONE - - - - - - - - CPR (4*PPR) - - - - - - - true - - - <html><head/><body><p>Encoder resolution after quadrature (4 * PPR = CPR)</p></body></html> - - - false - - - 65535 - - - - - - - true - - - ABN polarity (LOW) - - - - - - - true - - - Home with ABN index - - - - - - - <html><head/><body><p>Allows using a local TMC encoder for commutation and an externally selected encoder for position reporting</p></body></html> - - - Combine ext. encoder - - - - - - - true - - - <html><head/><body><p>External encoder forwards the encoder</p><p>selection of the Axis (if available).<br/>Please select the encoder there.</p></body></html> - - - - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - - 0 - 1 - - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 20 - 0 - - - - - - - - - 0 - 0 - - - - Qt::LeftToRight - - - 28000 - - - 0 - - - true - - - %p% Power - - - - - - - - 500 - 16777215 - - - - More - - - - - - Torque Filter - - - - - - - - - - Torque Filter freq. - - - - - - - false - - - - 100 - 0 - - - - Hz - - - 10 - - - 8192 - - - 1000 - - - - - - - <html><head/><body><p>Caution: can increase max speed but can also cause steppers to accelerate exponentially if too high</p></body></html> - - - Flux field weakening - - - - - - - false - - - - 100 - 0 - - - - <html><head/><body><p>Caution: can increase max speed but can also cause steppers to accelerate exponentially if too high</p></body></html> - - - false - - - A - - - 5.000000000000000 - - - 0.100000000000000 - - - - - - - Qt::Horizontal - - - - - - - HW Version - - - true - - - - - - - Select hardware version - - - - - - - Force encoder alignment - - - - - - - Start full calibration - - - - - - - Qt::Horizontal - - - - - - - State: - - - - - - - State - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Motor current: - - - - - - - - 0 - 0 - - - - 0A - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Temperature: - - - - - - - 0°C - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Voltage: - - - - - - - 0V - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - <html><head/><body><p>Use flux component to dissipate energy before enabling the brake resistor. </p><p>Heats up the motor slightly more. Brake resistor will still operate normally.</p><p>Only disable if suitable brake resistor is installed.</p></body></html> - - - Prefer energy dissipation in motor - - - - - - - + Flux I + + + + + + + + 100 + 0 + + + + false + + + 32767 + + + + + + + Apply PID settings + + + + + + + false + + + P Precision (1/16) + + + buttonGroup_precision + + + + + + + false + + + I Precision (1/16) + + + buttonGroup_precision + + + + + + + Autotune + + + + + + + <html><head/><body><p>Toggles between parallel and sequential PI modes. Requires retuning.<br/>Advanced (sequential) can improve impulse response.</p></body></html> + + + Sequential PI + + + + + + + <html><head/><body><p>Space vector PWM increases the speed of BLDC motors by allowing higher phase voltage.</p><p>Disable if the star point is not isolated.</p></body></html> + + + SV-PWM + + + + + + + + + + Hardware + + + + + + + 1 + 0 + + + + Motor + + + + + + Motor Type + + + + + + + + None + + + + + + + + Pole Pairs + + + + + + + false + + + 50 + + + + + + + <html><head/><body><p>Inverts forces received by the driver when combine with ext. encoder is active if the internal and external encoder directions don't match</p></body></html> + + + Invert forces (ext. encoder) + + + + + + + + + + Anticogging + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Apply anti-cogging (Exp.) + + + + + + + + 0 + 0 + + + + Magnitude + + + + + + + 4 + + + -3.276700000000000 + + + 3.276700000000000 + + + 0.000100000000000 + + + + + + + -32767 + + + 32767 + + + 100 + + + 1000 + + + true + + + Qt::Orientation::Horizontal + + + false + + + false + + + + + + + + + + 0 + + + 0 + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Apply motor settings + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + 1 + 0 + + + + Encoder + + + + + + Encoder Type + + + + + + + + NONE + + + + + + + + CPR (4*PPR) + + + + + + + true + + + <html><head/><body><p>Encoder resolution after quadrature (4 * PPR = CPR)</p></body></html> + + + false + + + 65535 + + + + + + + true + + + ABN polarity (LOW) + + + + + + + true + + + Home with ABN index + + + + + + + <html><head/><body><p>Allows using a local TMC encoder for commutation and an externally selected encoder for position reporting</p></body></html> + + + Combine ext. encoder + + + + + + + true + + + <html><head/><body><p>External encoder forwards the encoder</p><p>selection of the Axis (if available).<br/>Please select the encoder there.</p></body></html> + + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + diff --git a/res/updatebrowser.ui b/res/updatebrowser.ui deleted file mode 100644 index be60e4f0..00000000 --- a/res/updatebrowser.ui +++ /dev/null @@ -1,191 +0,0 @@ - - - Form - - - - 0 - 0 - 705 - 622 - - - - Updatebrowser - - - - - - Info: Use the main repo for firmware updates and always use matching configurator release - - - - - - - - 0 - 1 - - - - Releases - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - true - - - - - - - - - - - - - 0 - 1 - - - - Release files - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - true - - - - - - - - - - - - Repository - - - - - - <html><head/><body><p>Ultrawipf/OpenFFBoard</p></body></html> - - - OpenFFBoard (Firmware + Configurator) - - - true - - - buttonGroup_repo - - - - - - - <html><head/><body><p>Ultrawipf/OpenFFBoard-configurator</p></body></html> - - - Configurator only (Only use if newer than main repo) - - - buttonGroup_repo - - - - - - - - - - - 0 - 2 - - - - Release notes - - - - - - Info - - - - - - - - - - - - - Notify about releases at startup - - - - - - - - - - - diff --git a/serial_comms.py b/serial_comms.py index b58a4732..9f4d4f85 100644 --- a/serial_comms.py +++ b/serial_comms.py @@ -220,7 +220,7 @@ def processMatchedReply(self,match): if callbackObject["delete"]: # delete if flag is set #print("Deleting",callbackObject) - if (SerialComms.callbackDict[cls] is not None) \ + if (SerialComms.callbackDict.get(cls) is not None) \ and (callbackObject in SerialComms.callbackDict[cls]) : SerialComms.callbackDict[cls].remove(callbackObject) else : diff --git a/serial_ui.py b/serial_ui.py index 735dbfe2..11e1b799 100644 --- a/serial_ui.py +++ b/serial_ui.py @@ -13,7 +13,7 @@ import PyQt6.QtWidgets import base_ui import main -import helper +import updater class SerialChooser(base_ui.WidgetUI, base_ui.CommunicationHandler): @@ -36,26 +36,22 @@ def __init__(self, serial: PyQt6.QtSerialPort.QSerialPort, main_ui: main.MainUi) base_ui.CommunicationHandler.__init__(self) self._serial = serial self.main = main_ui - self.main_id = None - self._classes = [] - self._class_ids = {} self._port = None self._ports = [] self.pushButton_refresh.clicked.connect(self.get_ports) self.pushButton_connect.clicked.connect(self.serial_connect_button) - self.pushButton_send.clicked.connect(self.send_line) - self.lineEdit_cmd.returnPressed.connect(self.send_line) - self.pushButton_ok.clicked.connect(self.main_btn) - + self.pushButton_update.clicked.connect(self.open_settings_firmware_tab) + self.update() + # Initially hide the update button (already hidden in UI file) + self.pushButton_update.setVisible(False) def showEvent(self, event): # pylint: disable=unused-argument, invalid-name """On show event, init the param. Connect the communication module with the history widget to load the board response. """ - self.get_raw_reply().connect(self.serial_log) self.shown.emit() # Tab is hidden @@ -65,27 +61,8 @@ def hideEvent(self, event): # pylint: disable=unused-argument, invalid-name Disconnect the communication module with the history widget to stop to log the board response. """ - self.get_raw_reply().disconnect(self.serial_log) self.hidden.emit() - def serial_log(self, txt): - """Add a new text in the history widget.""" - if isinstance(txt, list): - txt = "\n".join(txt) - else: - txt = str(txt) - self.serialLogBox.append(txt) - - def send_line(self): - """Read the command input text, display it in history widget and send it to the board.""" - cmd = self.lineEdit_cmd.text() + "\n" - self.serial_log(">" + cmd) - self.serial_write_raw(cmd) - - def write(self, data): - """Write data to the serial port.""" - self._serial.write(data) - def update(self): """Update the UI when a connection is successfull. @@ -94,20 +71,17 @@ def update(self): """ if self._serial.isOpen(): self.pushButton_connect.setText(self.tr("Disconnect")) - self.comboBox_port.setEnabled(False) - self.pushButton_refresh.setEnabled(False) - self.pushButton_send.setEnabled(True) - self.lineEdit_cmd.setEnabled(True) + self.pushButton_refresh.setVisible(False) + self.comboBox_port.setVisible(False) + self.label_port.setVisible(True) + self.label_port.setText(self.comboBox_port.currentText()) self.connected.emit(True) - self.get_main_classes() else: self.pushButton_connect.setText(self.tr("Connect")) - self.comboBox_port.setEnabled(True) - self.pushButton_refresh.setEnabled(True) - self.pushButton_send.setEnabled(False) - self.lineEdit_cmd.setEnabled(False) + self.pushButton_refresh.setVisible(True) + self.comboBox_port.setVisible(True) + self.label_port.setVisible(False) self.connected.emit(False) - self.groupBox_system.setEnabled(False) def serial_connect_button(self): """Check if it's not connected, and call start the serial connection.""" @@ -156,12 +130,13 @@ def get_ports(self): port.vendorIdentifier(), port.productIdentifier(), ) in self.OFFICIAL_VID_PID - name = port.portName() + " : " + port.description() + - if supported_vid_pid and not name.startswith("cu."): - name += " (FFBoard device)" + if supported_vid_pid and not port.portName().startswith("cu."): + name = F"FFBoard device ({port.portName()})" else: - name += " (Unsupported device)" + name = F"{port.description()} ({port.portName()})" + self.comboBox_port.addItem(name) if supported_vid_pid and not name.startswith("cu."): @@ -169,13 +144,13 @@ def get_ports(self): nb_compatible_device = nb_compatible_device + 1 self.comboBox_port.setItemData( i, - PyQt6.QtGui.QColor("green"), + PyQt6.QtGui.QColor(0x00FF00), PyQt6.QtCore.Qt.ItemDataRole.ForegroundRole, ) else: self.comboBox_port.setItemData( i, - PyQt6.QtGui.QColor("red"), + PyQt6.QtGui.QColor(0x990000), PyQt6.QtCore.Qt.ItemDataRole.ForegroundRole, ) @@ -201,45 +176,47 @@ def auto_connect(self, nb_compatible_device): if (nb_compatible_device == 1) : self.serial_connect_button() - def update_mains(self, dat): - """Parse the list of main classes received from board, and update the combobox.""" - self.comboBox_main.clear() - self._class_ids, self._classes = helper.classlistToIds(dat) - - if self.main_id is None: - # self.main.resetPort() - self.groupBox_system.setEnabled(False) + def update_port_name_outdated(self): + """Change the color of the port name in the combo box.""" + # Get the current text of the combo box + current_text = self.label_port.text() #self.comboBox_port.currentText() + outdated_text = " [OUTDATED !]" + self.label_port.setEnabled(False) + + # If we have a valid text, change its color + if current_text and not (outdated_text in current_text) : + #current_index = self.comboBox_port.currentIndex() + #self.comboBox_port.setItemText(current_index, current_text + outdated_text) + self.label_port.setText(current_text + outdated_text) + + def check_for_updates(self): + """Check for available updates and show/hide the update button accordingly.""" + # Check firmware version against latest release + release = updater.GithubRelease.get_latest_release(updater.MAINREPO) + if not release: return - self.groupBox_system.setEnabled(True) - - helper.updateClassComboBox( - self.comboBox_main, self._class_ids, self._classes, self.main_id - ) - - self.main.log("Detected mode: " + self.comboBox_main.currentText()) - self.main.update_tabs() - - def get_main_classes(self): - """Get the main classes available from the board in Async.""" - - def fct(i): - """Store the main currently selected to refresh the UI.""" - self.main_id = i - - self.get_value_async("main", "id", fct, conversion=int, delete=True) - self.get_value_async("sys", "lsmain", self.update_mains, delete=True) - - def main_btn(self): - """Read the select main class in the combobox. - - Push it to the board and display the reload warning. - """ - index = self._classes[self.comboBox_main.currentIndex()][0] - self.send_value("sys", "main", index) - self.main.reconnect() - msg = PyQt6.QtWidgets.QMessageBox( - PyQt6.QtWidgets.QMessageBox.Icon.Information, - "Main class changed", - "Chip is rebooting. Please reconnect.", - ) - msg.exec() + releaseversion, _ = updater.GithubRelease.get_version(release) + # Compare firmware version with latest release + if updater.UpdateChecker.compare_versions(self.main.fw_version_str, releaseversion): + # New release available for firmware + self.pushButton_update.setText(f"Update available {releaseversion}") + self.pushButton_update.setVisible(True) + else: + self.pushButton_update.setVisible(False) + + def open_settings_firmware_tab(self): + """Open the settings tab and select the firmware sub-tab when update button is clicked.""" + self.main.select_tab("settings") + try: + # Use a single shot timer to delay the tab selection + PyQt6.QtCore.QTimer.singleShot(100, self.select_update_tab) + except Exception as e: + pass + + def select_update_tab(self): + """Select the Update Browser tab in the settings UI.""" + try: + # Try to access the tabWidget_update and select the second tab (index 1) + self.main.settings_ui.findChild(PyQt6.QtWidgets.QTabWidget).setCurrentIndex(1) + except Exception as e: + pass diff --git a/settings_ui.py b/settings_ui.py new file mode 100644 index 00000000..068719f1 --- /dev/null +++ b/settings_ui.py @@ -0,0 +1,576 @@ +"""Serial UI module. + +Regroup all required classes to manage the Serial Connection UI +and the link with the communication module. + +Module : serial_ui +Authors : yannick +""" +from concurrent.futures import process +import PyQt6.QtGui +import PyQt6.QtSerialPort +import PyQt6.QtCore +import PyQt6.QtWidgets +import base_ui +import main +import helper +import config +import pydfu +import updater +from PyQt6.QtGui import QColor +from PyQt6.QtCore import Qt + + +class Settings(base_ui.WidgetUI, base_ui.CommunicationHandler): + """This classe is the main Serial Chooser manager. + + *) Display the UI + *) Manage the user interraction : connect/disconnect + *) Manage the serial port status + """ + + OFFICIAL_VID_PID = [(0x1209, 0xFFB0)] # Highlighted in serial selector + STM_DEVID_ADR_F4 = 0x40024000 + + def __init__(self, main_ui, serialport : PyQt6.QtSerialPort.QSerialPort): + """Initialize the manager with the QSerialPort for serial commmunication and the mainUi.""" + base_ui.WidgetUI.__init__(self, main_ui, "settings_serial.ui") + base_ui.CommunicationHandler.__init__(self) + + self.main = main_ui + self.main_id = None + + # prefer the serial port managed by the shared comms object if present + self._serial = self.comms.serial + + # OpenFFBoard Tab + self.pushButton_send.clicked.connect(self.send_line) + self.lineEdit_cmd.returnPressed.connect(self.send_line) + self.pushButton_mainclasschange.clicked.connect(self.main_btn) + + self.pushButton_reboot.clicked.connect(self.reboot) + self.pushButton_save.clicked.connect(self.save_flashdump_to_file) + self.pushButton_load.clicked.connect(self.load_flashdump_from_file) + self.pushButton_resetFactory.clicked.connect(self.reset_factory_btn) + + # Update Tab - DFU + self.selected_file = None + self.dfu_device = None + self.first_fail = True + self.elements = None + self.uploading = False + + self.pushButton_DFU.clicked.connect(self.dfu) + self.pushButton_filechooser.clicked.connect(self.file_clicked) + self.pushButton_fullerase.clicked.connect(self.full_erase_clicked) + self.pushButton_upload.clicked.connect(self.upload_clicked) + + self.device_found = False + self.timer = PyQt6.QtCore.QTimer(self) + self.timer.timeout.connect(self.init_dfu_ui) + + if self.main.connected: + self.getInfoSerial() + + self.checkBox_massErase.setEnabled(False) # TODO disable checkbox for now + + self.devinfo = {"devid":0,"revid":0,"uid":0,"signature":0,"CUR_HW_TYPE":None} + + # Update Tab - Update Browser + self.listWidget_release.currentItemChanged.connect(self.release_changed) + self.listWidget_files.currentItemChanged.connect(self.file_changed) + self.buttonGroup_repo.buttonClicked.connect(self.repo_changed) + self.read_releases(updater.MAINREPO) + + if self.main.profile_ui: + self.checkBox_notify.setChecked(not self.main.profile_ui.get_global_setting("donotnotify_updates",False)) + self.checkBox_notify.toggled.connect(self.notify_checkbox_toggled) + else: + self.checkBox_notify.setVisible(False) + + # Update UI according to current connection state + self.update_connected() + + # Connect the debug checkbox from UI + self.checkBox_debug.stateChanged.connect(self.toggle_debug_mode) + + def reboot(self): + """Send the reboot message to the board.""" + self.send_command("sys", "reboot") + self.main.reconnect() + + def save_flashdump_to_file(self): + """Send a async message to get the flashdump from board.""" + self.get_value_async("sys", "flashdump", config.saveDump) + + def load_flashdump_from_file(self): + """Load dumpfile and send config to board.""" + dump = config.loadDump() + if not dump: + return + + if self.main.connected: + for sector in dump["flash"]: + self.send_value("sys", "flashraw", sector["val"], sector["addr"], 0) + # Message + msg = PyQt6.QtWidgets.QMessageBox( + PyQt6.QtWidgets.QMessageBox.Icon.Information, + self.tr("Restore flash dump"), + self.tr("Uploaded flash dump.\nPlease reboot."), + ) + else: + # Message + msg = PyQt6.QtWidgets.QMessageBox( + PyQt6.QtWidgets.QMessageBox.Icon.Warning, + self.tr("Can't restore flash dump"), + self.tr("Please connect board first."), + ) + + + msg.exec() + + def reset_factory(self, btn): + """Send a async message to reset factory settings.""" + cmd = btn.text() + if cmd == "OK": + self.send_value("sys", "format", 1) + self.send_command("sys", "reboot") + self.main.reset_port() + + def reset_factory_btn(self): + """Prompt a confirmation to the user when he click on reset factory.""" + msg = PyQt6.QtWidgets.QMessageBox() + msg.setIcon(PyQt6.QtWidgets.QMessageBox.Icon.Warning) + msg.setText(self.tr("Format flash and reset?")) + msg.setStandardButtons( + PyQt6.QtWidgets.QMessageBox.StandardButton.Ok + | PyQt6.QtWidgets.QMessageBox.StandardButton.Cancel + ) + msg.buttonClicked.connect(self.reset_factory) # pylint: disable=no-value-for-parameter + msg.exec() + + def showEvent(self, event): # pylint: disable=unused-argument, invalid-name + """On show event, init the param. + + Connect the communication module with the history widget to load the board response. + """ + self.get_raw_reply().connect(self.serial_log) + self.timer.start(1000) + return super().showEvent(event) + + + # Tab is hidden + def hideEvent(self, event): # pylint: disable=unused-argument, invalid-name + """On hide event, disconnect the event. + + Disconnect the communication module with the history widget + to stop to log the board response. + """ + # disconnect the serial logging + try: + self.get_raw_reply().disconnect(self.serial_log) + except TypeError: + pass + + # stop DFU timer + if (self.timer.isActive()): + self.timer.stop() + if self.dfu_device and not self.uploading: + pydfu.exit_dfu() + + def serial_log(self, txt): + """Add a new text in the history widget.""" + if isinstance(txt, list): + txt = "\n".join(txt) + else: + txt = str(txt) + self.serialLogBox.append(txt) + + def send_line(self): + """Read the command input text, display it in history widget and send it to the board.""" + cmd = self.lineEdit_cmd.text() + "\n" + self.serial_log(">" + cmd) + self.serial_write_raw(cmd) + + def write(self, data): + """Write data to the serial port.""" + self._serial.write(data) + + def update_connected(self, state=None): + """Update the UI when a connection is successfull. + + Emit for all the UI the [connected] event. + This is also called with state=False when disconnected. + Event come from main_ui when connection state change. + """ + if state is None: + state = self.main.connected + + # Hardware Tab + self.groupBox_system.setEnabled(state) + self.groupBox_terminal.setEnabled(state) + + # Update Tab + #self.groupBox_dfu.setEnabled(state) + self.pushButton_DFU.setEnabled(state) + + if state: + self.get_main_classes() + else: + try: + self.get_raw_reply().disconnect(self.serial_log) + except Exception: + pass + if self.timer.isActive(): + self.timer.stop() + + def update_mains(self, dat): + """Parse the list of main classes received from board, and update the combobox.""" + self.comboBox_main.clear() + self._class_ids, self._classes = helper.classlistToIds(dat) + + if self.main_id is None: + self.groupBox_system.setEnabled(False) + return + + helper.updateClassComboBox( + self.comboBox_main, self._class_ids, self._classes, self.main_id + ) + + self.main.log("Detected mode: " + self.comboBox_main.currentText()) + self.main.update_tabs() + + def get_main_classes(self): + """Get the main classes available from the board in Async.""" + + def fct(i): + """Store the main currently selected to refresh the UI.""" + self.main_id = i + + self.get_value_async("main", "id", fct, conversion=int, delete=True) + self.get_value_async("sys", "lsmain", self.update_mains, delete=True) + + def main_btn(self): + """Read the select main class in the combobox. + + Push it to the board and display the reload warning. + """ + index = self._classes[self.comboBox_main.currentIndex()][0] + self.send_value("sys", "main", index) + self.main.reconnect() + msg = PyQt6.QtWidgets.QMessageBox( + PyQt6.QtWidgets.QMessageBox.Icon.Information, + "Main class changed", + "Chip is rebooting. Please reconnect.", + ) + msg.exec() + + # DFU Methods + def getInfoDfu(self): + #self.devinfo["devid"] = pydfu.read_memory(self.STM_DEVID_ADR_F4,32) + #print(self.devinfo) + pass + + def getInfoSerial(self): + pass + + def init_dfu_ui(self): + """Set the component status and display log message.""" + dfu_devices = pydfu.get_dfu_devices(idVendor=0x0483, idProduct=0xDF11) + if not dfu_devices: + # No devices found + self.groupbox_controls_update.setEnabled(False) + if self.first_fail: + self.log_dfu(self.tr("Searching devices...\n")) + self.log_dfu(self.tr( + "Make sure the bootloader is detected and drivers installed. Short boot0 to " + "force the bootloader when connecting.\n" + )) + self.log_dfu(self.tr("No DFU device found.\nRetrying..")) + self.first_fail = False + else: + self.log_dfu(".") + # Enable the DFU button if the serial is connected + if self.main.connected: + self.pushButton_DFU.setEnabled(True) + else: + self.pushButton_DFU.setEnabled(False) + elif len(dfu_devices) > 1: + self.log_dfu(self.tr("Found multiple DFU devices:" + str(dfu_devices) + "\n")) + self.log_dfu(self.tr("Please disconnect other DFU devices to avoid mistakes\n")) + + else: + self.timer.stop() + try: + pydfu.init() + except ValueError as e: + self.log_dfu(self.tr("\nFound DFU device but could not connect: " + str(e.args[1]) + "\n")) + self.timer.start() + return + self.log_dfu(self.tr("\nFound DFU device. Please select an option\n")) + self.dfu_device = dfu_devices[0] + self.getInfoDfu() + self.groupbox_controls_update.setEnabled(True) + self.pushButton_filechooser.setEnabled(True) + self.pushButton_fullerase.setEnabled(True) + self.pushButton_DFU.setEnabled(False) + + def dfu(self): + """Send the dfu command to the board, log message, and close serial.""" + # + self.send_command("sys", "dfu") + self.log_dfu("\nEntering DFU...\n") + self.main.reset_port() + + def file_clicked(self): + """Open the dialog box to select the file to Upload.""" + dlg = PyQt6.QtWidgets.QFileDialog() + dlg.setFileMode(PyQt6.QtWidgets.QFileDialog.FileMode.ExistingFile) + dlg.setNameFilters( + [ + "Firmware files (*.hex *.dfu)", + "DFU files (*.dfu)", + "Intel hex files (*.hex)", + ] + ) + if dlg.exec(): + filenames = dlg.selectedFiles() + self.select_file(filenames[0]) + self.pushButton_upload.setEnabled(True) + else: + self.pushButton_upload.setEnabled(False) + + def select_file(self, filename): + """Use the appropriate dfu reader depends on file extension : dfu, hex.""" + self.selected_file = filename + self.label_filename.setText(self.selected_file) + if self.selected_file.endswith("dfu"): + elements = pydfu.read_dfu_file(self.selected_file) + elif self.selected_file.endswith("hex"): + elements,metadata = pydfu.read_hex_file(self.selected_file,"#") + if(metadata): + self.check_metadata(metadata) + else: + self.log_dfu("Not a known firmware file\n") + return + + if not elements: + self.log_dfu("Error parsing file\n") + return + size = sum([e["size"] for e in elements]) + self.log_dfu(F"Loaded {len(elements)} segments with {round(size/1024,2)}kB\n") + self.elements = elements + + def upload_clicked(self): + """Start the upload after the button click event.""" + self.uploading = True + elements = self.elements + mass_erase = self.checkBox_massErase.isChecked() + self.groupbox_controls_update.setEnabled(False) + if mass_erase: + self.full_erase() + + self.log_dfu( + F"Uploading {len(elements)} segments... Do NOT " + "close this window or disconnect until done!\n" + ) + try: + pydfu.write_elements(elements, mass_erase, progress=self.progress,logfunc=self.log_dfu) + self.log_dfu("Uploaded!\n") + except pydfu.DFUException as exception: + self.log_dfu(str(exception)) + self.log_dfu("\nUSB Exception during flashing... Please reflash firmware!\n") + self.uploading = False + pydfu.exit_dfu() + self.log_dfu("Done. Please reset\n") + self.groupbox_controls_update.setEnabled(True) + self.dfu_device = None + + def full_erase_clicked(self): + """Ask an confirmation on erase click button event.""" + msg = PyQt6.QtWidgets.QMessageBox() + msg.setIcon(PyQt6.QtWidgets.QMessageBox.Icon.Warning) + msg.setWindowTitle("Full chip erase") + msg.setText("Fully erase the chip?") + msg.setInformativeText( + "This erases EVERYTHING.\nFirmware and settings.\nYou may need a programmer " + "or short the boot0 pins to reflash it!" + ) + msg.setStandardButtons( + PyQt6.QtWidgets.QMessageBox.StandardButton.Ok + | PyQt6.QtWidgets.QMessageBox.StandardButton.Cancel + ) + ret = msg.exec() + # Warning displayed. Erase! + if ret == PyQt6.QtWidgets.QMessageBox.StandardButton.Ok: + self.full_erase() + + def full_erase(self): + """Erase the chip using pydfu.""" + if self.dfu_device: + self.log_dfu("Full chip erase started...\n") + try: + self.progress(0, 25, 100) + pydfu.mass_erase() + self.progress(0, 100, 100) + self.log_dfu("Chip erased\n") + except pydfu.DFUException as exception: + self.progress(0, 100, 100) + self.log_dfu(str(exception)) + self.log_dfu("\nUSB Exception during erasing... Please reflash firmware!\n") + + def log_dfu(self, txt): + """Append a message in the displayed log.""" + self.textBrowser_dfu.moveCursor(PyQt6.QtGui.QTextCursor.MoveOperation.End) + self.textBrowser_dfu.insertPlainText(txt) + self.textBrowser_dfu.moveCursor(PyQt6.QtGui.QTextCursor.MoveOperation.End) + self.update() + PyQt6.QtWidgets.QApplication.processEvents() + + def progress(self, addr, offset, size): + """Update the UI progress bar with the current value.""" + if addr: + pass # ignore the addr parameter for the moment + self.progressBar.setValue(int(offset * 100 / size)) + self.update() + PyQt6.QtWidgets.QApplication.processEvents() + + def check_metadata(self,metadata): + # Split all comment lines into a dict of definename:value or None + metadatadict = {line[0]:line[1] if len(line)>1 else None for line in [l.split(" ",1) for l in metadata]} + # print(metadatadict) + warnmsg = "" + if "HW_TYPE" in metadatadict and self.devinfo["CUR_HW_TYPE"]: # hw name definition of new firmware should match current one + if self.devinfo["CUR_HW_TYPE"] != metadatadict["HW_TYPE"]: + # Warn hwtype + warnmsg += self.tr(f"Warning: Current firmware type {self.devinfo['CUR_HW_TYPE']} does not match new firmware type {metadatadict['HW_TYPE']}\n") + if warnmsg: + self.log_dfu(warnmsg + "\n") + msg = PyQt6.QtWidgets.QMessageBox() + msg.setIcon(PyQt6.QtWidgets.QMessageBox.Icon.Warning) + msg.setWindowTitle("WARNING") + msg.setText(self.tr("Firmware mismatch detected!")) + msg.setInformativeText(warnmsg) + ret = msg.exec() + + # Update Browser Methods + def notify_checkbox_toggled(self,status): + self.main.profile_ui.set_global_setting("donotnotify_updates",not status) + + def fill_releases(self,releases : dict): + self.listWidget_release.clear() + for r in releases: + if r.get("name",None): + name = f"{r.get('tag_name','No tag')} ({r.get('name')})" + else: + name = f"{r.get('tag_name','No tag')}" + item = PyQt6.QtWidgets.QListWidgetItem(name) + if r.get("prerelease",False): + item.setBackground(QColor('#ffb810')) + else: + item.setBackground(QColor('#70ea5d')) + item.setData(Qt.ItemDataRole.UserRole, r) + self.listWidget_release.addItem(item) + + self.listWidget_release.setCurrentRow(0) + + + def get_selected_release(self): + r = self.listWidget_release.currentItem().data(Qt.ItemDataRole.UserRole) + return r + + def repo_changed(self,button): + if button == self.radioButton_configurator: + self.read_releases(updater.GUIREPO) + else: + self.read_releases(updater.MAINREPO) + + def read_releases(self,repo : str): + releases = updater.GithubRelease.get_releases(repo,True) + if releases: + self.fill_releases(releases) + + + def fill_files(self,release : dict): + self.listWidget_files.clear() + for r in release.get("assets",[]): + name = r['name'] + url = r['browser_download_url'] + item = PyQt6.QtWidgets.QListWidgetItem(name) + item.setData(Qt.ItemDataRole.UserRole, url) + self.listWidget_files.addItem(item) + self.listWidget_files.setCurrentRow(0) + + + def release_changed(self,current : PyQt6.QtWidgets.QListWidgetItem,old : PyQt6.QtWidgets.QListWidgetItem): + if not current: + return + release = current.data(Qt.ItemDataRole.UserRole) + self.textBrowser_releasenotes.setMarkdown(updater.GithubRelease.get_description(release)) + self.label_releaseUrl.setText(f"{release['html_url']}") + self.fill_files(release) + publishtime = updater.GithubRelease.get_time(release) + infotext = f"Date: {publishtime}" + self.label_releaseInfo.setText(infotext) + + def file_changed(self,current : PyQt6.QtWidgets.QListWidgetItem,old : PyQt6.QtWidgets.QListWidgetItem): + if not current: + return + url = current.data(Qt.ItemDataRole.UserRole) + self.label_fileUrl.setText(f"Download") + + def init_language_selector(self): + """Initialize the language selector in the settings tab.""" + # Get available languages + import glob + import os + languages = ["en_US"] # Default language + languages.extend([os.path.splitext(os.path.basename(f))[0] for f in glob.glob(helper.res_path("*.qm","translations"))]) + + # Clear existing items + self.comboBox_language.clear() + + # Add languages to combo box + for langid in languages: + self.comboBox_language.addItem(langid) + + # Set current language + current_lang = self.main.profile_ui.get_global_setting("language", "en_US") + current_index = self.comboBox_language.findText(current_lang) + if current_index >= 0: + self.comboBox_language.setCurrentIndex(current_index) + + # Connect signal + self.comboBox_language.currentTextChanged.connect(self.change_language) + + def change_language(self, language_id): + """Change the application language.""" + # Check if language is different from current + if language_id == self.main.profile_ui.get_global_setting("language", "en_US"): + return + + # Remove current translator + from PyQt6.QtWidgets import QApplication + app = QApplication.instance() + if app and hasattr(self.main, 'translator'): + app.removeTranslator(self.main.translator) + + # Load new language + if language_id != "en_US": + langfile = helper.res_path(f"{language_id}.qm", "translations") + if hasattr(self.main, 'translator') and self.main.translator.load(langfile): + if app: + app.installTranslator(self.main.translator) + + # Store language setting + self.main.profile_ui.set_global_setting("language", language_id) + + # Emit signal to restart application (as in original code) + self.main.languagechanged.emit() + + def toggle_debug_mode(self, state): + """Toggle debug mode on the board.""" + # Send debug mode command to board + self.send_value("sys", "debug", 1 if state else 0) + # Reload main classes to apply debug mode + self.get_main_classes() diff --git a/tmc4671_ui.py b/tmc4671_ui.py index 2717df0e..50e51ed3 100644 --- a/tmc4671_ui.py +++ b/tmc4671_ui.py @@ -1,5 +1,5 @@ -from PyQt6.QtWidgets import QMessageBox,QVBoxLayout,QGroupBox,QComboBox,QLabel,QApplication -from helper import res_path,classlistToIds,updateListComboBox +from PyQt6.QtWidgets import QMessageBox,QVBoxLayout,QGroupBox,QComboBox,QLabel,QApplication,QDialog,QTextEdit,QPushButton +from helper import res_path,classlistToIds,updateListComboBox,qtBlockAndCall from PyQt6.QtCore import QTime, QTimer from PyQt6.QtCore import Qt,QMargins from PyQt6.QtGui import QColor @@ -7,9 +7,10 @@ from base_ui import WidgetUI from optionsdialog import OptionsDialog,OptionsDialogGroupBox -from PyQt6.QtCharts import QChart,QChartView,QLineSeries,QValueAxis +from PyQt6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis, QBarSeries, QBarSet from base_ui import CommunicationHandler + ext_notice = """External encoder forwards the encoder selection of the Axis (if available). Please select the encoder there.""" @@ -22,7 +23,7 @@ class TMC4671Ui(WidgetUI,CommunicationHandler): - STATES = ["uninitialized","waitPower","Shutdown","Running","EncoderInit","EncoderFinished","HardError","OverTemp","IndexSearch","FullCalibration","ExternalEncoderInit","PI Autotune"] + STATES = ["uninitialized","waitPower","Shutdown","Running","EncoderInit","EncoderFinished","HardError","OverTemp","IndexSearch","FullCalibration","ExternalEncoderInit","PI Autotune", "CoggingCalibration", "SlewRateCalibration", "NONE"] def __init__(self, main=None, unique=0): WidgetUI.__init__(self, main,'tmc4671_ui.ui') @@ -33,6 +34,12 @@ def __init__(self, main=None, unique=0): self.axis = unique + self.anti_coggingEnable = False + self.cogging_data = [0] * 128 + self.cogging_data_received = [False] * 128 + self.cogging_supported = False + self.cogging_dialog = None + self.cogging_text = "" self.max_datapoints = 10000 self.max_datapointsVisibleTime = 30 self.adc_to_amps = 0#2.5 / (0x7fff * 60.0 * 0.0015) @@ -51,6 +58,14 @@ def __init__(self, main=None, unique=0): self.pushButton_align.clicked.connect(self.alignEnc) self.pushButton_autotunepid.clicked.connect(self.autotunePid) + self.pushButton_cogging.clicked.connect(self.coggingDetection) + self.pushButton_resetCoggingTable.clicked.connect(self.resetCoggingTable) + self.pushButton_reloadCoggingTable.clicked.connect(self.reloadCoggingTable) + self.tabWidget.currentChanged.connect(self.tabChanged) + self.doubleSpinBox_coggScale.setMinimum(-3.2767) + self.doubleSpinBox_coggScale.setMaximum(3.2767) + self.doubleSpinBox_coggScale.setSingleStep(0.001) + self.doubleSpinBox_coggScale.setDecimals(4) #self.initUi() self.timer.timeout.connect(self.updateTimer) @@ -59,6 +74,9 @@ def __init__(self, main=None, unique=0): # Chart setup self.chart = QChart() + is_light = QApplication.instance().property("is_light_theme") + chart_theme = QChart.ChartTheme.ChartThemeLight if is_light else QChart.ChartTheme.ChartThemeDark + self.chart.setTheme(chart_theme) self.chart.setBackgroundRoundness(5) self.chart.setMargins(QMargins(0,0,0,0)) self.chartXaxis = QValueAxis(self.chart) @@ -73,7 +91,6 @@ def __init__(self, main=None, unique=0): # use Application.instance().palette().dark().color() but with 25% opacity self.chartYaxis_Amps.setGridLineColor(QColor(QApplication.instance().palette().dark().color().red(),QApplication.instance().palette().dark().color().green(),QApplication.instance().palette().dark().color().blue(),64)) self.chartYaxis_Temps.setGridLineColor(QColor(QApplication.instance().palette().dark().color().red(),QApplication.instance().palette().dark().color().green(),QApplication.instance().palette().dark().color().blue(),64)) - self.chart.setBackgroundBrush(QApplication.instance().palette().window()) self.chart.addAxis(self.chartYaxis_Amps,Qt.AlignmentFlag.AlignLeft) @@ -97,6 +114,15 @@ def __init__(self, main=None, unique=0): self.lines_Flux.attachAxis(self.chartYaxis_Amps) self.lines_Flux.attachAxis(self.chartXaxis) + self.lines_Cogging = QLineSeries(self.chart) + self.lines_Cogging.setName("Cogging A") + self.lines_Cogging.setOpacity(0.5) + self.lines_Cogging.setUseOpenGL(True) + self.chart.addSeries(self.lines_Cogging) + self.lines_Cogging.setColor(QColor("purple")) + self.lines_Cogging.attachAxis(self.chartYaxis_Amps) + self.lines_Cogging.attachAxis(self.chartXaxis) + self.lines_Temps = QLineSeries(self.chart) self.lines_Temps.setName("Temp °C") self.lines_Temps.setColor(QColor("orange")) @@ -119,6 +145,42 @@ def __init__(self, main=None, unique=0): ax.setLabelsBrush(QApplication.instance().palette().text()) + # Cogging Chart setup + self.chart_cogging = QChart() + is_light = QApplication.instance().property("is_light_theme") + chart_theme = QChart.ChartTheme.ChartThemeLight if is_light else QChart.ChartTheme.ChartThemeDark + self.chart_cogging.setTheme(chart_theme) + self.chart_cogging.setBackgroundRoundness(5) + self.chart_cogging.setMargins(QMargins(0,0,0,0)) + self.chart_cogging_Xaxis = QValueAxis(self.chart_cogging) + self.chart_cogging_Xaxis.setGridLineColor(QColor(QApplication.instance().palette().dark().color().red(),QApplication.instance().palette().dark().color().green(),QApplication.instance().palette().dark().color().blue(),128)) + self.chart_cogging.addAxis(self.chart_cogging_Xaxis,Qt.AlignmentFlag.AlignBottom) + + self.chart_cogging_Yaxis = QValueAxis(self.chart_cogging) + self.chart_cogging_Yaxis.setGridLineColor(QColor(QApplication.instance().palette().dark().color().red(),QApplication.instance().palette().dark().color().green(),QApplication.instance().palette().dark().color().blue(),64)) + self.chart_cogging.setBackgroundBrush(QApplication.instance().palette().window()) + + self.chart_cogging.addAxis(self.chart_cogging_Yaxis,Qt.AlignmentFlag.AlignLeft) + + self.bar_set_cogging = QBarSet("Harmonics") + self.bar_series_cogging = QBarSeries() + self.bar_series_cogging.append(self.bar_set_cogging) + + self.chart_cogging.addSeries(self.bar_series_cogging) + self.bar_set_cogging.setColor(QColor("cornflowerblue")) + self.bar_series_cogging.attachAxis(self.chart_cogging_Yaxis) + self.bar_series_cogging.attachAxis(self.chart_cogging_Xaxis) + + self.chart_cogging_Xaxis.setRange(1, 128) + self.chart_cogging_Yaxis.setMin(0) + self.chart_cogging_Yaxis.setMax(10) + self.graphWidget_Cogging.setRubberBand(QChartView.RubberBand.VerticalRubberBand) + self.graphWidget_Cogging.setChart(self.chart_cogging) + + self.chart_cogging.legend().setLabelBrush(QApplication.instance().palette().text()) + for ax in self.chart_cogging.axes(): + ax.setLabelsBrush(QApplication.instance().palette().text()) + self.checkBox_advancedpid.stateChanged.connect(self.advancedPidChanged) self.lastPrecP = self.checkBox_P_Precision.isChecked() self.lastPrecI = self.checkBox_I_Precision.isChecked() @@ -185,6 +247,11 @@ def __init__(self, main=None, unique=0): self.register_callback("tmc","calibrated",self.calibrated,instance=self.axis,conversion=int) + self.register_callback("tmc","coggingTable",self.updateCogging,self.axis,str) + self.register_callback("tmc","calibrateCogging",self.coggingDetectionMsg,self.axis,str) + self.register_callback("tmc","cogging",self.anticoggingStatus,self.axis,int,typechar='?') + self.register_callback("tmc","coggingScale",self.coggingScaleCb,self.axis,int) + self.checkBox_combineEncoders.stateChanged.connect(self.extEncoderChanged) @@ -207,18 +274,41 @@ def hideEvent(self,event): self.timer.stop() self.timer_status.stop() - def motorselChanged(self,val): + def coggingSupportedCb(self, info): + self.cogging_supported = (info != -1) + self.updateMotorUI() + + def anticoggingStatus(self, data): + self.anti_coggingEnable = (data == 1) + self.updateMotorUI() + + def motorselChanged(self, val): + self.updateMotorUI() + + def updateMotorUI(self): data = self.comboBox_mtype.currentData() - if(data == 2 or data == 3): # stepper or bldc - self.spinBox_poles.setEnabled(True) - self.doubleSpinBox_fluxoffset.setEnabled(True) - self.checkBox_fluxdissipate.setEnabled(True) - self.pushButton_autotunepid.setEnabled(True) - else: - self.spinBox_poles.setEnabled(False) - self.doubleSpinBox_fluxoffset.setEnabled(False) - self.checkBox_fluxdissipate.setEnabled(False) - self.pushButton_autotunepid.setEnabled(False) + supported_motor = (data == 2 or data == 3) # stepper or bldc + + self.spinBox_poles.setEnabled(supported_motor) + self.doubleSpinBox_fluxoffset.setEnabled(supported_motor) + self.checkBox_fluxdissipate.setEnabled(supported_motor) + self.pushButton_autotunepid.setEnabled(supported_motor) + + # Cogging visibility depends on motor support AND firmware command existence + cogging_enabled = supported_motor and self.cogging_supported + self.pushButton_cogging.setEnabled(cogging_enabled) + self.checkBox_cogging.setEnabled(cogging_enabled) + self.groupBox_anticogging.setEnabled(cogging_enabled) + self.tabWidget.setTabEnabled(1, cogging_enabled) + + + # If anti-cogging was enabled, notify user and disable it before graying out + self.checkBox_cogging.setChecked(self.anti_coggingEnable) + if self.anti_coggingEnable: + if not supported_motor: + #msg = QMessageBox(QMessageBox.Icon.Information,self.tr("Anti-Cogging"),self.tr("Auto-disabling Anti-Cogging on this motor")) + #msg.exec() + self.checkBox_cogging.setChecked(False) if(data == 3): self.checkBox_svpwm.setEnabled(True) @@ -269,24 +359,38 @@ def updateCurrent(self,torqueflux): tflist = [(int(v)) for v in torqueflux.split(":")] flux = None + cogging = None torque = abs(tflist[0]) - if(len(tflist) == 2): + if len(tflist) >= 2: flux = tflist[1] - currents = complex(torque,flux) + if len(tflist) >= 3: + cogging = tflist[2] + + currents = complex(torque, flux if flux is not None else 0) try: torque = abs(float(torque)) if self.adc_to_amps != 0: amps = currents * self.adc_to_amps txt = f"Torque: {amps.real:+.3f}A" - if(flux != None): + + total_amps = abs(amps.real) + if flux is not None: txt += f"\nFlux: {amps.imag:+.3f}A" - txt += f"\nTotal: {abs(amps):.3f}A" + total_amps += abs(amps.imag) + if cogging is not None: + c_amps = cogging * self.adc_to_amps + txt += f"\nCogging: {c_amps:+.3f}A" + total_amps += abs(c_amps) + if flux is not None or cogging is not None: + txt += f"\nTotal: {total_amps:.3f}A" + self.label_Current.setText(txt) else: amps = 100*currents / 0x7fff # percent - self.label_Current.setText(str(round(amps.real,3))+"%") + txt = str(round(amps.real,3))+"%" + self.label_Current.setText(txt) self.progressBar_power.setValue(int(abs(currents))) @@ -294,18 +398,65 @@ def updateCurrent(self,torqueflux): self.lines_Amps.append(self.chartLastX,amps.real) self.lines_Flux.append(self.chartLastX,abs(amps.imag)) + cogging_val = 0 + if cogging is not None: + if self.adc_to_amps != 0: + cogging_val = cogging * self.adc_to_amps + else: + cogging_val = 100 * cogging / 0x7fff + self.lines_Cogging.append(self.chartLastX, cogging_val) + if(self.lines_Amps.count() > self.max_datapoints): self.lines_Amps.remove(0) self.lines_Flux.remove(0) - scalemax = max(abs(amps.imag),abs(amps.real)) + + if self.lines_Cogging.count() > self.max_datapoints: + self.lines_Cogging.remove(0) + + scalemax = max(abs(amps.imag), abs(amps.real), abs(cogging_val)) if(scalemax > self.chartYaxis_Amps.max()): self.chartYaxis_Amps.setMax(round(scalemax,2)) # increase range + + if cogging_val < self.chartYaxis_Amps.min(): + self.chartYaxis_Amps.setMin(round(cogging_val, 2)) # increase range downwards self.chartXaxis.setMax(self.chartLastX) self.chartXaxis.setMin(max(self.lines_Amps.at(0).x(),max(0,self.chartLastX-self.max_datapointsVisibleTime))) except Exception as e: - self.main.log("TMC update error: " + str(e)) + self.main.log("TMC update error: " + str(e)) + + def updateCogging(self,data): + try: + if "data" in data: + # Correctly parse the "item:X,data:(Y,Z,...)" format + item_str, data_str = data.split(',', 1) + start_index = int(item_str.split(':')[1]) + + # Extract the numbers from within the parentheses + values_str = data_str.split('(')[1].split(')')[0] + points = [float(p) for p in values_str.split(',') if p] + + for i, p in enumerate(points): + if start_index + i < len(self.cogging_data): + self.cogging_data[start_index + i] = p + self.cogging_data_received[start_index + i] = True + + # Redraw the entire graph with the updated data + self.bar_set_cogging.remove(0, self.bar_set_cogging.count()) + # Add a dummy zero at index 0 so that harmonics 1-128 align with X axis values 1-128 + self.bar_set_cogging.append(0.0) + self.bar_set_cogging.append(self.cogging_data) + + self.chart_cogging_Xaxis.setRange(1, 128) + + valid_data = [p for i, p in enumerate(self.cogging_data) if self.cogging_data_received[i]] + if valid_data: + self.chart_cogging_Yaxis.setMax(max(10, max(valid_data))) + self.chart_cogging_Yaxis.setMin(0) + + except Exception as e: + self.main.log("TMC cogging update error: " + str(e)) def updateTemp(self,t): t = t/100.0 @@ -367,6 +518,8 @@ def submitMotor(self): self.send_value("tmc","combineEncoder",val = 1 if self.checkBox_combineEncoders.isChecked() else 0,instance=self.axis) self.send_value("tmc","invertForce",val = 1 if self.checkBox_invertForce.isChecked() else 0,instance=self.axis) + self.send_value("tmc","cogging",val = 1 if self.checkBox_cogging.isChecked() else 0,instance=self.axis) + self.send_value("tmc","coggingScale",val=self.horizontalSlider_coggmag.value(),instance=self.axis) def submitPid(self): # PIDs @@ -446,13 +599,19 @@ def init_ui(self): self.lines_Amps.clear() self.lines_Temps.clear() self.lines_Flux.clear() + self.lines_Cogging.clear() + self.clearCoggingGraph() self.chartYaxis_Amps.setMin(0) self.chartYaxis_Temps.setMin(0) self.chartYaxis_Temps.setMax(90) try: # Fill encoder source types self.send_commands("tmc",["mtype","encsrc","tmcHwType","trqbq_mode"],self.axis,'!') - self.send_commands("tmc",["tmctype","tmcHwType","iScale","trqbq_f"],self.axis) + self.send_commands("tmc",["tmctype","tmcHwType","iScale","calibrated","trqbq_f","coggingScale"],self.axis) + self.send_command("tmc","cogging",self.axis,'?') + + # Check if cogging is supported + self.get_value_async("tmc", "cmdinfo", self.coggingSupportedCb, self.axis, conversion=int, adr=44) self.getMotor() self.getPids() if not self.init_done: @@ -461,9 +620,13 @@ def init_ui(self): self.pushButton_submitpid.clicked.connect(self.submitPid) self.comboBox_torqueFilter.currentIndexChanged.connect(self.torqueFilterChanged) self.spinBox_torqueFilterFreq.valueChanged.connect(lambda x : self.send_value("tmc","trqbq_f",x,instance=self.axis)) + self.horizontalSlider_coggmag.valueChanged.connect(self.coggingScaleChanged) + self.doubleSpinBox_coggScale.valueChanged.connect(self.coggingSpinBoxChanged) self.init_done = True # Check if calibrated + if self.tabWidget.currentWidget() == self.tab_6: + self.reloadCoggingTable() except Exception as e: self.main.log("Error initializing TMC tab. Please reconnect: " + str(e)) return False @@ -485,7 +648,6 @@ def calibrated(self,v): if not v and self.isEnabled() and self.comboBox_mtype.currentIndex() != 0 and self.comboBox_enc.currentIndex() != 0: # Warning displayed def cb(ret): - print(ret) if ret == QMessageBox.StandardButton.Ok: self.send_command("tmc","calibrate",self.axis) self.calmsg.finished.connect(cb) @@ -525,6 +687,102 @@ def f(res): self.get_value_async("tmc","encalign",f,self.axis,typechar='?') self.main.log("Started encoder alignment") + def coggingDetectionMsg(self, data): + if data: + msg_text = str(data) + status = 0 + # Parsing du nouveau format: ("message de log",0) + if msg_text.startswith('("') and msg_text.endswith(')'): + parts = msg_text.rsplit('",', 1) + if len(parts) == 2: + text_part = parts[0][2:] # Remove '("' + try: + status = int(parts[1][:-1]) # Remove ')' + msg_text = text_part + except ValueError: + pass + + # Append new message to the accumulated text + if self.cogging_text: + self.cogging_text += "\n" + self.cogging_text += msg_text + + if self.cogging_dialog is None: + # Create and show a resizable QDialog with a QTextEdit + self.cogging_dialog = QDialog(self) + self.cogging_dialog.setWindowTitle(self.tr("Cogging calibration")) + self.cogging_dialog.setMinimumSize(500, 400) + + layout = QVBoxLayout(self.cogging_dialog) + + self.cogging_text_edit = QTextEdit() + self.cogging_text_edit.setReadOnly(True) + layout.addWidget(self.cogging_text_edit) + + close_btn = QPushButton(self.tr("Close")) + close_btn.clicked.connect(self.cogging_dialog.close) + layout.addWidget(close_btn) + + self.cogging_dialog.show() + # Reset state when closed + def on_finish(): + self.cogging_dialog = None + self.cogging_text = "" + self.cogging_text_edit = None + self.timer.start(50) + self.timer_status.start(250) + self.cogging_dialog.finished.connect(on_finish) + + # Update text and scroll to bottom + self.cogging_text_edit.setText(self.cogging_text) + self.cogging_text_edit.verticalScrollBar().setValue(self.cogging_text_edit.verticalScrollBar().maximum()) + + # Automatically handle end of calibration + if status == 1: + self.pushButton_cogging.setEnabled(True) + self.timer.start(50) + self.timer_status.start(250) + self.reloadCoggingTable() + self.send_command("tmc", "cogging", self.axis, '?') + + def coggingDetection(self): + self.pushButton_cogging.setEnabled(False) + self.timer.stop() + self.timer_status.stop() + self.send_command("tmc","calibrateCogging", self.axis) + self.main.log("Started cogging detection") + + def tabChanged(self, index): + # Automatically reload the cogging table when its tab is selected + if self.tabWidget.widget(index) == self.tab_6: + self.reloadCoggingTable() + + def clearCoggingGraph(self): + self.bar_set_cogging.remove(0, self.bar_set_cogging.count()) + self.cogging_data = [0] * 128 + self.cogging_data_received = [False] * 128 + # Reset axes to default values + self.chart_cogging_Yaxis.setMin(0) + self.chart_cogging_Yaxis.setMax(10) + + def resetCoggingTable(self): + self.send_value("tmc", "coggingTable", 0, instance=self.axis) + self.clearCoggingGraph() + + def reloadCoggingTable(self): + self.clearCoggingGraph() + self.send_command("tmc", "coggingTable", self.axis, '?') + + def coggingScaleChanged(self, val): + qtBlockAndCall(self.doubleSpinBox_coggScale, self.doubleSpinBox_coggScale.setValue, val / 10000.0) + + def coggingSpinBoxChanged(self, val): + slider_val = int(round(val * 10000.0)) + qtBlockAndCall(self.horizontalSlider_coggmag, self.horizontalSlider_coggmag.setValue, slider_val) + + def coggingScaleCb(self, val): + qtBlockAndCall(self.horizontalSlider_coggmag, self.horizontalSlider_coggmag.setValue, val) + qtBlockAndCall(self.doubleSpinBox_coggScale, self.doubleSpinBox_coggScale.setValue, val / 10000.0) def getMotor(self): commands=["mtype","poles","encsrc","cpr","abnindex","abnpol","combineEncoder","invertForce","fluxbrake","calibrated"] @@ -584,3 +842,4 @@ def typeCb(self,entries): def readValues(self): self.get_value_async("tmc","tmcHwType",self.typeCb,self.axis,str,typechar='!') + diff --git a/updater.py b/updater.py index a8aa8923..94ea2831 100644 --- a/updater.py +++ b/updater.py @@ -4,19 +4,13 @@ Module : updater Authors : yannick """ -import PyQt6.QtGui -import PyQt6.QtCore -import PyQt6.QtWidgets -from PyQt6.QtWidgets import QListWidgetItem,QCheckBox,QGroupBox,QHBoxLayout,QVBoxLayout,QLabel,QDialog,QTextBrowser,QPushButton + import requests import json import re -import helper -from PyQt6.QtGui import QPalette, QColor from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QLabel, QDialog, QTextBrowser from datetime import datetime -from optionsdialog import OptionsDialogGroupBox -from profile_ui import ProfileUI MAINREPO = "Ultrawipf/OpenFFBoard" GUIREPO = "Ultrawipf/OpenFFBoard-Configurator" @@ -110,93 +104,7 @@ def check_update(repo : str, version : str): return UpdateChecker.compare_versions(version,releasever), release - -class UpdateBrowser(PyQt6.QtWidgets.QDialog): - """Gets updates and displays them in a list""" - def __init__(self,parentWidget,settingsmanager : ProfileUI = None): - PyQt6.QtWidgets.QDialog.__init__(self, parentWidget) - PyQt6.uic.loadUi(helper.res_path("updatebrowser.ui"), self) - - self.listWidget_release.currentItemChanged.connect(self.release_changed) - self.listWidget_files.currentItemChanged.connect(self.file_changed) - self.buttonGroup_repo.buttonClicked.connect(self.repo_changed) - self.read_releases(MAINREPO) - self.settingsmanager = settingsmanager - - if settingsmanager: - self.checkBox_notify.setChecked(not self.settingsmanager.get_global_setting("donotnotify_updates",False)) - self.checkBox_notify.toggled.connect(self.notify_checkbox_toggled) - else: - self.checkBox_notify.setVisible(False) - - def notify_checkbox_toggled(self,status): - self.settingsmanager.set_global_setting("donotnotify_updates",not status) - - - def fill_releases(self,releases : dict): - self.listWidget_release.clear() - for r in releases: - if r.get("name",None): - name = f"{r.get('tag_name','No tag')} ({r.get('name')})" - else: - name = f"{r.get('tag_name','No tag')}" - item = QListWidgetItem(name) - if r.get("prerelease",False): - item.setBackground(QColor('#ffb810')) - else: - item.setBackground(QColor('#70ea5d')) - item.setData(Qt.ItemDataRole.UserRole, r) - self.listWidget_release.addItem(item) - - self.listWidget_release.setCurrentRow(0) - - - def get_selected_release(self): - r = self.listWidget_release.currentItem().data(Qt.ItemDataRole.UserRole) - return r - - def repo_changed(self,button): - if button == self.radioButton_configurator: - self.read_releases(GUIREPO) - else: - self.read_releases(MAINREPO) - - def read_releases(self,repo : str): - releases = GithubRelease.get_releases(repo,True) - if releases: - self.fill_releases(releases) - - - def fill_files(self,release : dict): - self.listWidget_files.clear() - for r in release.get("assets",[]): - name = r['name'] - url = r['browser_download_url'] - item = QListWidgetItem(name) - item.setData(Qt.ItemDataRole.UserRole, url) - self.listWidget_files.addItem(item) - self.listWidget_files.setCurrentRow(0) - - - def release_changed(self,current : QListWidgetItem,old : QListWidgetItem): - if not current: - return - release = current.data(Qt.ItemDataRole.UserRole) - self.textBrowser_releasenotes.setMarkdown(GithubRelease.get_description(release)) - self.label_releaseUrl.setText(f"{release['html_url']}") - self.fill_files(release) - publishtime = GithubRelease.get_time(release) - infotext = f"Date: {publishtime}" - self.label_releaseInfo.setText(infotext) - - def file_changed(self,current : QListWidgetItem,old : QListWidgetItem): - if not current: - return - url = current.data(Qt.ItemDataRole.UserRole) - self.label_fileUrl.setText(f"Download") - - - + class UpdateNotification(QDialog): """Shows a dialog with release information""" def __init__(self,release,main,desc,curver,donotnotifysetting = None): @@ -222,14 +130,11 @@ def __init__(self,release,main,desc,curver,donotnotifysetting = None): self.titlelabel = QLabel(f" {GithubRelease.get_title(release)}") self.titlelabel.setOpenExternalLinks(True) self.vbox.addWidget(self.titlelabel) - - self.updatebrowserbutton = QPushButton("Open update browser",self) - self.updatebrowserbutton.clicked.connect(self.open_updater) - + self.releasenotes = QTextBrowser(self) self.releasenotes.setMarkdown(GithubRelease.get_description(release)) self.vbox.addWidget(self.releasenotes) - self.vbox.addWidget(self.updatebrowserbutton) + #self.vbox.addWidget(self.updatebrowserbutton) if donotnotifysetting: self.donotnotify = QCheckBox("Notify about releases at startup",self) @@ -240,9 +145,3 @@ def donotnotify_save(status): self.main.profile_ui.set_global_setting(donotnotifysetting,not status) self.donotnotify.toggled.connect(donotnotify_save) - - - def open_updater(self): - """Opens the updatebrowser""" - self.close() - UpdateBrowser(self,self.main.profile_ui).exec()