diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3627e8ce..ae4ac50a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -195,7 +195,7 @@ "pip", "install", "--force-reinstall", - "${workspaceFolder}/dist/pygpsclient-1.5.22-py3-none-any.whl", + "${workspaceFolder}/dist/pygpsclient-1.6.0-py3-none-any.whl", ] }, "problemMatcher": [], @@ -275,15 +275,18 @@ "problemMatcher": [] }, { - "label": "Run Installed Version", + "label": "Run from Source", "type": "shell", - "command": "${config:python.defaultInterpreterPath}", + "command": "python3", "args": [ "-m", "pygpsclient", //"--verbosity", //"3" ], + "options": { + "cwd": "${workspaceFolder}/src" + }, "problemMatcher": [] }, ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56cf3a2f..c069e7f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,10 +15,11 @@ Please help us keep our issue list small by adding fixes: #{$ISSUE_NO} to the co ## Coding conventions * This is open source software. Code should be as simple and transparent as possible. Favour clarity over brevity. -* Avoid external library dependencies unless there's a compelling reason not to. -* We use and recommend [Visual Studio Code](https://code.visualstudio.com/) with the [Python Extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python) for development and testing. +* Avoid external library dependencies (*especially those only available as source distributions*) unless there's a compelling reason not to. +* Avoid platform-specific methods (*be vigilant with tkinter window manager methods*). * Code should be documented in accordance with [Sphinx](https://www.sphinx-doc.org/en/master/) docstring conventions. * Code should formatted using [black](https://pypi.org/project/black/). +* We use and recommend [Visual Studio Code](https://code.visualstudio.com/) with the [Python Extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python) for development and testing. * We use and recommend [pylint](https://pypi.org/project/pylint/) for code analysis. * We use and recommend [bandit](https://pypi.org/project/bandit/) for security vulnerability analysis. * Commits must be [signed](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). diff --git a/INSTALLATION.md b/INSTALLATION.md index c78e7caf..76581c46 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -63,7 +63,7 @@ In the following, `python3` & `pip` refer to the Python 3 executables. You may n - Python >= 3.10⁴ - Tk (tkinter) >= 8.6⁵ (*tkinter is a commonly used library for developing Graphical User Interfaces (GUI) in Python*) -- Screen resolution >= 640 x 400; Ideally 1920 x 1080, though at lower screen resolutions (<= 1024 width), top level dialogs will be resizable and scrollable. +- Screen resolution >= 640 x 480 (VGA); Ideally 1920 x 1080, though at lower screen resolutions (<= 1024 width), top level dialogs will be resizable and scrollable. **NB** It is highly recommended to use the latest official [Python.org](https://www.python.org/downloads/) installation package for your platform, rather than any pre-installed version. diff --git a/README.md b/README.md index 0c33f4ec..d4dff19c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ [TTY Commands](#ttycommands) | [Load/Save/Record Commands](#recorder) | [NTRIP Client](#ntripconfig) | -[SPARTN Client](#spartnconfig) | [Socket Server / NTRIP Caster](#socketserver) | [GPX Track Viewer](#gpxviewer) | [Mapquest API Key](#mapquestapi) | @@ -20,10 +19,10 @@ [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. +* 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, SBF, QGC, RTCM3, NTRIP, SPARTN, MQTT and TTY (ASCII) 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) and [SPARTN](#spartnconfig) client facilities. +* 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 LG290P/LG580P/LC29H and Septentrio Mosaic G5/X5). * Supports GNSS (*and related*) device configuration via proprietary UBX, NMEA and ASCII TTY protocols, including most u-blox, Quectel, Septentrio and Feyman GNSS devices. * Can be installed using the standard `pip` Python package manager - see [installation instructions](#installation) below. @@ -100,6 +99,9 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. --- ## Instructions +#### 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. 1. 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. 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). 1. 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. @@ -109,9 +111,9 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. ![connect-file icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/binary-1-24.png?raw=true) and select the file type (`*.log, *.ubx, *.*`) and path. PyGPSClient datalog files will be named e.g. `pygpsdata-20220427114802.log`, but any binary dump of an GNSS receiver output is acceptable, including `*.ubx` files produced by u-center. The 'File Delay' spinbox sets the delay in milliseconds between individual file reads, acting as a throttle on file readback. 1. To disconnect from the data stream, click ![disconnect icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-media-control-50-24.png?raw=true). +1. To immediately disconnect and terminate all running threads, click Ctrl-K ("Kill Switch"). 1. To exit the application, click ![exit icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-door-6-24.png?raw=true), or press Ctrl-Q, or click the application window's close window icon. -1. To immediately disconnect and terminate all running threads, click Ctrl-K ("Kill Switch"). 1. Protocols Shown - Select which protocols to display; NMEA, UBX, SBF, QGC, RTCM3, SPARTN or TTY (NB: this only changes the displayed protocols - to change the actual protocols output by the receiver, use the [UBX Configuration Dialog](#ubxconfig)). - **NB:** Serial connection must be stopped before changing to or from TTY (terminal) protocol. - **NB:** Enabling TTY (terminal) mode will disable all other protocols. @@ -120,7 +122,6 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. 1. File Delay - Select delay in milliseconds between individual reads when streaming from binary file (default 20 milliseconds). 1. Tags - Enable color tags in console (see Console Widget below). 1. Position Format and Units - Change the displayed position (D.DD / D.M.S / D.M.MM / ECEF) and unit (metric/imperial) formats. -1. Include C/No = 0 - Include or exclude satellites where carrier to noise ratio (C/No) = 0. 1. DataLogging - Turn Data logging in the selected format (Binary, Parsed, Hex Tabular, Hex String, Parsed+Hex Tabular) on or off. On first selection, you will be prompted to select the directory into which timestamped log files are saved. Log files are cycled when a maximum size is reached (default is 10 MB, manually configurable via `logsize_n` setting). 1. GPX Track - Turn track recording (in GPX format) on or off. On first selection, you will be prompted to select the directory into which timestamped GPX track files are saved. 1. Database - Turn spatialite database recording (*where available*) on or off. On first selection, you will be prompted to select the directory into which the `pygpsclient.sqlite` database is saved. Note that, when first created, the database's spatial metadata will take a few seconds to initialise (*up to a minute on Raspberry Pi and similar SBC*). **NB** This facility is dependent on your Python environment supporting the requisite [sqlite3 `mod_spatialite` extension](https://www.gaia-gis.it/fossil/libspatialite/index) - see [INSTALLATION.md](https://github.com/semuconsulting/PyGPSClient/blob/master/INSTALLATION.md#prereqs) for further details. If not supported, the option will be greyed out. Check the Menu..Help..About dialog for an indication of the current spatialite support status. @@ -129,7 +130,6 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. 1. To save the current configuration to a file, go to File..Save Configuration. 1. To load a saved configuration file, go to File..Load Configuration. The default configuration file location is `$HOME/pygpsclient.json`. **NB** Any active serial or RTK connection must be stopped before loading a new configuration. -1. [Socket Server / NTRIP Caster](#socketserver) facility with two modes of operation: (a) open, unauthenticated Socket Server or (b) NTRIP Caster (mountpoint = `pygnssutils`). 1. [UBX Configuration Dialog](#ubxconfig), with the ability to send a variety of UBX CFG configuration commands to u-blox GNSS devices. This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the UBX Configuration Dialog (*only functional when connected to a UBX GNSS device via serial port*), click ![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-ubx.png?raw=true), or go to Menu..Options..UBX Configuration Dialog. 1. [NMEA Configuration Dialog](#nmeaconfig), with the ability to send a variety of NMEA configuration commands to GNSS devices (e.g. Quectel LG290P). This includes the facility to add **user-defined commands or command sequences** - see instructions under [user-defined presets](#userdefined) below. To display the NMEA Configuration Dialog (*only functional when connected to a compatible GNSS device via serial port*), click ![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-nmea.png?raw=true), or go to Menu..Options..NMEA Configuration Dialog. @@ -137,12 +137,22 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. ![gear icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-gear-2-24-tty.png?raw=true), or go to Menu..Options..TTY Commands. 1. [NTRIP Client](#ntripconfig) facility with the ability to connect to a specified NTRIP caster, parse the incoming RTCM3 or SPARTN data and feed this data to a compatible GNSS receiver (*requires an Internet connection and access to an NTRIP caster and local mountpoint*). To display the NTRIP Client Configuration Dialog, click ![ntrip icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-antenna-4-24.png?raw=true), or go to Menu..Options..NTRIP Configuration Dialog. +1. [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 +![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. 1. [SPARTN Client](#spartnconfig) facility with the ability to configure an IP or L-Band SPARTN Correction source and SPARTN-compatible GNSS receiver (e.g. ZED-F9P) and pass the incoming correction data to the GNSS receiver (*requires an Internet connection and access to a SPARTN location service*). To display the SPARTN Client Configuration Dialog, go to Menu..Options..SPARTN Configuration Dialog. 1. [GPX Track Viewer](#gpxviewer) utility with elevation and speed profiles and track metadata. To display the GPX Track viewer, go to Menu..Options..GPX Track Viewer. -#### Configuration settings +#### Saving and loading configuration settings -- Configuration settings for PyGPSClient can be saved and recalled via the Menu..File..Save Configuration and Menu..File..Load Configuration options. By default, PyGPSClient will look for a file named `pygpsclient.json` in the user's home directory. Certain configuration settings require manual editing e.g. custom preset UBX, NMEA and TTY commands and tag colour schemes - see details below. It is recommended to re-save the configuration settings after each PyGPSClient version update, or if you see the warning "Consider re-saving" on startup. +- Configuration settings for PyGPSClient can be saved and recalled via the Menu..File..Save Configuration and Menu..File..Load Configuration options. By default, PyGPSClient will look for a file named `pygpsclient.json` in the user's home directory. Certain configuration settings require manual editing e.g. custom preset UBX, NMEA and TTY commands and tag colour schemes - see details below. +- It is recommended to re-save the configuration settings after each PyGPSClient version update, or if you see the warning "Consider re-saving" on startup. +- PyGPSClient will prompt you to stop all running input and output streams before loading a new configuration. + +#### Toplevel ('pop-up') dialog setting + +- The behaviour of Toplevel ('pop-up') dialogs will depend on the screen resolution. If the width or height of a Toplevel dialog exceeds the screen resolution, the dialog will be displayed in a scrollable, resizeable window. Otherwise, the dialog is displayed as a fixed, non-resizeable panel. +- A boolean configuration setting `transient_dialog_b` governs whether Toplevel dialogs are 'transient' (i.e. always on top of main application dialog) or not. Changing this setting to `0` allows Toplevel dialogs to be minimised independently of the main application window, but be mindful that some dialogs may end up hidden behind others e.g. "Open file/folder" dialogs. **If a file open button appears unresponsive, check that the "Open file/folder" panel isn't already open but obscured**. +- If you're accessing the desktop via a VNC session (e.g. to a headless Raspberry Pi) it is recommended to keep the setting at the default `1`, as VNC may not recognise keystrokes on overlaid non-transient windows. #### Checking for the latest version @@ -152,17 +162,15 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. - PyGPSClient processes all incoming GNSS data in 'real time' but, by default, the GUI is only refreshed every 0.5 seconds. The refresh rate can be configured via the `guiupdateinterval_f` setting in the json configuration file. **NB:** PyGPSClient may become unresponsive on slower platforms (e.g. Raspberry Pi) at high message rates if the GUI update interval is less than 0.1 seconds, though lower intervals (<= 0.1 secs) can be accommodated on more powerful platforms. -#### Transient dialog setting - -- A boolean configuration setting `transient_dialog_b` governs whether pop-up dialogs are 'transient' (i.e. always on top of main application dialog) or not. Changing this setting to `0` allows pop-up dialogs to be minimised independently of the main application window, but be mindful that some dialogs may end up hidden behind others e.g. "Open file/folder" dialogs. **If a file open button appears unresponsive, check that the "Open file/folder" panel isn't already open but obscured**. If you're accessing the desktop via a VNC session (e.g. to a headless Raspberry Pi) it is recommended to keep the setting at the default `1`, as VNC may not recognise keystrokes on overlaid transient windows. - +#### User-selectable widgets --- -| User-selectable 'widgets' | To show or hide the various widgets, go to Menu..View and click on the relevant hide/show option. | +| Widget | To show or hide the various widgets, go to Menu..View and click on the relevant hide/show option. | |---------------------------|---------------------------------------------------------------------------------------------------| |![banner widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/banner_widget.png?raw=true)| Expandable banner showing key navigation status information based on messages received from receiver. To expand or collapse the banner or serial port configuration widgets, click the ![expand icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-arrow-80-16.png?raw=true)/![expand icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-triangle-1-16.png?raw=true) buttons. **NB**: some fields (e.g. hdop/vdop, hacc/vacc) are only available from proprietary NMEA or UBX messages and may not be output by default. The minimum messages required to populate all available fields are: NMEA: GGA, GSA, GSV, RMC, UBX00 (proprietary); UBX: NAV-DOP, NAV-PVT, NAV-SAT | |![console widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/console_widget.png?raw=true)| Configurable serial console widget showing incoming GNSS data streams in either parsed, binary or tabular hexadecimal formats. Double-right-click to copy contents of console to the clipboard. The scroll behaviour and number of lines retained in the console can be configured via the settings panel. Supports user-configurable color tagging of selected strings for easy identification. Color tags are loaded from the `"colortag_b":` value (`0` = disable, `1` = enable) and `"colortags_l":` list (`[string, color]` pairs) in your json configuration file (see example provided). If color is set to "HALT", streaming will halt on any match and a warning displayed. NB: color tagging does impose a small performance overhead - turning it off will improve console response times at very high transaction rates.| |![skyview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/skyview_widget.png?raw=true)| Skyview widget showing current satellite visibility and position (elevation / azimuth). Satellite icon borders are colour-coded to distinguish between different GNSS constellations. For consistency between NMEA and UBX data sources, will display GLONASS NMEA SVID (65-96) rather than slot (1-24). | -|![levelsview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/graphview_widget.png?raw=true)| Levels view widget showing current satellite carrier-to-noise (C/No) levels for each GNSS constellation. Double-click to toggle legend. | +|![levelsview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/graphview_widget.png?raw=true)| Levels view widget showing current satellite carrier-to-noise (C/No) levels for each GNSS constellation. Double-click to toggle legend. Double-right-click to toggle levels where C/No = 0 dbHz. | +|![signalsview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/signalsview_widget.png?raw=true)| Signals view widget showing current svid/signal carrier-to-noise (C/No) level and (where applicable) correction source for each GNSS svid/signal received (*GNSS receiver must be capable of outputting UBX NAV-SIG messages*). Signal identifiers are in RINEX format e.g. `L1_C/A`, `E5_aQ`, etc. Double-click to toggle legend. Double-right-click to toggle signals where C/No = 0 dbHz. | |![world map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/staticmap.png?raw=true)| Map widget with various modes of display - select from "map" / "sat" (online) or "world" / "custom" (offline). Select zoom level 1 - 20. Double-click the zoom level label to reset the zoom to 10. Double-right-click the zoom label to maximise zoom to 20. Tick Track to show track (track will only be recorded while this box is checked). Double-Right-click will clear the map. Map Type = 'world': a static offline Mercator world map showing current global location. |![online map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/webmap_widget.png?raw=true)| Map Type = 'map', 'sat' or 'hyb' (hybrid): Dynamic, online web map or satellite image via MapQuest API (*requires an Internet connection and free [Mapquest API Key](#mapquestapi)*). By default, the web map will automatically refresh every 60 seconds (*indicated by a small timer icon at the top left*). The default refresh rate can be amended by changing the `"mapupdateinterval_n":` value in your json configuration file, but **NB** the facility is not intended to be used for real-time navigation. Double-click anywhere in the map to immediately refresh. | |![offline map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/custommap.png?raw=true)| Map Type = 'custom': One or more user-defined offline geo-referenced map images can be imported using the Menu..Options..Import Custom Map facility, or by manually setting the `usermaps_l` field in the json configuration file. The `usermaps_l` setting represents a list of map paths and extents in the format ["path to map image", [minlat, minlon, maxlat, maxlon]] - see [example configuration file](https://github.com/semuconsulting/PyGPSClient/blob/master/pygpsclient.json#L281). Map images must be a [supported format](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html) and use a standard WGS84 Web Mercator projection e.g. EPSG:4326. PyGPSClient will automatically select the first map whose extents encompass the current location, based on the order in which the maps appear in `usermaps_l`. NB: The minimum and maximum viable 'zoom' levels depend on the resolution and extents of the imported image and the user's display - if the zoom bounds exceed the image extents, the Zoom spinbox will be highlighted. Offline and online zoom levels will not necessarily correspond. | @@ -257,7 +265,14 @@ The following example illustrates a series of ASCII configuration commands being ![recorder screenshot](https://github.com/semuconsulting/PyGPSClient/blob/master/images/recorder_dialog.png?raw=true) -This 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 file. Saved files 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. The Configuration Load facility can accept configuration files in either UBX/NMEA binary (\*.bin), TTY (\*.tty) or u-center UBX text format (\*.txt). Files saved using the [ubxsave](#ubxsave) CLI utility (*installed via the `pygnssutils` library*) can also be reloaded and replayed. **Tip:** The contents of a binary (\*.bin) config file can be reviewed using PyGPSClient's [file streaming facility](#filestream), *BUT* remember to set the `Msg Mode` in the Settings panel to `SET` rather than the default `GET` ![msgmode capture](https://github.com/semuconsulting/PyGPSClient/blob/master/images/msgmode.png?raw=true). +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. 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 `pygnssutils` library*) can also be reloaded and replayed. + +**Tip:** The contents of a binary (\*.bin) config file can be reviewed using PyGPSClient's [file streaming facility](#filestream), *BUT* remember to set the `Msg Mode` in the Settings panel to `SET` rather than the default `GET` ![msgmode capture](https://github.com/semuconsulting/PyGPSClient/blob/master/images/msgmode.png?raw=true). --- ## NTRIP Client Facilities @@ -284,7 +299,7 @@ The NTRIP Configuration utility allows users to receive and process NTRIP RTK Co 1. For NTRIP services which require client position data via NMEA GGA sentences, select the appropriate sentence transmission interval in seconds. The default is 'None' (no GGA sentences sent). A value of 10 or 60 seconds is typical. 1. If GGA sentence transmission is enabled, GGA sentences can either be populated from live navigation data (*assuming a receiver is connected and outputting valid position data*) or from fixed reference settings entered in the NTRIP configuration panel (latitude, longitude, elevation and geoid separation - all four reference settings must be provided). 1. To connect to the NTRIP server, click ![connect icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-media-control-48-24.png?raw=true). To disconnect, click ![disconnect icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-media-control-50-24.png?raw=true). -1. If NTRIP data is being successfully received, the banner '**dgps:**' status indicator should change to 'YES' and indicate the age and reference station of the correction data (where available) ![dgps status](https://github.com/semuconsulting/PyGPSClient/blob/master/images/dgps_status.png?raw=true). Note that DGPS status is typically maintained for up to 60 seconds after loss of correction signal. +1. If NTRIP data is being successfully received, the banner '**corr:**' status indicator should change to 'YES' and indicate the age and reference station of the correction data (where available) ![dgps status](https://github.com/semuconsulting/PyGPSClient/blob/master/images/dgps_status.png?raw=true). Note that DGPS status is typically maintained for up to 60 seconds after loss of correction signal. 1. Some NTRIP services may output RTCM3 or SPARTN correction messages at a high rate, flooding the GUI console display. To suppress these messages in the console, de-select the 'RTCM' or'SPARTN' options in 'Protocols Shown' - the RTCM3 or SPARTN messages will continue to be processed in the background. Below is a illustrative NTRIP DGPS data log, showing: @@ -312,13 +327,13 @@ The Socket Server / NTRIP Caster facility is capable of operating in either of t 1. SOCKET SERVER - an open, unauthenticated TCP socket server available to any socket client including, for example, another instance of PyGPSClient or the [`gnssstreamer` CLI utility](https://github.com/semuconsulting/pygnssutils#gnssstreamer). In this mode it will broadcast the host's currently connected GNSS data stream. The default port is 50012. 2. NTRIP CASTER - a simple implementation of an authenticated NTRIP caster available to any NTRIP client including, for example, PyGPSClient's NTRIP Client facility, [`gnssntripclient`](https://github.com/semuconsulting/pygnssutils#gnssntripclient) or BKG's [NTRIP Client (BNC)](https://igs.bkg.bund.de/ntrip/download). Login credentials for the NTRIP caster are set via the `"ntripcasteruser_s":` and `"ntripcasterpassword_s":` settings in the *.json confirmation file (they can also be set via PyGPSClient command line arguments `--ntripcasteruser`, `--ntripcasterpassword`, or by setting environment variables `NTRIPCASTER_USER`, `NTRIPCASTER_PASSWORD`). Default settings are as follows: bind address: 0.0.0.0, port: 2101, mountpoint: pygnssutils, user: anon, password: password. -By default, the server/caster binds to the host address '0.0.0.0' (IPv4) or '::' (IPv6) i.e. all available IP addresses on the host machine. This can be overridden via the settings panel or a host environment variable `PYGPSCLIENT_BINDADDRESS`. A label on the settings panel indicates the number of connected clients, and the server/caster status is indicated in the topmost banner: running with no clients: ![transmit icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-noclient-10-24.png?raw=true), running with clients: ![transmit icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-transmit-10-24.png?raw=true). +By default, the server/caster binds to the host address '0.0.0.0' (IPv4) or '::' (IPv6) i.e. all available IP addresses on the host machine. This can be overridden via the settings panel or a host environment variable `PYGPSCLIENT_BINDADDRESS`. The server/caster status is indicated: running with no clients: ![transmit icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-noclient-10-24.png?raw=true), running with clients (label shows number of active clients): ![transmit icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-transmit-10-24.png?raw=true). **Pre-Requisites:** 1. Running in NTRIP CASTER mode is predicated on the host being connected to an RTK-compatible GNSS receiver **operating in Base Station mode** (either `FIXED` or `SURVEY_IN`) and outputting the requisite RTCM3 message types (1005/6, 1077, 1087, 1097, etc.). 1. It may be necessary to add a firewall rule and/or enable port-forwarding on the host machine or router to allow remote traffic on the specified address:port. -1. The server supports encrypted TLS (HTTPS) connections. The TLS certificate/key location can be set via environment variable `PYGNSSUTILS_PEMPATH`; the default is `$HOME/pygnssutils.pem`. A self-signed pem file suitable for test and demonstration purposes can be created interactively thus: +1. The server supports encrypted TLS (HTTPS) connections. The TLS server private key / certificate location can be set via environment variable `PYGNSSUTILS_PEMPATH`; the default is `$HOME/pygnssutils.pem`. A self-signed pem file suitable for test and demonstration purposes can be created interactively thus: ```shell openssl req -x509 -newkey rsa:4096 -keyout pygnssutils.pem -out pygnssutils.pem -sha256 -days 3650 -nodes ``` @@ -407,10 +422,10 @@ If the command description contains the term `CONFIRM`, a pop-up confirmation bo 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. -The `pygpsclient.ubx2preset()` and `pygpsclient.nmea2preset()` helper functions may be used to convert a `UBXMessage` or `NMEAMessage` object into a preset string suitable for copying and pasting into the `"ubxpresents_l":` or `"nmeapresets_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 preset string suitable for copying and pasting into the `"ubxpresents_l":`, `"nmeapresets_l":` or `"ttypresets_l":` JSON configuration sections: ```python -from pygpsclient import ubx2preset, nmea2preset +from pygpsclient import ubx2preset, nmea2preset, tty2preset from pyubx2 import UBXMessage from pynmeagps import NMEAMessage, SET @@ -419,14 +434,20 @@ print(ubx2preset(ubx, "Configure NAV-STATUS Message Rate on ZED-F9P")) nmea = NMEAMessage("P", "QTMCFGUART", SET, baudrate=460800) print(nmea2preset(nmea, "Configure UART baud rate on LG290P")) + +tty = b"AT+SYSTEM_RESET\r\n" +print(tty2preset(tty, "IM19 System Reset CONFIRM")) ``` ``` Configure NAV-STATUS Message Rate on ZED-F9P, CFG, CFG-MSG, 0103000100000000, 1 Configure UART baud rate on LG290P; P; QTMCFGUART; W,460800; 1 +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#L188) 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 @@ -439,7 +460,7 @@ For further details, refer to the `pygnssutils` homepage at [https://github.com/ 1. 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. As of October 2025, u-blox have [discontinued their PointPerfect SPARTN L-Band and MQTT services](https://portal.u-blox.com/s/question/0D5Oj00000uB53GKAS/suspension-of-european-pointperfect-lband-spartn-service). As a result, PyGPSClient's [SPARTN Configuration](#spartnconfig) panel is largely redundant and is disabled by default in version>=1.5.17, though it can be re-enabled by manually setting the `lband_enabled_b` configuration setting to 1. +2. 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. 3. Some Homebrew-installed Python environments on MacOS can give rise to critical segmentation errors (*illegal memory access*) when shell subprocesses are invoked, due to the way permissions are implemented. This may, for example, affect About..Update functionality; the workaround is to update via a standard CLI `pip install --upgrade` command. @@ -450,6 +471,8 @@ For further details, refer to the `pygnssutils` homepage at [https://github.com/ sudo apt-get install build-essential libssl-dev libffi-dev python3-dev pkg-config ``` +5. As of October 2025, u-blox have [discontinued their PointPerfect SPARTN L-Band and MQTT services](https://portal.u-blox.com/s/question/0D5Oj00000uB53GKAS/suspension-of-european-pointperfect-lband-spartn-service). As a result, PyGPSClient's [SPARTN Configuration](#spartnconfig) panel is largely redundant and is disabled by default in version>=1.5.17, though it can be re-enabled by manually setting the `lband_enabled_b` configuration setting to 1. + --- ## License diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0b335799..d596d9e9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,20 @@ # PyGPSClient Release Notes +### RELEASE 1.6.0 + +FIXES: + +1. Fix Load Configuration error \#232 `AttributeError: 'tuple' object has no attribute 'upper'`. + +ENHANCEMENTS: + +1. Add user-selectable Signals widget, displaying individual GNSS PRN / Signal ID levels and (where applicable) correction sources (receiver must support UBX NAV-SIG messages). Provides greater granularity than the existing Levels widget for UBX devices. Signal IDs are shown in RINEX format e.g. "L1_C/A", "E5_aQ", etc. +1. Add user-defined preset import facility to Configuration Load/Save/Record panel (accessed via Menu..Options..Configuration Command Recorder). This allows user to record a sequence of UBX, NMEA or TTY commands as they are sent to the receiver and to import this sequence as a user-defined preset in the PyGPSClient json configuration file. This obviates the need to edit the configuration file manually. Remember to re-save the configuration file to persist the changes. +1. Add Undock/Dock Settings panel facility, via Menu..View..Undock/Dock Settings. Settings panel can now be undocked from the main application window and displayed as a separate Toplevel dialog. If (*and only if*) non-transient (`transient_dialog_b: 0`), the settings panel can be minimized independently of the main window. +1. NTRIP Caster / Socket Server Configuration is now a separate Toplevel dialog panel, accessed through Server Config button on Settings panel or via Menu..Options..Server Configuration. Number of connected clients is now displayed in topmost banner panel. +1. Show "C/No = 0 dbHz" option ("unused satellites") is now accessible through double-right-click on LevelsView and SignalsView widgets; option removed from main Settings panel. +1. Minor cosmetic updates to various panels to improve navigation on smaller / lower resolution screens. + ### RELEASE 1.5.23 FIXES: diff --git a/docs/pygpsclient.rst b/docs/pygpsclient.rst index a1b5c8a3..a4499183 100644 --- a/docs/pygpsclient.rst +++ b/docs/pygpsclient.rst @@ -36,10 +36,10 @@ pygpsclient.canvas\_map module :undoc-members: :show-inheritance: -pygpsclient.canvas\_plot module -------------------------------- +pygpsclient.canvas\_subclasses module +------------------------------------- -.. automodule:: pygpsclient.canvas_plot +.. automodule:: pygpsclient.canvas_subclasses :members: :undoc-members: :show-inheritance: @@ -300,10 +300,26 @@ pygpsclient.serialconfig\_lband\_frame module :undoc-members: :show-inheritance: -pygpsclient.serverconfig\_frame module --------------------------------------- +pygpsclient.serverconfig\_dialog module +--------------------------------------- + +.. automodule:: pygpsclient.serverconfig_dialog + :members: + :undoc-members: + :show-inheritance: + +pygpsclient.settings\_child\_frame module +----------------------------------------- + +.. automodule:: pygpsclient.settings_child_frame + :members: + :undoc-members: + :show-inheritance: -.. automodule:: pygpsclient.serverconfig_frame +pygpsclient.settings\_dialog module +----------------------------------- + +.. automodule:: pygpsclient.settings_dialog :members: :undoc-members: :show-inheritance: @@ -316,6 +332,14 @@ pygpsclient.settings\_frame module :undoc-members: :show-inheritance: +pygpsclient.signalsview\_frame module +------------------------------------- + +.. automodule:: pygpsclient.signalsview_frame + :members: + :undoc-members: + :show-inheritance: + pygpsclient.skyview\_frame module --------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 772fbbeb..b28e73e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,12 +129,12 @@ disable = """ too-many-public-methods, too-many-locals, invalid-name, - logging-fstring-interpolation + logging-fstring-interpolation, """ [tool.pytest.ini_options] minversion = "7.0" -addopts = "--cov --cov-report html --cov-fail-under 18" +addopts = "--cov --cov-report html --cov-fail-under 16" pythonpath = ["src"] testpaths = ["tests"] @@ -145,7 +145,7 @@ source = ["src"] source = ["src"] [tool.coverage.report] -fail_under = 18 +fail_under = 16 [tool.coverage.html] directory = "htmlcov" diff --git a/src/pygpsclient/__init__.py b/src/pygpsclient/__init__.py index 9188e7c8..1e3bc118 100644 --- a/src/pygpsclient/__init__.py +++ b/src/pygpsclient/__init__.py @@ -9,7 +9,7 @@ # pylint: disable=invalid-name from pygpsclient._version import __version__ -from pygpsclient.helpers import nmea2preset, ubx2preset +from pygpsclient.helpers import nmea2preset, tty2preset, ubx2preset from pygpsclient.sqlite_handler import retrieve_data version = __version__ diff --git a/src/pygpsclient/__main__.py b/src/pygpsclient/__main__.py index cbd2b861..1f6eec7d 100644 --- a/src/pygpsclient/__main__.py +++ b/src/pygpsclient/__main__.py @@ -102,6 +102,16 @@ def main(): type=int, default=SUPPRESS, ) + ap.add_argument( + "--tlspempath", + help="Fully qualified path to TLS PEM (private key/certificate) file", + default=SUPPRESS, + ) + ap.add_argument( + "--tlscrtpath", + help="Fully qualified path to TLS CRT (certificate) file", + default=SUPPRESS, + ) ap.add_argument( "--verbosity", help=( diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py index 0be58edd..376e36fd 100644 --- a/src/pygpsclient/_version.py +++ b/src/pygpsclient/_version.py @@ -8,4 +8,4 @@ :license: BSD 3-Clause """ -__version__ = "1.5.23" +__version__ = "1.6.0" diff --git a/src/pygpsclient/about_dialog.py b/src/pygpsclient/about_dialog.py index 22d2c888..1c36d47e 100644 --- a/src/pygpsclient/about_dialog.py +++ b/src/pygpsclient/about_dialog.py @@ -53,9 +53,6 @@ } -MINDIM = (600, 400) - - class AboutDialog(ToplevelDialog): """ About dialog box class @@ -78,7 +75,7 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument self._checkonstartup.set(self.__app.configuration.get("checkforupdate_b")) self._updates = [] - super().__init__(app, DLGTABOUT, MINDIM) + super().__init__(app, DLGTABOUT) self._body() self._do_layout() diff --git a/src/pygpsclient/app.py b/src/pygpsclient/app.py index 4df2a2a3..6ec3f5e7 100644 --- a/src/pygpsclient/app.py +++ b/src/pygpsclient/app.py @@ -33,7 +33,7 @@ :license: BSD 3-Clause """ -# pylint: disable=too-many-ancestors, no-member, too-many-lines +# pylint: disable=too-many-ancestors, no-member import logging from datetime import datetime, timedelta @@ -44,7 +44,7 @@ from sys import executable from threading import Thread from time import process_time_ns, time -from tkinter import NSEW, Frame, Label, PhotoImage, Tk, Toplevel, font +from tkinter import EW, NSEW, NW, Frame, Label, PhotoImage, Tk, Toplevel, font from types import NoneType from pygnssutils import GNSSMQTTClient, GNSSNTRIPClient, MQTTMessage @@ -66,10 +66,12 @@ from serial import SerialException, SerialTimeoutException from pygpsclient._version import __version__ as VERSION +from pygpsclient.banner_frame import BannerFrame from pygpsclient.configuration import Configuration from pygpsclient.dialog_state import DialogState from pygpsclient.file_handler import FileHandler from pygpsclient.globals import ( + BGCOL, CLASS, CONFIGFILE, CONNECTED, @@ -105,7 +107,9 @@ from pygpsclient.qgc_handler import QGCHandler from pygpsclient.rtcm3_handler import RTCM3Handler from pygpsclient.sbf_handler import SBFHandler +from pygpsclient.settings_frame import SettingsFrame from pygpsclient.sqlite_handler import DBINMEM, SQLOK, SqliteHandler +from pygpsclient.status_frame import StatusFrame from pygpsclient.stream_handler import StreamHandler from pygpsclient.strings import ( CONFIGERR, @@ -113,12 +117,11 @@ DLGSTOPRTK, DLGTNTRIP, DLGTRECORD, + DLGTSETTINGS, ENDOFFILE, INACTIVE_TIMEOUT, INTROTXTNOPORTS, KILLSWITCH, - LOADCONFIGBAD, - LOADCONFIGOK, NOTCONN, NOWDGSWARN, SAVECONFIGBAD, @@ -129,18 +132,12 @@ from pygpsclient.tty_handler import TTYHandler from pygpsclient.ubx_handler import UBXHandler from pygpsclient.widget_state import ( - COL, COLSPAN, DEFAULT, HIDE, - MAXCOLS, - MAXCOLSPAN, MAXROWSPAN, - MENU, - ROW, - ROWSPAN, + MAXSPAN, SHOW, - STICKY, VISIBLE, WDGCHART, WDGCONSOLE, @@ -168,14 +165,14 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self.__master = master self.logger = logging.getLogger(__name__) - # Init Frame class - Frame.__init__(self, self.__master) + super().__init__(master) self.__master.protocol("WM_DELETE_WINDOW", self.on_exit) self.__master.title(TITLE) self.__master.iconphoto(True, PhotoImage(file=ICON_APP128)) self._deferredmsg = None + self._server_status = -1 # socket server status -1 = inactive self.gnss_inqueue = Queue() # messages from GNSS receiver self.gnss_outqueue = Queue() # messages to GNSS receiver self.ntrip_inqueue = Queue() # messages from NTRIP source @@ -199,6 +196,7 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self.ntrip_handler = GNSSNTRIPClient(self) self.spartn_handler = GNSSMQTTClient(self) self.sqlite_handler = SqliteHandler(self) + self.frm_settings = None self._conn_status = DISCONNECTED self._rtk_conn_status = DISCONNECTED self._nowidgets = True @@ -234,9 +232,9 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self._do_layout() self._attach_events() - # instantiate widgets - for value in self.widget_state.state.values(): - frm = getattr(self, value[FRAME]) + # initialise widgets + for wdg in self.widget_state.state.values(): + frm = getattr(self, wdg[FRAME]) if hasattr(frm, "init_frame"): frm.update_idletasks() frm.init_frame() @@ -264,109 +262,136 @@ def _body(self): self.menu = MenuBar(self) self.__master.config(menu=self.menu) - # initialise widget state - for value in self.widget_state.state.values(): + self.frm_banner = BannerFrame(self, borderwidth=2, relief="groove") + self.frm_status = StatusFrame(self, borderwidth=2, relief="groove") + self.frm_settings = SettingsFrame(self) + self.frm_widgets = Frame(self.__master, bg=BGCOL) + + # instantiate widgets + for wdg in self.widget_state.state.values(): setattr( + # self.frm_widgets, self, - value[FRAME], - value[CLASS](self, borderwidth=2, relief="groove"), + wdg[FRAME], + wdg[CLASS](self, self.frm_widgets, borderwidth=2, relief="groove"), ) def _do_layout(self): """ - Arrange widgets in main application frame, and set - widget visibility and menu label (show/hide). + Arrange visible widgets in main application frame and set + menu labels (show/hide). - NB: PyGPSClient generally favours 'grid' rather than 'pack' - layout management throughout: + NB: PyGPSClient uses 'grid' rather than 'pack' layout management throughout: - grid weight = 0 means fixed, non-expandable - grid weight > 0 means expandable """ + # get maximum column and row spans for frm_widgets + cols = 0 + maxcols = 1 + maxrows = 0 + for wdg in self.widget_state.state.values(): + if wdg[VISIBLE]: + if cols == 0: + maxrows += 1 + cols += wdg.get(COLSPAN, 1) + if cols > self.configuration.get("maxcolumns_n"): + cols = 0 + maxrows += 1 + maxcols = max(cols, maxcols) + + # dynamically position widgets in frm_widgets col = 0 row = 1 - maxcol = 0 - maxrow = 0 - men = 0 - for name in self.widget_state.state: - col, row, maxcol, maxrow, men = self._widget_grid( - name, col, row, maxcol, maxrow, men - ) - - for col in range(MAXCOLSPAN + 1): - self.__master.grid_columnconfigure(col, weight=0) - for row in range(MAXROWSPAN + 2): - self.__master.grid_rowconfigure(row, weight=0) - for col in range(maxcol): - self.__master.grid_columnconfigure(col, weight=5) - for row in range(1, maxrow + 1): - self.__master.grid_rowconfigure(row, weight=5) - - def _widget_grid( - self, name: str, col: int, row: int, maxcol: int, maxrow: int, men: int - ) -> tuple: - """ - Arrange widgets and update menu label (show/hide). - - Widgets with explicit COL(umn) settings will be placed in fixed - positions; widgets with no COL(umn) setting will be arranged - dynamically (left to right, top to bottom). - - :param str name: name of widget - :param int col: col - :param int row: row - :param int maxcol: max cols - :param int maxrow: max rows - :param int men: menu position - :return: max row & col - :rtype: tuple - """ - - maxcols = self.configuration.get("maxcolumns_n") # type: ignore - wdg = self.widget_state.state[name] - dynamic = wdg.get(COL, None) is None - frm = getattr(self, wdg[FRAME]) - if wdg[VISIBLE]: - self.widget_enable_messages(name) - fcol = wdg.get(COL, col) - frow = wdg.get(ROW, row) - colspan = wdg.get(COLSPAN, 1) - if colspan == MAXCOLS: - colspan = maxcols - rowspan = wdg.get(ROWSPAN, 1) - if dynamic and fcol + colspan > maxcols: - fcol = 0 - frow += 1 - frm.grid( - column=fcol, - row=frow, - columnspan=colspan, - rowspan=rowspan, - padx=2, - pady=2, - sticky=wdg.get(STICKY, NSEW), - ) - lbl = HIDE - if dynamic: - col += colspan - if col >= maxcols: # type: ignore + men = 2 + for name, wdg in self.widget_state.state.items(): + frm = getattr(self, wdg[FRAME]) + if wdg[VISIBLE]: + # enable any GNSS data required by widget + self.widget_enable_messages(name) + cols = ( + maxcols if wdg.get(COLSPAN, 1) == MAXSPAN else wdg.get(COLSPAN, 1) + ) + frm.grid(column=col, row=row, columnspan=cols, sticky=NSEW) + col += cols + if col >= maxcols: col = 0 - row += rowspan - maxcol = max(maxcol, fcol + colspan) - maxrow = max(maxrow, frow) - else: - frm.grid_forget() - lbl = SHOW - - # update menu label (show/hide) - if wdg.get(MENU, True): + row += 1 + lbl = HIDE + else: + frm.grid_forget() + lbl = SHOW + # update menu label (show/hide) self.menu.view_menu.entryconfig(men, label=f"{lbl} {name}") men += 1 - # force widget to rescale - # frm.event_generate("") + # do main layout + self.frm_banner.grid(column=0, row=0, columnspan=maxcols + 1, sticky=EW) + self.frm_widgets.grid( + column=0, row=1, columnspan=maxcols, rowspan=maxrows, sticky=NSEW + ) + if isinstance(self.frm_settings, SettingsFrame): # docked + if self.configuration.get("showsettings_b"): + self.frm_settings.grid( + column=maxcols, row=1, rowspan=maxrows, sticky=NW + ) + else: + self.frm_settings.grid_forget() + self.frm_status.grid( + column=0, row=maxrows + 1, columnspan=maxcols + 1, sticky=EW + ) + # update settings menu labels (dock/undock, show/hide) + lbl = "Undock" if self.configuration.get("docksettings_b") else "Dock" + self.menu.view_menu.entryconfig(0, label=f"{lbl} Settings") + lbl = HIDE if self.configuration.get("showsettings_b") else SHOW + self.menu.view_menu.entryconfig(1, label=f"{lbl} Settings") + + # set 'pack' behaviour of main layout + for frm in (self, self.__master, self.frm_widgets): + for col in range(maxcols): + frm.grid_columnconfigure(col, weight=1) + for col in range(maxcols, self.configuration.get("maxcolumns_n") + 1): + frm.grid_columnconfigure(col, weight=0) + for row in range(1, maxrows + 1): + frm.grid_rowconfigure(row, weight=1) + for row in range(maxrows + 1, MAXROWSPAN + 1): + frm.grid_rowconfigure(row, weight=0) + + def settings_toggle(self): + """ + Toggle settings visibility. + """ + + self.configuration.set( + "showsettings_b", not self.configuration.get("showsettings_b") + ) + self._do_layout() + + def settings_dock(self): + """ + Toggle settings docking. + + - If undocked, destroy any existing instance of SettingsFrame + and launch SettingsDialog instead. + - If docked, destroy SettingsDialog and instantiate SettingsFrame. + """ - return col, row, maxcol, maxrow, men + self.configuration.set( + "docksettings_b", not self.configuration.get("docksettings_b") + ) + if self.configuration.get("docksettings_b"): + if self.dialog_state.state[DLGTSETTINGS][DLG] is not None: + self.dialog_state.state[DLGTSETTINGS][DLG].destroy() + self.dialog_state.state[DLGTSETTINGS][DLG] = None + self.frm_settings = SettingsFrame(self) + else: + if self.dialog_state.state[DLGTSETTINGS][DLG] is None: + if isinstance(self.frm_settings, SettingsFrame): + self.frm_settings.grid_forget() + self.frm_settings.destroy() + self.start_dialog(DLGTSETTINGS) + self.frm_settings = self.dialog_state.state[DLGTSETTINGS][DLG] + self._do_layout() def widget_toggle(self, name: str): """ @@ -393,17 +418,27 @@ def widget_enable_messages(self, name: str): if hasattr(frm, "enable_messages"): frm.enable_messages(self.widget_state.state[name][VISIBLE]) - def widget_reset(self): + def reset_layout(self): """ - Reset widgets to default layout. + Reset to default layout. """ - for nam, wdg in self.widget_state.state.items(): + for name, wdg in self.widget_state.state.items(): vis = wdg.get(DEFAULT, False) wdg[VISIBLE] = vis - self.configuration.set(nam, vis) + self.configuration.set(name, vis) + self.configuration.set("showsettings_b", True) + self.configuration.set("docksettings_b", True) self._do_layout() + def reset_frames(self): + """ + Reset frames. + """ + + self.frm_mapview.reset_map_refresh() + self.frm_spectrumview.reset() + def reset_gnssstatus(self): """ Reset gnss_status dictionary e.g. after reconnecting. @@ -462,21 +497,16 @@ def load_config(self): if err == "": # load succeeded self.update_widgets() for frm in ( - self.frm_settings, + self.frm_settings.frm_settings, self.frm_settings.frm_serial, self.frm_settings.frm_socketclient, - self.frm_settings.frm_socketserver, ): frm.reset() self._do_layout() if self._nowidgets: self.status_label = (NOWDGSWARN.format(filename), ERRCOL) - else: - self.status_label = (LOADCONFIGOK.format(filename), OKCOL) - elif err == "cancelled": # user cancelled - return - else: # config error - self.status_label = (LOADCONFIGBAD.format(filename), ERRCOL) + elif err == "cancelled": + pass def save_config(self): """ @@ -514,6 +544,7 @@ def _refresh_widgets(self): Refresh visible widgets. """ + self.frm_banner.update_frame() for wdg, wdgdata in self.widget_state.state.items(): frm = getattr(self, wdgdata[FRAME]) if hasattr(frm, "update_frame") and wdgdata[VISIBLE]: @@ -560,6 +591,8 @@ def sockserver_start(self): https = cfg.get("sockhttps_b") ntripuser = cfg.get("ntripcasteruser_s") ntrippassword = cfg.get("ntripcasterpassword_s") + tlspempath = cfg.get("tlspempath_s") + ntriprtcmstr = "1002(1),1006(5),1077(1),1087(1),1097(1),1127(1),1230(1)" self._socket_thread = Thread( target=self._sockserver_thread, args=( @@ -567,6 +600,8 @@ def sockserver_start(self): host, port, https, + tlspempath, + ntriprtcmstr, ntripuser, ntrippassword, SOCKSERVER_MAX_CLIENTS, @@ -575,16 +610,16 @@ def sockserver_start(self): daemon=True, ) self._socket_thread.start() - self.frm_banner.update_transmit_status(0) + self.server_status = 0 # 0 = active, no clients def sockserver_stop(self): """ Stop socket server thread. """ - self.frm_banner.update_transmit_status(-1) if self._socket_server is not None: self._socket_server.shutdown() + self.server_status = -1 # -1 = inactive def _sockserver_thread( self, @@ -592,6 +627,8 @@ def _sockserver_thread( host: str, port: int, https: int, + tlspempath: str, + ntriprtcmstr: str, ntripuser: str, ntrippassword: str, maxclients: int, @@ -605,6 +642,8 @@ def _sockserver_thread( :param str host: socket host name (0.0.0.0) :param int port: socket port (50010) :param int https: https enabled (0) + :param str tlspempath: path to TLS PEM file ("$HOME/pygnssutils.pem") + :param str ntriprtcmstr: NTRIP caster RTCM type(rate) sourcetable entry :param int maxclients: max num of clients (5) :param Queue socketqueue: socket server read queue """ @@ -620,6 +659,8 @@ def _sockserver_thread( requesthandler, ntripuser=ntripuser, ntrippassword=ntrippassword, + tlspempath=tlspempath, + ntriprtcmstr=ntriprtcmstr, ) as self._socket_server: self._socket_server.serve_forever() except OSError as err: @@ -633,7 +674,7 @@ def update_clients(self, clients: int): :param int clients: no of connected clients """ - self.frm_settings.frm_socketserver.clients = clients + self.server_status = clients def _shutdown(self): """ @@ -684,9 +725,9 @@ def on_gnss_read(self, event): # pylint: disable=unused-argument raw_data, parsed_data = self.gnss_inqueue.get(False) if raw_data is not None and parsed_data is not None: self.process_data(raw_data, parsed_data) - # if socket server is running, output raw data to socket - if self.frm_settings.frm_socketserver.socketserving: - self.socket_outqueue.put(raw_data) + # if socket server is running, output raw data to socket + if self.server_status: + self.socket_outqueue.put(raw_data) self.gnss_inqueue.task_done() except Empty: pass @@ -699,9 +740,7 @@ def on_gnss_eof(self, event): # pylint: disable=unused-argument :param event event: <> event """ - self.frm_settings.frm_socketserver.socketserving = ( - False # turn off socket server - ) + self.server_status = -1 self._refresh_widgets() self.conn_status = DISCONNECTED self.status_label = (ENDOFFILE, ERRCOL) @@ -714,9 +753,7 @@ def on_gnss_timeout(self, event): # pylint: disable=unused-argument :param event event: <> event """ - self.frm_settings.frm_socketserver.socketserving = ( - False # turn off socket server - ) + self.server_status = -1 self._refresh_widgets() self.conn_status = DISCONNECTED self.status_label = (INACTIVE_TIMEOUT, ERRCOL) @@ -1074,10 +1111,34 @@ def conn_status(self, status: int): self._conn_status = status self.frm_banner.update_conn_status(status) - self.frm_settings.enable_controls(status) + self.frm_settings.frm_settings.enable_controls(status) if status == DISCONNECTED: self.conn_label = (NOTCONN, INFOCOL) + @property + def server_status(self) -> int: + """ + Getter for socket server status. + + :return: server status + :rtype: int + """ + + return self._server_status + + @server_status.setter + def server_status(self, status: int): + """ + Setter for socket server status. + + :param int status: server status + -1 - inactive, 0 = active no clients, >0 = active clients + """ + + self._server_status = status + self.frm_banner.update_transmit_status(status) + self.configuration.set("sockserver_b", status >= 0) + @property def rtk_conn_status(self) -> int: """ diff --git a/src/pygpsclient/banner_frame.py b/src/pygpsclient/banner_frame.py index b1945a59..48c266ba 100644 --- a/src/pygpsclient/banner_frame.py +++ b/src/pygpsclient/banner_frame.py @@ -72,13 +72,15 @@ def __init__(self, app, *args, **kwargs): Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + + super().__init__(self.__master, *args, **kwargs) self._status = False self._show_advanced = False @@ -121,6 +123,9 @@ def _body(self): self._frm_advanced2 = Frame(self, bg=BGCOL) self.option_add("*Font", self.__app.font_md2) + self._lbl_clients = Label( + self._frm_connect, bg=self._bgcol, fg="green", width=2, anchor=W + ) self._lbl_ltime = Label( self._frm_basic, text="utc:", @@ -168,9 +173,9 @@ def _body(self): self._lbl_lacc = Label( self._frm_advanced2, text="acc:", bg=self._bgcol, fg=self._fgcol, anchor=N ) - self._lbl_ldgps = Label( + self._lbl_lcorr = Label( self._frm_advanced2, - text="dgps:", + text="corr:", bg=self._bgcol, fg=self._fgcol, anchor=N, @@ -186,7 +191,6 @@ def _body(self): self._lbl_transmit_preset = Label( self._frm_connect, bg=self._bgcol, image=self._img_blank ) - self._lbl_time = Label( self._frm_basic, bg=self._bgcol, fg="cyan", width=15, anchor=W ) @@ -224,7 +228,7 @@ def _body(self): self._frm_advanced2, bg=self._bgcol, fg="mediumpurple1", width=12, anchor=W ) self._lbl_diffcorr = Label( - self._frm_advanced2, bg=self._bgcol, fg="hotpink", width=3, anchor=W + self._frm_advanced2, bg=self._bgcol, fg="hotpink", width=4, anchor=W ) self.option_add("*Font", self.__app.font_sm) @@ -252,7 +256,8 @@ def _do_layout(self): self._lbl_status_preset.grid(column=0, row=0, padx=2, pady=3, sticky=W) self._lbl_rtk_preset.grid(column=1, row=0, padx=2, pady=3, sticky=W) - self._lbl_transmit_preset.grid(column=2, row=0, padx=2, pady=3, sticky=W) + self._lbl_transmit_preset.grid(column=2, row=0, padx=1, pady=3, sticky=W) + self._lbl_clients.grid(column=3, row=0, padx=1, pady=3, sticky=W) self._lbl_ltime.grid(column=1, row=0, pady=0, padx=0, sticky=W) self._lbl_time.grid(column=2, row=0, pady=0, padx=0, sticky=W) self._lbl_llat.grid(column=3, row=0, pady=0, padx=0, sticky=W) @@ -281,7 +286,7 @@ def _do_layout(self): self._lbl_lacc.grid(column=7, row=0, pady=0, padx=0, sticky=W) self._lbl_hvacc.grid(column=8, row=0, pady=0, padx=0, sticky=W) self._lbl_lacc_u.grid(column=9, row=0, pady=0, padx=0, sticky=W) - self._lbl_ldgps.grid(column=10, row=0, pady=0, padx=0, sticky=W) + self._lbl_lcorr.grid(column=10, row=0, pady=0, padx=0, sticky=W) self._lbl_diffcorr.grid(column=11, row=0, pady=0, padx=0, sticky=W) self._lbl_diffstat.grid(column=12, row=0, pady=0, padx=0, sticky=W) @@ -358,10 +363,13 @@ def update_transmit_status(self, transmit: int = 1): if transmit > 0: self._lbl_transmit_preset.configure(image=self._img_transmit) + self._lbl_clients.config(text=transmit, fg="#6b8839") elif transmit == 0: self._lbl_transmit_preset.configure(image=self._img_noclient) + self._lbl_clients.config(text=transmit, fg="#e7b03e") else: self._lbl_transmit_preset.configure(image=self._img_blank) + self._lbl_clients.config(text=" ", fg=BGCOL) def update_frame(self): """ @@ -634,7 +642,7 @@ def _set_fontsize(self): self._lbl_lsip, self._lbl_lsiv, self._lbl_lacc, - self._lbl_ldgps, + self._lbl_lcorr, ): fnt, _ = scale_font(w, 12, txt) ctl.config(font=fnt) diff --git a/src/pygpsclient/canvas_plot.py b/src/pygpsclient/canvas_subclasses.py similarity index 84% rename from src/pygpsclient/canvas_plot.py rename to src/pygpsclient/canvas_subclasses.py index 7f608bb0..649ab916 100644 --- a/src/pygpsclient/canvas_plot.py +++ b/src/pygpsclient/canvas_subclasses.py @@ -1,7 +1,8 @@ """ -canvas_plot.py +canvas_subclasses.py -Multi-purpose CanvasGraph and CanvasCompass subclasses for PyGPSClient application. +Multi-purpose CanvasContainer, CanvasGraph and CanvasCompass subclasses +for PyGPSClient application. Simplifies plotting of graphs and compass representations. @@ -18,7 +19,25 @@ from datetime import timedelta from math import ceil, cos, radians, sin -from tkinter import NE, NW, SE, Canvas, E, Frame, N, S, W, font +from tkinter import ( + ALL, + EW, + HORIZONTAL, + NE, + NS, + NSEW, + NW, + SE, + VERTICAL, + Canvas, + E, + Frame, + N, + S, + Scrollbar, + W, + font, +) from typing import Literal from pygpsclient.globals import GRIDLEGEND, GRIDMAJCOL, GRIDMINCOL, SQRT2, TIME0 @@ -32,6 +51,60 @@ DEFRADII = {"ele": (0, 30, 45, 60, 75, 90), "lin": range(10, 1, -2)} +class CanvasContainer(Canvas): + """ + Custom expandable and scrollable Canvas Container class, + used to contain frames whose dimensions exceed the current + application window size. + """ + + def __init__(self, app, container, *args, **kwargs): + """ + Constructor. + + :param app: Application + :param container: Container frame + """ + + self.__app = app # Reference to main application class + self.__master = self.__app.appmaster # Reference to root class (Tk) + self.x_scrollbar = Scrollbar(container, orient=HORIZONTAL) + self.y_scrollbar = Scrollbar(container, orient=VERTICAL) + + super().__init__( + container, + xscrollcommand=self.x_scrollbar.set, + yscrollcommand=self.y_scrollbar.set, + *args, + **kwargs, + ) + + self.frm_container = Frame(self, borderwidth=2, relief="groove") + self.grid(column=0, row=0, sticky=NSEW) + self.show_scroll() + self.x_scrollbar.config(command=self.xview) + self.y_scrollbar.config(command=self.yview) + # ensure container canvas expands to accommodate child frames + self.create_window((0, 0), window=self.frm_container, anchor=NW) + self.bind("", lambda e: self.config(scrollregion=self.bbox(ALL))) + container.grid_columnconfigure(0, weight=1) + container.grid_rowconfigure(0, weight=1) + + def show_scroll(self, show: bool = True): + """ + Show or hide scrollbars. + + :param bool show: show or hide + """ + + if show: + self.x_scrollbar.grid(column=0, row=1, sticky=EW) + self.y_scrollbar.grid(column=1, row=0, sticky=NS) + else: + self.x_scrollbar.grid_forget() + self.y_scrollbar.grid_forget() + + class CanvasGraph(Canvas): """ Custom Canvas Graph class. @@ -69,6 +142,7 @@ def create_graph( xcol: str = "#000000", ycol: tuple = ("#000000",), xlabels: bool = False, + xlabelsfrm: str = "000", ylabels: bool = False, fontscale: int = 30, **kwargs, @@ -77,26 +151,27 @@ def create_graph( Extends tkinter.Canvas Class to simplify drawing graphs on canvas. Accommodates multiple Y axis channels. - :param float xdatamax: x maximum data value, - :param float xdatamin: x minimum data value, - :param tuple ydatamax: y channel(s) maximum data value, - :param tuple ydatamin: y channel(s) minimum data value, - :param int xtickmaj: x major ticks, + :param float xdatamax: x maximum data value + :param float xdatamin: x minimum data value + :param tuple ydatamax: y channel(s) maximum data value + :param tuple ydatamin: y channel(s) minimum data value + :param int xtickmaj: x major ticks :param int ytickmaj: y major ticks - :param int xtickmin: x minor ticks, - :param int ytickmin: y minor ticks, - :param str fillmaj: major axis color, - :param str fillmin: minor axis color, - :param int xdp: x label decimal places, - :param tuple ydp: y channel(s) label decimal places, - :param str xlegend: x legend, + :param int xtickmin: x minor ticks + :param int ytickmin: y minor ticks + :param str fillmaj: major axis color + :param str fillmin: minor axis color + :param int xdp: x label decimal places + :param tuple ydp: y channel(s) label decimal places + :param str xlegend: x legend :param str xtimeformat: x label time format e.g. "%H:%M:%S" - :param tuple ylegend: y channels legend, - :param str xcol: x label color, - :param tuple ycol: y channel(s) color, - :param bool xlabels: x labels on/off, - :param bool ylabels: y labels on/off, - :param int fontscale: font scaling factor (higher is smaller), + :param tuple ylegend: y channels legend + :param str xcol: x label color + :param tuple ycol: y channel(s) color + :param bool xlabels: x labels on/off + :param str xlabelsfrm: xlabel format string e.g. "000" + :param bool ylabels: y labels on/off + :param int fontscale: font scaling factor (higher is smaller) :return: return code :rtype: int :raises: ValueError if Y channel args have dissimilar lengths @@ -140,10 +215,11 @@ def linspace(num: int, start: float, stop: float): self.fnth = self.font.metrics("linespace") self.xoffl = self.fnth * ceil(len(ydatamax) / 2) * 1.5 self.xoffr = self.xoffl - self.yoffb = self.fnth * 1.5 xangle = kwargs.pop("xangle", 0) - if xangle != 0: # add extra Y offset for slanted X labels - self.yoffb += self.font.measure("000") * sin(radians(xangle)) + if xangle == 0: + self.yoffb = self.fnth * 1.5 + else: # add extra Y offset for slanted X labels + self.yoffb = self.font.measure(xlabelsfrm) * cos(radians(xangle)) * 1.2 self.yofft = self.fnth self.xdatamax = xdatamax self.xdatamin = xdatamin diff --git a/src/pygpsclient/chart_frame.py b/src/pygpsclient/chart_frame.py index 07f2bc36..4b2b166f 100644 --- a/src/pygpsclient/chart_frame.py +++ b/src/pygpsclient/chart_frame.py @@ -29,7 +29,7 @@ font, ) -from pygpsclient.canvas_plot import ( +from pygpsclient.canvas_subclasses import ( TAG_DATA, TAG_GRID, TAG_XLABEL, @@ -98,11 +98,12 @@ class ChartviewFrame(Frame): CHartview frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app, parent, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -110,7 +111,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) self.chartsettings = self.__app.configuration.get("chartsettings_d") def_w, def_h = WIDGETU6 diff --git a/src/pygpsclient/configuration.py b/src/pygpsclient/configuration.py index dfe078e3..eab42534 100644 --- a/src/pygpsclient/configuration.py +++ b/src/pygpsclient/configuration.py @@ -16,6 +16,12 @@ from os import getenv from types import NoneType +from pygnssutils import ( + PYGNSSUTILS_CRT, + PYGNSSUTILS_CRTPATH, + PYGNSSUTILS_PEM, + PYGNSSUTILS_PEMPATH, +) from pyubx2 import GET from serial import PARITY_NONE @@ -95,7 +101,8 @@ def __init__(self, app): # Set initial default configuration self._settings = { "version_s": version, - # main settings from frm_settings + "showsettings_b": 1, + "docksettings_b": 1, **self.widget_config, "checkforupdate_b": 0, "transient_dialog_b": 1, # whether pop-up dialogs are on top of main app window @@ -139,6 +146,8 @@ def __init__(self, app): "trackpath_s": "", "database_b": 0, "databasepath_s": "", + "tlspempath_s": PYGNSSUTILS_PEM, + "tlscrtpath_s": PYGNSSUTILS_CRT, # serial port settings from frm_serial "serialport_s": "/dev/ttyACM0", "bpsrate_n": 9600, @@ -284,6 +293,8 @@ def loadfile(self, filename: str | NoneType = None) -> tuple: resave = True continue else: + if err == "cancelled": # user cancelled + return filename, err if "No such file or directory" in err: err = LOADCONFIGNONE.format(fname) else: @@ -346,6 +357,12 @@ def loadcli(self, **kwargs): arg = kwargs.pop("ntripcasterpassword", getenv("NTRIPCASTER_PASSWORD", None)) if arg is not None: self.set("ntripcasterpassword_s", arg) + arg = kwargs.pop("tlspempath", getenv(PYGNSSUTILS_PEMPATH, PYGNSSUTILS_PEM)) + if arg is not None: + self.set("tlspempath_s", arg) + arg = kwargs.pop("tlscrtpath", getenv(PYGNSSUTILS_CRTPATH, PYGNSSUTILS_CRT)) + if arg is not None: + self.set("tlscrtpath_s", arg) def set(self, name: str, value: object): """ diff --git a/src/pygpsclient/console_frame.py b/src/pygpsclient/console_frame.py index 55db8fca..c2f7ec0e 100644 --- a/src/pygpsclient/console_frame.py +++ b/src/pygpsclient/console_frame.py @@ -56,11 +56,12 @@ class ConsoleFrame(Frame): Console frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -68,7 +69,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU3 self.width = kwargs.get("width", def_w) diff --git a/src/pygpsclient/dialog_state.py b/src/pygpsclient/dialog_state.py index ce863981..5bfed920 100644 --- a/src/pygpsclient/dialog_state.py +++ b/src/pygpsclient/dialog_state.py @@ -2,12 +2,11 @@ dialog_state.py Class holding global constants, strings and dictionaries -used to maintain the state of the various threaded dialogs. +used to maintain the state of the various pop-up dialogs. CLASS = name of dialog class -THD = instance of thread DLG = instance of dialog frame -RESIZE = whether dialog is resizeable +RESIZE = whether dialog is resizeable (defaults to False) Created on 16 Aug 2023 @@ -23,6 +22,8 @@ from pygpsclient.nmea_config_dialog import NMEAConfigDialog from pygpsclient.ntrip_client_dialog import NTRIPConfigDialog from pygpsclient.recorder_dialog import RecorderDialog +from pygpsclient.serverconfig_dialog import ServerConfigDialog +from pygpsclient.settings_dialog import SettingsDialog from pygpsclient.spartn_dialog import SPARTNConfigDialog from pygpsclient.strings import ( DLG, @@ -32,6 +33,8 @@ DLGTNMEA, DLGTNTRIP, DLGTRECORD, + DLGTSERVER, + DLGTSETTINGS, DLGTSPARTN, DLGTTTY, DLGTUBX, @@ -71,6 +74,11 @@ def __init__(self): DLG: None, RESIZE: False, }, + DLGTSERVER: { + CLASS: ServerConfigDialog, + DLG: None, + RESIZE: True, + }, DLGTSPARTN: { CLASS: SPARTNConfigDialog, DLG: None, @@ -89,12 +97,17 @@ def __init__(self): DLGTTTY: { CLASS: TTYPresetDialog, DLG: None, - RESIZE: True, + RESIZE: False, }, DLGTRECORD: { CLASS: RecorderDialog, DLG: None, RESIZE: False, }, + DLGTSETTINGS: { + CLASS: SettingsDialog, + DLG: None, + RESIZE: False, + }, # add any new dialogs here } diff --git a/src/pygpsclient/dynamic_config_frame.py b/src/pygpsclient/dynamic_config_frame.py index a8f80f02..d9bc5297 100644 --- a/src/pygpsclient/dynamic_config_frame.py +++ b/src/pygpsclient/dynamic_config_frame.py @@ -155,23 +155,23 @@ class Dynamic_Config_Frame(Frame): Dynamic configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container + self.__container = parent self.logger = logging.getLogger(__name__) self._protocol = kwargs.pop("protocol", "UBX") - super().__init__(container.container, *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)) diff --git a/src/pygpsclient/file_handler.py b/src/pygpsclient/file_handler.py index 3df6344b..8a1fe431 100644 --- a/src/pygpsclient/file_handler.py +++ b/src/pygpsclient/file_handler.py @@ -124,6 +124,7 @@ def load_config(self, filename: Path = CONFIGFILE) -> tuple: try: if filename is None: filename = self.open_file( + None, "config", ( ("config files", "*.json"), diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py index 1f44abcd..f845ab4f 100644 --- a/src/pygpsclient/globals.py +++ b/src/pygpsclient/globals.py @@ -141,7 +141,6 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): FORMAT_BOTH, ) FRAME = "frm" -GGA_INTERVALS = ("None", "2", "5", "10", "60", "120") GITHUB_URL = "https://github.com/semuconsulting/PyGPSClient" GLONASS_NMEA = True # use GLONASS NMEA SVID (65-96) rather than slot (1-24) GNSS = "GNSS" @@ -170,6 +169,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): ICON_EXIT = path.join(DIRNAME, "resources/iconmonstr-door-6-24.png") ICON_EXPAND = path.join(DIRNAME, "resources/iconmonstr-arrow-80-16.png") ICON_GITHUB = path.join(DIRNAME, "resources/github-256.png") +ICON_IMPORT = path.join(DIRNAME, "resources/iconmonstr-import-24.png") ICON_LOAD = path.join(DIRNAME, "resources/iconmonstr-folder-18-24.png") ICON_LOGREAD = path.join(DIRNAME, "resources/binary-1-24.png") ICON_NMEACONFIG = path.join(DIRNAME, "resources/iconmonstr-gear-2-24-nmea.png") @@ -179,6 +179,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): ICON_PENDING = path.join(DIRNAME, "resources/iconmonstr-time-6-24.png") ICON_PLAY = path.join(DIRNAME, "resources/iconmonstr-media-control-48-24.png") ICON_POS = path.join(DIRNAME, "resources/iconmonstr-plus-lined-24.png") +ICON_POWEROFF = path.join(DIRNAME, "resources/iconmonstr-poweroff-8-24.png") ICON_RECORD = path.join(DIRNAME, "resources/iconmonstr-record-24.png") ICON_REDRAW = path.join(DIRNAME, "resources/iconmonstr-refresh-lined-24.png") ICON_REFRESH = path.join(DIRNAME, "resources/iconmonstr-refresh-6-16.png") @@ -203,14 +204,15 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): LC29H = "Quectel LC29H" LG290P = "Quectel LG29P/LG580P" LICENSE_URL = "https://github.com/semuconsulting/PyGPSClient/blob/master/LICENSE" +LIN = "Linux" +MAC = "Darwin" MAPAPI_URL = "https://developer.mapquest.com/user/login/sign-up" MAX_SNR = 60 # upper limit of levelsview CNo axis MAXFLOAT = 2e20 MAXLOGSIZE = 10485760 # maximum size of individual log file in bytes MIN_GUI_UPDATE_INTERVAL = 0.1 # minimum GUI widget update interval (seconds) MINFLOAT = -MAXFLOAT -MINHEIGHT = 600 -MINWIDTH = 800 +MINDIM = "mindim" MOSAIC_X5 = "Septentrio Mosaic X3/X5" MQAPIKEY = "mqapikey" MQTTIPMODE = 0 @@ -219,6 +221,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): NOPORTS = 3 NTRIP = "NTRIP" NTRIP_EVENT = "<>" +OVERSCAN = 1.1 # 'overscan' allowance for low resolution screens PASSTHRU = "Passthrough" PORTIDS = ("0 I2C", "1 UART1", "2 UART2", "3 USB", "4 SPI") PUBLICIP_URL = "https://ipinfo.io/json" @@ -231,12 +234,8 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): ROUTE = "route" RXMMSG = "RXM-SPARTN-KEY" SAT_EXPIRY = 10 # how long passed satellites are kept in the sky and graph view -SCREENSCALE = 0.8 # screen resolution scaling factor -SOCK_NTRIP = "NTRIP CASTER" -SOCK_SERVER = "SOCKET SERVER" SOCKCLIENT_HOST = "localhost" SOCKCLIENT_PORT = 50010 -SOCKMODES = (SOCK_SERVER, SOCK_NTRIP) SOCKSERVER_HOST = "0.0.0.0" # i.e. bind to all host IP addresses SOCKSERVER_MAX_CLIENTS = 5 SOCKSERVER_NTRIP_PORT = 2101 @@ -288,6 +287,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): WIDGETU3 = (800, 200) # Console size WIDGETU4 = (500, 500) # GPX Track viewer size WIDGETU6 = (400, 200) # Chart size +WIN = "Windows" WORLD = "world" XML_HDR = '' ZED_F9 = "u-blox ZED-F9" @@ -335,6 +335,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): NMEA_CFGOTHER = 17 SERVERCONFIG = 18 SBF_MONHW = 19 +SIGNALSVIEW = 20 KNOWNGPS = ( "cp210", diff --git a/src/pygpsclient/gnss_status.py b/src/pygpsclient/gnss_status.py index fb429463..b6a1352e 100644 --- a/src/pygpsclient/gnss_status.py +++ b/src/pygpsclient/gnss_status.py @@ -56,6 +56,9 @@ def __init__(self): self.rel_pos_flags = [] # rover relative position flags # dict of satellite {(gnssid,svid}: (gnssId, svid, elev, azim, cno, last_updated)} self.gsv_data = {} + # dict of signal {(gnssid,svid,sigid}: (gnssId, svid, sigid, cno, corrsource, quality, + # sigflags, last_updated)} + self.sig_data = {} # dict of hardware, firmware and software versions self.version_data = { "swversion": NA, diff --git a/src/pygpsclient/gpx_dialog.py b/src/pygpsclient/gpx_dialog.py index 80189491..820b6c40 100644 --- a/src/pygpsclient/gpx_dialog.py +++ b/src/pygpsclient/gpx_dialog.py @@ -34,7 +34,7 @@ from pynmeagps import haversine, planar from pygpsclient.canvas_map import HYB, MAP, SAT, CanvasMap -from pygpsclient.canvas_plot import CanvasGraph +from pygpsclient.canvas_subclasses import CanvasGraph from pygpsclient.globals import ( BGCOL, CUSTOM, @@ -67,8 +67,6 @@ from pygpsclient.toplevel_dialog import ToplevelDialog MD_LINES = 3 # number of lines of metadata -MINDIM = (400, 500) - GPXTYPES = {TRACK: "trkpt", WAYPOINT: "wpt", ROUTE: "rtept"} AXISLBLTAG = "axl" CHARTMINY = 0 @@ -91,7 +89,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app self.logger = logging.getLogger(__name__) # self.__master = self.__app.appmaster # link to root Tk window - super().__init__(app, DLGTGPX, MINDIM) + super().__init__(app, DLGTGPX) self._mapzoom = IntVar() self._maptype = StringVar() self._gpxtype = StringVar() @@ -136,7 +134,7 @@ def _body(self): Create widgets. """ - self._frm_body = Frame(self.container, borderwidth=2, relief="groove") + self._frm_body = Frame(self.container) self._frm_map = Frame(self._frm_body, borderwidth=2, relief="groove", bg=BGCOL) self._frm_profile = Frame( self._frm_body, borderwidth=2, relief="groove", bg=BGCOL diff --git a/src/pygpsclient/hardware_info_frame.py b/src/pygpsclient/hardware_info_frame.py index d9650a51..aa156371 100644 --- a/src/pygpsclient/hardware_info_frame.py +++ b/src/pygpsclient/hardware_info_frame.py @@ -35,21 +35,21 @@ class Hardware_Info_Frame(Frame): Hardware & firmware information panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class - self.__container = container + self.__container = parent self._protocol = kwargs.pop("protocol", "UBX") - super().__init__(container.container, *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)) diff --git a/src/pygpsclient/helpers.py b/src/pygpsclient/helpers.py index 70f0733f..6c20c1e5 100644 --- a/src/pygpsclient/helpers.py +++ b/src/pygpsclient/helpers.py @@ -35,11 +35,11 @@ Spinbox, StringVar, Tk, + font, ) -from tkinter.font import Font from typing import Literal -from pynmeagps import WGS84_SMAJ_AXIS, haversine +from pynmeagps import WGS84_SMAJ_AXIS, NMEAMessage, haversine from pyubx2 import ( SET, SET_LAYER_RAM, @@ -53,6 +53,7 @@ from requests import get from pygpsclient.globals import ( + BSR, ERRCOL, FIXLOOKUP, GPSEPOCH0, @@ -66,9 +67,9 @@ MPS2KNT, MPS2KPH, MPS2MPH, + OVERSCAN, PUBLICIP_URL, ROMVER_NEW, - SCREENSCALE, TIME0, UI, UIK, @@ -92,12 +93,6 @@ MAXPORT = 65535 MAXALT = 10000.0 # meters arbitrary -# NTRIP enumerations -NMEA = {"0": "No GGA", "1": "GGA"} -AUTHS = {"N": "None", "B": "Basic", "D": "Digest"} -CARRIERS = {"0": "No", "1": "L1", "2": "L1,L2"} -SOLUTIONS = {"0": "Single", "1": "Network"} - def validate(self: Entry, valmode: int, low=MINFLOAT, high=MAXFLOAT) -> bool: """ @@ -282,24 +277,22 @@ def check_latest(name: str) -> str: return NA -def check_lowres(master: Tk, dim: tuple) -> tuple: +def check_lowres(master: Tk, dim: tuple, overscan: float = OVERSCAN) -> tuple: """ Check if dialog dimensions exceed effective screen resolution. :param tkinter.Tk master: reference to root :param tuple dim: dialog dimensions in pixels (height, width) + :param float overscan: screen 'overscan' allowance :return: low resolution yes/no and effective resolution :rtype: tuple (boolean, (screen height/width)) """ - sh, sw = screenres(master) + sh, sw = [int(i / overscan) for i in screenres(master)] dh, dw = dim - if sh < dh or sw < dw: - maxh, maxw = sh, sw - lowres = True - else: - maxh, maxw = dh, dw - lowres = False + maxh = min(sh, dh) + maxw = min(sw, dw) + lowres = (maxh, maxw) != dim return lowres, (maxh, maxw) @@ -389,6 +382,40 @@ def dop2str(dop: float) -> str: return dops +def fitfont( + fmt: str, + maxw: int, + maxh: int, + angle: int = 0, + maxsiz: int = 10, + constraint: int = 3, +) -> tuple[font.Font, float, float]: + """ + Create font to fit space. + + :param str format: format of string + :param int maxw: max width in pixels + :param int maxh: max height in pixels + :param int angle: font angle in degrees + :param int maxsiz: maximum font size in pixels + :param int constraint: 1 = width, 2 = height, 3 = width & height + :return: tuple of (sized font, font width, font height) + :rtype: tuple[font.Font, float, float] + """ + + fw, fh = maxw + 1, maxh + 1 + rw, rh = fw, fh + siz = maxsiz + fnt = font.Font(size=-siz) + while ( + (rw > maxw and constraint & 1) or (rh > maxh and constraint & 2) + ) and siz > 0: + fnt = font.Font(size=-siz) + rw, rh = fontdim(fmt, fnt, angle) + siz -= 1 + return fnt, fw, fh + + def fix2desc(msgid: str, fix: object) -> str: """ Get integer fix value for given message fix status. @@ -418,6 +445,25 @@ def ft2m(feet: float) -> float: return feet / 3.28084 +def fontdim(fmt: str, fnt: font.Font, angle: int = 0) -> tuple[float, float]: + """ + Get x,y pixel dimensions of string in given rotated font. + + :param str fmt: format string e.g. "000" + :param font.Font fnt: font + :param int angle: rotation angle in degrees (0 = horizontal) + :return: tuple of (width, height) + :rtype: tuple[float, float] + """ + + theta = radians(angle) + fw = fnt.measure(fmt) + fh = fnt.metrics("linespace") + rw = abs(fw * cos(theta)) + abs(fh * sin(theta)) + rh = abs(fh * cos(theta)) + abs(fw * sin(theta)) + return rw, rh + + def get_mp_distance(lat: float, lon: float, mp: list) -> float: """ Get distance to mountpoint from current location (if known). @@ -460,22 +506,22 @@ def get_mp_info(srt: list) -> dict: "identifier": srt[1], "format": srt[2], "messages": srt[3], - "carrier": CARRIERS.get(srt[4], srt[4]), + "carrier": srt[4], "navs": srt[5], "network": srt[6], "country": srt[7], "lat": srt[8], "lon": srt[9], - "gga": NMEA.get(srt[10], srt[10]), - "solution": SOLUTIONS.get(srt[11], srt[11]), + "gga": srt[10], + "solution": srt[11], "generator": srt[12], "encrypt": srt[13], - "auth": AUTHS.get(srt[14], srt[14]), + "auth": srt[14], "fee": srt[15], "bitrate": srt[16], } except IndexError: - return None + return {"name": NA, "gga": 0} def get_point_at_vector( @@ -802,7 +848,9 @@ def ned2vector(n: float, e: float, d: float) -> tuple: return dis, hdg -def nmea2preset(msgs: tuple, desc: str = "") -> str: +def nmea2preset( + msgs: NMEAMessage | tuple[NMEAMessage] | list[NMEAMessage], desc: str = "" +) -> str: """ Convert one or more NMEAMessages to format suitable for adding to user-defined preset list `nmeapresets_l` in PyGPSClient .json configuration files. @@ -812,14 +860,14 @@ def nmea2preset(msgs: tuple, desc: str = "") -> str: e.g. "Configure Signals; P; QTMCFGSIGNAL; W,7,3,F,3F,7,1; 1" - :param tuple msgs: NMEAmessage or tuple of NMEAmessages + :param NMEAMessage | tuple[NMEAMessage] | list[NMEAMessage] msgs: NMEAmessage(s) :param str desc: preset description :return: preset string :rtype: str """ desc = desc.replace(";", " ") - if not isinstance(msgs, tuple): + if not isinstance(msgs, (tuple, list)): msgs = (msgs,) preset = ( f"{msgs[0].identity} {['GET','SET','POLL'][msgs[0].msgmode]}" @@ -985,7 +1033,7 @@ def rgb2str(r: int, g: int, b: int) -> str: def scale_font( - width: int, basesize: int, txtwidth: int, maxsize: int = 0, fnt: Font = None + width: int, basesize: int, txtwidth: int, maxsize: int = 0, fnt: font.Font = None ) -> tuple: """ Scale font size to widget width. @@ -999,23 +1047,22 @@ def scale_font( :rtype: tuple """ - fnt = Font(size=12) if fnt is None else fnt + fnt = font.Font(size=12) if fnt is None else fnt fs = basesize * width / fnt.measure("W" * txtwidth) - fnt = Font(size=int(min(fs, maxsize))) if maxsize else Font(size=int(fs)) + fnt = font.Font(size=int(min(fs, maxsize))) if maxsize else font.Font(size=int(fs)) return fnt, fnt.metrics("linespace") -def screenres(master: Tk, scale: float = SCREENSCALE) -> tuple: +def screenres(master: Tk) -> tuple: """ Get effective screen resolution. :param tkinter.Tk master: reference to root - :param float scale: screen scaling factor - :return: adjusted screen resolution in pixels (height, width) + :return: screen resolution in pixels (height, width) :rtype: tuple """ - return (master.winfo_screenheight() * scale, master.winfo_screenwidth() * scale) + return (master.winfo_screenheight(), master.winfo_screenwidth()) def secs2unit(secs: int) -> tuple: @@ -1238,6 +1285,32 @@ def time2str(tim: float, sformat: str = "%H:%M:%S") -> str: return dt.strftime(sformat) +def tty2preset(msgs: bytes | tuple[bytes] | list[bytes], desc: str = "") -> str: + """ + Convert one or more ASCII TTY commands to format suitable for adding to user-defined + preset list `ttypresets_l` in PyGPSClient .json configuration files. + + The format is: + "; " + + e.g. "IM19 System reset CONFIRM; AT+SYSTEM_RESET" + + :param bytes | tuple[bytes] | list[bytes] msgs: ASCII TTY command(s) + :param str desc: preset description + :return: preset string + :rtype: str + """ + + desc = desc.replace(";", " ") + if not isinstance(msgs, (tuple, list)): + msgs = (msgs,) + preset = "TTY Command" if desc == "" else desc + for msg in msgs: + cmd = msg.decode("ascii", errors=BSR).strip("\r\n") + preset += f"; {cmd}" + return preset + + def unused_sats(data: dict) -> int: """ Get number of 'unused' sats in gnss_data.gsv_data. @@ -1250,7 +1323,9 @@ def unused_sats(data: dict) -> int: return sum(1 for (_, _, _, _, cno, _) in data.values() if cno == 0) -def ubx2preset(msgs: tuple, desc: str = "") -> str: +def ubx2preset( + msgs: UBXMessage | tuple[UBXMessage] | list[UBXMessage], desc: str = "" +) -> str: """ Convert one or more UBXMessages to format suitable for adding to user-defined preset list `ubxpresets_l` in PyGPSClient .json configuration files. @@ -1260,14 +1335,14 @@ def ubx2preset(msgs: tuple, desc: str = "") -> str: e.g. "Set NMEA High Precision Mode, CFG, CFG-VALSET, 000100000600931001, 1" - :param tuple msgs: UBXMessage or tuple of UBXmessages + :param UBXMessage | tuple[UBXMessage] | list[UBXMessage] msgs: UBXMessage(s) :param str desc: preset description :return: preset string :rtype: str """ desc = desc.replace(",", " ") - if not isinstance(msgs, tuple): + if not isinstance(msgs, (tuple, list)): msgs = (msgs,) preset = ( f"{msgs[0].identity} {['GET','SET','POLL'][msgs[0].msgmode]}" diff --git a/src/pygpsclient/importmap_dialog.py b/src/pygpsclient/importmap_dialog.py index ebfc7c5c..9b1a36e0 100644 --- a/src/pygpsclient/importmap_dialog.py +++ b/src/pygpsclient/importmap_dialog.py @@ -51,8 +51,6 @@ from pygpsclient.strings import DLGTIMPORTMAP from pygpsclient.toplevel_dialog import ToplevelDialog -MINDIM = (456, 418) - class ImportMapDialog(ToplevelDialog): """ImportMapDialog class.""" @@ -62,7 +60,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # self.__master = self.__app.appmaster # link to root Tk window - super().__init__(app, DLGTIMPORTMAP, MINDIM) + super().__init__(app, DLGTIMPORTMAP) self.width = int(kwargs.get("width", 400)) self.height = int(kwargs.get("height", 400)) self._first = IntVar() @@ -83,7 +81,7 @@ def _body(self): Create widgets. """ - self._frm_body = Frame(self.container, borderwidth=2, relief="groove") + self._frm_body = Frame(self.container) self._can_mapview = CanvasMap( self.__app, self._frm_body, width=self.width, height=self.height, bg=BGCOL ) diff --git a/src/pygpsclient/imu_frame.py b/src/pygpsclient/imu_frame.py index 21b8cdb8..532d526f 100644 --- a/src/pygpsclient/imu_frame.py +++ b/src/pygpsclient/imu_frame.py @@ -72,18 +72,19 @@ class IMUFrame(Frame): IMU (Inertial Management Unit) frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: Optional args to pass to Frame parent class :param kwargs: Optional kwargs to pass to Frame parent class """ self.__app = app self.__master = self.__app.appmaster - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 diff --git a/src/pygpsclient/levelsview_frame.py b/src/pygpsclient/levelsview_frame.py index e263e972..2711a72d 100644 --- a/src/pygpsclient/levelsview_frame.py +++ b/src/pygpsclient/levelsview_frame.py @@ -16,7 +16,7 @@ from tkinter import NE, NSEW, Frame, font -from pygpsclient.canvas_plot import ( +from pygpsclient.canvas_subclasses import ( TAG_DATA, TAG_GRID, TAG_XLABEL, @@ -31,11 +31,12 @@ MAX_SNR, WIDGETU2, ) -from pygpsclient.helpers import col2contrast, unused_sats +from pygpsclient.helpers import col2contrast, fitfont, unused_sats OL_WID = 1 FONTSCALELG = 40 -FONTSCALESV = 30 +XLBLANGLE = 35 +XLBLFMT = "000" class LevelsviewFrame(Frame): @@ -43,11 +44,12 @@ class LevelsviewFrame(Frame): Levelsview frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -55,7 +57,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 self.width = kwargs.get("width", def_w) @@ -83,6 +85,8 @@ def _attach_events(self): self.bind("", self._on_resize) self._canvas.bind("", self._on_legend) + self._canvas.bind("", self._on_cno0) + self._canvas.bind("", self._on_cno0) def _on_legend(self, event): # pylint: disable=unused-argument """ @@ -96,6 +100,18 @@ def _on_legend(self, event): # pylint: disable=unused-argument ) self._redraw = True + def _on_cno0(self, event): # pylint: disable=unused-argument + """ + On double-right-click - include levels where C/No = 0. + + :param event: event + """ + + self.__app.configuration.set( + "unusedsat_b", not self.__app.configuration.get("unusedsat_b") + ) + self._redraw = True + def init_frame(self): """ Initialise graph view @@ -111,15 +127,13 @@ def init_frame(self): ylegend=("C/No dBHz",), ycol=(FGCOL,), ylabels=True, - xangle=35, + xlabelsfrm=XLBLFMT, + xangle=XLBLANGLE, fontscale=FONTSCALELG, tags=tags, ) self._redraw = False - if self.__app.configuration.get("legend_b"): - self._draw_legend() - def _draw_legend(self): """ Draw GNSS color code legend @@ -166,21 +180,15 @@ def update_frame(self): w, h = self.width, self.height self.init_frame() - offset = self._canvas.xoffl # AXIS_XL + 2 + offset = self._canvas.xoffl colwidth = (w - self._canvas.xoffl - self._canvas.xoffr + 1) / siv - # scale x axis label - fsiz = min(w * 15 / siv, w, h) - svfont = font.Font(size=int(fsiz / FONTSCALESV)) + xfnt, _, _ = fitfont(XLBLFMT, colwidth, self._canvas.yoffb, XLBLANGLE) for val in sorted(data.values()): # sort by ascending gnssid, svid gnssId, prn, _, _, cno, _ = val - if cno == 0: - if show_unused: - cno = 1 # show 'place marker' in graph - else: - continue + if cno == 0 and not show_unused: + continue snr_y = int(cno) * (h - self._canvas.yoffb - 1) / MAX_SNR (_, ol_col) = GNSS_LIST[gnssId] - prn = f"{int(prn):02}" self._canvas.create_rectangle( offset, h - self._canvas.yoffb - 1, @@ -194,16 +202,18 @@ def update_frame(self): self._canvas.create_text( offset + colwidth / 2, h - self._canvas.yoffb - 1, - text=prn, + text=f"{int(prn):02}", fill=FGCOL, - font=svfont, - angle=35, + font=xfnt, + angle=XLBLANGLE, anchor=NE, tags=TAG_DATA, ) offset += colwidth - self.update_idletasks() + if self.__app.configuration.get("legend_b"): + self._draw_legend() + self.update_idletasks() def _on_resize(self, event): # pylint: disable=unused-argument """ diff --git a/src/pygpsclient/map_frame.py b/src/pygpsclient/map_frame.py index ce871a60..9e67adaf 100644 --- a/src/pygpsclient/map_frame.py +++ b/src/pygpsclient/map_frame.py @@ -66,11 +66,12 @@ class MapviewFrame(Frame): Map frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -78,7 +79,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 self.width = kwargs.get("width", def_w) diff --git a/src/pygpsclient/menu_bar.py b/src/pygpsclient/menu_bar.py index f597fc81..ec37ac14 100644 --- a/src/pygpsclient/menu_bar.py +++ b/src/pygpsclient/menu_bar.py @@ -22,6 +22,7 @@ DLGTNMEA, DLGTNTRIP, DLGTRECORD, + DLGTSERVER, DLGTTTY, DLGTUBX, MENUABOUT, @@ -34,12 +35,12 @@ MENUSAVE, MENUVIEW, ) -from pygpsclient.widget_state import MENU DIALOGS = ( DLGTUBX, DLGTNMEA, DLGTNTRIP, + DLGTSERVER, DLGTSPARTN, # service discontinued by u-blox DLGTGPX, DLGTIMPORTMAP, @@ -86,15 +87,24 @@ def __init__(self, app, *args, **kwargs): # Create a pull-down menu for view operations # Menu labels are set in app._grid_widgets() function self.view_menu = Menu(self, tearoff=False) - for wdg, wdict in self.__app.widget_state.state.items(): - if wdict.get(MENU, True): - self.view_menu.add_command( - underline=1, command=lambda i=wdg: self.__app.widget_toggle(i) - ) + self.view_menu.add_command( + underline=1, + label="Undock Settings", + command=self.__app.settings_dock, + ) + self.view_menu.add_command( + underline=1, + label="Hide Settings", + command=self.__app.settings_toggle, + ) + for nam in self.__app.widget_state.state: + self.view_menu.add_command( + underline=1, command=lambda i=nam: self.__app.widget_toggle(i) + ) self.view_menu.add_command( underline=1, label=MENURESET, - command=lambda: self.__app.widget_reset(), # pylint: disable=unnecessary-lambda + command=lambda: self.__app.reset_layout(), # pylint: disable=unnecessary-lambda ) self.add_cascade(menu=self.view_menu, label=MENUVIEW) diff --git a/src/pygpsclient/nmea_config_dialog.py b/src/pygpsclient/nmea_config_dialog.py index bd9937d7..85bf79fd 100644 --- a/src/pygpsclient/nmea_config_dialog.py +++ b/src/pygpsclient/nmea_config_dialog.py @@ -34,8 +34,6 @@ from pygpsclient.strings import DLGTNMEA from pygpsclient.toplevel_dialog import ToplevelDialog -MINDIM = (541, 810) - class NMEAConfigDialog(ToplevelDialog): """ @@ -53,7 +51,7 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument self.__app = app # Reference to main application class - super().__init__(app, DLGTNMEA, MINDIM) + super().__init__(app, DLGTNMEA) self._cfg_msg_command = None self._pending_confs = {} diff --git a/src/pygpsclient/nmea_preset_frame.py b/src/pygpsclient/nmea_preset_frame.py index f6efeabd..aeb37436 100644 --- a/src/pygpsclient/nmea_preset_frame.py +++ b/src/pygpsclient/nmea_preset_frame.py @@ -57,12 +57,12 @@ class NMEA_PRESET_Frame(Frame): NMEA Preset and User-defined configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -70,9 +70,9 @@ def __init__(self, app, container, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) self.logger = logging.getLogger(__name__) - self.__container = container + self.__container = parent - Frame.__init__(self, self.__container.container, *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)) diff --git a/src/pygpsclient/ntrip_client_dialog.py b/src/pygpsclient/ntrip_client_dialog.py index 19ae9a0d..a6ef3a43 100644 --- a/src/pygpsclient/ntrip_client_dialog.py +++ b/src/pygpsclient/ntrip_client_dialog.py @@ -3,9 +3,12 @@ NTRIP client configuration dialog. -Initial settings are from the saved configuration. +Initial settings are from the saved json configuration. Once started, the persisted state for the NTRIP client is held in -the threaded NTRIP handler itself, NOT in this frame. +the client itself (pygnssutils.ntripclient), NOT in this dialog. + +The NTRIP client invokes the `set_controls` method in this dialog +to update any established connection parameters. The dialog may be closed while the NTRIP client is running. @@ -24,7 +27,6 @@ EW, HORIZONTAL, NORMAL, - NS, NSEW, VERTICAL, Button, @@ -33,7 +35,9 @@ IntVar, Label, Listbox, + N, Radiobutton, + S, Scrollbar, Spinbox, StringVar, @@ -41,6 +45,7 @@ W, ttk, ) +from types import NoneType from pygnssutils import NOGGA from pygnssutils.helpers import find_mp_distance @@ -49,7 +54,6 @@ CONNECTED_NTRIP, DISCONNECTED, ERRCOL, - GGA_INTERVALS, INFOCOL, READONLY, TRACEMODE_WRITE, @@ -68,6 +72,7 @@ from pygpsclient.socketconfig_ntrip_frame import SocketConfigNtripFrame from pygpsclient.strings import ( DLGTNTRIP, + LBLDATATYPE, LBLGGAFIXED, LBLGGALIVE, LBLNTRIPGGAINT, @@ -79,6 +84,7 @@ ) from pygpsclient.toplevel_dialog import ToplevelDialog +GGA_INTERVALS = ("None", "2", "5", "10", "60", "120") NTRIP_VERSIONS = ("2.0", "1.0") KM2MILES = 0.6213712 IP4 = "IPv4" @@ -88,7 +94,6 @@ NTRIP_SPARTN = "ppntrip.services.u-blox.com" TCPIPV4 = "IPv4" TCPIPV6 = "IPv6" -MINDIM = (500, 505) class NTRIPConfigDialog(ToplevelDialog): @@ -109,7 +114,7 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument self.logger = getLogger(__name__) self.__master = self.__app.appmaster # Reference to root class (Tk) - super().__init__(app, DLGTNTRIP, MINDIM) + super().__init__(app, DLGTNTRIP) self._cfg_msg_command = None self._pending_confs = { UBX_MONVER: (), @@ -124,7 +129,6 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument self._ntrip_https = IntVar() self._ntrip_version = StringVar() self._ntrip_mountpoint = StringVar() - self._ntrip_mpdist = StringVar() self._ntrip_user = StringVar() self._ntrip_password = StringVar() self._ntrip_gga_interval = StringVar() @@ -149,7 +153,7 @@ def _body(self): """ # pylint: disable=unnecessary-lambda - self._frm_body = Frame(self.container, borderwidth=2, relief="groove") + self._frm_body = Frame(self) self._frm_socket = SocketConfigNtripFrame( self.__app, self._frm_body, @@ -162,12 +166,11 @@ def _body(self): textvariable=self._ntrip_mountpoint, state=NORMAL, relief="sunken", - width=15, + width=12, ) self._lbl_mpdist = Label( self._frm_body, - textvariable=self._ntrip_mpdist, width=30, anchor=W, ) @@ -176,7 +179,7 @@ def _body(self): self._frm_body, height=4, relief="sunken", - width=55, + width=40, ) self._scr_sourcetablev = Scrollbar(self._frm_body, orient=VERTICAL) self._scr_sourcetableh = Scrollbar(self._frm_body, orient=HORIZONTAL) @@ -194,7 +197,7 @@ def _body(self): textvariable=self._ntrip_version, state=READONLY, ) - self._lbl_datatype = Label(self._frm_body, text="Data Type") + self._lbl_datatype = Label(self._frm_body, text=LBLDATATYPE) self._spn_datatype = Spinbox( self._frm_body, values=(RTCM, SPARTN), @@ -209,7 +212,7 @@ def _body(self): textvariable=self._ntrip_user, state=NORMAL, relief="sunken", - width=50, + width=40, ) self._lbl_password = Label(self._frm_body, text=LBLNTRIPPWD) self._ent_password = Entry( @@ -217,7 +220,7 @@ def _body(self): textvariable=self._ntrip_password, state=NORMAL, relief="sunken", - width=20, + width=40, show="*", ) self._lbl_ntripggaint = Label(self._frm_body, text=LBLNTRIPGGAINT) @@ -297,50 +300,50 @@ def _do_layout(self): # body of grid self._frm_socket.grid( - column=0, row=0, columnspan=3, rowspan=3, padx=3, pady=3, sticky=W + column=0, row=0, columnspan=5, rowspan=3, padx=3, pady=3, sticky=W ) ttk.Separator(self._frm_body).grid( column=0, row=3, columnspan=5, padx=3, pady=3, sticky=EW ) self._lbl_mountpoint.grid(column=0, row=4, padx=3, pady=3, sticky=W) self._ent_mountpoint.grid(column=1, row=4, padx=3, pady=3, sticky=W) - self._lbl_mpdist.grid(column=2, row=4, columnspan=2, padx=3, pady=3, sticky=W) + self._lbl_mpdist.grid(column=2, columnspan=2, row=4, padx=3, pady=3, sticky=W) self._lbl_sourcetable.grid(column=0, row=5, padx=3, pady=3, sticky=W) self._lbx_sourcetable.grid( column=1, row=5, columnspan=3, rowspan=4, padx=3, pady=3, sticky=EW ) - self._scr_sourcetablev.grid(column=4, row=5, rowspan=4, sticky=NS) + self._scr_sourcetablev.grid(column=4, row=5, rowspan=4, sticky=(N, S, W)) self._scr_sourcetableh.grid(column=1, columnspan=3, row=9, sticky=EW) self._lbl_ntripversion.grid(column=0, row=10, padx=3, pady=3, sticky=W) self._spn_ntripversion.grid(column=1, row=10, padx=3, pady=3, sticky=W) - self._lbl_datatype.grid(column=0, row=11, padx=3, pady=3, sticky=W) - self._spn_datatype.grid(column=1, row=11, padx=3, pady=3, sticky=W) - self._lbl_user.grid(column=0, row=12, padx=3, pady=3, sticky=W) - self._ent_user.grid(column=1, row=12, columnspan=3, padx=3, pady=3, sticky=W) - self._lbl_password.grid(column=0, row=13, padx=3, pady=3, sticky=W) + self._lbl_datatype.grid(column=2, row=10, padx=3, pady=3, sticky=W) + self._spn_datatype.grid(column=3, row=10, padx=3, pady=3, sticky=W) + self._lbl_user.grid(column=0, row=11, padx=3, pady=3, sticky=W) + self._ent_user.grid(column=1, row=11, columnspan=3, padx=3, pady=3, sticky=W) + self._lbl_password.grid(column=0, row=12, padx=3, pady=3, sticky=W) self._ent_password.grid( - column=1, row=13, columnspan=2, padx=3, pady=3, sticky=W + column=1, row=12, columnspan=3, padx=3, pady=3, sticky=W ) ttk.Separator(self._frm_body).grid( - column=0, row=14, columnspan=5, padx=3, pady=3, sticky=EW + column=0, row=13, columnspan=5, padx=3, pady=3, sticky=EW ) - self._lbl_ntripggaint.grid(column=0, row=15, padx=2, pady=3, sticky=W) - self._spn_ntripggaint.grid(column=1, row=15, padx=3, pady=2, sticky=W) - self._rad_ggalive.grid(column=0, row=16, padx=3, pady=2, sticky=W) - self._rad_ggafixed.grid(column=1, row=16, padx=3, pady=2, sticky=W) - self._lbl_lat.grid(column=0, row=17, padx=3, pady=2, sticky=W) - self._ent_lat.grid(column=1, row=17, columnspan=2, padx=3, pady=2, sticky=W) - self._lbl_lon.grid(column=2, row=17, padx=3, pady=2, sticky=W) - self._ent_lon.grid(column=3, row=17, columnspan=2, padx=3, pady=2, sticky=W) - self._lbl_alt.grid(column=0, row=18, padx=3, pady=2, sticky=W) - self._ent_alt.grid(column=1, row=18, columnspan=2, padx=3, pady=2, sticky=W) - self._lbl_sep.grid(column=2, row=18, padx=3, pady=2, sticky=W) - self._ent_sep.grid(column=3, row=18, columnspan=2, padx=3, pady=2, sticky=W) + self._rad_ggalive.grid(column=0, row=14, padx=3, pady=2, sticky=W) + self._rad_ggafixed.grid(column=1, row=14, padx=3, pady=2, sticky=W) + self._lbl_ntripggaint.grid(column=2, row=14, padx=2, pady=3, sticky=W) + self._spn_ntripggaint.grid(column=3, row=14, padx=3, pady=2, sticky=W) + self._lbl_lat.grid(column=0, row=15, padx=3, pady=2, sticky=W) + self._ent_lat.grid(column=1, row=15, padx=3, pady=2, sticky=W) + self._lbl_lon.grid(column=2, row=15, padx=3, pady=2, sticky=W) + self._ent_lon.grid(column=3, row=15, padx=3, pady=2, sticky=W) + self._lbl_alt.grid(column=0, row=16, padx=3, pady=2, sticky=W) + self._ent_alt.grid(column=1, row=16, padx=3, pady=2, sticky=W) + self._lbl_sep.grid(column=2, row=16, padx=3, pady=2, sticky=W) + self._ent_sep.grid(column=3, row=16, padx=3, pady=2, sticky=W) ttk.Separator(self._frm_body).grid( - column=0, row=19, columnspan=5, padx=3, pady=3, sticky=EW + column=0, row=17, columnspan=5, padx=3, pady=3, sticky=EW ) - self._btn_connect.grid(column=0, row=20, padx=3, pady=3, sticky=W) - self._btn_disconnect.grid(column=1, row=20, padx=3, pady=3, sticky=W) + self._btn_connect.grid(column=0, row=18, padx=3, pady=3, sticky=W) + self._btn_disconnect.grid(column=1, row=18, padx=3, pady=3, sticky=W) def _attach_events(self): """ @@ -354,7 +357,6 @@ def _attach_events(self): self._ntrip_https, self._ntrip_version, self._ntrip_mountpoint, - self._ntrip_mpdist, self._ntrip_user, self._ntrip_password, self._ntrip_gga_interval, @@ -365,7 +367,6 @@ def _attach_events(self): self._ntrip_gga_sep, ): setting.trace_add(TRACEMODE_WRITE, self._on_update_config) - # self.bind("", self._on_resize) def _reset(self): """ @@ -390,7 +391,7 @@ def _on_update_config(self, var, index, mode): # pylint: disable=unused-argumen cfg.set("ntripclientuser_s", self._ntrip_user.get()) cfg.set("ntripclientpassword_s", self._ntrip_password.get()) ggaint = self._ntrip_gga_interval.get() - ggaint = NOGGA if ggaint == "None" else int(ggaint) + ggaint = NOGGA if ggaint in ("None", "Not Required") else int(ggaint) cfg.set("ntripclientggainterval_n", ggaint) cfg.set("ntripclientggamode_b", self._ntrip_gga_mode.get()) cfg.set("ntripclientreflat_f", float(self._ntrip_gga_lat.get())) @@ -400,13 +401,15 @@ def _on_update_config(self, var, index, mode): # pylint: disable=unused-argumen except (ValueError, TclError): pass - def set_controls(self, connected: bool, msgt: tuple = None): + def set_controls(self, connected: bool, msgt: NoneType | tuple = None): """ Set App RTK connection status and enable or disable controls depending on connection status. + NB: Called by pygnssutils.ntripclient when NTRIP connection established. + :param bool status: connection status (True/False) - :param tuple msgt: tuple of (message, color) + :param NoneType | tuple msgt: tuple of (message, color) """ self.__app.rtk_conn_status = CONNECTED_NTRIP if connected else DISCONNECTED @@ -485,14 +488,12 @@ def _on_select_mp(self, event): w = event.widget index = int(w.curselection()[0]) srt = w.get(index) # sourcetable entry - name = srt[0] info = get_mp_info(srt) - # self.logger.debug(f"MP info: {name} {info}") - notes = ( - "" - if info is None - else f', {info["gga"]}, {info["encrypt"]}, {info["auth"]}' - ) + name = info["name"] + if info["gga"] in ("0", 0): # gga not required + self._ntrip_gga_interval.set(GGA_INTERVALS[0]) + else: # gga required + self._ntrip_gga_interval.set(GGA_INTERVALS[3]) self._ntrip_mountpoint.set(name) lat, lon = self._get_coordinates() if isinstance(lat, float) and isinstance(lon, float): @@ -500,9 +501,9 @@ def _on_select_mp(self, event): lat, lon, self._settings["sourcetable"], name ) if mpname is None: - self.set_mp_dist(None, name, notes) + self.set_mp_dist(None, name) else: - self.set_mp_dist(mindist, mpname, notes) + self.set_mp_dist(mindist, mpname) except (IndexError, KeyError): # not yet populated pass @@ -690,7 +691,7 @@ def _valid_settings(self) -> bool: return valid - def set_mp_dist(self, dist: float, name: str = "", info: str = ""): + def set_mp_dist(self, dist: float, name: str = ""): """ Set mountpoint distance label. @@ -699,27 +700,26 @@ def set_mp_dist(self, dist: float, name: str = "", info: str = ""): if name in (None, ""): return - dist_l = "Distance n/a" + dist_l = "Baseline n/a" dist_u = "km" if isinstance(dist, float): units = self.__app.configuration.get("units_s") if units in (UI, UIK): dist *= KM2MILES dist_u = "miles" - dist_l = f"Dist: {dist:,.1f} {dist_u}{info}" + dist_l = f"Baseline: {dist:,.1f} {dist_u}" self._ntrip_mountpoint.set(name) - self._ntrip_mpdist.set(dist_l) + self._lbl_mpdist.config(text=dist_l) - def _get_coordinates(self) -> tuple: + def _get_coordinates(self) -> tuple[float, float]: """ Get coordinates from receiver or fixed reference settings. :returns: tuple (lat,lon) - :rtype: tuple + :rtype: tuple[float,float] """ try: - # if self._settings.get("ggamode", 0) == 0: # live position if self._ntrip_gga_mode.get() == 0: # live position status = self.__app.get_coordinates() lat, lon = status["lat"], status["lon"] @@ -728,4 +728,4 @@ def _get_coordinates(self) -> tuple: lon = float(self._settings["reflon"]) return lat, lon except (ValueError, TypeError): - return "", "" + return 0, 0 diff --git a/src/pygpsclient/recorder_dialog.py b/src/pygpsclient/recorder_dialog.py index c3db5009..3d16eed2 100644 --- a/src/pygpsclient/recorder_dialog.py +++ b/src/pygpsclient/recorder_dialog.py @@ -18,6 +18,7 @@ # pylint: disable=unused-argument +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 @@ -45,6 +46,7 @@ FGCOL, HOME, ICON_DELETE, + ICON_IMPORT, ICON_LOAD, ICON_RECORD, ICON_SAVE, @@ -56,7 +58,7 @@ PNTCOL, UNDO, ) -from pygpsclient.helpers import set_filename +from pygpsclient.helpers import nmea2preset, set_filename, tty2preset, ubx2preset from pygpsclient.strings import DLGTRECORD, SAVETITLE from pygpsclient.toplevel_dialog import ToplevelDialog @@ -71,8 +73,6 @@ VALSET = b"\x8a" VALGET = b"\x8b" -MINDIM = (500, 300) - class RecorderDialog(ToplevelDialog): """ @@ -91,7 +91,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # self.__master = self.__app.appmaster # link to root Tk window - super().__init__(app, DLGTRECORD, MINDIM) + super().__init__(app, DLGTRECORD) self.width = int(kwargs.get("width", 500)) self.height = int(kwargs.get("height", 300)) @@ -100,6 +100,7 @@ def __init__(self, app, *args, **kwargs): self._img_play = ImageTk.PhotoImage(Image.open(ICON_SEND)) 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_undo = ImageTk.PhotoImage(Image.open(ICON_UNDO)) self._img_delete = ImageTk.PhotoImage(Image.open(ICON_DELETE)) self._rec_status = STOP @@ -138,6 +139,14 @@ def _body(self): highlightbackground=BGCOL, highlightthickness=2, ) + self._btn_import = Button( + self._frm_body, + image=self._img_import, + width=40, + command=self._on_import, + highlightbackground=BGCOL, + highlightthickness=2, + ) self._btn_play = Button( self._frm_body, image=self._img_play, @@ -191,11 +200,12 @@ def _do_layout(self): self._frm_body.grid(column=0, row=0, sticky=NSEW) self._btn_load.grid(column=0, row=0, ipadx=3, ipady=3, sticky=W) self._btn_save.grid(column=1, row=0, ipadx=3, ipady=3, sticky=W) - self._btn_play.grid(column=2, row=0, ipadx=3, ipady=3, sticky=W) - self._btn_record.grid(column=3, row=0, ipadx=3, ipady=3, sticky=W) - self._btn_undo.grid(column=4, row=0, ipadx=3, ipady=3, sticky=W) - self._btn_delete.grid(column=5, row=0, ipadx=3, ipady=3, sticky=W) - self._lbl_memory.grid(column=6, row=0, ipadx=3, ipady=3, sticky=W) + self._btn_import.grid(column=2, row=0, ipadx=3, ipady=3, sticky=W) + self._btn_play.grid(column=3, row=0, ipadx=3, ipady=3, sticky=W) + self._btn_record.grid(column=4, row=0, ipadx=3, ipady=3, sticky=W) + 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) (cols, rows) = self.grid_size() @@ -451,6 +461,49 @@ def _on_record(self): self.status_label = (f"Recording {stat}", INFOCOL) self._update_status() + def _on_import(self): + """ + Import commands as presets. + + NB: Assumes all commands in a single recording are of the + same type (i.e. UBX, NMEA or TTY). + """ + + if self._rec_status == RECORD: + return + + if len(self.__app.recorded_commands) == 0: + self.status_label = ("Nothing to import", ERRCOL) + return + + try: + now = f'Recorded commands {datetime.now().strftime("%Y-%m-%d_%H:%M:%S")}' + if isinstance(self.__app.recorded_commands[0], UBXMessage): + self.__app.configuration.get("ubxpresets_l").append( + ubx2preset(self.__app.recorded_commands, now) + ) + typ = "UBX" + elif isinstance(self.__app.recorded_commands[0], NMEAMessage): + self.__app.configuration.get("nmeapresets_l").append( + nmea2preset(self.__app.recorded_commands, now) + ) + typ = "NMEA" + else: # tty + self.__app.configuration.get("ttypresets_l").append( + tty2preset(self.__app.recorded_commands, now) + ) + typ = "TTY" + + self.status_label = ( + f"{len(self.__app.recorded_commands)} commands imported as {typ} presets", + OKCOL, + ) + except AttributeError: + self.status_label = ( + "Recorded commands must be of same type", + ERRCOL, + ) + def _on_undo(self): """ Remove last record from in-memory recording. diff --git a/src/pygpsclient/resources/iconmonstr-door-6-24.png b/src/pygpsclient/resources/iconmonstr-door-6-24.png index 6a21f572..97eb1026 100644 Binary files a/src/pygpsclient/resources/iconmonstr-door-6-24.png and b/src/pygpsclient/resources/iconmonstr-door-6-24.png differ diff --git a/src/pygpsclient/resources/iconmonstr-import-24.png b/src/pygpsclient/resources/iconmonstr-import-24.png new file mode 100644 index 00000000..641dec0a Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-import-24.png differ diff --git a/src/pygpsclient/resources/iconmonstr-media-control-50-24.png b/src/pygpsclient/resources/iconmonstr-media-control-50-24.png index 42217cbd..3a417a78 100644 Binary files a/src/pygpsclient/resources/iconmonstr-media-control-50-24.png and b/src/pygpsclient/resources/iconmonstr-media-control-50-24.png differ diff --git a/src/pygpsclient/resources/iconmonstr-poweroff-8-24.png b/src/pygpsclient/resources/iconmonstr-poweroff-8-24.png new file mode 100644 index 00000000..095be783 Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-poweroff-8-24.png differ diff --git a/src/pygpsclient/rover_frame.py b/src/pygpsclient/rover_frame.py index 51113cf7..ba501a2c 100644 --- a/src/pygpsclient/rover_frame.py +++ b/src/pygpsclient/rover_frame.py @@ -17,7 +17,7 @@ from tkinter import NSEW, NW, SW, Frame -from pygpsclient.canvas_plot import ( +from pygpsclient.canvas_subclasses import ( MODE_POL, TAG_DATA, TAG_GRID, @@ -45,18 +45,19 @@ class RoverFrame(Frame): Rover view frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: Optional args to pass to Frame parent class :param kwargs: Optional kwargs to pass to Frame parent class """ self.__app = app self.__master = self.__app.appmaster - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 self.width = kwargs.get("width", def_w) diff --git a/src/pygpsclient/rtcm3_handler.py b/src/pygpsclient/rtcm3_handler.py index e12624e9..a9369ead 100644 --- a/src/pygpsclient/rtcm3_handler.py +++ b/src/pygpsclient/rtcm3_handler.py @@ -84,5 +84,5 @@ def _process_1005(self, parsed: RTCMMessage): # update Survey-In base station location if self.__app.frm_settings.frm_socketserver is not None: self.__app.frm_settings.frm_socketserver.update_base_location() - except (AttributeError, ValueError): + except (AttributeError, TypeError, ValueError): pass diff --git a/src/pygpsclient/scatter_frame.py b/src/pygpsclient/scatter_frame.py index cd6b875e..f5162b5b 100644 --- a/src/pygpsclient/scatter_frame.py +++ b/src/pygpsclient/scatter_frame.py @@ -46,7 +46,7 @@ from random import randrange -from pygpsclient.canvas_plot import ( +from pygpsclient.canvas_subclasses import ( MODE_POL, TAG_DATA, TAG_GRID, @@ -90,18 +90,19 @@ class ScatterViewFrame(Frame): Scatterplot view frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: Optional args to pass to Frame parent class :param kwargs: Optional kwargs to pass to Frame parent class """ self.__app = app self.__master = self.__app.appmaster - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU1 diff --git a/src/pygpsclient/serialconfig_frame.py b/src/pygpsclient/serialconfig_frame.py index d1e9a89a..c46ad6e7 100644 --- a/src/pygpsclient/serialconfig_frame.py +++ b/src/pygpsclient/serialconfig_frame.py @@ -25,6 +25,7 @@ LEFT, NORMAL, NS, + NSEW, VERTICAL, Button, Checkbutton, @@ -38,6 +39,7 @@ Scrollbar, Spinbox, StringVar, + TclError, W, ) @@ -91,12 +93,12 @@ class SerialConfigFrame(Frame): Serial port configuration frame class. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param tkinter.Frame container: reference to container frame + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs for value ranges, or to pass to Frame parent class """ @@ -109,7 +111,7 @@ def __init__(self, app, container, *args, **kwargs): self._msgmode_name_rng = kwargs.pop("msgmodes", MSGMODE_RNG) self._recognised = kwargs.pop("recognised", ()) - Frame.__init__(self, container, *args, **kwargs) + super().__init__(parent, *args, **kwargs) self.__app = app self._show_advanced = False @@ -149,7 +151,7 @@ def _body(self): self._frm_basic, border=2, relief="sunken", - width=38, + width=30, height=5, justify=LEFT, exportselection=False, @@ -260,7 +262,7 @@ def _do_layout(self): Layout widgets. """ - self._frm_basic.grid(column=0, row=0, columnspan=4, sticky=EW) + self._frm_basic.grid(column=0, row=0, sticky=NSEW) self._lbl_port.grid(column=0, row=0, sticky=W) self._lbx_port.grid(column=1, row=0, columnspan=3, sticky=EW, padx=3, pady=2) self._scr_portv.grid(column=4, row=0, sticky=NS) @@ -346,6 +348,19 @@ def reset(self): self._on_refresh_ports() self._attach_events(True) + def _on_toggle_advanced(self): + """ + Toggle advanced serial port settings panel on or off. + """ + + self._show_advanced = not self._show_advanced + if self._show_advanced: + self._frm_advanced.grid(column=0, row=1, sticky=NSEW) + self._btn_toggle.config(image=self._img_contract) + else: + self._frm_advanced.grid_forget() + self._btn_toggle.config(image=self._img_expand) + def _get_ports(self): """ Populate list of available serial ports using pyserial comports tool. @@ -497,19 +512,6 @@ def _on_update_user_defined_port(self, var, index, mode): self.__app.configuration.set("userport_s", self.userport) - def _on_toggle_advanced(self): - """ - Toggle advanced serial port settings panel on or off. - """ - - self._show_advanced = not self._show_advanced - if self._show_advanced: - self._frm_advanced.grid(column=0, row=1, columnspan=3, sticky=EW) - self._btn_toggle.config(image=self._img_contract) - else: - self._frm_advanced.grid_forget() - self._btn_toggle.config(image=self._img_expand) - def set_status(self, status: int = DISCONNECTED): """ Set connection status, which determines whether controls @@ -715,4 +717,7 @@ def _on_resize(self, event): # pylint: disable=unused-argument :param event event: resize event """ - self.__app.frm_settings.on_expand() + try: + self.__app.frm_settings.on_expand() + except (AttributeError, TclError): + pass # frm_settings not yet instantiated diff --git a/src/pygpsclient/serialconfig_lband_frame.py b/src/pygpsclient/serialconfig_lband_frame.py index 7363af82..af07fd77 100644 --- a/src/pygpsclient/serialconfig_lband_frame.py +++ b/src/pygpsclient/serialconfig_lband_frame.py @@ -13,6 +13,8 @@ # pylint: disable=unused-argument +from tkinter import Frame + from pygpsclient.serialconfig_frame import MSGMODED, PARITIES, SerialConfigFrame @@ -21,18 +23,18 @@ class SerialConfigLbandFrame(SerialConfigFrame): L-BAND Serial port configuration frame class. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param tkinter.Frame container: reference to container frame + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs for value ranges, or to pass to Frame parent class """ self.__app = app - super().__init__(app, container, *args, **kwargs) + super().__init__(app, parent, *args, **kwargs) def reset(self): """ diff --git a/src/pygpsclient/serverconfig_frame.py b/src/pygpsclient/serverconfig_dialog.py similarity index 91% rename from src/pygpsclient/serverconfig_frame.py rename to src/pygpsclient/serverconfig_dialog.py index b7dec45b..8cf9b79d 100644 --- a/src/pygpsclient/serverconfig_frame.py +++ b/src/pygpsclient/serverconfig_dialog.py @@ -1,7 +1,7 @@ """ -serverconfig_frame.py +serverconfig_dialog.py -Socket Server / NTRIP caster configuration panel Frame class. +Socket Server / NTRIP caster configuration panel Dialog class. Supports two modes of operation - Socket Server and NTRIP Caster. If running in NTRIP Caster mode, two base station modes are available - @@ -20,11 +20,14 @@ # pylint: disable=unused-argument, too-many-lines import logging +from pathlib import Path from time import sleep from tkinter import ( DISABLED, EW, NORMAL, + NSEW, + BooleanVar, Button, Checkbutton, DoubleVar, @@ -41,7 +44,7 @@ from tkinter.ttk import Progressbar from PIL import Image, ImageTk -from pygnssutils import RTCMTYPES, check_pemfile +from pygnssutils import RTCMTYPES from pynmeagps import NMEAMessage, ecef2llh, llh2ecef from pyubx2 import SET_LAYER_RAM, TXN_NONE, UBXMessage @@ -59,8 +62,6 @@ MOSAIC_X5, READONLY, SERVERCONFIG, - SOCK_NTRIP, - SOCKMODES, TRACEMODE_WRITE, VALFLOAT, VALINT, @@ -91,6 +92,7 @@ ) from pygpsclient.strings import ( DLGNOTLS, + DLGTSERVER, LBLACCURACY, LBLCONFIGBASE, LBLDISNMEA, @@ -102,6 +104,7 @@ LBLSERVERPORT, LBLSOCKSERVE, ) +from pygpsclient.toplevel_dialog import ToplevelDialog ACCURACIES = ( 10.0, @@ -133,34 +136,34 @@ POS_LLH = "LLH" PQTMVER = "PQTMVER" POSMODES = (POS_LLH, POS_ECEF) +SOCK_NTRIP = "NTRIP CASTER" +SOCK_SERVER = "SOCKET SERVER" +SOCKMODES = (SOCK_SERVER, SOCK_NTRIP) -class ServerConfigFrame(Frame): +class ServerConfigDialog(ToplevelDialog): """ - Server configuration frame class. + Server configuration dialog class. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs for value ranges, or to pass to Frame parent class """ - Frame.__init__(self, container, *args, **kwargs) + self.__app = app self.logger = logging.getLogger(__name__) + super().__init__(app, DLGTSERVER) - self.__app = app - self._container = container self._show_advanced = False - self._socket_serve = IntVar() + self._socket_serve = BooleanVar() self.sock_port = StringVar() self.sock_host = StringVar() self.sock_mode = StringVar() - self._sock_clients = IntVar() self.receiver_type = StringVar() self.base_mode = StringVar() self.https = IntVar() @@ -184,21 +187,23 @@ def __init__(self, app, container, *args, **kwargs): self._body() self._do_layout() - self.reset() + self._reset() # self._attach_events() # done in reset self._attach_events1() + self._finalise() def _body(self): """ Set up widgets. """ - self._frm_basic = Frame(self) + self._frm_body = Frame(self.container) + self._frm_basic = Frame(self._frm_body) self._chk_socketserve = Checkbutton( self._frm_basic, text=LBLSOCKSERVE, variable=self._socket_serve, - state=DISABLED, + state=NORMAL, ) self._lbl_sockmode = Label( self._frm_basic, @@ -207,7 +212,7 @@ def _body(self): self._spn_sockmode = Spinbox( self._frm_basic, values=SOCKMODES, - width=14, + width=16, state=READONLY, wrap=True, textvariable=self.sock_mode, @@ -253,11 +258,6 @@ def _body(self): relief="sunken", width=6, ) - self._lbl_clients = Label(self._frm_basic, text="Clients") - self._lbl_sockclients = Label( - self._frm_basic, - textvariable=self._sock_clients, - ) self._btn_toggle = Button( self._frm_basic, command=self._on_toggle_advanced, @@ -266,7 +266,7 @@ def _body(self): height=22, # state=DISABLED, ) - self._frm_advanced = Frame(self) + self._frm_advanced = Frame(self._frm_body) self._lbl_user = Label( self._frm_advanced, text="User", @@ -401,6 +401,7 @@ def _do_layout(self): Layout widgets. """ + self._frm_body.grid(column=0, row=0, sticky=NSEW) self._frm_basic.grid(column=0, row=0, columnspan=5, sticky=EW) self._chk_socketserve.grid( column=0, row=0, columnspan=2, rowspan=2, padx=2, pady=1, sticky=W @@ -409,8 +410,6 @@ def _do_layout(self): self._spn_sockmode.grid(column=3, row=0, padx=2, pady=1, sticky=W) self._lbl_sockhost.grid(column=0, row=2, padx=2, pady=1, sticky=W) self._ent_sockhost.grid(column=1, row=2, padx=2, pady=1, sticky=W) - self._lbl_clients.grid(column=2, row=2, padx=2, pady=1, sticky=W) - self._lbl_sockclients.grid(column=3, row=2, padx=2, pady=1, sticky=W) self._lbl_sockport.grid(column=0, row=3, padx=2, pady=1, sticky=W) self._ent_sockport.grid(column=1, row=3, padx=2, pady=1, sticky=W) self._chk_https.grid(column=2, row=3, columnspan=2, padx=2, pady=1, sticky=W) @@ -426,14 +425,15 @@ def _do_layout(self): self._lbl_basemode.grid(column=0, row=1, padx=2, pady=1, sticky=E) self._spn_basemode.grid(column=1, row=1, padx=2, pady=1, sticky=W) - def reset(self): + def _reset(self): """ Reset settings to defaults. """ self._attach_events(False) cfg = self.__app.configuration - self._socket_serve.set(cfg.get("sockserver_b")) + self._socket_serve.set(self.__app.server_status >= 0) + self._set_controls() self.sock_mode.set(SOCKMODES[cfg.get("sockmode_b")]) self._on_toggle_advanced() self.base_mode.set(cfg.get("ntripcasterbasemode_s")) @@ -449,24 +449,23 @@ def reset(self): self.disable_nmea.set(cfg.get("ntripcasterdisablenmea_b")) self.sock_host.set(cfg.get("sockhost_s")) https = cfg.get("sockhttps_b") - pem, pemexists = check_pemfile() - if https and not pemexists: + pem = cfg.get("tlspempath_s") + if https and not Path(pem).exists(): err = DLGNOTLS.format(hostpem=pem) - self.__app.status_label = (err, ERRCOL) + self.status_label = (err, ERRCOL) self.logger.error(err) cfg.set("sockhttps_b", 0) self._chk_https.config(state=DISABLED) https = 0 self.https.set(https) - self._lbl_publicip.config(text=publicip()) - self._lbl_lanip.config(text=lanip()) + self.after(5, lambda: self._lbl_publicip.config(text=publicip())) + self.after(5, lambda: self._lbl_lanip.config(text=lanip())) if cfg.get("sockmode_b"): # NTRIP CASTER self.sock_port.set(cfg.get("sockportntrip_n")) else: # SOCKET SERVER self.sock_port.set(cfg.get("sockport_n")) self.user.set(cfg.get("ntripcasteruser_s")) self.password.set(cfg.get("ntripcasterpassword_s")) - self.clients = 0 self._fixed_lat_temp = self.fixedlat.get() self._fixed_lon_temp = self.fixedlon.get() self._fixed_hae_temp = self.fixedhae.get() @@ -558,7 +557,6 @@ def set_status(self, status: int): if status == DISCONNECTED: self._chk_socketserve.configure(state=DISABLED) self._socket_serve.set(0) - self.clients = 0 else: self._chk_socketserve.configure(state=NORMAL) @@ -568,25 +566,30 @@ def _on_socketserve(self, var, index, mode): """ if self.valid_settings(): - self.__app.status_label = ("", INFOCOL) + self.status_label = ("", INFOCOL) else: - self.__app.status_label = ("ERROR - invalid entry", ERRCOL) + self.status_label = ("ERROR - invalid entry", ERRCOL) return self._quectel_restart = 0 if self._socket_serve.get(): # start server self.__app.sockserver_start() - self.__app.stream_handler.sock_serve = True + # self.__app.stream_handler.sock_serve = True self._fixed_lat_temp = self.fixedlat.get() self._fixed_lon_temp = self.fixedlon.get() self._fixed_hae_temp = self.fixedhae.get() else: # stop server self.__app.sockserver_stop() - self.__app.stream_handler.sock_serve = False - self.clients = 0 + # self.__app.stream_handler.sock_serve = False + + self._set_controls() + + def _set_controls(self): + """ + Set visibility of various fields depending on server status. + """ - # set visibility of various fields depending on server status for wid in ( self._ent_sockhost, self._ent_sockport, @@ -631,7 +634,6 @@ def _on_socketserve(self, var, index, mode): state = NORMAL wid.config(state=state) self._lbl_elapsed.config(text="") - self.__app.configuration.set("sockserver_b", int(self._socket_serve.get())) def _on_configure_base(self, *args, **kwargs): # pylint: disable=unused-argument """ @@ -650,9 +652,9 @@ def _config_receiver(self): # validate settings if self.valid_settings(): - self.__app.status_label = ("", INFOCOL) + self.status_label = ("", INFOCOL) else: - self.__app.status_label = ("ERROR - invalid entry", ERRCOL) + self.status_label = ("ERROR - invalid entry", ERRCOL) return delay = self.__app.configuration.get("guiupdateinterval_f") / 2 @@ -860,10 +862,10 @@ def _on_update_https(self, var, index, mode): Action when https flag is updated. """ - pem, pemexists = check_pemfile() - if self.https.get() and not pemexists: + pem = self.__app.configuration.get("tlspempath_s") + if self.https.get() and not Path(pem).exists(): err = DLGNOTLS.format(hostpem=pem) - self.__app.status_label = (err, ERRCOL) + self.status_label = (err, ERRCOL) self.logger.error(err) self._attach_events(False) self.https.set(0) @@ -1000,26 +1002,6 @@ def _set_coords(self, posmode: str): self.fixedlon.set(lon) self.fixedhae.set(hae) - @property - def clients(self) -> int: - """ - Getter for number of socket clients. - """ - - return self._sock_clients.get() - - @clients.setter - def clients(self, clients: int): - """ - Setter for number of socket clients. - - :param int clients: no of clients connected - """ - - self._sock_clients.set(clients) - if self._socket_serve.get() in ("1", 1): - self.__app.frm_banner.update_transmit_status(clients) - def _config_msg_rates(self, rate: int, port_type: str): """ Configure RTCM3 and UBX NAV-SVIN message rates. @@ -1144,12 +1126,12 @@ def svin_countdown(self, ela: int, valid: bool, active: bool): self._pgb_elapsed.grid_forget() @property - def socketserving(self) -> int: + def socketserving(self) -> bool: """ Getter for socket serve flag. :return: server running True/False - :rtype: int + :rtype: bool """ return self._socket_serve.get() @@ -1162,7 +1144,8 @@ def socketserving(self, state: bool): :param bool state: server running True/False """ - return self._socket_serve.set(state) + self._socket_serve.set(state) + self.__app.configuration.set("sockserver_b", state) def _on_resize(self, event): # pylint: disable=unused-argument """ diff --git a/src/pygpsclient/settings_child_frame.py b/src/pygpsclient/settings_child_frame.py new file mode 100644 index 00000000..c8adfb9e --- /dev/null +++ b/src/pygpsclient/settings_child_frame.py @@ -0,0 +1,881 @@ +""" +settings_childframe.py + +Settings frame class for PyGPSClient application. + +- Reads and updates configuration held in self.__app.configuration. +- Starts or stops data logging. +- Sets initial (saved) configuration of the following frames: +- frm_settings (SettingsFrame class) for general application settings. +- frm_serial (SerialConfigFrame class) for serial port settings. +- frm_socketclient (SocketConfigFrame class) for socket client settings. +- frm_socketserver (ServerConfigFrame class) for socket server settings. + +Created on 12 Sep 2020 + +:author: semuadmin (Steve Smith) +:copyright: 2020 semuadmin +:license: BSD 3-Clause +""" + +# pylint: disable=unnecessary-lambda, unused-argument + +from tkinter import ( + DISABLED, + EW, + NORMAL, + Button, + Checkbutton, + E, + Frame, + IntVar, + Label, + Spinbox, + StringVar, + TclError, + W, + ttk, +) + +from PIL import Image, ImageTk + +from pygpsclient.globals import ( + BPSRATES, + CONNECTED, + CONNECTED_FILE, + CONNECTED_SOCKET, + DDD, + DISCONNECTED, + DMM, + DMS, + ECEF, + ERRCOL, + FORMATS, + GNSS_EOF_EVENT, + GNSS_ERR_EVENT, + GNSS_EVENT, + GNSS_TIMEOUT_EVENT, + HOME, + ICON_CONN, + ICON_DISCONN, + ICON_LOGREAD, + ICON_NMEACONFIG, + ICON_NTRIPCONFIG, + ICON_POWEROFF, + ICON_SERIAL, + ICON_SOCKET, + ICON_TRANSMIT, + ICON_TTYCONFIG, + ICON_UBXCONFIG, + INFOCOL, + KNOWNGPS, + MSGMODES, + NOPORTS, + OKCOL, + READONLY, + TIMEOUTS, + TRACEMODE_WRITE, + UI, + UIK, + UMK, + UMM, +) +from pygpsclient.serialconfig_frame import SerialConfigFrame +from pygpsclient.socketconfig_frame import SocketConfigFrame +from pygpsclient.sqlite_handler import SQLOK +from pygpsclient.strings import ( + DLGTNMEA, + DLGTNTRIP, + DLGTSERVER, + DLGTTTY, + DLGTUBX, + LBLAUTOSCROLL, + LBLDATABASERECORD, + LBLDATADISP, + LBLDATALOG, + LBLDEGFORMAT, + LBLFILEDELAY, + LBLNMEACONFIG, + LBLNTRIPCONFIG, + LBLPROTDISP, + LBLSERVERCONFIG, + LBLTRACKRECORD, + LBLTTYCONFIG, + LBLUBXCONFIG, +) + +MAXLINES = ("200", "500", "1000", "2000", "100") +FILEDELAYS = (2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000) + + +class SettingsChildFrame(Frame): + """ + Settings child frame class. + """ + + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): + """ + Constructor. + + :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame + :param args: optional args to pass to Frame parent class + :param kwargs: optional kwargs to pass to Frame parent class + """ + + self.__app = app # Reference to main application class + self.__master = self.__app.appmaster # Reference to root class (Tk) + self.__container = parent + + super().__init__(parent, *args, **kwargs) + + self.infilepath = None + self.logpath = HOME + self.trackpath = HOME + self.databasepath = HOME + self._prot_nmea = IntVar() + self._prot_ubx = IntVar() + self._prot_sbf = IntVar() + self._prot_qgc = IntVar() + self._prot_rtcm = IntVar() + self._prot_spartn = IntVar() + self._prot_tty = IntVar() + self._autoscroll = IntVar() + self._maxlines = IntVar() + self._filedelay = IntVar() + self._units = StringVar() + self._degrees_format = StringVar() + self._console_format = StringVar() + self._datalog = IntVar() + self._logformat = StringVar() + self._record_track = IntVar() + self._record_database = IntVar() + self._colortag = IntVar() + self.defaultports = self.__app.configuration.get("defaultport_s") + self._validsettings = True + self._img_conn = ImageTk.PhotoImage(Image.open(ICON_CONN)) + self._img_serial = ImageTk.PhotoImage(Image.open(ICON_SERIAL)) + self._img_socket = ImageTk.PhotoImage(Image.open(ICON_SOCKET)) + self._img_disconn = ImageTk.PhotoImage(Image.open(ICON_DISCONN)) + self._img_exit = ImageTk.PhotoImage(Image.open(ICON_POWEROFF)) + self._img_ubxconfig = ImageTk.PhotoImage(Image.open(ICON_UBXCONFIG)) + self._img_nmeaconfig = ImageTk.PhotoImage(Image.open(ICON_NMEACONFIG)) + self._img_ttyconfig = ImageTk.PhotoImage(Image.open(ICON_TTYCONFIG)) + self._img_ntripconfig = ImageTk.PhotoImage(Image.open(ICON_NTRIPCONFIG)) + self._img_serverconfig = ImageTk.PhotoImage(Image.open(ICON_TRANSMIT)) + self._img_dataread = ImageTk.PhotoImage(Image.open(ICON_LOGREAD)) + + self._body() + self._do_layout() + self.reset() + # self._attach_events() # done in reset + + def _body(self): + """ + Set up frame and widgets. + """ + + # serial port configuration panel + self.frm_serial = SerialConfigFrame( + self.__app, + self.__container, + recognised=KNOWNGPS, + timeouts=TIMEOUTS, + bpsrates=BPSRATES, + msgmodes=list(MSGMODES.keys()), + ) + + # socket client configuration panel + self.frm_socketclient = SocketConfigFrame(self.__app, self.__container) + + # connection buttons + self._frm_buttons = Frame(self.__container) + self._btn_connect = Button( + self._frm_buttons, + width=45, + height=35, + image=self._img_serial, + command=lambda: self._on_connect(CONNECTED), + state=NORMAL, + ) + self._lbl_connect = Label(self._frm_buttons, text="USB/UART") + self._btn_connect_socket = Button( + self._frm_buttons, + width=45, + height=35, + image=self._img_socket, + command=lambda: self._on_connect(CONNECTED_SOCKET), + state=NORMAL, + ) + self._lbl_connect_socket = Label(self._frm_buttons, text="TCP/UDP") + self._btn_connect_file = Button( + self._frm_buttons, + width=45, + height=35, + image=self._img_dataread, + command=lambda: self._on_connect(CONNECTED_FILE), + state=NORMAL, + ) + self._lbl_connect_file = Label(self._frm_buttons, text="FILE") + self._btn_disconnect = Button( + self._frm_buttons, + width=45, + height=35, + image=self._img_disconn, + command=lambda: self._on_connect(DISCONNECTED), + state=DISABLED, + ) + self._lbl_disconnect = Label(self._frm_buttons, text="STOP") + self._btn_exit = Button( + self._frm_buttons, + width=45, + height=35, + image=self._img_exit, + command=lambda: self.__app.on_exit(), + state=NORMAL, + ) + + self._lbl_status_preset = Label( + self._frm_buttons, font=self.__app.font_md2, text="" + ) + + # Other configuration options + self._frm_options = Frame(self.__container) + self._frm_options_btns = Frame(self._frm_options) + self._lbl_protocol = Label(self._frm_options, text=LBLPROTDISP) + self._chk_nmea = Checkbutton( + self._frm_options, + text="NMEA", + variable=self._prot_nmea, + ) + self._chk_ubx = Checkbutton( + self._frm_options, + text="UBX", + variable=self._prot_ubx, + ) + self._chk_rtcm = Checkbutton( + self._frm_options, + text="RTCM", + variable=self._prot_rtcm, + ) + self._chk_spartn = Checkbutton( + self._frm_options, + text="SPARTN", + variable=self._prot_spartn, + ) + self._chk_sbf = Checkbutton( + self._frm_options, + text="SBF", + variable=self._prot_sbf, + ) + self._chk_qgc = Checkbutton( + self._frm_options, + text="QGC", + variable=self._prot_qgc, + ) + self._chk_tty = Checkbutton( + self._frm_options, + text="TTY", + variable=self._prot_tty, + ) + self._lbl_consoledisplay = Label(self._frm_options, text=LBLDATADISP) + self._spn_conformat = Spinbox( + self._frm_options, + values=FORMATS, + width=10, + state=READONLY, + wrap=True, + textvariable=self._console_format, + ) + self._chk_tags = Checkbutton( + self._frm_options, + text="Tags", + variable=self._colortag, + ) + self._lbl_format = Label(self._frm_options, text=LBLDEGFORMAT) + self._spn_format = Spinbox( + self._frm_options, + values=(DDD, DMS, DMM, ECEF), + width=6, + state=READONLY, + wrap=True, + textvariable=self._degrees_format, + ) + self._spn_units = Spinbox( + self._frm_options, + values=(UMM, UIK, UI, UMK), + width=13, + state=READONLY, + wrap=True, + textvariable=self._units, + ) + self._chk_scroll = Checkbutton( + self._frm_options, text=LBLAUTOSCROLL, variable=self._autoscroll + ) + self._spn_maxlines = Spinbox( + self._frm_options, + values=MAXLINES, + width=6, + wrap=True, + textvariable=self._maxlines, + state=READONLY, + ) + self._lbl_filedelay = Label( + self._frm_options, + text=LBLFILEDELAY, + ) + self._spn_filedelay = Spinbox( + self._frm_options, + value=FILEDELAYS, + width=4, + wrap=True, + textvariable=self._filedelay, + state=READONLY, + repeatdelay=1000, + repeatinterval=1000, + ) + self._chk_datalog = Checkbutton( + self._frm_options, + text=LBLDATALOG, + variable=self._datalog, + ) + self._spn_datalog = Spinbox( + self._frm_options, + values=(FORMATS), + width=20, + wrap=True, + textvariable=self._logformat, + state=READONLY, + ) + self._chk_recordtrack = Checkbutton( + self._frm_options, + text=LBLTRACKRECORD, + variable=self._record_track, + ) + self._chk_recorddatabase = Checkbutton( + self._frm_options, + text=LBLDATABASERECORD, + variable=self._record_database, + ) + # configuration panel buttons + self._lbl_ubxconfig = Label( + self._frm_options_btns, + text=LBLUBXCONFIG, + ) + self._btn_ubxconfig = Button( + self._frm_options_btns, + width=45, + image=self._img_ubxconfig, + command=lambda: self.__app.start_dialog(DLGTUBX), + ) + self._lbl_nmeaconfig = Label( + self._frm_options_btns, + text=LBLNMEACONFIG, + ) + self._btn_nmeaconfig = Button( + self._frm_options_btns, + width=45, + image=self._img_nmeaconfig, + command=lambda: self.__app.start_dialog(DLGTNMEA), + state=NORMAL, + ) + self._lbl_ttyconfig = Label( + self._frm_options_btns, + text=LBLTTYCONFIG, + ) + self._btn_ttyconfig = Button( + self._frm_options_btns, + width=45, + image=self._img_ttyconfig, + command=lambda: self.__app.start_dialog(DLGTTTY), + state=NORMAL, + ) + self._lbl_ntripconfig = Label( + self._frm_options_btns, + text=LBLNTRIPCONFIG, + ) + self._btn_ntripconfig = Button( + self._frm_options_btns, + width=45, + image=self._img_ntripconfig, + command=lambda: self.__app.start_dialog(DLGTNTRIP), + state=NORMAL, + ) + self._lbl_serverconfig = Label( + self._frm_options_btns, + text=LBLSERVERCONFIG, + ) + self._btn_serverconfig = Button( + self._frm_options_btns, + width=45, + image=self._img_serverconfig, + command=lambda: self.__app.start_dialog(DLGTSERVER), + state=NORMAL, + ) + + def _do_layout(self): + """ + Position widgets in frame. + """ + + self.frm_serial.grid(column=0, row=1, columnspan=4, padx=2, pady=2, sticky=EW) + ttk.Separator(self.__container).grid( + column=0, row=2, columnspan=4, padx=2, pady=2, sticky=EW + ) + + self.frm_socketclient.grid( + column=0, row=3, columnspan=4, padx=2, pady=2, sticky=EW + ) + ttk.Separator(self.__container).grid( + column=0, row=4, columnspan=4, padx=2, pady=2, sticky=EW + ) + + self._frm_buttons.grid(column=0, row=5, columnspan=4, sticky=EW) + self._btn_connect.grid(column=0, row=0, padx=2, pady=1) + self._btn_connect_socket.grid(column=1, row=0, padx=2, pady=1) + self._btn_connect_file.grid(column=2, row=0, padx=2, pady=1) + self._btn_disconnect.grid(column=3, row=0, padx=2, pady=1) + self._btn_exit.grid(column=4, row=0, padx=2, pady=1) + self._lbl_connect.grid(column=0, row=1, padx=1, pady=1, sticky=EW) + self._lbl_connect_socket.grid(column=1, row=1, padx=1, pady=1, sticky=EW) + self._lbl_connect_file.grid(column=2, row=1, padx=1, pady=1, sticky=EW) + self._lbl_disconnect.grid(column=3, row=1, padx=1, pady=1, sticky=EW) + + ttk.Separator(self.__container).grid( + column=0, row=7, columnspan=4, padx=2, pady=2, sticky=EW + ) + + self._frm_options.grid(column=0, row=8, columnspan=4, sticky=EW) + self._lbl_protocol.grid(column=0, row=0, padx=2, pady=2, sticky=W) + self._chk_nmea.grid(column=1, row=0, padx=0, pady=0, sticky=W) + self._chk_ubx.grid(column=2, row=0, padx=0, pady=0, sticky=W) + self._chk_rtcm.grid(column=3, row=0, padx=0, pady=0, sticky=W) + self._chk_sbf.grid(column=1, row=1, padx=0, pady=0, sticky=W) + self._chk_qgc.grid(column=2, row=1, padx=0, pady=0, sticky=W) + self._chk_spartn.grid(column=3, row=1, padx=0, pady=0, sticky=W) + self._chk_tty.grid(column=1, row=2, padx=0, pady=0, sticky=W) + self._lbl_consoledisplay.grid(column=0, row=3, padx=2, pady=2, sticky=W) + self._spn_conformat.grid( + column=1, row=3, columnspan=2, padx=1, pady=2, sticky=W + ) + self._chk_tags.grid(column=3, row=3, padx=1, pady=2, sticky=W) + self._lbl_format.grid(column=0, row=4, padx=2, pady=2, sticky=W) + self._spn_format.grid(column=1, row=4, padx=2, pady=2, sticky=W) + self._spn_units.grid(column=2, row=4, columnspan=2, padx=2, pady=2, sticky=W) + self._chk_scroll.grid(column=0, row=6, padx=2, pady=2, sticky=W) + self._spn_maxlines.grid(column=1, row=6, padx=2, pady=2, sticky=W) + self._lbl_filedelay.grid(column=2, row=6, padx=2, pady=2, sticky=E) + self._spn_filedelay.grid(column=3, row=6, padx=2, pady=2, sticky=W) + self._chk_datalog.grid(column=0, row=8, padx=2, pady=2, sticky=W) + self._spn_datalog.grid(column=1, row=8, columnspan=3, padx=2, pady=2, sticky=W) + self._chk_recordtrack.grid( + column=0, row=9, columnspan=2, padx=2, pady=2, sticky=W + ) + self._chk_recorddatabase.grid( + column=2, row=9, columnspan=2, padx=2, pady=2, sticky=W + ) + self._frm_options_btns.grid(column=0, row=10, columnspan=4, sticky=EW) + self._btn_ubxconfig.grid(column=0, row=0, padx=2, pady=1) + self._lbl_ubxconfig.grid(column=0, row=1) + self._btn_nmeaconfig.grid(column=1, row=0, padx=2, pady=1) + self._lbl_nmeaconfig.grid(column=1, row=1) + self._btn_ttyconfig.grid(column=2, row=0, padx=2, pady=1) + self._lbl_ttyconfig.grid(column=2, row=1) + self._btn_ntripconfig.grid(column=3, row=0, padx=2, pady=1) + self._lbl_ntripconfig.grid(column=3, row=1) + self._btn_serverconfig.grid(column=4, row=0, padx=2, pady=1) + self._lbl_serverconfig.grid(column=4, row=1) + + def _attach_events(self, add: bool = True): + """ + Bind events to widgets. + + (trace_update() is a class extension method defined in globals.py) + + :param bool add: add or remove trace + """ + + # pylint: disable=no-member + + tracemode = TRACEMODE_WRITE + self._prot_ubx.trace_update(tracemode, self._on_update_ubxprot, add) + self._prot_sbf.trace_update(tracemode, self._on_update_sbfprot, add) + self._prot_qgc.trace_update(tracemode, self._on_update_qgcprot, add) + self._prot_nmea.trace_update(tracemode, self._on_update_nmeaprot, add) + self._prot_rtcm.trace_update(tracemode, self._on_update_rtcmprot, add) + self._prot_spartn.trace_update(tracemode, self._on_update_spartnprot, add) + self._prot_tty.trace_update(tracemode, self._on_update_ttyprot, add) + self._autoscroll.trace_update(tracemode, self._on_update_autoscroll, add) + self._maxlines.trace_update(tracemode, self._on_update_maxlines, add) + self._filedelay.trace_update(tracemode, self._on_update_filedelay, add) + self._units.trace_update(tracemode, self._on_update_units, add) + self._degrees_format.trace_update(tracemode, self._on_update_degreesformat, add) + self._console_format.trace_update(tracemode, self._on_update_consoleformat, add) + self._colortag.trace_update(tracemode, self._on_update_colortag, add) + self._logformat.trace_update(tracemode, self._on_update_logformat, add) + self._datalog.trace_update(tracemode, self._on_data_log, add) + self._record_track.trace_update(tracemode, self._on_record_track, add) + self._record_database.trace_update(tracemode, self._on_record_database, add) + + def reset(self): + """ + Reset settings to saved configuration. + """ + + self._attach_events(False) + cfg = self.__app.configuration + self._prot_nmea.set(cfg.get("nmeaprot_b")) + self._prot_ubx.set(cfg.get("ubxprot_b")) + self._prot_sbf.set(cfg.get("sbfprot_b")) + self._prot_qgc.set(cfg.get("qgcprot_b")) + self._prot_rtcm.set(cfg.get("rtcmprot_b")) + self._prot_spartn.set(cfg.get("spartnprot_b")) + self._prot_tty.set(cfg.get("ttyprot_b")) + self._degrees_format.set(cfg.get("degreesformat_s")) + self._colortag.set(cfg.get("colortag_b")) + self._units.set(cfg.get("units_s")) + self._autoscroll.set(cfg.get("autoscroll_b")) + self._maxlines.set(cfg.get("maxlines_n")) + self._filedelay.set(cfg.get("filedelay_n")) + self._console_format.set(cfg.get("consoleformat_s")) + self._logformat.set(cfg.get("logformat_s")) + self._datalog.set(cfg.get("datalog_b")) + self.logpath = cfg.get("logpath_s") + self._record_track.set(cfg.get("recordtrack_b")) + self.trackpath = cfg.get("trackpath_s") + self.databasepath = cfg.get("databasepath_s") + if self.__app.db_enabled == SQLOK: + self._record_database.set(cfg.get("database_b")) + else: + self._record_database.set(0) + self._chk_recorddatabase.config(state=DISABLED) + + self.clients = self.__app.server_status + self.enable_controls(self.__app.conn_status) + self._attach_events(True) + + def _on_update_ubxprot(self, var, index, mode): + """ + Action on updating ubxprot. + """ + + if not self._prot_tty.get(): + self.__app.configuration.set("ubxprot_b", self._prot_ubx.get()) + + def _on_update_sbfprot(self, var, index, mode): + """ + Action on updating sbfprot. + """ + + if not self._prot_tty.get(): + self.__app.configuration.set("sbfprot_b", self._prot_sbf.get()) + + def _on_update_qgcprot(self, var, index, mode): + """ + Action on updating qgcprot. + """ + + if not self._prot_tty.get(): + self.__app.configuration.set("qgcprot_b", self._prot_qgc.get()) + + def _on_update_nmeaprot(self, var, index, mode): + """ + Action on updating nmeaprot. + """ + + if not self._prot_tty.get(): + self.__app.configuration.set("nmeaprot_b", self._prot_nmea.get()) + + def _on_update_rtcmprot(self, var, index, mode): + """ + Action on updating rtcmprot. + """ + + if not self._prot_tty.get(): + self.__app.configuration.set("rtcmprot_b", self._prot_rtcm.get()) + + def _on_update_spartnprot(self, var, index, mode): + """ + Action on updating spartnprot. + """ + + if not self._prot_tty.get(): + self.__app.configuration.set("spartnprot_b", self._prot_spartn.get()) + + def _on_update_ttyprot(self, var, index, mode): + """ + TTY mode has been updated. + """ + + try: + cfg = self.__app.configuration + tty = self._prot_tty.get() + self.update() + if tty: + + for wdg in ( + self._prot_nmea, + self._prot_ubx, + self._prot_sbf, + self._prot_qgc, + self._prot_rtcm, + self._prot_spartn, + ): + wdg.set(0) + else: + self._prot_nmea.set(cfg.get("nmeaprot_b")) + self._prot_ubx.set(cfg.get("ubxprot_b")) + self._prot_sbf.set(cfg.get("sbfprot_b")) + self._prot_qgc.set(cfg.get("qgcprot_b")) + self._prot_rtcm.set(cfg.get("rtcmprot_b")) + self._prot_spartn.set(cfg.get("spartnprot_b")) + cfg.set("ttyprot_b", tty) + except (ValueError, TclError): + pass + + def _on_update_consoleformat(self, var, index, mode): + """ + Action on updating console format. + """ + + self.__app.configuration.set("consoleformat_s", self._console_format.get()) + + def _on_update_maxlines(self, var, index, mode): + """ + Action on updating console maxlines. + """ + + self.__app.configuration.set("maxlines_n", self._maxlines.get()) + + def _on_update_filedelay(self, var, index, mode): + """ + Action on updating filedelay. + """ + + self.__app.configuration.set("filedelay_n", self._filedelay.get()) + + def _on_update_degreesformat(self, var, index, mode): + """ + Action on updating degrees format. + """ + + self.__app.configuration.set("degreesformat_s", self._degrees_format.get()) + + def _on_update_units(self, var, index, mode): + """ + Action on updating units. + """ + + self.__app.configuration.set("units_s", self._units.get()) + + def _on_update_colortag(self, var, index, mode): + """ + Action on updating color tagging. + """ + + self.__app.configuration.set("colortag_b", self._colortag.get()) + + def _on_update_autoscroll(self, var, index, mode): + """ + Action on updating autoscroll. + """ + + self.__app.configuration.set("autoscroll_b", self._autoscroll.get()) + + def _on_update_logformat(self, var, index, mode): + """ + Action on updating log format. + """ + + self.__app.configuration.set("logformat_s", self._logformat.get()) + + def _on_data_log(self, var, index, mode): + """ + Start or stop data logger. + """ + + if self._datalog.get() == 1: + if self.logpath in ("", None): + self.logpath = self.__app.file_handler.set_logfile_path() + if self.logpath is not None: + self.__app.configuration.set("datalog_b", 1) + self.__app.configuration.set("logpath_s", self.logpath) + self.__app.status_label = ( + f"Data logging enabled: {self.logpath}", + INFOCOL, + ) + if not self.__app.file_handler.open_logfile(): + self.logpath = "" + self._datalog.set(0) + else: + self.logpath = "" + self._datalog.set(0) + self._spn_datalog.config(state=DISABLED) + else: + self.__app.configuration.set("datalog_b", 0) + self._datalog.set(0) + self.__app.file_handler.close_logfile() + self.__app.status_label = ("Data logging disabled", INFOCOL) + self._spn_datalog.config(state=READONLY) + + def _on_record_track(self, var, index, mode): + """ + Start or stop track recorder. + """ + + if self._record_track.get() == 1: + if self.trackpath in ("", None): + self.trackpath = self.__app.file_handler.set_trackfile_path() + if self.trackpath is not None: + self.__app.configuration.set("recordtrack_b", 1) + self.__app.configuration.set("trackpath_s", self.trackpath) + self.__app.status_label = f"Track recording enabled: {self.trackpath}" + if not self.__app.file_handler.open_trackfile(): + self.trackpath = "" + self._record_track.set(0) + else: + self.trackpath = "" + self._record_track.set(0) + else: + self._record_track.set(0) + self.__app.configuration.set("recordtrack_b", 0) + self.__app.file_handler.close_trackfile() + self.__app.status_label = "Track recording disabled" + + def _on_record_database(self, var, index, mode): + """ + Start or stop database recorder. + """ + + if self._record_database.get() == 1: + if self.databasepath in ("", None): + self.databasepath = self.__app.file_handler.set_database_path() + if self.databasepath is not None: + rc = self.__app.sqlite_handler.open(dbpath=self.databasepath) + self.__app.configuration.set("database_b", rc == SQLOK) + self.__app.configuration.set("databasepath_s", self.databasepath) + else: + self.databasepath = "" + self._record_database.set(0) + else: + self.__app.configuration.set("database_b", 0) + self.__app.status_label = "Database recording disabled" + + def _on_connect(self, conntype: int): + """ + Start or stop connection (serial, socket or file). + + :param int conntype: connection type + """ + + connstr = "" + conndict = { + "protocol": self.__app.protocol_mask, + "read_event": GNSS_EVENT, + "eof_event": GNSS_EOF_EVENT, + "timeout_event": GNSS_TIMEOUT_EVENT, + "error_event": GNSS_ERR_EVENT, + "inqueue": self.__app.gnss_inqueue, + "outqueue": self.__app.gnss_outqueue, + "socket_inqueue": self.__app.socket_inqueue, + "conntype": conntype, + "msgmode": self.frm_serial.msgmode, + "inactivity_timeout": self.frm_serial.inactivity_timeout, + "tlscrtpath": self.__app.configuration.get("tlscrtpath_s"), + } + + # self.frm_socketserver.status_label = conntype + if conntype == CONNECTED: + frm = self.frm_serial + if frm.status == NOPORTS: + return + connstr = f"{frm.port}:{frm.port_desc} @ {frm.bpsrate}" + conndict = dict(conndict, **{"serial_settings": frm}) + # poll for device software version on connection + self.__app.poll_version(conndict["protocol"]) + elif conntype == CONNECTED_SOCKET: + frm = self.frm_socketclient + if not frm.valid_settings(): + self.__app.status_label = ("ERROR - invalid settings", ERRCOL) + return + connstr = f"{frm.server.get()}:{frm.port.get()}" + conndict = dict(conndict, **{"socket_settings": frm}) + # poll for device software version on connection + self.__app.poll_version(conndict["protocol"]) + elif conntype == CONNECTED_FILE: + self.infilepath = self.__app.file_handler.open_file( + self, + "datalog", + ( + ("datalog files", "*.log"), + ("u-center logs", "*.ubx"), + ("all files", "*.*"), + ), + ) + if self.infilepath is None: + return + connstr = f"{self.infilepath}" + conndict = dict(conndict, **{"in_filepath": self.infilepath}) + elif conntype == DISCONNECTED: + if self.__app.conn_status != DISCONNECTED: + self.__app.conn_status = DISCONNECTED + self.__app.stream_handler.stop() + return + else: + return + + self.__app.conn_status = conntype + self.__app.conn_label = (connstr, OKCOL) + self.__app.status_label = ("", INFOCOL) + self.__app.reset_frames() + self.__app.reset_gnssstatus() + self.__app.stream_handler.start(self.__app, conndict) + + def enable_controls(self, status: int): + """ + Public method to enable or disable controls depending on + connection status. + + :param int status: connection status as integer + (0=Disconnected, 1=Connected to serial, + 2=Connected to file, 3=No serial ports available) + + """ + + self.frm_serial.status_label = status + self.frm_socketclient.status_label = status + + self._btn_connect.config( + state=( + DISABLED + if status in (CONNECTED, CONNECTED_SOCKET, CONNECTED_FILE, NOPORTS) + else NORMAL + ) + ) + for ctl in ( + self._btn_connect_socket, + self._btn_connect_file, + self._chk_tty, + ): + ctl.config( + state=( + DISABLED + if status in (CONNECTED, CONNECTED_SOCKET, CONNECTED_FILE) + else NORMAL + ) + ) + self._btn_disconnect.config( + state=(DISABLED if status in (DISCONNECTED,) else NORMAL) + ) + + def get_size(self) -> tuple: + """ + Get current frame size. + + :return: (width, height) + :rtype: tuple + + """ + + self.update_idletasks() # Make sure we know about any resizing + return (self.winfo_width(), self.winfo_height()) diff --git a/src/pygpsclient/settings_dialog.py b/src/pygpsclient/settings_dialog.py new file mode 100644 index 00000000..dd38ca57 --- /dev/null +++ b/src/pygpsclient/settings_dialog.py @@ -0,0 +1,75 @@ +""" +settings_dialog.py + +Settings Toplevel dialog container class for PyGPSClient settings_child_frame. + +Used when Settings are "undocked". + +Created on 14 Jan 2026 + +:author: semuadmin (Steve Smith) +:copyright: 2020 semuadmin +:license: BSD 3-Clause +""" + +from tkinter import NSEW + +from pygpsclient.settings_child_frame import SettingsChildFrame +from pygpsclient.strings import DLGTSETTINGS +from pygpsclient.toplevel_dialog import ToplevelDialog + + +class SettingsDialog(ToplevelDialog): + """ + Settings frame class. + """ + + def __init__(self, app, *args, **kwargs): + """ + Constructor. + + :param Frame app: reference to main tkinter application + :param args: optional args to pass to Frame parent class + :param kwargs: optional kwargs to pass to Frame parent class + """ + + self.__app = app # Reference to main application class + self.__master = self.__app.appmaster # Reference to root class (Tk) + + super().__init__(app, DLGTSETTINGS) + + self._body() + self._do_layout() + self._finalise() + self.focus_force() + + def on_expand(self): + """ + Automatically expand container canvas when sub-frames are resized. + """ + + self._can_container.event_generate("") + + def _body(self): + """ + Set up frame and widgets. + """ + + self.frm_settings = SettingsChildFrame(self.__app, self.container) + self.frm_serial = self.frm_settings.frm_serial + self.frm_socketclient = self.frm_settings.frm_socketclient + + def _do_layout(self): + """ + Position widgets in frame. + """ + + self.frm_settings.grid(column=0, row=0, sticky=NSEW) + + def on_exit(self, *args, **kwargs): # pylint: disable=unused-argument + """ + Overridden method - closing this dialog 'docks' the Settings panel + back onto the main application window. + """ + + self.__app.settings_dock() diff --git a/src/pygpsclient/settings_frame.py b/src/pygpsclient/settings_frame.py index 1423fa26..e022620f 100644 --- a/src/pygpsclient/settings_frame.py +++ b/src/pygpsclient/settings_frame.py @@ -1,15 +1,9 @@ """ settings_frame.py -Settings frame class for PyGPSClient application. +Settings frame class for PyGPSClient settings_child_frame. -- Reads and updates configuration held in self.__app.configuration. -- Starts or stops data logging. -- Sets initial (saved) configuration of the following frames: -- frm_settings (SettingsFrame class) for general application settings. -- frm_serial (SerialConfigFrame class) for serial port settings. -- frm_socketclient (SocketConfigFrame class) for socket client settings. -- frm_socketserver (ServerConfigFrame class) for socket server settings. +Used when Settings are "docked". Created on 12 Sep 2020 @@ -18,116 +12,10 @@ :license: BSD 3-Clause """ -# pylint: disable=unnecessary-lambda, unused-argument +from tkinter import Frame -from platform import system -from tkinter import ( - ALL, - BOTH, - BOTTOM, - DISABLED, - EW, - HORIZONTAL, - LEFT, - NORMAL, - NW, - RIGHT, - VERTICAL, - Button, - Canvas, - Checkbutton, - E, - Frame, - IntVar, - Label, - Scrollbar, - Spinbox, - StringVar, - TclError, - W, - X, - Y, - ttk, -) - -from PIL import Image, ImageTk - -from pygpsclient.globals import ( - BPSRATES, - CONNECTED, - CONNECTED_FILE, - CONNECTED_SOCKET, - DDD, - DISCONNECTED, - DMM, - DMS, - ECEF, - ERRCOL, - FORMATS, - GNSS_EOF_EVENT, - GNSS_ERR_EVENT, - GNSS_EVENT, - GNSS_TIMEOUT_EVENT, - HOME, - ICON_CONN, - ICON_DISCONN, - ICON_EXIT, - ICON_LOGREAD, - ICON_NMEACONFIG, - ICON_NTRIPCONFIG, - ICON_SERIAL, - ICON_SOCKET, - ICON_TTYCONFIG, - ICON_UBXCONFIG, - INFOCOL, - KNOWNGPS, - MSGMODES, - NOPORTS, - OKCOL, - READONLY, - TIMEOUTS, - TRACEMODE_WRITE, - UI, - UIK, - UMK, - UMM, -) -from pygpsclient.serialconfig_frame import SerialConfigFrame -from pygpsclient.serverconfig_frame import ServerConfigFrame -from pygpsclient.socketconfig_frame import SocketConfigFrame -from pygpsclient.sqlite_handler import SQLOK -from pygpsclient.strings import ( - DLGTNMEA, - DLGTNTRIP, - DLGTTTY, - DLGTUBX, - LBLDATABASERECORD, - LBLDATADISP, - LBLDATALOG, - LBLDEGFORMAT, - LBLNMEACONFIG, - LBLNTRIPCONFIG, - LBLPROTDISP, - LBLSHOWUNUSED, - LBLTRACKRECORD, - LBLTTYCONFIG, - LBLUBXCONFIG, -) - -MAXLINES = ("200", "500", "1000", "2000", "100") -FILEDELAYS = (2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000) -# initial dimensions adjusted for different widget -# rendering on different platforms -if system() == "Linux": # Wayland - MINHEIGHT = 28 - MINWIDTH = 28 -elif system() == "Darwin": # MacOS - - MINHEIGHT = 38 - MINWIDTH = 30 -else: # Windows and others - MINHEIGHT = 35 - MINWIDTH = 26 +from pygpsclient.canvas_subclasses import CanvasContainer +from pygpsclient.settings_child_frame import SettingsChildFrame class SettingsFrame(Frame): @@ -147,49 +35,11 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) - - self.infilepath = None - self.logpath = HOME - self.trackpath = HOME - self.databasepath = HOME - self._prot_nmea = IntVar() - self._prot_ubx = IntVar() - self._prot_sbf = IntVar() - self._prot_qgc = IntVar() - self._prot_rtcm = IntVar() - self._prot_spartn = IntVar() - self._prot_tty = IntVar() - self._autoscroll = IntVar() - self._maxlines = IntVar() - self._filedelay = IntVar() - self._units = StringVar() - self._degrees_format = StringVar() - self._console_format = StringVar() - self._datalog = IntVar() - self._logformat = StringVar() - self._record_track = IntVar() - self._record_database = IntVar() - self._show_unusedsat = IntVar() - self._colortag = IntVar() - self.defaultports = self.__app.configuration.get("defaultport_s") - self._validsettings = True - self._img_conn = ImageTk.PhotoImage(Image.open(ICON_CONN)) - self._img_serial = ImageTk.PhotoImage(Image.open(ICON_SERIAL)) - self._img_socket = ImageTk.PhotoImage(Image.open(ICON_SOCKET)) - self._img_disconn = ImageTk.PhotoImage(Image.open(ICON_DISCONN)) - self._img_exit = ImageTk.PhotoImage(Image.open(ICON_EXIT)) - self._img_ubxconfig = ImageTk.PhotoImage(Image.open(ICON_UBXCONFIG)) - self._img_nmeaconfig = ImageTk.PhotoImage(Image.open(ICON_NMEACONFIG)) - self._img_ttyconfig = ImageTk.PhotoImage(Image.open(ICON_TTYCONFIG)) - self._img_ntripconfig = ImageTk.PhotoImage(Image.open(ICON_NTRIPCONFIG)) - self._img_dataread = ImageTk.PhotoImage(Image.open(ICON_LOGREAD)) + super().__init__(self.__master, *args, **kwargs) self._container() # create scrollable container self._body() self._do_layout() - self.reset() - # self._attach_events() # done in reset self.focus_force() def _container(self): @@ -200,34 +50,8 @@ def _container(self): function which invokes the on_expand() method here. """ - fntw = self.__app.font_md.measure("W") - fnth = self.__app.font_md.metrics("linespace") - dimw = fntw * MINWIDTH - dimh = fnth * MINHEIGHT - self._frm_main = Frame(self) - self._frm_main.pack(fill=BOTH, expand=1) - self_frm_scrollx = Frame(self._frm_main) - self_frm_scrollx.pack(fill=X, side=BOTTOM) - self._can_container = Canvas(self._frm_main, height=dimh, width=dimw) - self._frm_container = Frame(self._can_container) - self._can_container.pack(side=LEFT, fill=BOTH, expand=1) - x_scrollbar = Scrollbar( - self_frm_scrollx, orient=HORIZONTAL, command=self._can_container.xview - ) - x_scrollbar.pack(side=BOTTOM, fill=X) - y_scrollbar = Scrollbar( - self._frm_main, orient=VERTICAL, command=self._can_container.yview - ) - y_scrollbar.pack(side=RIGHT, fill=Y) - self._can_container.configure(xscrollcommand=x_scrollbar.set) - self._can_container.configure(yscrollcommand=y_scrollbar.set) - self._can_container.create_window((0, 0), window=self._frm_container, anchor=NW) - self._can_container.bind( - "", - lambda e: self._can_container.config( - scrollregion=self._can_container.bbox(ALL) - ), - ) + self._can_container = CanvasContainer(self.__app, self) + self._frm_container = self._can_container.frm_container def on_expand(self): """ @@ -241,724 +65,25 @@ def _body(self): Set up frame and widgets. """ - for i in range(4): - self._frm_container.grid_columnconfigure(i, weight=1) - self._frm_container.grid_rowconfigure(0, weight=1) - self._frm_container.option_add("*Font", self.__app.font_sm) - - # serial port configuration panel - self.frm_serial = SerialConfigFrame( - self.__app, - self._frm_container, - recognised=KNOWNGPS, - timeouts=TIMEOUTS, - bpsrates=BPSRATES, - msgmodes=list(MSGMODES.keys()), - ) - - # socket client configuration panel - self.frm_socketclient = SocketConfigFrame(self.__app, self._frm_container) - - # connection buttons - self._frm_buttons = Frame(self._frm_container) - self._btn_connect = Button( - self._frm_buttons, - width=45, - height=35, - image=self._img_serial, - command=lambda: self._on_connect(CONNECTED), - state=NORMAL, - ) - self._lbl_connect = Label(self._frm_buttons, text="USB/UART") - self._btn_connect_socket = Button( - self._frm_buttons, - width=45, - height=35, - image=self._img_socket, - command=lambda: self._on_connect(CONNECTED_SOCKET), - state=NORMAL, - ) - self._lbl_connect_socket = Label(self._frm_buttons, text="TCP/UDP") - self._btn_connect_file = Button( - self._frm_buttons, - width=45, - height=35, - image=self._img_dataread, - command=lambda: self._on_connect(CONNECTED_FILE), - state=NORMAL, - ) - self._lbl_connect_file = Label(self._frm_buttons, text="FILE") - self._btn_disconnect = Button( - self._frm_buttons, - width=45, - height=35, - image=self._img_disconn, - command=lambda: self._on_connect(DISCONNECTED), - state=DISABLED, - ) - self._lbl_disconnect = Label(self._frm_buttons, text="STOP") - self._btn_exit = Button( - self._frm_buttons, - width=45, - height=35, - image=self._img_exit, - command=lambda: self.__app.on_exit(), - state=NORMAL, - ) - - self._lbl_status_preset = Label( - self._frm_buttons, font=self.__app.font_md2, text="" - ) - - # Other configuration options - self._frm_options = Frame(self._frm_container) - self._frm_options_btns = Frame(self._frm_options) - self._lbl_protocol = Label(self._frm_options, text=LBLPROTDISP) - self._chk_nmea = Checkbutton( - self._frm_options, - text="NMEA", - variable=self._prot_nmea, - ) - self._chk_ubx = Checkbutton( - self._frm_options, - text="UBX", - variable=self._prot_ubx, - ) - self._chk_rtcm = Checkbutton( - self._frm_options, - text="RTCM", - variable=self._prot_rtcm, - ) - self._chk_spartn = Checkbutton( - self._frm_options, - text="SPARTN", - variable=self._prot_spartn, - ) - self._chk_sbf = Checkbutton( - self._frm_options, - text="SBF", - variable=self._prot_sbf, - ) - self._chk_qgc = Checkbutton( - self._frm_options, - text="QGC", - variable=self._prot_qgc, - ) - self._chk_tty = Checkbutton( - self._frm_options, - text="TTY", - variable=self._prot_tty, - ) - self._lbl_consoledisplay = Label(self._frm_options, text=LBLDATADISP) - self._spn_conformat = Spinbox( - self._frm_options, - values=FORMATS, - width=10, - state=READONLY, - wrap=True, - textvariable=self._console_format, - ) - self._chk_tags = Checkbutton( - self._frm_options, - text="Tags", - variable=self._colortag, - ) - self._lbl_format = Label(self._frm_options, text=LBLDEGFORMAT) - self._spn_format = Spinbox( - self._frm_options, - values=(DDD, DMS, DMM, ECEF), - width=6, - state=READONLY, - wrap=True, - textvariable=self._degrees_format, - ) - self._spn_units = Spinbox( - self._frm_options, - values=(UMM, UIK, UI, UMK), - width=13, - state=READONLY, - wrap=True, - textvariable=self._units, - ) - self._chk_scroll = Checkbutton( - self._frm_options, text="Autoscroll", variable=self._autoscroll - ) - self._spn_maxlines = Spinbox( - self._frm_options, - values=MAXLINES, - width=6, - wrap=True, - textvariable=self._maxlines, - state=READONLY, - ) - self._lbl_filedelay = Label( - self._frm_options, - text="File Delay", - ) - self._spn_filedelay = Spinbox( - self._frm_options, - value=FILEDELAYS, - width=4, - wrap=True, - textvariable=self._filedelay, - state=READONLY, - repeatdelay=1000, - repeatinterval=1000, - ) - self._chk_unusedsat = Checkbutton( - self._frm_options, text=LBLSHOWUNUSED, variable=self._show_unusedsat - ) - self._chk_datalog = Checkbutton( - self._frm_options, - text=LBLDATALOG, - variable=self._datalog, - ) - self._spn_datalog = Spinbox( - self._frm_options, - values=(FORMATS), - width=20, - wrap=True, - textvariable=self._logformat, - state=READONLY, - ) - self._chk_recordtrack = Checkbutton( - self._frm_options, - text=LBLTRACKRECORD, - variable=self._record_track, - ) - self._chk_recorddatabase = Checkbutton( - self._frm_options, - text=LBLDATABASERECORD, - variable=self._record_database, - ) - # configuration panel buttons - self._lbl_ubxconfig = Label( - self._frm_options_btns, - text=LBLUBXCONFIG, - ) - self._btn_ubxconfig = Button( - self._frm_options_btns, - width=45, - image=self._img_ubxconfig, - command=lambda: self.__app.start_dialog(DLGTUBX), - ) - self._lbl_nmeaconfig = Label( - self._frm_options_btns, - text=LBLNMEACONFIG, - ) - self._btn_nmeaconfig = Button( - self._frm_options_btns, - width=45, - image=self._img_nmeaconfig, - command=lambda: self.__app.start_dialog(DLGTNMEA), - state=NORMAL, - ) - self._lbl_ttyconfig = Label( - self._frm_options_btns, - text=LBLTTYCONFIG, - ) - self._btn_ttyconfig = Button( - self._frm_options_btns, - width=45, - image=self._img_ttyconfig, - command=lambda: self.__app.start_dialog(DLGTTTY), - state=NORMAL, - ) - self._lbl_ntripconfig = Label( - self._frm_options_btns, - text=LBLNTRIPCONFIG, - ) - self._btn_ntripconfig = Button( - self._frm_options_btns, - width=45, - image=self._img_ntripconfig, - command=lambda: self.__app.start_dialog(DLGTNTRIP), - state=NORMAL, - ) - # socket server configuration - self.frm_socketserver = ServerConfigFrame( - self.__app, - self._frm_container, - ) + self.frm_settings = SettingsChildFrame(self.__app, self._frm_container) + self.frm_serial = self.frm_settings.frm_serial + self.frm_socketclient = self.frm_settings.frm_socketclient def _do_layout(self): """ Position widgets in frame. """ - self.frm_serial.grid(column=0, row=1, columnspan=4, padx=2, pady=2, sticky=EW) - ttk.Separator(self._frm_container).grid( - column=0, row=2, columnspan=4, padx=2, pady=2, sticky=EW - ) - - self.frm_socketclient.grid( - column=0, row=3, columnspan=4, padx=2, pady=2, sticky=EW - ) - ttk.Separator(self._frm_container).grid( - column=0, row=4, columnspan=4, padx=2, pady=2, sticky=EW - ) - - self._frm_buttons.grid(column=0, row=5, columnspan=4, sticky=EW) - self._btn_connect.grid(column=0, row=0, padx=2, pady=1) - self._btn_connect_socket.grid(column=1, row=0, padx=2, pady=1) - self._btn_connect_file.grid(column=2, row=0, padx=2, pady=1) - self._btn_disconnect.grid(column=3, row=0, padx=2, pady=1) - self._btn_exit.grid(column=4, row=0, padx=2, pady=1) - self._lbl_connect.grid(column=0, row=1, padx=1, pady=1, sticky=EW) - self._lbl_connect_socket.grid(column=1, row=1, padx=1, pady=1, sticky=EW) - self._lbl_connect_file.grid(column=2, row=1, padx=1, pady=1, sticky=EW) - self._lbl_disconnect.grid(column=3, row=1, padx=1, pady=1, sticky=EW) - - ttk.Separator(self._frm_container).grid( - column=0, row=7, columnspan=4, padx=2, pady=2, sticky=EW - ) + self.frm_settings.grid(column=0, row=0) - self._frm_options.grid(column=0, row=8, columnspan=4, sticky=EW) - self._lbl_protocol.grid(column=0, row=0, padx=2, pady=2, sticky=W) - self._chk_nmea.grid(column=1, row=0, padx=0, pady=0, sticky=W) - self._chk_ubx.grid(column=2, row=0, padx=0, pady=0, sticky=W) - self._chk_sbf.grid(column=3, row=0, padx=0, pady=0, sticky=W) - self._chk_qgc.grid(column=4, row=0, padx=0, pady=0, sticky=W) - self._chk_rtcm.grid(column=1, row=1, padx=0, pady=0, sticky=W) - self._chk_spartn.grid(column=2, row=1, padx=0, pady=0, sticky=W) - self._chk_tty.grid(column=3, row=1, padx=0, pady=0, sticky=W) - self._lbl_consoledisplay.grid(column=0, row=2, padx=2, pady=2, sticky=W) - self._spn_conformat.grid( - column=1, row=2, columnspan=2, padx=1, pady=2, sticky=W - ) - self._chk_tags.grid(column=3, row=2, padx=1, pady=2, sticky=W) - self._lbl_format.grid(column=0, row=3, padx=2, pady=2, sticky=W) - self._spn_format.grid(column=1, row=3, padx=2, pady=2, sticky=W) - self._spn_units.grid(column=2, row=3, columnspan=2, padx=2, pady=2, sticky=W) - self._chk_scroll.grid(column=0, row=5, padx=2, pady=2, sticky=W) - self._spn_maxlines.grid(column=1, row=5, padx=2, pady=2, sticky=W) - self._lbl_filedelay.grid(column=2, row=5, padx=2, pady=2, sticky=E) - self._spn_filedelay.grid(column=3, row=5, padx=2, pady=2, sticky=W) - self._chk_unusedsat.grid( - column=0, row=6, columnspan=2, padx=2, pady=2, sticky=W - ) - self._chk_datalog.grid(column=0, row=7, padx=2, pady=2, sticky=W) - self._spn_datalog.grid(column=1, row=7, columnspan=3, padx=2, pady=2, sticky=W) - self._chk_recordtrack.grid( - column=0, row=8, columnspan=2, padx=2, pady=2, sticky=W - ) - self._chk_recorddatabase.grid( - column=2, row=8, columnspan=2, padx=2, pady=2, sticky=W - ) - self._frm_options_btns.grid(column=0, row=9, columnspan=4, sticky=EW) - self._btn_ubxconfig.grid(column=0, row=0, padx=5) - self._lbl_ubxconfig.grid(column=0, row=1) - self._btn_nmeaconfig.grid(column=1, row=0, padx=5) - self._lbl_nmeaconfig.grid(column=1, row=1) - self._btn_ttyconfig.grid(column=2, row=0, padx=5) - self._lbl_ttyconfig.grid(column=2, row=1) - self._btn_ntripconfig.grid(column=3, row=0, padx=5) - self._lbl_ntripconfig.grid(column=3, row=1) - ttk.Separator(self._frm_container).grid( - column=0, row=10, columnspan=4, padx=2, pady=2, sticky=EW - ) - self.frm_socketserver.grid( - column=0, row=11, columnspan=4, padx=2, pady=2, sticky=EW - ) - - def _attach_events(self, add: bool = True): - """ - Bind events to widgets. - - (trace_update() is a class extension method defined in globals.py) - - :param bool add: add or remove trace - """ - - # pylint: disable=no-member - - tracemode = TRACEMODE_WRITE - self._prot_ubx.trace_update(tracemode, self._on_update_ubxprot, add) - self._prot_sbf.trace_update(tracemode, self._on_update_sbfprot, add) - self._prot_qgc.trace_update(tracemode, self._on_update_qgcprot, add) - self._prot_nmea.trace_update(tracemode, self._on_update_nmeaprot, add) - self._prot_rtcm.trace_update(tracemode, self._on_update_rtcmprot, add) - self._prot_spartn.trace_update(tracemode, self._on_update_spartnprot, add) - self._prot_tty.trace_update(tracemode, self._on_update_ttyprot, add) - self._autoscroll.trace_update(tracemode, self._on_update_autoscroll, add) - self._maxlines.trace_update(tracemode, self._on_update_maxlines, add) - self._filedelay.trace_update(tracemode, self._on_update_filedelay, add) - self._units.trace_update(tracemode, self._on_update_units, add) - self._degrees_format.trace_update(tracemode, self._on_update_degreesformat, add) - self._console_format.trace_update(tracemode, self._on_update_consoleformat, add) - self._show_unusedsat.trace_update(tracemode, self._on_update_unusedsat, add) - self._colortag.trace_update(tracemode, self._on_update_colortag, add) - self._logformat.trace_update(tracemode, self._on_update_logformat, add) - self._datalog.trace_update(tracemode, self._on_data_log, add) - self._record_track.trace_update(tracemode, self._on_record_track, add) - self._record_database.trace_update(tracemode, self._on_record_database, add) - - def reset(self): - """ - Reset settings to saved configuration. - """ - - self._attach_events(False) - cfg = self.__app.configuration - self._prot_nmea.set(cfg.get("nmeaprot_b")) - self._prot_ubx.set(cfg.get("ubxprot_b")) - self._prot_sbf.set(cfg.get("sbfprot_b")) - self._prot_qgc.set(cfg.get("qgcprot_b")) - self._prot_rtcm.set(cfg.get("rtcmprot_b")) - self._prot_spartn.set(cfg.get("spartnprot_b")) - self._prot_tty.set(cfg.get("ttyprot_b")) - self._degrees_format.set(cfg.get("degreesformat_s")) - self._colortag.set(cfg.get("colortag_b")) - self._units.set(cfg.get("units_s")) - self._autoscroll.set(cfg.get("autoscroll_b")) - self._maxlines.set(cfg.get("maxlines_n")) - self._filedelay.set(cfg.get("filedelay_n")) - self._console_format.set(cfg.get("consoleformat_s")) - self._show_unusedsat.set(cfg.get("unusedsat_b")) - self._logformat.set(cfg.get("logformat_s")) - self._datalog.set(cfg.get("datalog_b")) - self.logpath = cfg.get("logpath_s") - self._record_track.set(cfg.get("recordtrack_b")) - self.trackpath = cfg.get("trackpath_s") - self.databasepath = cfg.get("databasepath_s") - if self.__app.db_enabled == SQLOK: - self._record_database.set(cfg.get("database_b")) - else: - self._record_database.set(0) - self._chk_recorddatabase.config(state=DISABLED) - - self.clients = 0 - self._attach_events(True) - - def _reset_frames(self): - """ - Reset frames. - """ - - self.__app.frm_mapview.reset_map_refresh() - self.__app.frm_spectrumview.reset() - self.__app.reset_gnssstatus() - - def _on_update_ubxprot(self, var, index, mode): - """ - Action on updating ubxprot. - """ - - if not self._prot_tty.get(): - self.__app.configuration.set("ubxprot_b", self._prot_ubx.get()) - - def _on_update_sbfprot(self, var, index, mode): - """ - Action on updating sbfprot. - """ - - if not self._prot_tty.get(): - self.__app.configuration.set("sbfprot_b", self._prot_sbf.get()) - - def _on_update_qgcprot(self, var, index, mode): - """ - Action on updating qgcprot. - """ - - if not self._prot_tty.get(): - self.__app.configuration.set("qgcprot_b", self._prot_qgc.get()) - - def _on_update_nmeaprot(self, var, index, mode): - """ - Action on updating nmeaprot. - """ - - if not self._prot_tty.get(): - self.__app.configuration.set("nmeaprot_b", self._prot_nmea.get()) - - def _on_update_rtcmprot(self, var, index, mode): - """ - Action on updating rtcmprot. - """ - - if not self._prot_tty.get(): - self.__app.configuration.set("rtcmprot_b", self._prot_rtcm.get()) - - def _on_update_spartnprot(self, var, index, mode): - """ - Action on updating spartnprot. - """ - - if not self._prot_tty.get(): - self.__app.configuration.set("spartnprot_b", self._prot_spartn.get()) - - def _on_update_ttyprot(self, var, index, mode): - """ - TTY mode has been updated. - """ - - try: - cfg = self.__app.configuration - tty = self._prot_tty.get() - self.update() - if tty: - - for wdg in ( - self._prot_nmea, - self._prot_ubx, - self._prot_sbf, - self._prot_qgc, - self._prot_rtcm, - self._prot_spartn, - ): - wdg.set(0) - else: - self._prot_nmea.set(cfg.get("nmeaprot_b")) - self._prot_ubx.set(cfg.get("ubxprot_b")) - self._prot_sbf.set(cfg.get("sbfprot_b")) - self._prot_qgc.set(cfg.get("qgcprot_b")) - self._prot_rtcm.set(cfg.get("rtcmprot_b")) - self._prot_spartn.set(cfg.get("spartnprot_b")) - cfg.set("ttyprot_b", tty) - except (ValueError, TclError): - pass - - def _on_update_consoleformat(self, var, index, mode): - """ - Action on updating console format. - """ - - self.__app.configuration.set("consoleformat_s", self._console_format.get()) - - def _on_update_maxlines(self, var, index, mode): - """ - Action on updating console maxlines. - """ - - self.__app.configuration.set("maxlines_n", self._maxlines.get()) - - def _on_update_filedelay(self, var, index, mode): - """ - Action on updating filedelay. - """ - - self.__app.configuration.set("filedelay_n", self._filedelay.get()) - - def _on_update_degreesformat(self, var, index, mode): - """ - Action on updating degrees format. - """ - - self.__app.configuration.set("degreesformat_s", self._degrees_format.get()) - - def _on_update_units(self, var, index, mode): - """ - Action on updating units. - """ - - self.__app.configuration.set("units_s", self._units.get()) - - def _on_update_colortag(self, var, index, mode): - """ - Action on updating color tagging. - """ - - self.__app.configuration.set("colortag_b", self._colortag.get()) - - def _on_update_autoscroll(self, var, index, mode): - """ - Action on updating autoscroll. - """ - - self.__app.configuration.set("autoscroll_b", self._autoscroll.get()) - - def _on_update_unusedsat(self, var, index, mode): - """ - Action on updating unused satellites. - """ - - self.__app.configuration.set("unusedsat_b", self._show_unusedsat.get()) - - def _on_update_logformat(self, var, index, mode): - """ - Action on updating log format. - """ - - self.__app.configuration.set("logformat_s", self._logformat.get()) - - def _on_data_log(self, var, index, mode): - """ - Start or stop data logger. - """ - - if self._datalog.get() == 1: - if self.logpath in ("", None): - self.logpath = self.__app.file_handler.set_logfile_path() - if self.logpath is not None: - self.__app.configuration.set("datalog_b", 1) - self.__app.configuration.set("logpath_s", self.logpath) - self.__app.status_label = ( - f"Data logging enabled: {self.logpath}", - INFOCOL, - ) - if not self.__app.file_handler.open_logfile(): - self.logpath = "" - self._datalog.set(0) - else: - self.logpath = "" - self._datalog.set(0) - self._spn_datalog.config(state=DISABLED) - else: - self.__app.configuration.set("datalog_b", 0) - self._datalog.set(0) - self.__app.file_handler.close_logfile() - self.__app.status_label = ("Data logging disabled", INFOCOL) - self._spn_datalog.config(state=READONLY) - - def _on_record_track(self, var, index, mode): - """ - Start or stop track recorder. - """ - - if self._record_track.get() == 1: - if self.trackpath in ("", None): - self.trackpath = self.__app.file_handler.set_trackfile_path() - if self.trackpath is not None: - self.__app.configuration.set("recordtrack_b", 1) - self.__app.configuration.set("trackpath_s", self.trackpath) - self.__app.status_label = f"Track recording enabled: {self.trackpath}" - if not self.__app.file_handler.open_trackfile(): - self.trackpath = "" - self._record_track.set(0) - else: - self.trackpath = "" - self._record_track.set(0) - else: - self._record_track.set(0) - self.__app.configuration.set("recordtrack_b", 0) - self.__app.file_handler.close_trackfile() - self.__app.status_label = "Track recording disabled" - - def _on_record_database(self, var, index, mode): - """ - Start or stop database recorder. - """ - - if self._record_database.get() == 1: - if self.databasepath in ("", None): - self.databasepath = self.__app.file_handler.set_database_path() - if self.databasepath is not None: - rc = self.__app.sqlite_handler.open(dbpath=self.databasepath) - self.__app.configuration.set("database_b", rc == SQLOK) - self.__app.configuration.set("databasepath_s", self.databasepath) - else: - self.databasepath = "" - self._record_database.set(0) - else: - self.__app.configuration.set("database_b", 0) - self.__app.status_label = "Database recording disabled" - - def _on_connect(self, conntype: int): - """ - Start or stop connection (serial, socket or file). - - :param int conntype: connection type - """ - - connstr = "" - conndict = { - "protocol": self.__app.protocol_mask, - "read_event": GNSS_EVENT, - "eof_event": GNSS_EOF_EVENT, - "timeout_event": GNSS_TIMEOUT_EVENT, - "error_event": GNSS_ERR_EVENT, - "inqueue": self.__app.gnss_inqueue, - "outqueue": self.__app.gnss_outqueue, - "socket_inqueue": self.__app.socket_inqueue, - "conntype": conntype, - "msgmode": self.frm_serial.msgmode, - "inactivity_timeout": self.frm_serial.inactivity_timeout, - } - - self.frm_socketserver.status_label = conntype - if conntype == CONNECTED: - frm = self.frm_serial - if frm.status == NOPORTS: - return - connstr = f"{frm.port}:{frm.port_desc} @ {frm.bpsrate}" - conndict = dict(conndict, **{"serial_settings": frm}) - # poll for device software version on connection - self.__app.poll_version(conndict["protocol"]) - elif conntype == CONNECTED_SOCKET: - frm = self.frm_socketclient - if not frm.valid_settings(): - self.__app.status_label = ("ERROR - invalid settings", ERRCOL) - return - connstr = f"{frm.server.get()}:{frm.port.get()}" - conndict = dict(conndict, **{"socket_settings": frm}) - # poll for device software version on connection - self.__app.poll_version(conndict["protocol"]) - elif conntype == CONNECTED_FILE: - self.infilepath = self.__app.file_handler.open_file( - self, - "datalog", - ( - ("datalog files", "*.log"), - ("u-center logs", "*.ubx"), - ("all files", "*.*"), - ), - ) - if self.infilepath is None: - return - connstr = f"{self.infilepath}" - conndict = dict(conndict, **{"in_filepath": self.infilepath}) - elif conntype == DISCONNECTED: - if self.__app.conn_status != DISCONNECTED: - self.__app.conn_status = DISCONNECTED - self.__app.stream_handler.stop() - return - else: - return - - self.__app.conn_status = conntype - self.__app.conn_label = (connstr, OKCOL) - self.__app.status_label = ("", INFOCOL) - self._reset_frames() - self.__app.stream_handler.start(self.__app, conndict) - - def enable_controls(self, status: int): - """ - Public method to enable or disable controls depending on - connection status. - - :param int status: connection status as integer - (0=Disconnected, 1=Connected to serial, - 2=Connected to file, 3=No serial ports available) - - """ - - self.frm_serial.status_label = status - self.frm_socketclient.status_label = status - self.frm_socketserver.status_label = status - - self._btn_connect.config( - state=( - DISABLED - if status in (CONNECTED, CONNECTED_SOCKET, CONNECTED_FILE, NOPORTS) - else NORMAL - ) - ) - for ctl in ( - self._btn_connect_socket, - self._btn_connect_file, - self._chk_tty, - ): - ctl.config( - state=( - DISABLED - if status in (CONNECTED, CONNECTED_SOCKET, CONNECTED_FILE) - else NORMAL - ) - ) - self._btn_disconnect.config( - state=(DISABLED if status in (DISCONNECTED,) else NORMAL) + # resize container canvas to accommodate frame + self._frm_container.update() + self._can_container.config( + height=self._frm_container.winfo_height(), + width=self._frm_container.winfo_width(), ) + self._can_container.update() def get_size(self) -> tuple: """ diff --git a/src/pygpsclient/signalsview_frame.py b/src/pygpsclient/signalsview_frame.py new file mode 100644 index 00000000..02800bae --- /dev/null +++ b/src/pygpsclient/signalsview_frame.py @@ -0,0 +1,373 @@ +""" +signalsview_frame.py + +Signals view frame class for PyGPSClient application. + +This handles a frame containing a graph of current signal C/No level, +correction source and other signal-related flags. + +Created on 24 Dec 2025 + +:author: semuadmin (Steve Smith) +:copyright: 2020 semuadmin +:license: BSD 3-Clause +""" + +# pylint: disable=no-member, unused-variable, duplicate-code + +from tkinter import ALL, NSEW, NW, SE, Frame, N, S, font + +from pyubx2 import CORRSOURCE, SIGID, UBXMessage + +from pygpsclient.canvas_subclasses import ( + TAG_DATA, + TAG_GRID, + TAG_XLABEL, + TAG_YLABEL, + CanvasGraph, +) +from pygpsclient.globals import ( + BGCOL, + FGCOL, + GNSS_LIST, + GRIDMAJCOL, + MAX_SNR, + PNTCOL, + SIGNALSVIEW, + WIDGETU3, +) +from pygpsclient.helpers import col2contrast, fitfont, setubxrate +from pygpsclient.strings import DLGENABLENAVSIG, DLGNONAVSIG, DLGWAITNAVSIG + +OL_WID = 1 +FONTSCALELG = 40 +MAXWAIT = 10 +ACTIVE = "" +XLBLANGLE = 60 +XLBLFMT = "000 WWW_W/W" +# Correction source legend +CSLEG = ", ".join( + f"{key} {val}" for key, val in CORRSOURCE.items() if key != 0 +).replace(", 7", ",\n7") +CL = "A" * len(CSLEG.split("\n", 1)[0]) + + +def unused_sigs(data: dict) -> int: + """ + Get number of 'unused' sigs in gnss_data.sig_data. + + :param dict data: sig_data + :return: number of sigs where cno = 0 + :rtype: int + """ + + return sum(1 for (_, _, _, cno, _, _, _, _) in data.values() if cno == 0) + + +class SignalsviewFrame(Frame): + """ + Signalsview frame class. + """ + + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): + """ + Constructor. + + :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame + :param args: optional args to pass to Frame parent class + :param kwargs: optional kwargs to pass to Frame parent class + """ + + self.__app = app # Reference to main application class + self.__master = self.__app.appmaster # Reference to root class (Tk) + + super().__init__(parent, *args, **kwargs) + + def_w, def_h = WIDGETU3 + self.width = kwargs.get("width", def_w) + self.height = kwargs.get("height", def_h) + self._redraw = True + self._navsig_status = DLGENABLENAVSIG + self._pending_confs = {} + self._waits = 0 + self._body() + self._attach_events() + + def _body(self): + """ + Set up frame and widgets. + """ + + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self._canvas = CanvasGraph( + self.__app, self, width=self.width, height=self.height, bg=BGCOL + ) + self._canvas.grid(column=0, row=0, sticky=NSEW) + + def _attach_events(self): + """ + Bind events to frame. + """ + + self.bind("", self._on_resize) + self._canvas.bind("", self._on_legend) + self._canvas.bind("", self._on_cno0) + self._canvas.bind("", self._on_cno0) + + def _on_legend(self, event): # pylint: disable=unused-argument + """ + On double-click - toggle legend on/off. + + :param event: event + """ + + self.__app.configuration.set( + "legend_b", not self.__app.configuration.get("legend_b") + ) + self._redraw = True + + def _on_cno0(self, event): # pylint: disable=unused-argument + """ + On double-right-click - include signals where C/No = 0. + + :param event: event + """ + + self.__app.configuration.set( + "unusedsat_b", not self.__app.configuration.get("unusedsat_b") + ) + self._redraw = True + + def enable_messages(self, status: bool): + """ + Enable/disable UBX NAV-SIG message. + + :param bool status: 0 = off, 1 = on + """ + + setubxrate(self.__app, "NAV-SIG", status) + for msgid in ("ACK-ACK", "ACK-NAK"): + self._set_pending(msgid, SIGNALSVIEW) + self._navsig_status = DLGWAITNAVSIG + + def _set_pending(self, msgid: int, ubxfrm: int): + """ + Set pending confirmation flag for Signalsview frame to + signify that it's waiting for a confirmation message. + + :param int msgid: UBX message identity + :param int ubxfrm: integer representing UBX configuration frame + """ + + self._pending_confs[msgid] = ubxfrm + + def update_pending(self, msg: UBXMessage): + """ + Receives polled confirmation message from the ubx_handler and + updates signalsview canvas. + + :param UBXMessage msg: UBX config message + """ + + pending = self._pending_confs.get(msg.identity, False) + + if pending and msg.identity == "ACK-NAK": + self.reset() + w, h = self.width, self.height + self._canvas.create_text( + w / 2, + h / 2, + text=DLGNONAVSIG, + fill=PNTCOL, + anchor=S, + tags=TAG_DATA, + ) + self._pending_confs.pop("ACK-NAK") + self._navsig_status = DLGNONAVSIG + + if self._pending_confs.get("ACK-ACK", False): + self._pending_confs.pop("ACK-ACK") + + def reset(self): + """ + Reset spectrumview frame. + """ + + self.__app.gnss_status.sig_data = [] + self._canvas.delete(ALL) + self.update_frame() + + def init_frame(self): + """ + Initialise graph view + """ + + # only redraw the tags that have changed + tags = (TAG_GRID, TAG_XLABEL, TAG_YLABEL) if self._redraw else () + self._canvas.create_graph( + xdatamax=10, + ydatamax=(MAX_SNR,), + xtickmaj=10, + ytickmaj=int(MAX_SNR / 10), + ylegend=("C/No dBHz",), + ycol=(FGCOL,), + ylabels=True, + xlabelsfrm=XLBLFMT, + xangle=XLBLANGLE, + fontscale=FONTSCALELG, + tags=tags, + ) + self._redraw = False + + # display 'enable NAV-SIG' warning + self._canvas.create_text( + self.width / 2, + self.height / 2, + text=self._navsig_status, + fill=PNTCOL, + tags=TAG_DATA, + ) + + def _draw_legend(self): + """ + Draw GNSS color code and correction source legends + """ + + w = self.width / 12 / 2 + h = self.height / 18 + + # gnssid color code legend + lgfont = font.Font(size=int(min(self.width / 2, self.height) / FONTSCALELG)) + for i, (_, (gnssName, gnssCol)) in enumerate(GNSS_LIST.items()): + x = (self._canvas.xoffl * 2) + w * i + self._canvas.create_rectangle( + x, + self._canvas.yofft, + x + w - 5, + self._canvas.yofft + h, + outline=GRIDMAJCOL, + fill=gnssCol, + width=OL_WID, + tags=TAG_XLABEL, + ) + self._canvas.create_text( + (x + x + w - 5) / 2, + self._canvas.yofft + h / 2, + text=gnssName, + fill=col2contrast(gnssCol), + font=lgfont, + tags=TAG_XLABEL, + ) + + # correction source legend + xfnt, _, _ = fitfont(CL, self.width / 2 - self._canvas.xoffl, h / 2, maxsiz=12) + self._canvas.create_text( + self.width / 2, + self._canvas.yofft + 1, + text=f"Correction Source:\n{CSLEG}", + fill=FGCOL, + font=xfnt, + anchor=NW, + tags=TAG_DATA, + ) + + def update_frame(self): + """ + Plot signal signal-to-noise ratio (C/No). + Automatically adjust y axis according to number of satellites in view. + """ + + data = self.__app.gnss_status.sig_data + if len(data) == 0: + if self._waits >= MAXWAIT: + self._navsig_status = DLGNONAVSIG + else: + self._waits += 1 + else: + self._waits = 0 + self._navsig_status = ACTIVE + show_unused = self.__app.configuration.get("unusedsat_b") + siv = len(data) + siv = siv if show_unused else siv - unused_sigs(data) + if siv <= 0: + return + + w, h = self.width, self.height + self.init_frame() + + offset = self._canvas.xoffl + colwidth = (w - self._canvas.xoffl - self._canvas.xoffr + 1) / siv + xfnt, _, _ = fitfont( + XLBLFMT, + colwidth * 1.66, + self._canvas.yoffb, + XLBLANGLE, + ) + for val in sorted(data.values()): # sort by ascending gnssid, svid, sigid + gnssId, prn, sigid, cno, corrsource, quality, flags, _ = val + if cno == 0 and not show_unused: + continue + sig = SIGID.get((gnssId, sigid), sigid) + snr_y = int(cno) * (h - self._canvas.yoffb - 1) / MAX_SNR + (_, ol_col) = GNSS_LIST[gnssId] + prn = f"{int(prn):02}" + self._canvas.create_rectangle( + offset, + h - self._canvas.yoffb - 1, + offset + colwidth - OL_WID, + h - self._canvas.yoffb - snr_y - 1, + outline=GRIDMAJCOL, + fill=ol_col, + width=OL_WID, + tags=TAG_DATA, + ) + # xlabel prn - sigid + self._canvas.create_text( + offset + colwidth, + h - self._canvas.yoffb + 3, + text=f"{prn} {sig}", + fill=FGCOL, + font=xfnt, + angle=XLBLANGLE, + anchor=SE, + tags=TAG_DATA, + ) + # xcaption corrsource if > 0 + if corrsource: + self._canvas.create_text( + offset + colwidth / 2, + h - self._canvas.yoffb - snr_y + 2, + text=corrsource, + fill=col2contrast(ol_col), + font=xfnt, + anchor=N, + tags=TAG_DATA, + ) + offset += colwidth + + if self.__app.configuration.get("legend_b"): + self._draw_legend() + self.update_idletasks() + + def _on_resize(self, event): # pylint: disable=unused-argument + """ + Resize frame + + :param event event: resize event + """ + + self.width, self.height = self.get_size() + self._redraw = True + + def get_size(self): + """ + Get current canvas size. + + :return: window size (width, height) + :rtype: tuple + """ + + self.update_idletasks() # Make sure we know about any resizing + return self._canvas.winfo_width(), self._canvas.winfo_height() diff --git a/src/pygpsclient/skyview_frame.py b/src/pygpsclient/skyview_frame.py index 19af71c3..27f5a18d 100644 --- a/src/pygpsclient/skyview_frame.py +++ b/src/pygpsclient/skyview_frame.py @@ -16,7 +16,7 @@ from tkinter import NSEW, Frame -from pygpsclient.canvas_plot import ( +from pygpsclient.canvas_subclasses import ( MODE_CEL, TAG_DATA, TAG_GRID, @@ -40,11 +40,12 @@ class SkyviewFrame(Frame): Skyview frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -52,7 +53,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 self.width = kwargs.get("width", def_w) diff --git a/src/pygpsclient/socketconfig_frame.py b/src/pygpsclient/socketconfig_frame.py index 7af72f35..341c7499 100644 --- a/src/pygpsclient/socketconfig_frame.py +++ b/src/pygpsclient/socketconfig_frame.py @@ -61,21 +61,22 @@ class SocketConfigFrame(Frame): Socket configuration frame class. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param tkinter.Frame container: reference to container frame + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs for value ranges, or to pass to Frame parent class """ + self.__app = app + self._container = parent self._server_callback = kwargs.pop("server_callback", None) self._protocol_range = kwargs.pop("protocols", PROTOCOLS) - Frame.__init__(self, container, *args, **kwargs) + super().__init__(parent, *args, **kwargs) - self.__app = app - self._container = container self._show_advanced = False self.status = DISCONNECTED self.server = StringVar() diff --git a/src/pygpsclient/socketconfig_ntrip_frame.py b/src/pygpsclient/socketconfig_ntrip_frame.py index d71d31e1..fe69e444 100644 --- a/src/pygpsclient/socketconfig_ntrip_frame.py +++ b/src/pygpsclient/socketconfig_ntrip_frame.py @@ -13,7 +13,7 @@ # pylint: disable=unused-argument -from tkinter import TclError +from tkinter import Frame, TclError from pygpsclient.globals import DEFAULT_TLS_PORTS, VALINT, VALURL from pygpsclient.helpers import MAXPORT @@ -25,17 +25,18 @@ class SocketConfigNtripFrame(SocketConfigFrame): Socket configuration frame class. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param tkinter.Frame container: reference to container frame + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs for value ranges, or to pass to Frame parent class """ self.__app = app - super().__init__(app, container, *args, **kwargs) + super().__init__(app, parent, *args, **kwargs) def _on_update_server(self, var, index, mode): """ diff --git a/src/pygpsclient/spartn_dialog.py b/src/pygpsclient/spartn_dialog.py index c37b6ab9..2781f17b 100644 --- a/src/pygpsclient/spartn_dialog.py +++ b/src/pygpsclient/spartn_dialog.py @@ -38,8 +38,6 @@ CFGSET = "CFG-VALGET/SET" CFGPOLL = "CFG-VALGET" -MINDIM = (408, 758) - class SPARTNConfigDialog(ToplevelDialog): """, @@ -58,7 +56,7 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - super().__init__(app, DLGTSPARTN, MINDIM) + super().__init__(app, DLGTSPARTN) self._pending_confs = {} self._lband_enabled = self.__app.configuration.get("lband_enabled_b") diff --git a/src/pygpsclient/spartn_gnss_frame.py b/src/pygpsclient/spartn_gnss_frame.py index af5c8388..a996df04 100644 --- a/src/pygpsclient/spartn_gnss_frame.py +++ b/src/pygpsclient/spartn_gnss_frame.py @@ -102,9 +102,7 @@ class SPARTNGNSSDialog(Frame): SPARTNConfigDialog class. """ - def __init__( - self, app, container, *args, **kwargs - ): # pylint: disable=unused-argument + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. @@ -116,9 +114,9 @@ def __init__( self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container # container frame + self.__container = parent # container frame - Frame.__init__(self, self.__container.container, *args, **kwargs) + super().__init__(parent.container, *args, **kwargs) self._img_blank = ImageTk.PhotoImage(Image.open(ICON_BLANK)) self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) diff --git a/src/pygpsclient/spartn_lband_frame.py b/src/pygpsclient/spartn_lband_frame.py index 134779cf..27f1d337 100644 --- a/src/pygpsclient/spartn_lband_frame.py +++ b/src/pygpsclient/spartn_lband_frame.py @@ -130,23 +130,21 @@ class SpartnLbandDialog(Frame): SPARTNConfigDialog class. """ - def __init__( - self, app, container, *args, **kwargs - ): # pylint: disable=unused-argument + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame + :param Frame parent: reference to parent frame :param args: optional args to pass to parent class (not currently used) :param kwargs: optional kwargs to pass to parent class (not currently used) """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container # container frame + self.__container = parent # container frame - Frame.__init__(self, self.__container.container, *args, **kwargs) + super().__init__(parent.container, *args, **kwargs) self._img_blank = ImageTk.PhotoImage(Image.open(ICON_BLANK)) self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) diff --git a/src/pygpsclient/spartn_mqtt_frame.py b/src/pygpsclient/spartn_mqtt_frame.py index 2ebcc79c..4963507e 100644 --- a/src/pygpsclient/spartn_mqtt_frame.py +++ b/src/pygpsclient/spartn_mqtt_frame.py @@ -86,23 +86,21 @@ class SPARTNMQTTDialog(Frame): SPARTNConfigDialog class. """ - def __init__( - self, app, container, *args, **kwargs - ): # pylint: disable=unused-argument + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame + :param Frame parent: reference to parent frame :param args: optional args to pass to parent class (not currently used) :param kwargs: optional kwargs to pass to parent class (not currently used) """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container # container frame + self.__container = parent # container frame - Frame.__init__(self, self.__container.container, *args, **kwargs) + super().__init__(parent.container, *args, **kwargs) self._img_blank = ImageTk.PhotoImage(Image.open(ICON_BLANK)) self._img_pending = ImageTk.PhotoImage(Image.open(ICON_PENDING)) diff --git a/src/pygpsclient/spectrum_frame.py b/src/pygpsclient/spectrum_frame.py index 60196b66..01c11281 100644 --- a/src/pygpsclient/spectrum_frame.py +++ b/src/pygpsclient/spectrum_frame.py @@ -16,11 +16,12 @@ # pylint: disable=no-member, unused-argument import logging -from tkinter import ALL, EW, NSEW, NW, Checkbutton, Frame, IntVar, S, W +from tkinter import ALL, EW, NSEW, NW, Checkbutton, Frame, IntVar, N, S, W +from types import NoneType from pyubx2 import UBXMessage -from pygpsclient.canvas_plot import ( +from pygpsclient.canvas_subclasses import ( TAG_DATA, TAG_GRID, TAG_XLABEL, @@ -45,18 +46,21 @@ MAX_DB = 200 MIN_HZ = 1.1e9 MAX_HZ = 1.70e9 -RF_BANDS = { - "B1": 1575420000, +RF_FREQS = { + # "INM": 1536000000, "B3": 1268520000, - "B2": 1202025000, + "B2I": 1207140000, "B2a": 1176450000, + "B1C": 1575420000, + "B1I": 1561098000, "E6": 1278750000, - "E5b": 1202025000, + "E5b": 1207140000, "E5a": 1176450000, "E1": 1575420000, - "G3": 1202025000, - "G2": 1248060000, - "G1": 1600995000, + "G3": 1207140000, + "G2": 1246000000, + "G1": 1602000000, + "L6": 1278750000, "L5": 1176450000, "L2": 1227600000, "L1": 1575420000, @@ -91,11 +95,12 @@ class SpectrumviewFrame(Frame): Spectrumview frame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -104,7 +109,7 @@ def __init__(self, app, *args, **kwargs): self.__master = self.__app.appmaster # Reference to root class (Tk) self.logger = logging.getLogger(__name__) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 self.width = kwargs.get("width", def_w) @@ -288,7 +293,9 @@ def init_frame(self): tags=TAG_DATA, ) - def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None): + def _update_plot( + self, rfblocks: list, mode: str = MODELIVE, colors: dict | NoneType = None + ): """ Update spectrum plot with live or snapshot rf block data. @@ -307,7 +314,7 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None self.init_frame() # plot frequency bands if self._showrf: - self._plot_rf_bands(mode) + self._plot_RF_FREQS(mode) # for each RF block in MON-SPAN message for i, rfblock in enumerate(specxy): @@ -356,7 +363,7 @@ def _plot_rf_legend(self, col: str, mode: str, rf: int, index: int): - rfw * (index + 1) - (index * self._canvas.fnth) ) - y = self._canvas.yofft * 2 + y = 3 # self._canvas.yofft * 2 x2 = x1 + rfw if mode == MODESNAP: y += self._canvas.fnth @@ -367,42 +374,40 @@ def _plot_rf_legend(self, col: str, mode: str, rf: int, index: int): text=f"RF {rf + 1}", fill=FGCOL, font=self._canvas.font, - anchor=S, + anchor=N, tags=(mode, TAG_XLABEL), ) self._canvas.create_line( x1, - y, + y + self._canvas.fnth, x2, - y, + y + self._canvas.fnth, fill=col, width=OL_WID, tags=(mode, TAG_XLABEL), ) - def _plot_rf_bands(self, mode: str): + def _plot_RF_FREQS(self, mode: str): """ - Plot RF band markers + Plot RF frequency markers :param int mode: plot or snapshot """ - for nam, frq in RF_BANDS.items(): + for nam, frq in RF_FREQS.items(): if self._minhz < frq < self._maxhz: yoff, col = { "L": (self._canvas.fnth, GNSS_LIST[0][1]), # GPS "G": (self._canvas.fnth * 2, GNSS_LIST[6][1]), # GLONASS "E": (self._canvas.fnth * 3, GNSS_LIST[2][1]), # Galileo - "S": (self._canvas.fnth * 3, GNSS_LIST[2][1]), # Galileo SAR "B": (self._canvas.fnth * 4, GNSS_LIST[3][1]), # Beidou + "I": (self._canvas.fnth * 5, "#FF83FA"), # INMARSAT }[nam[0:1]] if nam not in ( - "E1", - "E5a", "E5b", - "B2a", - "B2", - "B1", + "E1", + "B2I", + "B1C", ): # same freq as other bands self._canvas.create_gline( frq / GHZ, diff --git a/src/pygpsclient/status_frame.py b/src/pygpsclient/status_frame.py index 9830d10e..a71d6cc2 100644 --- a/src/pygpsclient/status_frame.py +++ b/src/pygpsclient/status_frame.py @@ -31,28 +31,38 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + + super().__init__(self.__master, *args, **kwargs) self.width, self.height = self.get_size() self._body() - - self.bind("", self._on_resize) + self._do_layout() + self._attach_events() def _body(self): """ Set up frame and widgets. """ - self.grid_rowconfigure(0, weight=1) - - self.option_add("*Font", self.__app.font_md) - self.lbl_connection = Label(self, anchor=W) self.lbl_status = Label(self, anchor=W) + + def _do_layout(self): + """ + Position widgets in frame. + """ + self.lbl_connection.grid(column=0, row=0, sticky=EW) ttk.Separator(self, orient=VERTICAL).grid(column=1, row=0, sticky=NS) self.lbl_status.grid(column=2, row=0, sticky=EW) + def _attach_events(self): + """ + Bind events to frame. + """ + + self.bind("", self._on_resize) + def _on_resize(self, event): # pylint: disable=unused-argument """ Resize frame diff --git a/src/pygpsclient/stream_handler.py b/src/pygpsclient/stream_handler.py index eb4dec2d..385536ed 100644 --- a/src/pygpsclient/stream_handler.py +++ b/src/pygpsclient/stream_handler.py @@ -53,7 +53,6 @@ class to read and parse incoming data from the receiver. It places UBX_PROTOCOL, GNSSError, GNSSReader, - check_pemfile, ) from pynmeagps import NMEAMessageError, NMEAParseError, NMEAStreamError from pyqgc import QGCMessageError, QGCParseError, QGCStreamError @@ -220,9 +219,9 @@ def _read_thread( context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.load_verify_locations(findcacerts()) if selfsign: - pem, _ = check_pemfile() + crt = settings.get("tlscrtpath") # context.verify_mode = ssl.CERT_NONE - context.load_verify_locations(pem) + context.load_verify_locations(crt) context.check_hostname = False stream = context.wrap_socket(stream, server_hostname=server) stream.connect(conn) @@ -263,6 +262,7 @@ def _read_thread( master.event_generate(settings["timeout_event"]) except ( IOError, + FileNotFoundError, SerialException, SerialTimeoutException, OSError, @@ -270,10 +270,15 @@ def _read_thread( gaierror, ) as err: if not stopevent.is_set(): + fnam = ( + settings.get("tlscrtpath") + if isinstance(err, FileNotFoundError) + else "" + ) stopevent.set() master.event_generate(settings["error_event"]) # use after(0) to avoid tkinter main thread contention - status.after(0, status.config, {"text": str(err), "fg": ERRCOL}) + status.after(0, status.config, {"text": f"{err} {fnam}", "fg": ERRCOL}) def _readloop( self, diff --git a/src/pygpsclient/strings.py b/src/pygpsclient/strings.py index 26ecb299..43a9fb42 100644 --- a/src/pygpsclient/strings.py +++ b/src/pygpsclient/strings.py @@ -42,27 +42,29 @@ CONFIRM = "CONFIRM" ENDOFFILE = "End of file reached" FILEOPENERROR = "Error opening file {}" +HALTTAGWARN = "HALTED ON USER TAG MATCH: {}" INACTIVE_TIMEOUT = "Inactivity timeout" KILLSWITCH = "Running threads terminated by user" -LOADCONFIGNK = "Unrecognised configuration setting '{}: {}'" LOADCONFIGBAD = "Configuration not loaded {} {}. Using defaults" +LOADCONFIGNK = "Unrecognised configuration setting '{}: {}'" +LOADCONFIGNONE = "Configuration file not found {}. Using defaults" LOADCONFIGOK = "Configuration loaded {}{}" LOADCONFIGRESAVE = ". Consider re-saving" -LOADCONFIGNONE = "Configuration file not found {}. Using defaults" MAPCONFIGERR = "Custom map configuration error" MAPOPENERR = "Unable to open custom map:\n{}" MQTTCONN = "Connecting to MQTT server {}..." NMEAVALERROR = "Value error in NMEA message: {}" NOCONN = "NO CONNECTION" NOTCONN = "Not connected" +NOWDGSWARN = "WARNING! No widgets are enabled in config file {} - display will be blank" NOWEBMAP = "Unable to display map." NOWEBMAPCONN = NOWEBMAP + "\nCheck internet connection." NOWEBMAPFIX = NOWEBMAP + "\nNo satellite fix." NOWEBMAPHTTP = NOWEBMAP + "\nBad HTTP response: {}.\nCheck MQAPIKEY.\n" NOWEBMAPKEY = NOWEBMAP + f"\nMQAPIKEY not found or invalid.\n\n{MAPAPI_URL}" NULLSEND = "Nothing to send" -OUTOFBOUNDS = "No custom map available for {}" OPENFILEERROR = "ERROR! File could not be opened" +OUTOFBOUNDS = "No custom map available for {}" READTITLE = "Select File" SAVECONFIGBAD = "Configuration not saved {}" SAVECONFIGOK = "Configuration saved OK" @@ -76,8 +78,6 @@ VERCHECK = f"Newer version of {TITLE} available:" WAITNMEADATA = "Waiting for data..." WAITUBXDATA = "Waiting for data..." -NOWDGSWARN = "WARNING! No widgets are enabled in config file {} - display will be blank" -HALTTAGWARN = "HALTED ON USER TAG MATCH: {}" # Menu text MENUABOUT = "About" @@ -101,6 +101,7 @@ # Label text LBLACCURACY = "Accuracy (cm)" +LBLAUTOSCROLL = "Autoscroll" LBLCFGGENERIC = "UBX Legacy Command Configuration" LBLCFGGENERICNMEA = "NMEA Command Configuration" LBLCFGMSG = "CFG-MSG Message Rate Configuration" @@ -109,19 +110,22 @@ LBLCFGRECORD = "CFG Configuration Load/Save/Record" LBLCONFIGBASE = "Configure Base" LBLCTL = "Controls" +LBLDATABASERECORD = "Database" LBLDATADISP = "Format" LBLDATALOG = "Datalog" -LBLDATABASERECORD = "Database" +LBLDATATYPE = "Data Type" LBLDEGFORMAT = "Units" +LBLDISNMEA = "Disable NMEA" LBLDURATIONS = "Duration (s)" +LBLFILEDELAY = "File Read Delay" LBLGGAFIXED = "Fixed Reference" LBLGGALIVE = "Receiver" LBLJSONLOAD = "Load Keys From JSON" LBLLANIP = "LAN IP" -LBLSHOWTRACK = "Track" +LBLNMEACONFIG = "NMEA\nConfig" +LBLNMEAPRESET = "Preset NMEA Configuration Commands" LBLNODATA = "No data available" -LBLNMEACONFIG = "NMEA Config" -LBLNTRIPCONFIG = "NTRIP Client" +LBLNTRIPCONFIG = "NTRIP\nClient" LBLNTRIPGGAINT = "GGA Interval s" LBLNTRIPMOUNT = "Mountpoint" LBLNTRIPPORT = "Port" @@ -129,21 +133,21 @@ LBLNTRIPSERVER = "Server" LBLNTRIPSTR = "Sourcetable" LBLNTRIPUSER = "User" -LBLNTRIPVERSION = "Version" -LBLNMEAPRESET = "Preset NMEA Configuration Commands" -LBLUBXPRESET = "Preset UBX Configuration Commands" +LBLNTRIPVERSION = "NTRIP Version" LBLPROTDISP = "Protocols" LBLPUBLICIP = "Public IP" +LBLSERVERCONFIG = "Server\nConfig" LBLSERVERHOST = "Host IP" LBLSERVERMODE = "Mode" LBLSERVERPORT = "Port" LBLSET = "Settings" -LBLSHOWUNUSED = "Include C/No = 0" +LBLSHOWTRACK = "Track" LBLSOCKSERVE = "Socket Server /\nNTRIP Caster " # padded to align LBLSPARTNCONFIG = "SPARTN Client" LBLSPARTNGN = "GNSS RECEIVER CONFIGURATION (F9*)" LBLSPARTNIP = "IP CORRECTION CONFIGURATION (MQTT)" LBLSPARTNLB = "L-BAND CORRECTION CONFIGURATION (D9*)" +LBLSPORT = "SAVED PORT" LBLSPTNCURR = "CURRENT SPARTN KEY:" LBLSPTNDAT = "Valid from YYYYMMDD" LBLSPTNFP = "Configure receiver" @@ -152,16 +156,16 @@ LBLSPTNUPLOAD = "Upload keys" LBLSTREAM = "Stream\nfrom file" LBLTRACKRECORD = "GPX Track" -LBLTTYCONFIG = "TTY Config" -LBLUBXCONFIG = "UBX Config" +LBLTTYCONFIG = "TTY\nConfig" +LBLUBXCONFIG = "UBX\nConfig" +LBLUBXPRESET = "Preset UBX Configuration Commands" LBLUDPORT = "USER-DEFINED PORT" -LBLSPORT = "SAVED PORT" -LBLDISNMEA = "Disable NMEA" # Dialog text DLG = "dlg" DLGENABLEMONSPAN = "Enable or poll MON-SPAN message" DLGENABLEMONSYS = "Enable or poll MON-SYS/COMMS messages" +DLGENABLENAVSIG = "Enable or poll NAV-SIG message" DLGGPXERROR = "GPX Parsing Error!" DLGGPXOPEN = "Click folder icon to open GPX Track file" DLGGPXLOAD = "Loading GPX file ..." @@ -175,19 +179,23 @@ DLGJSONOK = "Keys loaded from {}" DLGNOMONSPAN = "This receiver does not appear to\nsupport MON-SPAN messages" DLGNOMONSYS = "This receiver does not appear to\nsupport MON-SYS/COMMS messages" +DLGNONAVSIG = "This receiver does not appear to\nsupport NAV-SIG messages" DLGACTION = "Confirm Command" DLGACTIONCONFIRM = "Are you sure?" DLGSPARTNWARN = "WARNING! Disconnect from {} client before using {} client" DLGWAITMONSPAN = "Waiting for MON-SPAN message..." DLGWAITMONSYS = "Waiting for MON-SYS/COMMS messages..." +DLGWAITNAVSIG = "Waiting for NAV-SIG message..." DLGSTOPRTK = "WARNING! Stop all active connections before loading configuration" DLGTABOUT = f"About {TITLE}" DLGTGPX = "GPX Track Viewer" DLGTNTRIP = "NTRIP Configuration" +DLGTSERVER = "Server Configuration" +DLGTSETTINGS = "Settings" DLGTRECORD = "Configuration Command Recorder" DLGTSPARTN = "SPARTN Configuration" DLGTNMEA = "NMEA Configuration" DLGTUBX = "UBX Configuration" DLGTIMPORTMAP = "Import Custom Map" DLGTTTY = "TTY Commands" -DLGNOTLS = "TLS certificate '{hostpem}' not found" +DLGNOTLS = "TLS PEM file '{hostpem}' not found" diff --git a/src/pygpsclient/sysmon_frame.py b/src/pygpsclient/sysmon_frame.py index 6a725724..c351de75 100644 --- a/src/pygpsclient/sysmon_frame.py +++ b/src/pygpsclient/sysmon_frame.py @@ -53,11 +53,12 @@ class SysmonFrame(Frame): SysmonFrame class. """ - def __init__(self, app, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application + :param Frame parent: reference to parent frame :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -65,7 +66,7 @@ def __init__(self, app, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - Frame.__init__(self, self.__master, *args, **kwargs) + super().__init__(parent, *args, **kwargs) def_w, def_h = WIDGETU2 self.width = kwargs.get("width", def_w) diff --git a/src/pygpsclient/toplevel_dialog.py b/src/pygpsclient/toplevel_dialog.py index f4f25285..a370769b 100644 --- a/src/pygpsclient/toplevel_dialog.py +++ b/src/pygpsclient/toplevel_dialog.py @@ -2,8 +2,14 @@ toplevel_dialog.py Top Level container dialog which displays child frames -within a scrollable and resizeable canvas, primarily -to allow dialog to be usable on low resolution screens. +depending on resize behaviour and available screen resolution: + +- If frame is resizeable (defined in dialog_state), display as such. +- If frame is non-resizeable and child frame dimensions exceed screen + dimensions, frame will be embedded as a window inside a scrollable + and resizeable canvas (can_container). +- Otherwise, frame will be displayed as a fixed, non-resizeable + dialog (frm_container). Created on 19 Sep 2020 @@ -14,25 +20,19 @@ import logging from tkinter import ( - ALL, EW, - HORIZONTAL, - NS, NSEW, - NW, - VERTICAL, Button, - Canvas, E, Frame, Label, - Scrollbar, Toplevel, W, ) from PIL import Image, ImageTk +from pygpsclient.canvas_subclasses import CanvasContainer from pygpsclient.globals import ( APPNAME, ERRCOL, @@ -49,8 +49,6 @@ ICON_START, ICON_WARNING, INFOCOL, - MINHEIGHT, - MINWIDTH, RESIZE, ) from pygpsclient.helpers import check_lowres @@ -62,30 +60,32 @@ class ToplevelDialog(Toplevel): ToplevelDialog class. """ - def __init__(self, app, dlgname: str, dim: tuple = (MINHEIGHT, MINWIDTH)): + def __init__(self, app, dlgname: str, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application :param str dlgname: dialog name - :param tuple dim: initial dimensions (height, width) """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) self._dlgname = dlgname self.logger = logging.getLogger(f"{APPNAME}.{dlgname}") - self.lowres, (self.height, self.width) = check_lowres(self.__master, dim) + self.width, self.height = 300, 300 # initial, updated in finalise() + self._resizable = kwargs.pop( + "resizable", self.__app.dialog_state.state[self._dlgname].get(RESIZE, False) + ) + transient = kwargs.pop( + "transient", self.__app.configuration.get("transient_dialog_b") + ) - super().__init__() + super().__init__(self.__master, *args, **kwargs) - if self.__app.configuration.get("transient_dialog_b"): + if transient: self.transient(self.__app) self.title(dlgname) # pylint: disable=E1102 - if self.__app.dialog_state.state[dlgname][RESIZE]: - self.resizable(True, True) - else: - self.resizable(self.lowres, self.lowres) + self.resizable(self._resizable, self._resizable) self.protocol("WM_DELETE_WINDOW", self.on_exit) self.img_none = ImageTk.PhotoImage(Image.open(ICON_BLANK)) self.img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) @@ -100,52 +100,22 @@ def __init__(self, app, dlgname: str, dim: tuple = (MINHEIGHT, MINWIDTH)): self.img_start = ImageTk.PhotoImage(Image.open(ICON_START)) self.img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) - self._con_body() + self._con_body(self._resizable) - def on_expand(self): - """ - Automatically expand container canvas when sub-frames are resized. - """ - - self._can_container.event_generate("") - - def _con_body(self): + def _con_body(self, resizable: bool): """ Set up scrollable frame and widgets. + + :param bool resizable: resizable """ - # create container frame - if self.lowres: - x_scrollbar = Scrollbar(self, orient=HORIZONTAL) - y_scrollbar = Scrollbar(self, orient=VERTICAL) - self._can_container = Canvas( - self, - width=self.width, - height=self.height, - xscrollcommand=x_scrollbar.set, - yscrollcommand=y_scrollbar.set, - ) - self._frm_container = Frame( - self._can_container, borderwidth=2, relief="groove" - ) - self._can_container.grid(column=0, row=0, sticky=NSEW) - x_scrollbar.grid(column=0, row=1, sticky=EW) - y_scrollbar.grid(column=1, row=0, sticky=NS) - x_scrollbar.config(command=self._can_container.xview) - y_scrollbar.config(command=self._can_container.yview) - # ensure container canvas expands to accommodate child frames - self._can_container.create_window( - (0, 0), window=self._frm_container, anchor=NW - ) - self._can_container.bind( - "", - lambda e: self._can_container.config( - scrollregion=self._can_container.bbox(ALL) - ), - ) - else: # normal resolution - self._frm_container = Frame(self, borderwidth=2, relief="groove") + # create container frame for non-resizeable dialogs + if resizable: + self._frm_container = Frame(self) self._frm_container.grid(column=0, row=0, sticky=NSEW) + else: + self._can_container = CanvasContainer(self.__app, self) + self._frm_container = self._can_container.frm_container # create status frame self._frm_status = Frame(self, borderwidth=2, relief="groove") @@ -153,34 +123,53 @@ def _con_body(self): self._btn_exit = Button( self._frm_status, image=self.img_exit, - width=50, + width=45, fg=ERRCOL, command=self.on_exit, ) self._frm_status.grid(column=0, row=2, sticky=EW) self._lbl_status.grid(column=0, row=0, sticky=EW) - self._btn_exit.grid(column=1, row=0, sticky=E) + self._btn_exit.grid(column=1, row=0, padx=4, sticky=E) # set column and row weights # NB!!! these govern the 'pack' behaviour of the frames on resize - self.grid_columnconfigure(0, weight=10) - self.grid_rowconfigure(0, weight=10) - self._frm_status.grid_columnconfigure(0, weight=10) - if self.lowres: - colsp, rowsp = self._can_container.grid_size() - else: - colsp, rowsp = self._frm_container.grid_size() + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self._frm_status.grid_columnconfigure(0, weight=1) + colsp, rowsp = self._frm_container.grid_size() for i in range(colsp): - self._frm_status.grid_columnconfigure(i, weight=10) + self._frm_status.grid_columnconfigure(i, weight=1) for i in range(rowsp): - self._frm_status.grid_rowconfigure(i, weight=10) + self._frm_status.grid_rowconfigure(i, weight=1) def _finalise(self): """ - Finalise Toplevel window after child frames have been created. + Finalise Toplevel dialog after child frames have been created. + + If screen is smaller than dialog (`lowres=True`), display within + smaller, resizeable and scrollable canvas container. + + Otherwise, make the container the same size as the dialog and hide + the scrollbars. + + NB Some Linux platforms appear to require Toplevel dialog windows + to be non-transient for the window 'maximise' icon to work properly """ - # self.status_label = (f"{self.height}, {self.width}") # testing only + if hasattr(self, "_can_container"): + self._frm_container.update_idletasks() + fh = self._frm_container.winfo_height() + fw = self._frm_container.winfo_width() + lowres, (sh, sw) = check_lowres(self.__master, (fh, fw)) + if lowres: + self._can_container.config( + height=min(int(sh * 0.75), fh), width=min(int(sw * 0.75), fw) + ) + self.resizable(True, True) + else: + self._can_container.config(height=fh, width=fw) + self._can_container.show_scroll(False) + self.resizable(self._resizable, self._resizable) def on_exit(self, *args, **kwargs): # pylint: disable=unused-argument """ @@ -190,6 +179,14 @@ def on_exit(self, *args, **kwargs): # pylint: disable=unused-argument self.__app.dialog_state.state[self._dlgname][DLG] = None self.destroy() + def on_expand(self): + """ + Automatically expand container canvas when sub-frames are resized. + """ + + if hasattr(self, "_can_container"): + self._can_container.event_generate("") + def _on_resize(self, event): # pylint: disable=unused-argument """ Resize frame. @@ -199,12 +196,12 @@ def _on_resize(self, event): # pylint: disable=unused-argument self.width, self.height = self.get_size() - def get_size(self): + def get_size(self) -> tuple[int, int]: """ Get current frame size. :return: window size (width, height) - :rtype: tuple + :rtype: tuple[int,int] """ self.__master.update_idletasks() # Make sure we know about any resizing diff --git a/src/pygpsclient/tty_preset_dialog.py b/src/pygpsclient/tty_preset_dialog.py index 1f26d60e..c188bfe4 100644 --- a/src/pygpsclient/tty_preset_dialog.py +++ b/src/pygpsclient/tty_preset_dialog.py @@ -58,7 +58,6 @@ CANCELLED = 0 CONFIRMED = 1 NOMINAL = 2 -MINDIM = (610, 500) class TTYPresetDialog(ToplevelDialog): @@ -77,7 +76,7 @@ def __init__(self, app, **kwargs): # pylint: disable=unused-argument self.__app = app self.__master = self.__app.appmaster # Reference to root class (Tk) - super().__init__(app, DLGTTTY, MINDIM) + super().__init__(app, DLGTTTY) self._confirm = False self._command = StringVar() self._crlf = IntVar() @@ -94,7 +93,7 @@ def _body(self): Set up frame and widgets. """ - self._frm_body = Frame(self.container, borderwidth=2, relief="groove") + self._frm_body = Frame(self.container) self._lbl_command = Label( self._frm_body, text="Command", diff --git a/src/pygpsclient/ubx_cfgval_frame.py b/src/pygpsclient/ubx_cfgval_frame.py index 7a4059ba..3e5c1ee4 100644 --- a/src/pygpsclient/ubx_cfgval_frame.py +++ b/src/pygpsclient/ubx_cfgval_frame.py @@ -31,6 +31,7 @@ Scrollbar, Spinbox, StringVar, + TclError, W, ) @@ -73,21 +74,21 @@ class UBX_CFGVAL_Frame(Frame): UBX CFG-VAL configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container + self.__container = parent - super().__init__(container.container, *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)) @@ -298,13 +299,16 @@ def _on_select_parm(self, *args, **kwargs): # pylint: disable=unused-argument Configuration parameter (keyname) has been selected. """ - idx = self._lbx_parm.curselection() - self._cfgval_keyname = self._lbx_parm.get(idx) - - (keyid, att) = cfgname2key(self._cfgval_keyname) - self._cfgkeyid.set(hex(keyid)) - self._cfgatt.set(att) - self._cfgval.set("") + try: + idx = self._lbx_parm.curselection() + self._cfgval_keyname = self._lbx_parm.get(idx) + + (keyid, att) = cfgname2key(self._cfgval_keyname) + self._cfgkeyid.set(hex(keyid)) + self._cfgatt.set(att) + self._cfgval.set("") + except TclError: + pass def _on_send_config(self, *args, **kwargs): # pylint: disable=unused-argument """ diff --git a/src/pygpsclient/ubx_config_dialog.py b/src/pygpsclient/ubx_config_dialog.py index 8e06ea34..1dfd94af 100644 --- a/src/pygpsclient/ubx_config_dialog.py +++ b/src/pygpsclient/ubx_config_dialog.py @@ -53,8 +53,6 @@ from pygpsclient.ubx_preset_frame import UBX_PRESET_Frame from pygpsclient.ubx_solrate_frame import UBX_RATE_Frame -MINDIM = (570, 1076) - class UBXConfigDialog(ToplevelDialog): """, @@ -72,7 +70,7 @@ def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument self.__app = app # Reference to main application class - super().__init__(app, DLGTUBX, MINDIM) + super().__init__(app, DLGTUBX) self._cfg_msg_command = None self._pending_confs = {} diff --git a/src/pygpsclient/ubx_handler.py b/src/pygpsclient/ubx_handler.py index 792438d9..1e29747d 100644 --- a/src/pygpsclient/ubx_handler.py +++ b/src/pygpsclient/ubx_handler.py @@ -22,8 +22,8 @@ from pygpsclient.globals import GLONASS_NMEA, UTF8 from pygpsclient.helpers import corrage2int, fix2desc, ned2vector, svid2gnssid -from pygpsclient.strings import DLGTSPARTN, DLGTUBX, NA -from pygpsclient.widget_state import VISIBLE, WDGSPECTRUM, WDGSYSMON +from pygpsclient.strings import DLGTSERVER, DLGTSPARTN, DLGTUBX, NA +from pygpsclient.widget_state import VISIBLE, WDGSIGNALS, WDGSPECTRUM, WDGSYSMON class UBXHandler: @@ -79,6 +79,8 @@ def process_data(self, raw_data: bytes, parsed_data: object): self._process_NAV_RELPOSNED(parsed_data) elif parsed_data.identity in ("NAV-SAT", "NAV2-SAT"): self._process_NAV_SAT(parsed_data) + elif parsed_data.identity in ("NAV-SIG", "NAV2-SIG"): + self._process_NAV_SIG(parsed_data) elif parsed_data.identity in ("NAV-STATUS", "NAV2-STATUS"): self._process_NAV_STATUS(parsed_data) elif parsed_data.identity == "NAV-SVIN": @@ -118,10 +120,10 @@ def _process_ACK(self, msg: UBXMessage): if self.__app.dialog(DLGTSPARTN) is not None: self.__app.dialog(DLGTSPARTN).update_pending(msg) - # if Spectrumview or Sysmon widgets are active, send ACKSs there + # if Spectrumview, Sysmon or Signals widgets are active, send ACKSs there if msg.identity in ("ACK-ACK", "ACK-NAK"): wdgs = self.__app.widget_state.state - for wdg in (WDGSYSMON, WDGSPECTRUM): + for wdg in (WDGSYSMON, WDGSPECTRUM, WDGSIGNALS): if wdgs[wdg][VISIBLE]: if msg.clsID == 6 and msg.msgID == 1: # CFG-MSG getattr(self.__app, wdgs[wdg]["frm"]).update_pending(msg) @@ -347,6 +349,47 @@ def _process_NAV_SAT(self, data: UBXMessage): self.__app.gnss_status.siv = len(self.__app.gnss_status.gsv_data) + def _process_NAV_SIG(self, data: UBXMessage): + """ + Process NAV-SIG sentences - Signal Information. + + NB: For consistency with NMEA GSV and UBX NAV-SVINFO message types, + this uses the NMEA SVID numbering range for GLONASS satellites + (65 - 96) rather than the Slot ID (1-24) by default. + To change this, set the GLONASS_NMEA flag in globals.py to False. + + :param UBXMessage data: NAV-SIG parsed message + """ + + self.__app.gnss_status.sig_data = {} + num_sig = int(data.numSigs) + now = time() + + for i in range(num_sig): + idx = f"_{i+1:02d}" + gnssId = getattr(data, "gnssId" + idx) + svid = getattr(data, "svId" + idx) + sigid = getattr(data, "sigId" + idx) + # use NMEA GLONASS numbering (65-96) rather than slotID (1-24) + if gnssId == 6 and svid < 25 and svid != 255 and GLONASS_NMEA: + svid += 64 + cno = getattr(data, "cno" + idx) + corrsource = getattr(data, "corrSource" + idx) + quality = getattr(data, "qualityInd" + idx) + sigflags = 0 + self.__app.gnss_status.sig_data[(gnssId, svid, sigid)] = ( + gnssId, + svid, + sigid, + cno, + corrsource, + quality, + sigflags, + now, + ) + + # print(f"DEBUG {self.__app.gnss_status.sig_data=}") + def _process_NAV_STATUS(self, data: UBXMessage): """ Process NAV-STATUS sentences - Status Information. @@ -366,8 +409,8 @@ def _process_NAV_SVIN(self, data: UBXMessage): :param UBXMessage data: NAV-SVIN parsed message """ - if self.__app.frm_settings.frm_socketserver is not None: - self.__app.frm_settings.frm_socketserver.svin_countdown( + if self.__app.dialog(DLGTSERVER) is not None: + self.__app.dialog(DLGTSERVER).svin_countdown( data.dur, data.valid, data.active ) diff --git a/src/pygpsclient/ubx_msgrate_frame.py b/src/pygpsclient/ubx_msgrate_frame.py index e9aec77b..d66d0cda 100644 --- a/src/pygpsclient/ubx_msgrate_frame.py +++ b/src/pygpsclient/ubx_msgrate_frame.py @@ -51,21 +51,21 @@ class UBX_MSGRATE_Frame(Frame): UBX Message Rate configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container + self.__container = parent - super().__init__(container.container, *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)) diff --git a/src/pygpsclient/ubx_port_frame.py b/src/pygpsclient/ubx_port_frame.py index 314c0037..be3781b9 100644 --- a/src/pygpsclient/ubx_port_frame.py +++ b/src/pygpsclient/ubx_port_frame.py @@ -47,21 +47,21 @@ class UBX_PORT_Frame(Frame): UBX Port and Protocol configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container + self.__container = parent - super().__init__(container.container, *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)) diff --git a/src/pygpsclient/ubx_preset_frame.py b/src/pygpsclient/ubx_preset_frame.py index 673cf710..2dadbfda 100644 --- a/src/pygpsclient/ubx_preset_frame.py +++ b/src/pygpsclient/ubx_preset_frame.py @@ -57,12 +57,12 @@ class UBX_PRESET_Frame(Frame): UBX Preset and User-defined configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ @@ -70,9 +70,9 @@ def __init__(self, app, container, *args, **kwargs): self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) self.logger = logging.getLogger(__name__) - self.__container = container + self.__container = parent - super().__init__(container.container, *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)) diff --git a/src/pygpsclient/ubx_solrate_frame.py b/src/pygpsclient/ubx_solrate_frame.py index 070fb4a8..cf6663cc 100644 --- a/src/pygpsclient/ubx_solrate_frame.py +++ b/src/pygpsclient/ubx_solrate_frame.py @@ -42,21 +42,21 @@ class UBX_RATE_Frame(Frame): UBX Navigation Solution Rate configuration command panel. """ - def __init__(self, app, container, *args, **kwargs): + def __init__(self, app: Frame, parent: Frame, *args, **kwargs): """ Constructor. :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) + :param Frame parent: reference to parent frame (config-dialog) :param args: optional args to pass to Frame parent class :param kwargs: optional kwargs to pass to Frame parent class """ self.__app = app # Reference to main application class self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container + self.__container = parent - super().__init__(container.container, *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)) diff --git a/src/pygpsclient/widget_state.py b/src/pygpsclient/widget_state.py index 2d3ce23e..65ec6f54 100644 --- a/src/pygpsclient/widget_state.py +++ b/src/pygpsclient/widget_state.py @@ -22,9 +22,6 @@ class definition and update `ubx_handler` to populate them. :license: BSD 3-Clause """ -from tkinter import NSEW, E, S, W - -from pygpsclient.banner_frame import BannerFrame from pygpsclient.chart_frame import ChartviewFrame from pygpsclient.console_frame import ConsoleFrame from pygpsclient.globals import CLASS, FRAME @@ -33,39 +30,31 @@ class definition and update `ubx_handler` to populate them. from pygpsclient.map_frame import MapviewFrame from pygpsclient.rover_frame import RoverFrame from pygpsclient.scatter_frame import ScatterViewFrame -from pygpsclient.settings_frame import SettingsFrame +from pygpsclient.signalsview_frame import SignalsviewFrame from pygpsclient.skyview_frame import SkyviewFrame from pygpsclient.spectrum_frame import SpectrumviewFrame -from pygpsclient.status_frame import StatusFrame from pygpsclient.sysmon_frame import SysmonFrame -COL = "COL" COLSPAN = "colspan" DEFAULT = "def" HIDE = "Hide" MAXCOLSPAN = 4 # max no of widget columns -MAXCOLS = 999 # always occupy the full row +MAXSPAN = 0 # always occupy the full row MAXROWSPAN = 4 # max no of widget rows -MENU = "men" RESET = "rst" -ROW = "row" -ROWSPAN = "rowspan" SHOW = "Show" -STICKY = "sty" VISIBLE = "vis" -WDGBANNER = "Banner" WDGCONSOLE = "Console" WDGLEVELS = "Levels" WDGMAP = "Map" WDGROVER = "Rover Plot" WDGSATS = "Satellites" WDGSCATTER = "Scatter Plot" -WDGSETTINGS = "Settings" WDGSPECTRUM = "Spectrum" -WDGSTATUS = "Status" WDGSYSMON = "System Monitor" WDGCHART = "Chart Plot" WDGIMUMON = "IMU Monitor" +WDGSIGNALS = "Signals" class WidgetState: @@ -79,47 +68,12 @@ def __init__(self): """ self.state = { - # these widgets have fixed positions - WDGBANNER: { # always on top - DEFAULT: True, - MENU: False, - CLASS: BannerFrame, - FRAME: "frm_banner", - VISIBLE: True, - STICKY: NSEW, - COL: 0, - ROW: 0, - COLSPAN: 6, - }, - WDGSETTINGS: { # always on right - DEFAULT: True, - CLASS: SettingsFrame, - FRAME: "frm_settings", - VISIBLE: True, - STICKY: NSEW, - COL: 5, - ROW: 1, - ROWSPAN: 4, - }, - WDGSTATUS: { # always on bottom - DEFAULT: True, - MENU: False, - CLASS: StatusFrame, - FRAME: "frm_status", - VISIBLE: True, - STICKY: (S, W, E), - COL: 0, - ROW: 5, - COLSPAN: 6, - }, - # these widgets rearrange dynamically according to - # which has been selected to be visible WDGCONSOLE: { DEFAULT: True, CLASS: ConsoleFrame, FRAME: "frm_console", VISIBLE: True, - COLSPAN: MAXCOLS, + COLSPAN: MAXSPAN, }, WDGSATS: { DEFAULT: True, @@ -133,6 +87,12 @@ def __init__(self): FRAME: "frm_levelsview", VISIBLE: True, }, + WDGSIGNALS: { + CLASS: SignalsviewFrame, + FRAME: "frm_signalsview", + VISIBLE: False, + COLSPAN: 2, + }, WDGMAP: { DEFAULT: True, CLASS: MapviewFrame, diff --git a/tests/test_static.py b/tests/test_static.py index d1fc7f34..027854a5 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -86,7 +86,6 @@ from pygpsclient.widget_state import ( DEFAULT, FRAME, - MENU, VISIBLE, WidgetState, ) @@ -132,7 +131,7 @@ def testconfiguration(self): self.assertEqual(cfg.get("lbandclientdrat_n"), 2400) self.assertEqual(cfg.get("userport_s"), "") self.assertEqual(cfg.get("spartnport_s"), "") - self.assertEqual(len(cfg.settings), 152) + self.assertEqual(len(cfg.settings), 154) # 155) kwargs = {"userport": "/dev/ttyACM0", "spartnport": "/dev/ttyACM1"} cfg.loadcli(**kwargs) self.assertEqual(cfg.get("userport_s"), "/dev/ttyACM0") @@ -504,7 +503,6 @@ def testwidgetgrid(self): # ensure widgets.py is correctly defined NoneType = type(None) for wdg, wdict in app.widget_state.state.items(): self.assertIsInstance(wdg, str), - self.assertIsInstance(wdict.get(MENU, True), bool), self.assertIsInstance(wdict.get(DEFAULT, False), bool), self.assertIsInstance(wdict[FRAME], str), self.assertEqual(wdict["frm"][0:4], "frm_"), @@ -536,20 +534,21 @@ def testgetmpinfo(self): # test get_mp_info "identifier": "Curug", "format": "RTCM 3.2", "messages": "1005(31),1074(1),1084(1),1094(1)", - "carrier": "L1,L2", + "carrier": "2", "navs": "GPS+GLO+GAL", "network": "SNIP", "country": "SRB", "lat": "45.47", "lon": "20.06", - "gga": "GGA", - "solution": "Single", + "gga": "1", + "solution": "0", "generator": "sNTRIP", "encrypt": "none", - "auth": "Basic", + "auth": "B", "fee": "N", "bitrate": "3120", } + EXPECTED_RESULT2 = {"name": "N/A", "gga": 0} srt = ( "ACACU", "Curug", @@ -573,7 +572,7 @@ def testgetmpinfo(self): # test get_mp_info res = get_mp_info(srt) self.assertEqual(res, EXPECTED_RESULT) res = get_mp_info([]) - self.assertEqual(res, None) + self.assertEqual(res, EXPECTED_RESULT2) def testpublicip(self): res = publicip() @@ -916,13 +915,32 @@ def testtrackbounds(self): self.assertAlmostEqual(center.lon, -1.815437, 7) def testunusedsats(self): - gsv_data = {(1,1): (0,0,0,0,5,0),(1,2): (0,0,0,0,7,0),(2,1): (0,0,0,0,0,0),(2,2): (0,0,0,0,1,0),(3,1): (0,0,0,0,5,0)} + gsv_data = { + (1, 1): (0, 0, 0, 0, 5, 0), + (1, 2): (0, 0, 0, 0, 7, 0), + (2, 1): (0, 0, 0, 0, 0, 0), + (2, 2): (0, 0, 0, 0, 1, 0), + (3, 1): (0, 0, 0, 0, 5, 0), + } self.assertEqual(unused_sats(gsv_data), 1) - gsv_data = {(1,1): (0,0,0,0,5,0),(1,2): (0,0,0,0,7,0),(2,1): (0,0,0,0,0,0),(2,2): (0,0,0,0,1,0),(3,1): (0,0,0,0,0,0)} + gsv_data = { + (1, 1): (0, 0, 0, 0, 5, 0), + (1, 2): (0, 0, 0, 0, 7, 0), + (2, 1): (0, 0, 0, 0, 0, 0), + (2, 2): (0, 0, 0, 0, 1, 0), + (3, 1): (0, 0, 0, 0, 0, 0), + } self.assertEqual(unused_sats(gsv_data), 2) - gsv_data = {(1,1): (0,0,0,0,5,0),(1,2): (0,0,0,0,7,0),(2,1): (0,0,0,0,1,0),(2,2): (0,0,0,0,1,0),(3,1): (0,0,0,0,4,0)} + gsv_data = { + (1, 1): (0, 0, 0, 0, 5, 0), + (1, 2): (0, 0, 0, 0, 7, 0), + (2, 1): (0, 0, 0, 0, 1, 0), + (2, 2): (0, 0, 0, 0, 1, 0), + (3, 1): (0, 0, 0, 0, 4, 0), + } self.assertEqual(unused_sats(gsv_data), 0) + if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName'] unittest.main()