diff --git a/.gitignore b/.gitignore index 26bea3fa..8416eda2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -__pycache__/* +__pycache__/ .vscode/* .qt_for_python/* profiles.json* diff --git a/CHANGELOG.md b/CHANGELOG.md index 926b8d35..96fcf1fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ### v1.17.1 +- Fixed app staying alive in the background after closing the window on linux +- Serial port and timers are now released on every exit path - Update check fails more gracefully at startup with no network - +- Fixed several random timeouts and disconnects. General stability improvements ### v1.17.0 - Fixes analog axis progressbars cutting off text and ADS111x progressbars not populating when set to 4 diff --git a/base_ui.py b/base_ui.py index b70f93b5..3f74dea2 100644 --- a/base_ui.py +++ b/base_ui.py @@ -181,12 +181,13 @@ def process_virtual_comms_buffer(self, buffer:str): """Inject the string buffer in the comms parser and process it as it a board answer.""" first_end_marker = buffer.find("]") first_start_marker = buffer.find("[") + if first_start_marker < 0 or first_end_marker < first_start_marker: + return match = self.comms.cmdRegex.search( buffer, first_start_marker, first_end_marker + 1 ) if match: self.comms.processMatchedReply(match) - self.comms.processMatchedReply(match) def get_raw_reply(self): """Expose the raw reply pySignal to connect on it.""" diff --git a/main.py b/main.py index 8abf400d..03f39191 100644 --- a/main.py +++ b/main.py @@ -133,7 +133,12 @@ def __init__(self): self.setup() self.languagechanged.connect(self.restart_app) - + + # Release the port and stop all timers no matter how the app is closed + # (window close, systray quit or language restart) + self._shutdown_done = False + app.aboutToQuit.connect(self.shutdown) + # start the auto disconnect timer (call the board) self.timer.start(5000) @@ -234,9 +239,8 @@ def change_lang_callback(self, enabled:bool): self.languagechanged.emit() # loading in next start def restart_app(self): + """Quit and let the main loop create a new instance. Cleanup is done in shutdown().""" self.restart_app_flag = True - self.reset_port() - base_ui.CommunicationHandler.comms.removeAllCallbacks() app.quit() def make_lang_selector(self): @@ -433,6 +437,7 @@ def reset_tabs(self): PyQt6.QtCore.QObject.disconnect(connection) except Exception as e: print("Error disconnecting", e) + self.tab_connections.clear() # Already disconnected. Do not try again on the next reset def update_tabs(self): """Get the active classes from the board, and add tab when not exist.""" @@ -563,8 +568,12 @@ def reconnect(self): self.reset_port() PyQt6.QtCore.QTimer.singleShot(1500, self.serialchooser.serial_connect_button) - def reset_port(self): - """Close serial port and remove tabs.""" + def reset_port(self, immediate=False): + """Close serial port and remove tabs. + + Set immediate to close the port without waiting for pending data to be sent. + Required when the event loop is about to stop as the delayed close would never run. + """ self.log("Reset port") self.profile_ui.setEnabled(False) #self.serial.waitForBytesWritten(250) # Broken on pyqt6.3 @@ -574,23 +583,31 @@ def close(): self.serial.close() self.comms_reset() self.timeouting = False + self.process_events_timer.stop() # Never left running if the reply does not arrive + if self.serial_timer: + self.serial_timer.stop() self.serialchooser.update() self.reset_tabs() if self.serial.bytesToWrite() > 0: # Not everything has been sent 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. + + if immediate: + close() + else: + 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", "") if not self.fw_version_str: self.log("Communication error") self.reset_port() + return fw_ver_split = [int(i) for i in self.fw_version_str.split(".")] min_fw_split = [int(i) for i in MIN_FW.split(".")] @@ -607,6 +624,11 @@ def version_check(self, ver): ) 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] + # Modal dialogs and network access must not run inside the serial receive path + PyQt6.QtCore.QTimer.singleShot(0, functools.partial(self.version_warning, gui_outdated, fw_outdated)) + + def version_warning(self, gui_outdated, fw_outdated): + """Show the version mismatch warning and check github for a newer firmware.""" if gui_outdated: msg = PyQt6.QtWidgets.QMessageBox( PyQt6.QtWidgets.QMessageBox.Icon.Information, @@ -629,17 +651,21 @@ def version_check(self, ver): "and GUI are up to date if you encounter errors.") ) msg.exec() + + if self.profile_ui.get_global_setting("donotnotify_updates",False): + return + # Check github - mainreporelease = updater.GithubRelease.get_latest_release(updater.MAINREPO) + try: + mainreporelease = updater.GithubRelease.get_latest_release(updater.MAINREPO) + except Exception: + return releaseversion,_ = updater.GithubRelease.get_version(mainreporelease) if updater.UpdateChecker.compare_versions(self.fw_version_str,releaseversion): - donotnotify = self.profile_ui.get_global_setting("donotnotify_updates",False) - if not donotnotify: - # New release available for firmware - msg = self.tr( "New firmware available") - notification = updater.UpdateNotification(mainreporelease,self,msg,self.fw_version_str) - notification.exec() - + # New release available for firmware + msg = self.tr( "New firmware available") + updater.UpdateNotification(mainreporelease,self,msg,self.fw_version_str).exec() + def signature_check(self,signature,uid,suffix=""): """Checks if the chip signature matches its UID""" key = ECSignature.key_from_bytes_public(PUBKEY) @@ -748,6 +774,36 @@ def display_ui(self): self.show() self.showNormal() + def closeEvent(self, event: PyQt6.QtGui.QCloseEvent): #pylint: disable=invalid-name + """Closing the main window exits the application. + + A visible QSystemTrayIcon keeps the application alive when the last window is + closed, so Qt's quitOnLastWindowClosed never fires and the process keeps running + invisibly with the update timer active. This happens on Linux depending on the + desktop environment and its systray implementation. + Quitting explicitly does not depend on that heuristic. + """ + super().closeEvent(event) + if event.isAccepted(): + app.quit() + + def shutdown(self): + """Stop all periodic activity and release the serial port before the app exits. + + Connected to aboutToQuit so it covers every exit path: window close, the systray + "Quit" action and the restart after a language change. + """ + if self._shutdown_done: # A restarted instance leaves the old handler connected + return + self._shutdown_done = True + + self.timer.stop() + self.reset_port(immediate=True) # Closes the port and stops the serial timers + self.comms.removeAllCallbacks() + + if self.systray: + self.systray.hide() + class SystrayWrapper(PyQt6.QtCore.QObject): """Manage the actions and the content of the systray menu.""" @@ -765,6 +821,7 @@ def __init__(self, main: MainUi): # Adding item on the menu bar tray = PyQt6.QtWidgets.QSystemTrayIcon(main) + self.tray = tray tray.setIcon(icon) tray.setVisible(True) tray.activated.connect(self.on_tray_icon_activated) # pylint: disable=no-value-for-parameter @@ -791,6 +848,10 @@ def __init__(self, main: MainUi): # Adding options to the System Tray tray.setContextMenu(menu) + def hide(self): + """Remove the icon from the systray. A visible icon can keep the app alive.""" + self.tray.setVisible(False) + def on_tray_icon_activated(self, reason): """Show the main UI if double click on icon in systray.""" if reason == PyQt6.QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick: diff --git a/serial_comms.py b/serial_comms.py index b58a4732..1d1c0a03 100644 --- a/serial_comms.py +++ b/serial_comms.py @@ -1,4 +1,4 @@ -import queue,time, traceback, sys +import queue,time, traceback, sys, codecs from PyQt6.QtCore import QObject from PyQt6.QtWidgets import QApplication from collections import deque @@ -22,6 +22,7 @@ class SerialComms(QObject): MAX_REQUEST_SIZE = 1024 MAX_DELAY_SEND_CMD = 30 + MAX_REPLY_BUFFER = 65536 cmdRegex = re.compile(r"\[(\w+)\.(?:(\d+)\.)?(\w+)([?!=]?)(?:(\d+))?(?:\?(\d+))?\|(.+)\]",re.DOTALL) callbackDict = {} @@ -35,6 +36,7 @@ def __init__(self,main,serialport : PyQt6.QtSerialPort.QSerialPort): self.serial.readyRead.connect(self.serialReceive) self.serial.aboutToClose.connect(self.reset) self.replytext = "" + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") self.logger = logging.getLogger("serial_comms") @staticmethod @@ -84,7 +86,10 @@ def sendValue(self,handler,cls,cmd,val,adr=None,instance=0): self.serialWriteRaw(cmdstring) def reset(self): + """Drops all buffered receive and send data so nothing leaks into the next session.""" self.replytext = "" + self.decoder.reset() + self.send_buffer = [] def checkOk(self,reply): if(reply == "OK" or reply.find("Err") == -1): @@ -140,36 +145,40 @@ def _send_over_uart(self): def serialReceive(self): - data = self.serial.readAll() - try: - newReply = data.data().decode("utf-8") - self.replytext += newReply # Buffer replies until newline found at end of buffer - except Exception as e: - print(f"Can not decode:\n{data}. Exception: {e}") + """Decodes incoming bytes and dispatches every complete reply frame.""" + # Incremental decoding keeps multibyte characters split across reads intact + self.replytext += self.decoder.decode(self.serial.readAll().data()) + + if len(self.replytext) > SerialComms.MAX_REPLY_BUFFER: + self.logger.error("Reply buffer overflow (%d bytes). Discarding.", len(self.replytext)) + self.replytext = "" + return + try: - while self.replytext: - firstEndmarker = self.replytext.find("]") firstStartmarker = self.replytext.find("[") - if(firstStartmarker >= 0 and firstEndmarker > 1 and firstStartmarker < firstEndmarker): - match = self.cmdRegex.search(self.replytext,firstStartmarker,firstEndmarker+1) - if (match): - curRepl = self.replytext[firstStartmarker+1:firstEndmarker] - self.replytext = self.replytext[match.end()::] # cut out everything before the end of the match - if self.processMatchedReply(match): - pass - else: - self.rawReply.emit(curRepl) - - else: - self.rawReply.emit(self.replytext[firstStartmarker+1:firstEndmarker]) - self.replytext = self.replytext[firstEndmarker+1::] - else: + if firstStartmarker < 0: + self.replytext = "" # Nothing left that could start a frame break + if firstStartmarker > 0: + self.replytext = self.replytext[firstStartmarker::] # Drop noise or truncated frames + + firstEndmarker = self.replytext.find("]") + if firstEndmarker < 0: + break # Frame is still incomplete + + match = self.cmdRegex.search(self.replytext,0,firstEndmarker+1) + if (match): + curRepl = self.replytext[1:firstEndmarker] + self.replytext = self.replytext[match.end()::] # cut out everything before the end of the match + if not self.processMatchedReply(match): + self.rawReply.emit(curRepl) + else: + self.rawReply.emit(self.replytext[1:firstEndmarker]) + self.replytext = self.replytext[firstEndmarker+1::] except Exception as e: - print("Can not process:",e) + self.logger.error("Can not process reply: %s", e) traceback.print_exception(*sys.exc_info()) - def processMatchedReply(self,match): @@ -185,7 +194,11 @@ def processMatchedReply(self,match): val = int(groups[GRP_CMDVAL1]) if groups[GRP_CMDVAL1] != None else None if cls in SerialComms.callbackDict: - for callbackObject in SerialComms.callbackDict[cls]: + expired = [] + # Iterate a snapshot. Callbacks may register or remove entries while running + for callbackObject in list(SerialComms.callbackDict[cls]): + if callbackObject not in SerialComms.callbackDict[cls]: + continue # Already removed by a nested call if callbackObject["cmd"] != cmd: continue if (instance != callbackObject["instance"]) and (callbackObject["instance"] != 0xff): @@ -206,26 +219,25 @@ def processMatchedReply(self,match): if reply == "ERR": self.logger.error(f"Error executing command {cmd}") continue + value = reply # Conversion must not leak into the next callback if(callbackObject["convert"]): try: - reply = callbackObject["convert"](reply) + value = callbackObject["convert"](reply) except ValueError as e: self.logger.error("Error converting object: " + str(e)) try: - callbackObject["callback"](reply) + callbackObject["callback"](value) except RuntimeError as e: self.logger.error(f"Error calling object {callbackObject['callback']}: " + str(e)) callbackObject["delete"] = True # force delete if callbackObject["delete"]: # delete if flag is set - #print("Deleting",callbackObject) - if (SerialComms.callbackDict[cls] is not None) \ - and (callbackObject in SerialComms.callbackDict[cls]) : - SerialComms.callbackDict[cls].remove(callbackObject) - else : - #self.logger.error(f"Not found callback {callbackObject} for {cls}") - pass + expired.append(callbackObject) deleted = True - + + for callbackObject in expired: + if callbackObject in SerialComms.callbackDict[cls]: + SerialComms.callbackDict[cls].remove(callbackObject) + return deleted diff --git a/serial_ui.py b/serial_ui.py index 735dbfe2..d23ce245 100644 --- a/serial_ui.py +++ b/serial_ui.py @@ -41,6 +41,10 @@ def __init__(self, serial: PyQt6.QtSerialPort.QSerialPort, main_ui: main.MainUi) self._class_ids = {} self._port = None self._ports = [] + self._logging = False + self._closing = False + + self._serial.errorOccurred.connect(self.serial_error) self.pushButton_refresh.clicked.connect(self.get_ports) self.pushButton_connect.clicked.connect(self.serial_connect_button) @@ -55,7 +59,9 @@ def showEvent(self, event): # pylint: disable=unused-argument, invalid-name Connect the communication module with the history widget to load the board response. """ - self.get_raw_reply().connect(self.serial_log) + if not self._logging: + self.get_raw_reply().connect(self.serial_log) + self._logging = True self.shown.emit() # Tab is hidden @@ -65,7 +71,9 @@ 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) + if self._logging: + self.get_raw_reply().disconnect(self.serial_log) + self._logging = False self.hidden.emit() def serial_log(self, txt): @@ -130,8 +138,22 @@ def serial_connect(self): if not self._serial.isOpen(): self.main.log("Can not open port") else: + # Discard whatever the OS buffered before we started listening + self._serial.clear(PyQt6.QtSerialPort.QSerialPort.Direction.AllDirections) self._serial.setDataTerminalReady(True) + def serial_error(self, error): + """Close the port when the device reports an unrecoverable error.""" + errors = PyQt6.QtSerialPort.QSerialPort.SerialPortError + if error in (errors.NoError, errors.NotOpenError) or self._closing: + return + + self.main.log("Serial error: " + self._serial.errorString()) + if error in (errors.ResourceError, errors.DeviceNotFoundError, errors.PermissionError) and self._serial.isOpen(): + self._closing = True + self.main.reset_port(immediate=True) + self._closing = False + def select_port(self, port_id): """Change the selected port.""" if port_id != -1 and len(self._ports) != 0: diff --git a/tests/test_serial_comms.py b/tests/test_serial_comms.py new file mode 100644 index 00000000..d89661d0 --- /dev/null +++ b/tests/test_serial_comms.py @@ -0,0 +1,196 @@ +"""Regression tests for the serial receive path. Run with: python -m unittest discover tests""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import PyQt6.QtSerialPort +from PyQt6.QtCore import QByteArray, QEventLoop, QTimer +from PyQt6.QtWidgets import QApplication + +import serial_comms + +app = QApplication.instance() or QApplication(sys.argv) + + +class FakePort(PyQt6.QtSerialPort.QSerialPort): + """QSerialPort whose readAll() returns whatever the test fed in.""" + + def __init__(self): + super().__init__() + self._data = QByteArray() + + def feed(self, data): + self._data = QByteArray(data) + + def readAll(self): + data = self._data + self._data = QByteArray() + return data + + +class FakeMain: + def log(self, *args): + pass + + +class SerialCommsTestCase(unittest.TestCase): + def setUp(self): + serial_comms.SerialComms.callbackDict.clear() + self.port = FakePort() + self.comms = serial_comms.SerialComms(FakeMain(), self.port) + self.comms.send_buffer = [] + self.raw = [] + self.comms.rawReply.connect(self.raw.append) + + def receive(self, data): + self.port.feed(data) + self.comms.serialReceive() + + def register(self, name, sink, cls="main", cmd="id", **kwargs): + kwargs.setdefault("delete", True) + kwargs.setdefault("typechar", "?") + serial_comms.SerialComms.registerCallback( + handler=name, cls=cls, cmd=cmd, callback=sink, **kwargs) + + +class TestCallbackDispatch(SerialCommsTestCase): + def test_all_matching_oneshot_callbacks_run(self): + """Removing an expired callback must not skip the next one.""" + called = [] + for name in ("timeout_check_cb", "get_main_classes", "id_cb", "fourth"): + self.register(name, (lambda n: lambda r: called.append(n))(name)) + + self.receive(b"[main.0.id?|0]\n") + + self.assertEqual(called, ["timeout_check_cb", "get_main_classes", "id_cb", "fourth"]) + self.assertEqual(serial_comms.SerialComms.callbackDict["main"], []) + + def test_persistent_callbacks_are_kept(self): + called = [] + self.register("oneshot", lambda r: called.append("oneshot"), delete=True) + self.register("keep", lambda r: called.append("keep"), delete=False) + + self.receive(b"[main.0.id?|0]\n") + self.receive(b"[main.0.id?|1]\n") + + self.assertEqual(called, ["oneshot", "keep", "keep"]) + + def test_conversion_does_not_leak_to_next_callback(self): + got = [] + self.register("converted", got.append, conversion=int) + self.register("raw", got.append) + + self.receive(b"[main.0.id?|7]\n") + + self.assertEqual(got, [7, "7"]) + + def test_nested_event_loop_does_not_double_call(self): + """A callback that opens a modal dialog must not make others run twice.""" + called = [] + + def blocking(reply): + called.append("blocking") + loop = QEventLoop() + QTimer.singleShot(10, loop.quit) + loop.exec() + + self.register("blocking", blocking) + self.register("second", lambda r: called.append("second")) + + self.receive(b"[main.0.id?|0]\n") + + self.assertEqual(called, ["blocking", "second"]) + + def test_failing_callback_is_removed(self): + def broken(reply): + raise RuntimeError("widget deleted") + + called = [] + self.register("broken", broken, delete=False) + self.register("second", lambda r: called.append("second")) + + self.receive(b"[main.0.id?|0]\n") + + self.assertEqual(called, ["second"]) + self.assertEqual(serial_comms.SerialComms.callbackDict["main"], []) + + +class TestParser(SerialCommsTestCase): + def collect(self): + got = [] + self.register("sink", got.append, delete=False) + return got + + def test_plain_frames(self): + got = self.collect() + self.receive(b"[main.0.id?|1]\n[main.0.id?|2]\n") + self.assertEqual(got, ["1", "2"]) + + def test_frame_split_across_reads(self): + got = self.collect() + self.receive(b"[main.0.i") + self.receive(b"d?|1]\n") + self.assertEqual(got, ["1"]) + + def test_truncated_frame_before_first_start_marker(self): + """A stray ']' must not wedge the parser forever.""" + got = self.collect() + self.receive(b"sys.0.swver?|1.17.0]\n") + self.receive(b"[main.0.id?|1]\n") + self.receive(b"[main.0.id?|2]\n") + self.assertEqual(got, ["1", "2"]) + self.assertNotIn("]", self.comms.replytext) + + def test_noise_between_frames(self): + got = self.collect() + self.receive(b"[main.0.id?|1]\ndebug: booting\n[main.0.id?|2]\n") + self.assertEqual(got, ["1", "2"]) + + def test_multibyte_split_across_reads(self): + """A read boundary inside a UTF-8 sequence must not drop the chunk.""" + got = self.collect() + frame = "[main.0.id?|1]\n[sys.0.temp?|41.2 °C]\n[main.0.id?|2]\n".encode() + cut = frame.index(b"\xc2") + 1 + self.receive(frame[:cut]) + self.receive(frame[cut:]) + self.assertEqual(got, ["1", "2"]) + + def test_invalid_byte_does_not_drop_chunk(self): + got = self.collect() + self.receive(b"[main.0.id?|1]\n\xff\xfe[main.0.id?|2]\n") + self.assertEqual(got, ["1", "2"]) + + def test_buffer_is_capped(self): + self.receive(b"[" + b"x" * (serial_comms.SerialComms.MAX_REPLY_BUFFER + 1)) + self.assertEqual(self.comms.replytext, "") + + def test_unmatched_frame_is_emitted_raw(self): + self.receive(b"[not a command]\n") + self.assertEqual(self.raw, ["not a command"]) + self.assertEqual(self.comms.replytext, "") + + +class TestReset(SerialCommsTestCase): + def test_reset_clears_receive_and_send_buffers(self): + self.comms.replytext = "[partial" + self.comms.send_buffer = ["main.0.id?;"] + + self.comms.reset() + + self.assertEqual(self.comms.replytext, "") + self.assertEqual(self.comms.send_buffer, []) + + def test_decoder_state_is_dropped(self): + got = [] + self.register("sink", got.append, delete=False) + self.receive("[sys.0.temp?|°".encode()[:-1]) + self.comms.reset() + self.receive(b"[main.0.id?|1]\n") + self.assertEqual(got, ["1"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_serial_integration.py b/tests/test_serial_integration.py new file mode 100644 index 00000000..f00fe403 --- /dev/null +++ b/tests/test_serial_integration.py @@ -0,0 +1,86 @@ +"""End to end test of the receive path over a real tty. POSIX only.""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import PyQt6.QtSerialPort +from PyQt6.QtCore import QIODevice, QTimer, QEventLoop +from PyQt6.QtWidgets import QApplication + +import serial_comms + +app = QApplication.instance() or QApplication(sys.argv) + + +class FakeMain: + def log(self, *args): + pass + + +@unittest.skipUnless(hasattr(os, "openpty"), "needs a POSIX pty") +class TestSerialOverPty(unittest.TestCase): + def setUp(self): + serial_comms.SerialComms.callbackDict.clear() + self.master, slave = os.openpty() + name = os.ttyname(slave) + os.close(slave) + + self.port = PyQt6.QtSerialPort.QSerialPort() + self.port.setPortName(name) + self.port.setBaudRate(115200) + self.assertTrue(self.port.open(QIODevice.OpenModeFlag.ReadWrite), self.port.errorString()) + self.port.clear(PyQt6.QtSerialPort.QSerialPort.Direction.AllDirections) + self.comms = serial_comms.SerialComms(FakeMain(), self.port) + + def tearDown(self): + self.port.close() + os.close(self.master) + + def pump(self, ms=300): + loop = QEventLoop() + QTimer.singleShot(ms, loop.quit) + loop.exec() + + def test_burst_is_fully_delivered(self): + got = [] + serial_comms.SerialComms.registerCallback( + handler="t", cls="main", cmd="id", callback=got.append, delete=False, typechar="?") + + os.write(self.master, b"sys.0.swver?|1.17.0]\n") # leftover tail from a previous session + for i in range(300): + os.write(self.master, ("[main.0.id?|%d]\n" % i).encode()) + os.write(self.master, "[sys.0.temp?|41.2 °C]\n".encode()) + self.pump() + + self.assertEqual(got, [str(i) for i in range(300)]) + + def test_modal_dialog_does_not_lose_replies(self): + """Opening a nested event loop from a callback must not drop pending replies.""" + got = [] + opened = [] + + def blocking(reply): + opened.append(reply) + loop = QEventLoop() + QTimer.singleShot(200, loop.quit) + loop.exec() + + serial_comms.SerialComms.registerCallback( + handler="t", cls="sys", cmd="swver", callback=blocking, delete=True, typechar="?") + serial_comms.SerialComms.registerCallback( + handler="t", cls="main", cmd="id", callback=got.append, delete=False, typechar="?") + + os.write(self.master, b"[sys.0.swver?|1.17.0]\n") + for i in range(50): + os.write(self.master, ("[main.0.id?|%d]\n" % i).encode()) + self.pump(600) + + self.assertEqual(opened, ["1.17.0"]) + self.assertEqual(got, [str(i) for i in range(50)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/translations/es_ES_Full.qm b/translations/es_ES_Full.qm new file mode 100644 index 00000000..f7f333f3 Binary files /dev/null and b/translations/es_ES_Full.qm differ diff --git a/updater.py b/updater.py index fa08f6dc..b5d5d06c 100644 --- a/updater.py +++ b/updater.py @@ -30,8 +30,8 @@ def get_releases(repo : str, prerelease : bool = False, draft : bool = False): """ url = f"https://api.github.com/repos/{repo}/releases" try: - response = requests.get(url) - except requests.ConnectionError: + response = requests.get(url,timeout=5) + except requests.RequestException: return [] if not response: return [] @@ -49,7 +49,7 @@ def get_latest_release(repo : str): url = f"https://api.github.com/repos/{repo}/releases/latest" try: response = requests.get(url,timeout=1) # 1s timeout to avoid blocking startup - except requests.ConnectionError: + except requests.RequestException: # Timeout is not a ConnectionError return {} if not response: return {}