From 16adecb9bf87f8f789ba6c46acb48dea69f37626 Mon Sep 17 00:00:00 2001
From: SEMU Admin <28569967+semuadmin@users.noreply.github.com>
Date: Sun, 18 Jan 2026 08:25:31 +0000
Subject: [PATCH] minor black reformatting
streamline set_layout weights
add screengeom to config
add CTRL-U update binding
minor UI tweaks
minor UI tweaks
---
README.md | 4 +-
RELEASE_NOTES.md | 4 +
src/pygpsclient/_version.py | 2 +-
src/pygpsclient/about_dialog.py | 97 +++++----
src/pygpsclient/app.py | 251 +++++++++++++++---------
src/pygpsclient/configuration.py | 3 +-
src/pygpsclient/dynamic_config_frame.py | 2 +-
src/pygpsclient/file_handler.py | 2 +-
src/pygpsclient/globals.py | 1 +
src/pygpsclient/hardware_info_frame.py | 2 +-
src/pygpsclient/helpers.py | 48 +++++
src/pygpsclient/levelsview_frame.py | 2 +-
src/pygpsclient/nmea_preset_frame.py | 2 +-
src/pygpsclient/recorder_dialog.py | 2 +-
src/pygpsclient/rover_frame.py | 2 +-
src/pygpsclient/settings_dialog.py | 4 +-
src/pygpsclient/settings_frame.py | 3 +-
src/pygpsclient/signalsview_frame.py | 2 +-
src/pygpsclient/skyview_frame.py | 2 +-
src/pygpsclient/spartn_gnss_frame.py | 4 +-
src/pygpsclient/spectrum_frame.py | 2 +-
src/pygpsclient/strings.py | 6 +-
src/pygpsclient/ubx_cfgval_frame.py | 4 +-
src/pygpsclient/ubx_config_dialog.py | 2 +-
src/pygpsclient/ubx_msgrate_frame.py | 2 +-
src/pygpsclient/ubx_port_frame.py | 2 +-
src/pygpsclient/ubx_preset_frame.py | 2 +-
src/pygpsclient/ubx_solrate_frame.py | 2 +-
tests/test_static.py | 2 +-
29 files changed, 290 insertions(+), 173 deletions(-)
diff --git a/README.md b/README.md
index d4dff19..6f6c3a8 100644
--- a/README.md
+++ b/README.md
@@ -144,13 +144,13 @@ For more comprehensive installation instructions, please refer to [INSTALLATION.
#### Saving and loading configuration settings
-- Configuration settings for PyGPSClient can be saved and recalled via the Menu..File..Save Configuration and Menu..File..Load Configuration options. By default, PyGPSClient will look for a file named `pygpsclient.json` in the user's home directory. Certain configuration settings require manual editing e.g. custom preset UBX, NMEA and TTY commands and tag colour schemes - see details below.
+- Configuration settings for PyGPSClient can be saved and recalled via the Menu..File..Save/Load Configuration options. By default, PyGPSClient will look for a file named `pygpsclient.json` in the user's home directory. Certain configuration settings require manual editing e.g. custom preset UBX, NMEA and TTY commands and tag colour schemes - see details below.
- It is recommended to re-save the configuration settings after each PyGPSClient version update, or if you see the warning "Consider re-saving" on startup.
- PyGPSClient will prompt you to stop all running input and output streams before loading a new configuration.
#### Toplevel ('pop-up') dialog setting
-- The behaviour of Toplevel ('pop-up') dialogs will depend on the screen resolution. If the width or height of a Toplevel dialog exceeds the screen resolution, the dialog will be displayed in a scrollable, resizeable window. Otherwise, the dialog is displayed as a fixed, non-resizeable panel.
+- The behaviour of Toplevel ('pop-up') dialogs will depend on the screen resolution and 'transient' setting. If the width or height of a Toplevel dialog exceeds the screen resolution, the dialog will be displayed in a scrollable, resizeable window. Otherwise, the dialog is displayed as a fixed, non-resizeable panel.
- A boolean configuration setting `transient_dialog_b` governs whether Toplevel dialogs are 'transient' (i.e. always on top of main application dialog) or not. Changing this setting to `0` allows Toplevel dialogs to be minimised independently of the main application window, but be mindful that some dialogs may end up hidden behind others e.g. "Open file/folder" dialogs. **If a file open button appears unresponsive, check that the "Open file/folder" panel isn't already open but obscured**.
- If you're accessing the desktop via a VNC session (e.g. to a headless Raspberry Pi) it is recommended to keep the setting at the default `1`, as VNC may not recognise keystrokes on overlaid non-transient windows.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index d596d9e..672a030 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,9 @@
# PyGPSClient Release Notes
+### RELEASE 1.6.1
+
+1. Updates to main application window geometry (size and position) handling. Current window geometry is now saved to json configuration file as `screengeom_s` (e.g. `"1373x798+71+44"`), and will be restored on restart. Default startup geometry is centered at 75% of screen resolution.
+
### RELEASE 1.6.0
FIXES:
diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py
index 376e36f..ffa05fa 100644
--- a/src/pygpsclient/_version.py
+++ b/src/pygpsclient/_version.py
@@ -8,4 +8,4 @@
:license: BSD 3-Clause
"""
-__version__ = "1.6.0"
+__version__ = "1.6.1"
diff --git a/src/pygpsclient/about_dialog.py b/src/pygpsclient/about_dialog.py
index 1c36d47..44d8ec8 100644
--- a/src/pygpsclient/about_dialog.py
+++ b/src/pygpsclient/about_dialog.py
@@ -11,20 +11,12 @@
"""
import logging
-from platform import python_version
+from platform import machine, python_version
from tkinter import Button, Checkbutton, Frame, IntVar, Label, Tcl
from webbrowser import open_new_tab
from PIL import Image, ImageTk
-from pygnssutils import version as PGVERSION
-from pynmeagps import version as NMEAVERSION
-from pyqgc import version as QGCVERSION
-from pyrtcm import version as RTCMVERSION
-from pysbf2 import version as SBFVERSION
-from pyspartn import version as SPARTNVERSION
-from pyubx2 import version as UBXVERSION
-
-from pygpsclient._version import __version__ as VERSION
+
from pygpsclient.globals import (
ERRCOL,
ICON_APP128,
@@ -36,22 +28,22 @@
SPONSOR_URL,
TRACEMODE_WRITE,
)
-from pygpsclient.helpers import brew_installed, check_latest
+from pygpsclient.helpers import LIBVERSIONS, brew_installed, check_for_updates
from pygpsclient.sqlite_handler import SQLSTATUS
-from pygpsclient.strings import ABOUTTXT, BREWWARN, COPYRIGHT, DLGTABOUT, GITHUB_URL
+from pygpsclient.strings import (
+ ABOUTTXT,
+ BREWUPDATE,
+ BREWWARN,
+ COPYRIGHT,
+ DLGTABOUT,
+ GITHUB_URL,
+ NA,
+ UPDATEERR,
+ UPDATEINPROG,
+ UPDATERESTART,
+)
from pygpsclient.toplevel_dialog import ToplevelDialog
-LIBVERSIONS = {
- "PyGPSClient": VERSION,
- "pygnssutils": PGVERSION,
- "pyubx2": UBXVERSION,
- "pysbf2": SBFVERSION,
- "pyqgc": QGCVERSION,
- "pynmeagps": NMEAVERSION,
- "pyrtcm": RTCMVERSION,
- "pyspartn": SPARTNVERSION,
-}
-
class AboutDialog(ToplevelDialog):
"""
@@ -73,7 +65,6 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument
self._img_sponsor = ImageTk.PhotoImage(Image.open(ICON_SPONSOR))
self._checkonstartup = IntVar()
self._checkonstartup.set(self.__app.configuration.get("checkforupdate_b"))
- self._updates = []
super().__init__(app, DLGTABOUT)
@@ -102,6 +93,7 @@ def _body(self):
self._lbl_python_version = Label(
self._frm_body,
text=(
+ f"Arch: {machine()} "
f"Python: {python_version()} Tk: {tkv} "
f"Spatial: {SQLSTATUS[self.__app.db_enabled]}"
),
@@ -118,7 +110,7 @@ def _body(self):
)
self._btn_checkupdate = Button(
self._frm_body,
- text="Check for updates",
+ text="",
width=14,
cursor="hand2",
)
@@ -175,7 +167,7 @@ def _attach_events(self):
Bind events to dialog.
"""
- self._btn_checkupdate.bind("", self._check_for_update)
+ self._set_update_btn_mode(False)
self._lbl_github.bind("", self._on_github)
self._lbl_sponsoricon.bind("", self._on_sponsor)
self._lbl_copyright.bind("", self._on_license)
@@ -197,7 +189,7 @@ def _on_github(self, *args, **kwargs): # pylint: disable=unused-argument
"""
if brew_installed():
- self._brew_warning()
+ self.status_label = (BREWWARN, INFOCOL)
return
open_new_tab(GITHUB_URL)
@@ -209,7 +201,7 @@ def _on_sponsor(self, *args, **kwargs): # pylint: disable=unused-argument
"""
if brew_installed():
- self._brew_warning()
+ self.status_label = (BREWWARN, INFOCOL)
return
open_new_tab(SPONSOR_URL)
@@ -221,7 +213,7 @@ def _on_license(self, *args, **kwargs): # pylint: disable=unused-argument
"""
if brew_installed():
- self._brew_warning()
+ self.status_label = (BREWWARN, INFOCOL)
return
open_new_tab(LICENSE_URL)
@@ -232,26 +224,27 @@ def _check_for_update(self, *args, **kwargs): # pylint: disable=unused-argument
Check for updates.
"""
- self.status_label = ""
- self._updates = []
- for i, (nam, current) in enumerate(LIBVERSIONS.items()):
- latest = check_latest(nam)
+ versions = check_for_updates()
+ self.status_label = ("Checking for updates...", INFOCOL)
+ for i, (nam, current, latest) in enumerate(versions):
txt = f"{nam}: {current}"
if latest == current:
txt += " ✓"
col = OKCOL
- elif latest == "N/A":
+ elif latest == NA:
txt += " - Info not available!"
col = ERRCOL
else:
- self._updates.append(nam)
txt += f" - Latest version is {latest}"
col = ERRCOL
self._lbl_lib_versions[i].config(text=txt, fg=col)
- if len(self._updates) > 0:
- self._btn_checkupdate.config(text="UPDATE", fg=INFOCOL)
- self._btn_checkupdate.bind("", self._do_update)
+ updates = [nam for (nam, current, latest) in versions if latest != current]
+ if len(updates) > 0:
+ self.status_label = ("Updates available", OKCOL)
+ self._set_update_btn_mode(True)
+ else:
+ self.status_label = ("No updates available", INFOCOL)
def _do_update(self, *args, **kwargs): # pylint: disable=unused-argument
"""
@@ -259,22 +252,28 @@ def _do_update(self, *args, **kwargs): # pylint: disable=unused-argument
"""
if brew_installed():
- self._brew_warning()
+ self.status_label = (BREWUPDATE, INFOCOL)
return
- self._btn_checkupdate.config(text="UPDATING...", fg=INFOCOL)
- self.update_idletasks()
- rc = self.__app.do_app_update(self._updates)
+ self.status_label = (UPDATEINPROG, INFOCOL)
+ rc = self.__app.do_app_update()
if rc:
- self._btn_checkupdate.config(text="RESTART APP", fg=OKCOL)
- self._btn_checkupdate.bind("", self.__app.on_exit)
+ self.status_label = (UPDATERESTART, OKCOL)
else:
- self._btn_checkupdate.config(text="UPDATE FAILED", fg=ERRCOL)
- self._btn_checkupdate.bind("", self._check_for_update)
+ self.status_label = (UPDATEERR.format(err=rc), ERRCOL)
+ self._set_update_btn_mode(False)
- def _brew_warning(self):
+ def _set_update_btn_mode(self, update: bool):
"""
- Display warning that some functionality unavailable with Homebrew.
+ Set Check for update button label and binding.
+
+ :param bool update: False = check, True = update
"""
- self.status_label = (BREWWARN, INFOCOL)
+ if update:
+ self._btn_checkupdate.config(text="UPDATE", fg=OKCOL)
+ self._btn_checkupdate.bind("", self._do_update)
+ else:
+ self._btn_checkupdate.config(text="CHECK FOR UPDATES", fg=INFOCOL)
+ self._btn_checkupdate.bind("", self._check_for_update)
+ self.update_idletasks()
diff --git a/src/pygpsclient/app.py b/src/pygpsclient/app.py
index 6ec3f5e..db5427c 100644
--- a/src/pygpsclient/app.py
+++ b/src/pygpsclient/app.py
@@ -89,6 +89,7 @@
GNSS_TIMEOUT_EVENT,
ICON_APP128,
INFOCOL,
+ MAINSCALE,
MQTT_PROTOCOL,
NOPORTS,
NTRIP_EVENT,
@@ -101,7 +102,12 @@
UNDO,
)
from pygpsclient.gnss_status import GNSSStatus
-from pygpsclient.helpers import check_latest
+from pygpsclient.helpers import (
+ brew_installed,
+ check_for_updates,
+ check_latest,
+ set_geom,
+)
from pygpsclient.menu_bar import MenuBar
from pygpsclient.nmea_handler import NMEAHandler
from pygpsclient.qgc_handler import QGCHandler
@@ -112,6 +118,7 @@
from pygpsclient.status_frame import StatusFrame
from pygpsclient.stream_handler import StreamHandler
from pygpsclient.strings import (
+ BREWUPDATE,
CONFIGERR,
DLG,
DLGSTOPRTK,
@@ -127,6 +134,9 @@
SAVECONFIGBAD,
SAVECONFIGOK,
TITLE,
+ UPDATEERR,
+ UPDATEINPROG,
+ UPDATERESTART,
VERCHECK,
)
from pygpsclient.tty_handler import TTYHandler
@@ -135,7 +145,6 @@
COLSPAN,
DEFAULT,
HIDE,
- MAXROWSPAN,
MAXSPAN,
SHOW,
VISIBLE,
@@ -167,11 +176,30 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements
super().__init__(master)
+ # load config from json file
+ self._deferredmsg = None
+ self.widget_state = WidgetState() # widget state
+ self.file_handler = FileHandler(self)
+ self.configuration = Configuration(self) # configuration state
+ configfile = kwargs.pop("config", CONFIGFILE)
+ _, configerr = self.configuration.loadfile(configfile)
+ # load config from CLI arguments & env variables
+ self.configuration.loadcli(**kwargs)
+ if configerr == "":
+ self.update_widgets() # set initial widget state
+ # warning if all widgets have been disabled in config
+ if self._nowidgets:
+ self.status_label = (NOWDGSWARN.format(configfile), ERRCOL)
+
+ # setup main application window
+ geom = self.configuration.get("screengeom_s")
+ if geom == "":
+ geom = set_geom(master, MAINSCALE)
+ self.__master.geometry(geom)
self.__master.protocol("WM_DELETE_WINDOW", self.on_exit)
self.__master.title(TITLE)
self.__master.iconphoto(True, PhotoImage(file=ICON_APP128))
- self._deferredmsg = None
self._server_status = -1 # socket server status -1 = inactive
self.gnss_inqueue = Queue() # messages from GNSS receiver
self.gnss_outqueue = Queue() # messages to GNSS receiver
@@ -180,11 +208,8 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements
self.spartn_outqueue = Queue() # messages to SPARTN correction rcvr
self.socket_inqueue = Queue() # message from socket
self.socket_outqueue = Queue() # message to socket
- self.widget_state = WidgetState() # widget state
self.dialog_state = DialogState() # dialog state
- self.configuration = Configuration(self) # configuration state
self.gnss_status = GNSSStatus() # holds latest GNSS readings
- self.file_handler = FileHandler(self)
self.stream_handler = StreamHandler(self)
self.spartn_stream_handler = StreamHandler(self)
self.nmea_handler = NMEAHandler(self)
@@ -208,17 +233,6 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements
self.recording = False # RecordDialog status
self.recording_type = 0 # 0 = TTY ONLY, 1 = UBX/NMEA
- # load config from json file
- configfile = kwargs.pop("config", CONFIGFILE)
- _, configerr = self.configuration.loadfile(configfile)
- # load config from CLI arguments & env variables
- self.configuration.loadcli(**kwargs)
- if configerr == "":
- self.update_widgets() # set initial widget state
- # warning if all widgets have been disabled in config
- if self._nowidgets:
- self.status_label = (NOWDGSWARN.format(configfile), ERRCOL)
-
# open database if database recording enabled
dbpath = self.configuration.get("databasepath_s")
if self.configuration.get("database_b") and dbpath != "":
@@ -244,15 +258,15 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements
if self.frm_settings.frm_serial.status == NOPORTS:
self.status_label = (INTROTXTNOPORTS, ERRCOL)
- # check for more recent version (if enabled)
- if self.configuration.get("checkforupdate_b") and configerr == "":
- self._check_update()
-
# display any deferred messages
if isinstance(self._deferredmsg, tuple):
self.status_label = self._deferredmsg
self._deferredmsg = None
+ # check for more recent version (if enabled)
+ if self.configuration.get("checkforupdate_b") and configerr == "":
+ self._check_update()
+
def _body(self):
"""
Set up frame and widgets.
@@ -260,7 +274,7 @@ def _body(self):
self._set_default_fonts()
self.menu = MenuBar(self)
- self.__master.config(menu=self.menu)
+ self.__master.config(menu=self.menu, bg=BGCOL)
self.frm_banner = BannerFrame(self, borderwidth=2, relief="groove")
self.frm_status = StatusFrame(self, borderwidth=2, relief="groove")
@@ -278,84 +292,119 @@ def _body(self):
def _do_layout(self):
"""
- Arrange visible widgets in main application frame and set
- menu labels (show/hide).
+ Arrange and 'pack' visible widgets in main application frame and set
+ show/hide View menu labels.
+
+ Widget order, visibility and columnspan are defined in widget_state.
+ Visible widgets are gridded in sequence, left to right, top to bottom,
+ subject to a maximum columnspan defined in "maxcolumns_n" (default 4).
+ A widget columnspan of 0 (MAXSPAN) signifies the widget occupies a
+ full row.
NB: PyGPSClient uses 'grid' rather than 'pack' layout management throughout:
- - grid weight = 0 means fixed, non-expandable
- - grid weight > 0 means expandable
+
+ - grid col/row weight = 0 means non-expandable
+ - grid col/row weight > 0 and `sticky=NSEW` is equivalent to
+ `pack(fill = BOTH, expand = True)`
+
+ FYI: `grid.forget()` method has potential memory leak; use sparingly!
"""
- # get maximum column and row spans for frm_widgets
+ # do main layout
+ self.frm_banner.grid(column=0, row=0, columnspan=2, sticky=EW)
+ self.frm_widgets.grid(column=0, row=1, sticky=NSEW)
+ if isinstance(self.frm_settings, SettingsFrame): # docked
+ if self.configuration.get("showsettings_b"):
+ self.frm_settings.grid(column=1, row=1, sticky=NW)
+ else:
+ if self.frm_settings.winfo_ismapped():
+ self.frm_settings.grid_forget()
+ self.frm_status.grid(column=0, row=2, columnspan=2, sticky=EW)
+
+ # get overall column and row spans for frm_widgets
+ maxcols = self.configuration.get("maxcolumns_n")
+ wcolspan = 0
+ wrowspan = 1
cols = 0
- maxcols = 1
- maxrows = 0
- for wdg in self.widget_state.state.values():
- if wdg[VISIBLE]:
- if cols == 0:
- maxrows += 1
- cols += wdg.get(COLSPAN, 1)
- if cols > self.configuration.get("maxcolumns_n"):
- cols = 0
- maxrows += 1
- maxcols = max(cols, maxcols)
+ wids = [
+ wdg.get(COLSPAN, 1)
+ for wdg in self.widget_state.state.values()
+ if wdg[VISIBLE]
+ ]
+ for c in wids:
+ if c == MAXSPAN or c + cols > maxcols:
+ wrowspan += 2 if c == MAXSPAN and cols > 0 else 1
+ cols = 0
+ cols += c
+ wcolspan = max(wcolspan, cols)
+ wcolspan = max(1, wcolspan)
# dynamically position widgets in frm_widgets
col = 0
- row = 1
+ row = 0
men = 2
for name, wdg in self.widget_state.state.items():
frm = getattr(self, wdg[FRAME])
if wdg[VISIBLE]:
- # enable any GNSS data required by widget
+ lbl = HIDE
+ # enable any GNSS message output required by widget
self.widget_enable_messages(name)
- cols = (
- maxcols if wdg.get(COLSPAN, 1) == MAXSPAN else wdg.get(COLSPAN, 1)
- )
- frm.grid(column=col, row=row, columnspan=cols, sticky=NSEW)
- col += cols
- if col >= maxcols:
+ c = wdg.get(COLSPAN, 1)
+ cols = wcolspan if c == MAXSPAN else c
+ # only grid if position has changed
+ frmi = frm.grid_info()
+ if (
+ frmi.get("column", None) != col
+ or frmi.get("row", None) != row
+ or frmi.get("columnspan", None) != cols
+ ):
+ frm.grid(column=col, row=row, columnspan=cols, sticky=NSEW)
+ if c == MAXSPAN or c + col >= maxcols:
col = 0
row += 1
- lbl = HIDE
+ else:
+ col += c
else:
- frm.grid_forget()
lbl = SHOW
- # update menu label (show/hide)
+ # only forget if gridded (memory leak!)
+ if frm.winfo_ismapped():
+ frm.grid_forget()
+
+ # update View menu label (show/hide)
self.menu.view_menu.entryconfig(men, label=f"{lbl} {name}")
men += 1
- # do main layout
- self.frm_banner.grid(column=0, row=0, columnspan=maxcols + 1, sticky=EW)
- self.frm_widgets.grid(
- column=0, row=1, columnspan=maxcols, rowspan=maxrows, sticky=NSEW
- )
- if isinstance(self.frm_settings, SettingsFrame): # docked
- if self.configuration.get("showsettings_b"):
- self.frm_settings.grid(
- column=maxcols, row=1, rowspan=maxrows, sticky=NW
- )
- else:
- self.frm_settings.grid_forget()
- self.frm_status.grid(
- column=0, row=maxrows + 1, columnspan=maxcols + 1, sticky=EW
- )
- # update settings menu labels (dock/undock, show/hide)
+ # update View menu labels for Settings (dock/undock, show/hide)
lbl = "Undock" if self.configuration.get("docksettings_b") else "Dock"
self.menu.view_menu.entryconfig(0, label=f"{lbl} Settings")
lbl = HIDE if self.configuration.get("showsettings_b") else SHOW
self.menu.view_menu.entryconfig(1, label=f"{lbl} Settings")
- # set 'pack' behaviour of main layout
- for frm in (self, self.__master, self.frm_widgets):
- for col in range(maxcols):
- frm.grid_columnconfigure(col, weight=1)
- for col in range(maxcols, self.configuration.get("maxcolumns_n") + 1):
- frm.grid_columnconfigure(col, weight=0)
- for row in range(1, maxrows + 1):
- frm.grid_rowconfigure(row, weight=1)
- for row in range(maxrows + 1, MAXROWSPAN + 1):
- frm.grid_rowconfigure(row, weight=0)
+ # set column and row weights to control 'pack' behaviour of main layout
+ self.__master.grid_columnconfigure(0, weight=1)
+ self.__master.grid_rowconfigure(1, weight=1)
+ wcol, wrow = self.frm_widgets.grid_size()
+ for col in range(wcol):
+ w = 1 if col < wcolspan else 0
+ self.frm_widgets.grid_columnconfigure(col, weight=w)
+ for row in range(wrow):
+ w = 1 if row < wrowspan else 0
+ self.frm_widgets.grid_rowconfigure(row, weight=w)
+
+ def _attach_events(self):
+ """
+ Bind events to main application.
+ """
+
+ self.__master.bind(GNSS_EVENT, self.on_gnss_read)
+ self.__master.bind(GNSS_EOF_EVENT, self.on_gnss_eof)
+ self.__master.bind(GNSS_TIMEOUT_EVENT, self.on_gnss_timeout)
+ self.__master.bind(GNSS_ERR_EVENT, self.on_stream_error)
+ self.__master.bind(NTRIP_EVENT, self.on_ntrip_read)
+ self.__master.bind(SPARTN_EVENT, self.on_spartn_read)
+ self.__master.bind_all("", self.on_exit)
+ self.__master.bind_all("", self.on_killswitch)
+ # also bound in check_updates
def settings_toggle(self):
"""
@@ -446,20 +495,6 @@ def reset_gnssstatus(self):
self.gnss_status = GNSSStatus()
- def _attach_events(self):
- """
- Bind events to main application.
- """
-
- self.__master.bind(GNSS_EVENT, self.on_gnss_read)
- self.__master.bind(GNSS_EOF_EVENT, self.on_gnss_eof)
- self.__master.bind(GNSS_TIMEOUT_EVENT, self.on_gnss_timeout)
- self.__master.bind(GNSS_ERR_EVENT, self.on_stream_error)
- self.__master.bind(NTRIP_EVENT, self.on_ntrip_read)
- self.__master.bind(SPARTN_EVENT, self.on_spartn_read)
- self.__master.bind_all("", self.on_exit)
- self.__master.bind_all("", self.on_killswitch)
-
def _set_default_fonts(self):
"""
Set default fonts for entire application.
@@ -513,9 +548,14 @@ def save_config(self):
Save configuration file menu option.
"""
+ # save current screen geometry
+ self.configuration.set("screengeom_s", self.__master.geometry())
+
err = self.configuration.savefile()
if err == "":
self.status_label = (SAVECONFIGOK, OKCOL)
+ elif err == "cancelled":
+ pass
else: # save failed
self.status_label = (SAVECONFIGBAD.format(err), ERRCOL)
@@ -976,7 +1016,14 @@ def _check_update(self):
latest = check_latest(TITLE)
if latest not in (VERSION, "N/A"):
- self.status_label = (f"{VERCHECK} {latest}", ERRCOL)
+ shortcut = "" if brew_installed() else " CTRL-U to update."
+ self.status_label = (
+ VERCHECK.format(title=TITLE, version=latest, shortcut=shortcut),
+ ERRCOL,
+ )
+ self.__master.bind_all("", self.do_app_update)
+ else:
+ self.__master.unbind("")
def poll_version(self, protocol: int):
"""
@@ -1114,6 +1161,11 @@ def conn_status(self, status: int):
self.frm_settings.frm_settings.enable_controls(status)
if status == DISCONNECTED:
self.conn_label = (NOTCONN, INFOCOL)
+ elif status in (CONNECTED, CONNECTED_SOCKET):
+ for name, wdg in self.widget_state.state.items():
+ if wdg[VISIBLE]:
+ # enable any GNSS message output required by widget
+ self.widget_enable_messages(name)
@property
def server_status(self) -> int:
@@ -1229,18 +1281,25 @@ def db_enabled(self) -> int | str:
return self._db_enabled
- def do_app_update(self, updates: list) -> int:
+ def do_app_update(self, *args, **kwargs) -> int:
"""
Update outdated application packages to latest versions.
NB: Some platforms (e.g. Homebrew-installed Python environments)
may block Python subprocess calls ('run') on security grounds.
- :param list updates: list of packages to be updated
:return: return code 0 = error, 1 = OK
:rtype: int
"""
+ if brew_installed():
+ self.status_label = (BREWUPDATE, INFOCOL)
+ return 0
+
+ self.status_label = (UPDATEINPROG, INFOCOL)
+ updates = [
+ nam for (nam, current, latest) in check_for_updates() if latest != current
+ ]
if len(updates) < 1:
return 1
@@ -1259,15 +1318,17 @@ def do_app_update(self, updates: list) -> int:
"install",
"--upgrade",
]
- for pkg in updates:
- cmd.append(pkg)
+ for name in updates:
+ cmd.append(name)
result = None
try:
self.logger.debug(f"{executable=} {pth=} {cmd=}")
result = run(cmd, check=True, capture_output=True)
+ self.status_label = (UPDATERESTART, OKCOL)
self.logger.debug(result.stdout)
return 1
- except CalledProcessError:
+ except CalledProcessError as err:
+ self.status_label = (UPDATEERR.format(err=err), ERRCOL)
self.logger.error(result.stdout)
return 0
diff --git a/src/pygpsclient/configuration.py b/src/pygpsclient/configuration.py
index eab4253..95a7666 100644
--- a/src/pygpsclient/configuration.py
+++ b/src/pygpsclient/configuration.py
@@ -101,10 +101,11 @@ def __init__(self, app):
# Set initial default configuration
self._settings = {
"version_s": version,
+ "screengeom_s": "", # screen geometry in tkinter format f"{w}x{h}+{x}+{y}"
"showsettings_b": 1,
"docksettings_b": 1,
**self.widget_config,
- "checkforupdate_b": 0,
+ "checkforupdate_b": 1,
"transient_dialog_b": 1, # whether pop-up dialogs are on top of main app window
"guiupdateinterval_f": GUI_UPDATE_INTERVAL, # GUI widget update interval in seconds
"mapupdateinterval_n": MAP_UPDATE_INTERVAL,
diff --git a/src/pygpsclient/dynamic_config_frame.py b/src/pygpsclient/dynamic_config_frame.py
index d9bc529..98a3d6c 100644
--- a/src/pygpsclient/dynamic_config_frame.py
+++ b/src/pygpsclient/dynamic_config_frame.py
@@ -266,7 +266,7 @@ def _do_layout(self):
column=0, row=15, columnspan=4, rowspan=15, sticky=EW
)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/file_handler.py b/src/pygpsclient/file_handler.py
index 8a1fe43..5f1e68b 100644
--- a/src/pygpsclient/file_handler.py
+++ b/src/pygpsclient/file_handler.py
@@ -203,7 +203,7 @@ def save_config(self, config: dict, filename: Path = CONFIGFILE) -> str:
),
)
if filename in ((), ""):
- return None # User cancelled
+ return "cancelled" # User cancelled
with open(filename, "w", encoding="utf-8") as file:
cfgstr = json.dumps(config)
diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py
index f845ab4..db2194b 100644
--- a/src/pygpsclient/globals.py
+++ b/src/pygpsclient/globals.py
@@ -207,6 +207,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs):
LIN = "Linux"
MAC = "Darwin"
MAPAPI_URL = "https://developer.mapquest.com/user/login/sign-up"
+MAINSCALE = 0.75 # initial size of main window relative to screen size
MAX_SNR = 60 # upper limit of levelsview CNo axis
MAXFLOAT = 2e20
MAXLOGSIZE = 10485760 # maximum size of individual log file in bytes
diff --git a/src/pygpsclient/hardware_info_frame.py b/src/pygpsclient/hardware_info_frame.py
index aa15637..300980e 100644
--- a/src/pygpsclient/hardware_info_frame.py
+++ b/src/pygpsclient/hardware_info_frame.py
@@ -92,7 +92,7 @@ def _do_layout(self):
self._lbl_gnssl.grid(column=0, row=2, columnspan=1, padx=2, sticky=W)
self._lbl_gnss.grid(column=1, row=2, columnspan=4, padx=2, sticky=W)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/helpers.py b/src/pygpsclient/helpers.py
index 6c20c1e..bfd983d 100644
--- a/src/pygpsclient/helpers.py
+++ b/src/pygpsclient/helpers.py
@@ -39,7 +39,13 @@
)
from typing import Literal
+from pygnssutils import version as PGVERSION
from pynmeagps import WGS84_SMAJ_AXIS, NMEAMessage, haversine
+from pynmeagps import version as NMEAVERSION
+from pyqgc import version as QGCVERSION
+from pyrtcm import version as RTCMVERSION
+from pysbf2 import version as SBFVERSION
+from pyspartn import version as SPARTNVERSION
from pyubx2 import (
SET,
SET_LAYER_RAM,
@@ -50,8 +56,10 @@
attsiz,
atttyp,
)
+from pyubx2 import version as UBXVERSION
from requests import get
+from pygpsclient._version import __version__ as VERSION
from pygpsclient.globals import (
BSR,
ERRCOL,
@@ -92,6 +100,16 @@
# validation type flags
MAXPORT = 65535
MAXALT = 10000.0 # meters arbitrary
+LIBVERSIONS = {
+ "PyGPSClient": VERSION,
+ "pygnssutils": PGVERSION,
+ "pyubx2": UBXVERSION,
+ "pysbf2": SBFVERSION,
+ "pyqgc": QGCVERSION,
+ "pynmeagps": NMEAVERSION,
+ "pyrtcm": RTCMVERSION,
+ "pyspartn": SPARTNVERSION,
+}
def validate(self: Entry, valmode: int, low=MINFLOAT, high=MAXFLOAT) -> bool:
@@ -277,6 +295,20 @@ def check_latest(name: str) -> str:
return NA
+def check_for_updates() -> list[tuple[str, str, str]]:
+ """
+ Check for updates.
+
+ :return: list of module name, current and latest version
+ :rtype: list[tuple[str,str,str]]
+ """
+
+ updates = []
+ for nam, current in LIBVERSIONS.items():
+ updates.append((nam, current, check_latest(nam)))
+ return updates
+
+
def check_lowres(master: Tk, dim: tuple, overscan: float = OVERSCAN) -> tuple:
"""
Check if dialog dimensions exceed effective screen resolution.
@@ -1107,6 +1139,22 @@ def set_filename(fpath: str, mode: str, ext: str) -> tuple:
return filename, filepath
+def set_geom(window, scale: float = 1) -> str:
+ """
+ Set geometry string to size and centre window on screen.
+
+ :param window: window
+ :param float scale: scale window (relative to screen)
+ :return: formatted geometry string
+ :rtype: str
+ """
+
+ sh, sw = screenres(window)
+ w, h = [int(i * scale) for i in (sw, sh)]
+ x, y = [int((i / 2) - (j / 2)) for i, j in ((sw, w), (sh, h))]
+ return f"{w}x{h}+{x}+{y}"
+
+
def setubxrate(app: object, mid: str, rate: int = 1, prot: str = "UBX") -> UBXMessage:
"""
Set rate on specified UBX message on default port(s).
diff --git a/src/pygpsclient/levelsview_frame.py b/src/pygpsclient/levelsview_frame.py
index 2711a72..c7f24f7 100644
--- a/src/pygpsclient/levelsview_frame.py
+++ b/src/pygpsclient/levelsview_frame.py
@@ -188,7 +188,7 @@ def update_frame(self):
if cno == 0 and not show_unused:
continue
snr_y = int(cno) * (h - self._canvas.yoffb - 1) / MAX_SNR
- (_, ol_col) = GNSS_LIST[gnssId]
+ _, ol_col = GNSS_LIST[gnssId]
self._canvas.create_rectangle(
offset,
h - self._canvas.yoffb - 1,
diff --git a/src/pygpsclient/nmea_preset_frame.py b/src/pygpsclient/nmea_preset_frame.py
index aeb3743..b3cdd6a 100644
--- a/src/pygpsclient/nmea_preset_frame.py
+++ b/src/pygpsclient/nmea_preset_frame.py
@@ -130,7 +130,7 @@ def _do_layout(self):
column=3, row=2, padx=3, ipadx=3, ipady=3, sticky=EW
)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/recorder_dialog.py b/src/pygpsclient/recorder_dialog.py
index 3d16eed..32f3094 100644
--- a/src/pygpsclient/recorder_dialog.py
+++ b/src/pygpsclient/recorder_dialog.py
@@ -208,7 +208,7 @@ def _do_layout(self):
self._lbl_memory.grid(column=7, row=0, ipadx=3, ipady=3, sticky=W)
self._lbl_activity.grid(column=0, row=2, columnspan=7, padx=3, sticky=EW)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/rover_frame.py b/src/pygpsclient/rover_frame.py
index ba501a2..28f759c 100644
--- a/src/pygpsclient/rover_frame.py
+++ b/src/pygpsclient/rover_frame.py
@@ -264,7 +264,7 @@ def _store_track(
nth = 3
numpt = len(self.points)
if numpt > 0:
- (hdg_1, dis_1) = self.points[-1]
+ hdg_1, dis_1 = self.points[-1]
if round(hdg, dp) == round(hdg_1, dp) and round(dis, dp) == round(
dis_1, dp
):
diff --git a/src/pygpsclient/settings_dialog.py b/src/pygpsclient/settings_dialog.py
index dd38ca5..5032e56 100644
--- a/src/pygpsclient/settings_dialog.py
+++ b/src/pygpsclient/settings_dialog.py
@@ -12,7 +12,7 @@
:license: BSD 3-Clause
"""
-from tkinter import NSEW
+from tkinter import NSEW, Frame
from pygpsclient.settings_child_frame import SettingsChildFrame
from pygpsclient.strings import DLGTSETTINGS
@@ -24,7 +24,7 @@ class SettingsDialog(ToplevelDialog):
Settings frame class.
"""
- def __init__(self, app, *args, **kwargs):
+ def __init__(self, app: Frame, *args, **kwargs):
"""
Constructor.
diff --git a/src/pygpsclient/settings_frame.py b/src/pygpsclient/settings_frame.py
index e022620..6e99845 100644
--- a/src/pygpsclient/settings_frame.py
+++ b/src/pygpsclient/settings_frame.py
@@ -23,7 +23,7 @@ class SettingsFrame(Frame):
Settings frame class.
"""
- def __init__(self, app, *args, **kwargs):
+ def __init__(self, app: Frame, *args, **kwargs):
"""
Constructor.
@@ -65,7 +65,6 @@ def _body(self):
Set up frame and widgets.
"""
- self._frm_container.option_add("*Font", self.__app.font_sm)
self.frm_settings = SettingsChildFrame(self.__app, self._frm_container)
self.frm_serial = self.frm_settings.frm_serial
self.frm_socketclient = self.frm_settings.frm_socketclient
diff --git a/src/pygpsclient/signalsview_frame.py b/src/pygpsclient/signalsview_frame.py
index 02800ba..1aeaa50 100644
--- a/src/pygpsclient/signalsview_frame.py
+++ b/src/pygpsclient/signalsview_frame.py
@@ -311,7 +311,7 @@ def update_frame(self):
continue
sig = SIGID.get((gnssId, sigid), sigid)
snr_y = int(cno) * (h - self._canvas.yoffb - 1) / MAX_SNR
- (_, ol_col) = GNSS_LIST[gnssId]
+ _, ol_col = GNSS_LIST[gnssId]
prn = f"{int(prn):02}"
self._canvas.create_rectangle(
offset,
diff --git a/src/pygpsclient/skyview_frame.py b/src/pygpsclient/skyview_frame.py
index 27f5a18..6bf1579 100644
--- a/src/pygpsclient/skyview_frame.py
+++ b/src/pygpsclient/skyview_frame.py
@@ -121,7 +121,7 @@ def update_frame(self):
if cno == 0 and not show_unused:
continue
x, y = self._canvas.d2xy(int(azi), int(ele))
- (_, ol_col) = GNSS_LIST[gnssId]
+ _, ol_col = GNSS_LIST[gnssId]
prn = f"{int(prn):02}"
bg_col = snr2col(cno)
self._canvas.create_circle(
diff --git a/src/pygpsclient/spartn_gnss_frame.py b/src/pygpsclient/spartn_gnss_frame.py
index a996df0..25f3432 100644
--- a/src/pygpsclient/spartn_gnss_frame.py
+++ b/src/pygpsclient/spartn_gnss_frame.py
@@ -528,10 +528,10 @@ def _on_load_json(self):
scheme, host, port = spc.server.split(":")
self.__container.server = host.replace("//", "")
self.__container.clientid = spc.clientid
- (key, start, _) = spc.current_key
+ key, start, _ = spc.current_key
self._spartn_key1.set(key)
self._spartn_valdate1.set(start.strftime("%Y%m%d"))
- (key, start, _) = spc.next_key
+ key, start, _ = spc.next_key
self._spartn_key2.set(key)
self._spartn_valdate2.set(start.strftime("%Y%m%d"))
self.__container.status_label = (DLGJSONOK.format(jsonfile), OKCOL)
diff --git a/src/pygpsclient/spectrum_frame.py b/src/pygpsclient/spectrum_frame.py
index 01c1128..0849a06 100644
--- a/src/pygpsclient/spectrum_frame.py
+++ b/src/pygpsclient/spectrum_frame.py
@@ -466,7 +466,7 @@ def _get_limits(self, rfblocks: list) -> tuple:
# for each RF block in MON-SPAN message
for i, rfblock in enumerate(rfblocks):
- (spec, spn, res, ctr, pga) = rfblock
+ spec, spn, res, ctr, pga = rfblock
minhz = int(min(minhz, ctr - res * (spn / res) / 2))
maxhz = int(max(maxhz, ctr + res * (spn / res) / 2))
spanhz = []
diff --git a/src/pygpsclient/strings.py b/src/pygpsclient/strings.py
index 43a9fb4..47aad1c 100644
--- a/src/pygpsclient/strings.py
+++ b/src/pygpsclient/strings.py
@@ -34,6 +34,7 @@
# Message text
BADJSONERROR = "ERROR! Invalid metadata file"
BREWWARN = "Function unavailable under Homebrew"
+BREWUPDATE = "In-app update not available under Homebrew. Use terminal."
CONFIGBAD = "{} command rejected"
CONFIGERR = "Invalid configuration data"
CONFIGOK = "{} command accepted"
@@ -74,8 +75,11 @@
SETINITTXT = "Settings initialised"
STOPDATA = "Serial reader process stopped"
UBXPOLL = "Polling current UBX configuration..."
+UPDATEERR = "Error updating application {err}"
+UPDATEINPROG = "Updating application..."
+UPDATERESTART = "Application updated. Close and Restart"
VALERROR = "ERROR! Please correct highlighted entries"
-VERCHECK = f"Newer version of {TITLE} available:"
+VERCHECK = "Newer version of {title} available: {version}.{shortcut}"
WAITNMEADATA = "Waiting for data..."
WAITUBXDATA = "Waiting for data..."
diff --git a/src/pygpsclient/ubx_cfgval_frame.py b/src/pygpsclient/ubx_cfgval_frame.py
index 3e5c1ee..8c8b6d1 100644
--- a/src/pygpsclient/ubx_cfgval_frame.py
+++ b/src/pygpsclient/ubx_cfgval_frame.py
@@ -232,7 +232,7 @@ def _do_layout(self):
column=4, row=13, rowspan=2, ipadx=3, ipady=3, sticky=E
)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
@@ -303,7 +303,7 @@ def _on_select_parm(self, *args, **kwargs): # pylint: disable=unused-argument
idx = self._lbx_parm.curselection()
self._cfgval_keyname = self._lbx_parm.get(idx)
- (keyid, att) = cfgname2key(self._cfgval_keyname)
+ keyid, att = cfgname2key(self._cfgval_keyname)
self._cfgkeyid.set(hex(keyid))
self._cfgatt.set(att)
self._cfgval.set("")
diff --git a/src/pygpsclient/ubx_config_dialog.py b/src/pygpsclient/ubx_config_dialog.py
index 1dfd94a..495b1fb 100644
--- a/src/pygpsclient/ubx_config_dialog.py
+++ b/src/pygpsclient/ubx_config_dialog.py
@@ -155,7 +155,7 @@ def _do_layout(self):
row = 0
col += colsp
for frm in (self._frm_config_dynamic,):
- (colsp, rowsp) = frm.grid_size()
+ colsp, rowsp = frm.grid_size()
frm.grid(
column=col,
row=row,
diff --git a/src/pygpsclient/ubx_msgrate_frame.py b/src/pygpsclient/ubx_msgrate_frame.py
index d66d0cd..b5ce1d1 100644
--- a/src/pygpsclient/ubx_msgrate_frame.py
+++ b/src/pygpsclient/ubx_msgrate_frame.py
@@ -182,7 +182,7 @@ def _do_layout(self):
column=5, row=1, rowspan=6, ipadx=3, ipady=3, sticky=E
)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/ubx_port_frame.py b/src/pygpsclient/ubx_port_frame.py
index be3781b..d98e3ed 100644
--- a/src/pygpsclient/ubx_port_frame.py
+++ b/src/pygpsclient/ubx_port_frame.py
@@ -168,7 +168,7 @@ def _do_layout(self):
column=5, row=1, rowspan=2, ipadx=3, ipady=3, sticky=E
)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/ubx_preset_frame.py b/src/pygpsclient/ubx_preset_frame.py
index 2dadbfd..60c924e 100644
--- a/src/pygpsclient/ubx_preset_frame.py
+++ b/src/pygpsclient/ubx_preset_frame.py
@@ -128,7 +128,7 @@ def _do_layout(self):
self._btn_send_command.grid(column=3, row=1, ipadx=3, ipady=3, sticky=EW)
self._lbl_send_command.grid(column=3, row=3, ipadx=3, ipady=3, sticky=EW)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/src/pygpsclient/ubx_solrate_frame.py b/src/pygpsclient/ubx_solrate_frame.py
index cf6663c..6548633 100644
--- a/src/pygpsclient/ubx_solrate_frame.py
+++ b/src/pygpsclient/ubx_solrate_frame.py
@@ -138,7 +138,7 @@ def _do_layout(self):
column=5, row=1, rowspan=3, ipadx=3, ipady=3, sticky=E
)
- (cols, rows) = self.grid_size()
+ cols, rows = self.grid_size()
for i in range(cols):
self.grid_columnconfigure(i, weight=1)
for i in range(rows):
diff --git a/tests/test_static.py b/tests/test_static.py
index 027854a..1a544ef 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -131,7 +131,7 @@ def testconfiguration(self):
self.assertEqual(cfg.get("lbandclientdrat_n"), 2400)
self.assertEqual(cfg.get("userport_s"), "")
self.assertEqual(cfg.get("spartnport_s"), "")
- self.assertEqual(len(cfg.settings), 154) # 155)
+ self.assertEqual(len(cfg.settings), 155)
kwargs = {"userport": "/dev/ttyACM0", "spartnport": "/dev/ttyACM1"}
cfg.loadcli(**kwargs)
self.assertEqual(cfg.get("userport_s"), "/dev/ttyACM0")