diff --git a/README.md b/README.md index 0c33f4ec..41c5e5de 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,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 +128,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,6 +135,8 @@ 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. @@ -162,7 +162,8 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. |![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 +258,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 @@ -318,7 +326,7 @@ By default, the server/caster binds to the host address '0.0.0.0' (IPv4) or '::' 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 +415,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 +427,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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0b335799..1a3eebb0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,13 @@ # PyGPSClient Release Notes +### RELEASE 1.6.0 + +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 standard Levels widget. 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. 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. NTRIP Caster / Socket Server Configuration is now a separate Toplevel dialog panel, accessed through Server Config button or Menu Option 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 SpectrumView widget and Settings panel. + ### RELEASE 1.5.23 FIXES: diff --git a/docs/pygpsclient.rst b/docs/pygpsclient.rst index a1b5c8a3..fd8231db 100644 --- a/docs/pygpsclient.rst +++ b/docs/pygpsclient.rst @@ -300,10 +300,10 @@ pygpsclient.serialconfig\_lband\_frame module :undoc-members: :show-inheritance: -pygpsclient.serverconfig\_frame module --------------------------------------- +pygpsclient.serverconfig\_dialog module +--------------------------------------- -.. automodule:: pygpsclient.serverconfig_frame +.. automodule:: pygpsclient.serverconfig_dialog :members: :undoc-members: :show-inheritance: @@ -316,6 +316,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/images/app.png b/images/app.png index b40123df..355a7f99 100644 Binary files a/images/app.png and b/images/app.png differ diff --git a/images/recorder_dialog.png b/images/recorder_dialog.png index aa058e4f..45fd5f9f 100644 Binary files a/images/recorder_dialog.png and b/images/recorder_dialog.png differ diff --git a/images/signalsview_widget.png b/images/signalsview_widget.png new file mode 100644 index 00000000..009976d7 Binary files /dev/null and b/images/signalsview_widget.png differ diff --git a/pyproject.toml b/pyproject.toml index 772fbbeb..ec601ae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,12 +129,13 @@ disable = """ too-many-public-methods, too-many-locals, invalid-name, - logging-fstring-interpolation + logging-fstring-interpolation, + fixme, """ [tool.pytest.ini_options] minversion = "7.0" -addopts = "--cov --cov-report html --cov-fail-under 18" +addopts = "--cov --cov-report html --cov-fail-under 17" pythonpath = ["src"] testpaths = ["tests"] @@ -145,7 +146,7 @@ source = ["src"] source = ["src"] [tool.coverage.report] -fail_under = 18 +fail_under = 17 [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/app.py b/src/pygpsclient/app.py index 4df2a2a3..b606a2be 100644 --- a/src/pygpsclient/app.py +++ b/src/pygpsclient/app.py @@ -176,6 +176,7 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements 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 @@ -465,7 +466,6 @@ def load_config(self): self.frm_settings, self.frm_settings.frm_serial, self.frm_settings.frm_socketclient, - self.frm_settings.frm_socketserver, ): frm.reset() self._do_layout() @@ -560,6 +560,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)" # TODO self._socket_thread = Thread( target=self._sockserver_thread, args=( @@ -567,6 +569,8 @@ def sockserver_start(self): host, port, https, + tlspempath, + ntriprtcmstr, ntripuser, ntrippassword, SOCKSERVER_MAX_CLIENTS, @@ -575,16 +579,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 +596,8 @@ def _sockserver_thread( host: str, port: int, https: int, + tlspempath: str, + ntriprtcmstr: str, ntripuser: str, ntrippassword: str, maxclients: int, @@ -605,6 +611,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 +628,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 +643,8 @@ def update_clients(self, clients: int): :param int clients: no of connected clients """ - self.frm_settings.frm_socketserver.clients = clients + self.server_status = clients + # self.frm_settings.frm_socketserver.clients = clients TODO def _shutdown(self): """ @@ -684,9 +695,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: # TODO + self.socket_outqueue.put(raw_data) self.gnss_inqueue.task_done() except Empty: pass @@ -699,9 +710,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 +723,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) @@ -1078,6 +1085,30 @@ def conn_status(self, status: int): 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..86e2a1c3 100644 --- a/src/pygpsclient/banner_frame.py +++ b/src/pygpsclient/banner_frame.py @@ -170,7 +170,7 @@ def _body(self): ) self._lbl_ldgps = Label( self._frm_advanced2, - text="dgps:", + text="corr:", bg=self._bgcol, fg=self._fgcol, anchor=N, @@ -186,6 +186,9 @@ def _body(self): self._lbl_transmit_preset = Label( self._frm_connect, bg=self._bgcol, image=self._img_blank ) + self._lbl_clients = Label( + self._frm_connect, bg=self._bgcol, fg="green", width=2, anchor=W + ) self._lbl_time = Label( self._frm_basic, bg=self._bgcol, fg="cyan", width=15, anchor=W @@ -252,7 +255,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) @@ -358,10 +362,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): """ diff --git a/src/pygpsclient/canvas_plot.py b/src/pygpsclient/canvas_plot.py index 7f608bb0..bac819c4 100644 --- a/src/pygpsclient/canvas_plot.py +++ b/src/pygpsclient/canvas_plot.py @@ -69,6 +69,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 +78,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 +142,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/configuration.py b/src/pygpsclient/configuration.py index dfe078e3..5ed8e4af 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 @@ -139,6 +145,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, @@ -346,6 +354,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/dialog_state.py b/src/pygpsclient/dialog_state.py index ce863981..674e4571 100644 --- a/src/pygpsclient/dialog_state.py +++ b/src/pygpsclient/dialog_state.py @@ -23,6 +23,7 @@ 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.spartn_dialog import SPARTNConfigDialog from pygpsclient.strings import ( DLG, @@ -32,6 +33,7 @@ DLGTNMEA, DLGTNTRIP, DLGTRECORD, + DLGTSERVER, DLGTSPARTN, DLGTTTY, DLGTUBX, @@ -71,6 +73,11 @@ def __init__(self): DLG: None, RESIZE: False, }, + DLGTSERVER: { + CLASS: ServerConfigDialog, + DLG: None, + RESIZE: False, + }, DLGTSPARTN: { CLASS: SPARTNConfigDialog, DLG: None, diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py index 1f44abcd..6fdd3a29 100644 --- a/src/pygpsclient/globals.py +++ b/src/pygpsclient/globals.py @@ -170,6 +170,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") @@ -335,6 +336,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/helpers.py b/src/pygpsclient/helpers.py index 70f0733f..ab88a68d 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, @@ -389,6 +390,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 +453,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). @@ -802,7 +856,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 +868,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 +1041,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,9 +1055,9 @@ 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") @@ -1238,6 +1294,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 +1332,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 +1344,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/levelsview_frame.py b/src/pygpsclient/levelsview_frame.py index e263e972..1e2a97f0 100644 --- a/src/pygpsclient/levelsview_frame.py +++ b/src/pygpsclient/levelsview_frame.py @@ -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): @@ -83,6 +84,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 +99,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 +126,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 +179,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 +201,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/menu_bar.py b/src/pygpsclient/menu_bar.py index f597fc81..ff46f615 100644 --- a/src/pygpsclient/menu_bar.py +++ b/src/pygpsclient/menu_bar.py @@ -22,6 +22,7 @@ DLGTNMEA, DLGTNTRIP, DLGTRECORD, + DLGTSERVER, DLGTTTY, DLGTUBX, MENUABOUT, @@ -40,6 +41,7 @@ DLGTUBX, DLGTNMEA, DLGTNTRIP, + DLGTSERVER, DLGTSPARTN, # service discontinued by u-blox DLGTGPX, DLGTIMPORTMAP, diff --git a/src/pygpsclient/recorder_dialog.py b/src/pygpsclient/recorder_dialog.py index c3db5009..72665942 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 @@ -100,6 +102,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 +141,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 +202,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 +463,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-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/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/serialconfig_frame.py b/src/pygpsclient/serialconfig_frame.py index d1e9a89a..6ac96494 100644 --- a/src/pygpsclient/serialconfig_frame.py +++ b/src/pygpsclient/serialconfig_frame.py @@ -149,7 +149,7 @@ def _body(self): self._frm_basic, border=2, relief="sunken", - width=38, + width=30, height=5, justify=LEFT, exportselection=False, diff --git a/src/pygpsclient/serverconfig_frame.py b/src/pygpsclient/serverconfig_dialog.py similarity index 92% rename from src/pygpsclient/serverconfig_frame.py rename to src/pygpsclient/serverconfig_dialog.py index b7dec45b..e3fc8289 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,13 @@ # pylint: disable=unused-argument, too-many-lines import logging +from pathlib import Path from time import sleep from tkinter import ( DISABLED, EW, NORMAL, + NSEW, Button, Checkbutton, DoubleVar, @@ -41,7 +43,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 @@ -91,6 +93,7 @@ ) from pygpsclient.strings import ( DLGNOTLS, + DLGTSERVER, LBLACCURACY, LBLCONFIGBASE, LBLDISNMEA, @@ -102,6 +105,7 @@ LBLSERVERPORT, LBLSOCKSERVE, ) +from pygpsclient.toplevel_dialog import ToplevelDialog ACCURACIES = ( 10.0, @@ -133,34 +137,32 @@ POS_LLH = "LLH" PQTMVER = "PQTMVER" POSMODES = (POS_LLH, POS_ECEF) +MINDIM = (400, 600) -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, MINDIM) - self.__app = app - self._container = container self._show_advanced = False self._socket_serve = IntVar() 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 +186,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, borderwidth=2, relief="groove") + 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, @@ -253,11 +257,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 +265,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 +400,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 +409,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 +424,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(cfg.get("sockserver_b")) + self._socket_serve.set(self.__app.server_status >= 0) # TODO self.sock_mode.set(SOCKMODES[cfg.get("sockmode_b")]) self._on_toggle_advanced() self.base_mode.set(cfg.get("ntripcasterbasemode_s")) @@ -449,10 +448,10 @@ 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) @@ -466,7 +465,6 @@ def reset(self): 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 +556,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,9 +565,9 @@ 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 @@ -584,7 +581,6 @@ def _on_socketserve(self, var, index, mode): else: # stop server self.__app.sockserver_stop() self.__app.stream_handler.sock_serve = False - self.clients = 0 # set visibility of various fields depending on server status for wid in ( @@ -650,9 +646,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 +856,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 +996,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. diff --git a/src/pygpsclient/settings_frame.py b/src/pygpsclient/settings_frame.py index 1423fa26..5ce697fb 100644 --- a/src/pygpsclient/settings_frame.py +++ b/src/pygpsclient/settings_frame.py @@ -77,6 +77,7 @@ ICON_NTRIPCONFIG, ICON_SERIAL, ICON_SOCKET, + ICON_TRANSMIT, ICON_TTYCONFIG, ICON_UBXCONFIG, INFOCOL, @@ -93,22 +94,24 @@ 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, + DLGTSERVER, DLGTTTY, DLGTUBX, + LBLAUTOSCROLL, LBLDATABASERECORD, LBLDATADISP, LBLDATALOG, LBLDEGFORMAT, + LBLFILEDELAY, LBLNMEACONFIG, LBLNTRIPCONFIG, LBLPROTDISP, - LBLSHOWUNUSED, + LBLSERVERCONFIG, LBLTRACKRECORD, LBLTTYCONFIG, LBLUBXCONFIG, @@ -116,18 +119,17 @@ 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 +# initial dimensions (in font character units) adjusted +# for different widget rendering on different platforms if system() == "Linux": # Wayland MINHEIGHT = 28 - MINWIDTH = 28 + MINWIDTH = 24 elif system() == "Darwin": # MacOS - - MINHEIGHT = 38 - MINWIDTH = 30 -else: # Windows and others - MINHEIGHT = 35 + MINHEIGHT = 37 MINWIDTH = 26 +else: # Windows and others + MINHEIGHT = 34 + MINWIDTH = 22 class SettingsFrame(Frame): @@ -170,7 +172,6 @@ def __init__(self, app, *args, **kwargs): 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 @@ -183,6 +184,7 @@ def __init__(self, app, *args, **kwargs): 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._container() # create scrollable container @@ -382,7 +384,7 @@ def _body(self): textvariable=self._units, ) self._chk_scroll = Checkbutton( - self._frm_options, text="Autoscroll", variable=self._autoscroll + self._frm_options, text=LBLAUTOSCROLL, variable=self._autoscroll ) self._spn_maxlines = Spinbox( self._frm_options, @@ -394,7 +396,7 @@ def _body(self): ) self._lbl_filedelay = Label( self._frm_options, - text="File Delay", + text=LBLFILEDELAY, ) self._spn_filedelay = Spinbox( self._frm_options, @@ -406,9 +408,6 @@ def _body(self): 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, @@ -476,10 +475,16 @@ def _body(self): command=lambda: self.__app.start_dialog(DLGTNTRIP), state=NORMAL, ) - # socket server configuration - self.frm_socketserver = ServerConfigFrame( - self.__app, - self._frm_container, + 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): @@ -518,49 +523,42 @@ def _do_layout(self): 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._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=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) + 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=8, columnspan=2, padx=2, pady=2, sticky=W + column=0, row=9, columnspan=2, padx=2, pady=2, sticky=W ) self._chk_recorddatabase.grid( - column=2, row=8, columnspan=2, padx=2, pady=2, sticky=W + column=2, row=9, 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._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=5) + 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=5) + 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=5) + self._btn_ntripconfig.grid(column=3, row=0, padx=2, pady=1) 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 - ) + 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): """ @@ -587,7 +585,6 @@ def _attach_events(self, add: bool = True): 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) @@ -615,7 +612,6 @@ def reset(self): 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") @@ -768,13 +764,6 @@ def _on_update_autoscroll(self, var, index, mode): 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. @@ -874,9 +863,10 @@ def _on_connect(self, conntype: int): "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 + # self.frm_socketserver.status_label = conntype if conntype == CONNECTED: frm = self.frm_serial if frm.status == NOPORTS: @@ -935,7 +925,6 @@ def enable_controls(self, status: int): self.frm_serial.status_label = status self.frm_socketclient.status_label = status - self.frm_socketserver.status_label = status self._btn_connect.config( state=( diff --git a/src/pygpsclient/signalsview_frame.py b/src/pygpsclient/signalsview_frame.py new file mode 100644 index 00000000..cba0f641 --- /dev/null +++ b/src/pygpsclient/signalsview_frame.py @@ -0,0 +1,370 @@ +""" +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, SE, Frame, N, S, W, font + +from pyubx2 import CORRSOURCE, SIGID, UBXMessage + +from pygpsclient.canvas_plot 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") + + +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, *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) + + Frame.__init__(self, self.__master, *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 + self._canvas.create_text( + self.width / 2, + self._canvas.yofft + h / 2, + text=f"Correction Source:\n{CSLEG}", + fill=FGCOL, + font=lgfont, + anchor=W, + 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 + 2, + 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/spectrum_frame.py b/src/pygpsclient/spectrum_frame.py index 60196b66..4156a53f 100644 --- a/src/pygpsclient/spectrum_frame.py +++ b/src/pygpsclient/spectrum_frame.py @@ -16,7 +16,8 @@ # 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 @@ -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, @@ -288,7 +292,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 +313,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 +362,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 +373,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/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..e82d7370 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,21 @@ LBLCFGRECORD = "CFG Configuration Load/Save/Record" LBLCONFIGBASE = "Configure Base" LBLCTL = "Controls" +LBLDATABASERECORD = "Database" LBLDATADISP = "Format" LBLDATALOG = "Datalog" -LBLDATABASERECORD = "Database" 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" @@ -130,20 +133,20 @@ LBLNTRIPSTR = "Sourcetable" LBLNTRIPUSER = "User" LBLNTRIPVERSION = "Version" -LBLNMEAPRESET = "Preset NMEA Configuration Commands" -LBLUBXPRESET = "Preset UBX Configuration Commands" 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 +155,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 +178,22 @@ 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" 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/ubx_handler.py b/src/pygpsclient/ubx_handler.py index 792438d9..a06bbd05 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 # TODO collate sigFlags bits + 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/widget_state.py b/src/pygpsclient/widget_state.py index 2d3ce23e..b2cc429f 100644 --- a/src/pygpsclient/widget_state.py +++ b/src/pygpsclient/widget_state.py @@ -34,6 +34,7 @@ class definition and update `ubx_handler` to populate them. 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 @@ -66,6 +67,7 @@ class definition and update `ubx_handler` to populate them. WDGSYSMON = "System Monitor" WDGCHART = "Chart Plot" WDGIMUMON = "IMU Monitor" +WDGSIGNALS = "Signals" class WidgetState: @@ -133,6 +135,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..f0598250 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -132,7 +132,7 @@ def testconfiguration(self): self.assertEqual(cfg.get("lbandclientdrat_n"), 2400) self.assertEqual(cfg.get("userport_s"), "") self.assertEqual(cfg.get("spartnport_s"), "") - self.assertEqual(len(cfg.settings), 152) + self.assertEqual(len(cfg.settings), 155) kwargs = {"userport": "/dev/ttyACM0", "spartnport": "/dev/ttyACM1"} cfg.loadcli(**kwargs) self.assertEqual(cfg.get("userport_s"), "/dev/ttyACM0")