From fd3c29fc5a8b0f1b4e79f2b090778d99f419ff7f Mon Sep 17 00:00:00 2001 From: Yannick Richter Date: Wed, 2 Sep 2026 11:02:59 +0200 Subject: [PATCH 1/3] Add ES translation --- translations/es_ES_Full.qm | Bin 0 -> 112557 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 translations/es_ES_Full.qm diff --git a/translations/es_ES_Full.qm b/translations/es_ES_Full.qm new file mode 100644 index 0000000000000000000000000000000000000000..f7f333f3b229603698c387418c37a203530a841e GIT binary patch literal 112557 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 literal 0 HcmV?d00001 From 4561ed4f7f34f15d1316ce1c255a3d7098e0bb64 Mon Sep 17 00:00:00 2001 From: Yannick Richter Date: Wed, 2 Sep 2026 12:17:28 +0200 Subject: [PATCH 2/3] Properly close application on linux --- CHANGELOG.md | 2 ++ main.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 926b8d35..61cde55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ### 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 diff --git a/main.py b/main.py index 8abf400d..c4447f43 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,17 +583,24 @@ 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", "") @@ -748,6 +764,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 +811,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 +838,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: From 435a5f09d0f4988c5717daed14e873d00f3d2eef Mon Sep 17 00:00:00 2001 From: Yannick Richter Date: Wed, 2 Sep 2026 18:21:37 +0200 Subject: [PATCH 3/3] Fix random disconnects and lost serial data The receive path had three defects that mostly showed on Linux, where the event loop drives both reading and writing. - processMatchedReply removed expired callbacks from the list it was iterating, so every second matching callback was skipped and left registered. With four handlers on main.id this dropped the timeout acknowledge and triggered "Timeout. Please reconnect". - A ']' arriving before any '[' made the parser break out of its loop forever. Every later reply piled up behind it and nothing was dispatched again until the port was closed. - A read boundary inside a multibyte character raised in decode() and the whole chunk was discarded, which is what produced those truncated frames. Also stop opening modal dialogs and doing blocking github requests from inside the receive path, clear the OS buffers on open, drop the send buffer on reset and handle QSerialPort errors. --- .gitignore | 2 +- CHANGELOG.md | 2 +- base_ui.py | 3 +- main.py | 26 ++-- serial_comms.py | 84 +++++++------ serial_ui.py | 26 +++- tests/test_serial_comms.py | 196 +++++++++++++++++++++++++++++++ tests/test_serial_integration.py | 86 ++++++++++++++ updater.py | 6 +- 9 files changed, 379 insertions(+), 52 deletions(-) create mode 100644 tests/test_serial_comms.py create mode 100644 tests/test_serial_integration.py 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 61cde55e..96fcf1fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - 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 c4447f43..03f39191 100644 --- a/main.py +++ b/main.py @@ -607,6 +607,7 @@ def version_check(self, ver): 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(".")] @@ -623,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, @@ -645,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) 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/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 {}