diff --git a/.vscode/settings.json b/.vscode/settings.json index be7ddb80..239b7cb2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,6 @@ "telemetry.feedback.enabled": false, "python.analysis.addHoverSummaries": false, "python-envs.defaultEnvManager": "ms-python.python:venv", - "python-envs.pythonProjects": [], "python.venvPath": "${userHome}/pygpsclient/bin/python3", "python.systemPath": "/usr/local/bin/python3.14", "python.defaultInterpreterPath": "${userHome}/pygpsclient/bin/python3", diff --git a/README.md b/README.md index e3bbd44b..e330bd6b 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,16 @@ [Mapquest API Key](#mapquestapi) | [User-defined Presets](#userdefined) | [CLI Utilities](#cli) | -[Known Issues](#knownissues) | +[Troubleshooting](#troubleshoot) | [License](#license) | [Author Information](#author) PyGPSClient is a free, open-source, multi-platform graphical GNSS/GPS testing, diagnostic and configuration application written entirely by volunteers in Python and tkinter. * Runs on any platform which supports a Python 3 interpreter (>=3.10) and tkinter (>=8.6) GUI framework, including Windows, MacOS, Linux and Raspberry Pi OS. Accommodates low resolution screens (>= 640x480) via resizable and/or scrollable panels. -* Supports NMEA, UBX (u-blox binary), SBF (Septentrio binary), UNI (Unicore binary), QGC (Quectel binary), RTCM3, NTRIP, SPARTN, MQTT and TTY (ASCII) protocols¹. +* Supports NMEA, UBX (u-blox binary), SBF (Septentrio binary), UNI (Unicore binary), QGC (Quectel binary), RTCM3, NTRIP, SPARTN, MQTT and TTY (ASCII text) protocols¹. * Capable of reading from a variety of GNSS data streams: Serial (USB / UART), Socket (TCP / UDP), binary data stream (terminal or file capture) and binary recording (e.g. u-center \*.ubx). * Provides [NTRIP](#ntripconfig) client facilities. -* Can serve as an [NTRIP base station](#basestation) with an RTK-compatible receiver (e.g. u-blox ZED-F9P/ZED-X20P, Quectel LG/LC Series, Septentrio Mosaic G5/X5 or Unicore UM9** Series). +* Can serve as an [NTRIP base station](#basestation) with an RTK-compatible receiver (e.g. u-blox ZED-F9P/ZED-X20P, Quectel LG/LC Series, Septentrio Mosaic Series or Unicore UM9** Series). * Supports GNSS (*and related*) device configuration via proprietary UBX, NMEA and ASCII TTY protocols, including most u-blox, Quectel, Septentrio, Unicore and Feyman GNSS devices. * Can be installed using the standard `pip` Python package manager - see [installation instructions](#installation) below. @@ -102,14 +102,13 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. #### Settings panel 1. By default, the Settings panel is displayed to the right of the main application window. It can be hidden or shown via Menu..View..Hide/Show Settings. The panel can also be 'undocked' from the main application window via Menu..View..Undock Settings and - if [non-transient](#transient) (`transient_dialog_b: 0`) - minimized independently of the main window. Exiting the undocked dialog, or selecting Menu..View..Dock Settings, will 'dock' the panel. -2. Protocols Shown - Select which message protocols to display in the console; NMEA, UBX (*u-blox binary*), SBF (*Septentrio binary*), UNI (*Unicore binary*), QGC (*Quectel binary*), RTCM3, SPARTN or TTY (*ASCII text*). NB: this only changes the *displayed* protocols - to change the actual protocols output by the receiver, use the relevant configuration command(s). - - **NB:** Serial connection must be stopped before changing to or from TTY (terminal) protocol. - - **NB:** Enabling TTY (terminal) mode will disable all other protocols. +2. Protocols Shown - Select which message protocols to display in the console; NMEA, UBX (*u-blox binary*), SBF (*Septentrio binary*), UNI (*Unicore binary*), QGC (*Quectel binary*), RTCM3, SPARTN or TTY (*terminal*). NB: this only changes the *displayed* protocols - to change the actual protocols output by the receiver, use the relevant configuration command(s). + - **NB:** Serial connection must be stopped before changing to or from TTY (terminal) protocol. Enabling TTY (terminal) mode will disable all other protocols. 3. To connect to a GNSS receiver via USB or UART port, select the device from the listbox, set the appropriate serial connection parameters and click ![connect icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/usbport-1-24.png?raw=true). The application will endeavour to pre-select a recognised GNSS/GPS device but this is platform and device dependent. Press the ![refresh](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-refresh-6-16.png?raw=true) button to refresh the list of connected devices at any point. - `Rate bps` (baud rate) is typically the only setting that might need adjusting, but tweaking the `timeout` setting may improve performance on certain platforms. - - When the connection is first established, PyGPSClient will poll various hardware information messages (*one for each selected protocol*) to the receiver in an attempt to establish its model and firmware version. You may see a handful of 'unknown protocol' warnings in response to some of these queries - these can be disregarded. **NB:** Some receivers will not output hardware information messages at low baud rates (<38,400). - If you get a permissions error on attempting to connect to a serial port e.g. `[Errno 13] permission denied /dev/ttyACM0`, refer to the [Installation Guidelines - User Privileges](https://github.com/semuconsulting/PyGPSClient/blob/master/INSTALLATION.md#user-privileges). + - When the connection is first established, PyGPSClient will poll various hardware information messages (*one for each selected protocol*) in an attempt to establish the receiver's manufacturer, model and firmware version. You may see a handful of 'unknown protocol' warnings in response to some of these queries - these can be disregarded. **NB:** Some receivers will not output hardware information messages at low baud rates (<38,400). - The `Msg Mode` parameter defaults to `GET` i.e., periodic or poll response messages *from* a receiver. If you wish to parse streams of command or poll messages being sent *to* a receiver, set the `Msg Mode` to `SET` or `POLL`. An optional serial or socket stream inactivity timeout can also be set (in seconds; 0 = no timeout). 4. A custom user-defined serial port can also be passed via the json configuration file setting `"userport_s":`, via environment variable `PYGPSCLIENT_USERPORT` or as a command line argument `--userport`. A special userport value of "ubxsimulator" invokes the experimental [`pyubxutils.UBXSimulator`](https://github.com/semuconsulting/pyubxutils/blob/main/src/pyubxutils/ubxsimulator.py) utility to emulate a GNSS NMEA/UBX serial stream. 5. To connect to a TCP or UDP socket, enter the server URL and port, select the protocol (defaults to TCP) and click @@ -143,23 +142,25 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. 17. 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). 18. 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. See also [GPX Track Viewer](#gpxviewer). -19. 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. +19. 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 or so on some platforms e.g. Raspberry Pi*). + - Database logging 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 - `no-ext` means the spatialite extension is not supported; `no-ms` means spatialite *is* supported but the necessary `mod_spatialite` extension module cannot be found in the PATH; a numeric version number like `3.51.2` indicates spatialite is fully supported. + - Spatialite databases can be utilised by a wide range of GIS analysis and visualisation tools, including GRASS, QGIS, MapInfo, ArcGIS, etc. - *FYI* a helper method `retrieve_data()` is available to retrieve data from this database - see [Sphinx documentation](https://www.semuconsulting.com/pygpsclient/pygpsclient.html#pygpsclient.sqllite_handler.retrieve_data) and [retrieve_data.py](https://github.com/semuconsulting/PyGPSClient/blob/master/examples/retrieve_data.py) example for details. #### Pop-up Configuration Dialogs -20. [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 +20. [UBX Configuration Dialog](#ubxconfig), with the ability to send a variety of UBX CFG configuration commands to any u-blox GNSS device. 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-ubx.png?raw=true), or go to Menu..Options..UBX Configuration Dialog. -21. [NMEA Configuration Dialog](#nmeaconfig), with the ability to send a variety of NMEA configuration commands to GNSS devices (e.g. Quectel LGSERIES). 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. -22. [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, Unicore UM980). 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/iconmonstr-gear-2-24-tty.png?raw=true), or go to Menu..Options..TTY Commands. +21. [NMEA Configuration Dialog](#nmeaconfig), with the ability to send a variety of NMEA configuration commands to compatible GNSS devices (e.g. Quectel LG or LC Series). 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. +22. [TTY Config Dialog](#ttycommands), with the ability to send a variety of TTY (ASCII) configuration commands to compatible GNSS and related devices (e.g. Septentrio Mosaic Series, Unicore UM9** Series). This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the TTY 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-tty.png?raw=true), or go to Menu..Options..TTY Configuration. 23. [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. -24. [Server Config](#socketserver) facility with the ability to act as generic socket server or NTRIP caster (mountpoint = `pygnssutils`). To display the Server Configuration Dialog, click +24. [Server Config](#socketserver) facility with the ability to act as an NTRIP caster (mountpoint = `pygnssutils`) or generic socket server. To display the Server Configuration Dialog, click ![server icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-transmit-10-24.png?raw=true), or go to Menu..Options..Server Configuration Dialog. -25. [GPX Track Viewer](#gpxviewer) facility with elevation and speed profiles and track metadata. To display the GPX Track viewer, go to Menu..Options..GPX Track Viewer. -26. [Import Custom Map](#custommap) facility which allows the user to import geo-referenced images for use as maps. To display the Import Custom Map dialog, go to Menu..Options..Import Custom Map. -27. [Configuration Command Recorder](#recorder) facility which allows the user to record, save, load and replay UBX, NMEA or TTY configuration commands sent to a receiver. To display the Command Record Facility dialog, go to Menu..Options..Configuration Command Recorder. +25. [GPX Track Viewer](#gpxviewer) facility with the ability to map GPX files containing track, route or waypoint data and show elevation and speed profiles and other metadata. To display the GPX Track viewer, go to Menu..Options..GPX Track Viewer. +26. [Import Custom Map](#custommap) facility which allows the user to import geo-referenced images for use as background maps. To display the Import Custom Map dialog, go to Menu..Options..Import Custom Map. +27. [Configuration Command Recorder](#recorder) facility which allows the user to record, save, load, import (*as a preset*) and replay UBX, NMEA or TTY configuration commands sent to a receiver. To display the Command Record Facility dialog, go to Menu..Options..Configuration Command Recorder. 28. [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. #### GUI refresh rate setting @@ -219,7 +220,7 @@ The UBX Configuration Dialog currently provides the following UBX configuration 1. Message Rate panel (CFG-MSG) sets message rates per port for UBX and NMEA messages (*legacy protocols only*). Message rate is relative to navigation solution frequency e.g. a message rate of '4' means 'every 4th navigation solution' (higher = less frequent). 1. Configuration Interface widget (CFG-VALSET, CFG-VALDEL and CFG-VALGET) queries and sets configuration for *modern protocols only*. 1. UBX Legacy Command configuration panel providing structured updates for a range of legacy CFG-* configuration commands (*legacy protocols only*). Note: 'X' (byte) type attributes can be entered as integers or hexadecimal strings e.g. 522125312 or 0x1f1f0000. Once a command is selected, the configuration is polled and the current values displayed. The user can then amend these values as required and send the updated configuration. Some polls require input arguments (e.g. portID) - these are highlighted and will be set at default values initially (e.g. portID = 0), but can be amended by the user and re-polled using the ![refresh](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-refresh-lined-24.png?raw=true) button. -1. Preset Commands widget supports a variety of user-defined UBX commands and queries - see [user defined presets](#userdefined). +1. Preset Commands widget supports a variety of user-defined UBX commands and queries - see [user-defined presets](#userdefined). An icon to the right of each 'SEND' ![send icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-12-24.png?raw=true) button indicates the confirmation status of the configuration command; @@ -235,7 +236,11 @@ warning ![warning icon](https://github.com/semuconsulting/PyGPSClient/blob/maste **Pre-Requisites:** - Receiver capable of being configured via proprietary NMEA sentences, connected to the workstation via USB or UART port. -- The facility includes support for several Quectel LG and LC series receivers via PQTM*, PSTM* and PAIR* sentences. Additional types may be supported in the underlying NMEA parser library [pynmeagps](https://github.com/semuconsulting/pynmeagps) in later releases (*contributions welcome*). +- The facility includes support for a wide range of Quectel LG and LC series receivers via `PQTM*`, `PSTM*` and `PAIR*` sentences¹ ². Additional types may be supported in the underlying NMEA parser library [pynmeagps](https://github.com/semuconsulting/pynmeagps) in later releases (*contributions welcome*). + + ¹ Note that Quectel receivers implement a bewildering array of different configuration protocols, based on a mixture of proprietary NMEA `PQTM*`, `PSTM*` and `PAIR*` message types. Implementation depends on the *specific model variant and firmware version*, and several models (e.g. LG290 and LC29) exist in a wide range of variants. Refer to the GNSS Protocol Guide for your *specific* variant for details on the available configuration commands. + + ² Note that several Quectel configuration commands require a Hot Restart (PQTMHOT) *or* Save (PQTMSAVEPAR or PAIR513) and Reset (PQTMSRR) before taking effect, including for example PQTMCFGCNST (Enable/Disable Constellations), PQTMCFGFIX (Configure Fix Rate), PQTMCFGSAT (Configure Satellite Masks), PQTMCFGSIGNAL (Configure Signal Masks), PAIR050 (Set Fix Rate) and PAIR864 (Set Baud Rate). Devices (e.g. some LC variants) that don't implement a software reset command may have to be physically disconnected and reconnected. **Instructions:** @@ -243,6 +248,7 @@ The NMEA Configuration Dialog currently provides the following NMEA configuratio 1. Version panel shows current device hardware/firmware versions (*Double-left-click to refresh*). 1. Dynamic configuration panel providing structured updates for supported receivers e.g. Quectel LGSERIES via PQTM* sentences, or LCSERIES via PAIR* sentences. Once a command is selected, the configuration is polled and the current values displayed. The user can then amend these values as required and send the updated configuration. Some polls require input arguments (e.g. portid or msgname) - these are highlighted and will be set at default values initially (e.g. portid = 1), but can be amended by the user and re-polled using the ![refresh](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-refresh-lined-24.png?raw=true) button. 1. Preset Commands widget supports a variety of user-defined NMEA commands and queries - see [user defined presets](#userdefined). +1. Preset commands, once selected, can be edited or overwritten in the 'Commands' field before sending, but commands must observe the format `; ; ; ` (e.g. `P; QTMCFGUART; W,115200; 1` - see [user-defined presets](#userdefined)). An icon to the right of each 'SEND' ![send icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-12-24.png?raw=true) button indicates the confirmation status of the configuration command; @@ -250,8 +256,6 @@ An icon to the right of each 'SEND' confirmed ![confirmed icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-check-mark-8-24.png?raw=true) or warning ![warning icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-warning-1-24.png?raw=true)). -**NB:** Several Quectel LG and LC series commands require a Hot Restart (PQTMHOT) before taking effect, including PQTMCFGCNST (Enable/Disable Constellations), PQTMCFGFIX (Configure Fix Rate), PQTMCFGSAT (Configure Satellite Masks) and PQTMCFGSIGNAL (Configure Signal Masks). This is a Quectel protocol constraint, not a PyGPSClient issue. - --- ## TTY Configuration Facilities @@ -280,7 +284,7 @@ The following example illustrates a series of ASCII configuration commands being The Configuration Command Load/Save/Record facility supports the following functionality: 1. It allows users to record ![record icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-record-24.png?raw=true) a sequence of UBX, NMEA or TTY configuration commands as they are sent to a device, and to save ![save icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-save-14-24.png?raw=true) this recording to a binary file. 1. Saved recordings can be reloaded ![load icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-folder-18-24.png?raw=true) and the configuration commands replayed ![play icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-12-24.png?raw=true). This provides a means to easily reproduce a given sequence of configuration commands, or copy a saved configuration between compatible devices. -1. Recorded commands of a similar type (UBX, NMEA or TTY) can also be imported ![import icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-import-24.png?raw=true) into PyGPSClient's json configuration file as [user defined presets](#user-defined-presets). They can then be replayed from the Presets panel via a single click. +1. Recorded commands of a similar type (UBX, NMEA or TTY) can also be imported ![import icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-import-24.png?raw=true) into PyGPSClient's json configuration file as [user defined presets](#user-defined-presets) - you will be prompted to enter a preset description (*defaults to current timestamp if blank*). The preset can then be replayed from the Presets panel via a single click. 1. The Configuration Load facility can accept configuration files in either UBX/NMEA binary (\*.bin), TTY (\*.tty) or u-center UBX text format (\*.txt) (as also used by [Ardusimple](https://www.ardusimple.com/configuration-files/?wmc-currency=EUR)). 1. Files saved using the [ubxsave](#ubxsave) CLI utility (*installed via the `pyubxutils` library*) can also be reloaded and replayed. @@ -439,17 +443,21 @@ that this is a User variable rather than a System/Global variable. --- ## User Defined Presets -The UBX, NMEA and TTY Configuration Dialogs include the facility to send user-defined configuration messages or message sequences to a compatible receiver. These can be set up by adding appropriate comma- or semicolon-delimited message descriptions and payload definitions to the `"ubxpresets_l"`, `"nmeapresets_l"` or `"ttypresets_l"` settings in your json configuration file (see [example provided](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L189)). The message definition comprises a free-format text description (*avoid embedded commas or semi-colons*) followed by one or more pyubx2 (UBX), pynmeagps (NMEA) or tty (ASCII) message constructors, e,g. +The UBX, NMEA and TTY Configuration Dialogs include the facility to send user-defined configuration messages or message sequences to a compatible receiver. These can be set up as follows: -- UBX - `, , , , ` -- NMEA - `; ; ; ; ` -- TTY - `; ` +1. By manually adding appropriate comma- or semicolon-delimited message descriptions and payload definitions to the `"ubxpresets_l"`, `"nmeapresets_l"` or `"ttypresets_l"` sections of your json configuration file (see [example provided](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L189)). The message definition comprises a free-format text description (*avoid embedded commas or semi-colons*) followed by one or more pyubx2 (UBX), pynmeagps (NMEA) or tty (ASCII) message constructors, e.g. + + - UBX - `, [, , , , ...]` + - NMEA - `; [; ; ; ; ...]` + - TTY - `; [; ...]` + +2. By using the [Configuration Command Load/Save/Record](#recorder) facility to record commands as they are sent to the receiver, and automatically import these recorded commands into the relevant `"...presets_l"` section of the json configuration file. If the command description contains the term `CONFIRM`, a pop-up confirmation box will appear before the command is actioned. -When PyGPSClient is first started, these settings are pre-populated with an initial set of preset commands, which can be saved to a \*.json configuration file and then manually removed, amended or supplemented in accordance with the user's preferences. To reinstate this initial set at a later date, insert the line `"INIT_PRESETS",` at the top of the relevant `"ubxpresets_l"`, `"nmeapresets_l"` or `"ttypresets_l"` configuration setting. +When PyGPSClient is first started, the preset command sections are pre-populated in-memory with an initial set of preset commands, which can be saved to a json configuration file and then manually edited in accordance with the user's preferences. To reinstate this initial set at a later date, insert the line `"INIT_PRESETS"` at the top of the relevant `"ubxpresets_l"`, `"nmeapresets_l"` or `"ttypresets_l"` configuration section. -The `pygpsclient.ubx2preset()`, `pygpsclient.nmea2preset()` and `pygpsclient.tty2preset()` helper functions may be used to convert a `UBXMessage`, `NMEAMessage` or ASCII text object into a preset string suitable for copying and pasting into the `"ubxpresents_l":`, `"nmeapresets_l":` or `"ttypresets_l":` JSON configuration sections: +The `pygpsclient.ubx2preset()`, `pygpsclient.nmea2preset()` and `pygpsclient.tty2preset()` helper functions may be used to convert a `UBXMessage`, `NMEAMessage` or ASCII text object into a string suitable for copying and pasting into the `"ubxpresets_l":`, `"nmeapresets_l":` or `"ttypresets_l":` configuration file sections: ```python from pygpsclient import ubx2preset, nmea2preset, tty2preset @@ -473,8 +481,6 @@ IM19 System reset CONFIRM; AT+SYSTEM_RESET 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#L189) file. -The [Configuration Command Load/Save/Record facility](#configuration-command-loadsaverecord-facility) can also be used to import recorded configuration command sequences into the presets section of the json configuration file. - --- ## Command Line Utilities @@ -483,11 +489,11 @@ The `pygnssutils` and `pyubxutils` libraries which underpin many of the function For further details, refer to the `pygnssutils` homepage at [https://github.com/semuconsulting/pygnssutils](https://github.com/semuconsulting/pygnssutils) or `pyubxutils` homepage at [https://github.com/semuconsulting/pyubxutils](https://github.com/semuconsulting/pyubxutils). --- -## Known Issues +## Troubleshooting 1. If you encounter persistent `WARNING>>Error parsing data stream Serial stream terminated unexpectedly` messages in the console, this may be indicative of insufficient serial port bandwidth (baudrate or timeout) for the current output message cohort (*particularly if this includes Ephemera or Observation data*). Try increasing the baudrate in the first instance. -2. Most budget USB-UART adapters (e.g. FT232, CH345, CP2102) have a bandwidth limit of around 3MB/s and may not work reliably above 115200 baud, even if the receiver supports higher baud rates. If you're using an adapter and notice significant message corruption, try reducing the baud rate to a maximum 115200. +2. Most [budget USB-UART adapters](https://www.amazon.co.uk/DSD-TECH-adapter-FT232RL-Compatible/dp/B07BBPX8B8?ref_=ast_sto_dp) (e.g. FT232, CH345, CP2102, *including those embedded on development boards*) have a bandwidth limit of around 3Mbps (≈ 375000 baud) and may not work reliably above 230600 baud, even if the receiver supports higher baud rates. If you're using an adapter and notice significant message corruption (e.g. frequent `WARNING>>..invalid checksum` messages), try reducing the baud rate to a maximum 230600. 3. Some Linux Wayland platforms appear to require Toplevel dialog windows to be non-transient (`transient_dialog_b: 0`) for the window 'maximise' icon to work properly. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fe2544ad..e8c94fc4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,16 @@ # PyGPSClient Release Notes +### RELEASE 1.6.5 + +FIXES: +1. Fix custom map import spurious validation error. + +ENHANCEMENTS: +1. Add preset description entry field to Configuration Command Recorder import facility. +1. Make TTY Presets dialog fully resizeable. +1. Update NMEA config panel to allow preset commands to be edited or entered manually before sending. +1. Update NMEA config panel to recognise correct PAIR responses for some Quectel set and poll commands (e.g. a PAIR864 Set baud rate command corresponds to a PAIR865 poll response). + ### RELEASE 1.6.4 FIXES: @@ -8,7 +19,7 @@ FIXES: ENHANCEMENTS: 1. Improve Base Station receiver configuration handling in the NTRIP Caster/Socket Server dialog. -1. Add connected device descriptor to status bar (e.g. "u-blox ZED-F9P", "Unicore UM981S", "Septentrio mosaic-X5" or "Quectel LG290AG03"). **FYI** This is based on a series of query messages (*one for each enabled protocol*) sent approximately 3 seconds after the connection is started (*you may see 'unknown protocol' warnings in response to some of these messages; these can be ignored)*. Failing this, a generic descriptor is displayed on receipt of a message protocol unique to a particular manufacturer (*e.g. "u-blox" on receipt of a UBX message, "Unicore" on receipt of a UNI message, etc.*). Note that some (mainly older) devices may not return a meaningful descriptor, in which case "N/A" will be displayed. +1. Add connected device descriptor to status bar (e.g. "u-blox ZED-F9P", "Unicore UM981S", "Septentrio mosaic-X5" or "Quectel LG290AG03"). **FYI** This is based on a poll of hardware information messages (*one for each enabled protocol*) sent approximately 3 seconds after the connection is started (*you may see 'unknown protocol' warnings in response to some of these messages; these can be ignored)*. Failing this, a generic descriptor is displayed on receipt of a message protocol unique to a particular manufacturer (*e.g. "u-blox" on receipt of a UBX message, "Unicore" on receipt of a UNI message, etc.*). Note that some (mainly older) devices may not return a meaningful descriptor, in which case "N/A" will be displayed. Note also that some receivers will not output hardware information or other status messages at low baud rates (< 38,400) 1. Minor updates to serial port configuration panel (additional timeout values). 1. Mininum pynmeagps version updated to v1.1.1. diff --git a/images/app.png b/images/app.png index efadd79e..8be39e8e 100644 Binary files a/images/app.png and b/images/app.png differ diff --git a/images/nmeaconfig_widget.png b/images/nmeaconfig_widget.png index e0eda9b6..167a68c5 100644 Binary files a/images/nmeaconfig_widget.png and b/images/nmeaconfig_widget.png differ diff --git a/images/tty_dialog.png b/images/tty_dialog.png index 3365e302..98312f7c 100644 Binary files a/images/tty_dialog.png and b/images/tty_dialog.png differ diff --git a/images/ubxconfig_widget.png b/images/ubxconfig_widget.png index 068595f3..f2e9469d 100644 Binary files a/images/ubxconfig_widget.png and b/images/ubxconfig_widget.png differ diff --git a/pyproject.toml b/pyproject.toml index 2942e9b5..58634ed8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ "Pillow>=9.0.0", "pygnssutils>=1.1.22", "pyunigps>=0.2.0", - "pynmeagps>=1.1.1", + "pynmeagps>=1.1.2", ] [project.scripts] diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py index 2ccbd725..01eed04f 100644 --- a/src/pygpsclient/_version.py +++ b/src/pygpsclient/_version.py @@ -8,4 +8,4 @@ :license: BSD 3-Clause """ -__version__ = "1.6.4" +__version__ = "1.6.5" diff --git a/src/pygpsclient/dialog_state.py b/src/pygpsclient/dialog_state.py index 5bfed920..48131f93 100644 --- a/src/pygpsclient/dialog_state.py +++ b/src/pygpsclient/dialog_state.py @@ -97,7 +97,7 @@ def __init__(self): DLGTTTY: { CLASS: TTYPresetDialog, DLG: None, - RESIZE: False, + RESIZE: True, }, DLGTRECORD: { CLASS: RecorderDialog, diff --git a/src/pygpsclient/dynamic_config_frame.py b/src/pygpsclient/dynamic_config_frame.py index 8a102ed7..1c783e24 100644 --- a/src/pygpsclient/dynamic_config_frame.py +++ b/src/pygpsclient/dynamic_config_frame.py @@ -23,6 +23,7 @@ END, EW, LEFT, + NE, NSEW, NW, VERTICAL, @@ -40,7 +41,6 @@ W, ) -from PIL import Image, ImageTk from pynmeagps import ( NMEA_MSGIDS_PROP, NMEA_PAYLOADS_POLL_PROP, @@ -63,12 +63,6 @@ from pygpsclient.globals import ( ERRCOL, - ICON_CONFIRMED, - ICON_PENDING, - ICON_REDRAW, - ICON_SEND, - ICON_UNKNOWN, - ICON_WARNING, INFOCOL, NMEA_CFGOTHER, OKCOL, @@ -98,6 +92,8 @@ ) # alternative POLL dictionary names for where POLL command # doesn't correspond to SET (fudge for Quectel) +# e.g. a PAIR864 (set baud rate) command corresponds to PAIR865 (poll baud rate) +# (for PAIR commands, the poll is typically one digit higher than the set) ALT_POLL_NAMES = { "AIR050": "AIR051", "AIR058": "AIR059", @@ -111,8 +107,8 @@ "AIR100": "AIR101", "AIR104": "AIR105", "AIR400": "AIR401", - "AIR410": "AIR421", - "AIR420": "AIR411", + "AIR410": "AIR411", + "AIR420": "AIR421", "AIR432": "AIR433", "AIR434": "AIR435", "AIR436": "AIR437", @@ -173,12 +169,6 @@ def __init__(self, app: Frame, parent: Frame, *args, **kwargs): super().__init__(parent.container, *args, **kwargs) - self._img_send = ImageTk.PhotoImage(Image.open(ICON_SEND)) - self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) - self._img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) - self._img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) - self._img_unknown = ImageTk.PhotoImage(Image.open(ICON_UNKNOWN)) - self._img_refresh = ImageTk.PhotoImage(Image.open(ICON_REDRAW)) self._cfg_id = "" # identity of selected CFG command self._cfg_atts = {} # this holds the attributes of the selected CFG command self._expected_response = None @@ -210,22 +200,22 @@ def _body(self): self, orient=VERTICAL, command=self._lbx_cfg_cmd.yview ) self._lbx_cfg_cmd.config(yscrollcommand=self._scr_cfg_cmd.set) - self._lbl_send_command = Label(self, image=self._img_pending) + self._lbl_send_command = Label(self, image=self.__container.img_none) self._btn_send_command = Button( self, - image=self._img_send, + image=self.__container.img_send, width=50, command=self._on_set_cfg, font=self.__app.font_md, ) self._btn_refresh = Button( self, - image=self._img_refresh, - width=50, + image=self.__container.img_redraw, + width=40, command=self._on_refresh, font=self.__app.font_md, ) - self._lbl_command = Label(self, text="", width=30, anchor=W) + self._lbl_command = Label(self, text="", anchor=W) self._frm_container = Frame(self) self._can_container = Canvas(self._frm_container) self._frm_attrs = Frame(self._can_container) @@ -246,31 +236,29 @@ def _do_layout(self): Layout widgets. """ - 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=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=EW) + self._lbl_cfg_dyn.grid(column=0, row=0, columnspan=3, sticky=EW) + self._lbx_cfg_cmd.grid(column=0, row=1, columnspan=1, sticky=EW) + self._scr_cfg_cmd.grid(column=1, row=1, sticky=(N, S, W)) + self._btn_send_command.grid(column=2, row=1, ipadx=3, ipady=3, sticky=NE) + self._lbl_send_command.grid(column=3, row=1, ipadx=3, ipady=3, sticky=NE) + self._btn_refresh.grid(column=4, row=1, ipadx=3, ipady=3, sticky=NE) + self._lbl_command.grid(column=0, row=2, columnspan=5, sticky=EW) self._frm_container.grid( - column=0, row=8, columnspan=4, rowspan=15, padx=3, sticky=NSEW + column=0, + row=3, + columnspan=5, + rowspan=1, + sticky=NSEW, ) self._can_container.grid( - column=0, row=0, columnspan=3, rowspan=15, padx=3, sticky=NSEW + column=0, + row=0, + columnspan=5, + rowspan=1, + 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=EW - ) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) + self._scr_container_ver.grid(column=5, row=0, sticky=(N, S, W)) + self._scr_container_hor.grid(column=0, row=2, columnspan=5, sticky=EW) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): @@ -297,7 +285,7 @@ def reset(self): self._lbx_cfg_cmd.insert(i, cmd) self._clear_widgets() - self._lbl_send_command.config(image=self._img_unknown) + self._lbl_send_command.config(image=self.__container.img_unknown) def _setscroll(self, event): # pylint: disable=unused-argument """ @@ -379,7 +367,7 @@ 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._lbl_send_command.config(image=self.__container.img_pending) self.__container.status_label = f"P{self._cfg_id} SET message sent" for msgid in pendcfg: self.__container.set_pending(msgid, penddlg) @@ -406,7 +394,7 @@ def _do_poll_cfg(self, *args, **kwargs): # pylint: disable=unused-argument return msg = penddlg = pendcfg = None - # use alternate names for some NMEA PAIR/PQTM POLL commands + # use alternate name for some NMEA PAIR/PQTM POLL commands cfg_id = ALT_POLL_NAMES.get(self._cfg_id, self._cfg_id) # set any POLL arguments to specified or default values # e.g. portid = "1", msgname="RMC" @@ -428,13 +416,13 @@ def _do_poll_cfg(self, *args, **kwargs): # pylint: disable=unused-argument if msg is not None: self.__container.send_command(msg) self.__container.status_label = f"{cp}{cfg_id} POLL message sent" - self._lbl_send_command.config(image=self._img_pending) + self._lbl_send_command.config(image=self.__container.img_pending) for msgid in pendcfg: self.__container.set_pending(msgid, penddlg) self._expected_response = POLL else: # CFG cannot be POLLed self.__container.status_label = f"{cp}{cfg_id} No POLL available" - self._lbl_send_command.config(image=self._img_unknown) + self._lbl_send_command.config(image=self.__container.img_unknown) def _do_poll_args(self, cfg_id: str) -> dict: """ @@ -477,23 +465,26 @@ def _do_poll_args(self, cfg_id: str) -> dict: pass return args - def update_status(self, msg: object): + def update_status(self, msg: NMEAMessage | UBXMessage): """ UBXHandler or NMEAHandler module has received expected command response and forwarded it to this module, entry widgets are pre-populated with current configuration values and confirmation status is updated. - :param object msg: UBXMessage or NMEAMessage response + :param NMEAMessage | UBXMessage msg: UBXMessage or NMEAMessage response """ + # self.logger.debug(f"{msg.identity=}") ok = False - # strip off any variant suffix from cfg_id - # e.g. "QTMCFGUART_CURR" -> "PQTMCFGUART" - cfg_id = ( - "P" + self._cfg_id.rsplit("_", 1)[0] - if self._protocol == NMEA - else self._cfg_id - ) + if self._protocol == NMEA: + # use alternate name for some NMEA PAIR/PQTM POLL commands + # e.g. a PAIR864 (set baud rate) command corresponds to PAIR865 (poll baud rate) + cfg_id = ALT_POLL_NAMES.get(self._cfg_id, self._cfg_id) + # strip off any variant suffix from cfg_id + # e.g. "QTMCFGUART_CURR" -> "PQTMCFGUART" + cfg_id = "P" + cfg_id.rsplit("_", 1)[0] + else: + cfg_id = self._cfg_id # if this message identity matches an expected response if msg.identity in (cfg_id, ACK, NAK): @@ -513,10 +504,10 @@ def update_status(self, msg: object): f"{cfg_id} message acknowledged", OKCOL, ) - self._lbl_send_command.config(image=self._img_confirmed) + self._lbl_send_command.config(image=self.__container.img_confirmed) else: self.__container.status_label = (f"{cfg_id} message rejected", ERRCOL) - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) self.update() def _clear_widgets(self): @@ -530,13 +521,13 @@ def _clear_widgets(self): wdg.destroy() wdg = None Label(self._frm_attrs, text="Attribute", width=12, anchor=W).grid( - column=0, row=0, padx=3, sticky=(W) + column=0, row=0, padx=3, sticky=W ) Label(self._frm_attrs, text="Value", width=20, anchor=W).grid( - column=1, row=0, padx=3, sticky=(W) + column=1, row=0, padx=3, sticky=W ) Label(self._frm_attrs, text="Type", width=5, anchor=W).grid( - column=2, row=0, padx=3, sticky=(W) + column=2, row=0, padx=3, sticky=W ) def _add_widgets(self, pdict: dict, row: int, index: int) -> int: diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py index 8f9bf202..70698650 100644 --- a/src/pygpsclient/globals.py +++ b/src/pygpsclient/globals.py @@ -271,6 +271,8 @@ VALDMY = 8 VALLEN = 9 VALBOOL = 10 +VALREGEX = 11 +VALCUSTOM = 12 WAYPOINT = "waypoint" WIDGETU1 = (200, 200) # small widget size WIDGETU2 = (300, 200) # medium widget size diff --git a/src/pygpsclient/hardware_info_frame.py b/src/pygpsclient/hardware_info_frame.py index 394377a6..07cfeff3 100644 --- a/src/pygpsclient/hardware_info_frame.py +++ b/src/pygpsclient/hardware_info_frame.py @@ -10,7 +10,7 @@ :license: BSD 3-Clause """ -from tkinter import Frame, Label, W +from tkinter import EW, Frame, Label, W from PIL import Image, ImageTk @@ -62,38 +62,31 @@ def _body(self): """ self._lbl_hwverl = Label(self, text="Hardware") - self._lbl_hwver = Label(self) + self._lbl_hwver = Label(self, anchor=W) self._lbl_swverl = Label(self, text="Software") - self._lbl_swver = Label(self) + self._lbl_swver = Label(self, anchor=W) self._lbl_fwverl = Label(self, text="Firmware") - self._lbl_fwver = Label(self) + self._lbl_fwver = Label(self, anchor=W) self._lbl_romverl = Label(self, text="Protocol") - self._lbl_romver = Label(self) + self._lbl_romver = Label(self, anchor=W) self._lbl_gnssl = Label(self, text="GNSS/AS") - self._lbl_gnss = Label(self) + self._lbl_gnss = Label(self, anchor=W) def _do_layout(self): """ Layout widgets. """ - self._lbl_hwverl.grid(column=0, row=0, padx=2, sticky=W) - self._lbl_hwver.grid(column=1, row=0, columnspan=2, padx=2, sticky=W) - self._lbl_swverl.grid(column=3, row=0, padx=2, sticky=W) - self._lbl_swver.grid(column=4, row=0, columnspan=2, padx=2, sticky=W) - self._lbl_fwverl.grid(column=0, row=1, padx=2, sticky=W) - self._lbl_fwver.grid(column=1, row=1, columnspan=2, padx=2, sticky=W) - self._lbl_romverl.grid(column=3, row=1, padx=2, sticky=W) - self._lbl_romver.grid(column=4, row=1, columnspan=2, padx=2, sticky=W) - self._lbl_gnssl.grid(column=0, row=2, columnspan=1, padx=2, sticky=W) - self._lbl_gnss.grid(column=1, row=2, columnspan=4, padx=2, sticky=W) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) - # self.option_add("*Font", self.__app.font_sm) + self._lbl_hwverl.grid(column=0, row=0, padx=3, sticky=W) + self._lbl_hwver.grid(column=1, row=0, columnspan=2, padx=3, sticky=EW) + self._lbl_swverl.grid(column=3, row=0, padx=3, sticky=W) + self._lbl_swver.grid(column=4, row=0, columnspan=2, padx=3, sticky=EW) + self._lbl_fwverl.grid(column=0, row=1, padx=3, sticky=W) + self._lbl_fwver.grid(column=1, row=1, columnspan=2, padx=3, sticky=EW) + self._lbl_romverl.grid(column=3, row=1, padx=3, sticky=W) + self._lbl_romver.grid(column=4, row=1, columnspan=2, padx=3, sticky=EW) + self._lbl_gnssl.grid(column=0, row=2, padx=3, sticky=W) + self._lbl_gnss.grid(column=1, row=2, columnspan=4, padx=3, sticky=EW) def _attach_events(self): """ diff --git a/src/pygpsclient/helpers.py b/src/pygpsclient/helpers.py index 9b4f6a67..d4703e62 100644 --- a/src/pygpsclient/helpers.py +++ b/src/pygpsclient/helpers.py @@ -38,7 +38,7 @@ Tk, font, ) -from types import NoneType +from types import FunctionType, NoneType from typing import Any, Literal from pygnssutils import version as PGVERSION @@ -88,6 +88,7 @@ UMK, VALBLANK, VALBOOL, + VALCUSTOM, VALDMY, VALFLOAT, VALHEX, @@ -95,6 +96,7 @@ VALLEN, VALNONBLANK, VALNONSPACE, + VALREGEX, VALURL, Area, Point, @@ -117,20 +119,32 @@ } -def validate(self: Entry, valmode: int, low=MINFLOAT, high=MAXFLOAT) -> bool: +def validate( + self: Entry, + valmode: int, + low: int | float = MINFLOAT, + high: int | float = MAXFLOAT, + regex: str | NoneType = None, + func: FunctionType | NoneType = None, + args: tuple = (), +) -> bool: """ Extends tkinter.Entry class to add parameterised validation and error highlighting. :param Entry self: tkinter entry widget instance :param int valmode: int representing validation type - can be OR'd - :param object low: optional min value - :param object high: optional max value + :param int | float low: optional min value + :param int | float high: optional max value + :param str | NoneType regex: regex expression + :param FunctionType | NoneType func: custom validation function + :param Any args: optional function arguments + :return: True/False :rtype: bool """ - valid = True + valid = False try: val = self.get() if valmode == VALBLANK and val == "": @@ -151,10 +165,16 @@ def validate(self: Entry, valmode: int, low=MINFLOAT, high=MAXFLOAT) -> bool: valid = val != "" and not val.isspace() elif valmode == VALHEX: # valid hexadecimal bytes.fromhex(val) + valid = True elif valmode == VALDMY: # valid date YYYYMMDD datetime(int(val[0:4]), int(val[4:6]), int(val[6:8])) + valid = True elif valmode == VALLEN: # valid length valid = low <= len(val) <= high + elif valmode == VALREGEX and regex is not None: # matches given regex + valid = re.compile(regex).search(val) is not None + elif valmode == VALCUSTOM and func is not None: # custom validation function + valid = func(val, *args) except ValueError: valid = False diff --git a/src/pygpsclient/importmap_dialog.py b/src/pygpsclient/importmap_dialog.py index 9b1a36e0..c7192a17 100644 --- a/src/pygpsclient/importmap_dialog.py +++ b/src/pygpsclient/importmap_dialog.py @@ -203,9 +203,9 @@ def _valid_entries(self) -> bool: valid = True valid = valid & self._ent_minlat.validate(VALFLOAT, -90, 90) - valid = valid & self._ent_maxlat.validate(-90, 90) + valid = valid & self._ent_maxlat.validate(VALFLOAT, -90, 90) valid = valid & self._ent_minlon.validate(VALFLOAT, -180, 180) - valid = valid & self._ent_maxlon.validate(-180, 180) + valid = valid & self._ent_maxlon.validate(VALFLOAT, -180, 180) if valid: self.status_label = "" self._btn_import.config(state=NORMAL) diff --git a/src/pygpsclient/init_presets.py b/src/pygpsclient/init_presets.py index 1ce3bef7..cad62c19 100644 --- a/src/pygpsclient/init_presets.py +++ b/src/pygpsclient/init_presets.py @@ -62,15 +62,41 @@ "Poll UART1/2 Baudrates, CFG, CFG-VALGET, 000000000100524001005340, 2", ], "nmeapresets_l": [ - "Quectel Restore Factory Defaults CONFIRM; P; QTMRESTOREPAR; ; 1", - "Quectel Save Configuration to NVM CONFIRM; P; QTMSAVEPAR; ; 1", - "Quectel HOT restart CONFIRM; P; QTMHOT; ; 1", - "Quectel WARM restart CONFIRM; P; QTMWARM; ; 1", - "Quectel COLD restart CONFIRM; P; QTMCOLD; ; 1", - "Quectel System Reset and Reboot CONFIRM; P; QTMSRR; ; 1", - "Quectel Check Hardware Version; P; QTMVERNO; ; 2", - "Quectel Start GNSS; P; QTMGNSSSTART; ; 1", - "Quectel Stop GNSS; P; QTMGNSSSTOP; ; 1", + "Quectel LG* Restore Factory Defaults CONFIRM; P; QTMRESTOREPAR; ; 1", + "Quectel LG* Save Configuration to NVM CONFIRM; P; QTMSAVEPAR; ; 1", + "Quectel LG* HOT restart CONFIRM; P; QTMHOT; ; 1", + "Quectel LG* WARM restart CONFIRM; P; QTMWARM; ; 1", + "Quectel LG* COLD restart CONFIRM; P; QTMCOLD; ; 1", + "Quectel LG* System Reset and Reboot CONFIRM; P; QTMSRR; ; 1", + "Quectel LG* Check Hardware Version; P; QTMVERNO; ; 2", + "Quectel LG* Start GNSS; P; QTMGNSSSTART; ; 1", + "Quectel LG* Stop GNSS; P; QTMGNSSSTOP; ; 1", + "Quectel LG* Set current UART baudrate to 115200; P; QTMCFGUART; W,115200; 1", + "Quectel LG* Set current UART baudrate to 460800; P; QTMCFGUART; W,460800; 1", + "Quectel LG* Set Fix Rate to 1 Hz CONFIRM; P; QTMCFGFIXRATE; W,1000; 1; P; QTMSAVEPAR; ; 1; P; QTMSRR; ; 1", + "Quectel LG* Set Fix Rate to 5 Hz CONFIRM; P; QTMCFGFIXRATE; W,200; 1; P; QTMSAVEPAR; ; 1; P; QTMSRR; ; 1", + "Quectel LG* Set Fix Rate to 10 Hz CONFIRM; P; QTMCFGFIXRATE; W,100; 1; P; QTMSAVEPAR; ; 1; P; QTMSRR; ; 1", + "Quectel LG* Set Base Mode Survey-In; P; QTMCFGRCVRMODE; W,2; 1; P; QTMCFGSVIN; W,1,60,3000,0.0,0.0,0.0; 1; P; QTMCFGMSGRATE; W,PQTMSVINSTATUS,1,1; 1; P; QTMSAVEPAR; ; 1; P; QTMSRR; ; 1", + "Quectel LG* Set Base Mode Fixed; P; QTMCFGRCVRMODE; W,2; 1; P; QTMCFGSVIN; W,2,0,0,-2213540.321087019,-4577229.071167925,3838042.2419518335; 1; P; QTMSAVEPAR; ; 1; P; QTMSRR; ; 1", + "Quectel LG* Set Rover Mode; P; QTMCFGRCVRMODE; W,1; 1; P; QTMSAVEPAR; ; 1; P; QTMSRR; ; 1", + "Quectel LG* Enable SVIN Status message; P; QTMCFGMSGRATE; W,PQTMSVINSTATUS,1,1; 1", + "Quectel LG* Disable SVIN Status message; P; QTMCFGMSGRATE; W,PQTMSVINSTATUS,0,1; 1", + "Quectel LG* Set PQTMPVT message rate to 1; P; QTMCFGMSGRATE; W,PQTMPVT,1,1; 1", + "Quectel LG* Enable all QTM PVT outputs; P; QTMCFGMSGRATE; W,PQTMDOP,1,1; 1; P; QTMCFGMSGRATE; W,PQTMEPE,1,2; 1; P; QTMCFGMSGRATE; W,PQTMODO,1,1; 1; P; QTMCFGMSGRATE; W,PQTMPL,1,1; 1; P; QTMCFGMSGRATE; W,PQTMPVT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVEL,1,1; 1", + "Quectel LG* Enable all QTM outputs; P; QTMCFGMSGRATE; W,PQTMANTENNASTATUS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGAIC,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGANTDELTA,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGANTINF,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGBLD,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGCNST,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGDR,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGDRHOT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGDRRTD,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGEINSMSG,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGELETHD,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGFIXRATE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGGEOFENCE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGGEOFENCE_DIS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGGEOFENCE_POLY,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGGEOSEP,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGIMUINT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGIMUTC,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGLA,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGLAM,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGMSGRATE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGMSGRATE_NOVER,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGMSGRATE_INTF,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGMSGRATE_INTFNOVER,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGNAVMODE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGNMEADP,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGNMEATID,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGODO,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGPPS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGPROT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGRCVRMODE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGRSID,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGRTCM,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGRTK,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGRTKSRCTYPE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSAT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSAT_LOW,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSBAS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSIGGRP,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSIGNAL,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSIGNAL2,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSTATICHOLD,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGSVIN,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGUART,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGVEHMOT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMCFGWN,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDEBUGON,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDEBUGOFF,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDOP,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDRCAL,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDRCLR,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDRPVA,1,1; 1; P; QTMCFGMSGRATE; W,PQTMDRSAVE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMEOE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMEPE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMGEOFENCESTATUS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMGETUTC,1,1; 1; P; QTMCFGMSGRATE; W,PQTMGNSSSTART,1,1; 1; P; QTMCFGMSGRATE; W,PQTMGNSSSTOP,1,1; 1; P; QTMCFGMSGRATE; W,PQTMGPS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMJAMMINGSTATUS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMIMU,1,1; 1; P; QTMCFGMSGRATE; W,PQTMIMUTYPE,1,1; 1; P; QTMCFGMSGRATE; W,PQTMINS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMLS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMNAV,1,1; 1; P; QTMCFGMSGRATE; W,PQTMODO,1,1; 1; P; QTMCFGMSGRATE; W,PQTMPL,1,1; 1; P; QTMCFGMSGRATE; W,PQTMPVT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMQVER,1,1; 1; P; QTMCFGMSGRATE; W,PQTMRESETODO,1,1; 1; P; QTMCFGMSGRATE; W,PQTMRESTOREPAR,1,1; 1; P; QTMCFGMSGRATE; W,PQTMSAVEPAR,1,1; 1; P; QTMCFGMSGRATE; W,PQTMSENMSG,1,1; 1; P; QTMCFGMSGRATE; W,PQTMSN,1,1; 1; P; QTMCFGMSGRATE; W,PQTMSTD,1,1; 1; P; QTMCFGMSGRATE; W,PQTMSVINSTATUS,1,1; 1; P; QTMCFGMSGRATE; W,PQTMTAR,1,1; 1; P; QTMCFGMSGRATE; W,PQTMTXT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMUNIQID,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVEHATT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVEHMOT,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVEHMSG,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVEL,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVER,1,1; 1; P; QTMCFGMSGRATE; W,PQTMVERNO,1,1; 1", + "Quectel LG* Poll all QTM outputs; P; QTMCFGAIC; R; 2; P; QTMCFGANTDELTA; R; 2; P; QTMCFGANTINF; R; 2; P; QTMCFGBLD; R; 2; P; QTMCFGCNST; R; 2; P; QTMCFGDR; R; 2; P; QTMCFGELETHD; R; 2; P; QTMCFGFIXRATE; R; 2; P; QTMCFGGEOFENCE; R,0; 2; P; QTMCFGGEOSEP; R; 2; P; QTMCFGIMUINT; R; 2; P; QTMCFGLA; R; 2; P; QTMCFGLAM; R; 2; P; QTMCFGMSGRATE; R,; 2; P; QTMCFGMSGRATE_NOVER; ; 2; P; QTMCFGMSGRATE_INTF; ; 2; P; QTMCFGMSGRATE_INTFNOVER; ; 2; P; QTMCFGNAVMODE; R; 2; P; QTMCFGNMEADP; R; 2; P; QTMCFGNMEATID; R; 2; P; QTMCFGODO; R; 2; P; QTMCFGPPS; R,0; 2; P; QTMCFGPROT; R,0,0; 2; P; QTMCFGRCVRMODE; R; 2; P; QTMCFGRSID; R; 2; P; QTMCFGRTCM; R; 2; P; QTMCFGRTK; R; 2; P; QTMCFGRTKSRCTYPE; R; 2; P; QTMCFGSAT; R,0,; 2; P; QTMCFGSBAS; R; 2; P; QTMCFGSIGGRP; R; 2; P; QTMCFGSIGNAL; R; 2; P; QTMCFGSIGNAL2; R; 2; P; QTMCFGSTATICHOLD; R; 2; P; QTMCFGSVIN; R; 2; P; QTMCFGUART_CURR; ; 2; P; QTMCFGUART; R; 2; P; QTMCFGVEHMOT; R; 2; P; QTMCFGWN; R; 2; P; QTMGETUTC; ; 2; P; QTMQVER; 0; 2; P; QTMSN; ; 2; P; QTMUNIQID; ; 2; P; QTMVEHATT; ; 2; P; QTMVERNO; ; 2", + "Quectel LG* Enable OGC RAW-PPPB2B output;P; QTMCFGMSGRATE; W,1,1,0AB2,1,1;1", + "Quectel LG* Enable OGC RAW-QZSSL6 output;P; QTMCFGMSGRATE; W,1,1,0AB6,1,1;1", + "Quectel LG* Enable OGC RAW-HASE6 output;P; QTMCFGMSGRATE; W,1,1,0AE6,1,1;1", + "Quectel LC* Restore Factory Defaults CONFIRM; P; AIR007; ; 1", + "Quectel LC* Save Configuration to NVM CONFIRM; P; AIR513; ; 1", + "Quectel LC* HOT restart CONFIRM; P; AIR004; ; 1", + "Quectel LC* WARM restart CONFIRM; P; AIR005; ; 1", + "Quectel LC* COLD restart CONFIRM; P; AIR006; ; 1", + "Quectel LC* Set UART1 baudrate to 115200; P; AIR864; 0,0,115200; 1; P; AIR513; ; 1", + "Quectel LC* Set UART1 baudrate to 460800; P; AIR864; 0,0,460800; 1; P; AIR513; ; 1", + "Quectel LC* Poll UART1 baudrate; P; AIR865; 0,0; 2", + "Quectel LC* Set common fix rate; P; AIR050; 1000; 1", ], "ttypresets_l": [ "Septentrio X5 Initialise Command Mode; SSSSSSSSSS", diff --git a/src/pygpsclient/nmea_config_dialog.py b/src/pygpsclient/nmea_config_dialog.py index f2adf9df..513d1691 100644 --- a/src/pygpsclient/nmea_config_dialog.py +++ b/src/pygpsclient/nmea_config_dialog.py @@ -86,33 +86,22 @@ def _do_layout(self): Position widgets in frame. """ - # top of grid - col = 0 - row = 0 - # left column of grid - for frm in (self.frm_device_info, self._frm_preset): - colsp, rowsp = frm.grid_size() - frm.grid( - column=col, - row=row, - columnspan=colsp, - rowspan=rowsp, - sticky=NSEW, - ) - row += rowsp - # right column of grid - row = 0 - col += colsp - for frm in (self._frm_config_dynamic,): - colsp, rowsp = frm.grid_size() - frm.grid( - column=col, - row=row, - columnspan=colsp, - rowspan=rowsp, - sticky=NSEW, - ) - row += rowsp + self.frm_device_info.grid(column=0, row=0, columnspan=2, sticky=NSEW) + self._frm_preset.grid(column=0, row=1, sticky=NSEW) + self._frm_config_dynamic.grid(column=1, row=1, sticky=NSEW) + + self.container.grid_columnconfigure(0, weight=1) + self.container.grid_columnconfigure(1, weight=1) + self.container.grid_rowconfigure(1, weight=1) + colsp, rowsp = self._frm_preset.grid_size() + for col in range(colsp - 2): + self._frm_preset.grid_columnconfigure(col, weight=1) + self._frm_preset.grid_rowconfigure(2, weight=1) + colsp, rowsp = self._frm_config_dynamic.grid_size() + for col in range(colsp): + self._frm_config_dynamic.grid_columnconfigure(col, weight=1) + for row in range(1, rowsp): + self._frm_config_dynamic.grid_rowconfigure(row, weight=1) def _reset(self): """ @@ -154,6 +143,7 @@ def update_pending(self, msg: NMEAMessage): :param NMEAMessage msg: NMEA config message """ + # self.logger.debug(f"{msg.identity=}") nmeafrm = self._pending_confs.get(msg.identity, None) if nmeafrm is not None: diff --git a/src/pygpsclient/nmea_handler.py b/src/pygpsclient/nmea_handler.py index d7dda679..9d99fb8e 100644 --- a/src/pygpsclient/nmea_handler.py +++ b/src/pygpsclient/nmea_handler.py @@ -128,6 +128,8 @@ def process_data(self, raw_data: bytes, parsed_data: NMEAMessage): self._process_FMI(parsed_data) elif parsed_data.msgID[0:3] == "QTM" and hasattr(parsed_data, "status"): self._process_QTMACK(parsed_data) + elif parsed_data.msgID[0:3] == "AIR": + self._process_AIR(parsed_data) except ValueError: pass @@ -404,9 +406,9 @@ def _process_QTMVERNO(self, data: NMEAMessage): """ self.__app.gnss_status.version_data["swversion"] = "N/A" - self.__app.gnss_status.version_data["hwversion"] = f"Quectel {data.verstr}" + self.__app.gnss_status.version_data["hwversion"] = f"Quectel {data.verstr[0:8]}" self.__app.gnss_status.version_data["fwversion"] = ( - f"{data.builddate}-{data.buildtime}" + f"{data.verstr[8:]} {data.builddate}-{data.buildtime}" ) self.__app.gnss_status.version_data["romversion"] = NA self.__app.gnss_status.version_data["gnss"] = NA @@ -518,3 +520,18 @@ def _process_IMU(self, data: NMEAMessage): ims["status"] = str(getattr(data, "quality", "")) except (TypeError, KeyError, AttributeError): pass + + def _process_AIR(self, data: NMEAMessage): + """ + Process PAIR sentences. + + Quectel LC* series configuration or poll message. + + :param NMEAMessage data: PAIR* message + """ + + try: + if self.__app.dialog(DLGTNMEA) is not None: + self.__app.dialog(DLGTNMEA).update_pending(data) + except (TypeError, KeyError, AttributeError): + pass diff --git a/src/pygpsclient/nmea_preset_frame.py b/src/pygpsclient/nmea_preset_frame.py index b3cdd6a9..69083d86 100644 --- a/src/pygpsclient/nmea_preset_frame.py +++ b/src/pygpsclient/nmea_preset_frame.py @@ -15,15 +15,19 @@ EW, HORIZONTAL, LEFT, + NE, + NSEW, VERTICAL, Button, E, + Entry, Frame, Label, Listbox, N, S, Scrollbar, + StringVar, W, ) @@ -39,7 +43,9 @@ ICON_WARNING, NMEA_PRESET, OKCOL, + VALREGEX, ) +from pygpsclient.helpers import validate # pylint: disable=unused-import from pygpsclient.strings import ( CONFIRM, DLGACTION, @@ -50,6 +56,7 @@ CANCELLED = 0 CONFIRMED = 1 NOMINAL = 2 +NMEAPRESETREGEX = r"^(?:(?:[^;]+;){3}\s?[0-2];?)+$" class NMEA_PRESET_Frame(Frame): @@ -80,6 +87,8 @@ def __init__(self, app: Frame, parent: Frame, *args, **kwargs): self._img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) self._preset_command = None self._configfile = None + self._command = StringVar() + self._confirm = False self._body() self._do_layout() self._attach_events() @@ -90,13 +99,23 @@ def _body(self): Set up frame and widgets. """ + self._lbl_command = Label( + self, + text="Command", + ) + self._ent_command = Entry( + self, + textvariable=self._command, + relief="sunken", + width=40, + ) self._lbl_presets = Label(self, text=LBLNMEAPRESET, anchor=W) self._lbx_preset = Listbox( self, border=2, relief="sunken", - height=30, - width=55, + height=20, + width=40, justify=LEFT, exportselection=False, ) @@ -106,7 +125,7 @@ def _body(self): self._lbx_preset.config(xscrollcommand=self._scr_preseth.set) self._scr_presetv.config(command=self._lbx_preset.yview) self._scr_preseth.config(command=self._lbx_preset.xview) - self._lbl_send_command = Label(self) + self._lbl_send_command = Label(self, image=self.__container.img_none) self._btn_send_command = Button( self, image=self._img_send, @@ -119,22 +138,18 @@ def _do_layout(self): Layout widgets. """ - 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=EW + self._lbl_command.grid(column=0, row=0, sticky=W) + self._ent_command.grid(column=1, row=0, columnspan=4, sticky=EW) + self._lbl_presets.grid(column=0, row=1, columnspan=5, sticky=EW) + self._lbx_preset.grid(column=0, row=2, columnspan=3, sticky=NSEW) + self._scr_presetv.grid(column=2, row=2, sticky=(N, S, E)) + self._scr_preseth.grid(column=0, row=3, columnspan=3, sticky=EW) + self._btn_send_command.grid( + column=3, row=2, padx=3, ipadx=3, ipady=3, sticky=NE ) - 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=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=EW + column=4, row=2, padx=3, ipadx=3, ipady=3, sticky=NE ) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): @@ -152,27 +167,31 @@ def reset(self): self.__app.configuration.init_presets("nmea") for i, preset in enumerate(self.__app.configuration.get("nmeapresets_l")): self._lbx_preset.insert(i, preset) + self._command.set("") + self._confirm = False def _on_select_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ Preset command has been selected. """ - idx = self._lbx_preset.curselection() - self._preset_command = self._lbx_preset.get(idx) + cmd = self._lbx_preset.get(self._lbx_preset.curselection()) + self._confirm = CONFIRM in cmd + self._command.set(cmd[cmd.find(";", 1) + 1 :].strip()) def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ Preset command send button has been clicked. """ - if self._preset_command in ("", None): - self.__container.status_label = ("Select preset", ERRCOL) + if not self._ent_command.validate(VALREGEX, regex=NMEAPRESETREGEX): + self.__container.status_label = ("Invalid command format", ERRCOL) return + self._preset_command = self._command.get() confids = [] try: - if CONFIRM in self._preset_command: + if self._confirm: if ConfirmBox(self, DLGACTION, DLGACTIONCONFIRM).show(): confids = self._do_user_defined(self._preset_command) status = CONFIRMED @@ -212,7 +231,7 @@ def _do_user_defined(self, command: str) -> list: confids = [] try: seg = command.split(";") - for i in range(1, len(seg), 4): + for i in range(0, len(seg), 4): talker = seg[i].strip() msg_id = seg[i + 1].strip() payload = seg[i + 2].strip() diff --git a/src/pygpsclient/recorder_dialog.py b/src/pygpsclient/recorder_dialog.py index 184eeda9..372f0a1a 100644 --- a/src/pygpsclient/recorder_dialog.py +++ b/src/pygpsclient/recorder_dialog.py @@ -21,7 +21,19 @@ from datetime import datetime from threading import Event, Thread from time import sleep -from tkinter import CENTER, EW, NSEW, Button, Frame, Label, TclError, W, filedialog +from tkinter import ( + CENTER, + EW, + NSEW, + Button, + Entry, + Frame, + Label, + StringVar, + TclError, + W, + filedialog, +) from PIL import Image, ImageTk from pynmeagps import NMEAMessage @@ -59,7 +71,7 @@ UNDO, ) from pygpsclient.helpers import nmea2preset, set_filename, tty2preset, ubx2preset -from pygpsclient.strings import DLGTRECORD, SAVETITLE +from pygpsclient.strings import DLGTRECORD, NA, SAVETITLE from pygpsclient.toplevel_dialog import ToplevelDialog CFG = b"\x06" @@ -72,6 +84,7 @@ TTYONLY = 0 VALSET = b"\x8a" VALGET = b"\x8b" +SAVE = chr(0x1F4BE) class RecorderDialog(ToplevelDialog): @@ -101,14 +114,17 @@ def __init__(self, app, *args, **kwargs): self._img_stop = ImageTk.PhotoImage(Image.open(ICON_STOP)) self._img_record = ImageTk.PhotoImage(Image.open(ICON_RECORD)) self._img_import = ImageTk.PhotoImage(Image.open(ICON_IMPORT)) + self._img_importconfirm = ImageTk.PhotoImage(Image.open(ICON_SEND)) self._img_undo = ImageTk.PhotoImage(Image.open(ICON_UNDO)) self._img_delete = ImageTk.PhotoImage(Image.open(ICON_DELETE)) + self._importdesc = StringVar() self._rec_status = STOP self._configfile = None self._stop_event = Event() self._bg = self.cget("bg") # default background color self._configfile = None self._configpath = None + self._save_to_preset = False self._body() self._do_layout() @@ -191,6 +207,11 @@ def _body(self): self._lbl_activity = Label( self._frm_body, text="", anchor=CENTER, bg=BGCOL, fg=FGCOL ) + self._ent_import = Entry( + self._frm_body, + textvariable=self._importdesc, + relief="sunken", + ) def _do_layout(self): """ @@ -206,7 +227,9 @@ def _do_layout(self): self._btn_undo.grid(column=5, row=0, ipadx=3, ipady=3, sticky=W) self._btn_delete.grid(column=6, row=0, ipadx=3, ipady=3, sticky=W) self._lbl_memory.grid(column=7, row=0, ipadx=3, ipady=3, sticky=W) - self._lbl_activity.grid(column=0, row=2, columnspan=7, padx=3, sticky=EW) + self._lbl_activity.grid( + column=0, row=2, columnspan=8, pady=5, padx=3, sticky=EW + ) cols, rows = self.grid_size() for i in range(cols): @@ -230,6 +253,8 @@ def _reset(self): self._rec_status = STOP if self.__app.recording else RECORD self._on_record() self.update_count() + self._ent_import.grid_forget() + self._save_to_preset = False def _set_configfile_path(self, ext: str = "bin") -> tuple: """ @@ -378,7 +403,7 @@ def _on_load_txt(self, fname: str) -> int: def _on_save(self): """ - Save commands from in-memory recording to file. + Save commands from in-memory recording to file or preset. """ if self._rec_status == RECORD: @@ -388,6 +413,10 @@ def _on_save(self): self.status_label = ("Nothing to save", ERRCOL) return + if self._save_to_preset: # saving to preset section of json config + self._on_import_confirm() + return + ext = "tty" if self.__app.recording_type == TTYONLY else "bin" fname, self._configfile = self._set_configfile_path(ext) if self._configfile is None: # user cancelled @@ -470,27 +499,45 @@ def _on_import(self): """ if self._rec_status == RECORD: + self.status_label = ("Stop recording first", ERRCOL) return if len(self.__app.recorded_commands) == 0: self.status_label = ("Nothing to import", ERRCOL) return + self.status_label = ( + f"Enter preset description, then click Save {SAVE}", + INFOCOL, + ) + self._importdesc.set(self._get_preset_desc()) + self._lbl_activity.grid_forget() + self._ent_import.grid(column=0, row=2, columnspan=8, padx=3, sticky=EW) + self._save_to_preset = True + self.update() + + def _on_import_confirm(self): + """ + Confirm save to preset. + """ + try: - now = f'Recorded commands {datetime.now().strftime("%Y-%m-%d_%H:%M:%S")}' + desc = self._importdesc.get().replace(";", " ").replace(",", " ") + if desc == "": + desc = self._get_preset_desc() if isinstance(self.__app.recorded_commands[0], UBXMessage): self.__app.configuration.get("ubxpresets_l").append( - ubx2preset(self.__app.recorded_commands, now) + ubx2preset(self.__app.recorded_commands, desc) ) typ = "UBX" elif isinstance(self.__app.recorded_commands[0], NMEAMessage): self.__app.configuration.get("nmeapresets_l").append( - nmea2preset(self.__app.recorded_commands, now) + nmea2preset(self.__app.recorded_commands, desc) ) typ = "NMEA" else: # tty self.__app.configuration.get("ttypresets_l").append( - tty2preset(self.__app.recorded_commands, now) + tty2preset(self.__app.recorded_commands, desc) ) typ = "TTY" @@ -504,6 +551,13 @@ def _on_import(self): ERRCOL, ) + self._lbl_activity.grid( + column=0, row=2, columnspan=7, pady=5, padx=3, sticky=EW + ) + self._ent_import.grid_forget() + self.update() + self._save_to_preset = False + def _on_undo(self): """ Remove last record from in-memory recording. @@ -581,3 +635,15 @@ def _flash_record(self, stop: Event): self._lbl_activity.config(text="", fg=FGCOL, bg=BGCOL) except TclError: # if dialog closed without stopping recording pass + + def _get_preset_desc(self) -> str: + """ + Get default preset description. + + :return: description + :rtype: str + """ + + dev = self.__app.gnss_status.version_data["hwversion"] + dev = "Unknown device" if dev == NA else dev.replace(";", " ").replace(",", " ") + return f'{dev} {datetime.now().strftime("%Y-%m-%d_%H:%M:%S")}' diff --git a/src/pygpsclient/toplevel_dialog.py b/src/pygpsclient/toplevel_dialog.py index 94a45daa..d2127f6c 100644 --- a/src/pygpsclient/toplevel_dialog.py +++ b/src/pygpsclient/toplevel_dialog.py @@ -47,6 +47,7 @@ ICON_REDRAW, ICON_SEND, ICON_START, + ICON_UNKNOWN, ICON_WARNING, INFOCOL, RESIZE, @@ -99,6 +100,7 @@ def __init__(self, app, dlgname: str, *args, **kwargs): 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.img_unknown = ImageTk.PhotoImage(Image.open(ICON_UNKNOWN)) self._con_body(self._resizable) diff --git a/src/pygpsclient/tty_preset_dialog.py b/src/pygpsclient/tty_preset_dialog.py index 2dc759b9..67e8f396 100644 --- a/src/pygpsclient/tty_preset_dialog.py +++ b/src/pygpsclient/tty_preset_dialog.py @@ -15,7 +15,6 @@ HORIZONTAL, LEFT, NE, - NS, NSEW, VERTICAL, Button, @@ -47,8 +46,10 @@ TTYERR, TTYMARKER, TTYOK, + VALNONBLANK, ) from pygpsclient.hardware_info_frame import Hardware_Info_Frame +from pygpsclient.helpers import validate # pylint: disable=unused-import from pygpsclient.strings import ( CONFIRM, DLGACTION, @@ -67,11 +68,12 @@ class TTYPresetDialog(ToplevelDialog): TTY Preset and User-defined configuration command dialog. """ - def __init__(self, app, **kwargs): # pylint: disable=unused-argument + def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument """ Constructor. :param Frame app: reference to main tkinter application + :param args: optional args to pass to parent class (not currently used) :param kwargs: optional kwargs to pass to Frame parent class """ @@ -129,7 +131,7 @@ def _body(self): self._frm_body, border=2, relief="sunken", - height=25, + height=20, width=55, justify=LEFT, exportselection=False, @@ -140,7 +142,7 @@ def _body(self): self._lbx_preset.config(xscrollcommand=self._scr_preseth.set) self._scr_presetv.config(command=self._lbx_preset.yview) self._scr_preseth.config(command=self._lbx_preset.xview) - self._lbl_send_command = Label(self._frm_body) + self._lbl_send_command = Label(self._frm_body, image=self.img_none) self._btn_send_command = Button( self._frm_body, image=self.img_send, @@ -155,40 +157,34 @@ def _do_layout(self): self.frm_device_info.grid(column=0, row=0, sticky=EW) self._frm_body.grid(column=0, row=1, 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=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=EW - ) - self._lbl_presets.grid(column=0, row=3, columnspan=3, padx=3, sticky=EW) + self._lbl_command.grid(column=0, row=0, sticky=W) + self._ent_command.grid(column=1, row=0, columnspan=4, sticky=EW) + self._chk_crlf.grid(column=0, row=1, sticky=W) + self._chk_echo.grid(column=1, row=1, sticky=W) + self._chk_delay.grid(column=2, row=1, sticky=W) + ttk.Separator(self._frm_body).grid(column=0, row=2, columnspan=5, sticky=EW) + self._lbl_presets.grid(column=0, row=3, columnspan=4, sticky=EW) self._lbx_preset.grid( column=0, - row=3, + row=4, columnspan=3, - rowspan=10, - padx=3, - pady=3, - sticky=NS, + sticky=NSEW, ) - 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=EW) + self._scr_presetv.grid(column=2, row=4, sticky=(N, S, E)) + self._scr_preseth.grid(column=0, row=5, columnspan=4, sticky=EW) self._btn_send_command.grid( - column=3, row=3, padx=3, ipadx=3, ipady=3, sticky=NE + column=3, row=4, padx=3, ipadx=3, ipady=3, sticky=NE ) self._lbl_send_command.grid( - column=3, row=4, padx=3, ipadx=3, ipady=3, sticky=EW + column=4, row=4, padx=3, ipadx=3, ipady=3, sticky=NE ) - self.container.grid_columnconfigure(0, weight=10) - self.container.grid_rowconfigure(0, weight=10) - self.grid_columnconfigure(0, weight=10) - self.grid_rowconfigure(0, weight=10) + + self.container.grid_columnconfigure(0, weight=1) + self.container.grid_rowconfigure(1, weight=1) colsp, _ = self._frm_body.grid_size() - for col in range(colsp - 1): - self._frm_body.grid_columnconfigure(col, weight=10) - self._frm_body.grid_rowconfigure(3, weight=10) + for col in range(colsp - 2): + self._frm_body.grid_columnconfigure(col, weight=1) + self._frm_body.grid_rowconfigure(4, weight=1) def _attach_events(self): """ @@ -249,8 +245,8 @@ def _on_send_command(self, *args, **kwargs): # pylint: disable=unused-argument Preset command send button has been clicked. """ - if self._command.get() in ("", None): - self.status_label = ("Enter or select command", ERRCOL) + if not self._ent_command.validate(VALNONBLANK): + self.status_label = ("Invalid command format", ERRCOL) return try: diff --git a/src/pygpsclient/ubx_cfgval_frame.py b/src/pygpsclient/ubx_cfgval_frame.py index 8c8b6d1a..0cd2b181 100644 --- a/src/pygpsclient/ubx_cfgval_frame.py +++ b/src/pygpsclient/ubx_cfgval_frame.py @@ -121,7 +121,7 @@ def _body(self): self, border=2, relief="sunken", - height=12, + height=10, justify=LEFT, exportselection=False, ) @@ -136,7 +136,7 @@ def _body(self): self, border=2, relief="sunken", - height=12, + height=10, justify=LEFT, exportselection=False, ) @@ -202,41 +202,33 @@ def _do_layout(self): Layout widgets. """ - 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=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=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=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) - self._lbl_key.grid(column=1, row=8, padx=3, pady=0, sticky=E) - self._lbl_keyid.grid(column=2, row=8, padx=3, pady=0, sticky=W) - self._lbl_type.grid(column=3, row=8, padx=3, pady=0, sticky=E) - self._lbl_att.grid(column=4, row=8, padx=3, pady=0, sticky=W) - 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=EW) + self._lbl_configdb.grid(column=0, row=0, columnspan=5, sticky=EW) + self._lbl_cat.grid(column=0, row=1, sticky=EW) + self._lbx_cat.grid(column=0, row=2, rowspan=10, sticky=EW) + self._scr_catv.grid(column=0, row=2, rowspan=10, sticky=(N, S, E)) + self._scr_cath.grid(column=0, row=12, sticky=EW) + self._lbl_parm.grid(column=1, row=1, columnspan=4, sticky=EW) + self._lbx_parm.grid(column=1, row=2, columnspan=4, rowspan=10, sticky=EW) + self._scr_parmv.grid(column=4, row=2, rowspan=10, sticky=(N, S, E)) + self._scr_parmh.grid(column=1, row=12, columnspan=4, sticky=EW) + self._rad_cfgget.grid(column=0, row=13, sticky=W) + self._rad_cfgset.grid(column=0, row=14, sticky=W) + self._rad_cfgdel.grid(column=0, row=15, sticky=W) + self._lbl_key.grid(column=1, row=13, sticky=E) + self._lbl_keyid.grid(column=2, row=13, sticky=W) + self._lbl_type.grid(column=3, row=13, sticky=E) + self._lbl_att.grid(column=4, row=13, sticky=W) + self._lbl_layer.grid(column=1, row=14, sticky=E) + self._spn_layer.grid(column=2, row=14, sticky=W) + self._lbl_val.grid(column=1, row=15, sticky=E) + self._ent_val.grid(column=2, row=15, columnspan=3, sticky=EW) self._btn_send_command.grid( - column=3, row=12, rowspan=2, ipadx=3, ipady=3, sticky=E + column=3, row=16, rowspan=2, ipadx=3, ipady=3, sticky=E ) self._lbl_send_command.grid( - column=4, row=13, rowspan=2, ipadx=3, ipady=3, sticky=E + column=4, row=16, rowspan=2, ipadx=3, ipady=3, sticky=E ) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): diff --git a/src/pygpsclient/ubx_config_dialog.py b/src/pygpsclient/ubx_config_dialog.py index 9fbaffc1..0c386a5f 100644 --- a/src/pygpsclient/ubx_config_dialog.py +++ b/src/pygpsclient/ubx_config_dialog.py @@ -32,7 +32,6 @@ CONNECTED, CONNECTED_SIMULATOR, CONNECTED_SOCKET, - ENABLE_CFG_LEGACY, ERRCOL, UBX_CFGMSG, UBX_CFGOTHER, @@ -118,52 +117,27 @@ def _do_layout(self): Position widgets in frame. """ - # top of grid - col = 0 - row = 0 - colsp = 0 - for frm in ( - self.frm_device_info, - self._frm_config_port, - self._frm_config_rate, - self._frm_config_msg, - ): - colsp, rowsp = frm.grid_size() - frm.grid( - column=col, - row=row, - columnspan=colsp, - rowspan=rowsp, - sticky=NSEW, - ) - row += rowsp - # middle column of grid - row = 0 - col += colsp - for frm in (self._frm_configdb, self._frm_preset): - colsp, rowsp = frm.grid_size() - frm.grid( - column=col, - row=row, - columnspan=colsp, - rowspan=rowsp, - sticky=NSEW, - ) - row += rowsp - # right column of grid - if ENABLE_CFG_LEGACY: - row = 0 - col += colsp - for frm in (self._frm_config_dynamic,): - colsp, rowsp = frm.grid_size() - frm.grid( - column=col, - row=row, - columnspan=colsp, - rowspan=rowsp, - sticky=NSEW, - ) - row += rowsp + self.frm_device_info.grid(column=0, row=0, columnspan=3, sticky=NSEW) + self._frm_config_port.grid(column=0, row=1, sticky=NSEW) + self._frm_config_rate.grid(column=0, row=2, sticky=NSEW) + self._frm_config_msg.grid(column=0, row=3, sticky=NSEW) + self._frm_configdb.grid(column=1, row=1, rowspan=2, sticky=NSEW) + self._frm_preset.grid(column=1, row=3, rowspan=2, sticky=NSEW) + self._frm_config_dynamic.grid(column=2, row=1, rowspan=4, sticky=NSEW) + + for col in range(0, 3): + self.container.grid_columnconfigure(col, weight=1) + for row in range(0, 4): + self.container.grid_rowconfigure(row, weight=1) + colsp, rowsp = self._frm_preset.grid_size() + for col in range(colsp - 2): + self._frm_preset.grid_columnconfigure(col, weight=1) + self._frm_preset.grid_rowconfigure(2, weight=1) + colsp, rowsp = self._frm_config_dynamic.grid_size() + for col in range(colsp): + self._frm_config_dynamic.grid_columnconfigure(col, weight=1) + for row in range(1, rowsp): + self._frm_config_dynamic.grid_rowconfigure(row, weight=1) def _reset(self): """ diff --git a/src/pygpsclient/ubx_handler.py b/src/pygpsclient/ubx_handler.py index 1f1ff4c7..801ec794 100644 --- a/src/pygpsclient/ubx_handler.py +++ b/src/pygpsclient/ubx_handler.py @@ -413,8 +413,6 @@ def _process_NAV_SIG(self, data: UBXMessage): now, ) - # print(f"DEBUG {self.__app.gnss_status.sig_data=}") - def _process_NAV_STATUS(self, data: UBXMessage): """ Process NAV-STATUS sentences - Status Information. diff --git a/src/pygpsclient/ubx_msgrate_frame.py b/src/pygpsclient/ubx_msgrate_frame.py index b5ce1d1d..7f65ca82 100644 --- a/src/pygpsclient/ubx_msgrate_frame.py +++ b/src/pygpsclient/ubx_msgrate_frame.py @@ -29,22 +29,19 @@ W, ) -from PIL import Image, ImageTk from pyubx2 import POLL, SET, UBX_MSGIDS, UBXMessage from pyubx2.ubxhelpers import key_from_val from pygpsclient.globals import ( ERRCOL, - ICON_CONFIRMED, - ICON_PENDING, - ICON_SEND, - ICON_WARNING, OKCOL, READONLY, UBX_CFGMSG, ) from pygpsclient.strings import LBLCFGMSG +MAX_RATE = 0xFF + class UBX_MSGRATE_Frame(Frame): """ @@ -67,10 +64,6 @@ def __init__(self, app: Frame, parent: Frame, *args, **kwargs): super().__init__(parent.container, *args, **kwargs) - self._img_send = ImageTk.PhotoImage(Image.open(ICON_SEND)) - self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) - self._img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) - self._img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) self._usb_rate = IntVar() self._uart1_rate = IntVar() self._uart2_rate = IntVar() @@ -88,13 +81,12 @@ def _body(self): Set up frame and widgets. """ - MAX_RATE = 0xFF self._lbl_cfg_msg = Label(self, text=LBLCFGMSG, anchor=W) self._lbx_cfg_msg = Listbox( self, border=2, relief="sunken", - height=11, + height=10, justify=LEFT, exportselection=False, ) @@ -146,10 +138,10 @@ def _body(self): textvariable=self._spi_rate, state=READONLY, ) - self._lbl_send_command = Label(self) + self._lbl_send_command = Label(self, image=self.__container.img_none) self._btn_send_command = Button( self, - image=self._img_send, + image=self.__container.img_send, width=50, fg=OKCOL, command=self._on_send_cfg_msg, @@ -160,33 +152,27 @@ def _do_layout(self): """ Layout widgets. """ - self._lbl_cfg_msg.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) + self._lbl_cfg_msg.grid(column=0, row=0, columnspan=6, sticky=EW) self._lbx_cfg_msg.grid( - column=0, row=1, columnspan=2, rowspan=6, padx=3, pady=3, sticky=EW + column=0, row=1, columnspan=2, rowspan=10, padx=3, sticky=EW ) - self._scr_cfg_msg.grid(column=1, row=1, rowspan=6, sticky=(N, S, E)) - self._lbl_usb.grid(column=2, row=1, padx=0, pady=1, sticky=E) - self._spn_usb.grid(column=3, row=1, padx=0, pady=0, sticky=W) - self._lbl_uart1.grid(column=2, row=2, padx=0, pady=1, sticky=E) - self._spn_uart1.grid(column=3, row=2, padx=0, pady=0, sticky=W) - self._lbl_uart2.grid(column=2, row=3, padx=0, pady=1, sticky=E) - self._spn_uart2.grid(column=3, row=3, padx=0, pady=0, sticky=W) - self._lbl_ddc.grid(column=2, row=4, padx=0, pady=1, sticky=E) - self._spn_ddc.grid(column=3, row=4, padx=0, pady=0, sticky=W) - self._lbl_spi.grid(column=2, row=5, padx=0, pady=1, sticky=E) - self._spn_spi.grid(column=3, row=5, padx=0, pady=0, sticky=W) + self._scr_cfg_msg.grid(column=1, row=1, rowspan=10, sticky=(N, S, E)) + self._lbl_usb.grid(column=2, row=1, rowspan=2, sticky=E) + self._spn_usb.grid(column=3, row=1, rowspan=2, sticky=W) + self._lbl_uart1.grid(column=2, row=3, rowspan=2, sticky=E) + self._spn_uart1.grid(column=3, row=3, rowspan=2, sticky=W) + self._lbl_uart2.grid(column=2, row=5, rowspan=2, sticky=E) + self._spn_uart2.grid(column=3, row=5, rowspan=2, sticky=W) + self._lbl_ddc.grid(column=2, row=7, rowspan=2, sticky=E) + self._spn_ddc.grid(column=3, row=7, rowspan=2, sticky=W) + self._lbl_spi.grid(column=2, row=9, rowspan=2, sticky=E) + self._spn_spi.grid(column=3, row=9, rowspan=2, sticky=W) self._btn_send_command.grid( - column=4, row=1, rowspan=6, ipadx=3, ipady=3, sticky=E + column=4, row=1, rowspan=10, ipadx=3, ipady=3, sticky=E ) self._lbl_send_command.grid( - column=5, row=1, rowspan=6, ipadx=3, ipady=3, sticky=E + column=5, row=1, rowspan=10, ipadx=3, ipady=3, sticky=E ) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): @@ -221,11 +207,11 @@ def update_status(self, msg: UBXMessage): self._uart2_rate.set(msg.rateUART2) self._usb_rate.set(msg.rateUSB) self._spi_rate.set(msg.rateSPI) - self._lbl_send_command.config(image=self._img_confirmed) + self._lbl_send_command.config(image=self.__container.img_confirmed) elif msg.identity == "ACK-NAK": self.__container.status_label = ("CFG-MSG POLL message rejected", ERRCOL) - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) def _on_select_cfg_msg(self, *args, **kwargs): # pylint: disable=unused-argument """ @@ -265,7 +251,7 @@ def _on_send_cfg_msg(self, *args, **kwargs): # pylint: disable=unused-argument rateSPI=rateSPI, ) self.__container.send_command(msg) - self._lbl_send_command.config(image=self._img_pending) + self._lbl_send_command.config(image=self.__container.img_pending) self.__container.status_label = "CFG-MSG SET message sent" for msgid in ("ACK-ACK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGMSG) @@ -281,7 +267,7 @@ 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._lbl_send_command.config(image=self.__container.img_pending) 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 d98e3edb..beabb39c 100644 --- a/src/pygpsclient/ubx_port_frame.py +++ b/src/pygpsclient/ubx_port_frame.py @@ -12,9 +12,9 @@ from tkinter import ( EW, + NE, Button, Checkbutton, - E, Frame, IntVar, Label, @@ -23,17 +23,12 @@ W, ) -from PIL import Image, ImageTk from pyubx2 import POLL, SET, UBXMessage from pygpsclient.globals import ( BPSRATES, CONNECTED, ERRCOL, - ICON_CONFIRMED, - ICON_PENDING, - ICON_SEND, - ICON_WARNING, OKCOL, PORTIDS, READONLY, @@ -63,10 +58,6 @@ def __init__(self, app: Frame, parent: Frame, *args, **kwargs): super().__init__(parent.container, *args, **kwargs) - self._img_send = ImageTk.PhotoImage(Image.open(ICON_SEND)) - self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) - self._img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) - self._img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) self._bpsrate = IntVar() self._portid = StringVar() self._inprot = (1, 1, 0, 1) @@ -129,10 +120,10 @@ def _body(self): self._chk_outprot_rtcm3 = Checkbutton( self, text="RTCM3", variable=self._outprot_rtcm3 ) - self._lbl_send_command = Label(self, image=self._img_pending) + self._lbl_send_command = Label(self, image=self.__container.img_none) self._btn_send_command = Button( self, - image=self._img_send, + image=self.__container.img_send, width=50, command=self._on_send_port, font=self.__app.font_md, @@ -143,36 +134,26 @@ def _do_layout(self): Layout widgets. """ - 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 - ) - self._spn_ubx_portid.grid(column=1, row=1, columnspan=1, rowspan=2, sticky=W) - self._lbl_ubx_bpsrate.grid( - column=2, row=1, columnspan=1, rowspan=2, padx=3, sticky=W - ) - self._spn_ubx_bpsrate.grid(column=3, row=1, columnspan=2, rowspan=2, sticky=W) - self._lbl_inprot.grid(column=0, row=3, padx=3, sticky=W) + self._lbl_cfg_port.grid(column=0, row=0, columnspan=6, sticky=EW) + self._lbl_ubx_portid.grid(column=0, row=1, sticky=W) + self._spn_ubx_portid.grid(column=1, row=1, sticky=W) + self._lbl_ubx_bpsrate.grid(column=0, row=2, sticky=W) + self._spn_ubx_bpsrate.grid(column=1, row=2, sticky=W) + self._lbl_inprot.grid(column=0, row=3, sticky=W) self._chk_inprot_nmea.grid(column=1, row=3, sticky=W) self._chk_inprot_ubx.grid(column=2, row=3, sticky=W) self._chk_inprot_rtcm2.grid(column=3, row=3, sticky=W) self._chk_inprot_rtcm3.grid(column=4, row=3, sticky=W) - self._lbl_outprot.grid(column=0, row=4, padx=3, sticky=W) + self._lbl_outprot.grid(column=0, row=4, sticky=W) self._chk_outprot_nmea.grid(column=1, row=4, sticky=W) self._chk_outprot_ubx.grid(column=2, row=4, sticky=W) self._chk_outprot_rtcm3.grid(column=3, row=4, sticky=W) self._btn_send_command.grid( - column=4, row=1, rowspan=2, ipadx=3, ipady=3, sticky=E + column=3, row=1, rowspan=2, ipadx=3, ipady=3, sticky=NE ) self._lbl_send_command.grid( - column=5, row=1, rowspan=2, ipadx=3, ipady=3, sticky=E + column=4, row=1, rowspan=2, ipadx=3, ipady=3, sticky=NE ) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): @@ -207,12 +188,12 @@ def update_status(self, msg: UBXMessage): self._outprot_ubx.set(msg.outUBX) self._outprot_nmea.set(msg.outNMEA) self._outprot_rtcm3.set(msg.outRTCM3) - self._lbl_send_command.config(image=self._img_confirmed) + self._lbl_send_command.config(image=self.__container.img_confirmed) self.__container.status_label = ("CFG-PRT GET message received", OKCOL) elif msg.identity == "ACK-NAK": self.__container.status_label = ("CFG-PRT POLL message rejected", ERRCOL) - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) def _on_select_portid(self): """ @@ -253,7 +234,7 @@ def _on_send_port(self, *args, **kwargs): # pylint: disable=unused-argument outRTCM3=outRTCM3, ) self.__container.send_command(msg) - self._lbl_send_command.config(image=self._img_pending) + self._lbl_send_command.config(image=self.__container.img_pending) self.__container.status_label = "CFG-PRT SET message sent" for msgid in ("ACK-NAK", "ACK-NAK"): self.__container.set_pending(msgid, UBX_CFGPRT) @@ -268,7 +249,7 @@ def _do_poll_prt(self, *args, **kwargs): # pylint: disable=unused-argument portID = int(self._portid.get()[0:1]) msg = UBXMessage("CFG", "CFG-PRT", POLL, portID=portID) self.__container.send_command(msg) - self._lbl_send_command.config(image=self._img_pending) + self._lbl_send_command.config(image=self.__container.img_pending) 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 60c924e5..0bd68b12 100644 --- a/src/pygpsclient/ubx_preset_frame.py +++ b/src/pygpsclient/ubx_preset_frame.py @@ -15,31 +15,32 @@ EW, HORIZONTAL, LEFT, + NE, + NSEW, VERTICAL, Button, E, + Entry, Frame, Label, Listbox, N, S, Scrollbar, + StringVar, W, ) -from PIL import Image, ImageTk from pyubx2 import UBXMessage from pygpsclient.confirm_box import ConfirmBox from pygpsclient.globals import ( ERRCOL, - ICON_CONFIRMED, - ICON_PENDING, - ICON_SEND, - ICON_WARNING, OKCOL, UBX_PRESET, + VALREGEX, ) +from pygpsclient.helpers import validate # pylint: disable=unused-import from pygpsclient.strings import ( CONFIRM, DLGACTION, @@ -50,6 +51,7 @@ CANCELLED = 0 CONFIRMED = 1 NOMINAL = 2 +UBXPRESETREGEX = r"^(?:(?:[^,]+,){3}\s?[0-2],?)+$" class UBX_PRESET_Frame(Frame): @@ -74,12 +76,10 @@ def __init__(self, app: Frame, parent: Frame, *args, **kwargs): super().__init__(parent.container, *args, **kwargs) - self._img_send = ImageTk.PhotoImage(Image.open(ICON_SEND)) - self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) - self._img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) - self._img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) self._preset_command = None self._configfile = None + self._command = StringVar() + self._confirm = False self._body() self._do_layout() self._attach_events() @@ -90,12 +90,22 @@ def _body(self): Set up frame and widgets. """ + self._lbl_command = Label( + self, + text="Command", + ) + self._ent_command = Entry( + self, + textvariable=self._command, + relief="sunken", + width=40, + ) self._lbl_presets = Label(self, text=LBLUBXPRESET, anchor=W) self._lbx_preset = Listbox( self, border=2, relief="sunken", - height=12, + height=10, width=40, justify=LEFT, exportselection=False, @@ -106,10 +116,10 @@ def _body(self): self._lbx_preset.config(xscrollcommand=self._scr_preseth.set) self._scr_presetv.config(command=self._lbx_preset.yview) self._scr_preseth.config(command=self._lbx_preset.xview) - self._lbl_send_command = Label(self) + self._lbl_send_command = Label(self, image=self.__container.img_none) self._btn_send_command = Button( self, - image=self._img_send, + image=self.__container.img_send, width=50, command=self._on_send_preset, ) @@ -119,20 +129,14 @@ def _do_layout(self): Layout widgets. """ - 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=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=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): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) + self._lbl_command.grid(column=0, row=0, sticky=W) + self._ent_command.grid(column=1, row=0, columnspan=4, sticky=EW) + self._lbl_presets.grid(column=0, row=1, columnspan=5, sticky=EW) + self._lbx_preset.grid(column=0, row=2, columnspan=2, sticky=NSEW) + self._scr_presetv.grid(column=2, row=2, sticky=(N, S, E)) + self._scr_preseth.grid(column=0, row=3, columnspan=2, sticky=EW) + self._btn_send_command.grid(column=3, row=2, ipadx=3, ipady=3, sticky=NE) + self._lbl_send_command.grid(column=4, row=2, ipadx=3, ipady=3, sticky=NE) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): @@ -150,29 +154,33 @@ def reset(self): self.__app.configuration.init_presets("ubx") for i, preset in enumerate(self.__app.configuration.get("ubxpresets_l")): self._lbx_preset.insert(i, preset) + self._command.set("") + self._confirm = False def _on_select_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ Preset command has been selected. """ - idx = self._lbx_preset.curselection() - self._preset_command = self._lbx_preset.get(idx) + cmd = self._lbx_preset.get(self._lbx_preset.curselection()) + self._confirm = CONFIRM in cmd + self._command.set(cmd[cmd.find(",", 1) + 1 :].strip()) def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument """ Preset command send button has been clicked. """ - if self._preset_command in ("", None): - self.__container.status_label = ("Select preset", ERRCOL) + if not self._ent_command.validate(VALREGEX, regex=UBXPRESETREGEX): + self.__container.status_label = ("Invalid command format", ERRCOL) return + self._preset_command = self._command.get() status = CONFIRMED confids = ("MON-VER", "ACK-ACK") try: confids = ("MON-VER", "ACK-ACK", "ACK-NAK") - if CONFIRM in self._preset_command: + if self._confirm: if ConfirmBox(self, DLGACTION, DLGACTIONCONFIRM).show(): self._format_preset(self._preset_command) status = CONFIRMED @@ -183,7 +191,7 @@ def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument status = CONFIRMED if status == CONFIRMED: - self._lbl_send_command.config(image=self._img_pending) + self._lbl_send_command.config(image=self.__container.img_pending) self.__container.status_label = "Command(s) sent" for msgid in confids: self.__container.set_pending(msgid, UBX_PRESET) @@ -194,7 +202,7 @@ def _on_send_preset(self, *args, **kwargs): # pylint: disable=unused-argument except Exception as err: # pylint: disable=broad-except self.__container.status_label = (f"Error {err}", ERRCOL) - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) def _format_preset(self, command: str): """ @@ -209,7 +217,7 @@ def _format_preset(self, command: str): try: seg = command.split(",") - for i in range(1, len(seg), 4): + for i in range(0, len(seg), 4): ubx_class = seg[i].strip() ubx_id = seg[i + 1].strip() payload = seg[i + 2].strip() @@ -222,7 +230,7 @@ def _format_preset(self, command: str): self.__container.send_command(msg) except Exception as err: # pylint: disable=broad-except self.__container.status_label = (f"Error {err}", ERRCOL) - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) def update_status(self, msg: UBXMessage): """ @@ -232,8 +240,8 @@ def update_status(self, msg: UBXMessage): """ if msg.identity in ("ACK-ACK", "MON-VER"): - self._lbl_send_command.config(image=self._img_confirmed) + self._lbl_send_command.config(image=self.__container.img_confirmed) self.__container.status_label = ("Preset command(s) acknowledged", OKCOL) elif msg.identity == "ACK-NAK": - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) self.__container.status_label = ("Preset command(s) rejected", ERRCOL) diff --git a/src/pygpsclient/ubx_solrate_frame.py b/src/pygpsclient/ubx_solrate_frame.py index 6548633e..e42010f6 100644 --- a/src/pygpsclient/ubx_solrate_frame.py +++ b/src/pygpsclient/ubx_solrate_frame.py @@ -12,16 +12,11 @@ from tkinter import EW, Button, E, Frame, IntVar, Label, Spinbox, StringVar, W -from PIL import Image, ImageTk from pyubx2 import POLL, SET, UBXMessage from pygpsclient.globals import ( CONNECTED, ERRCOL, - ICON_CONFIRMED, - ICON_PENDING, - ICON_SEND, - ICON_WARNING, OKCOL, READONLY, UBX_CFGRATE, @@ -58,10 +53,6 @@ def __init__(self, app: Frame, parent: Frame, *args, **kwargs): super().__init__(parent.container, *args, **kwargs) - self._img_send = ImageTk.PhotoImage(Image.open(ICON_SEND)) - self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) - self._img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) - self._img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) self._measint = IntVar() self._navrate = IntVar() self._timeref = StringVar() @@ -104,10 +95,10 @@ def _body(self): wrap=True, textvariable=self._timeref, ) - self._lbl_send_command = Label(self, image=self._img_pending) + self._lbl_send_command = Label(self, image=self.__container.img_none) self._btn_send_command = Button( self, - image=self._img_send, + image=self.__container.img_send, width=50, command=self._on_send_rate, font=self.__app.font_md, @@ -118,31 +109,19 @@ def _do_layout(self): Layout widgets. """ - 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 - ) - self._spn_ubx_measint.grid(column=2, row=1, columnspan=1, rowspan=1, sticky=W) - self._lbl_ubx_navrate.grid( - column=0, row=2, columnspan=2, rowspan=1, padx=3, pady=3, sticky=W - ) - self._spn_ubx_navrate.grid(column=2, row=2, columnspan=2, rowspan=1, sticky=W) - self._lbl_ubx_timeref.grid( - column=0, row=3, columnspan=2, rowspan=1, padx=3, pady=3, sticky=W - ) - self._spn_ubx_timeref.grid(column=2, row=3, columnspan=2, rowspan=1, sticky=W) + self._lbl_cfg_rate.grid(column=0, row=0, columnspan=6, sticky=EW) + self._lbl_ubx_measint.grid(column=0, row=1, columnspan=2, sticky=W) + self._spn_ubx_measint.grid(column=2, row=1, sticky=W) + self._lbl_ubx_navrate.grid(column=0, row=2, columnspan=2, sticky=W) + self._spn_ubx_navrate.grid(column=2, row=2, columnspan=2, sticky=W) + self._lbl_ubx_timeref.grid(column=0, row=3, columnspan=2, sticky=W) + self._spn_ubx_timeref.grid(column=2, row=3, columnspan=2, sticky=W) self._btn_send_command.grid( column=4, row=1, rowspan=3, ipadx=3, ipady=3, sticky=E ) self._lbl_send_command.grid( column=5, row=1, rowspan=3, ipadx=3, ipady=3, sticky=E ) - - cols, rows = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) self.option_add("*Font", self.__app.font_sm) def _attach_events(self): @@ -172,12 +151,12 @@ def update_status(self, msg: UBXMessage): self._measint.set(msg.measRate) self._navrate.set(msg.navRate) self._timeref.set(TIMEREFS[msg.timeRef]) - self._lbl_send_command.config(image=self._img_confirmed) + self._lbl_send_command.config(image=self.__container.img_confirmed) self.__container.status_label = ("CFG-RATE GET message received", OKCOL) elif msg.identity == "ACK-NAK": self.__container.status_label = ("CFG-RATE POLL message rejected", ERRCOL) - self._lbl_send_command.config(image=self._img_warn) + self._lbl_send_command.config(image=self.__container.img_warn) def _on_send_rate(self, *args, **kwargs): # pylint: disable=unused-argument """ @@ -199,7 +178,7 @@ def _on_send_rate(self, *args, **kwargs): # pylint: disable=unused-argument timeRef=tref, ) self.__container.send_command(msg) - self._lbl_send_command.config(image=self._img_pending) + self._lbl_send_command.config(image=self.__container.img_pending) self.__container.status_label = "CFG-RATE SET message sent" self.__container.set_pending(UBX_CFGRATE, ("ACK-ACK", "ACK-NAK")) @@ -212,7 +191,7 @@ 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._lbl_send_command.config(image=self.__container.img_pending) 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/tests/test_tk.py b/tests/test_tk.py index 0799e33a..042a1df6 100644 --- a/tests/test_tk.py +++ b/tests/test_tk.py @@ -27,9 +27,12 @@ VALHEX, VALDMY, VALLEN, + VALREGEX, + VALCUSTOM, ) from pygpsclient.helpers import validate +NMEAPREREGEX = r"^(?:(?:[^;]+;){3}\s?[0-2];?)+$" class TkTest(unittest.TestCase): def setUp(self): @@ -40,6 +43,13 @@ def tearDown(self): def testEntryValidate(self): + def customfunc(val) -> bool: + + try: + return 5 < int(val) < 12 + except ValueError: + return False + try: root = Tk() @@ -81,6 +91,33 @@ def testEntryValidate(self): self.assertTrue(ent.validate(VALURL)) self.assertTrue(ent.validate(VALLEN, 8, 9)) self.assertFalse(ent.validate(VALLEN, 3, 4)) + ent.delete(0 ,'end') + ent.insert(0, "P; QTMCFGUART; W,115200; 1") + self.assertTrue(ent.validate(VALREGEX, regex=NMEAPREREGEX)) + ent.delete(0 ,'end') + ent.insert(0, "P; QTMCFGUART; W,115200; 1;P; QTMCFGUART; W,230400; 1;P; QTMCFGUART; W,460800; 1") + self.assertTrue(ent.validate(VALREGEX, regex=NMEAPREREGEX)) + ent.delete(0 ,'end') + ent.insert(0, "P; QTMCFGUART; W,115200; 1;P; QTMCFGUART; W,230400; 4;P; QTMCFGUART; W,460800; 1") + self.assertFalse(ent.validate(VALREGEX, regex=NMEAPREREGEX)) + ent.delete(0 ,'end') + ent.insert(0, "P; QTMCFGUART; W,115200; 4") + self.assertFalse(ent.validate(VALREGEX, regex=NMEAPREREGEX)) + ent.delete(0 ,'end') + ent.insert(0, "P, QTMCFGUART, W,115200, 1") + self.assertFalse(ent.validate(VALREGEX, regex=NMEAPREREGEX)) + ent.delete(0 ,'end') + ent.insert(0, "") + self.assertFalse(ent.validate(VALREGEX, regex=NMEAPREREGEX)) + ent.delete(0 ,'end') + ent.insert(0, '9') + self.assertTrue(ent.validate(VALCUSTOM, func=customfunc)) + ent.delete(0 ,'end') + ent.insert(0, '18') + self.assertFalse(ent.validate(VALCUSTOM, func=customfunc)) + ent.delete(0 ,'end') + ent.insert(0, 'D') + self.assertFalse(ent.validate(VALCUSTOM, func=customfunc)) except TclError as err: if str(err) == "no display name and no $DISPLAY environment variable":