Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__pycache__/*
__pycache__/
.vscode/*
.qt_for_python/*
profiles.json*
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion base_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
91 changes: 76 additions & 15 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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(".")]
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand All @@ -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:
Expand Down
84 changes: 48 additions & 36 deletions serial_comms.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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
Loading
Loading