diff --git a/INSTALLATION.md b/INSTALLATION.md index 36c21417..c78e7caf 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -41,7 +41,7 @@ There are two file formats in common use for distributing Python packages: By default, pip will look for wheel first, and only resort to using sdist where no wheel is available for the target platform. -For pure Python packages like PyGPSClient and its subsidiary [GNSS utilities](https://github.com/semuconsulting), which contain no non-Python extension modules, the differences between sdist and wheel distributions are to some extent academic. The distinction is, however, relevant for some of PyGPSClient's optional dependencies (e.g. `cryptography` and `rasterio`) - see [troubleshooting](#troubleshooting) for further details. +For pure Python packages like PyGPSClient and its subsidiary [GNSS utilities](https://github.com/semuconsulting), which contain no non-Python extension modules, the differences between sdist and wheel distributions are to some extent academic - a single pygpsclient `pygpsclient-*-none-any.whl` package can be installed on *any* target architecture. The distinction is, however, relevant for some of PyGPSClient's optional dependencies (e.g. `cryptography` and `rasterio`) - see [troubleshooting](#troubleshooting) for further details. ### site_packages and binaries directories diff --git a/README.md b/README.md index d5154b5b..cfc10a13 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ This is an independent project and we have no affiliation whatsoever with any GN *Screenshot showing mixed-protocol stream from u-blox ZED-F9P receiver, using PyGPSClient's [NTRIP Client](#ntripconfig) with a base station 26 km to the west to achieve better than 2 cm accuracy* +#### References + +1. [Glossary of GNSS Terms and Abbreviations](https://www.semuconsulting.com/gnsswiki/glossary/). +1. [GNSS Positioning - A Reviser](https://www.semuconsulting.com/gnsswiki/) - a general overview of GNSS, OSR, SSR, RTK, NTRIP and SPARTN positioning and error correction technologies and terminology. +1. [Achieving cm Level GNSS Accuracy using RTK](https://www.semuconsulting.com/gnsswiki/rtktips/) - practical tips on high precision RTK using PyGPSClient. +1. From time to time, instructional videos may be posted to the [semuadmin YouTube channel](https://www.youtube.com/@semuadmin). +1. [Sphinx API Documentation](https://www.semuconsulting.com/pygpsclient) in HTML format. + --- ## Current Status @@ -46,13 +54,7 @@ This is an independent project and we have no affiliation whatsoever with any GN ![Contributors](https://img.shields.io/github/contributors/semuconsulting/PyGPSClient.svg) ![Open Issues](https://img.shields.io/github/issues-raw/semuconsulting/PyGPSClient) - -The PyGPSClient home page is at [PyGPSClient](https://github.com/semuconsulting/PyGPSClient). The following references may be useful: -1. [Glossary of GNSS Terms and Abbreviations](https://www.semuconsulting.com/gnsswiki/glossary/). -1. [GNSS Positioning - A Reviser](https://www.semuconsulting.com/gnsswiki/) - a general overview of GNSS, OSR, SSR, RTK, NTRIP and SPARTN positioning and error correction technologies and terminology. -1. [Achieving cm Level GNSS Accuracy using RTK](https://www.semuconsulting.com/gnsswiki/rtktips/) - practical tips on high precision RTK using PyGPSClient. -1. [Sphinx API Documentation](https://www.semuconsulting.com/pygpsclient) in HTML format. -1. From time to time, instructional videos may be posted to the [semuadmin YouTube channel](https://www.youtube.com/@semuadmin). +The PyGPSClient home page is at [PyGPSClient](https://github.com/semuconsulting/PyGPSClient). Contributions welcome - please refer to [CONTRIBUTING.MD](https://github.com/semuconsulting/PyGPSClient/blob/master/CONTRIBUTING.md). @@ -116,8 +118,8 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. 1. File Delay - Select delay in milliseconds between individual reads when streaming from binary file (default 20 milliseconds). 1. Tags - Enable color tags in console (see Console Widget below). 1. Position Format and Units - Change the displayed position (D.DD / D.M.S / D.M.MM / ECEF) and unit (metric/imperial) formats. -1. Show Unused Satellites - Include or exclude satellites that are not used in the navigation solution (e.g. because their signal level is too low) from the graph and sky view panels. -1. DataLogging - Turn Data logging in the selected format on or off. On first selection, you will be prompted to select the directory into which timestamped log files are saved. +1. Include C/N0 = 0 - Include or exclude satellites where carrier to noise ratio (C/N0) = 0. +1. DataLogging - Turn Data logging in the selected format (Binary, Parsed, Hex Tabular, Hex String, Parsed+Hex Tabular) on or off. On first selection, you will be prompted to select the directory into which timestamped log files are saved. Log files are cycled when a maximum size is reached (default is 10 MB, manually configurable via `logsize_n` setting). 1. GPX Track - Turn track recording (in GPX format) on or off. On first selection, you will be prompted to select the directory into which timestamped GPX track files are saved. 1. Database - Turn spatialite database recording (*where available*) on or off. On first selection, you will be prompted to select the directory into which the `pygpsclient.sqlite` database is saved. Note that, when first created, the database's spatial metadata will take a few seconds to initialise (*up to a minute on Raspberry Pi and similar SBC*). **NB** This facility is dependent on your Python environment supporting the requisite [sqlite3 `mod_spatialite` extension](https://www.gaia-gis.it/fossil/libspatialite/index) - see [INSTALLATION.md](https://github.com/semuconsulting/PyGPSClient/blob/master/INSTALLATION.md#prereqs) for further details. If not supported, the option will be greyed out. Check the Menu..Help..About dialog for an indication of the current spatialite support status. @@ -127,10 +129,10 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. **NB** Any active serial or RTK connection must be stopped before loading a new configuration. 1. [Socket Server / NTRIP Caster](#socketserver) facility with two modes of operation: (a) open, unauthenticated Socket Server or (b) NTRIP Caster (mountpoint = `pygnssutils`). 1. [UBX Configuration Dialog](#ubxconfig), with the ability to send a variety of UBX CFG configuration commands to u-blox GNSS devices. This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the UBX Configuration Dialog (*only functional when connected to a UBX GNSS device via serial port*), click -![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24.png?raw=true), or go to Menu..Options..UBX Configuration Dialog. -1. [NMEA Configuration Dialog](#nmeaconfig), with the ability to send a variety of NMEA configuration commands to GNSS devices (e.g. Quectel LG290P). This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the NMEA Configuration Dialog (*only functional when connected to a compatible GNSS device via serial port*), click ![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-brown.png?raw=true), or go to Menu..Options..NMEA Configuration Dialog. +![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-ubx.png?raw=true), or go to Menu..Options..UBX Configuration Dialog. +1. [NMEA Configuration Dialog](#nmeaconfig), with the ability to send a variety of NMEA configuration commands to GNSS devices (e.g. Quectel LG290P). This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the NMEA Configuration Dialog (*only functional when connected to a compatible GNSS device via serial port*), click ![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-nmea.png?raw=true), or go to Menu..Options..NMEA Configuration Dialog. 1. [TTY Config Dialog](#ttycommands), with the ability to send a variety of TTY (ASCII) configuration commands to GNSS and related devices (e.g. Septentrio X5, Feyman IM19). This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the TTY Commands Dialog (*only functional when connected to a compatible GNSS device via serial port*), click -![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/icon-tty-24-green.png?raw=true), or go to Menu..Options..TTY Commands. +![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-tty.png?raw=true), or go to Menu..Options..TTY Commands. 1. [NTRIP Client](#ntripconfig) facility with the ability to connect to a specified NTRIP caster, parse the incoming RTCM3 or SPARTN data and feed this data to a compatible GNSS receiver (*requires an Internet connection and access to an NTRIP caster and local mountpoint*). To display the NTRIP Client Configuration Dialog, click ![ntrip icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-antenna-4-24.png?raw=true), or go to Menu..Options..NTRIP Configuration Dialog. 1. [SPARTN Client](#spartnconfig) facility with the ability to configure an IP or L-Band SPARTN Correction source and SPARTN-compatible GNSS receiver (e.g. ZED-F9P) and pass the incoming correction data to the GNSS receiver (*requires an Internet connection and access to a SPARTN location service*). To display the SPARTN Client Configuration Dialog, go to Menu..Options..SPARTN Configuration Dialog. @@ -138,7 +140,7 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. #### 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 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. 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. #### Checking for the latest version @@ -150,20 +152,20 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. #### Transient dialog setting -- A boolean configuration setting `transient_dialog_b` governs whether pop-up dialogs are 'transient' (i.e. always on top of main application dialog) or not. The default setting of `0` allows pop-up 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**. +- A boolean configuration setting `transient_dialog_b` governs whether pop-up dialogs are 'transient' (i.e. always on top of main application dialog) or not. Changing this setting to `0` allows pop-up 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 transient windows. --- | User-selectable 'widgets' | To show or hide the various widgets, go to Menu..View and click on the relevant hide/show option. | |---------------------------|---------------------------------------------------------------------------------------------------| -|![banner widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/banner_widget.png?raw=true)| Expandable banner showing key navigation status information based on messages received from receiver. To expand or collapse the banner or serial port configuration widgets, click the ![expand icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-80-16.png?raw=true)/![expand icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-triangle-1-16.png?raw=true) buttons. **NB**: some fields (e.g. hdop/vdop, hacc/vacc) are only available from proprietary NMEA or UBX messages and may not be output by default. The minimum messages required to populate all available fields are: NMEA: GGA, GSA, GSV, RMC, UBX00 (proprietary); UBX: NAV-DOP, NAV-PVT, NAV_SAT | +|![banner widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/banner_widget.png?raw=true)| Expandable banner showing key navigation status information based on messages received from receiver. To expand or collapse the banner or serial port configuration widgets, click the ![expand icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-80-16.png?raw=true)/![expand icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-triangle-1-16.png?raw=true) buttons. **NB**: some fields (e.g. hdop/vdop, hacc/vacc) are only available from proprietary NMEA or UBX messages and may not be output by default. The minimum messages required to populate all available fields are: NMEA: GGA, GSA, GSV, RMC, UBX00 (proprietary); UBX: NAV-DOP, NAV-PVT, NAV-SAT | |![console widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/console_widget.png?raw=true)| Configurable serial console widget showing incoming GNSS data streams in either parsed, binary or tabular hexadecimal formats. Double-right-click to copy contents of console to the clipboard. The scroll behaviour and number of lines retained in the console can be configured via the settings panel. Supports user-configurable color tagging of selected strings for easy identification. Color tags are loaded from the `"colortag_b":` value (`0` = disable, `1` = enable) and `"colortags_l":` list (`[string, color]` pairs) in your json configuration file (see example provided). If color is set to "HALT", streaming will halt on any match and a warning displayed. NB: color tagging does impose a small performance overhead - turning it off will improve console response times at very high transaction rates.| |![skyview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/skyview_widget.png?raw=true)| Skyview widget showing current satellite visibility and position (elevation / azimuth). Satellite icon borders are colour-coded to distinguish between different GNSS constellations. For consistency between NMEA and UBX data sources, will display GLONASS NMEA SVID (65-96) rather than slot (1-24). | -|![levelsview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/graphview_widget.png?raw=true)| Levelsview widget showing current satellite carrier-to-noise (CNo) levels for each GNSS constellation. Double-click to toggle legend. | +|![levelsview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/graphview_widget.png?raw=true)| Levels view widget showing current satellite carrier-to-noise (CNo) levels for each GNSS constellation. Double-click to toggle legend. | |![world map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/staticmap.png?raw=true)| Map widget with various modes of display - select from "map" / "sat" (online) or "world" / "custom" (offline). Select zoom level 1 - 20. Double-click the zoom level label to reset the zoom to 10. Double-right-click the zoom label to maximise zoom to 20. Tick Track to show track (track will only be recorded while this box is checked). Double-Right-click will clear the map. Map Type = 'world': a static offline Mercator world map showing current global location. |![online map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/webmap_widget.png?raw=true)| Map Type = 'map', 'sat' or 'hyb' (hybrid): Dynamic, online web map or satellite image via MapQuest API (*requires an Internet connection and free [Mapquest API Key](#mapquestapi)*). By default, the web map will automatically refresh every 60 seconds (*indicated by a small timer icon at the top left*). The default refresh rate can be amended by changing the `"mapupdateinterval_n":` value in your json configuration file, but **NB** the facility is not intended to be used for real-time navigation. Double-click anywhere in the map to immediately refresh. | -|![offline map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/custommap.png?raw=true)| Map Type = 'custom': One or more custom geo-referenced offline maps can be imported using the Menu..Options..Import Custom Map facility, or by manually setting the `usermaps_l` field in the json configuration file. The `usermaps_l` setting represents a list of map paths and bounding boxes in the format ["path to map image", [minlat, minlon, maxlat, maxlon]] - see [example configuration file](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L281). Map images must be a [supported format](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html) and use a standard WGS84 Web Mercator projection e.g. EPSG:4326. PyGPSClient will automatically select the first image whose extents encompass the current location, based on the order in which the maps appear in `usermaps_l`. NB: The minimum and maximum viable 'zoom' levels depend on the resolution and extents of the imported image and the user's display - if the zoom bounds exceed the image extents, the Zoom spinbox will be highlighted. | -|![import custom map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/importcustommap.png?raw=true)| Import Custom Map dialog. Click ![load icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-folder-18-24.png?raw=true) to open the custom map image location (*the default file suffix is `*.tif` - select Show Options to select any file suffix `*.*`*). If the `rasterio` library is installed and the image is geo-referenced (e.g. using [QGIS](https://qgis.org/)), the map extents will be automatically extracted - otherwise they must be entered manually. Import the custom map path and extent settings by clicking ![play icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-12-24.png?raw=true). By default, the imported map will be appended to the existing list - click 'First?' to insert the map at the top of the list instead. See [Creating Custom Maps for PyGPSClient](https://www.semuconsulting.com/gnsswiki/custommapwiki/) for tips on how to create a suitable georeferenced map image.| -|![spectrum widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/spectrum_widget.png?raw=true)| Spectrum widget showing a spectrum analysis chart (*GNSS receiver must be capable of outputting UBX MON-SPAN messages*). Clicking anywhere in the spectrum chart will display the frequency and decibel reading at that point. Double-clicking anywhere in the chart will toggle the GNSS frequency band markers (L1, G2, etc.) on or off. Right-click anywhere in the chart to capture a snapshot of the spectrum data, which will then be superimposed on the live data. Double-right-click to clear snapshot. **NB:** Some receivers (e.g. NEO-F10N) will not output the requisite MON-SPAN messages unless the port baud rate is at least 57,600. | +|![offline map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/custommap.png?raw=true)| Map Type = 'custom': One or more user-defined offline geo-referenced map images can be imported using the Menu..Options..Import Custom Map facility, or by manually setting the `usermaps_l` field in the json configuration file. The `usermaps_l` setting represents a list of map paths and extents in the format ["path to map image", [minlat, minlon, maxlat, maxlon]] - see [example configuration file](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L281). Map images must be a [supported format](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html) and use a standard WGS84 Web Mercator projection e.g. EPSG:4326. PyGPSClient will automatically select the first map whose extents encompass the current location, based on the order in which the maps appear in `usermaps_l`. NB: The minimum and maximum viable 'zoom' levels depend on the resolution and extents of the imported image and the user's display - if the zoom bounds exceed the image extents, the Zoom spinbox will be highlighted. Offline and online zoom levels will not necessarily correspond. | +|![import custom map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/importcustommap.png?raw=true)| Import Custom Map dialog. Click ![load icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-folder-18-24.png?raw=true) to open the custom map image location (*the default file suffix is `*.tif` - select Show Options to select any file suffix `*.*`*). If the `rasterio` library is installed and the image is geo-referenced (e.g. using [QGIS](https://qgis.org/)), the map extents will be automatically extracted - otherwise they must be entered manually. Import the custom map path and extent settings by clicking ![play icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-12-24.png?raw=true). By default, the imported map will be appended to the existing list - click 'First?' to insert the map at the top of the list instead. See [Creating Custom Maps for PyGPSClient](https://www.semuconsulting.com/gnsswiki/custommapwiki/) for tips on how to create a suitable geo-referenced map image.| +|![spectrum widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/spectrum_widget.png?raw=true)| Spectrum widget showing a spectrum analysis chart (*GNSS receiver must be capable of outputting UBX MON-SPAN messages*). Clicking anywhere in the spectrum chart will display the frequency and decibel reading at that point. Double-clicking anywhere in the chart will toggle the GNSS frequency band markers (L1, G2, etc.) on or off. Right-click anywhere in the chart to capture a snapshot of the spectrum data, which will then be superimposed on the live data (*this can, for example, be used to compare reception with different antenna configurations*). Double-right-click to clear snapshot. **NB:** Some receivers (e.g. NEO-F10N) will not output the requisite MON-SPAN messages unless the port baud rate is at least 57,600. | |![sysmon widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/sysmon_widget.png?raw=true)| System Monitor widget showing device cpu, memory and I/O utilisation (*GNSS receiver must be capable of outputting UBX MON-SYS/MON-COMMS or SBF ReceiverStatus messages*). Tick checkbox to toggle between actual (cumulative) I/O stats and pending I/O. Primarily intended for u-blox modules, but can display limited system information for other devices. | |![scatterplot widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/scatterplot_widget.png?raw=true)| Scatterplot widget showing variability in position reporting over time. (Optional) Enter fixed reference position. Select Average to center plot on dynamic average position (*displayed at top left*), or Fixed to center on fixed reference position (*if entered*). Check Autorange to set plot range automatically. Set the update interval (e.g. 4 = every 4th navigation solution). Use the range slider or mouse wheel to adjust plot range. Right-click to set fixed reference point to the current mouse cursor position. Double-click to clear the existing data. | |![rover widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/rover_widget.png?raw=true) | Rover widget plots the relative 2D position, track and status information for the roving receiver in a fixed or moving base / rover RTK configuration. Can also display relative position of NTRIP mountpoint and receiver in a static RTK configuration. Double-click to clear existing plot. | @@ -413,7 +415,7 @@ Configure NAV-STATUS Message Rate on ZED-F9P, CFG, CFG-MSG, 0103000100000000, 1 Configure UART baud rate on LG290P; P; QTMCFGUART; W,460800; 1 ``` -Multiple commands can be concatenated on a single line. Illustrative examples are shown in the sample [pygpsclient.json](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L174) file. +Multiple commands can be concatenated on a single line. Illustrative examples are shown in the sample [pygpsclient.json](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L188) file. --- ## Command Line Utilities diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 522e27be..642f9ffa 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,17 @@ # PyGPSClient Release Notes +### RELEASE 1.5.22 + +FIXES: + +1. Fix KeyError in UBX Legacy Dynamic Config Panel https://github.com/semuconsulting/PyGPSClient/issues/227 + +ENHANCEMENTS: + +1. Make maximum individual data log file size (in bytes) manually configurable via `logsize_n` setting in json configuration file. Default is 10 MB. +1. Tolerate unrecognised configuration settings in json file with warning "Consider re-saving" (previously json file would have been rejected). Unrecognised settings will be logged as INFO messages (--verbosity 2). +1. Internal enhancements to thread handling to improve dialog response at high transaction rates. + ### RELEASE 1.5.21 FIXES: diff --git a/images/app.png b/images/app.png index b346355e..b40123df 100644 Binary files a/images/app.png and b/images/app.png differ diff --git a/pygpsclient.json b/pygpsclient.json index 653e72a4..c23eba3b 100644 --- a/pygpsclient.json +++ b/pygpsclient.json @@ -1,5 +1,5 @@ { - "version_s": "1.5.19", + "version_s": "1.5.21", "Banner": true, "Settings": true, "Status": true, @@ -47,9 +47,10 @@ "showtrack_b": 0, "legend_b": 1, "unusedsat_b": 0, - "logformat_s": "Binary", "datalog_b": 0, + "logformat_s": "Binary", "logpath_s": "", + "logsize_n": 10485760, "recordtrack_b": 0, "trackpath_s": "", "database_b": 0, @@ -148,7 +149,6 @@ "scatterlon_f": -115.81513 }, "imusettings_d": { - "source_s": "ESF-ALG", "range_n": 30, "option_s": "N/A" }, diff --git a/pyproject.toml b/pyproject.toml index 863bc0b2..b5c333c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,11 +49,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: GIS", ] -dependencies = [ - "requests>=2.28.0", - "Pillow>=9.0.0", - "pygnssutils>=1.1.20" -] +dependencies = ["requests>=2.28.0", "Pillow>=9.0.0", "pygnssutils>=1.1.20"] [project.scripts] pygpsclient = "pygpsclient.__main__:main" @@ -65,10 +61,7 @@ repository = "https://github.com/semuconsulting/PyGPSClient" changelog = "https://github.com/semuconsulting/PyGPSClient/blob/master/RELEASE_NOTES.md" [dependency-groups] -optional = [ - "rasterio", - "cryptography", -] +optional = ["rasterio", "cryptography"] build = [ "awscli", "build", @@ -141,7 +134,7 @@ disable = """ [tool.pytest.ini_options] minversion = "7.0" -addopts = "--cov --cov-report html --cov-fail-under 18" +addopts = "--cov --cov-report html --cov-fail-under 17" pythonpath = ["src"] testpaths = ["tests"] @@ -152,7 +145,7 @@ source = ["src"] source = ["src"] [tool.coverage.report] -fail_under = 18 +fail_under = 17 [tool.coverage.html] directory = "htmlcov" diff --git a/src/pygpsclient/__main__.py b/src/pygpsclient/__main__.py index 96460ef6..b8bd5b8b 100644 --- a/src/pygpsclient/__main__.py +++ b/src/pygpsclient/__main__.py @@ -1,5 +1,5 @@ """ -Entry point for PyGPSClient Application. +CLI Entry point for PyGPSClient Application. Created on 12 Sep 2020 diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py index b15456cd..3d6e45dc 100644 --- a/src/pygpsclient/_version.py +++ b/src/pygpsclient/_version.py @@ -8,4 +8,4 @@ :license: BSD 3-Clause """ -__version__ = "1.5.21" +__version__ = "1.5.22" diff --git a/src/pygpsclient/about_dialog.py b/src/pygpsclient/about_dialog.py index 3aaa5c0f..22d2c888 100644 --- a/src/pygpsclient/about_dialog.py +++ b/src/pygpsclient/about_dialog.py @@ -235,7 +235,7 @@ def _check_for_update(self, *args, **kwargs): # pylint: disable=unused-argument Check for updates. """ - self.set_status("") + self.status_label = "" self._updates = [] for i, (nam, current) in enumerate(LIBVERSIONS.items()): latest = check_latest(nam) @@ -280,4 +280,4 @@ def _brew_warning(self): Display warning that some functionality unavailable with Homebrew. """ - self.set_status(BREWWARN, INFOCOL) + self.status_label = (BREWWARN, INFOCOL) diff --git a/src/pygpsclient/app.py b/src/pygpsclient/app.py index a8b8a795..260735e1 100644 --- a/src/pygpsclient/app.py +++ b/src/pygpsclient/app.py @@ -3,13 +3,17 @@ PyGPSClient - Main tkinter application class. +Essentially the 'Model' in a nominal MVC (Model-View-Controller) +architecture. + - Loads configuration from json file (if available) - Instantiates all frames, widgets, and protocol handlers. -- Starts and stops threaded dialog and protocol handler processes. -- Maintains current serial and RTK connection status. -- Reacts to various message events, processes navigation data - placed on input message queue by serial, socket or file stream reader - and assigns to appropriate NMEA, UBX or RTCM protocol handler. +- Maintains state of all user-selectable widgets. +- Maintains state of all threaded dialog and protocol handler processes. +- Maintains state of serial and RTK connections. +- Handles event-driven data processing of navigation data placed on + input message queue by stream handler and assigns to appropriate + protocol handler. - Maintains central dictionary of current key navigation data as `gnss_status`, for use by user-selectable widgets. @@ -29,7 +33,7 @@ :license: BSD 3-Clause """ -# pylint: disable=too-many-ancestors, no-member +# pylint: disable=too-many-ancestors, no-member, too-many-lines import logging from datetime import datetime, timedelta @@ -39,7 +43,9 @@ from subprocess import CalledProcessError, run from sys import executable from threading import Thread -from tkinter import E, Frame, N, PhotoImage, S, Tk, Toplevel, W, font +from time import process_time_ns, time +from tkinter import NSEW, Frame, Label, PhotoImage, Tk, Toplevel, font +from types import NoneType from pygnssutils import GNSSMQTTClient, GNSSNTRIPClient, MQTTMessage from pygnssutils.gnssreader import ( @@ -64,7 +70,6 @@ from pygpsclient.dialog_state import DialogState from pygpsclient.file_handler import FileHandler from pygpsclient.globals import ( - CFG, CLASS, CONFIGFILE, CONNECTED, @@ -90,7 +95,6 @@ SPARTN_EVENT, SPARTN_PROTOCOL, STATUSPRIORITY, - THD, TTY_PROTOCOL, ) from pygpsclient.gnss_status import GNSSStatus @@ -155,6 +159,9 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements :param kwargs: optional (CLI) kwargs """ + self.starttime = time() # for run time benchmarking + self.processtime = 0 # for process time benchmarking + self.__master = master self.logger = logging.getLogger(__name__) @@ -195,8 +202,6 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self._last_gui_update = datetime.now() self._socket_thread = None self._socket_server = None - self._colcount = 0 - self._rowcount = 0 self.consoledata = [] # load config from json file @@ -206,8 +211,9 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self.configuration.loadcli(**kwargs) if configerr == "": self.update_widgets() # set initial widget state - if self._nowidgets: # if all widgets have been disabled in config - self.set_status(NOWDGSWARN.format(configfile), ERRCOL) + # 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") @@ -222,7 +228,7 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self._do_layout() self._attach_events() - # initialise widgets + # instantiate widgets for value in self.widget_state.state.values(): frm = getattr(self, value[FRAME]) if hasattr(frm, "init_frame"): @@ -232,7 +238,7 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements # display initial connection status self.frm_banner.update_conn_status(DISCONNECTED) if self.frm_settings.frm_serial.status == NOPORTS: - self.set_status(INTROTXTNOPORTS, ERRCOL) + self.status_label = (INTROTXTNOPORTS, ERRCOL) # check for more recent version (if enabled) if self.configuration.get("checkforupdate_b") and configerr == "": @@ -240,8 +246,7 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements # display any deferred messages if isinstance(self._deferredmsg, tuple): - msg, col = self._deferredmsg - self.set_status(msg, col) + self.status_label = self._deferredmsg self._deferredmsg = None def _body(self): @@ -253,7 +258,7 @@ def _body(self): self.menu = MenuBar(self) self.__master.config(menu=self.menu) - # instantiate widgets + # initialise widget state for value in self.widget_state.state.values(): setattr( self, @@ -265,6 +270,11 @@ def _do_layout(self): """ Arrange widgets in main application frame, and set widget visibility and menu label (show/hide). + + NB: PyGPSClient generally favours 'grid' rather than 'pack' + layout management throughout: + - grid weight = 0 means fixed, non-expandable + - grid weight > 0 means expandable """ col = 0 @@ -277,15 +287,10 @@ def _do_layout(self): name, col, row, maxcol, maxrow, men ) - # ensure widgets expand to size of container (needed - # when not using 'pack' grid management) - # weight = 0 means fixed, non-expandable - # weight > 0 means expandable for col in range(MAXCOLSPAN + 1): self.__master.grid_columnconfigure(col, weight=0) for row in range(MAXROWSPAN + 2): self.__master.grid_rowconfigure(row, weight=0) - # print(f"{maxcol=} {maxrow=}") for col in range(maxcol): self.__master.grid_columnconfigure(col, weight=5) for row in range(1, maxrow + 1): @@ -297,9 +302,9 @@ def _widget_grid( """ Arrange widgets and update menu label (show/hide). - Widgets with explicit COL settings will be placed in fixed - positions; widgets with no COL setting will be arranged - dynamically. + Widgets with explicit COL(umn) settings will be placed in fixed + positions; widgets with no COL(umn) setting will be arranged + dynamically (left to right, top to bottom). :param str name: name of widget :param int col: col @@ -330,7 +335,7 @@ def _widget_grid( rowspan=rowspan, padx=2, pady=2, - sticky=wdg.get(STICKY, (N, S, W, E)), + sticky=wdg.get(STICKY, NSEW), ) lbl = HIDE if dynamic: @@ -350,7 +355,7 @@ def _widget_grid( men += 1 # force widget to rescale - frm.event_generate("") + # frm.event_generate("") return col, row, maxcol, maxrow, men @@ -370,7 +375,7 @@ def widget_toggle(self, name: str): def widget_enable_messages(self, name: str): """ - Enable any NMEA, UBX or RTCM messages required by widget. + Enable any GNSS messages required by widget. :param str name: widget name """ @@ -392,7 +397,7 @@ def widget_reset(self): def reset_gnssstatus(self): """ - Reset gnss_status dict e.g. after reconnecting. + Reset gnss_status dictionary e.g. after reconnecting. """ self.gnss_status = GNSSStatus() @@ -423,44 +428,9 @@ def _set_default_fonts(self): self.font_md2 = font.Font(size=14) self.font_lg = font.Font(size=18) - def set_connection(self, message, color=OKCOL): - """ - Sets connection description in status bar. - - :param str message: message to be displayed in connection label - :param str color: rgb color string - - """ - - if hasattr(self, "frm_status"): - self.frm_status.set_connection(message, color=color) - - def set_status(self, message, color=OKCOL): - """ - Sets text of status bar, or defer if frm_status not yet instantiated. - - :param str message: message to be displayed in status label - :param str color: rgb color string - """ - - def priority(col): - return STATUSPRIORITY.get(col, 0) - - if hasattr(self, "frm_status"): - color = INFOCOL if color == "blue" else color - self.frm_status.set_status(message, color) - self.update_idletasks() - else: # defer message until frm_status is instantiated - if isinstance(self._deferredmsg, tuple): - defpty = priority(self._deferredmsg[1]) - else: - defpty = 0 - if priority(color) > defpty: - self._deferredmsg = (message, color) - def set_event(self, evt: str): """ - Generate event + Generate master event. :param str evt: event type string """ @@ -474,9 +444,9 @@ def load_config(self): # Warn if Streaming, NTRIP or SPARTN clients are running if self.conn_status == DISCONNECTED and self.rtk_conn_status == DISCONNECTED: - self.set_status("", OKCOL) + self.status_label = ("", OKCOL) else: - self.set_status(DLGSTOPRTK, ERRCOL) + self.status_label = (DLGSTOPRTK, ERRCOL) return filename, err = self.configuration.loadfile() @@ -491,13 +461,13 @@ def load_config(self): frm.reset() self._do_layout() if self._nowidgets: - self.set_status(NOWDGSWARN.format(filename), ERRCOL) + self.status_label = (NOWDGSWARN.format(filename), ERRCOL) else: - self.set_status(LOADCONFIGOK.format(filename), OKCOL) + self.status_label = (LOADCONFIGOK.format(filename), OKCOL) elif err == "cancelled": # user cancelled return else: # config error - self.set_status(LOADCONFIGBAD.format(filename), ERRCOL) + self.status_label = (LOADCONFIGBAD.format(filename), ERRCOL) def save_config(self): """ @@ -506,9 +476,9 @@ def save_config(self): err = self.configuration.savefile() if err == "": - self.set_status(SAVECONFIGOK, OKCOL) + self.status_label = (SAVECONFIGOK, OKCOL) else: # save failed - self.set_status(SAVECONFIGBAD.format(err), ERRCOL) + self.status_label = (SAVECONFIGBAD.format(err), ERRCOL) def update_widgets(self): """ @@ -528,45 +498,32 @@ def update_widgets(self): if self._nowidgets: self.widget_state.state["Status"][VISIBLE] = "true" except KeyError as err: - self.set_status(f"{CONFIGERR} - {err}", ERRCOL) - - def start_dialog(self, dlg: str): - """ - Start a threaded dialog task if the dialog is not already open. + self.status_label = (f"{CONFIGERR} - {err}", ERRCOL) - :param str dlg: name of dialog - """ - - if self.dialog_state.state[dlg][THD] is None: - self.dialog_state.state[dlg][THD] = Thread( - target=self._dialog_thread, args=(dlg,), daemon=False - ) - self.dialog_state.state[dlg][THD].start() - - def _dialog_thread(self, dlg: str): + def _refresh_widgets(self): """ - THREADED PROCESS - - Dialog thread. - - :param str dlg: name of dialog + Refresh visible widgets. """ - config = ( - self.configuration.settings if self.dialog_state.state[dlg][CFG] else {} - ) - cls = self.dialog_state.state[dlg][CLASS] - self.dialog_state.state[dlg][DLG] = cls(self, saved_config=config) + for wdg, wdgdata in self.widget_state.state.items(): + frm = getattr(self, wdgdata[FRAME]) + if hasattr(frm, "update_frame") and wdgdata[VISIBLE]: + if wdg == WDGCONSOLE: + frm.update_frame(self.consoledata) + self.consoledata = [] + else: + frm.update_frame() - def stop_dialog(self, dlg: str): + def start_dialog(self, dlg: str): """ - Register dialog as closed. + Open a top level dialog if the dialog is not already open. :param str dlg: name of dialog """ - self.dialog_state.state[dlg][THD] = None - self.dialog_state.state[dlg][DLG] = None + if self.dialog_state.state[dlg][DLG] is None: + cls = self.dialog_state.state[dlg][CLASS] + self.dialog_state.state[dlg][DLG] = cls(self) def dialog(self, dlg: str) -> Toplevel: """ @@ -632,7 +589,7 @@ def _sockserver_thread( socketqueue: Queue, ): """ - THREADED + THREADED PROCESS Socket Server thread. :param int ntripmode: 0 = open socket server, 1 = NTRIP server @@ -657,11 +614,12 @@ def _sockserver_thread( ) as self._socket_server: self._socket_server.serve_forever() except OSError as err: - self.set_status(f"Error starting socket server {err}", ERRCOL) + self.status_label = (f"Error starting socket server {err}", ERRCOL) def update_clients(self, clients: int): """ Update number of connected clients in settings panel. + Called by pygnssutils.socket_server. :param int clients: no of connected clients """ @@ -697,12 +655,12 @@ def on_killswitch(self, *args, **kwargs): # pylint: disable=unused-argument for dlg in self.dialog_state.state: if self.dialog(dlg) is not None: self.dialog(dlg).destroy() - self.stop_dialog(dlg) + # self.stop_dialog(dlg) self.conn_status = DISCONNECTED self.rtk_conn_status = DISCONNECTED except Exception as err: # pylint: disable=broad-exception-caught self.logger.error(err) - self.set_status(KILLSWITCH, ERRCOL) + self.status_label = (KILLSWITCH, ERRCOL) self.logger.debug(KILLSWITCH) def on_gnss_read(self, event): # pylint: disable=unused-argument @@ -737,7 +695,7 @@ def on_gnss_eof(self, event): # pylint: disable=unused-argument ) self._refresh_widgets() self.conn_status = DISCONNECTED - self.set_status(ENDOFFILE, ERRCOL) + self.status_label = (ENDOFFILE, ERRCOL) def on_gnss_timeout(self, event): # pylint: disable=unused-argument """ @@ -752,7 +710,7 @@ def on_gnss_timeout(self, event): # pylint: disable=unused-argument ) self._refresh_widgets() self.conn_status = DISCONNECTED - self.set_status(INACTIVE_TIMEOUT, ERRCOL) + self.status_label = (INACTIVE_TIMEOUT, ERRCOL) def on_stream_error(self, event): # pylint: disable=unused-argument """ @@ -794,7 +752,7 @@ def on_ntrip_read(self, event): # pylint: disable=unused-argument except Empty: pass except (SerialException, SerialTimeoutException) as err: - self.set_status(f"Error sending to device {err}", ERRCOL) + self.status_label = (f"Error sending to device {err}", ERRCOL) def on_spartn_read(self, event): # pylint: disable=unused-argument """ @@ -824,14 +782,14 @@ def on_spartn_read(self, event): # pylint: disable=unused-argument except Empty: pass except (SerialException, SerialTimeoutException) as err: - self.set_status(f"Error sending to device {err}", ERRCOL) + self.status_label = (f"Error sending to device {err}", ERRCOL) - def update_ntrip_status(self, status: bool, msgt: tuple = None): + def update_ntrip_status(self, status: bool, msgt: tuple | NoneType = None): """ Update NTRIP configuration dialog connection status. :param bool status: connected to NTRIP server yes/no - :param tuple msgt: tuple of (message, color) + :param tuple | None msgt: tuple of (message, color) or None """ if self.dialog(DLGTNTRIP) is not None: @@ -839,7 +797,9 @@ def update_ntrip_status(self, status: bool, msgt: tuple = None): def get_coordinates(self) -> dict: """ - Get current coordinates and fix data. + Supply current coordinates and fix data to any widget + that requests it (mirrors NMEA GGA format). + Called by pygnssutils.ntrip_client. :return: dict of coords and fix data :rtype: dict @@ -865,13 +825,16 @@ def get_coordinates(self) -> dict: def process_data(self, raw_data: bytes, parsed_data: object, marker: str = ""): """ - Update the various GUI widgets, GPX track and log file. + THIS IS THE MAIN GNSS DATA PROCESSING LOOP + + Update the various GUI widgets, data & gpx logs and database. :param bytes raw_data: raw message data :param object parsed data: NMEAMessage, UBXMessage or RTCMMessage :param str marker: string prepended to console entries e.g. "NTRIP>>" """ + start = process_time_ns() # self.logger.debug(f"data received {parsed_data.identity}") msgprot = 0 protfilter = self.protocol_mask @@ -912,7 +875,7 @@ def process_data(self, raw_data: bytes, parsed_data: object, marker: str = ""): elif msgprot == MQTT_PROTOCOL: pass - # update chart data if chart is visible + # update chart plot if chart is visible if self.widget_state.state[WDGCHART][VISIBLE]: getattr(self, self.widget_state.state[WDGCHART][FRAME]).update_data( parsed_data @@ -943,12 +906,13 @@ def process_data(self, raw_data: bytes, parsed_data: object, marker: str = ""): self.file_handler.write_logfile(raw_data, parsed_data) self.update_idletasks() + self.processtime = process_time_ns() - start def send_to_device(self, data: object): """ Send raw data to connected device. - :param object data: raw GNSS data (NMEA, UBX, ASCII, RTCM3, SPARTN) + :param object data: raw GNSS data (NMEA, UBX, TTY, RTCM3, SPARTN) """ self.logger.debug(f"Sending message {data}") @@ -959,20 +923,6 @@ def send_to_device(self, data: object): ): self.gnss_outqueue.put(data) - def _refresh_widgets(self): - """ - Refresh visible widgets. - """ - - for wdg, wdgdata in self.widget_state.state.items(): - frm = getattr(self, wdgdata[FRAME]) - if hasattr(frm, "update_frame") and wdgdata[VISIBLE]: - if wdg == WDGCONSOLE: - frm.update_frame(self.consoledata) - self.consoledata = [] - else: - frm.update_frame() - def _check_update(self): """ Check for updated version. @@ -980,7 +930,7 @@ def _check_update(self): latest = check_latest(TITLE) if latest not in (VERSION, "N/A"): - self.set_status(f"{VERCHECK} {latest}", ERRCOL) + self.status_label = (f"{VERCHECK} {latest}", ERRCOL) def poll_version(self, protocol: int): """ @@ -999,14 +949,10 @@ def poll_version(self, protocol: int): if isinstance(msg, (UBXMessage, NMEAMessage)): self.send_to_device(msg.serialize()) - self.set_status( - f"{msg.identity} POLL message sent", - ) + self.status_label = (f"{msg.identity} POLL message sent", INFOCOL) elif isinstance(msg, bytes): self.send_to_device(msg) - self.set_status( - "Setup POLL message sent", - ) + self.status_label = ("Setup POLL message sent", INFOCOL) @property def appmaster(self) -> Tk: @@ -1019,6 +965,85 @@ def appmaster(self) -> Tk: return self.__master + @property + def conn_label(self) -> Label: + """ + Getter for connection_label. + + :return: status label + :rtype: Label + """ + + return self.frm_status.lbl_connection + + @conn_label.setter + def conn_label(self, connection: str | tuple[str, str]): + """ + Sets connection description in status bar. + + :param str | tuple connection: (connection, color) + """ + + if isinstance(connection, tuple): + connection, color = connection + else: + color = INFOCOL + + # truncate very long connection description + if len(connection) > 100: + connection = "..." + connection[-100:] + + if hasattr(self, "frm_status"): + self.conn_label.after( + 0, self.conn_label.config, {"text": connection, "fg": color} + ) + self.update_idletasks() + + @property + def status_label(self) -> Label: + """ + Getter for status_label. + + :return: status label + :rtype: Label + """ + + return self.frm_status.lbl_status + + @status_label.setter + def status_label(self, message: str | tuple[str, str]): + """ + Sets status message, or defers if frm_status not yet instantiated. + + :param str | tuple message: (message, color) + """ + + def priority(col): + return STATUSPRIORITY.get(col, 0) + + if isinstance(message, tuple): + message, color = message + else: + color = INFOCOL + + # truncate very long messages + if len(message) > 200: + message = "..." + message[-200:] + + if hasattr(self, "frm_status"): + color = INFOCOL if color == "blue" else color + self.status_label.after( + 0, self.status_label.config, {"text": message, "fg": color} + ) + self.update_idletasks() + else: # defer message until frm_status is instantiated + if isinstance(self._deferredmsg, tuple): + defpty = priority(self._deferredmsg[1]) + else: + defpty = 0 + if priority(color) > defpty: + self._deferredmsg = (message, color) + @property def conn_status(self) -> int: """ @@ -1042,12 +1067,12 @@ def conn_status(self, status: int): self.frm_banner.update_conn_status(status) self.frm_settings.enable_controls(status) if status == DISCONNECTED: - self.set_connection(NOTCONN) + self.conn_label = (NOTCONN, INFOCOL) @property def rtk_conn_status(self) -> int: """ - Getter for SPARTN connection status. + Getter for RTK connection status. :return: connection status :rtype: int @@ -1058,7 +1083,7 @@ def rtk_conn_status(self) -> int: @rtk_conn_status.setter def rtk_conn_status(self, status: int): """ - Setter for SPARTN connection status. + Setter for RTK connection status. :param int status: connection status """ @@ -1089,21 +1114,24 @@ def protocol_mask(self) -> int: return mask @property - def db_enabled(self) -> bool: + def db_enabled(self) -> int | str: """ Getter for database enabled status. - :return: database enabled status - :rtype: bool + :return: database enabled status or err code + :rtype: int | str """ return self._db_enabled def do_app_update(self, updates: list) -> int: """ - Update outdated application modules to latest versions. + 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 modules to be updated + :param list updates: list of packages to be updated :return: return code 0 = error, 1 = OK :rtype: int """ diff --git a/src/pygpsclient/banner_frame.py b/src/pygpsclient/banner_frame.py index 8628e5ce..b1945a59 100644 --- a/src/pygpsclient/banner_frame.py +++ b/src/pygpsclient/banner_frame.py @@ -12,7 +12,8 @@ :license: BSD 3-Clause """ -from tkinter import NW, SUNKEN, Button, E, Frame, Label, N, W +from time import time +from tkinter import NE, NW, SUNKEN, Button, E, Frame, Label, N, W from PIL import Image, ImageTk from pynmeagps.nmeahelpers import latlon2dmm, latlon2dms, llh2ecef @@ -46,7 +47,15 @@ UIK, UMK, ) -from pygpsclient.helpers import dop2str, m2ft, ms2kmph, ms2knots, ms2mph, scale_font +from pygpsclient.helpers import ( + dop2str, + m2ft, + ms2kmph, + ms2knots, + ms2mph, + scale_font, + unused_sats, +) from pygpsclient.strings import NA DGPSYES = "YES" @@ -202,6 +211,9 @@ def _body(self): self._lbl_trk = Label( self._frm_advanced, bg=self._bgcol, fg="deepskyblue", width=8, anchor=W ) + self._lbl_benchmark = Label( + self._frm_advanced, text="", bg=self._bgcol, fg="grey", width=15, anchor=E + ) self._lbl_siv = Label( self._frm_advanced2, bg=self._bgcol, fg="yellow", width=2, anchor=W ) @@ -230,7 +242,7 @@ def _body(self): width=2, ) self._lbl_diffstat = Label( - self._frm_advanced2, bg=self._bgcol, fg="hotpink", width=20, anchor=W + self._frm_advanced2, bg=self._bgcol, fg="hotpink", width=25, anchor=W ) def _do_layout(self): @@ -258,6 +270,7 @@ def _do_layout(self): self._lbl_spd.grid(column=3, row=0, pady=0, padx=0, sticky=W) self._lbl_ltrk.grid(column=4, row=0, pady=0, padx=0, sticky=W) self._lbl_trk.grid(column=5, row=0, pady=0, padx=0, sticky=W) + self._lbl_benchmark.grid(column=6, row=0, pady=0, padx=0, sticky=E) self._lbl_lsiv.grid(column=0, row=0, pady=0, padx=0, sticky=W) self._lbl_siv.grid(column=1, row=0, pady=0, padx=0, sticky=W) self._lbl_lsip.grid(column=2, row=0, pady=0, padx=0, sticky=W) @@ -272,7 +285,7 @@ def _do_layout(self): self._lbl_diffcorr.grid(column=11, row=0, pady=0, padx=0, sticky=W) self._lbl_diffstat.grid(column=12, row=0, pady=0, padx=0, sticky=W) - self._btn_toggle.grid(column=0, row=0, padx=0, pady=0, sticky=(N, E)) + self._btn_toggle.grid(column=0, row=0, padx=0, pady=0, sticky=NE) self._toggle_advanced() @@ -282,10 +295,10 @@ def _toggle_advanced(self): """ self._frm_connect.grid( - column=0, row=0, rowspan=2, pady=3, ipadx=3, ipady=3, sticky=(N, W) + column=0, row=0, rowspan=2, pady=3, ipadx=3, ipady=3, sticky=NW ) self._frm_basic.grid(column=1, row=0, pady=2, sticky=W) - self._frm_toggle.grid(column=5, row=0, rowspan=2, pady=2, sticky=(N, E)) + self._frm_toggle.grid(column=5, row=0, rowspan=2, pady=2, sticky=NE) self._show_advanced = not self._show_advanced if self._show_advanced: self._frm_advanced.grid(column=1, row=1, pady=2, sticky=W) @@ -377,6 +390,13 @@ def _update_time(self): else: self._lbl_time.config(text=f"{tim:%H:%M:%S.%f}") + # display run time in s and process time in µs + if self.__app.configuration.get("version_s") == "BENCHMARK": + tim = time() + self._lbl_benchmark.config( + text=f"{tim-self.__app.starttime:.0f} s {self.__app.processtime/1000:.0f} µs" + ) + def _update_pos(self, pos_format, units): """ Update position. @@ -470,11 +490,19 @@ def _update_fix(self): def _update_siv(self): """ - Update siv and sip + Update siv and sip. + + Exclude unused sats (cno = 0) if show_unused is not set. """ + siv = self.__app.gnss_status.siv + siv = ( + siv + if self.__app.configuration.get("unusedsat_b") + else siv - unused_sats(self.__app.gnss_status.gsv_data) + ) try: - self._lbl_siv.config(text=f"{self.__app.gnss_status.siv:02d}") + self._lbl_siv.config(text=f"{siv:02d}") self._lbl_sip.config(text=f"{self.__app.gnss_status.sip:02d}") except (TypeError, ValueError): self._lbl_siv.config(text=NA) @@ -583,6 +611,7 @@ def _set_fontsize(self): self._lbl_hae, self._lbl_spd, self._lbl_trk, + self._lbl_benchmark, self._lbl_pdop, self._lbl_fix, self._lbl_sip, diff --git a/src/pygpsclient/canvas_map.py b/src/pygpsclient/canvas_map.py index 869b3318..a4feeaca 100644 --- a/src/pygpsclient/canvas_map.py +++ b/src/pygpsclient/canvas_map.py @@ -62,7 +62,7 @@ point_in_bounds, scale_font, ) -from pygpsclient.mapquest import ( +from pygpsclient.mapquest_handler import ( HYB, MAP, MAPQTIMEOUT, diff --git a/src/pygpsclient/chart_frame.py b/src/pygpsclient/chart_frame.py index 051a104a..07f2bc36 100644 --- a/src/pygpsclient/chart_frame.py +++ b/src/pygpsclient/chart_frame.py @@ -17,17 +17,15 @@ from random import choice from time import time from tkinter import ( + EW, NORMAL, - E, + NSEW, Entry, Frame, Label, - N, - S, Spinbox, StringVar, TclError, - W, font, ) @@ -299,22 +297,22 @@ def _do_layout(self): Position widgets in frame. """ - self._canvas.grid(column=0, row=0, columnspan=6, sticky=(N, S, E, W)) - self._lbl_id.grid(column=0, row=1, sticky=(W, E)) - self._lbl_name.grid(column=1, row=1, sticky=(W, E)) - self._lbl_scale.grid(column=2, row=1, sticky=(W, E)) - self._lbl_miny.grid(column=3, row=1, sticky=(W, E)) - self._lbl_maxy.grid(column=4, row=1, sticky=(W, E)) + self._canvas.grid(column=0, row=0, columnspan=6, sticky=NSEW) + self._lbl_id.grid(column=0, row=1, sticky=EW) + self._lbl_name.grid(column=1, row=1, sticky=EW) + self._lbl_scale.grid(column=2, row=1, sticky=EW) + self._lbl_miny.grid(column=3, row=1, sticky=EW) + self._lbl_maxy.grid(column=4, row=1, sticky=EW) for chn in range(self._num_chans): - self._ent_id[chn].grid(column=0, row=2 + chn, sticky=(W, E)) - self._ent_name[chn].grid(column=1, row=2 + chn, sticky=(W, E)) - self._spn_scale[chn].grid(column=2, row=2 + chn, sticky=(W, E)) - self._spn_miny[chn].grid(column=3, row=2 + chn, sticky=(W, E)) - self._spn_maxy[chn].grid(column=4, row=2 + chn, sticky=(W, E)) - self._lbl_timrange.grid(column=5, row=1, sticky=(W, E)) - self._spn_timrange.grid(column=5, row=2, sticky=(W, E)) - self._lbl_maxpoints.grid(column=5, row=3, sticky=(W, E)) - self._spn_maxpoints.grid(column=5, row=4, sticky=(W, E)) + self._ent_id[chn].grid(column=0, row=2 + chn, sticky=EW) + self._ent_name[chn].grid(column=1, row=2 + chn, sticky=EW) + self._spn_scale[chn].grid(column=2, row=2 + chn, sticky=EW) + self._spn_miny[chn].grid(column=3, row=2 + chn, sticky=EW) + self._spn_maxy[chn].grid(column=4, row=2 + chn, sticky=EW) + self._lbl_timrange.grid(column=5, row=1, sticky=EW) + self._spn_timrange.grid(column=5, row=2, sticky=EW) + self._lbl_maxpoints.grid(column=5, row=3, sticky=EW) + self._spn_maxpoints.grid(column=5, row=4, sticky=EW) def _attach_events(self): """ @@ -425,7 +423,9 @@ def update_data(self, parsed_data: object): continue # wildcards *+-, sum, max or min of group of values - if name[-1] in ("*", "+", "-"): + if name == "processtime": + val = self.__app.processtime / 1000 # microseconds + elif name[-1] in ("*", "+", "-"): vals = [] for attr in parsed_data.__dict__: if name[:-1] in attr and name[0] != "_": @@ -593,6 +593,7 @@ def _update_plot(self, data: dict): chn=chn, tags=(TAG_DATA,), ) + self.update_idletasks() def _on_clipboard(self, event): # pylint: disable=unused-argument """ diff --git a/src/pygpsclient/configuration.py b/src/pygpsclient/configuration.py index 0a951feb..8372c689 100644 --- a/src/pygpsclient/configuration.py +++ b/src/pygpsclient/configuration.py @@ -10,8 +10,11 @@ :license: BSD 3-Clause """ +# pylint: disable=logging-format-interpolation + import logging from os import getenv +from types import NoneType from pyubx2 import GET from serial import PARITY_NONE @@ -27,6 +30,7 @@ FORMAT_BINARY, FORMAT_PARSED, GUI_UPDATE_INTERVAL, + MAXLOGSIZE, MIN_GUI_UPDATE_INTERVAL, MQTTIPMODE, OKCOL, @@ -47,13 +51,14 @@ ZED_F9, ) from pygpsclient.init_presets import INIT_PRESETS -from pygpsclient.mapquest import MAP_UPDATE_INTERVAL +from pygpsclient.mapquest_handler import MAP_UPDATE_INTERVAL from pygpsclient.spartn_lband_frame import D9S_PP_EU as D9S_PP from pygpsclient.strings import ( LOADCONFIGBAD, LOADCONFIGNK, LOADCONFIGNONE, LOADCONFIGOK, + LOADCONFIGRESAVE, ) from pygpsclient.widget_state import VISIBLE @@ -128,6 +133,7 @@ def __init__(self, app): "datalog_b": 0, "logformat_s": FORMAT_BINARY, "logpath_s": "", + "logsize_n": MAXLOGSIZE, "recordtrack_b": 0, "trackpath_s": "", "database_b": 0, @@ -235,9 +241,8 @@ def __init__(self, app): "scatterlon_f": 0.0, }, "imusettings_d": { - "source_s": "ESF-ALG", "range_n": 180, - "option_s": "N/A", + "option_s": "N/A", # reserved for future use }, "chartsettings_d": { "numchn_n": 4, @@ -251,11 +256,11 @@ def __init__(self, app): "colortags_l": [], } - def loadfile(self, filename: str = None) -> tuple: + def loadfile(self, filename: str | NoneType = None) -> tuple: """ Load configuration from json file. - :param str filename: config file name + :param str | NoneType filename: config file name :return: tuple of filename and err message (or "" if OK) :rtype: tuple """ @@ -263,18 +268,20 @@ def loadfile(self, filename: str = None) -> tuple: fname, config, err = self.__app.file_handler.load_config(filename) key = "" val = 0 + resave = False if err == "": # load succeeded - try: - for key, val in config.items(): - key = key.replace("mgtt", "mqtt") # tolerate "mgtt" typo - if key == "protocol_n": # redundant, ignore - continue - if key == "guiupdateinterval_f": # disallow excessive value - val = max(MIN_GUI_UPDATE_INTERVAL, val) + for key, val in config.items(): + if key == "version_s" and val != version: + resave = True + key = key.replace("mgtt", "mqtt") # tolerate "mgtt" typo + if key == "guiupdateinterval_f": # disallow excessive value + val = max(MIN_GUI_UPDATE_INTERVAL, val) + try: self.set(key, val) - # self.__app.set_status(LOADCONFIGOK.format(fname), OKCOL) - except KeyError: # unrecognised setting - err = LOADCONFIGNK.format(key, val) + except KeyError: # ignore unrecognised setting + self.logger.info(LOADCONFIGNK.format(key, val)) + resave = True + continue else: if "No such file or directory" in err: err = LOADCONFIGNONE.format(fname) @@ -282,21 +289,23 @@ def loadfile(self, filename: str = None) -> tuple: err = LOADCONFIGBAD.format(fname, err) if err == "": # config valid - self.__app.set_status(LOADCONFIGOK.format(fname), OKCOL) + rs = LOADCONFIGRESAVE if resave else "" + self.__app.status_label = (LOADCONFIGOK.format(fname, rs), OKCOL) else: - self.__app.set_status(err, ERRCOL) + self.__app.status_label = (err, ERRCOL) return fname, err - def savefile(self, filename: str = None) -> str: + def savefile(self, filename: str | NoneType = None) -> str: """ Save configuration to json file. - :param str filename: config file name + :param str | NoneType filename: config file name :return: error code, or "" if OK :rtype: str """ + self.set("version_s", version) return self.__app.file_handler.save_config(self.settings, filename) def loadcli(self, **kwargs): diff --git a/src/pygpsclient/console_frame.py b/src/pygpsclient/console_frame.py index e7c2319c..55db8fca 100644 --- a/src/pygpsclient/console_frame.py +++ b/src/pygpsclient/console_frame.py @@ -17,7 +17,18 @@ :license: BSD 3-Clause """ -from tkinter import END, HORIZONTAL, NONE, VERTICAL, E, Frame, N, S, Scrollbar, Text, W +from tkinter import ( + END, + EW, + HORIZONTAL, + NONE, + NS, + NSEW, + VERTICAL, + Frame, + Scrollbar, + Text, +) from pyubx2 import hextable @@ -109,9 +120,9 @@ def _do_layout(self): Set position of widgets in frame """ - self.txt_console.grid(column=0, row=0, pady=1, padx=1, sticky=(N, S, E, W)) - self.sblogv.grid(column=1, row=0, sticky=(N, S, E)) - self.sblogh.grid(column=0, row=1, sticky=(S, E, W)) + self.txt_console.grid(column=0, row=0, pady=1, padx=1, sticky=NSEW) + self.sblogv.grid(column=1, row=0, sticky=NS) + self.sblogh.grid(column=0, row=1, sticky=EW) def _attach_events(self): """ @@ -217,7 +228,7 @@ def _on_halt(self, event): # pylint: disable=unused-argument """ self.__app.stream_handler.stop() - self.__app.set_status(HALTTAGWARN.format(self._halt), ERRCOL) + self.__app.status_label = (HALTTAGWARN.format(self._halt), ERRCOL) self.__app.conn_status = DISCONNECTED def _on_clipboard(self, event): # pylint: disable=unused-argument diff --git a/src/pygpsclient/dialog_state.py b/src/pygpsclient/dialog_state.py index c7b0e600..9da3deb9 100644 --- a/src/pygpsclient/dialog_state.py +++ b/src/pygpsclient/dialog_state.py @@ -7,7 +7,6 @@ CLASS = name of dialog class THD = instance of thread DLG = instance of dialog frame -CFG = whether to pass configuration data to dialog RESIZE = whether dialog is resizeable Created on 16 Aug 2023 @@ -18,7 +17,7 @@ """ from pygpsclient.about_dialog import AboutDialog -from pygpsclient.globals import CFG, CLASS, RESIZE, THD +from pygpsclient.globals import CLASS, RESIZE from pygpsclient.gpx_dialog import GPXViewerDialog from pygpsclient.importmap_dialog import ImportMapDialog from pygpsclient.nmea_config_dialog import NMEAConfigDialog @@ -52,58 +51,42 @@ def __init__(self): self.state = { DLGTABOUT: { CLASS: AboutDialog, - THD: None, DLG: None, - CFG: False, RESIZE: False, }, DLGTUBX: { CLASS: UBXConfigDialog, - THD: None, DLG: None, - CFG: True, RESIZE: False, }, DLGTNMEA: { CLASS: NMEAConfigDialog, - THD: None, DLG: None, - CFG: True, RESIZE: False, }, DLGTNTRIP: { CLASS: NTRIPConfigDialog, - THD: None, DLG: None, - CFG: True, RESIZE: False, }, DLGTSPARTN: { CLASS: SPARTNConfigDialog, - THD: None, DLG: None, - CFG: True, RESIZE: False, }, DLGTGPX: { CLASS: GPXViewerDialog, - THD: None, DLG: None, - CFG: True, RESIZE: True, }, DLGTIMPORTMAP: { CLASS: ImportMapDialog, - THD: None, DLG: None, - CFG: True, RESIZE: True, }, DLGTTTY: { CLASS: TTYPresetDialog, - THD: None, DLG: None, - CFG: True, RESIZE: True, }, # add any new dialogs here diff --git a/src/pygpsclient/dynamic_config_frame.py b/src/pygpsclient/dynamic_config_frame.py index c7c54e48..a8f80f02 100644 --- a/src/pygpsclient/dynamic_config_frame.py +++ b/src/pygpsclient/dynamic_config_frame.py @@ -21,7 +21,9 @@ from tkinter import ( ALL, END, + EW, LEFT, + NSEW, NW, VERTICAL, Button, @@ -244,24 +246,24 @@ def _do_layout(self): Layout widgets. """ - self._lbl_cfg_dyn.grid(column=0, row=0, columnspan=4, padx=3, sticky=(W, E)) + self._lbl_cfg_dyn.grid(column=0, row=0, columnspan=4, padx=3, sticky=EW) self._lbx_cfg_cmd.grid( - column=0, row=1, columnspan=2, rowspan=6, padx=3, pady=3, sticky=(W, E) + column=0, row=1, columnspan=2, rowspan=6, padx=3, pady=3, sticky=EW ) self._scr_cfg_cmd.grid(column=1, row=1, rowspan=6, sticky=(N, S, E)) self._btn_send_command.grid(column=3, row=1, ipadx=3, ipady=3, sticky=W) self._lbl_send_command.grid(column=3, row=2, ipadx=3, ipady=3, sticky=W) self._btn_refresh.grid(column=3, row=3, ipadx=3, ipady=3, sticky=W) - self._lbl_command.grid(column=0, row=7, columnspan=4, padx=3, sticky=(W, E)) + self._lbl_command.grid(column=0, row=7, columnspan=4, padx=3, sticky=EW) self._frm_container.grid( - column=0, row=8, columnspan=4, rowspan=15, padx=3, sticky=(N, S, W, E) + column=0, row=8, columnspan=4, rowspan=15, padx=3, sticky=NSEW ) self._can_container.grid( - column=0, row=0, columnspan=3, rowspan=15, padx=3, sticky=(N, S, W, E) + column=0, row=0, columnspan=3, rowspan=15, padx=3, sticky=NSEW ) self._scr_container_ver.grid(column=3, row=0, rowspan=15, sticky=(N, S, E)) self._scr_container_hor.grid( - column=0, row=15, columnspan=4, rowspan=15, sticky=(W, E) + column=0, row=15, columnspan=4, rowspan=15, sticky=EW ) (cols, rows) = self.grid_size() @@ -324,13 +326,14 @@ def _on_select_cfg(self, *args, **kwargs): # pylint: disable=unused-argument self._expected_response = None idx = self._lbx_cfg_cmd.curselection() - self._cfg_id = self._lbx_cfg_cmd.get(idx)[1:] if self._protocol == NMEA: + self._cfg_id = self._lbx_cfg_cmd.get(idx)[1:] cfgid = self._cfg_id.rsplit("_", 1)[0] pde = NMEA_MSGIDS_PROP[cfgid].replace("Sets/Gets", "") pdesc = f"P{cfgid} {pde}" pdic = NMEA_PAYLOADS_SET_PROP[self._cfg_id] else: # UBX + self._cfg_id = self._lbx_cfg_cmd.get(idx) pdesc = self._cfg_id pdic = UBX_PAYLOADS_SET[self._cfg_id] self._lbl_command.config(text=f"{pdesc}") @@ -345,7 +348,7 @@ def _on_set_cfg(self, *args, **kwargs): # pylint: disable=unused-argument """ if self._cfg_id in ("", None): - self.__container.set_status("Select command", ERRCOL) + self.__container.status_label = ("Select command", ERRCOL) return nam = "" @@ -377,16 +380,14 @@ def _on_set_cfg(self, *args, **kwargs): # pylint: disable=unused-argument # send message, update status and await response self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - f"P{self._cfg_id} SET message sent", - ) + self.__container.status_label = f"P{self._cfg_id} SET message sent" for msgid in pendcfg: self.__container.set_pending(msgid, penddlg) self._expected_response = SET except ValueError as err: self.logger.debug(traceback.format_exc()) - self.__container.set_status( + self.__container.status_label = ( f"INVALID! {nam}, {att}: {err}", ERRCOL, ) @@ -401,7 +402,7 @@ def _do_poll_cfg(self, *args, **kwargs): # pylint: disable=unused-argument """ if self._cfg_id in ("", None): - self.__container.set_status("Select command", ERRCOL) + self.__container.status_label = ("Select command", ERRCOL) return msg = penddlg = pendcfg = None @@ -423,17 +424,16 @@ def _do_poll_cfg(self, *args, **kwargs): # pylint: disable=unused-argument penddlg = UBX_CFGOTHER pendcfg = (msg.identity, NAK) + cp = "P" if self._protocol == NMEA else "" if msg is not None: self.__container.send_command(msg) - self.__container.set_status(f"P{cfg_id} POLL message sent", INFOCOL) + self.__container.status_label = f"{cp}{cfg_id} POLL message sent" self._lbl_send_command.config(image=self._img_pending) for msgid in pendcfg: self.__container.set_pending(msgid, penddlg) self._expected_response = POLL else: # CFG cannot be POLLed - self.__container.set_status( - f"P{cfg_id} No POLL available", - ) + self.__container.status_label = f"{cp}{cfg_id} No POLL available" self._lbl_send_command.config(image=self._img_unknown) def _do_poll_args(self, cfg_id: str) -> dict: @@ -509,10 +509,13 @@ def update_status(self, msg: object): self._update_widgets(msg) if ok: - self.__container.set_status(f"P{cfg_id} message acknowledged", OKCOL) + self.__container.status_label = ( + f"{cfg_id} message acknowledged", + OKCOL, + ) self._lbl_send_command.config(image=self._img_confirmed) else: - self.__container.set_status(f"P{cfg_id} message rejected", ERRCOL) + self.__container.status_label = (f"{cfg_id} message rejected", ERRCOL) self._lbl_send_command.config(image=self._img_warn) self.update() diff --git a/src/pygpsclient/file_handler.py b/src/pygpsclient/file_handler.py index f2bcc514..3df6344b 100644 --- a/src/pygpsclient/file_handler.py +++ b/src/pygpsclient/file_handler.py @@ -21,7 +21,8 @@ import logging from datetime import datetime, timedelta from pathlib import Path -from tkinter import filedialog +from tkinter import Frame, Toplevel, filedialog +from types import NoneType from pyubx2 import hextable @@ -37,7 +38,6 @@ GPX_NS, GPX_TRACK_INTERVAL, HOME, - MAXLOGLINES, XML_HDR, ) from pygpsclient.helpers import set_filename @@ -73,7 +73,7 @@ def __init__(self, app): self._configpath = None self._configfile = None self._initdir = {} - self._lines = 0 + self._logsize = 0 self._last_track_update = datetime.fromordinal(1) def __del__(self): @@ -84,20 +84,27 @@ def __del__(self): self.close_logfile() self.close_trackfile() - def open_file(self, mode: str, exts: tuple = DEFEXT) -> str: + def open_file( + self, + parent: Frame | Toplevel, + mode: str, + exts: tuple = DEFEXT, + ) -> str | NoneType: """ Generic routine to open specified file type. + :param Frame | Toplevel | NoneType: parent: parent window :param str mode: type of file e.g. "config", "gpxtrack" etc. :param tuple exts: tuple of file types ("description", "ext") :return: fully qualified path to file, or None if user cancelled - :rtype: str + :rtype: str | NoneType """ fil = filedialog.askopenfilename( title=f"Open {mode.upper()} File", initialdir=self._initdir.get(mode, HOME), filetypes=exts, + parent=parent, ) if fil in ((), ""): return None # User cancelled @@ -204,13 +211,13 @@ def save_config(self, config: dict, filename: Path = CONFIGFILE) -> str: except (OSError, json.JSONDecodeError) as err: return str(err) - def set_logfile_path(self, initdir=HOME) -> Path: + def set_logfile_path(self, initdir=HOME) -> Path | NoneType: """ Set file path. :param str initdir: initial directory (HOME) :return: file path - :rtype: str + :rtype: str | NoneType """ self._logpath = filedialog.askdirectory( @@ -232,12 +239,12 @@ def open_logfile(self) -> int: try: self._logpath = self.__app.configuration.get("logpath_s") - self._lines = 0 + self._logsize = 0 _, self._logname = set_filename(self._logpath, "data", "log") self._logfile = open(self._logname, "a+b") return 1 except FileNotFoundError as err: - self.__app.set_status(f"{err}", ERRCOL) + self.__app.status_label = (f"{err}", ERRCOL) return 0 def write_logfile(self, raw_data, parsed_data): @@ -252,6 +259,7 @@ def write_logfile(self, raw_data, parsed_data): return lfm = self.__app.configuration.get("logformat_s") + maxsize = self.__app.configuration.get("logsize_n") data = [] if lfm in (FORMAT_PARSED, FORMAT_BOTH): data.append(parsed_data) @@ -268,11 +276,11 @@ def write_logfile(self, raw_data, parsed_data): try: self._logfile.write(datum) self._logfile.flush() - self._lines += 1 + self._logsize += len(datum) except ValueError: pass - if self._lines > MAXLOGLINES: + if self._logsize > maxsize: self.close_logfile() self.open_logfile() @@ -318,7 +326,7 @@ def open_trackfile(self) -> int: _, self._trackname = set_filename(self._trackpath, "track", "gpx") self._trackfile = open(self._trackname, "a", encoding="utf-8") except FileNotFoundError as err: - self.__app.set_status(f"{err}", ERRCOL) + self.__app.status_label = (f"{err}", ERRCOL) return 0 date = datetime.now().isoformat() + "Z" @@ -466,13 +474,13 @@ def update_gpx_track(self): self._last_track_update = datetime.now() - def set_database_path(self, initdir=HOME) -> Path: + def set_database_path(self, initdir: Path = HOME) -> str | NoneType: """ Set database directory. - :param str initdir: initial directory (HOME) - :return: file path - :rtype: str + :param Path initdir: initial directory (HOME) + :return: file path or None + :rtype: str | NoneType """ self._databasepath = filedialog.askdirectory( diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py index 36aef7ef..22e4b5c1 100644 --- a/src/pygpsclient/globals.py +++ b/src/pygpsclient/globals.py @@ -73,7 +73,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): MQTT_PROTOCOL = 64 TTY_PROTOCOL = 128 -# Various global constants +# Various global constants - please keep in ascending alphabetical order HOME = Path.home() APPNAME = __name__.split(".", 1)[0] # i.e. "pygpsclient" ASCII = "ascii" @@ -90,7 +90,6 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): 4800, ) BSR = "backslashreplace" -CFG = "cfg" CLASS = "cls" COLORTAGS = "colortags" CONFIGFILE = path.join(HOME, f"{APPNAME}.json") @@ -109,13 +108,14 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): 4: "socket", } CRLF = b"\x0d\x0a" +CUSTOM = "custom" DDD = "DD.D" DEFAULT_BUFSIZE = 4096 DEFAULT_PASSWORD = "password" # nosec DEFAULT_PORT = 50010 -DEFAULT_TLS_PORTS = (443, 2102, 8443, 50443, 58443) DEFAULT_REGION = "eu" DEFAULT_SERVER = "localhost" +DEFAULT_TLS_PORTS = (443, 2102, 8443, 50443, 58443) DEFAULT_USER = "anon" DIRNAME = path.dirname(__file__) DISCONNECTED = 0 @@ -172,11 +172,10 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): ICON_GITHUB = path.join(DIRNAME, "resources/github-256.png") ICON_LOAD = path.join(DIRNAME, "resources/iconmonstr-folder-18-24.png") ICON_LOGREAD = path.join(DIRNAME, "resources/binary-1-24.png") -ICON_NMEACONFIG = path.join(DIRNAME, "resources/iconmonstr-gear-2-24-brown.png") +ICON_NMEACONFIG = path.join(DIRNAME, "resources/iconmonstr-gear-2-24-nmea.png") ICON_NOCLIENT = path.join(DIRNAME, "resources/iconmonstr-noclient-10-24.png") ICON_NOTRANSMIT = path.join(DIRNAME, "resources/iconmonstr-notransmit-10-24.png") ICON_NTRIPCONFIG = path.join(DIRNAME, "resources/iconmonstr-antenna-4-24.png") -ICON_TTYCONFIG = path.join(DIRNAME, "resources/icon-tty-24-green.png") ICON_PENDING = path.join(DIRNAME, "resources/iconmonstr-time-6-24.png") ICON_PLAY = path.join(DIRNAME, "resources/iconmonstr-media-control-48-24.png") ICON_POS = path.join(DIRNAME, "resources/iconmonstr-plus-lined-24.png") @@ -188,42 +187,35 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): ICON_SERIAL = path.join(DIRNAME, "resources/usbport-1-24.png") ICON_SOCKET = path.join(DIRNAME, "resources/ethernet-1-24.png") ICON_SPARTNCONFIG = path.join(DIRNAME, "resources/iconmonstr-antenna-3-24.png") -# ICON_SPARTNDISABLE = path.join(DIRNAME, "resources/iconmonstr-antenna-3-24-greyed.png") +ICON_SPONSOR = path.join(DIRNAME, "resources/bmac-logo-60.png") ICON_START = path.join(DIRNAME, "resources/marker_start.png") ICON_STOP = path.join(DIRNAME, "resources/iconmonstr-stop-1-24.png") ICON_TRANSMIT = path.join(DIRNAME, "resources/iconmonstr-transmit-10-24.png") -ICON_UBXCONFIG = path.join(DIRNAME, "resources/iconmonstr-gear-2-24.png") +ICON_TTYCONFIG = path.join(DIRNAME, "resources/iconmonstr-gear-2-24-tty.png") +ICON_UBXCONFIG = path.join(DIRNAME, "resources/iconmonstr-gear-2-24-ubx.png") ICON_UNDO = path.join(DIRNAME, "resources/iconmonstr-undo-24.png") ICON_UNKNOWN = path.join(DIRNAME, "resources/clear-1-24.png") ICON_WARNING = path.join(DIRNAME, "resources/iconmonstr-warning-1-24.png") IMG_WORLD = path.join(DIRNAME, "resources/world.png") -ICON_SPONSOR = path.join(DIRNAME, "resources/bmac-logo-60.png") IMG_WORLD_BOUNDS = Area(-90, -180, 90, 180) -LBAND = "LBAND" -LICENSE_URL = "https://github.com/semuconsulting/PyGPSClient/blob/master/LICENSE" -MAPAPI_URL = "https://developer.mapquest.com/user/login/sign-up" -MQTTIPMODE = 0 -MQTTLBANDMODE = 1 -MINHEIGHT = 600 -MINWIDTH = 800 -CUSTOM = "custom" IMPORT = "import" -WORLD = "world" +LBAND = "LBAND" LC29H = "Quectel LC29H" LG290P = "Quectel LG29P/LG580P" -MAX_SNR = 60 # upper limit of graphview snr axis -MAXLOGLINES = 10000 # maximum number of 'lines' per datalog file -MIN_GUI_UPDATE_INTERVAL = 0.1 # minimum GUI widget update interval (seconds) +LICENSE_URL = "https://github.com/semuconsulting/PyGPSClient/blob/master/LICENSE" +MAPAPI_URL = "https://developer.mapquest.com/user/login/sign-up" +MAX_SNR = 60 # upper limit of levelsview CNo axis MAXFLOAT = 2e20 +MAXLOGSIZE = 10485760 # maximum size of individual log file in bytes +MIN_GUI_UPDATE_INTERVAL = 0.1 # minimum GUI widget update interval (seconds) MINFLOAT = -MAXFLOAT -MOSAIC_X5 = "Septentrio Mosaic X5" +MINHEIGHT = 600 +MINWIDTH = 800 +MOSAIC_X5 = "Septentrio Mosaic X3/X5" MQAPIKEY = "mqapikey" -MSGMODES = { - "GET": GET, - "SET": SET, - "POLL": POLL, - "SETPOLL": SETPOLL, -} +MQTTIPMODE = 0 +MQTTLBANDMODE = 1 +MSGMODES = {"GET": GET, "SET": SET, "POLL": POLL, "SETPOLL": SETPOLL} NOPORTS = 3 NTRIP = "NTRIP" NTRIP_EVENT = "<>" @@ -233,13 +225,12 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): PYPI_URL = "https://pypi.org/pypi/PyGPSClient" QUITONERRORDEFAULT = 1 RCVR_CONNECTION = "USB,UART1" # default GNSS receiver connection port(s) -ROMVER_NEW = "23.01" # min device ROM version using configuration database READONLY = "readonly" RESIZE = "resize" +ROMVER_NEW = "23.01" # min device ROM version using configuration database ROUTE = "route" RXMMSG = "RXM-SPARTN-KEY" SAT_EXPIRY = 10 # how long passed satellites are kept in the sky and graph view - SCREENSCALE = 0.8 # screen resolution scaling factor SOCK_NTRIP = "NTRIP CASTER" SOCK_SERVER = "SOCKET SERVER" @@ -250,13 +241,13 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): SOCKSERVER_MAX_CLIENTS = 5 SOCKSERVER_NTRIP_PORT = 2101 SOCKSERVER_PORT = 50012 +SPARTN_BASEDATE_CURRENT = -1 +SPARTN_BASEDATE_DATASTREAM = 0 +SPARTN_DEFAULT_KEY = "abcd1234abcd1234abcd1234abcd1234" SPARTN_EOF_EVENT = "<>" SPARTN_ERR_EVENT = "<>" SPARTN_EVENT = "<>" SPARTN_KEYLEN = 16 -SPARTN_DEFAULT_KEY = "abcd1234abcd1234abcd1234abcd1234" -SPARTN_BASEDATE_CURRENT = -1 -SPARTN_BASEDATE_DATASTREAM = 0 SPARTN_OUTPORT = 8883 SPARTN_PPREGIONS = ("eu", "us", "jp", "kr", "au") SPARTN_PPSERVER_URL = "pp.services.u-blox.com" @@ -265,19 +256,10 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): SPONSOR_URL = "https://buymeacoffee.com/semuconsulting" SQRT2 = 0.7071067811865476 # square root of 2 STATUSPRIORITY = {INFOCOL: 0, "blue": 0, OKCOL: 2, "green": 1, ERRCOL: 3, "red": 3} -THD = "thd" TIME0 = datetime(1970, 1, 1) # basedate for time() -TIMEOUTS = ( - "0.1", - "0.2", - "1", - "2", - "5", - "10", - "20", - "None", - "0", -) +TIMEOUTS = ("0.1", "0.2", "1", "2", "5", "10", "20", "None", "0") +# map nmea talker to gnss_id +TKGN = {"GN": 0, "GP": 0, "GA": 2, "GB": 3, "BD": 3, "GQ": 5, "GL": 6, "GI": 7} TOPIC_IP = "/pp/ip/{}" TOPIC_MGA = "/pp/ubx/mga" TOPIC_RXM = "/pp/ubx/0236/ip" @@ -305,6 +287,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): WIDGETU3 = (800, 200) # Console size WIDGETU4 = (500, 500) # GPX Track viewer size WIDGETU6 = (400, 200) # Chart size +WORLD = "world" XML_HDR = '' ZED_F9 = "u-blox ZED-F9" ZED_X20 = "u-blox ZED-X20" @@ -352,7 +335,6 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): SERVERCONFIG = 18 SBF_MONHW = 19 -# keep KNOWNGPS all lower case KNOWNGPS = ( "cp210", "ft230", @@ -375,13 +357,12 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): "usb_to_uart", "user-defined", ) +""" +Recognised GNSS device serial port designators. +Used to 'auto-select' GNSS device in serial port list. +(keep in lower-case) +""" -# map of fix values to descriptions -# the keys in this map are a concatenation of NMEA/UBX -# message identifier and attribute value e.g.: -# GGA1: GGA + quality = 1 -# NAV-STATUS3: NAV-STATUS + gpsFix = 3 -# (valid for NMEA >=4) FIXLOOKUP = { "GGA1": "3D", # quality "GGA2": "3D", @@ -454,3 +435,11 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): "PVTGeodetic8": "RTK FLOAT", "PVTGeodetic10": "PPP", } +""" +Map of fix values to descriptions. +The keys in this map are a concatenation of NMEA/UBX +message identifier and attribute value e.g. +GGA1: GGA + quality = 1 +NAV-STATUS3: NAV-STATUS + gpsFix = 3 +(valid for NMEA >=4) +""" diff --git a/src/pygpsclient/gnss_status.py b/src/pygpsclient/gnss_status.py index 761bfbb0..fb429463 100644 --- a/src/pygpsclient/gnss_status.py +++ b/src/pygpsclient/gnss_status.py @@ -54,14 +54,16 @@ def __init__(self): self.acc_heading = 0.0 # rover relative position heading accuracy self.acc_length = 0.0 # rover relative position distance accuracy self.rel_pos_flags = [] # rover relative position flags - self.gsv_data = {} # list of satellite tuples (gnssId, svid, elev, azim, cno) + # dict of satellite {(gnssid,svid}: (gnssId, svid, elev, azim, cno, last_updated)} + self.gsv_data = {} + # dict of hardware, firmware and software versions self.version_data = { "swversion": NA, "hwversion": NA, "fwversion": NA, "romversion": NA, "gnss": NA, - } # dict of hardware, firmware and software versions + } self.sysmon_data = {} # dict of system monitor data (cpu and memory load, etc.) self.spectrum_data = [] # list of spectrum data (spec, spn, res, ctr, pga) self.comms_data = {} # dict of comms port utilisation (tx and rx loads) diff --git a/src/pygpsclient/gpx_dialog.py b/src/pygpsclient/gpx_dialog.py index 24129d48..80189491 100644 --- a/src/pygpsclient/gpx_dialog.py +++ b/src/pygpsclient/gpx_dialog.py @@ -17,13 +17,13 @@ from statistics import mean, median from tkinter import ( ALL, + EW, + NSEW, Button, - E, Frame, IntVar, Label, N, - S, Spinbox, StringVar, W, @@ -214,15 +214,15 @@ def _do_layout(self): Arrange widgets. """ - self._frm_body.grid(column=0, row=0, sticky=(N, S, E, W)) - self._frm_map.grid(column=0, row=0, sticky=(N, S, E, W)) - self._frm_profile.grid(column=0, row=1, sticky=(W, E)) - self._frm_info.grid(column=0, row=2, sticky=(W, E)) - self._frm_controls.grid(column=0, row=3, columnspan=7, sticky=(W, E)) - self._can_mapview.grid(column=0, row=0, sticky=(N, S, E, W)) - self._can_profile.grid(column=0, row=0, sticky=(N, S, E, W)) + self._frm_body.grid(column=0, row=0, sticky=NSEW) + self._frm_map.grid(column=0, row=0, sticky=NSEW) + self._frm_profile.grid(column=0, row=1, sticky=EW) + self._frm_info.grid(column=0, row=2, sticky=EW) + self._frm_controls.grid(column=0, row=3, columnspan=7, sticky=EW) + self._can_mapview.grid(column=0, row=0, sticky=NSEW) + self._can_profile.grid(column=0, row=0, sticky=NSEW) for i in range(MD_LINES): - self._lbl_info[i].grid(column=0, row=i, padx=1, pady=1, sticky=(W, E)) + self._lbl_info[i].grid(column=0, row=i, padx=1, pady=1, sticky=EW) self._btn_load.grid(column=0, row=1, padx=3, pady=3) self._lbl_maptype.grid( column=1, @@ -339,7 +339,7 @@ def _on_redraw(self, *args, **kwargs): Handle redraw button press. """ - self.set_status(DLGGPXWAIT, INFOCOL) + self.status_label = (DLGGPXWAIT, INFOCOL) self._detach_events() self._reset() self._spn_zoom.config(highlightbackground="gray90", highlightthickness=3) @@ -355,6 +355,7 @@ def _open_gpxfile(self) -> str: """ return self.__app.file_handler.open_file( + self, "gpx", (("gpx files", "*.gpx"), ("all files", "*.*")), ) @@ -367,7 +368,7 @@ def _on_load(self): self._gpxfile = self._open_gpxfile() if self._gpxfile is None: # user cancelled return - self.set_status(DLGGPXLOAD, INFOCOL) + self.status_label = (DLGGPXLOAD, INFOCOL) self._parse_gpx() def _parse_gpx(self): @@ -381,7 +382,7 @@ def _parse_gpx(self): trkpts = parser.getElementsByTagName(f"{ptyp}") self._process_track(trkpts, ptyp) except (TypeError, AttributeError, expat.ExpatError) as err: - self.set_status(f"{DLGGPXERROR}\n{repr(err)}", ERRCOL) + self.status_label = (f"{DLGGPXERROR}\n{repr(err)}", ERRCOL) self.logger.error(traceback.format_exc()) def _process_track(self, trkpts: list, ptyp: str): @@ -396,7 +397,7 @@ def _process_track(self, trkpts: list, ptyp: str): self._no_time = False self._no_ele = False if self._rng == 0: - self.set_status(DLGGPXNULL.format(ptyp), ERRCOL) + self.status_label = (DLGGPXNULL.format(ptyp), ERRCOL) return minlat = minlon = 400 @@ -458,7 +459,7 @@ def _process_track(self, trkpts: list, ptyp: str): self._draw_map() self._draw_profile() self._draw_metadata() - self.set_status(DLGGPXLOADED, INFOCOL) + self.status_label = (DLGGPXLOADED, INFOCOL) def _draw_map(self): """ diff --git a/src/pygpsclient/hardware_info_frame.py b/src/pygpsclient/hardware_info_frame.py index 0a31aa3f..d9650a51 100644 --- a/src/pygpsclient/hardware_info_frame.py +++ b/src/pygpsclient/hardware_info_frame.py @@ -24,7 +24,6 @@ ICON_SEND, ICON_WARNING, NMEA_MONHW, - OKCOL, SBF_MONHW, UBX_MONVER, ) @@ -136,19 +135,17 @@ def _do_poll_ver(self, *args, **kwargs): # pylint: disable=unused-argument if isinstance(msg, (NMEAMessage, UBXMessage)): self.__app.send_to_device(msg.serialize()) - self.__app.set_status( - f"{msg.identity} POLL message sent", - ) + self.__container.status_label = f"{msg.identity} POLL message sent" elif isinstance(msg, bytes): self.__app.send_to_device(msg) - self.__app.set_status("Setup POLL message sent") + self.__container.status_label = "Setup POLL message sent" self.__container.set_pending(pendmsg, penddlg) - def update_status(self, msg: object): + def update_status(self, msg: UBXMessage | NMEAMessage): """ Update pending confirmation status. - :param object msg: UBX or NMEA config message + :param UBXMessage | NMEAMessage msg: UBX or NMEA config message """ self._lbl_swver.config( @@ -165,4 +162,4 @@ def update_status(self, msg: object): ) self._lbl_gnss.config(text=self.__app.gnss_status.version_data.get("gnss", NA)) - self.__container.set_status(f"{msg.identity} GET message received", OKCOL) + self.__container.status_label = f"{msg.identity} GET message received" diff --git a/src/pygpsclient/helpers.py b/src/pygpsclient/helpers.py index 4cfc066c..70f0733f 100644 --- a/src/pygpsclient/helpers.py +++ b/src/pygpsclient/helpers.py @@ -315,30 +315,7 @@ def col2contrast(col: str) -> str: r, g, b = str2rgb(col) luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - col = "black" if luminance > 0.5 else "white" - return col - - -def config_nmea(state: int, port_type: str = "USB") -> UBXMessage: - """ - Enable or disable NMEA messages at port level and use minimum UBX - instead (NAV-PRT, NAV_SAT, NAV_DOP). - - :param int state: 1 = disable NMEA, 0 = enable NMEA - :param str port_type: port that rcvr is connected on - """ - - nmea_state = 0 if state else 1 - layers = 1 - transaction = 0 - cfg_data = [] - cfg_data.append((f"CFG_{port_type}OUTPROT_NMEA", nmea_state)) - cfg_data.append((f"CFG_{port_type}OUTPROT_UBX", 1)) - cfg_data.append((f"CFG_MSGOUT_UBX_NAV_PVT_{port_type}", state)) - cfg_data.append((f"CFG_MSGOUT_UBX_NAV_DOP_{port_type}", state)) - cfg_data.append((f"CFG_MSGOUT_UBX_NAV_SAT_{port_type}", state * 4)) - - return UBXMessage.config_set(layers, transaction, cfg_data) + return "black" if luminance > 0.5 else "white" def corrage2int(code: int) -> int: @@ -1261,6 +1238,18 @@ def time2str(tim: float, sformat: str = "%H:%M:%S") -> str: return dt.strftime(sformat) +def unused_sats(data: dict) -> int: + """ + Get number of 'unused' sats in gnss_data.gsv_data. + + :param dict data: {(gnssid,svid}: (gnssId, svid, elev, azim, cno, last_updated)} + :return: number of sats where cno = 0 + :rtype: int + """ + + return sum(1 for (_, _, _, _, cno, _) in data.values() if cno == 0) + + def ubx2preset(msgs: tuple, desc: str = "") -> str: """ Convert one or more UBXMessages to format suitable for adding to user-defined diff --git a/src/pygpsclient/importmap_dialog.py b/src/pygpsclient/importmap_dialog.py index 717e7b54..ebfc7c5c 100644 --- a/src/pygpsclient/importmap_dialog.py +++ b/src/pygpsclient/importmap_dialog.py @@ -15,7 +15,9 @@ from tkinter import ( ALL, DISABLED, + EW, NORMAL, + NSEW, Button, Checkbutton, E, @@ -23,8 +25,6 @@ Frame, IntVar, Label, - N, - S, StringVar, W, ) @@ -42,7 +42,7 @@ BGCOL, ERRCOL, IMPORT, - INFOCOL, + OKCOL, TRACEMODE_WRITE, VALFLOAT, Area, @@ -51,14 +51,6 @@ from pygpsclient.strings import DLGTIMPORTMAP from pygpsclient.toplevel_dialog import ToplevelDialog -# profile chart parameters: -AXIS_XL = 35 # x axis left offset -AXIS_XR = 35 # x axis right offset -AXIS_Y = 15 # y axis bottom offset -ELEAX_COL = "green4" # color of elevation plot axis -ELE_COL = "palegreen3" # color of elevation plot -SPD_COL = INFOCOL # color of speed plot -MD_LINES = 2 # number of lines of metadata MINDIM = (456, 418) @@ -140,9 +132,9 @@ def _do_layout(self): """ Arrange widgets. """ - self._frm_body.grid(column=0, row=0, sticky=(N, S, E, W)) - self._can_mapview.grid(column=0, row=0, sticky=(N, S, E, W)) - self._frm_controls.grid(column=0, row=1, sticky=(W, E)) + self._frm_body.grid(column=0, row=0, sticky=NSEW) + self._can_mapview.grid(column=0, row=0, sticky=NSEW) + self._frm_controls.grid(column=0, row=1, sticky=EW) self._btn_load.grid(column=0, row=0, padx=3, pady=3) self._btn_redraw.grid(column=1, row=0, padx=3, pady=3) self._btn_import.grid(column=2, row=0, padx=3, pady=3) @@ -189,12 +181,12 @@ def _reset(self): self._can_mapview.delete(ALL) # self._btn_import.config(state=DISABLED) if not HASRASTERIO: - self.set_status( - "Warning: rasterio library is not installed - bounds must be entered manually", - INFOCOL, + self.status_label = ( + "Warning: rasterio library is not installed - " + "bounds must be entered manually" ) else: - self.set_status("") + self.status_label = "" def _on_update(self, var, index, mode): """ @@ -217,10 +209,10 @@ def _valid_entries(self) -> bool: valid = valid & self._ent_minlon.validate(VALFLOAT, -180, 180) valid = valid & self._ent_maxlon.validate(-180, 180) if valid: - self.set_status("", INFOCOL) + self.status_label = "" self._btn_import.config(state=NORMAL) else: - self.set_status("Error: invalid entry", ERRCOL) + self.status_label = ("Error: invalid entry", ERRCOL) self._btn_import.config(state=DISABLED) return valid @@ -229,7 +221,7 @@ def _on_load(self): Load custom map from file. """ - self.set_status("") + self.status_label = "" self._custommap = self._open_mapfile() if self._custommap is not None: bounds = self._get_bounds(self._custommap) @@ -257,9 +249,13 @@ def _on_redraw(self, *args, **kwargs): def _open_mapfile(self) -> str: """ Open custom map file. + + :return: fully qualified path to map file + :rtype: str """ return self.__app.file_handler.open_file( + self, "tif", (("GeoTiff files", "*.tif"), ("all files", "*.*")), ) @@ -281,15 +277,15 @@ def _get_bounds(self, mappath) -> Area: ras.crs.to_epsg(), 4326, *ras.bounds ) except Exception: # pylint: disable=broad-exception-caught - self.set_status( + self.status_label = ( "Warning: image is not georeferenced - bounds must be entered manually", ERRCOL, ) - self._lonmin.set(round(lonmin, 8)) - self._latmin.set(round(latmin, 8)) - self._lonmax.set(round(lonmax, 8)) - self._latmax.set(round(latmax, 8)) + self._lonmin.set(str(round(lonmin, 8))) + self._latmin.set(str(round(latmin, 8))) + self._lonmax.set(str(round(lonmax, 8))) + self._latmax.set(str(round(latmax, 8))) return Area( float(self._latmin.get()), float(self._lonmin.get()), @@ -311,10 +307,10 @@ def _on_import(self): latmax = float(self._latmax.get()) if lonmax + 180 <= lonmin + 180 or latmax + 90 <= latmin + 90: - self.set_status("Error: minimum must be less than maximum", ERRCOL) + self.status_label = ("Error: minimum must be less than maximum", ERRCOL) else: usermaps = self.__app.configuration.get("usermaps_l") idx = 0 if self._first.get() else len(usermaps) + 1 usermaps.insert(idx, [self._custommap, [latmin, lonmin, latmax, lonmax]]) self.__app.configuration.set("usermaps_l", usermaps) - self.set_status("Custom map imported", INFOCOL) + self.status_label = ("Custom map imported", OKCOL) diff --git a/src/pygpsclient/imu_frame.py b/src/pygpsclient/imu_frame.py index 7f45699c..21b8cdb8 100644 --- a/src/pygpsclient/imu_frame.py +++ b/src/pygpsclient/imu_frame.py @@ -14,14 +14,13 @@ """ from tkinter import ( + EW, + NSEW, NW, Canvas, - E, Frame, IntVar, Label, - N, - S, Spinbox, StringVar, TclError, @@ -134,12 +133,12 @@ def _body(self): textvariable=self._option, state=READONLY, ) - self.canvas.grid(column=0, row=0, columnspan=4, sticky=(N, S, E, W)) + self.canvas.grid(column=0, row=0, columnspan=4, sticky=NSEW) - self._lbl_range.grid(column=0, row=1, sticky=(W, E)) - self._spn_range.grid(column=1, row=1, sticky=(W, E)) - self._lbl_option.grid(column=2, row=1, sticky=(W, E)) - self._spn_option.grid(column=3, row=1, sticky=(W, E)) + self._lbl_range.grid(column=0, row=1, sticky=EW) + self._spn_range.grid(column=1, row=1, sticky=EW) + self._lbl_option.grid(column=2, row=1, sticky=EW) + self._spn_option.grid(column=3, row=1, sticky=EW) def reset(self): """ diff --git a/src/pygpsclient/levelsview_frame.py b/src/pygpsclient/levelsview_frame.py index e591b2e5..911e5f70 100644 --- a/src/pygpsclient/levelsview_frame.py +++ b/src/pygpsclient/levelsview_frame.py @@ -14,7 +14,7 @@ # pylint: disable=no-member -from tkinter import BOTH, NE, YES, Frame, font +from tkinter import NE, NSEW, Frame, font from pygpsclient.canvas_plot import ( TAG_DATA, @@ -31,11 +31,11 @@ MAX_SNR, WIDGETU2, ) -from pygpsclient.helpers import col2contrast +from pygpsclient.helpers import col2contrast, unused_sats OL_WID = 1 FONTSCALELG = 40 -FONTSCALESV = 40 +FONTSCALESV = 30 class LevelsviewFrame(Frame): @@ -74,7 +74,7 @@ def _body(self): self._canvas = CanvasGraph( self.__app, self, width=self.width, height=self.height, bg=BGCOL ) - self._canvas.pack(fill=BOTH, expand=YES) + self._canvas.grid(column=0, row=0, sticky=NSEW) def _attach_events(self): """ @@ -106,9 +106,9 @@ def init_frame(self): self._canvas.create_graph( xdatamax=10, ydatamax=(MAX_SNR,), - xtickmaj=2, + xtickmaj=5, ytickmaj=int(MAX_SNR / 10), - ylegend=("cno",), + ylegend=("C/N0 dBHz",), ycol=(FGCOL,), ylabels=True, xangle=35, @@ -157,10 +157,11 @@ def update_frame(self): """ data = self.__app.gnss_status.gsv_data - siv = len(self.__app.gnss_status.gsv_data) - + show_unused = self.__app.configuration.get("unusedsat_b") + siv = len(data) if siv == 0: return + siv = siv if show_unused else siv - unused_sats(data) w, h = self.width, self.height self.init_frame() @@ -168,14 +169,16 @@ def update_frame(self): offset = self._canvas.xoffl # AXIS_XL + 2 colwidth = (w - self._canvas.xoffl - self._canvas.xoffr + 1) / siv # scale x axis label - svfont = font.Font(size=int(min(w, h) / FONTSCALESV)) - for d in sorted(data.values()): # sort by ascending gnssid, svid - gnssId, prn, _, _, snr = d - if snr in ("", "0", 0): - snr = 1 # show 'place marker' in graph - else: - snr = int(snr) - snr_y = int(snr) * (h - self._canvas.yoffb - 1) / MAX_SNR + fsiz = min(w * 15 / siv, w, h) + svfont = font.Font(size=int(fsiz / FONTSCALESV)) + for val in sorted(data.values()): # sort by ascending gnssid, svid + gnssId, prn, _, _, cno, _ = val + if cno == 0: + if show_unused: + cno = 1 # show 'place marker' in graph + else: + continue + snr_y = int(cno) * (h - self._canvas.yoffb - 1) / MAX_SNR (_, ol_col) = GNSS_LIST[gnssId] prn = f"{int(prn):02}" self._canvas.create_rectangle( @@ -190,7 +193,7 @@ def update_frame(self): ) self._canvas.create_text( offset + colwidth / 2, - h - self._canvas.yoffb, + h - self._canvas.yoffb - 1, text=prn, fill=FGCOL, font=svfont, @@ -200,7 +203,7 @@ def update_frame(self): ) offset += colwidth - # self._canvas.update_idletasks() + self.update_idletasks() def _on_resize(self, event): # pylint: disable=unused-argument """ diff --git a/src/pygpsclient/map_frame.py b/src/pygpsclient/map_frame.py index 6eadd585..ce871a60 100644 --- a/src/pygpsclient/map_frame.py +++ b/src/pygpsclient/map_frame.py @@ -24,14 +24,13 @@ from time import time from tkinter import ( DISABLED, + EW, NORMAL, + NSEW, Checkbutton, - E, Frame, IntVar, Label, - N, - S, Spinbox, StringVar, W, @@ -48,7 +47,7 @@ WORLD, Point, ) -from pygpsclient.mapquest import ( +from pygpsclient.mapquest_handler import ( MAX_ZOOM, MIN_UPDATE_INTERVAL, MIN_ZOOM, @@ -156,8 +155,8 @@ def _do_layout(self): Arrange widgets in frame. """ - self._can_mapview.grid(column=0, row=0, sticky=(N, S, E, W)) - self._frm_options.grid(column=0, row=1, sticky=(E, W)) + self._can_mapview.grid(column=0, row=0, sticky=NSEW) + self._frm_options.grid(column=0, row=1, sticky=EW) self._spn_maptype.grid(column=0, row=0, padx=1, sticky=W) self._lbl_zoom.grid(column=1, row=0, padx=1, sticky=W) self._spn_zoom.grid(column=2, row=0, padx=1, sticky=W) diff --git a/src/pygpsclient/mapquest_handler.py b/src/pygpsclient/mapquest_handler.py new file mode 100644 index 00000000..10397cf2 --- /dev/null +++ b/src/pygpsclient/mapquest_handler.py @@ -0,0 +1,239 @@ +""" +mapquest_handler.py + +MapQuest API Constants and Methods. + +MapQuest polygon compression and decompression routines +adapted from the original javascript examples: + +https://developer.mapquest.com/documentation/api/static-map/ +https://developer.mapquest.com/documentation/common/encode-decode/ + +Created on 04 May 2023 + +:author: semuadmin (Steve Smith) +:copyright: 2020 semuadmin +:license: BSD 3-Clause + +""" + +from pygpsclient.globals import Area + +# MapQuest API URLS: +MAPQURL = ( + "https://www.mapquestapi.com/staticmap/v5/map?" + + "key={key}&type={type}&size={width},{height}" +) +BBOX = "&boundingBox={lat2},{lon1},{lat1},{lon2}" # top left, bottom right +HACC = "&shape=radius:{radius}|weight:1|fill:ccffff50|border:88888850|{lat},{lon}" +LOC = "&locations={lat},{lon}|{marker}" +LOCS = "&locations={lat1},{lon1}||{lat2},{lon2}&defaultMarker=marker-num" +LOCICON = "marker-sm-616161-ff4444" +MARGIN = "&margin={margin}" # bbox margin (default 50) +RETINA = "@2x" # append to size clause for double the resolution +SCALE = "&scalebar={scale}|bottom" # true or false +TRK = "&shape=weight:2|border:{color}|{track}" +ZOOM = "&zoom={zoom}" # zoom level 1-20 + +POINTLIMIT = 500 # max number of shape points supported by MapQuest API +MAPQTIMEOUT = 5 +# how frequently the mapquest api is called to update the web map (seconds) +MAP_UPDATE_INTERVAL = 60 +MIN_UPDATE_INTERVAL = 5 +MAX_ZOOM = 20 +MIN_ZOOM = 1 +TRKCOL = "ff00ff" +# MapQuest static API map types ("dark" and "light" not used here) +MAP = "map" +SAT = "sat" +HYB = "hyb" + + +def compress_track(track: tuple, precision: int = 6, limit: int = POINTLIMIT) -> str: + """ + Convert track to compressed Mapquest format. + + :param tuple track: tuple of Points + :param int precision: no decimal places precision (6) + :param int limit: max no of points (500) + :return: compressed track + :rtype: str + """ + + # if the number of trackpoints exceeds the MapQuest API limit, + # increase step count until the number is within limits + points = [] + stp = 1 + rng = len(track) + while rng / stp > limit: + stp += 1 + for i, p in enumerate(track): + if i % stp == 0: + points.append(p.lat) + points.append(p.lon) + + # compress polygon for MapQuest API + return mapq_compress(points, precision) + + +def format_mapquest_request( + mqapikey: str, + maptype: str, + width: int, + height: int, + zoom: int, + locations: list, + bbox: Area = None, + hacc: float = 0, +) -> str: + """ + Formats URL for web map download. + + :param str mqapikey: MapQuest API key + :param str maptype: "map" or "sat" + :param int width: width of canvas + :param int height: height of canvas + :param int zoom: zoom factor + :param list locations: list of Points + :param Area bbox: bounding box (will override zoom) + :param float hacc: horizontal accuracy + :return: formatted MapQuest URL + :rtype: str + """ + # pylint: disable=too-many-arguments, too-many-positional-arguments + + url = MAPQURL.format(key=mqapikey, type=maptype, width=width, height=height) + radius = str(hacc / 1000) # km + zoom = min(20, zoom) + + if bbox is None: # use location and zoom level + url += ZOOM.format(zoom=zoom) + else: # use bounding box (remember upper left, bottom right) + url += BBOX.format( + lat2=bbox.lat2, lon1=bbox.lon1, lat1=bbox.lat1, lon2=bbox.lon2 + ) + MARGIN.format(margin=0) + + if isinstance(locations, list): # at least one location + if len(locations) > 1: # multiple locations (track) + comp = compress_track(locations) + url += LOCS.format( + lat1=locations[0].lat, + lon1=locations[0].lon, + lat2=locations[-1].lat, + lon2=locations[-1].lon, + ) + TRK.format(color=TRKCOL, track=f"cmp6|enc:{comp}") + else: # single location + url += LOC.format( + lat=locations[0].lat, lon=locations[0].lon, marker=LOCICON + ) + + if hacc > 0: + url += HACC.format( + radius=radius, lat=locations[0].lat, lon=locations[0].lon + ) + + # seems to be bug in MapQuest API which causes error + # if scalebar displayed at maximum zoom + url += SCALE.format(scale=("true" if zoom < 20 else "false")) + + return url + + +def mapq_encode(num: int) -> str: + """ + Encode number representing character. + + :param int num: number to encode + :return: encoded number as string + :rtype: str + """ + + num = num << 1 + if num < 0: + num = ~(num) + + encoded = "" + while num >= 0x20: + encoded += chr((0x20 | (num & 0x1F)) + 63) + num >>= 5 + + encoded += chr(num + 63) + return encoded + + +def mapq_decompress(encoded: str, precision: int = 6) -> list: + """ + Decompress polygon for MapQuest API. + + :param str encoded: polygon encoded as string + :param int precision: no decimal places precision (6) + :return: polygon as list of point tuples (lat,lon) + :rtype: list + """ + + precision = 10**-precision + leng = len(encoded) + index = 0 + lat = 0 + lng = 0 + array = [] + while index < leng: + shift = 0 + result = 0 + b = 0xFF + while b >= 0x20: + b = ord(encoded[index]) - 63 + index += 1 + result |= (b & 0x1F) << shift + shift += 5 + + dlat = ~(result >> 1) if (result & 1) else (result >> 1) + lat += dlat + shift = 0 + result = 0 + b = 0xFF + while b >= 0x20: + b = ord(encoded[index]) - 63 + index += 1 + result |= (b & 0x1F) << shift + shift += 5 + + dlng = ~(result >> 1) if (result & 1) else (result >> 1) + lng += dlng + array.append(lat * precision) + array.append(lng * precision) + + return array + + +def mapq_compress(points: list, precision: int = 6) -> str: + """ + Compress polygon for MapQuest API. + + :param list points: polygon as list of point tuples (lat, lon) + :param int precision: no decimal places precision (6) + :return: polygon encoded as string + :rtype: string + """ + + oldLat = 0 + oldLng = 0 + leng = len(points) + index = 0 + encoded = "" + precision = 10**precision + while index < leng: + # Round to N decimal places + lat = round(points[index] * precision) + index += 1 + lng = round(points[index] * precision) + index += 1 + + # Encode the differences between the points + encoded += mapq_encode(lat - oldLat) + encoded += mapq_encode(lng - oldLng) + + oldLat = lat + oldLng = lng + + return encoded diff --git a/src/pygpsclient/nmea_config_dialog.py b/src/pygpsclient/nmea_config_dialog.py index c2723130..f8a92947 100644 --- a/src/pygpsclient/nmea_config_dialog.py +++ b/src/pygpsclient/nmea_config_dialog.py @@ -15,7 +15,7 @@ :license: BSD 3-Clause """ -from tkinter import E, N, S, W +from tkinter import NSEW from pynmeagps import NMEAMessage @@ -99,7 +99,7 @@ def _do_layout(self): row=row, columnspan=colsp, rowspan=rowsp, - sticky=(N, S, W, E), + sticky=NSEW, ) row += rowsp # right column of grid @@ -112,7 +112,7 @@ def _do_layout(self): row=row, columnspan=colsp, rowspan=rowsp, - sticky=(N, S, W, E), + sticky=NSEW, ) row += rowsp @@ -128,7 +128,7 @@ def _reset(self): CONNECTED_SOCKET, CONNECTED_SIMULATOR, ): - self.set_status("Device not connected", ERRCOL) + self.status_label = ("Device not connected", ERRCOL) def _attach_events(self): """ diff --git a/src/pygpsclient/nmea_handler.py b/src/pygpsclient/nmea_handler.py index 2b6f9692..51db874e 100644 --- a/src/pygpsclient/nmea_handler.py +++ b/src/pygpsclient/nmea_handler.py @@ -23,7 +23,7 @@ from pynmeagps import NMEAMessage from pyubx2 import itow2utc -from pygpsclient.globals import SAT_EXPIRY +from pygpsclient.globals import SAT_EXPIRY, TKGN from pygpsclient.helpers import fix2desc, kmph2ms, knots2ms, svid2gnssid from pygpsclient.strings import DLGTNMEA @@ -219,46 +219,38 @@ def _process_GSV(self, data: NMEAMessage): :param pynmeagps.NMEAMessage data: parsed GSV sentence """ - show_unused = self.__app.configuration.get("unusedsat_b") - self.gsv_data = {} - gsv_dict = {} + gnss = TKGN.get(data.talker, 0) now = time() - if data.talker == "GA": - gnss = 2 # Galileo - elif data.talker in ("GB", "BD"): - gnss = 3 # Beidou (only available in MMEA 4.11) - elif data.talker == "GL": - gnss = 6 # GLONASS - elif data.talker == "GI": - gnss = 7 # NAVIC - else: - gnss = 0 # GPS, SBAS, QZSS for i in range(4): idx = f"_{i+1:02d}" svid = getattr(data, "svid" + idx, "") + elev = getattr(data, "elv" + idx, 0) + azim = getattr(data, "az" + idx, 0) + cno = getattr(data, "cno" + idx, 0) + if not isinstance(cno, (int, float)): + cno = 0 if svid != "": - key = f"{gnss}-{svid}" - gsv_dict[key] = ( + svid = int(svid) + if gnss == 0 and (120 <= svid <= 158): + gnss = 1 # SBAS + self.__app.gnss_status.gsv_data[(gnss, svid)] = ( gnss, svid, - getattr(data, "elv" + idx), - getattr(data, "az" + idx), - str(getattr(data, "cno" + idx)), + elev, + azim, + cno, now, ) - for key, value in gsv_dict.items(): - self.gsv_log[key] = value + # discard sats that haven't been seen in a while + for key, (_, _, _, _, _, lastupdate) in list( + self.__app.gnss_status.gsv_data.items() + ): + if now - lastupdate > SAT_EXPIRY: + del self.__app.gnss_status.gsv_data[key] - for key, (gnssId, svid, elev, azim, cno, lastupdate) in self.gsv_log.items(): - if cno in ("", "0", 0) and not show_unused: # omit unused sats - continue - if now - lastupdate < SAT_EXPIRY: # expire passed sats - self.gsv_data[key] = (gnssId, svid, elev, azim, cno) - - self.__app.gnss_status.siv = len(self.gsv_data) - self.__app.gnss_status.gsv_data = self.gsv_data + self.__app.gnss_status.siv = len(self.__app.gnss_status.gsv_data) def _process_VTG(self, data: NMEAMessage): """ diff --git a/src/pygpsclient/nmea_preset_frame.py b/src/pygpsclient/nmea_preset_frame.py index c57eede9..f6efeabd 100644 --- a/src/pygpsclient/nmea_preset_frame.py +++ b/src/pygpsclient/nmea_preset_frame.py @@ -12,6 +12,7 @@ import logging from tkinter import ( + EW, HORIZONTAL, LEFT, VERTICAL, @@ -118,15 +119,15 @@ def _do_layout(self): Layout widgets. """ - self._lbl_presets.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_presets.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._lbx_preset.grid( - column=0, row=1, columnspan=3, rowspan=20, padx=3, pady=3, sticky=(W, E) + column=0, row=1, columnspan=3, rowspan=20, padx=3, pady=3, sticky=EW ) self._scr_presetv.grid(column=2, row=1, rowspan=20, sticky=(N, S, E)) - self._scr_preseth.grid(column=0, row=21, columnspan=3, sticky=(W, E)) + self._scr_preseth.grid(column=0, row=21, columnspan=3, sticky=EW) self._btn_send_command.grid(column=3, row=1, padx=3, ipadx=3, ipady=3, sticky=E) self._lbl_send_command.grid( - column=3, row=2, padx=3, ipadx=3, ipady=3, sticky=(W, E) + column=3, row=2, padx=3, ipadx=3, ipady=3, sticky=EW ) (cols, rows) = self.grid_size() @@ -166,7 +167,7 @@ def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ if self._preset_command in ("", None): - self.__container.set_status("Select preset", ERRCOL) + self.__container.status_label = ("Select preset", ERRCOL) return confids = [] @@ -183,22 +184,16 @@ def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument if status == CONFIRMED: self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "Command(s) sent", - ) + self.__container.status_label = "Command(s) sent" for msgid in confids: self.__container.set_pending(msgid, NMEA_PRESET) elif status == CANCELLED: - self.__container.set_status( - "Command(s) cancelled", - ) + self.__container.status_label = "Command(s) cancelled" elif status == NOMINAL: - self.__container.set_status( - "Command(s) sent, no results", - ) + self.__container.status_label = "Command(s) sent, no results" except Exception as err: # pylint: disable=broad-except - self.__container.set_status(f"Error {err}", ERRCOL) + self.__container.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self._img_warn) def _do_user_defined(self, command: str) -> list: @@ -231,7 +226,7 @@ def _do_user_defined(self, command: str) -> list: # self.logger.debug(f"{str(msg)=} - {msg.serialize()=} {confids=}") self.__container.send_command(msg) except Exception as err: # pylint: disable=broad-except - self.__app.set_status(f"Error {err}", ERRCOL) + self.__container.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self._img_warn) return confids @@ -246,7 +241,7 @@ def update_status(self, msg: NMEAMessage): status = getattr(msg, "status", "OK") if status == "OK": self._lbl_send_command.config(image=self._img_confirmed) - self.__container.set_status("Preset command(s) acknowledged", OKCOL) + self.__container.status_label = ("Preset command(s) acknowledged", OKCOL) elif status == "ERROR": self._lbl_send_command.config(image=self._img_warn) - self.__container.set_status("Preset command(s) rejected", ERRCOL) + self.__container.status_label = ("Preset command(s) rejected", ERRCOL) diff --git a/src/pygpsclient/ntrip_client_dialog.py b/src/pygpsclient/ntrip_client_dialog.py index 0a2c6e10..19ae9a0d 100644 --- a/src/pygpsclient/ntrip_client_dialog.py +++ b/src/pygpsclient/ntrip_client_dialog.py @@ -21,19 +21,19 @@ from tkinter import ( DISABLED, END, + EW, HORIZONTAL, NORMAL, + NS, + NSEW, VERTICAL, Button, - E, Entry, Frame, IntVar, Label, Listbox, - N, Radiobutton, - S, Scrollbar, Spinbox, StringVar, @@ -293,24 +293,24 @@ def _do_layout(self): """ # top of grid - self._frm_body.grid(column=0, row=0, sticky=(N, S, E, W)) + self._frm_body.grid(column=0, row=0, sticky=NSEW) # body of grid self._frm_socket.grid( column=0, row=0, columnspan=3, rowspan=3, padx=3, pady=3, sticky=W ) ttk.Separator(self._frm_body).grid( - column=0, row=3, columnspan=5, padx=3, pady=3, sticky=(W, E) + column=0, row=3, columnspan=5, padx=3, pady=3, sticky=EW ) self._lbl_mountpoint.grid(column=0, row=4, padx=3, pady=3, sticky=W) self._ent_mountpoint.grid(column=1, row=4, padx=3, pady=3, sticky=W) self._lbl_mpdist.grid(column=2, row=4, columnspan=2, padx=3, pady=3, sticky=W) self._lbl_sourcetable.grid(column=0, row=5, padx=3, pady=3, sticky=W) self._lbx_sourcetable.grid( - column=1, row=5, columnspan=3, rowspan=4, padx=3, pady=3, sticky=(E, W) + column=1, row=5, columnspan=3, rowspan=4, padx=3, pady=3, sticky=EW ) - self._scr_sourcetablev.grid(column=4, row=5, rowspan=4, sticky=(N, S)) - self._scr_sourcetableh.grid(column=1, columnspan=3, row=9, sticky=(E, W)) + self._scr_sourcetablev.grid(column=4, row=5, rowspan=4, sticky=NS) + self._scr_sourcetableh.grid(column=1, columnspan=3, row=9, sticky=EW) self._lbl_ntripversion.grid(column=0, row=10, padx=3, pady=3, sticky=W) self._spn_ntripversion.grid(column=1, row=10, padx=3, pady=3, sticky=W) self._lbl_datatype.grid(column=0, row=11, padx=3, pady=3, sticky=W) @@ -322,7 +322,7 @@ def _do_layout(self): column=1, row=13, columnspan=2, padx=3, pady=3, sticky=W ) ttk.Separator(self._frm_body).grid( - column=0, row=14, columnspan=5, padx=3, pady=3, sticky=(W, E) + column=0, row=14, columnspan=5, padx=3, pady=3, sticky=EW ) self._lbl_ntripggaint.grid(column=0, row=15, padx=2, pady=3, sticky=W) self._spn_ntripggaint.grid(column=1, row=15, padx=3, pady=2, sticky=W) @@ -337,7 +337,7 @@ def _do_layout(self): self._lbl_sep.grid(column=2, row=18, padx=3, pady=2, sticky=W) self._ent_sep.grid(column=3, row=18, columnspan=2, padx=3, pady=2, sticky=W) ttk.Separator(self._frm_body).grid( - column=0, row=19, columnspan=5, padx=3, pady=3, sticky=(W, E) + column=0, row=19, columnspan=5, padx=3, pady=3, sticky=EW ) self._btn_connect.grid(column=0, row=20, padx=3, pady=3, sticky=W) self._btn_disconnect.grid(column=1, row=20, padx=3, pady=3, sticky=W) @@ -429,12 +429,12 @@ def set_controls(self, connected: bool, msgt: tuple = None): else "Disconnected" ) if msgt is None: - self.set_status(msg, INFOCOL) + self.status_label = (msg, INFOCOL) else: msg, col = msgt - self.set_status(msg, col) + self.status_label = (msg, col) - self._frm_socket.set_status(connected) + self._frm_socket.status_label = connected self._btn_disconnect.config(state=(NORMAL if connected else DISABLED)) @@ -474,20 +474,6 @@ def set_controls(self, connected: bool, msgt: tuple = None): except TclError: # fudge during thread termination pass - def set_status(self, message: str, color: str = ""): - """ - Set status message. - - :param str message: message to be displayed - :param str color: rgb color of text - """ - - color = INFOCOL if color == "blue" else color - message = f"{message[:78]}.." if len(message) > 80 else message - if color != "": - self._lbl_status.config(fg=color) - self._status.set(" " + message) - def _on_select_mp(self, event): """ Mountpoint has been selected from listbox; set @@ -700,7 +686,7 @@ def _valid_settings(self) -> bool: valid = valid & self._ent_sep.validate(VALFLOAT, -MAXALT, MAXALT) if not valid: - self.set_status("ERROR - invalid settings", ERRCOL) + self.status_label = ("ERROR - invalid settings", ERRCOL) return valid diff --git a/src/pygpsclient/receiver_config_handler.py b/src/pygpsclient/receiver_config_handler.py index c4cdc3a9..b7612a80 100644 --- a/src/pygpsclient/receiver_config_handler.py +++ b/src/pygpsclient/receiver_config_handler.py @@ -19,6 +19,28 @@ from pygpsclient.helpers import val2sphp +def config_nmea(state: int, port_type: str = "USB") -> UBXMessage: + """ + Enable or disable NMEA messages at port level and use minimum UBX + instead (NAV-PRT, NAV_SAT, NAV_DOP). + + :param int state: 1 = disable NMEA, 0 = enable NMEA + :param str port_type: port that rcvr is connected on + """ + + nmea_state = 0 if state else 1 + layers = 1 + transaction = 0 + cfg_data = [] + cfg_data.append((f"CFG_{port_type}OUTPROT_NMEA", nmea_state)) + cfg_data.append((f"CFG_{port_type}OUTPROT_UBX", 1)) + cfg_data.append((f"CFG_MSGOUT_UBX_NAV_PVT_{port_type}", state)) + cfg_data.append((f"CFG_MSGOUT_UBX_NAV_DOP_{port_type}", state)) + cfg_data.append((f"CFG_MSGOUT_UBX_NAV_SAT_{port_type}", state * 4)) + + return UBXMessage.config_set(layers, transaction, cfg_data) + + def config_disable_ublox() -> UBXMessage: """ Disable base station mode for u-blox receivers. diff --git a/src/pygpsclient/resources/icon-tty-24-green.png b/src/pygpsclient/resources/icon-tty-24-green.png deleted file mode 100644 index 2a96ee75..00000000 Binary files a/src/pygpsclient/resources/icon-tty-24-green.png and /dev/null differ diff --git a/src/pygpsclient/resources/iconmonstr-gear-2-24-brown.png b/src/pygpsclient/resources/iconmonstr-gear-2-24-brown.png deleted file mode 100644 index 8fa75b21..00000000 Binary files a/src/pygpsclient/resources/iconmonstr-gear-2-24-brown.png and /dev/null differ diff --git a/src/pygpsclient/resources/iconmonstr-gear-2-24-nmea.png b/src/pygpsclient/resources/iconmonstr-gear-2-24-nmea.png new file mode 100644 index 00000000..9e38440c Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-gear-2-24-nmea.png differ diff --git a/src/pygpsclient/resources/iconmonstr-gear-2-24-tty.png b/src/pygpsclient/resources/iconmonstr-gear-2-24-tty.png new file mode 100644 index 00000000..40f6e05e Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-gear-2-24-tty.png differ diff --git a/src/pygpsclient/resources/iconmonstr-gear-2-24-ubx.png b/src/pygpsclient/resources/iconmonstr-gear-2-24-ubx.png new file mode 100644 index 00000000..864a5c16 Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-gear-2-24-ubx.png differ diff --git a/src/pygpsclient/resources/iconmonstr-gear-2-24.png b/src/pygpsclient/resources/iconmonstr-gear-2-24.png deleted file mode 100644 index 67382b4a..00000000 Binary files a/src/pygpsclient/resources/iconmonstr-gear-2-24.png and /dev/null differ diff --git a/src/pygpsclient/rover_frame.py b/src/pygpsclient/rover_frame.py index a09674c1..51113cf7 100644 --- a/src/pygpsclient/rover_frame.py +++ b/src/pygpsclient/rover_frame.py @@ -15,7 +15,7 @@ # pylint: disable=invalid-name, no-member -from tkinter import BOTH, NW, SW, YES, Frame +from tkinter import NSEW, NW, SW, Frame from pygpsclient.canvas_plot import ( MODE_POL, @@ -79,7 +79,7 @@ def _body(self): self._canvas = CanvasCompass( self.__app, self, MODE_POL, width=self.width, height=self.height, bg=BGCOL ) - self._canvas.pack(fill=BOTH, expand=YES) + self._canvas.grid(column=0, row=0, sticky=NSEW) def _attach_events(self): """ @@ -194,6 +194,7 @@ def update_frame(self): outline=TRKCOL, tags=TAG_DATA, ) + self.update_idletasks() # plot latest relative position with accuracy radius x, y = self._canvas.d2xy(hdg, dis / self._scale_c) diff --git a/src/pygpsclient/rtcm3_handler.py b/src/pygpsclient/rtcm3_handler.py index df3cc06b..e12624e9 100644 --- a/src/pygpsclient/rtcm3_handler.py +++ b/src/pygpsclient/rtcm3_handler.py @@ -54,7 +54,6 @@ def process_data(self, raw_data: bytes, parsed_data: object): self._process_1005(parsed_data) except ValueError: - # self.__app.set_status(RTCMVALERROR.format(err), ERRCOL) pass def _process_1005(self, parsed: RTCMMessage): diff --git a/src/pygpsclient/scatter_frame.py b/src/pygpsclient/scatter_frame.py index 839925f1..cd6b875e 100644 --- a/src/pygpsclient/scatter_frame.py +++ b/src/pygpsclient/scatter_frame.py @@ -23,20 +23,18 @@ # pylint: disable=no-member from tkinter import ( + EW, HORIZONTAL, + NSEW, NW, Checkbutton, - E, Entry, Frame, IntVar, - N, - S, Scale, Spinbox, StringVar, TclError, - W, ) try: @@ -80,9 +78,9 @@ INTS = (1, 2, 5, 10, 20, 50, 100) FIXCOL = "#00EE00" PNTTOPCOL = "#FF0000" -CULLMID = True # whether to cull random points from middle of array +CULLMID = False # whether to cull random points from middle of array FIXINAUTO = False # whether to include fixed ref point in autorange -MAXPOINTS = 500 +MAXPOINTS = 500 # maximum number of in-memory points before truncation PNT = "pnt" STDINT = 10 # standard deviation calculation interval @@ -222,13 +220,13 @@ def _body(self): variable=self._scale, showvalue=False, ) - self._canvas.grid(column=0, row=0, columnspan=3, sticky=(N, S, E, W)) - self._ent_reflat.grid(column=0, row=1, sticky=(W, E)) - self._ent_reflon.grid(column=1, row=1, sticky=(W, E)) - self._spn_center.grid(column=2, row=1, sticky=(W, E)) - self._chk_autorange.grid(column=0, row=2, sticky=(W, E)) - self._spn_interval.grid(column=1, row=2, sticky=(W, E)) - self._scl_range.grid(column=2, row=2, sticky=(W, E)) + self._canvas.grid(column=0, row=0, columnspan=3, sticky=NSEW) + self._ent_reflat.grid(column=0, row=1, sticky=EW) + self._ent_reflon.grid(column=1, row=1, sticky=EW) + self._spn_center.grid(column=2, row=1, sticky=EW) + self._chk_autorange.grid(column=0, row=2, sticky=EW) + self._spn_interval.grid(column=1, row=2, sticky=EW) + self._scl_range.grid(column=2, row=2, sticky=EW) def reset(self): """ @@ -489,6 +487,7 @@ def _update_plot(self): if i == lp: break self._draw_point(pnt, PNTCOL) + self.update_idletasks() if self._fixed is not None: self._draw_point(self._fixed, FIXCOL, 3) self._draw_point(self._points[-1], PNTTOPCOL) diff --git a/src/pygpsclient/serialconfig_frame.py b/src/pygpsclient/serialconfig_frame.py index fa79e599..d1e9a89a 100644 --- a/src/pygpsclient/serialconfig_frame.py +++ b/src/pygpsclient/serialconfig_frame.py @@ -20,9 +20,11 @@ from tkinter import ( DISABLED, + EW, HORIZONTAL, LEFT, NORMAL, + NS, VERTICAL, Button, Checkbutton, @@ -33,8 +35,6 @@ IntVar, Label, Listbox, - N, - S, Scrollbar, Spinbox, StringVar, @@ -260,13 +260,11 @@ def _do_layout(self): Layout widgets. """ - self._frm_basic.grid(column=0, row=0, columnspan=4, sticky=(W, E)) + self._frm_basic.grid(column=0, row=0, columnspan=4, sticky=EW) self._lbl_port.grid(column=0, row=0, sticky=W) - self._lbx_port.grid( - column=1, row=0, columnspan=3, sticky=(W, E), padx=3, pady=2 - ) - self._scr_portv.grid(column=4, row=0, sticky=(N, S)) - self._scr_porth.grid(column=1, row=1, columnspan=3, sticky=(E, W)) + self._lbx_port.grid(column=1, row=0, columnspan=3, sticky=EW, padx=3, pady=2) + self._scr_portv.grid(column=4, row=0, sticky=NS) + self._scr_porth.grid(column=1, row=1, columnspan=3, sticky=EW) self._lbl_bpsrate.grid(column=0, row=2, sticky=W) self._spn_bpsrate.grid(column=1, row=2, sticky=W, padx=3, pady=2) self._btn_refresh.grid(column=3, row=2, sticky=E) @@ -506,7 +504,7 @@ def _on_toggle_advanced(self): self._show_advanced = not self._show_advanced if self._show_advanced: - self._frm_advanced.grid(column=0, row=1, columnspan=3, sticky=(W, E)) + self._frm_advanced.grid(column=0, row=1, columnspan=3, sticky=EW) self._btn_toggle.config(image=self._img_contract) else: self._frm_advanced.grid_forget() diff --git a/src/pygpsclient/serverconfig_frame.py b/src/pygpsclient/serverconfig_frame.py index c6959a37..b7dec45b 100644 --- a/src/pygpsclient/serverconfig_frame.py +++ b/src/pygpsclient/serverconfig_frame.py @@ -17,12 +17,13 @@ :license: BSD 3-Clause """ -# pylint: disable=unused-argument +# pylint: disable=unused-argument, too-many-lines import logging from time import sleep from tkinter import ( DISABLED, + EW, NORMAL, Button, Checkbutton, @@ -69,7 +70,6 @@ ) from pygpsclient.helpers import ( MAXPORT, - config_nmea, lanip, publicip, ) @@ -82,6 +82,7 @@ config_fixed_lg290p, config_fixed_septentrio, config_fixed_ublox, + config_nmea, config_svin_lc29h, config_svin_lg290p, config_svin_quectel, @@ -159,8 +160,7 @@ def __init__(self, app, container, *args, **kwargs): self.sock_port = StringVar() self.sock_host = StringVar() self.sock_mode = StringVar() - self._sock_clients = StringVar() - # self._set_basemode = IntVar() + self._sock_clients = IntVar() self.receiver_type = StringVar() self.base_mode = StringVar() self.https = IntVar() @@ -401,7 +401,7 @@ def _do_layout(self): Layout widgets. """ - self._frm_basic.grid(column=0, row=0, columnspan=5, sticky=(W, E)) + self._frm_basic.grid(column=0, row=0, columnspan=5, sticky=EW) self._chk_socketserve.grid( column=0, row=0, columnspan=2, rowspan=2, padx=2, pady=1, sticky=W ) @@ -452,7 +452,7 @@ def reset(self): pem, pemexists = check_pemfile() if https and not pemexists: err = DLGNOTLS.format(hostpem=pem) - self.__app.set_status(err, ERRCOL) + self.__app.status_label = (err, ERRCOL) self.logger.error(err) cfg.set("sockhttps_b", 0) self._chk_https.config(state=DISABLED) @@ -541,7 +541,7 @@ def _set_advanced(self): """ if self._show_advanced: - self._frm_advanced.grid(column=0, row=1, columnspan=5, sticky=(W, E)) + self._frm_advanced.grid(column=0, row=1, columnspan=5, sticky=EW) self._btn_toggle.config(image=self._img_contract) else: self._frm_advanced.grid_forget() @@ -568,9 +568,9 @@ def _on_socketserve(self, var, index, mode): """ if self.valid_settings(): - self.__app.set_status("", INFOCOL) + self.__app.status_label = ("", INFOCOL) else: - self.__app.set_status("ERROR - invalid entry", ERRCOL) + self.__app.status_label = ("ERROR - invalid entry", ERRCOL) return self._quectel_restart = 0 @@ -650,9 +650,9 @@ def _config_receiver(self): # validate settings if self.valid_settings(): - self.__app.set_status("", INFOCOL) + self.__app.status_label = ("", INFOCOL) else: - self.__app.set_status("ERROR - invalid entry", ERRCOL) + self.__app.status_label = ("ERROR - invalid entry", ERRCOL) return delay = self.__app.configuration.get("guiupdateinterval_f") / 2 @@ -863,7 +863,7 @@ def _on_update_https(self, var, index, mode): pem, pemexists = check_pemfile() if self.https.get() and not pemexists: err = DLGNOTLS.format(hostpem=pem) - self.__app.set_status(err, ERRCOL) + self.__app.status_label = (err, ERRCOL) self.logger.error(err) self._attach_events(False) self.https.set(0) @@ -1020,7 +1020,7 @@ def clients(self, clients: int): if self._socket_serve.get() in ("1", 1): self.__app.frm_banner.update_transmit_status(clients) - def _config_msg_rates(self, rate: int, port_type: str) -> UBXMessage: + def _config_msg_rates(self, rate: int, port_type: str): """ Configure RTCM3 and UBX NAV-SVIN message rates. @@ -1144,12 +1144,12 @@ def svin_countdown(self, ela: int, valid: bool, active: bool): self._pgb_elapsed.grid_forget() @property - def socketserving(self) -> bool: + def socketserving(self) -> int: """ Getter for socket serve flag. :return: server running True/False - :rtype: bool + :rtype: int """ return self._socket_serve.get() diff --git a/src/pygpsclient/settings_frame.py b/src/pygpsclient/settings_frame.py index c36867a3..7f7ed5dc 100644 --- a/src/pygpsclient/settings_frame.py +++ b/src/pygpsclient/settings_frame.py @@ -3,7 +3,8 @@ Settings frame class for PyGPSClient application. -- Holds all the latest settings in self.config +- Reads and updates configuration held in self.__app.configuration. +- Starts or stops data logging. - Sets initial (saved) configuration of the following frames: - frm_settings (SettingsFrame class) for general application settings. - frm_serial (SerialConfigFrame class) for serial port settings. @@ -25,6 +26,7 @@ BOTH, BOTTOM, DISABLED, + EW, HORIZONTAL, LEFT, NORMAL, @@ -77,6 +79,7 @@ ICON_SOCKET, ICON_TTYCONFIG, ICON_UBXCONFIG, + INFOCOL, KNOWNGPS, MSGMODES, NOPORTS, @@ -438,8 +441,7 @@ def _body(self): self._frm_options_btns, width=45, image=self._img_ubxconfig, - command=lambda: self._on_ubx_config(), - state=NORMAL, + command=lambda: self.__app.start_dialog(DLGTUBX), ) self._lbl_nmeaconfig = Label( self._frm_options_btns, @@ -449,7 +451,7 @@ def _body(self): self._frm_options_btns, width=45, image=self._img_nmeaconfig, - command=lambda: self._on_nmea_config(), + command=lambda: self.__app.start_dialog(DLGTNMEA), state=NORMAL, ) self._lbl_ttyconfig = Label( @@ -460,7 +462,7 @@ def _body(self): self._frm_options_btns, width=45, image=self._img_ttyconfig, - command=lambda: self._on_tty_config(), + command=lambda: self.__app.start_dialog(DLGTTTY), state=NORMAL, ) self._lbl_ntripconfig = Label( @@ -471,7 +473,7 @@ def _body(self): self._frm_options_btns, width=45, image=self._img_ntripconfig, - command=lambda: self._on_ntrip_config(), + command=lambda: self.__app.start_dialog(DLGTNTRIP), state=NORMAL, ) # socket server configuration @@ -485,36 +487,34 @@ def _do_layout(self): Position widgets in frame. """ - self.frm_serial.grid( - column=0, row=1, columnspan=4, padx=2, pady=2, sticky=(W, E) - ) + self.frm_serial.grid(column=0, row=1, columnspan=4, padx=2, pady=2, sticky=EW) ttk.Separator(self._frm_container).grid( - column=0, row=2, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=2, columnspan=4, padx=2, pady=2, sticky=EW ) self.frm_socketclient.grid( - column=0, row=3, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=3, columnspan=4, padx=2, pady=2, sticky=EW ) ttk.Separator(self._frm_container).grid( - column=0, row=4, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=4, columnspan=4, padx=2, pady=2, sticky=EW ) - self._frm_buttons.grid(column=0, row=5, columnspan=4, sticky=(W, E)) + self._frm_buttons.grid(column=0, row=5, columnspan=4, sticky=EW) self._btn_connect.grid(column=0, row=0, padx=2, pady=1) self._btn_connect_socket.grid(column=1, row=0, padx=2, pady=1) self._btn_connect_file.grid(column=2, row=0, padx=2, pady=1) self._btn_disconnect.grid(column=3, row=0, padx=2, pady=1) self._btn_exit.grid(column=4, row=0, padx=2, pady=1) - self._lbl_connect.grid(column=0, row=1, padx=1, pady=1, sticky=(W, E)) - self._lbl_connect_socket.grid(column=1, row=1, padx=1, pady=1, sticky=(W, E)) - self._lbl_connect_file.grid(column=2, row=1, padx=1, pady=1, sticky=(W, E)) - self._lbl_disconnect.grid(column=3, row=1, padx=1, pady=1, sticky=(W, E)) + self._lbl_connect.grid(column=0, row=1, padx=1, pady=1, sticky=EW) + self._lbl_connect_socket.grid(column=1, row=1, padx=1, pady=1, sticky=EW) + self._lbl_connect_file.grid(column=2, row=1, padx=1, pady=1, sticky=EW) + self._lbl_disconnect.grid(column=3, row=1, padx=1, pady=1, sticky=EW) ttk.Separator(self._frm_container).grid( - column=0, row=7, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=7, columnspan=4, padx=2, pady=2, sticky=EW ) - self._frm_options.grid(column=0, row=8, columnspan=4, sticky=(W, E)) + self._frm_options.grid(column=0, row=8, columnspan=4, sticky=EW) self._lbl_protocol.grid(column=0, row=0, padx=2, pady=2, sticky=W) self._chk_nmea.grid(column=1, row=0, padx=0, pady=0, sticky=W) self._chk_ubx.grid(column=2, row=0, padx=0, pady=0, sticky=W) @@ -546,7 +546,7 @@ def _do_layout(self): self._chk_recorddatabase.grid( column=2, row=8, columnspan=2, padx=2, pady=2, sticky=W ) - self._frm_options_btns.grid(column=0, row=9, columnspan=4, sticky=(W, E)) + self._frm_options_btns.grid(column=0, row=9, columnspan=4, sticky=EW) self._btn_ubxconfig.grid(column=0, row=0, padx=5) self._lbl_ubxconfig.grid(column=0, row=1) self._btn_nmeaconfig.grid(column=1, row=0, padx=5) @@ -556,10 +556,10 @@ def _do_layout(self): self._btn_ntripconfig.grid(column=3, row=0, padx=5) self._lbl_ntripconfig.grid(column=3, row=1) ttk.Separator(self._frm_container).grid( - column=0, row=10, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=10, columnspan=4, padx=2, pady=2, sticky=EW ) self.frm_socketserver.grid( - column=0, row=11, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=11, columnspan=4, padx=2, pady=2, sticky=EW ) def _attach_events(self, add: bool = True): @@ -782,34 +782,6 @@ def _on_update_logformat(self, var, index, mode): self.__app.configuration.set("logformat_s", self._logformat.get()) - def _on_ubx_config(self, *args, **kwargs): - """ - Open UBX configuration dialog panel. - """ - - self.__app.start_dialog(DLGTUBX) - - def _on_nmea_config(self, *args, **kwargs): - """ - Open NMEA configuration dialog panel. - """ - - self.__app.start_dialog(DLGTNMEA) - - def _on_tty_config(self, *args, **kwargs): - """ - Open TTY configuration dialog panel. - """ - - self.__app.start_dialog(DLGTTTY) - - def _on_ntrip_config(self, *args, **kwargs): - """ - Open NTRIP Client configuration dialog panel. - """ - - self.__app.start_dialog(DLGTNTRIP) - def _on_data_log(self, var, index, mode): """ Start or stop data logger. @@ -821,7 +793,10 @@ def _on_data_log(self, var, index, mode): if self.logpath is not None: self.__app.configuration.set("datalog_b", 1) self.__app.configuration.set("logpath_s", self.logpath) - self.__app.set_status(f"Data logging enabled: {self.logpath}") + self.__app.status_label = ( + f"Data logging enabled: {self.logpath}", + INFOCOL, + ) if not self.__app.file_handler.open_logfile(): self.logpath = "" self._datalog.set(0) @@ -833,7 +808,7 @@ def _on_data_log(self, var, index, mode): self.__app.configuration.set("datalog_b", 0) self._datalog.set(0) self.__app.file_handler.close_logfile() - self.__app.set_status("Data logging disabled") + self.__app.status_label = ("Data logging disabled", INFOCOL) self._spn_datalog.config(state=READONLY) def _on_record_track(self, var, index, mode): @@ -847,7 +822,7 @@ def _on_record_track(self, var, index, mode): if self.trackpath is not None: self.__app.configuration.set("recordtrack_b", 1) self.__app.configuration.set("trackpath_s", self.trackpath) - self.__app.set_status(f"Track recording enabled: {self.trackpath}") + self.__app.status_label = f"Track recording enabled: {self.trackpath}" if not self.__app.file_handler.open_trackfile(): self.trackpath = "" self._record_track.set(0) @@ -858,7 +833,7 @@ def _on_record_track(self, var, index, mode): self._record_track.set(0) self.__app.configuration.set("recordtrack_b", 0) self.__app.file_handler.close_trackfile() - self.__app.set_status("Track recording disabled") + self.__app.status_label = "Track recording disabled" def _on_record_database(self, var, index, mode): """ @@ -877,7 +852,7 @@ def _on_record_database(self, var, index, mode): self._record_database.set(0) else: self.__app.configuration.set("database_b", 0) - self.__app.set_status("Database recording disabled") + self.__app.status_label = "Database recording disabled" def _on_connect(self, conntype: int): """ @@ -901,7 +876,7 @@ def _on_connect(self, conntype: int): "inactivity_timeout": self.frm_serial.inactivity_timeout, } - self.frm_socketserver.set_status(conntype) + self.frm_socketserver.status_label = conntype if conntype == CONNECTED: frm = self.frm_serial if frm.status == NOPORTS: @@ -913,7 +888,7 @@ def _on_connect(self, conntype: int): elif conntype == CONNECTED_SOCKET: frm = self.frm_socketclient if not frm.valid_settings(): - self.__app.set_status("ERROR - invalid settings", ERRCOL) + self.__app.status_label = ("ERROR - invalid settings", ERRCOL) return connstr = f"{frm.server.get()}:{frm.port.get()}" conndict = dict(conndict, **{"socket_settings": frm}) @@ -921,6 +896,7 @@ def _on_connect(self, conntype: int): self.__app.poll_version(conndict["protocol"]) elif conntype == CONNECTED_FILE: self.infilepath = self.__app.file_handler.open_file( + self, "datalog", ( ("datalog files", "*.log"), @@ -940,9 +916,9 @@ def _on_connect(self, conntype: int): else: return - self.__app.set_connection(connstr, OKCOL) - self.__app.set_status("") self.__app.conn_status = conntype + self.__app.conn_label = (connstr, OKCOL) + self.__app.status_label = ("", INFOCOL) self._reset_frames() self.__app.stream_handler.start(self.__app, conndict) @@ -957,9 +933,9 @@ def enable_controls(self, status: int): """ - self.frm_serial.set_status(status) - self.frm_socketclient.set_status(status) - self.frm_socketserver.set_status(status) + self.frm_serial.status_label = status + self.frm_socketclient.status_label = status + self.frm_socketserver.status_label = status self._btn_connect.config( state=( diff --git a/src/pygpsclient/skyview_frame.py b/src/pygpsclient/skyview_frame.py index 6d2bd5fb..807be43a 100644 --- a/src/pygpsclient/skyview_frame.py +++ b/src/pygpsclient/skyview_frame.py @@ -14,8 +14,7 @@ # pylint: disable = no-member -from operator import itemgetter -from tkinter import BOTH, YES, Frame +from tkinter import NSEW, Frame from pygpsclient.canvas_plot import ( MODE_CEL, @@ -30,7 +29,7 @@ GNSS_LIST, WIDGETU2, ) -from pygpsclient.helpers import col2contrast, snr2col +from pygpsclient.helpers import col2contrast, snr2col, unused_sats OL_WID = 4 FONTSCALE = 30 @@ -79,7 +78,7 @@ def _body(self): height=self.height, bg=self.bg_col, ) - self._canvas.pack(fill=BOTH, expand=YES) + self._canvas.grid(column=0, row=0, sticky=NSEW) def _attach_events(self): """ @@ -107,16 +106,23 @@ def update_frame(self): """ data = self.__app.gnss_status.gsv_data + show_unused = self.__app.configuration.get("unusedsat_b") + siv = len(data) + if siv == 0: + return + siv = siv if show_unused else siv - unused_sats(data) + self.init_frame() - for d in sorted(data.values(), key=itemgetter(4)): # sort by ascending snr + for val in sorted(data.values(), key=lambda x: x[4]): # sort by ascending C/N0 try: - gnssId, prn, ele, azi, snr = d + gnssId, prn, ele, azi, cno, _ = val + if cno == 0 and not show_unused: + continue x, y = self._canvas.d2xy(int(azi), int(ele)) - snr = 0 if snr == "" else int(snr) (_, ol_col) = GNSS_LIST[gnssId] prn = f"{int(prn):02}" - bg_col = snr2col(snr) + bg_col = snr2col(cno) self._canvas.create_circle( x, y, @@ -137,7 +143,7 @@ def update_frame(self): except ValueError: pass - self._canvas.update_idletasks() + self.update_idletasks() def _on_resize(self, event): # pylint: disable=unused-argument """ diff --git a/src/pygpsclient/socketconfig_frame.py b/src/pygpsclient/socketconfig_frame.py index bb546440..7af72f35 100644 --- a/src/pygpsclient/socketconfig_frame.py +++ b/src/pygpsclient/socketconfig_frame.py @@ -18,9 +18,9 @@ from tkinter import ( DISABLED, + EW, NORMAL, Checkbutton, - E, Entry, Frame, IntVar, @@ -91,7 +91,6 @@ def __init__(self, app, container, *args, **kwargs): self._do_layout() self.reset() # self._attach_events() # done in reset - self._attach_events1() def _body(self): """ @@ -133,11 +132,9 @@ def _do_layout(self): Layout widgets. """ - self._frm_basic.grid(column=0, row=0, columnspan=4, sticky=(W, E)) + self._frm_basic.grid(column=0, row=0, columnspan=4, sticky=EW) self._lbl_server.grid(column=0, row=0, padx=2, pady=2, sticky=W) - self.ent_server.grid( - column=1, row=0, padx=2, pady=2, columnspan=4, sticky=(W, E) - ) + self.ent_server.grid(column=1, row=0, padx=2, pady=2, columnspan=4, sticky=EW) self._lbl_port.grid(column=0, row=1, padx=2, pady=2, sticky=W) self.ent_port.grid(column=1, row=1, padx=2, pady=2, sticky=W) self._lbl_protocol.grid(column=2, row=1, padx=2, pady=2, sticky=W) @@ -145,13 +142,6 @@ def _do_layout(self): self._chk_https.grid(column=1, row=2, padx=2, pady=2, sticky=W) self._chk_selfsign.grid(column=2, row=2, padx=2, pady=2, sticky=W) - def _attach_events1(self): - """ - Bind resize event to frame. - """ - - self.bind("", self._on_resize) - def _attach_events(self, add: bool = True): """ Add or remove event bindings to/from widgets. diff --git a/src/pygpsclient/spartn_dialog.py b/src/pygpsclient/spartn_dialog.py index 29a513dc..c37b6ab9 100644 --- a/src/pygpsclient/spartn_dialog.py +++ b/src/pygpsclient/spartn_dialog.py @@ -16,7 +16,8 @@ :license: BSD 3-Clause """ -from tkinter import E, N, S, W +from tkinter import NSEW +from types import NoneType from pyubx2 import UBXMessage @@ -100,7 +101,7 @@ def _do_layout(self): row=0, ipadx=5, ipady=5, - sticky=(N, S, W, E), + sticky=NSEW, ) col = 1 if self._lband_enabled: @@ -109,7 +110,7 @@ def _do_layout(self): row=0, ipadx=5, ipady=5, - sticky=(N, S, W, E), + sticky=NSEW, ) col += 1 self.frm_gnss.grid( @@ -117,7 +118,7 @@ def _do_layout(self): row=0, ipadx=5, ipady=5, - sticky=(N, S, W, E), + sticky=NSEW, ) def _reset(self): @@ -125,7 +126,7 @@ def _reset(self): Reset configuration widgets. """ - self.set_status("") + self.status_label = "" def _attach_events(self): """ @@ -134,18 +135,6 @@ def _attach_events(self): # self.bind("", self._on_resize) - def set_status(self, message: str, color: str = ""): - """ - Set status message. - :param str message: message to be displayed - :param str color: rgb color of text - """ - - message = (message[:180] + "..") if len(message) > 180 else message - if color != "": - self._lbl_status.config(fg=color) - self._status.set(" " + message) - def set_pending(self, msgid: int, spartnfrm: int): """ Set pending confirmation flag for UBX configuration frame to @@ -186,12 +175,12 @@ def update_pending(self, msg: UBXMessage): if self._pending_confs.get(msgid, None) == spartnfrm: self._pending_confs.pop(msgid) - def set_controls(self, status: bool, msgt: tuple = None): + def set_controls(self, status: bool, msgt: tuple | NoneType = None): """ Set controls in IP or L-Band clients. :param bool status: connected to SPARTN server yes/no - :param tuple msgt: tuple of (message, color) + :param tuple | Nonetype msgt: tuple of (message, color) or None """ if status == CONNECTED_SPARTNIP: @@ -199,8 +188,7 @@ def set_controls(self, status: bool, msgt: tuple = None): elif status == CONNECTED_SPARTNLB: self.frm_corrlband.set_controls(status) if msgt is not None: - msg, col = msgt - self.set_status(msg, col) + self.status_label = msgt def disconnect_ip(self, msg: str = ""): """ diff --git a/src/pygpsclient/spartn_gnss_frame.py b/src/pygpsclient/spartn_gnss_frame.py index dc6b5273..af5c8388 100644 --- a/src/pygpsclient/spartn_gnss_frame.py +++ b/src/pygpsclient/spartn_gnss_frame.py @@ -19,10 +19,10 @@ from datetime import datetime, timedelta, timezone from tkinter import ( + EW, NORMAL, Button, Checkbutton, - E, Entry, Frame, IntVar, @@ -60,6 +60,7 @@ VALHEX, VALLEN, ) +from pygpsclient.helpers import validate # pylint: disable=unused-import from pygpsclient.helpers import ( date2wnotow, parse_rxmspartnkey, @@ -249,7 +250,7 @@ def _do_layout(self): self._lbl_loadjson.grid(column=0, row=1, padx=3, pady=2, sticky=W) self._btn_loadjson.grid(column=1, row=1, columnspan=3, padx=3, pady=2, sticky=W) ttk.Separator(self).grid( - column=0, row=2, columnspan=4, padx=2, pady=3, sticky=(W, E) + column=0, row=2, columnspan=4, padx=2, pady=3, sticky=EW ) self._lbl_curr.grid(column=0, row=3, columnspan=4, padx=3, pady=2, sticky=W) self._lbl_key1.grid(column=0, row=4, padx=3, pady=2, sticky=W) @@ -257,7 +258,7 @@ def _do_layout(self): self._lbl_valdate1.grid(column=0, row=5, padx=3, pady=2, sticky=W) self._ent_valdate1.grid(column=1, row=5, columnspan=3, padx=3, pady=2, sticky=W) ttk.Separator(self).grid( - column=0, row=6, columnspan=4, padx=2, pady=3, sticky=(W, E) + column=0, row=6, columnspan=4, padx=2, pady=3, sticky=EW ) self._lbl_next.grid(column=0, row=7, columnspan=3, padx=3, pady=2, sticky=W) self._lbl_key2.grid(column=0, row=8, padx=3, pady=2, sticky=W) @@ -265,7 +266,7 @@ def _do_layout(self): self._lbl_valdate2.grid(column=0, row=9, padx=3, pady=2, sticky=W) self._ent_valdate2.grid(column=1, row=9, columnspan=3, padx=3, pady=2, sticky=W) ttk.Separator(self).grid( - column=0, row=10, columnspan=4, padx=2, pady=3, sticky=(W, E) + column=0, row=10, columnspan=4, padx=2, pady=3, sticky=EW ) self._rad_source0.grid(column=0, row=11, padx=3, pady=2, sticky=W) self._rad_source1.grid(column=1, row=11, columnspan=3, padx=3, pady=2, sticky=W) @@ -277,7 +278,7 @@ def _do_layout(self): column=2, row=13, columnspan=2, padx=3, pady=2, sticky=W ) ttk.Separator(self).grid( - column=0, row=14, columnspan=4, padx=2, pady=3, sticky=(W, E) + column=0, row=14, columnspan=4, padx=2, pady=3, sticky=EW ) self._btn_send_command.grid(column=2, row=15, padx=3, pady=2, sticky=W) self._lbl_send_command.grid(column=3, row=15, padx=3, pady=2, sticky=W) @@ -340,7 +341,7 @@ def _valid_gnss_settings(self) -> bool: valid = valid & self._ent_valdate2.validate(VALDMY) if not valid: - self.__container.set_status("ERROR - invalid settings", ERRCOL) + self.__container.status_label = ("ERROR - invalid settings", ERRCOL) return valid @@ -423,8 +424,7 @@ def _on_send_gnss_config(self): return if self.__app.conn_status != CONNECTED: - self.__container.set_status(NOTCONN, ERRCOL) - # return + self.__container.status_label = (NOTCONN, ERRCOL) if self._send_f9p_config.get(): msgc = "config" @@ -442,13 +442,13 @@ def _on_send_gnss_config(self): else: msgk = "" if msgk == "" and msgc == "": - self.__container.set_status(NULLSEND, ERRCOL) + self.__container.status_label = (NULLSEND, ERRCOL) return if msgk != "" and msgc != "": msga = " and " else: msga = "" - self.__container.set_status(f"{(msgk + msga + msgc).capitalize()} sent", OKCOL) + self.__container.status_label = f"{(msgk + msga + msgc).capitalize()} sent" self._lbl_send_command.config(image=self._img_pending) for msgid in ("RXM-SPARTNKEY", "CFG-VALGET", "ACK-ACK", "ACK-NAK"): self.__container.set_pending(msgid, SPARTN_GNSS) @@ -503,13 +503,13 @@ def update_status(self, msg: UBXMessage): col = OKCOL else: col = ERRCOL - self.__container.set_status(CONFIGRXM.format(RXMMSG, msg.numKeys), col) + self.__container.status_label = (CONFIGRXM.format(RXMMSG, msg.numKeys), col) elif msg.identity == "ACK-ACK": self._lbl_send_command.config(image=self._img_confirmed) - self.__container.set_status(CONFIGOK.format(CFGSET), OKCOL) + self.__container.status_label = (CONFIGOK.format(CFGSET), OKCOL) elif msg.identity == "ACK-NAK": self._lbl_send_command.config(image=self._img_warn) - self.__container.set_status(CONFIGBAD.format(CFGSET), ERRCOL) + self.__container.status_label = (CONFIGBAD.format(CFGSET), ERRCOL) self.update_idletasks() def _on_load_json(self): @@ -519,7 +519,7 @@ def _on_load_json(self): # pylint: disable=unused-variable jsonfile = self.__app.file_handler.open_file( - "spartnjson", (("json files", "*.json"), ("all files", "*.*")) + self, "spartnjson", (("json files", "*.json"), ("all files", "*.*")) ) if jsonfile is None: return @@ -536,6 +536,6 @@ def _on_load_json(self): (key, start, _) = spc.next_key self._spartn_key2.set(key) self._spartn_valdate2.set(start.strftime("%Y%m%d")) - self.__container.set_status(DLGJSONOK.format(jsonfile), OKCOL) + self.__container.status_label = (DLGJSONOK.format(jsonfile), OKCOL) except Exception as err: # pylint: disable=broad-exception-caught - self.__container.set_status(DLGJSONERR.format(err), ERRCOL) + self.__container.status_label = (DLGJSONERR.format(err), ERRCOL) diff --git a/src/pygpsclient/spartn_lband_frame.py b/src/pygpsclient/spartn_lband_frame.py index 589f0fee..134779cf 100644 --- a/src/pygpsclient/spartn_lband_frame.py +++ b/src/pygpsclient/spartn_lband_frame.py @@ -17,16 +17,15 @@ from tkinter import ( DISABLED, + EW, NORMAL, + NSEW, Button, Checkbutton, - E, Entry, Frame, IntVar, Label, - N, - S, Spinbox, StringVar, TclError, @@ -67,6 +66,7 @@ TRACEMODE_WRITE, VALINT, ) +from pygpsclient.helpers import validate # pylint: disable=unused-import from pygpsclient.serialconfig_lband_frame import SerialConfigLbandFrame from pygpsclient.strings import CONFIGBAD, CONFIGOK, DLGSPARTNWARN, LBLSPARTNLB @@ -310,10 +310,10 @@ def _do_layout(self): column=0, row=0, columnspan=4, padx=3, pady=2, sticky=W ) self._frm_spartn_serial.grid( - column=0, row=1, columnspan=4, padx=3, pady=2, sticky=(N, S, W, E) + column=0, row=1, columnspan=4, padx=3, pady=2, sticky=NSEW ) ttk.Separator(self).grid( - column=0, row=2, columnspan=4, padx=2, pady=3, sticky=(W, E) + column=0, row=2, columnspan=4, padx=2, pady=3, sticky=EW ) self._lbl_freq.grid(column=0, row=3, sticky=W) self._ent_freq.grid(column=1, row=3, sticky=W) @@ -337,7 +337,7 @@ def _do_layout(self): self._lbl_ebno.grid(column=0, row=11, sticky=W) self._lbl_fec.grid(column=1, row=11, sticky=W) ttk.Separator(self).grid( - column=0, row=12, columnspan=4, padx=2, pady=3, sticky=(W, E) + column=0, row=12, columnspan=4, padx=2, pady=3, sticky=EW ) self._btn_connect.grid(column=0, row=13, padx=3, pady=2, sticky=W) self._btn_disconnect.grid(column=1, row=13, padx=3, pady=2, sticky=W) @@ -383,7 +383,7 @@ def _reset(self): self._spartn_descrminit.set(cfg.get("lbandclientdescrminit_n")) self._spartn_unqword.set(cfg.get("lbandclientunqword_s")) self._spartn_outport.set(cfg.get("lbandclientoutport_s")) - self.__container.set_status("") + self.__container.status_label = "" if self.__app.rtk_conn_status == CONNECTED_SPARTNLB: self.set_controls(CONNECTED_SPARTNLB) else: @@ -418,7 +418,7 @@ def set_controls(self, status: int): :param int status: connection status (0 = disconnected, 1 = connected) """ - self._frm_spartn_serial.set_status(status) + self._frm_spartn_serial.status_label = status stat = DISABLED if status == CONNECTED_SPARTNLB else NORMAL for wdg in (self._btn_connect,): wdg.config(state=stat) @@ -461,7 +461,7 @@ def _valid_settings(self) -> bool: valid = valid & self._ent_unqword.validate(VALINT, 0, U8MAX) # U8 if not valid: - self.__container.set_status("ERROR - invalid settings", ERRCOL) + self.__container.status_label = ("ERROR - invalid settings", ERRCOL) return valid @@ -472,7 +472,7 @@ def on_connect(self): frm = self._frm_spartn_serial if self.__app.rtk_conn_status == CONNECTED_SPARTNIP: - self.__container.set_status( + self.__container.status_label = ( DLGSPARTNWARN.format("IP", "L-Band"), ERRCOL, ) @@ -497,8 +497,8 @@ def on_connect(self): # start serial stream thread self.__app.rtk_conn_status = CONNECTED_SPARTNLB self.__app.spartn_stream_handler.start(self.__container, conndict) - self.__container.set_status( - f"Connected to {frm.port}:{frm.port_desc} @ {frm.bpsrate}", OKCOL + self.__container.status_label = ( + f"Connected to {frm.port}:{frm.port_desc} @ {frm.bpsrate}" ) self.set_controls(CONNECTED_SPARTNLB) @@ -516,10 +516,7 @@ def on_disconnect(self, msg: str = ""): if self.__app.spartn_stream_handler is not None: self.__app.spartn_stream_handler.stop() self.__app.rtk_conn_status = DISCONNECTED - self.__container.set_status( - msg, - ERRCOL, - ) + self.__container.status_label = (msg, ERRCOL) self.set_controls(DISCONNECTED) def on_error(self, event): # pylint: disable=unused-argument @@ -627,7 +624,7 @@ def _on_send_config(self): msg = self._format_cfgcorr() self._send_command(msg) - self.__container.set_status(f"{CFGSET} command sent", OKCOL) + self.__container.status_label = f"{CFGSET} command sent" self._lbl_send.config(image=self._img_pending) # save config to persistent memory @@ -680,14 +677,14 @@ def update_status(self, msg: UBXMessage): self._spartn_prescrm.set(msg.CFG_PMP_USE_PRESCRAMBLING) if hasattr(msg, "CFG_PMP_UNIQUE_WORD"): self._spartn_unqword.set(msg.CFG_PMP_UNIQUE_WORD) - self.__container.set_status(f"{CFGPOLL} received", OKCOL) + self.__container.status_label = (f"{CFGPOLL} received", OKCOL) self._lbl_send.config(image=self._img_confirmed) elif msg.identity == "ACK-ACK": self._lbl_send.config(image=self._img_confirmed) - self.__container.set_status(CONFIGOK.format(CFGSET), OKCOL) + self.__container.status_label = (CONFIGOK.format(CFGSET), OKCOL) elif msg.identity == "ACK-NAK": self._lbl_send.config(image=self._img_warn) - self.__container.set_status(CONFIGBAD.format(CFGSET), ERRCOL) + self.__container.status_label = (CONFIGBAD.format(CFGSET), ERRCOL) elif msg.identity == "RXM-PMP": self._lbl_ebno.config(text=f"Eb/N0: {msg.ebno} dB") self._lbl_fec.config(text=f"FEC Bits: {msg.fecBits}") diff --git a/src/pygpsclient/spartn_mqtt_frame.py b/src/pygpsclient/spartn_mqtt_frame.py index 069e52b5..2ebcc79c 100644 --- a/src/pygpsclient/spartn_mqtt_frame.py +++ b/src/pygpsclient/spartn_mqtt_frame.py @@ -22,6 +22,7 @@ from pathlib import Path from tkinter import ( DISABLED, + EW, NORMAL, Button, Checkbutton, @@ -76,7 +77,7 @@ VALINT, VALLEN, ) -from pygpsclient.helpers import MAXPORT +from pygpsclient.helpers import MAXPORT, validate # pylint: disable=unused-import from pygpsclient.strings import DLGSPARTNWARN, LBLSPARTNIP, MQTTCONN @@ -279,19 +280,15 @@ def _do_layout(self): self._chk_mqtt_keytopic.grid(column=3, row=5, padx=3, pady=2, sticky=W) self._chk_mqtt_freqtopic.grid(column=4, row=5, padx=3, pady=2, sticky=W) self._lbl_mqttcrt.grid(column=0, row=6, padx=3, pady=2, sticky=W) - self._ent_mqttcrt.grid( - column=1, row=6, padx=3, columnspan=3, pady=2, sticky=(W, E) - ) + self._ent_mqttcrt.grid(column=1, row=6, padx=3, columnspan=3, pady=2, sticky=EW) self._btn_opencrt.grid(column=4, row=6, padx=3, pady=2, sticky=E) self._lbl_mqttpem.grid(column=0, row=7, padx=3, pady=2, sticky=W) - self._ent_mqttpem.grid( - column=1, row=7, columnspan=3, padx=3, pady=2, sticky=(W, E) - ) + self._ent_mqttpem.grid(column=1, row=7, columnspan=3, padx=3, pady=2, sticky=EW) self._btn_openpem.grid(column=4, row=7, padx=3, pady=2, sticky=E) self._lbl_spartndecode.grid(column=0, row=8, columnspan=2, sticky=W) self._chk_spartndecode.grid(column=2, row=8, sticky=W) ttk.Separator(self).grid( - column=0, row=9, columnspan=6, padx=2, pady=3, sticky=(W, E) + column=0, row=9, columnspan=6, padx=2, pady=3, sticky=EW ) self._btn_connect.grid(column=0, row=10, padx=3, pady=2, sticky=W) self._btn_disconnect.grid(column=2, row=10, padx=3, pady=2, sticky=W) @@ -324,9 +321,7 @@ def _reset(self): self._get_settings() self._reset_keypaths(self._mqtt_clientid.get()) - self.__container.set_status( - "", - ) + self.__container.status_label = "" if self.__app.rtk_conn_status == CONNECTED_SPARTNIP: self.set_controls(CONNECTED_SPARTNIP) else: @@ -519,6 +514,7 @@ def _get_spartncerts(self, ext: str): """ spfile = self.__app.file_handler.open_file( + self, "spartncert", ( ("spartn files", f"*.{ext}"), @@ -536,7 +532,7 @@ def on_connect(self): """ if self.__app.rtk_conn_status == CONNECTED_SPARTNLB: - self.__container.set_status( + self.__container.status_label = ( DLGSPARTNWARN.format("L-Band", "IP"), ERRCOL, ) @@ -571,13 +567,10 @@ def on_connect(self): output=self._settings["output"], ) self.set_controls(CONNECTED_SPARTNIP) - self.__container.set_status( - MQTTCONN.format(server), - OKCOL, - ) + self.__container.status_label = (MQTTCONN.format(server), OKCOL) self.__app.rtk_conn_status = CONNECTED_SPARTNIP else: - self.__container.set_status("ERROR! Invalid Settings", ERRCOL) + self.__container.status_label = ("ERROR! Invalid Settings", ERRCOL) def on_disconnect(self, msg: str = ""): """ @@ -590,10 +583,7 @@ def on_disconnect(self, msg: str = ""): if self.__app.rtk_conn_status == CONNECTED_SPARTNIP: self.__app.spartn_handler.stop() self.__app.rtk_conn_status = DISCONNECTED - self.__container.set_status( - msg, - ERRCOL, - ) + self.__container.status_label = (msg, ERRCOL) self.set_controls(DISCONNECTED) def update_status(self, msg: UBXMessage): diff --git a/src/pygpsclient/spectrum_frame.py b/src/pygpsclient/spectrum_frame.py index c4aea6b2..60196b66 100644 --- a/src/pygpsclient/spectrum_frame.py +++ b/src/pygpsclient/spectrum_frame.py @@ -16,7 +16,7 @@ # pylint: disable=no-member, unused-argument import logging -from tkinter import ALL, NW, Checkbutton, E, Frame, IntVar, N, S, W +from tkinter import ALL, EW, NSEW, NW, Checkbutton, Frame, IntVar, S, W from pyubx2 import UBXMessage @@ -143,8 +143,8 @@ def _body(self): variable=self._pgaoffset, anchor=W, ) - self._canvas.grid(column=0, row=0, columnspan=3, sticky=(N, S, E, W)) - self.chk_pgaoffset.grid(column=0, row=1, sticky=(W, E)) + self._canvas.grid(column=0, row=0, columnspan=3, sticky=NSEW) + self.chk_pgaoffset.grid(column=0, row=1, sticky=EW) def _attach_events(self): """ @@ -333,6 +333,7 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None width=OL_WID, tags=(mode, TAG_DATA), ) + self.update_idletasks() # display any marked db/hz coordinate if self._chartpos is not None: diff --git a/src/pygpsclient/sqlite_handler.py b/src/pygpsclient/sqlite_handler.py index e5933167..a9bea021 100644 --- a/src/pygpsclient/sqlite_handler.py +++ b/src/pygpsclient/sqlite_handler.py @@ -26,6 +26,7 @@ import traceback from datetime import datetime, timezone from os import path +from types import NoneType from pynmeagps import ecef2llh @@ -127,8 +128,9 @@ def _create( """ try: - self.__app.set_status( - f"Database {self._db} initialising - please wait...", INFOCOL + self.__app.status_label = ( + f"Database {self._db} initialising - please wait...", + INFOCOL, ) self.logger.debug("Spatial metadata initialisation in progress...") self._connection.execute("SELECT InitSpatialMetaData();") @@ -137,7 +139,10 @@ def _create( self._cursor.executescript(SQLC1.format(table=tbname)) return SQLOK except sqlite3.Error as err: - self.__app.set_status(f"Error initialising spatial database {err}", ERRCOL) + self.__app.status_label = ( + f"Error initialising spatial database {err}", + ERRCOL, + ) self.logger.debug(traceback.format_exc()) return SQLERR @@ -146,7 +151,7 @@ def open( dbpath: str = HOME, dbname: str = DBNAME, tbname: str = TBNAME, - ) -> str: + ) -> str | int: """ Create sqlite3 connection and cursor. @@ -154,7 +159,7 @@ def open( :param str dbname: name of sqlite3 database file :param str tbname: name of table containing gnss data :return: result - :rtype: str + :rtype: str | int """ testing = dbname == DBINMEM @@ -181,18 +186,18 @@ def open( if testing: self._connection.close() else: - self.__app.set_status(f"Database {self._db} opened", OKCOL) + self.__app.status_label = (f"Database {self._db} opened", OKCOL) return SQLOK except AttributeError as err: - self.__app.set_status(f"SQL error: {err}", errcol) + self.__app.status_label = (f"SQL error: {err}", errcol) self.logger.debug(traceback.format_exc()) return NOEXT # extensions not supported except sqlite3.OperationalError as err: - self.__app.set_status(f"SQL error {db}: {err}", errcol) + self.__app.status_label = (f"SQL error {db}: {err}", errcol) self.logger.debug(traceback.format_exc()) return NOMODS # no mod_spatial extension found except sqlite3.Error as err: - self.__app.set_status(f"SQL error {db}: {err}", errcol) + self.__app.status_label = (f"SQL error {db}: {err}", errcol) self.logger.debug(traceback.format_exc()) return SQLERR # other sqlite error @@ -257,17 +262,17 @@ def load_data(self, ignore_null: bool = True) -> str: self.logger.debug(f"Executed SQL statement {sql}") return SQLOK except sqlite3.Error as err: - self.__app.set_status(f"SQL error: {err}", ERRCOL) + self.__app.status_label = (f"SQL error: {err}", ERRCOL) self.logger.debug(traceback.format_exc()) return SQLERR @property - def database(self) -> str: + def database(self) -> str | NoneType: """ Getter for database name. - :return: database path/name - :rtype: str + :return: database path/name or None + :rtype: str | NoneType """ return self._db diff --git a/src/pygpsclient/status_frame.py b/src/pygpsclient/status_frame.py index ac9fb290..9830d10e 100644 --- a/src/pygpsclient/status_frame.py +++ b/src/pygpsclient/status_frame.py @@ -12,7 +12,7 @@ :license: BSD 3-Clause """ -from tkinter import VERTICAL, E, Frame, Label, N, S, StringVar, W, ttk +from tkinter import EW, NS, VERTICAL, Frame, Label, W, ttk class StatusFrame(Frame): @@ -33,8 +33,6 @@ def __init__(self, app, *args, **kwargs): self.__master = self.__app.appmaster # Reference to root class (Tk) Frame.__init__(self, self.__master, *args, **kwargs) - self._status = StringVar() - self._connection = StringVar() self.width, self.height = self.get_size() self._body() @@ -49,49 +47,11 @@ def _body(self): self.option_add("*Font", self.__app.font_md) - self._lbl_connection = Label(self, textvariable=self._connection, anchor=W) - self._lbl_status_preset = Label(self, textvariable=self._status, anchor=W) - self._lbl_connection.grid(column=0, row=0, sticky=(W, E)) - ttk.Separator(self, orient=VERTICAL).grid(column=1, row=0, sticky=(N, S)) - self._lbl_status_preset.grid(column=2, row=0, sticky=(W, E)) - - def set_connection(self, connection: str, color: str = ""): - """ - Sets connection description in status bar. - - :param str connection: description of connection - :param str color: rgb color string (default=blue) - - """ - - if len(connection) > 100: - connection = "..." + connection[-100:] - if color != "": - self._lbl_connection.config(fg=color) - self._connection.set(" " + connection) - - def set_status(self, message, color: str = ""): - """ - Sets message in status bar. - - :param str message: message to be displayed in status bar - :param str color: rgb color string (default=blue) - - """ - - if len(message) > 200: - message = "..." + message[-200:] - if color != "": - self._lbl_status_preset.config(fg=color) - self._status.set(" " + message) - - def clear_status(self): - """ - Clears status bar. - """ - - self._connection.set("") - self._status.set("") + self.lbl_connection = Label(self, anchor=W) + self.lbl_status = Label(self, anchor=W) + self.lbl_connection.grid(column=0, row=0, sticky=EW) + ttk.Separator(self, orient=VERTICAL).grid(column=1, row=0, sticky=NS) + self.lbl_status.grid(column=2, row=0, sticky=EW) def _on_resize(self, event): # pylint: disable=unused-argument """ @@ -112,6 +72,4 @@ def get_size(self): """ self.update_idletasks() # Make sure we know about any resizing - width = self.winfo_width() - height = self.winfo_height() - return (width, height) + return self.winfo_width(), self.winfo_height() diff --git a/src/pygpsclient/stream_handler.py b/src/pygpsclient/stream_handler.py index 1a185e7d..eb4dec2d 100644 --- a/src/pygpsclient/stream_handler.py +++ b/src/pygpsclient/stream_handler.py @@ -16,7 +16,7 @@ class to read and parse incoming data from the receiver. It places - SettingsFrame - i/o with the main GNSS receiver. - SpartnLbandDialog - i/o with a SPARTN L-Band receiver when SPARTN Client active. -The caller object can implement a 'set_status()' method to +The caller object can implement a 'status_label = ()' method to display any status messages output by StreamHandler. Created on 16 Sep 2020 @@ -27,12 +27,22 @@ class to read and parse incoming data from the receiver. It places """ import logging -import socket import ssl from datetime import datetime, timedelta +from io import BufferedReader from queue import Empty +from socket import ( + AF_INET, + AF_INET6, + SOCK_DGRAM, + SOCK_STREAM, + gaierror, + getaddrinfo, + socket, +) from threading import Event, Thread from time import sleep +from tkinter import Frame, Label, Tk from certifi import where as findcacerts from pygnssutils import ( @@ -88,11 +98,11 @@ def __init__(self, app): self._stopevent = Event() self._ttyevent = Event() - def start(self, caller: object, settings: dict): + def start(self, caller: Frame, settings: dict): """ Start the stream read thread. - :param caller owner: calling object + :param Frame caller: calling Frame :param dict settings: settings dictionary """ @@ -100,9 +110,10 @@ def start(self, caller: object, settings: dict): self._stream_thread = Thread( target=self._read_thread, args=( - caller, + self.__master, self._stopevent, settings, + caller.status_label, # for status update messages ), daemon=True, ) @@ -118,9 +129,10 @@ def stop(self): def _read_thread( self, - caller, + master: Tk, stopevent: Event, settings: dict, + status: Label, ): """ THREADED PROCESS @@ -161,6 +173,7 @@ def _read_thread( ) as stream: if settings["protocol"] & TTY_PROTOCOL: self._readlooptty( + master, stopevent, stream, settings, @@ -168,6 +181,7 @@ def _read_thread( ) else: self._readloop( + master, stopevent, stream, settings, @@ -178,6 +192,7 @@ def _read_thread( in_filepath = settings["in_filepath"] with open(in_filepath, "rb") as stream: self._readloop( + master, stopevent, stream, settings, @@ -191,16 +206,16 @@ def _read_thread( https = int(soc.https.get()) selfsign = int(soc.selfsign.get()) if soc.protocol.get()[-4:] == "IPv6": - afam = socket.AF_INET6 - conn = socket.getaddrinfo(server, port)[1][4] + afam = AF_INET6 + conn = getaddrinfo(server, port)[1][4] else: # IPv4 - afam = socket.AF_INET + afam = AF_INET conn = (server, port) if soc.protocol.get()[:3] == "UDP": - socktype = socket.SOCK_DGRAM + socktype = SOCK_DGRAM else: # TCP - socktype = socket.SOCK_STREAM - with socket.socket(afam, socktype) as stream: + socktype = SOCK_STREAM + with socket(afam, socktype) as stream: if https: context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.load_verify_locations(findcacerts()) @@ -211,9 +226,10 @@ def _read_thread( context.check_hostname = False stream = context.wrap_socket(stream, server_hostname=server) stream.connect(conn) - if socktype == socket.SOCK_DGRAM: + if socktype == SOCK_DGRAM: stream.send(b"") # send empty datagram to establish connection self._readloop( + master, stopevent, stream, settings, @@ -224,6 +240,7 @@ def _read_thread( with UBXSimulator() as stream: if settings["protocol"] & TTY_PROTOCOL: self._readlooptty( + master, stopevent, stream, settings, @@ -231,6 +248,7 @@ def _read_thread( ) else: self._readloop( + master, stopevent, stream, settings, @@ -239,28 +257,29 @@ def _read_thread( except EOFError: stopevent.set() - self.__master.event_generate(settings["eof_event"]) + master.event_generate(settings["eof_event"]) except TimeoutError: stopevent.set() - self.__master.event_generate(settings["timeout_event"]) + master.event_generate(settings["timeout_event"]) except ( IOError, SerialException, SerialTimeoutException, OSError, AttributeError, - socket.gaierror, + gaierror, ) as err: if not stopevent.is_set(): stopevent.set() - self.__master.event_generate(settings["error_event"]) - if hasattr(caller, "set_status"): - caller.set_status(str(err), ERRCOL) + master.event_generate(settings["error_event"]) + # use after(0) to avoid tkinter main thread contention + status.after(0, status.config, {"text": str(err), "fg": ERRCOL}) def _readloop( self, + master: Tk, stopevent: Event, - stream: object, + stream: Serial | BufferedReader | socket, settings: dict, inactivity: int, ): @@ -272,7 +291,7 @@ def _readloop( prevent thrashing. :param Event stopevent: thread stop event - :param object stream: serial data stream + :param Serial | BufferedReader stream: serial data stream :param dict settings: settings dictionary :param int inactivity: inactivity timeout (s) """ @@ -286,7 +305,7 @@ def _errorhandler(err: Exception): parsed_data = f"Error parsing data stream {err}" settings["inqueue"].put((raw_data, parsed_data)) - self.__master.event_generate(settings["read_event"]) + master.event_generate(settings["read_event"]) conntype = settings["conntype"] @@ -320,7 +339,7 @@ def _errorhandler(err: Exception): raw_data, parsed_data = ubr.read() if raw_data is not None: settings["inqueue"].put((raw_data, parsed_data)) - self.__master.event_generate(settings["read_event"]) + master.event_generate(settings["read_event"]) lastevent = datetime.now() else: # timeout or eof if conntype == CONNECTED_FILE: @@ -331,7 +350,6 @@ def _errorhandler(err: Exception): raise TimeoutError if conntype == CONNECTED_FILE: lastread = datetime.now() - self.__master.update_idletasks() # write any queued output data to serial stream if conntype in (CONNECTED, CONNECTED_SOCKET): @@ -365,10 +383,14 @@ def _errorhandler(err: Exception): _errorhandler(err) continue + # allow for any tkinter events e.g. dialogs + self.__app.update_idletasks() + def _readlooptty( self, + master: Tk, stopevent: Event, - stream: object, + stream: Serial, settings: dict, delay: float = 0.0, ): @@ -377,7 +399,7 @@ def _readlooptty( TTY (ASCII) Read stream continously until stop event or stream error. :param Event stopevent: thread stop event - :param object stream: serial data stream + :param Serial stream: serial data stream :param dict settings: settings dictionary :param float delay: delay between commands (secs) """ @@ -391,7 +413,7 @@ def _errorhandler(err: Exception): parsed_data = f"Error parsing data stream {err}" settings["inqueue"].put((raw_data, parsed_data)) - self.__master.event_generate(settings["read_event"]) + master.event_generate(settings["read_event"]) raw_data = None while not stopevent.is_set(): @@ -421,7 +443,7 @@ def _errorhandler(err: Exception): settings["inqueue"].put( (raw_data, raw_data.decode(ASCII, errors=BSR)) ) - self.__master.event_generate(settings["read_event"]) + master.event_generate(settings["read_event"]) except (ValueError, SerialException) as err: _errorhandler(err) diff --git a/src/pygpsclient/strings.py b/src/pygpsclient/strings.py index 43eab2bb..31791e2d 100644 --- a/src/pygpsclient/strings.py +++ b/src/pygpsclient/strings.py @@ -44,10 +44,11 @@ FILEOPENERROR = "Error opening file {}" INACTIVE_TIMEOUT = "Inactivity timeout" KILLSWITCH = "Running threads terminated by user" -LOADCONFIGNK = "Unrecognised configuration setting '{}: {}'; using defaults" -LOADCONFIGBAD = "Configuration not loaded {} {}; using defaults" -LOADCONFIGOK = "Configuration loaded {}" -LOADCONFIGNONE = "Configuration file not found {}; using defaults" +LOADCONFIGNK = "Unrecognised configuration setting '{}: {}'" +LOADCONFIGBAD = "Configuration not loaded {} {}. Using defaults" +LOADCONFIGOK = "Configuration loaded {}{}" +LOADCONFIGRESAVE = ". Consider re-saving" +LOADCONFIGNONE = "Configuration file not found {}. Using defaults" MAPCONFIGERR = "Custom map configuration error" MAPOPENERR = "Unable to open custom map:\n{}" MQTTCONN = "Connecting to MQTT server {}..." @@ -137,7 +138,7 @@ LBLSERVERMODE = "Mode" LBLSERVERPORT = "Port" LBLSET = "Settings" -LBLSHOWUNUSED = "Show Unused Satellites" +LBLSHOWUNUSED = "Include C/N0 = 0" LBLSOCKSERVE = "Socket Server /\nNTRIP Caster " # padded to align LBLSPARTNCONFIG = "SPARTN Client" LBLSPARTNGN = "GNSS RECEIVER CONFIGURATION (F9*)" @@ -189,29 +190,3 @@ DLGTIMPORTMAP = "Import Custom Map" DLGTTTY = "TTY Commands" DLGNOTLS = "TLS certificate '{hostpem}' not found" - -# UBX Preset Command Descriptions -PSTALLINFOFF = "CFG-INF - Turn OFF all non-error INF msgs" -PSTALLINFON = "CFG-INF - Turn ON all INF msgs" -PSTALLLOGOFF = "CFG-MSG - Turn OFF all LOG msgs" -PSTALLLOGON = "CFG-MSG - Turn ON all LOG msgs" -PSTALLMONOFF = "CFG-MSG - Turn OFF all MON msgs" -PSTALLMONON = "CFG-MSG - Turn ON all MON msgs" -PSTALLNMEAOFF = "CFG-MSG - Turn OFF all NMEA msgs" -PSTALLNMEAON = "CFG-MSG - Turn ON all NMEA msgs" -PSTALLRXMOFF = "CFG-MSG - Turn OFF all RXM msgs" -PSTALLRXMON = "CFG-MSG - Turn ON all RXM msgs" -PSTALLSECOFF = "CFG-MSG - Turn OFF all SEC msgs" -PSTALLSECON = "CFG-MSG - Turn ON all SEC msgs" -PSTALLUBXOFF = "CFG-MSG - Turn OFF all UBX NAV msgs" -PSTALLUBXON = "CFG-MSG - Turn ON all UBX NAV msgs" -PSTMINNMEAON = "CFG-MSG - Turn ON minimum NMEA msgs" -PSTMINUBXON = "CFG-MSG - Turn ON minimum UBX NAV msgs" -PSTPOLLALLCFG = "CFG-xxx - Poll All Configuration Messages" -PSTPOLLALLNAV = "NAV(2)-xxx - Poll All Navigation Messages" -PSTPOLLINFO = "CFG-INF - Poll Info message config" -PSTPOLLPORT = "CFG-PRT - Poll Port config" -PSTRESET = "CFG-CFG - RESTORE FACTORY DEFAULTS" -PSTSAVE = "CFG-CFG - Save configuration to non-volatile memory" -PSTUSENMEA = "CFG-MSG - Enable NMEA, Suppress UBX" -PSTUSEUBX = "CFG-MSG - Enable UBX, Suppress NMEA" diff --git a/src/pygpsclient/sysmon_frame.py b/src/pygpsclient/sysmon_frame.py index 1853a3a0..6a725724 100644 --- a/src/pygpsclient/sysmon_frame.py +++ b/src/pygpsclient/sysmon_frame.py @@ -14,7 +14,7 @@ :license: BSD 3-Clause """ -from tkinter import ALL, NW, Canvas, E, Frame, IntVar, N, Radiobutton, S, W +from tkinter import ALL, EW, NSEW, NW, Canvas, E, Frame, IntVar, Radiobutton, W from pyubx2 import BOOTTYPE, UBXMessage @@ -106,8 +106,8 @@ def _body(self): bg=BGCOL, # selectcolor=BGCOL, ) - self._can_sysmon.grid(column=0, row=0, padx=0, pady=0, sticky=(N, S, W, E)) - self._frm_status.grid(column=0, row=1, padx=2, pady=2, sticky=(W, E)) + self._can_sysmon.grid(column=0, row=0, padx=0, pady=0, sticky=NSEW) + self._frm_status.grid(column=0, row=1, padx=2, pady=2, sticky=EW) self._rad_actual.grid(column=0, row=0, padx=0, pady=0, sticky=W) self._rad_pending.grid(column=1, row=0, padx=0, pady=0, sticky=W) self.grid_columnconfigure(0, weight=1) diff --git a/src/pygpsclient/toplevel_dialog.py b/src/pygpsclient/toplevel_dialog.py index 413410ed..f4f25285 100644 --- a/src/pygpsclient/toplevel_dialog.py +++ b/src/pygpsclient/toplevel_dialog.py @@ -12,9 +12,13 @@ :license: BSD 3-Clause """ +import logging from tkinter import ( ALL, + EW, HORIZONTAL, + NS, + NSEW, NW, VERTICAL, Button, @@ -22,10 +26,7 @@ E, Frame, Label, - N, - S, Scrollbar, - StringVar, Toplevel, W, ) @@ -33,6 +34,7 @@ from PIL import Image, ImageTk from pygpsclient.globals import ( + APPNAME, ERRCOL, ICON_BLANK, ICON_CONFIRMED, @@ -46,11 +48,13 @@ ICON_SEND, ICON_START, ICON_WARNING, + INFOCOL, MINHEIGHT, MINWIDTH, RESIZE, ) from pygpsclient.helpers import check_lowres +from pygpsclient.strings import DLG class ToplevelDialog(Toplevel): @@ -70,6 +74,7 @@ def __init__(self, app, dlgname: str, dim: tuple = (MINHEIGHT, MINWIDTH)): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) self._dlgname = dlgname + self.logger = logging.getLogger(f"{APPNAME}.{dlgname}") self.lowres, (self.height, self.width) = check_lowres(self.__master, dim) super().__init__() @@ -94,7 +99,6 @@ def __init__(self, app, dlgname: str, dim: tuple = (MINHEIGHT, MINWIDTH)): self.img_send = ImageTk.PhotoImage(Image.open(ICON_SEND)) self.img_start = ImageTk.PhotoImage(Image.open(ICON_START)) self.img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) - self._status = StringVar() self._con_body() @@ -124,9 +128,9 @@ def _con_body(self): self._frm_container = Frame( self._can_container, borderwidth=2, relief="groove" ) - self._can_container.grid(column=0, row=0, sticky=(N, S, E, W)) - x_scrollbar.grid(column=0, row=1, sticky=(E, W)) - y_scrollbar.grid(column=1, row=0, sticky=(N, S)) + self._can_container.grid(column=0, row=0, sticky=NSEW) + x_scrollbar.grid(column=0, row=1, sticky=EW) + y_scrollbar.grid(column=1, row=0, sticky=NS) x_scrollbar.config(command=self._can_container.xview) y_scrollbar.config(command=self._can_container.yview) # ensure container canvas expands to accommodate child frames @@ -141,11 +145,11 @@ def _con_body(self): ) else: # normal resolution self._frm_container = Frame(self, borderwidth=2, relief="groove") - self._frm_container.grid(column=0, row=0, sticky=(N, S, E, W)) + self._frm_container.grid(column=0, row=0, sticky=NSEW) # create status frame self._frm_status = Frame(self, borderwidth=2, relief="groove") - self._lbl_status = Label(self._frm_status, textvariable=self._status, anchor=W) + self._lbl_status = Label(self._frm_status, anchor=W) self._btn_exit = Button( self._frm_status, image=self.img_exit, @@ -153,8 +157,8 @@ def _con_body(self): fg=ERRCOL, command=self.on_exit, ) - self._frm_status.grid(column=0, row=2, sticky=(W, E)) - self._lbl_status.grid(column=0, row=0, sticky=(W, E)) + self._frm_status.grid(column=0, row=2, sticky=EW) + self._lbl_status.grid(column=0, row=0, sticky=EW) self._btn_exit.grid(column=1, row=0, sticky=E) # set column and row weights @@ -176,28 +180,14 @@ def _finalise(self): Finalise Toplevel window after child frames have been created. """ - # self.set_status(f"{self.height}, {self.width}") # testing only - - def set_status(self, message: str, color: str = ""): - """ - Set status message. - - :param str message: message to be displayed - :param str color: rgb color of text (blue) - """ - - message = (message[:120] + "..") if len(message) > 120 else message - if color != "": - self._lbl_status.config(fg=color) - self._status.set(" " + message) - self.update_idletasks() + # self.status_label = (f"{self.height}, {self.width}") # testing only def on_exit(self, *args, **kwargs): # pylint: disable=unused-argument """ Handle Exit button press. """ - self.__app.stop_dialog(self._dlgname) + self.__app.dialog_state.state[self._dlgname][DLG] = None self.destroy() def _on_resize(self, event): # pylint: disable=unused-argument @@ -230,3 +220,38 @@ def container(self): """ return self._frm_container + + @property + def status_label(self) -> Label: + """ + Getter for status_label. + + :param self: Description + :return: Description + :rtype: Label + """ + + return self._lbl_status + + @status_label.setter + def status_label(self, message: str | tuple[str, str]): + """ + Setter for status_label. + + :param self: Description + :param tuple | str message: (message, color)) + """ + + if isinstance(message, tuple): + message, color = message + else: + color = INFOCOL + + # truncate very long messages + if len(message) > 100: + message = "..." + message[-100:] + + self.status_label.after( + 0, self.status_label.config, {"text": message, "fg": color} + ) + self.update_idletasks() diff --git a/src/pygpsclient/tty_preset_dialog.py b/src/pygpsclient/tty_preset_dialog.py index 07e102e0..f1bd7761 100644 --- a/src/pygpsclient/tty_preset_dialog.py +++ b/src/pygpsclient/tty_preset_dialog.py @@ -11,8 +11,12 @@ """ from tkinter import ( + EW, HORIZONTAL, LEFT, + NE, + NS, + NSEW, VERTICAL, Button, Checkbutton, @@ -146,17 +150,17 @@ def _do_layout(self): """ self._frm_body.grid( - column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5, sticky=(N, S, E, W) + column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5, sticky=NSEW ) self._lbl_command.grid(column=0, row=0, padx=3, sticky=W) - self._ent_command.grid(column=1, row=0, columnspan=3, padx=3, sticky=(W, E)) + self._ent_command.grid(column=1, row=0, columnspan=3, padx=3, sticky=EW) self._chk_crlf.grid(column=0, row=1, padx=3, sticky=W) self._chk_echo.grid(column=1, row=1, padx=3, sticky=W) self._chk_delay.grid(column=2, row=1, padx=3, sticky=W) ttk.Separator(self._frm_body).grid( - column=0, row=2, columnspan=4, padx=2, pady=2, sticky=(W, E) + column=0, row=2, columnspan=4, padx=2, pady=2, sticky=EW ) - self._lbl_presets.grid(column=0, row=3, columnspan=3, padx=3, sticky=(W, E)) + self._lbl_presets.grid(column=0, row=3, columnspan=3, padx=3, sticky=EW) self._lbx_preset.grid( column=0, row=3, @@ -164,15 +168,15 @@ def _do_layout(self): rowspan=10, padx=3, pady=3, - sticky=(N, S, W, E), + sticky=NS, ) self._scr_presetv.grid(column=2, row=3, rowspan=21, sticky=(N, S, E)) - self._scr_preseth.grid(column=0, row=24, columnspan=3, sticky=(W, E)) + self._scr_preseth.grid(column=0, row=24, columnspan=3, sticky=EW) self._btn_send_command.grid( - column=3, row=3, padx=3, ipadx=3, ipady=3, sticky=(N, E) + column=3, row=3, padx=3, ipadx=3, ipady=3, sticky=NE ) self._lbl_send_command.grid( - column=3, row=4, padx=3, ipadx=3, ipady=3, sticky=(N, W, E) + column=3, row=4, padx=3, ipadx=3, ipady=3, sticky=EW ) self.container.grid_columnconfigure(0, weight=10) self.container.grid_rowconfigure(0, weight=10) @@ -228,13 +232,13 @@ def _on_select_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ try: - self.set_status("", INFOCOL) + self.status_label = ("", INFOCOL) idx = self._lbx_preset.curselection() preset = self._lbx_preset.get(idx).split(";", 1) self._confirm = CONFIRM in preset[0] self._command.set(preset[1]) except IndexError: - self.set_status("Invalid preset format", ERRCOL) + self.status_label = ("Invalid preset format", ERRCOL) def _on_send_command(self, *args, **kwargs): # pylint: disable=unused-argument """ @@ -242,7 +246,7 @@ def _on_send_command(self, *args, **kwargs): # pylint: disable=unused-argument """ if self._command.get() in ("", None): - self.set_status("Enter or select command", ERRCOL) + self.status_label = ("Enter or select command", ERRCOL) return try: @@ -257,15 +261,15 @@ def _on_send_command(self, *args, **kwargs): # pylint: disable=unused-argument status = CONFIRMED if status == CONFIRMED: self._lbl_send_command.config(image=self.img_pending) - self.set_status("Command(s) sent") + self.status_label = "Command(s) sent" elif status == CANCELLED: - self.set_status("Command(s) cancelled") + self.status_label = "Command(s) cancelled" elif status == NOMINAL: - self.set_status("Command(s) sent, no results") + self.status_label = "Command(s) sent, no results" self._confirm = False except Exception as err: # pylint: disable=broad-except - self.set_status(f"Error {err}", ERRCOL) + self.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self.img_warn) def _parse_command(self, command: str): @@ -290,7 +294,7 @@ def _parse_command(self, command: str): (cmd, cmd.decode(ASCII, errors=BSR), TTYMARKER) ) except Exception as err: # pylint: disable=broad-except - self.set_status(f"Error {err}", ERRCOL) + self.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self.img_warn) def update_status(self, msg: bytes): @@ -304,10 +308,10 @@ def update_status(self, msg: bytes): for ack in TTYOK: if ack in msgstr: self._lbl_send_command.config(image=self.img_confirmed) - self.set_status("Command(s) acknowledged", OKCOL) + self.status_label = ("Command(s) acknowledged", OKCOL) return for nak in TTYERR: if nak in msgstr: self._lbl_send_command.config(image=self.img_warn) - self.set_status("Command(s) rejected", ERRCOL) + self.status_label = ("Command(s) rejected", ERRCOL) break diff --git a/src/pygpsclient/ubx_cfgval_frame.py b/src/pygpsclient/ubx_cfgval_frame.py index a8b5bd2f..7a4059ba 100644 --- a/src/pygpsclient/ubx_cfgval_frame.py +++ b/src/pygpsclient/ubx_cfgval_frame.py @@ -13,6 +13,7 @@ # pylint: disable=no-member from tkinter import ( + EW, HORIZONTAL, LEFT, NORMAL, @@ -200,17 +201,17 @@ def _do_layout(self): Layout widgets. """ - self._lbl_configdb.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) - self._lbl_cat.grid(column=0, row=1, padx=3, sticky=(W, E)) - self._lbx_cat.grid(column=0, row=2, rowspan=5, padx=3, pady=3, sticky=(W, E)) + self._lbl_configdb.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) + self._lbl_cat.grid(column=0, row=1, padx=3, sticky=EW) + self._lbx_cat.grid(column=0, row=2, rowspan=5, padx=3, pady=3, sticky=EW) self._scr_catv.grid(column=0, row=2, rowspan=5, sticky=(N, S, E)) - self._scr_cath.grid(column=0, row=7, sticky=(W, E)) - self._lbl_parm.grid(column=1, row=1, columnspan=4, padx=3, sticky=(W, E)) + self._scr_cath.grid(column=0, row=7, sticky=EW) + self._lbl_parm.grid(column=1, row=1, columnspan=4, padx=3, sticky=EW) self._lbx_parm.grid( - column=1, row=2, columnspan=4, rowspan=5, padx=3, pady=3, sticky=(W, E) + column=1, row=2, columnspan=4, rowspan=5, padx=3, pady=3, sticky=EW ) self._scr_parmv.grid(column=4, row=2, rowspan=5, sticky=(N, S, E)) - self._scr_parmh.grid(column=1, row=7, columnspan=4, sticky=(W, E)) + self._scr_parmh.grid(column=1, row=7, columnspan=4, sticky=EW) self._rad_cfgget.grid(column=0, row=8, padx=3, pady=0, sticky=W) self._rad_cfgset.grid(column=0, row=9, padx=3, pady=0, sticky=W) self._rad_cfgdel.grid(column=0, row=10, padx=3, pady=0, sticky=W) @@ -221,9 +222,7 @@ def _do_layout(self): self._lbl_layer.grid(column=1, row=9, padx=3, pady=0, sticky=E) self._spn_layer.grid(column=2, row=9, padx=3, pady=0, sticky=W) self._lbl_val.grid(column=1, row=10, padx=3, pady=0, sticky=E) - self._ent_val.grid( - column=2, row=10, columnspan=3, padx=3, pady=0, sticky=(W, E) - ) + self._ent_val.grid(column=2, row=10, columnspan=3, padx=3, pady=0, sticky=EW) self._btn_send_command.grid( column=3, row=12, rowspan=2, ipadx=3, ipady=3, sticky=E @@ -334,7 +333,7 @@ def _do_valset(self): atts = attsiz(self._cfgatt.get()) except ValueError as err: self._ent_val.validate(VALNONBLANK) - self.__container.set_status(f"INVALID ENTRY - {err}", ERRCOL) + self.__container.status_label = (f"INVALID ENTRY - {err}", ERRCOL) return False val = self._cfgval.get() layers = self._cfglayer.get() @@ -380,15 +379,13 @@ def _do_valset(self): msg = UBXMessage.config_set(layers, transaction, cfgData) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "CFG-VALSET SET message sent", - ) + self.__container.status_label = "CFG-VALSET SET message sent" for msgid in ("ACK-ACK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGVAL) else: self._lbl_send_command.config(image=self._img_warn) typ = ATTDICT[att] - self.__container.set_status( + self.__container.status_label = ( ( "INVALID ENTRY - must conform to parameter " f"type {att} ({typ}) and size {atts} bytes" @@ -417,7 +414,7 @@ def _do_valdel(self): msg = UBXMessage.config_del(layers, transaction, key) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status("CFG-VALDEL SET message sent") + self.__container.status_label = "CFG-VALDEL SET message sent" for msgid in ("ACK-ACK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGVAL) @@ -442,7 +439,7 @@ def _do_valget(self): msg = UBXMessage.config_poll(layers, transaction, keys) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status("CFG-VALGET POLL message sent") + self.__container.status_label = "CFG-VALGET POLL message sent" for msgid in ("CFG-VALGET", "ACK-ACK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGVAL) @@ -460,12 +457,12 @@ def update_status(self, msg: UBXMessage): # pylint: disable=unused-argument if isinstance(val, bytes): val = val.hex() self._cfgval.set(val) - self.__container.set_status("CFG-VALGET GET message received", OKCOL) + self.__container.status_label = ("CFG-VALGET GET message received", OKCOL) elif msg.identity == "ACK-ACK": self._lbl_send_command.config(image=self._img_confirmed) - self.__container.set_status("CFG-VAL command acknowledged", OKCOL) + self.__container.status_label = ("CFG-VAL command acknowledged", OKCOL) elif msg.identity == "ACK-NAK": self._lbl_send_command.config(image=self._img_warn) - self.__container.set_status("CFG-VAL command rejected", ERRCOL) + self.__container.status_label = ("CFG-VAL command rejected", ERRCOL) diff --git a/src/pygpsclient/ubx_config_dialog.py b/src/pygpsclient/ubx_config_dialog.py index ac7ae4f6..bb3a4c4c 100644 --- a/src/pygpsclient/ubx_config_dialog.py +++ b/src/pygpsclient/ubx_config_dialog.py @@ -23,7 +23,7 @@ :license: BSD 3-Clause """ -from tkinter import E, N, S, W +from tkinter import NSEW from pyubx2 import UBXMessage @@ -127,6 +127,7 @@ def _do_layout(self): # top of grid col = 0 row = 0 + colsp = 0 for frm in ( self._frm_device_info, self._frm_recorder, @@ -140,7 +141,7 @@ def _do_layout(self): row=row, columnspan=colsp, rowspan=rowsp, - sticky=(N, S, W, E), + sticky=NSEW, ) row += rowsp # middle column of grid @@ -153,7 +154,7 @@ def _do_layout(self): row=row, columnspan=colsp, rowspan=rowsp, - sticky=(N, S, W, E), + sticky=NSEW, ) row += rowsp # right column of grid @@ -167,7 +168,7 @@ def _do_layout(self): row=row, columnspan=colsp, rowspan=rowsp, - sticky=(N, S, W, E), + sticky=NSEW, ) row += rowsp @@ -185,7 +186,7 @@ def _reset(self): CONNECTED_SOCKET, CONNECTED_SIMULATOR, ): - self.set_status("Device not connected", ERRCOL) + self.status_label = ("Device not connected", ERRCOL) def _attach_events(self): """ diff --git a/src/pygpsclient/ubx_handler.py b/src/pygpsclient/ubx_handler.py index 3e3138a6..560e5f0f 100644 --- a/src/pygpsclient/ubx_handler.py +++ b/src/pygpsclient/ubx_handler.py @@ -16,6 +16,7 @@ """ import logging +from time import time from pyubx2 import UBXMessage, itow2utc @@ -323,9 +324,9 @@ def _process_NAV_SAT(self, data: UBXMessage): :param UBXMessage data: NAV-SAT parsed message """ - show_unused = self.__app.configuration.get("unusedsat_b") self.gsv_data = {} num_siv = int(data.numSvs) + now = time() for i in range(num_siv): idx = f"_{i+1:02d}" @@ -337,9 +338,7 @@ def _process_NAV_SAT(self, data: UBXMessage): elev = getattr(data, "elev" + idx) azim = getattr(data, "azim" + idx) cno = getattr(data, "cno" + idx) - if cno == 0 and not show_unused: # omit unused sats - continue - self.gsv_data[f"{gnssId}-{svid}"] = (gnssId, svid, elev, azim, cno) + self.gsv_data[(gnssId, svid)] = (gnssId, svid, elev, azim, cno, now) self.__app.gnss_status.siv = len(self.gsv_data) self.__app.gnss_status.gsv_data = self.gsv_data @@ -352,7 +351,6 @@ def _process_NAV_STATUS(self, data: UBXMessage): """ self.__app.gnss_status.diff_corr = data.diffSoln - # self.__app.gnss_status.diff_age = "<60" self.__app.gnss_status.fix = fix2desc("NAV-STATUS", data.gpsFix) if data.carrSoln > 0: self.__app.gnss_status.fix = fix2desc("NAV-STATUS", data.carrSoln + 5) diff --git a/src/pygpsclient/ubx_msgrate_frame.py b/src/pygpsclient/ubx_msgrate_frame.py index f4cd2dcf..da70e3da 100644 --- a/src/pygpsclient/ubx_msgrate_frame.py +++ b/src/pygpsclient/ubx_msgrate_frame.py @@ -13,6 +13,7 @@ """ from tkinter import ( + EW, LEFT, VERTICAL, Button, @@ -159,10 +160,9 @@ def _do_layout(self): """ Layout widgets. """ - - self._lbl_cfg_msg.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_cfg_msg.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._lbx_cfg_msg.grid( - column=0, row=1, columnspan=2, rowspan=11, padx=3, pady=3, sticky=(W, E) + column=0, row=1, columnspan=2, rowspan=11, padx=3, pady=3, sticky=EW ) self._scr_cfg_msg.grid(column=1, row=1, rowspan=11, sticky=(N, S, E)) self._lbl_usb.grid(column=2, row=1, rowspan=2, padx=0, pady=1, sticky=E) @@ -215,7 +215,7 @@ def update_status(self, msg: UBXMessage): """ if msg.identity == "CFG-MSG": - self.__container.set_status("CFG-MSG GET message received", OKCOL) + self.__container.status_label = ("CFG-MSG GET message received", OKCOL) self._ddc_rate.set(msg.rateDDC) self._uart1_rate.set(msg.rateUART1) self._uart2_rate.set(msg.rateUART2) @@ -224,7 +224,7 @@ def update_status(self, msg: UBXMessage): self._lbl_send_command.config(image=self._img_confirmed) elif msg.identity == "ACK-NAK": - self.__container.set_status("CFG-MSG POLL message rejected", ERRCOL) + self.__container.status_label = ("CFG-MSG POLL message rejected", ERRCOL) self._lbl_send_command.config(image=self._img_warn) def _on_select_cfg_msg(self, *args, **kwargs): # pylint: disable=unused-argument @@ -266,7 +266,7 @@ def _on_send_cfg_msg(self, *args, **kwargs): # pylint: disable=unused-argument ) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status("CFG-MSG SET message sent", OKCOL) + self.__container.status_label = "CFG-MSG SET message sent" for msgid in ("ACK-ACK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGMSG) @@ -282,8 +282,6 @@ def _do_poll_msg(self, msgtyp: bytes): msg = UBXMessage("CFG", "CFG-MSG", POLL, payload=msgtyp) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "CFG-MSG POLL message sent", - ) + self.__container.status_label = "CFG-MSG POLL message sent" for msgid in ("CFG-MSG", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGMSG) diff --git a/src/pygpsclient/ubx_port_frame.py b/src/pygpsclient/ubx_port_frame.py index 636f719b..314c0037 100644 --- a/src/pygpsclient/ubx_port_frame.py +++ b/src/pygpsclient/ubx_port_frame.py @@ -10,7 +10,18 @@ :license: BSD 3-Clause """ -from tkinter import Button, Checkbutton, E, Frame, IntVar, Label, Spinbox, StringVar, W +from tkinter import ( + EW, + Button, + Checkbutton, + E, + Frame, + IntVar, + Label, + Spinbox, + StringVar, + W, +) from PIL import Image, ImageTk from pyubx2 import POLL, SET, UBXMessage @@ -132,7 +143,7 @@ def _do_layout(self): Layout widgets. """ - self._lbl_cfg_port.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_cfg_port.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._lbl_ubx_portid.grid( column=0, row=1, columnspan=1, rowspan=2, padx=3, sticky=W ) @@ -197,10 +208,10 @@ def update_status(self, msg: UBXMessage): self._outprot_nmea.set(msg.outNMEA) self._outprot_rtcm3.set(msg.outRTCM3) self._lbl_send_command.config(image=self._img_confirmed) - self.__container.set_status("CFG-PRT GET message received", OKCOL) + self.__container.status_label = ("CFG-PRT GET message received", OKCOL) elif msg.identity == "ACK-NAK": - self.__container.set_status("CFG-PRT POLL message rejected", ERRCOL) + self.__container.status_label = ("CFG-PRT POLL message rejected", ERRCOL) self._lbl_send_command.config(image=self._img_warn) def _on_select_portid(self): @@ -243,9 +254,7 @@ def _on_send_port(self, *args, **kwargs): # pylint: disable=unused-argument ) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "CFG-PRT SET message sent", - ) + self.__container.status_label = "CFG-PRT SET message sent" for msgid in ("ACK-NAK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGPRT) @@ -260,8 +269,6 @@ def _do_poll_prt(self, *args, **kwargs): # pylint: disable=unused-argument msg = UBXMessage("CFG", "CFG-PRT", POLL, portID=portID) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "CFG-PRT POLL message sent", - ) + self.__container.status_label = "CFG-PRT POLL message sent" for msgid in ("CFG-PRT", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGPRT) diff --git a/src/pygpsclient/ubx_preset_frame.py b/src/pygpsclient/ubx_preset_frame.py index d3db7fe3..673cf710 100644 --- a/src/pygpsclient/ubx_preset_frame.py +++ b/src/pygpsclient/ubx_preset_frame.py @@ -12,6 +12,7 @@ import logging from tkinter import ( + EW, HORIZONTAL, LEFT, VERTICAL, @@ -118,14 +119,14 @@ def _do_layout(self): Layout widgets. """ - self._lbl_presets.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_presets.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._lbx_preset.grid( - column=0, row=1, columnspan=3, rowspan=12, padx=3, pady=3, sticky=(W, E) + column=0, row=1, columnspan=3, rowspan=12, padx=3, pady=3, sticky=EW ) self._scr_presetv.grid(column=2, row=1, rowspan=12, sticky=(N, S, E)) - self._scr_preseth.grid(column=0, row=13, columnspan=3, sticky=(W, E)) - self._btn_send_command.grid(column=3, row=1, ipadx=3, ipady=3, sticky=(W, E)) - self._lbl_send_command.grid(column=3, row=3, ipadx=3, ipady=3, sticky=(W, E)) + self._scr_preseth.grid(column=0, row=13, columnspan=3, sticky=EW) + 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() for i in range(cols): @@ -164,7 +165,7 @@ def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ if self._preset_command in ("", None): - self.__container.set_status("Select preset", ERRCOL) + self.__container.status_label = ("Select preset", ERRCOL) return status = CONFIRMED @@ -183,22 +184,16 @@ def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument if status == CONFIRMED: self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "Command(s) sent", - ) + self.__container.status_label = "Command(s) sent" for msgid in confids: self.__container.set_pending(msgid, UBX_PRESET) elif status == CANCELLED: - self.__container.set_status( - "Command(s) cancelled", - ) + self.__container.status_label = "Command(s) cancelled" elif status == NOMINAL: - self.__container.set_status( - "Command(s) sent, no results", - ) + self.__container.status_label = "Command(s) sent, no results" except Exception as err: # pylint: disable=broad-except - self.__container.set_status(f"Error {err}", ERRCOL) + self.__container.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self._img_warn) def _format_preset(self, command: str): @@ -226,7 +221,7 @@ def _format_preset(self, command: str): msg = UBXMessage(ubx_class, ubx_id, mode) self.__container.send_command(msg) except Exception as err: # pylint: disable=broad-except - self.__app.set_status(f"Error {err}", ERRCOL) + self.__container.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self._img_warn) def update_status(self, msg: UBXMessage): @@ -238,7 +233,7 @@ def update_status(self, msg: UBXMessage): if msg.identity in ("ACK-ACK", "MON-VER"): self._lbl_send_command.config(image=self._img_confirmed) - self.__container.set_status("Preset command(s) acknowledged", OKCOL) + self.__container.status_label = ("Preset command(s) acknowledged", OKCOL) elif msg.identity == "ACK-NAK": self._lbl_send_command.config(image=self._img_warn) - self.__container.set_status("Preset command(s) rejected", ERRCOL) + self.__container.status_label = ("Preset command(s) rejected", ERRCOL) diff --git a/src/pygpsclient/ubx_recorder_frame.py b/src/pygpsclient/ubx_recorder_frame.py index 7f1cdd62..0fe56c95 100644 --- a/src/pygpsclient/ubx_recorder_frame.py +++ b/src/pygpsclient/ubx_recorder_frame.py @@ -18,7 +18,7 @@ from threading import Event, Thread from time import sleep -from tkinter import Button, E, Frame, Label, TclError, W, filedialog +from tkinter import EW, Button, Frame, Label, TclError, W, filedialog from PIL import Image, ImageTk from pyubx2 import ( @@ -159,15 +159,15 @@ def _do_layout(self): Layout widgets. """ - self._lbl_recorder.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_recorder.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._btn_load.grid(column=0, row=1, ipadx=3, ipady=3, sticky=W) self._btn_save.grid(column=1, row=1, ipadx=3, ipady=3, sticky=W) self._btn_play.grid(column=2, row=1, ipadx=3, ipady=3, sticky=W) self._btn_record.grid(column=3, row=1, ipadx=3, ipady=3, sticky=W) self._btn_undo.grid(column=4, row=1, ipadx=3, ipady=3, sticky=W) self._btn_delete.grid(column=5, row=1, ipadx=3, ipady=3, sticky=W) - self._lbl_status.grid(column=0, row=2, columnspan=6, padx=3, sticky=(W, E)) - self._lbl_activity.grid(column=0, row=3, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_status.grid(column=0, row=2, columnspan=6, padx=3, sticky=EW) + self._lbl_activity.grid(column=0, row=3, columnspan=6, padx=3, sticky=EW) (cols, rows) = self.grid_size() for i in range(cols): @@ -206,6 +206,7 @@ def _open_configfile(self): """ return self.__app.file_handler.open_file( + self, "ubx", ( ("ubx config files", "*.ubx"), diff --git a/src/pygpsclient/ubx_solrate_frame.py b/src/pygpsclient/ubx_solrate_frame.py index dd051025..070fb4a8 100644 --- a/src/pygpsclient/ubx_solrate_frame.py +++ b/src/pygpsclient/ubx_solrate_frame.py @@ -10,7 +10,7 @@ :license: BSD 3-Clause """ -from tkinter import Button, E, Frame, IntVar, Label, Spinbox, StringVar, W +from tkinter import EW, Button, E, Frame, IntVar, Label, Spinbox, StringVar, W from PIL import Image, ImageTk from pyubx2 import POLL, SET, UBXMessage @@ -118,7 +118,7 @@ def _do_layout(self): Layout widgets. """ - self._lbl_cfg_rate.grid(column=0, row=0, columnspan=6, padx=3, sticky=(W, E)) + self._lbl_cfg_rate.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._lbl_ubx_measint.grid( column=0, row=1, columnspan=2, rowspan=1, padx=3, pady=3, sticky=W ) @@ -173,10 +173,10 @@ def update_status(self, msg: UBXMessage): self._navrate.set(msg.navRate) self._timeref.set(TIMEREFS[msg.timeRef]) self._lbl_send_command.config(image=self._img_confirmed) - self.__container.set_status("CFG-RATE GET message received", OKCOL) + self.__container.status_label = ("CFG-RATE GET message received", OKCOL) elif msg.identity == "ACK-NAK": - self.__container.set_status("CFG-RATE POLL message rejected", ERRCOL) + self.__container.status_label = ("CFG-RATE POLL message rejected", ERRCOL) self._lbl_send_command.config(image=self._img_warn) def _on_send_rate(self, *args, **kwargs): # pylint: disable=unused-argument @@ -200,9 +200,7 @@ def _on_send_rate(self, *args, **kwargs): # pylint: disable=unused-argument ) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "CFG-RATE SET message sent", - ) + self.__container.status_label = "CFG-RATE SET message sent" self.__container.set_pending(UBX_CFGRATE, ("ACK-ACK", "ACK-NAK")) self._do_poll_rate() @@ -215,8 +213,6 @@ def _do_poll_rate(self, *args, **kwargs): # pylint: disable=unused-argument msg = UBXMessage("CFG", "CFG-RATE", POLL) self.__container.send_command(msg) self._lbl_send_command.config(image=self._img_pending) - self.__container.set_status( - "CFG-RATE POLL message sent", - ) + self.__container.status_label = "CFG-RATE POLL message sent" for msgid in ("CFG-RATE", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGRATE) diff --git a/src/pygpsclient/widget_state.py b/src/pygpsclient/widget_state.py index 0917f993..ef30defd 100644 --- a/src/pygpsclient/widget_state.py +++ b/src/pygpsclient/widget_state.py @@ -22,7 +22,7 @@ class definition and update `ubx_handler` to populate them. :license: BSD 3-Clause """ -from tkinter import E, N, S, W +from tkinter import NSEW, E, S, W from pygpsclient.banner_frame import BannerFrame from pygpsclient.chart_frame import ChartviewFrame @@ -85,7 +85,7 @@ def __init__(self): CLASS: BannerFrame, FRAME: "frm_banner", VISIBLE: True, - STICKY: (N, W, E, S), + STICKY: NSEW, COL: 0, ROW: 0, COLSPAN: 6, @@ -95,7 +95,7 @@ def __init__(self): CLASS: SettingsFrame, FRAME: "frm_settings", VISIBLE: True, - STICKY: (N, W, E, S), + STICKY: NSEW, COL: 5, ROW: 1, ROWSPAN: 4, diff --git a/tests/test_configs.py b/tests/test_configs.py index 6cb9fe79..783e54f4 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -15,6 +15,7 @@ import unittest from pygpsclient.receiver_config_handler import ( + config_nmea, config_disable_lc29h, config_disable_lg290p, config_disable_septentrio, @@ -38,6 +39,11 @@ def setUp(self): def tearDown(self): pass + def test_config_nmea(self): + EXPECTED_RESULT = "" + res = config_nmea(1, "UART1") + self.assertEqual(str(res), EXPECTED_RESULT) + def test_config_disable_lc29h(self): EXPECTED_RESULT = "[NMEAMessage('P','AIR062', 1, payload=['-1', '0']), NMEAMessage('P','AIR432', 1, payload=['-1']), NMEAMessage('P','AIR434', 1, payload=['0']), NMEAMessage('P','QTMSAVEPAR', 1, payload=[]), NMEAMessage('P','AIR005', 1, payload=[])]" res = config_disable_lc29h() diff --git a/tests/test_static.py b/tests/test_static.py index ac88bf34..9b9da6bc 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -24,7 +24,6 @@ AreaXY, Point, TrackPoint, - OKCOL, UI, UMM, UIK, @@ -73,11 +72,12 @@ svid2gnssid, time2str, ubx2preset, + unused_sats, val2sphp, wnotow2date, xy2ll, ) -from pygpsclient.mapquest import ( +from pygpsclient.mapquest_handler import ( compress_track, format_mapquest_request, mapq_compress, @@ -107,8 +107,14 @@ def __init__(self): self.appmaster = "appmaster" self.widget_state = WidgetState() self.file_handler = DummyFileHandler() + self.label_status = "" - def set_status(self, message, color=OKCOL): + @property + def status_label(self) -> str: + return self.label_status + + @status_label.setter + def status_label(self, message): print(message) @@ -126,7 +132,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), 150) + self.assertEqual(len(cfg.settings), 151) kwargs = {"userport": "/dev/ttyACM0", "spartnport": "/dev/ttyACM1"} cfg.loadcli(**kwargs) self.assertEqual(cfg.get("userport_s"), "/dev/ttyACM0") @@ -140,13 +146,7 @@ def testloadfile(self): cfg = Configuration(DummyApp()) res = cfg.loadfile("bad.json") - self.assertEqual( - res, - ( - "bad.json", - "Unrecognised configuration setting 'xcheckforupdate_b: 0'; using defaults", - ), - ) + self.assertEqual(res, ("bad.json", "")) res = cfg.loadfile("good.json") self.assertEqual(res, ("good.json", "")) @@ -915,6 +915,13 @@ def testtrackbounds(self): self.assertAlmostEqual(center.lat, 53.367617, 7) self.assertAlmostEqual(center.lon, -1.815437, 7) + def testunusedsats(self): + gsv_data = {(1,1): (0,0,0,0,5,0),(1,2): (0,0,0,0,7,0),(2,1): (0,0,0,0,0,0),(2,2): (0,0,0,0,1,0),(3,1): (0,0,0,0,5,0)} + self.assertEqual(unused_sats(gsv_data), 1) + gsv_data = {(1,1): (0,0,0,0,5,0),(1,2): (0,0,0,0,7,0),(2,1): (0,0,0,0,0,0),(2,2): (0,0,0,0,1,0),(3,1): (0,0,0,0,0,0)} + self.assertEqual(unused_sats(gsv_data), 2) + gsv_data = {(1,1): (0,0,0,0,5,0),(1,2): (0,0,0,0,7,0),(2,1): (0,0,0,0,1,0),(2,2): (0,0,0,0,1,0),(3,1): (0,0,0,0,4,0)} + self.assertEqual(unused_sats(gsv_data), 0) if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName']