diff --git a/axis_ui.py b/axis_ui.py index 1ed08985..a188153f 100644 --- a/axis_ui.py +++ b/axis_ui.py @@ -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,12 +136,9 @@ 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) @@ -70,6 +149,7 @@ def __init__(self, main: 'main.MainUi'=None, unique=0): 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,137 @@ 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.groupBox_enableAxisBlock.toggled.connect(self.toggleAxisBlock) 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) + + # Set initial state of the collapsible groupbox + self.toggleAxisBlock(self.groupBox_enableAxisBlock.isChecked()) + def toggleAxisBlock(self, checked): + # This function hides/shows the content of the groupbox and adjusts its height + # to create a collapsible effect. + self.groupBox_hardware.setVisible(checked) + self.groupBox_axisOption.setVisible(checked) + self.groupBox_encoder.setVisible(checked) + + if checked: + # When checked (expanded), remove the maximum height constraint. + self.groupBox_enableAxisBlock.setMaximumHeight(16777215) # QWIDGETSIZE_MAX + else: + # When unchecked (collapsed), set a fixed height for the title bar. + # You might need to adjust this value (e.g., 30) to fit your UI style. + self.groupBox_enableAxisBlock.setMaximumHeight(40) + self.groupBox_enableAxisBlock.setMaximumWidth(800) + + # Notify this widget (the tab page) that its size hint has changed. + self.updateGeometry() + # Also notify the parent tab widget, forcing it to recalculate its own size hint. + self.main.tabWidget_main.updateGeometry() + + # Use a single shot timer to allow the event to process, then update the main window layout. + # This will now get the correct, updated size hint from the tab widget. + QTimer.singleShot(0, self.main.adjustSize) + def setSpeedLimit(self,val): if self.checkBox_speedlimit.isChecked(): self.send_value("axis","maxspeed",self.spinBox_speedlimit.value(),instance=self.axis) @@ -113,33 +320,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,8 +370,72 @@ 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: @@ -177,8 +443,10 @@ def init_ui(self): 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 +619,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/encoderconf_ui.py b/encoderconf_ui.py index c0757076..ef119dde 100644 --- a/encoderconf_ui.py +++ b/encoderconf_ui.py @@ -29,20 +29,23 @@ 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")) - - layout.setContentsMargins(0,0,0,0) + layout.setContentsMargins(5,0,5,0) + layout.setStretch(0,0) if self.widget: layout.addWidget(self.widget) + self.setLayout(layout) + + if self.widget: self.applyBtn = QPushButton("Apply") self.applyBtn.clicked.connect(self.widget.apply) footer = QHBoxLayout() @@ -52,7 +55,6 @@ def setType(self,id): footer.addStretch(5) layout.addLayout(footer) - self.setLayout(layout) class EncoderOption(QWidget): def __init__(self,parent): @@ -89,7 +91,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 +123,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 +170,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 +201,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/ffb_ui.py b/ffb_ui.py index 2ff871cf..76abf66a 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,6 +111,13 @@ def __init__(self, main : 'main.MainUi'=None, title = "FFB main"): self.register_callback("fx", "frictionPctSpeedToRampup", self.set_friction_pct_speed_rampup,0,int) + # --- 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) + if(self.init_ui()): tabId = self.main.add_tab(self,title) self.main.select_tab(tabId) @@ -131,6 +138,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 @@ -337,6 +345,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/main.py b/main.py index c221af34..b4a432b9 100644 --- a/main.py +++ b/main.py @@ -112,6 +112,8 @@ def __init__(self): self.timer = PyQt6.QtCore.QTimer(self) self.timer.timeout.connect(self.update_timer) # pylint: disable=no-value-for-parameter self.tabWidget_main.currentChanged.connect(self.tab_changed) + # Force the main window to resize to the content of the newly selected tab + self.tabWidget_main.currentChanged.connect(lambda: PyQt6.QtCore.QTimer.singleShot(0, self.adjustSize)) self.errors_dlg = errors.ErrorsDialog(self) self.effects_monitor_dlg = effects_monitor.EffectsMonitorDialog(self) self.maxaxischanged.connect(self.effects_monitor_dlg.set_max_axes) @@ -287,6 +289,15 @@ def open_dfu_dialog(self): dfu.deleteLater() + def changeEvent(self, event: PyQt6.QtCore.QEvent): + """Minimize to systray when window is minimized.""" + if event.type() == PyQt6.QtCore.QEvent.Type.WindowStateChange: + if self.windowState() & PyQt6.QtCore.Qt.WindowState.WindowMinimized: + self.hide() + event.ignore() + return + super().changeEvent(event) + def moveEvent(self, event: PyQt6.QtGui.QMoveEvent): #pylint: disable=invalid-name """Move all modal dialog when moving main ui.""" super().moveEvent(event) @@ -758,7 +769,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) @@ -928,6 +939,14 @@ 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) + self.parent = parent + self.init_ui() + + self.sig_dialog.signature_result.connect(parent.update_signature) + self.sig_dialog.signature_result.connect(lambda : self.init_ui()) + + def init_ui(self): + parent = self.parent verstr = "Version: " + VERSION self.hwuid = parent.hwuid self.sig_dialog = SignatureInputDialog(self,parent.hwuid) @@ -943,15 +962,13 @@ def __init__(self, parent : MainUi = None ): self.pushButton_signature.setEnabled(False) signaturestr = self.tr("Signature: ") - if not parent.hwsignature: + if not parent.hwsignature or parent.hwsignature == bytes([0xff])*64: 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() @@ -1045,6 +1062,12 @@ def process_events(): if __name__ == "__main__": 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) 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/res/MainWindow.ui b/res/MainWindow.ui index 71a55a52..cc46dc7f 100644 --- a/res/MainWindow.ui +++ b/res/MainWindow.ui @@ -15,7 +15,7 @@ - + 0 0 @@ -24,15 +24,15 @@ - + 0 - 20 + 0 0 - 200 + 0 @@ -43,7 +43,7 @@ - + 0 0 diff --git a/res/axis_ui.ui b/res/axis_ui.ui index 3de7ad74..a8888406 100644 --- a/res/axis_ui.ui +++ b/res/axis_ui.ui @@ -6,578 +6,181 @@ 0 0 - 941 - 907 + 948 + 873 Form - - - + + + - Axis settings + Position - - - 5 - - - 5 - - - - - Qt::Orientation::Vertical + + + + + Pos - - QSizePolicy::Policy::Preferred + + + + + + <html><head/><body><p>Zero the axis (Center)</p></body></html> - - - 20 - 5 - + + Set center position - + - - - - Qt::Orientation::Vertical - - - - 20 - 1 - + + + + Raw - + - - - - Qt::Orientation::Horizontal + + + + true - - QSizePolicy::Policy::Expanding + + false - - - 10 - 10 - + + true - - - - - - Qt::Orientation::Vertical + + QAbstractSpinBox::ButtonSymbols::NoButtons - - QSizePolicy::Policy::Minimum + + false - - - 20 - 0 - + + - - - - - - Qt::Orientation::Vertical + + ° - - - 20 - 0 - + + -9223372036854775808.000000000000000 - - - - - - - 0 - 0 - + + 9223372036854775808.000000000000000 - - Motor driver + + 0.000000000000000 - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Minimum - - - - 40 - 20 - - - - - - - - Change driver - - - - - - - - 2 - 0 - - - - - - - - Motor driver - - - - - - - - - 1 - 0 - + + + + false - - Encoder selection + + true + + + QAbstractSpinBox::ButtonSymbols::NoButtons + + + false + + + -1999999999 + + + 1999999999 + + + 0 - - - 5 - - - 5 - - - 5 - - - - - - 1 - 1 - - - - 0 - - - 1 - - - - - - - - - Encoder source - - - - - - - - 1 - 0 - - - - QComboBox::SizeAdjustPolicy::AdjustToContents - - - - - - - Change enc - - - - - - + + + + + + + + 0 + 0 + + + + FFB Power + + + + 10 + + + 5 + + + - + 0 0 - - Actions + + <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>Zero the axis (Center)</p></body></html> - - - Set center position - - - - - - - Encoder tuning... - - - - - - - false - - - Torque curve... - - - - - - - - Options + + + + <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> - - - 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> + + 102 255 + + 102 + + + 102 + Qt::Orientation::Horizontal - - - - <html><head/><body><p>Independent damper is an always on damper effect to smooth out acceleration spikes.</p></body></html> + + + + + 30 + 0 + - Permanent damper + 0 +(0%) - - + + 100 @@ -590,194 +193,201 @@ - - - 255 - + - <html><head/><body><p>Degrees of rotation</p></body></html> + <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> - Range (deg) + Overall Power - - - - <html><head/><body><p>Centering force when FFB is inactive</p></body></html> - + + - 255 - - - 0 + 32767 - - 0 + + 100 Qt::Orientation::Horizontal + + 100 + - - - - - 100 - 0 - + + + + <html><head/><body><p>How much torque will be reserved to stop the motor for endstop effect.</p></body></html> - - false + + Endstop intensity - - ° + + + + + + 0 - - 10 + + + + + + <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 + + + + + + + + + + Mechanical settings (Game independent) + + + + 10 + + + 5 + + + + + Permanent friction + + + + - 32767 + 255 - - 10 + + Qt::Orientation::Horizontal - - + + 100 0 - - false - 255 - - - - 10 - + + - 1440 - - - 10 - - - 10 + 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 inertia + Permanent damper - - + + + + <html><head/><body><p>Centering force when FFB is inactive.</p><p>Turned off when game effects takes place</p></body></html> + - Permanent friction + Desktop spring (No FFB) - - + + + + + 100 + 0 + + 255 - - Qt::Orientation::Horizontal - - - - - 255 + + + + <html><head/><body><p>Degrees of rotation</p></body></html> - - Qt::Orientation::Horizontal + + Range (deg) - - - - - 100 - 0 - + + + + 10 - 255 + 1440 + + + 10 + + + 10 + + + Qt::Orientation::Horizontal - - + + 100 0 + + false + 255 - - - - - - - - 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> + <html><head/><body><p>Independent damper is an always on damper effect to smooth out acceleration spikes</p></body></html> 255 @@ -788,43 +398,55 @@ - + - <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 + <html><head/><body><p>Centering force when FFB is inactive</p></body></html> 255 - 102 + 0 - 102 + 0 Qt::Orientation::Horizontal - - + + - 30 + 100 0 + + false + + + + + + + + + 255 + + + + + - 0 -(0%) + Permanent inertia - - + + 100 @@ -835,84 +457,502 @@ false - + ° + + + 10 - 255 + 32767 - - - - - - <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> + + 10 + + + + + + + + + + 0 + 1 + + + + Smoothing + + + + 10 + + + - Power + Effect equalizer + + + true - - - - 32767 - - - 100 + + + + + 0 + 2 + - - Qt::Orientation::Horizontal + + + 0 + 0 + - - 100 + + + + + + Reset gain + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Show Axis settings + + + false + + + true + + + + 5 + + + 5 + - - - <html><head/><body><p>How much torque will be reserved to stop the motor for endstop effect.</p></body></html> + + + Axis Options - - Endstop gain + + + 5 + + + 5 + + + + + false + + + false + + + °/s + + + 0 + + + 65535 + + + 10 + + + 0 + + + + + + + Speed limit + + + + + + + false + + + Torque curve... + + + + + + + 1.0 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + <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 + + + 5 + + + + + + 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 + + + + + + + + 1 + 0 + + + + QComboBox::SizeAdjustPolicy::AdjustToContents + + + + + + + Encoder tuning... + + + + + + + Encoder source + + + + + + + + 0 + 0 + + + + 0 + + + 1 + + + + + + 0 + 0 + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + - - - - 0 + + + + + 0 + 0 + + + + Motor driver + + + + + Change driver + + + + + + + + 2 + 0 + + + + + + + + Motor driver + + + + - - - <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> + + + Qt::Orientation::Vertical - - Effects intensity + + QSizePolicy::Policy::Expanding - + + + 20 + 40 + + + - + Qt::Orientation::Vertical + + QSizePolicy::Policy::MinimumExpanding + 20 - 10 + 0 + + + QChartView + QWidget +
PyQt6/QtCharts
+ 1 +
+
@@ -922,12 +962,12 @@ setValue(int) - 512 - 505 + 673 + 397 - 539 - 504 + 930 + 402 @@ -938,12 +978,12 @@ setValue(int) - 551 - 510 + 930 + 402 - 520 - 509 + 681 + 397 @@ -954,12 +994,12 @@ setValue(int) - 309 - 291 + 470 + 587 - 553 - 291 + 930 + 592 @@ -970,12 +1010,12 @@ setValue(int) - 553 - 291 + 930 + 592 - 309 - 291 + 470 + 587 @@ -986,12 +1026,12 @@ setValue(int) - 309 - 319 + 470 + 617 - 553 - 319 + 930 + 622 @@ -1002,12 +1042,12 @@ setValue(int) - 553 - 319 + 930 + 622 - 309 - 319 + 470 + 617 @@ -1018,12 +1058,12 @@ setValue(int) - 469 - 573 + 630 + 677 - 840 - 573 + 930 + 682 @@ -1034,12 +1074,12 @@ setValue(int) - 469 - 546 + 630 + 647 - 840 - 546 + 930 + 652 @@ -1050,12 +1090,12 @@ setValue(int) - 840 - 573 + 930 + 682 - 469 - 573 + 630 + 677 @@ -1066,12 +1106,12 @@ setValue(int) - 840 - 546 + 930 + 652 - 469 - 546 + 630 + 647 diff --git a/res/ffbclass.ui b/res/ffbclass.ui index 8d3fa786..0fb9744d 100644 --- a/res/ffbclass.ui +++ b/res/ffbclass.ui @@ -7,7 +7,7 @@ 0 0 543 - 469 + 542
@@ -32,10 +32,10 @@ - Qt::Vertical + Qt::Orientation::Vertical - QSizePolicy::MinimumExpanding + QSizePolicy::Policy::MinimumExpanding @@ -158,7 +158,7 @@ 0 - Qt::Horizontal + Qt::Orientation::Horizontal
@@ -168,16 +168,16 @@ <html><head/><body><p>Intensity of friction effect</p></body></html> - QFrame::NoFrame + QFrame::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised Friction Gain - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -209,7 +209,7 @@ 0 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -250,14 +250,14 @@ 0 - Qt::Horizontal + Qt::Orientation::Horizontal - Qt::Vertical + Qt::Orientation::Vertical @@ -267,16 +267,16 @@ <html><head/><body><p>Intensity of spring effects</p></body></html> - QFrame::NoFrame + QFrame::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised Spring Gain - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -348,16 +348,16 @@ <html><head/><body><p>Intensity of damper effect to smooth out acceleration spikes.</p></body></html> - QFrame::NoFrame + QFrame::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised Damper Gain - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -379,7 +379,7 @@ 0 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -389,16 +389,16 @@ <html><head/><body><p>Intensity of inertia effects</p></body></html> - QFrame::NoFrame + QFrame::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised Inertia Gain - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -454,7 +454,7 @@ - Static effect settings (Game effects) + Static && Periodic Filter (Game effects) false @@ -466,31 +466,6 @@ 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 - - - @@ -498,6 +473,13 @@ + + + + Qt::Orientation::Vertical + + + @@ -520,16 +502,28 @@ - - + + <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. + + 10 - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 500 + + + 4 + + + 10 + + + 10 + + + Qt::Orientation::Horizontal @@ -539,10 +533,10 @@ <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::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised 1 @@ -557,14 +551,74 @@ true - Qt::AlignCenter + Qt::AlignmentFlag::AlignCenter - - - - Qt::Vertical + + + + Reconstruction Filter + + + + + + + + + <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 @@ -589,30 +643,30 @@ 0 - Qt::Horizontal + Qt::Orientation::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> + <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> - Q + Freq. - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - + - Qt::Vertical + Qt::Orientation::Vertical @@ -624,6 +678,25 @@ + + comboBox_reportrate + horizontalSlider_cffilter + horizontalSlider_CFq + doubleSpinBox_CFq + radioButton_reconfilter_0 + radioButton_reconfilter_1 + radioButton_reconfilter_2 + radioButton_reconfilter_3 + horizontalSlider_spring + doubleSpinBox_spring + horizontalSlider_damper + doubleSpinBox_damper + horizontalSlider_friction + doubleSpinBox_friction + horizontalSlider_inertia + doubleSpinBox_inertia + pushButton_advanced_tuning + 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/tmc4671_ui.ui b/res/tmc4671_ui.ui index 06edf6a2..ebe2f4a9 100644 --- a/res/tmc4671_ui.ui +++ b/res/tmc4671_ui.ui @@ -7,7 +7,7 @@ 0 0 744 - 731 + 820 @@ -20,6 +20,237 @@ TMC4671 + + + + + 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 + + + + 0A + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Temperature: + + + + + + + 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 + + + + + + @@ -65,7 +296,7 @@ - + <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> @@ -78,13 +309,13 @@ - + - QFrame::NoFrame + QFrame::Shape::NoFrame - QFrame::Raised + QFrame::Shadow::Raised @@ -106,7 +337,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -119,7 +350,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -132,7 +363,101 @@ - + + + + 0 + + + + Current + + + + + + + 0 + 1 + + + + + + + + + Cogging + + + + + + + 0 + 1 + + + + + + + + + + Reload table + + + + + + + Reset table + + + + + + + + + + + + + + 0 + 0 + + + + Qt::LayoutDirection::LeftToRight + + + 28000 + + + 0 + + + true + + + %p% Power + + + + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + @@ -305,7 +630,23 @@ - + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Expanding + + + + 20 + 0 + + + + + @@ -399,285 +740,85 @@ - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - - 0 - 1 - - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 20 - 0 - - - - - - - - - 0 - 0 - - - - Qt::LeftToRight - - - 28000 - - - 0 - - - true - - - %p% Power - - - - - - - - 500 - 16777215 - - + + - More + Anticogging - - - - - Torque Filter - - - - - - - - + + + 9 + + + 9 + + + 9 + + + 9 + + + - Torque Filter freq. - - - - - - - false - - - - 100 - 0 - - - - Hz - - - 10 - - - 8192 - - - 1000 + Apply anti-cogging (Exp.) - - - <html><head/><body><p>Caution: can increase max speed but can also cause steppers to accelerate exponentially if too high</p></body></html> + + + + 0 + 0 + - Flux field weakening + Magnitude - - - - 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 + + + + -32767 - 5.000000000000000 + 32767 - 0.100000000000000 - - - - - - - Qt::Horizontal + 100 - - - - - - HW Version + + 1000 - + true - - - - - - Select hardware version - - - - - - - Force encoder alignment - - - - - - - Start full calibration - - - - - - Qt::Horizontal - - - - - - - State: - - - - - - - State + Qt::Orientation::Horizontal - - 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 + + false - - - - - - Voltage: + + false - - - - 0V + + + + 4 - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + -3.276700000000000 - - - - - - <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> + + 3.276700000000000 - - Prefer energy dissipation in motor + + 0.000100000000000 diff --git a/tmc4671_ui.py b/tmc4671_ui.py index 2717df0e..8f34f433 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) @@ -97,6 +112,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 +143,40 @@ def __init__(self, main=None, unique=0): ax.setLabelsBrush(QApplication.instance().palette().text()) + # Cogging Chart setup + self.chart_cogging = QChart() + 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 +243,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 +270,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 +355,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 +394,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 +514,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 +595,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 +616,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 +644,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 +683,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 +838,4 @@ def typeCb(self,entries): def readValues(self): self.get_value_async("tmc","tmcHwType",self.typeCb,self.axis,str,typechar='!') +