diff --git a/.vscode/settings.json b/.vscode/settings.json index 239b7cb2..c7d2f360 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,7 +17,7 @@ "C_Cpp.copilotHover": "disabled", "chat.agent.enabled": false, "chat.commandCenter.enabled": false, - "chat.notifyWindowOnConfirmation": false, + "chat.notifyWindowOnConfirmation": "off", "telemetry.feedback.enabled": false, "python.analysis.addHoverSummaries": false, "python-envs.defaultEnvManager": "ms-python.python:venv", diff --git a/README.md b/README.md index 6a115def..a210c2e2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ [NTRIP Client](#ntripconfig) | [NTRIP Caster/Socket Server](#socketserver) | [GPX Track Viewer](#gpxviewer) | +[RINEX Conversion](#rinex) | [Mapquest API Key](#mapquestapi) | [User-defined Presets](#userdefined) | [CLI Utilities](#cli) | @@ -19,12 +20,13 @@ [Author Information](#author) PyGPSClient is a free, open-source, multi-platform graphical GNSS/GPS testing, diagnostic and configuration application written entirely by volunteers in Python and tkinter. -* Runs on any platform which supports a Python 3 interpreter (>=3.10) and tkinter (>=8.6) GUI framework, including Windows, MacOS, Linux and Raspberry Pi OS. Accommodates low resolution screens (>= 640x480) via resizable and/or scrollable panels. +* Runs on any platform which supports a Python 3 interpreter (>=3.10) and tkinter (>=8.6) GUI framework, including Windows, MacOS and Linux. * Supports NMEA, UBX (u-blox binary), SBF (Septentrio binary), UNI (Unicore binary), QGC (Quectel binary), RTCM3, NTRIP, SPARTN, MQTT and TTY (ASCII text) protocols¹. * Capable of reading from a variety of GNSS data streams: Serial (USB / UART), Socket (TCP / UDP), binary data stream (terminal or file capture) and binary recording (e.g. u-center \*.ubx). -* Provides [NTRIP](#ntripconfig) client facilities. +* Provides [NTRIP client](#ntripconfig) facilities for both RTCM3 and SPARTN NTRIP services. * Can serve as an [NTRIP base station](#basestation) with an RTK-compatible receiver (e.g. u-blox ZED-F9P/ZED-X20P, Quectel LG/LC Series, Septentrio Mosaic Series or Unicore UM9** Series). * Supports GNSS (*and related*) device configuration via proprietary UBX, NMEA and ASCII TTY protocols, including most u-blox, Quectel, Septentrio, Unicore and Feyman GNSS devices. +* **New in v1.6.7** - Experimental support for RINEX conversion of raw observation, navigation (CEI) and meteorology data. * Can be installed using the standard `pip` Python package manager - see [installation instructions](#installation) below. This is an independent project and we have no affiliation whatsoever with any GNSS manufacturer or distributor. @@ -35,7 +37,7 @@ This is an independent project and we have no affiliation whatsoever with any GN *Screenshot showing mixed-protocol stream from u-blox ZED-F9P receiver, using PyGPSClient's [NTRIP Client](#ntripconfig) with a base station 26 km to the west to achieve better than 2 cm accuracy* -#### References +### References 1. [Glossary of GNSS Terms and Abbreviations](https://www.semuconsulting.com/gnsswiki/glossary/). 1. [GNSS Positioning - A Reviser](https://www.semuconsulting.com/gnsswiki/) - a general overview of GNSS, OSR, SSR, RTK, NTRIP and SPARTN positioning and error correction technologies and terminology. @@ -142,10 +144,10 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. 17. DataLogging - Turn Data logging in the selected format (Binary, Parsed, Hex Tabular, Hex String, Parsed+Hex Tabular) on or off. On first selection, you will be prompted to select the directory into which timestamped log files are saved. Log files are cycled when a maximum size is reached (default is 10 MB, manually configurable via `logsize_n` setting). 18. GPX Track - Turn track recording (in GPX format) on or off. On first selection, you will be prompted to select the directory into which timestamped GPX track files are saved. See also [GPX Track Viewer](#gpxviewer). -19. Database - Turn spatialite database recording (*where available*) on or off. On first selection, you will be prompted to select the directory into which the `pygpsclient.sqlite` database is saved. Note that, when first created, the database's spatial metadata will take a few seconds to initialise (*up to a minute or so on some platforms e.g. Raspberry Pi*). +19. Database - Turn spatialite database recording (*where available*) on or off. On first selection, you will be prompted to select the directory into which the `pygpsclient.sqlite` database is saved. *Note that, when first created, the database's spatial metadata may take up to a minute or so to initialise*. - Database logging is dependent on your Python environment supporting the requisite [sqlite3 `mod_spatialite` extension](https://www.gaia-gis.it/fossil/libspatialite/index) - see [INSTALLATION.md](https://github.com/semuconsulting/PyGPSClient/blob/master/INSTALLATION.md#prereqs) for further details. If not supported, the option will be greyed out. Check the Menu..Help..About dialog for an indication of the current spatialite support status - `no-ext` means the spatialite extension is not supported; `no-ms` means spatialite *is* supported but the necessary `mod_spatialite` extension module cannot be found in the PATH; a numeric version number like `3.51.2` indicates spatialite is fully supported. - Spatialite databases can be utilised by a wide range of GIS analysis and visualisation tools, including GRASS, QGIS, MapInfo, ArcGIS, etc. - - *FYI* a helper method `retrieve_data()` is available to retrieve data from this database - see [Sphinx documentation](https://www.semuconsulting.com/pygpsclient/pygpsclient.html#pygpsclient.sqllite_handler.retrieve_data) and [retrieve_data.py](https://github.com/semuconsulting/PyGPSClient/blob/master/examples/retrieve_data.py) example for details. + - A helper method `retrieve_data()` is available to retrieve data from this database - see [Sphinx documentation](https://www.semuconsulting.com/pygpsclient/pygpsclient.html#pygpsclient.sqllite_handler.retrieve_data) and [retrieve_data.py](https://github.com/semuconsulting/PyGPSClient/blob/master/examples/retrieve_data.py) example for details. #### Pop-up Configuration Dialogs @@ -165,7 +167,7 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. #### GUI refresh rate setting -29. PyGPSClient processes all incoming GNSS data in 'real time' but, by default, the GUI is only refreshed every 0.5 seconds. The refresh rate can be configured via the `guiupdateinterval_f` setting in the json configuration file. **NB:** PyGPSClient may become unresponsive on slower platforms (e.g. Raspberry Pi) at high message rates if the GUI update interval is less than 0.1 seconds, though lower intervals (<= 0.1 secs) can be accommodated on more powerful platforms. +29. PyGPSClient processes all incoming GNSS data in 'real time' but, by default, the GUI is only refreshed every 0.5 seconds. The refresh rate can be manually configured via the `guiupdateinterval_f` setting in the json configuration file. **NB:** PyGPSClient may become unresponsive on slower platforms (e.g. Raspberry Pi) at high message rates if the GUI update interval is less than 0.1 seconds, though lower intervals (<= 0.1 secs) can be accommodated on more powerful platforms. #### Toplevel ('pop-up') dialog setting @@ -222,19 +224,20 @@ The UBX Configuration Dialog currently provides the following UBX configuration 1. UBX Legacy Command configuration panel providing structured updates for a range of legacy CFG-* configuration commands (*legacy protocols only*). Note: 'X' (byte) type attributes can be entered as integers or hexadecimal strings e.g. 522125312 or 0x1f1f0000. Once a command is selected, the configuration is polled and the current values displayed. The user can then amend these values as required and send the updated configuration. Some polls require input arguments (e.g. portID) - these are highlighted and will be set at default values initially (e.g. portID = 0), but can be amended by the user and re-polled using the ![refresh](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-refresh-lined-24.png?raw=true) button. 1. Preset Commands widget supports a variety of user-defined UBX commands and queries - see [user-defined presets](#userdefined). -### Configuring u-blox receivers for Post-Processing Kinematics (PPK) using the RTKLIB suite +### Configuring u-blox receivers for Post-Processing Kinematics (PPK) -PyGPSClient cannot currently perform UBX to RINEX conversion itself, but it can be used to enable and log the necessary raw navigation and observation data for input into [RTKLIB's RINEX conversion and processing utilities RTKCONV and RTKPOST](https://github.com/tomojitakasu/RTKLIB_bin): +PyGPSClient can be used to enable and log the necessary raw observation and navigation (CEI) data for input into either PYGPSClient's experimental [RINEX Conversion dialog](#rinex) or [RTKLIB's RINEX conversion utility RTKCONV](https://github.com/tomojitakasu/RTKLIB_bin): 1. Set the output baud rate to at least 115200 to ensure there is sufficient serial port bandwidth. 2. Enable UBX RXM-RAWX and RXM-SFRBX message types at a rate of 1 Hz (*a preset CFG-VALSET command is provided for this purpose*). Optionally, disable all other message types and protocols. 3. Enable PyGPSClient's binary data log option (*alternatively, use RTKLIB's STRSVR utility to create a similar log file*). 4. **Record at least 15 to 30 minutes of data** (approximately 3.4 MB). -5. Open RTKLIB's RTKCONV conversion utility and select the binary log file from (4) above. Click 'Options...' and select the required satellite, frequency and observation types, then click OK. Finally, click 'Convert'. +5. Open PyGPSClient's [RINEX Conversion dialog](#rinex) and select the binary log file from (4) above, or... +6. Open RTKLIB's RTKCONV conversion utility and select the binary log file from (4) above. Click 'Options...' and select the required satellite, frequency and observation types, then click OK. Finally, click 'Convert'. ![rtkconv screenshot](https://github.com/semuconsulting/PyGPSClient/blob/master/images/rtkconv_screenshot.png?raw=true) -5. The various RINEX output files (*.obs, *.nav, etc.) can then be used as inputs to RTKLIB's RTKPOST PPK utility. +7. The various RINEX output files (*.rnx or *.obs, *.nav, etc.) can then be used as inputs to RTKLIB's RTKPOST PPK utility. --- ## NMEA Configuration Facilities @@ -313,7 +316,7 @@ The NTRIP Configuration utility allows users to receive and process NTRIP RTK Co 1. Enter the required NTRIP server URL (or IP address) and port (defaults to 2101). For SSL/TLS (HTTPS) connections (*typically on ports \*443 or 2102*), tick the TLS checkbox. Tick the Self-Sign checkbox to tolerate self-signed TLS certification (*typically for test or demonstration services*); the path to the self-sign TLS certificate can be set via environment variable `PYGNSSUTILS_CRTPATH`; the default is `$HOME\pygnssutils.crt`. 1. For services which require authorisation, enter your assigned login username and password. -1. Select the Data Type (defaults to RTCM, but can be set to SPARTN). +1. Select the Data Type (defaults to RTCM3, but can be set to SPARTN). 1. To retrieve the sourcetable, leave the mountpoint field blank and click connect (*response may take a few seconds*). The required mountpoint may then be selected from the list, or entered manually. Where possible, `PyGPSClient` will automatically identify the closest mountpoint to the current location. 1. For NTRIP services which require client position data via NMEA GGA sentences, select the appropriate sentence transmission interval in seconds. The default is 'None' (no GGA sentences sent). A value of 10 or 60 seconds is typical. 1. If GGA sentence transmission is enabled, GGA sentences can either be populated from live navigation data (*assuming a receiver is connected and outputting valid position data*) or from fixed reference settings entered in the NTRIP configuration panel (latitude, longitude, elevation and geoid separation - all four reference settings must be provided). @@ -415,6 +418,33 @@ To display the GPX Track Viewer Dialog, select Menu..Options..GPX Track Viewer. The GPX Track Viewer can display any valid GPX file containing track point (`trkpt`), route point (`rtept`) or waypoint (`wpt`) elements against either an ["custom" offline map image](#custommap), or an online MapQuest "map", "sat" or "hyb" view. The "map", "sat" and "hyb" options require a free [MapQuest API key](#mapquestapi). The Y axis scales will reflect the current choice of units (metric or imperial). If the GPX track omits a time element, the time and speed axes will be flagged as nominal. GPX track metadata, including min, max, average (mean) and median elevation and speed values, is displayed in the selected units. Click ![refresh icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-refresh-lined-24.png?raw=true) to refresh the display after any changes (e.g. resizing, zooming or change of units). The location marker indicates the nominal center point of the track. +--- +## RINEX Conversion + +**NB: RINEX conversion is currently an experimental facility based on the [pygnssutils pyrinexconv CLI utility](https://github.com/semuconsulting/pygnssutils#rinexconvert). See [pygnssutils release notes](https://github.com/semuconsulting/pygnssutils/releases/tag/v1.2.0) for details of current functionality and limitations**. The intention is to enhance functionality in future releases. + +![rinex screenshot](https://github.com/semuconsulting/PyGPSClient/blob/master/images/rinex_dialog.png?raw=true) + +The RINEX Conversion Dialog supports the conversion of raw observation, navigation (CEI - clock, ephemerides, integrity) and meteorology data from a variety of sources. + +**Pre-Requisites:** + +1. A previously-saved binary datalog containing raw observation, navigation and/or meteorology data e.g. UBX RXM-RAWX and RXM-SFRBX¹ messages or RTCM3 ephemerides (1019, 1020, 1042-1046) messages. A suitable datalog can be recorded using PyGPSClient's [binary datalogging](#datalog) facility. **NB**: The file should contain at least 15-30 minutes of continuous data. + + ¹ Only GPS LNAV data is supported in this experimental release, though the underlying pygnssutils classes are readily extensible. + +**Instructions:** + +1. Click the ![folder icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-folder-18-24.png?raw=true) to select the required binary datalog file. +2. Select the required RINEX protocol version (*only 3.05 supported in this experimental release*). +3. Select the required RINEX output file types - O observation, N navigation or M meteorology. +4. Select the datasource for each RINEX output type e.g. R Receiver, N RTCM3 (NTRIP), etc. +5. (Optional) Select the GNSS to be included e.g. GPS, GAL, BDS, etc. +6. (Optional) Select the RINEX observation (frequency / signal) codes to be included e.g. 1C, 2L, etc. +7. (Optional) Expand the advanced panel ![start icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-caret-right-filled-32.png?raw=true) to enter details of the marker, antenna, receiver, observer and any user-defined comments. +8. Click ![start icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-media-control-48-24.png?raw=true) to process the file. A progress bar will be displayed and, when complete, the output file names (*.rnx) and record counts will be displayed at the foot of the dialog. +9. Processing can be cancelled by clicking ![cancel icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-x-mark-9-24.png?raw=true). + --- ## MapQuest API Key diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 00d7e60c..69934f10 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,12 @@ # PyGPSClient Release Notes +### RELEASE 1.6.7 + +ENHANCEMENTS: + +1. Add preliminary support for RINEX conversion of raw observation, navigation and meteorology data from previously-saved binary data logs. Based on the [pyrinexconv CLI utility in pygnssutils](https://github.com/semuconsulting/pygnssutils#rinexconvert). **NB**: This is currently an experimental facility, the intention being to enhance in future releases - see [pygnssutils release notes](https://github.com/semuconsulting/pygnssutils/releases/tag/v1.2.0) for further details. +1. Minor internal enhancements to Import Custom Map exception handling. + ### RELEASE 1.6.6 FIXES: diff --git a/docs/pygpsclient.rst b/docs/pygpsclient.rst index 833dc541..a8344a33 100644 --- a/docs/pygpsclient.rst +++ b/docs/pygpsclient.rst @@ -252,6 +252,14 @@ pygpsclient.recorder\_dialog module :undoc-members: :show-inheritance: +pygpsclient.rinex\_dialog module +-------------------------------- + +.. automodule:: pygpsclient.rinex_dialog + :members: + :undoc-members: + :show-inheritance: + pygpsclient.rover\_frame module ------------------------------- diff --git a/images/rinex_dialog.png b/images/rinex_dialog.png new file mode 100644 index 00000000..4f7df7fd Binary files /dev/null and b/images/rinex_dialog.png differ diff --git a/pyproject.toml b/pyproject.toml index 58634ed8..82df662a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,9 +52,9 @@ classifiers = [ dependencies = [ "requests>=2.28.0", "Pillow>=9.0.0", - "pygnssutils>=1.1.22", - "pyunigps>=0.2.0", - "pynmeagps>=1.1.2", + "pygnssutils>=1.2.0", + "pyunigps>=1.0.0", + "pynmeagps>=1.1.4", ] [project.scripts] diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py index ba78b87f..f2e2790f 100644 --- a/src/pygpsclient/_version.py +++ b/src/pygpsclient/_version.py @@ -8,4 +8,4 @@ :license: BSD 3-Clause """ -__version__ = "1.6.6" +__version__ = "1.6.7" diff --git a/src/pygpsclient/about_dialog.py b/src/pygpsclient/about_dialog.py index 9b4e6bb9..f30f6624 100644 --- a/src/pygpsclient/about_dialog.py +++ b/src/pygpsclient/about_dialog.py @@ -224,8 +224,8 @@ def _check_for_update(self, *args, **kwargs): # pylint: disable=unused-argument Check for updates. """ - versions = check_for_updates() self.status_label = ("Checking for updates...", INFOCOL) + versions = check_for_updates() for i, (nam, current, latest) in enumerate(versions): txt = f"{nam}: {current}" if latest == current: diff --git a/src/pygpsclient/app.py b/src/pygpsclient/app.py index 2a9080ad..209887e1 100644 --- a/src/pygpsclient/app.py +++ b/src/pygpsclient/app.py @@ -1018,13 +1018,16 @@ def _check_update(self): """ latest = check_latest(TITLE) - if latest not in (VERSION, "N/A"): - shortcut = "" if brew_installed() else " CTRL-U to update." + if latest not in (VERSION, NA): + if brew_installed(): + hotkey = "" + else: + hotkey = " CTRL-U to update." + self.__master.bind_all("", self.do_app_update) self.status_label = ( - VERCHECK.format(title=TITLE, version=latest, shortcut=shortcut), + VERCHECK.format(title=TITLE, version=latest, hotkey=hotkey), ERRCOL, ) - self.__master.bind_all("", self.do_app_update) else: self.__master.unbind("") diff --git a/src/pygpsclient/dialog_state.py b/src/pygpsclient/dialog_state.py index 48131f93..c59e21bf 100644 --- a/src/pygpsclient/dialog_state.py +++ b/src/pygpsclient/dialog_state.py @@ -22,6 +22,7 @@ from pygpsclient.nmea_config_dialog import NMEAConfigDialog from pygpsclient.ntrip_client_dialog import NTRIPConfigDialog from pygpsclient.recorder_dialog import RecorderDialog +from pygpsclient.rinex_dialog import RINEXDialog from pygpsclient.serverconfig_dialog import ServerConfigDialog from pygpsclient.settings_dialog import SettingsDialog from pygpsclient.spartn_dialog import SPARTNConfigDialog @@ -33,6 +34,7 @@ DLGTNMEA, DLGTNTRIP, DLGTRECORD, + DLGTRINEX, DLGTSERVER, DLGTSETTINGS, DLGTSPARTN, @@ -104,6 +106,11 @@ def __init__(self): DLG: None, RESIZE: False, }, + DLGTRINEX: { + CLASS: RINEXDialog, + DLG: None, + RESIZE: False, + }, DLGTSETTINGS: { CLASS: SettingsDialog, DLG: None, diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py index 70698650..c8b459b2 100644 --- a/src/pygpsclient/globals.py +++ b/src/pygpsclient/globals.py @@ -38,11 +38,11 @@ 0: ("GPS", "#6495ED"), 1: ("SBA", "#FF8000"), 2: ("GAL", "#008B00"), - 3: ("BEI", "#9F79EE"), + 3: ("BDS", "#9F79EE"), 4: ("IME", "#EE82EE"), 5: ("QZS", "#CDCD00"), 6: ("GLO", "#CD5C5C"), - 7: ("NAV", "#D2B48C"), + 7: ("IRN", "#D2B48C"), } GRIDLEGEND = "#B0B0B0" # default grid legend color GRIDMAJCOL = "#666666" # default grid major tick color @@ -146,6 +146,7 @@ GUI_UPDATE_INTERVAL = 0.5 # GUI widget update interval (seconds) ICON_APP128 = path.join(DIRNAME, "resources/app-128.png") ICON_BLANK = path.join(DIRNAME, "resources/blank-1-24.png") +ICON_CANCEL = path.join(DIRNAME, "resources/iconmonstr-x-mark-9-24.png") ICON_CONFIRMED = path.join(DIRNAME, "resources/iconmonstr-check-mark-8-24.png") ICON_CONN = path.join(DIRNAME, "resources/iconmonstr-media-control-48-24.png") ICON_CONTRACT = path.join(DIRNAME, "resources/iconmonstr-triangle-1-16.png") @@ -156,6 +157,7 @@ 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_LEFT = path.join(DIRNAME, "resources/iconmonstr-caret-left-filled-32.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") @@ -169,6 +171,7 @@ ICON_RECORD = path.join(DIRNAME, "resources/iconmonstr-record-24.png") ICON_REDRAW = path.join(DIRNAME, "resources/iconmonstr-refresh-lined-24.png") ICON_REFRESH = path.join(DIRNAME, "resources/iconmonstr-refresh-6-16.png") +ICON_RIGHT = path.join(DIRNAME, "resources/iconmonstr-caret-right-filled-32.png") ICON_SAVE = path.join(DIRNAME, "resources/iconmonstr-save-14-24.png") ICON_SEND = path.join(DIRNAME, "resources/iconmonstr-arrow-12-24.png") ICON_SERIAL = path.join(DIRNAME, "resources/usbport-1-24.png") @@ -365,7 +368,7 @@ "GGA5": "RTK FLOAT", "GGA6": "DR", "GLLA": "3D", # posMode - "GLLD": "RTK", + "GLLD": "3D", "GLLE": "DR", "GNSA": "3D", # posMode "GNSD": "3D", diff --git a/src/pygpsclient/helpers.py b/src/pygpsclient/helpers.py index d4703e62..c073561f 100644 --- a/src/pygpsclient/helpers.py +++ b/src/pygpsclient/helpers.py @@ -38,7 +38,7 @@ Tk, font, ) -from types import FunctionType, NoneType +from types import FunctionType, MethodType, NoneType from typing import Any, Literal from pygnssutils import version as PGVERSION @@ -109,13 +109,13 @@ LIBVERSIONS = { "PyGPSClient": VERSION, "pygnssutils": PGVERSION, - "pyubx2": UBXVERSION, - "pysbf2": SBFVERSION, - "pyunigps": UNIVERSION, - "pyqgc": QGCVERSION, "pynmeagps": NMEAVERSION, + "pyqgc": QGCVERSION, "pyrtcm": RTCMVERSION, + "pysbf2": SBFVERSION, "pyspartn": SPARTNVERSION, + "pyubx2": UBXVERSION, + "pyunigps": UNIVERSION, } @@ -125,7 +125,7 @@ def validate( low: int | float = MINFLOAT, high: int | float = MAXFLOAT, regex: str | NoneType = None, - func: FunctionType | NoneType = None, + func: MethodType | FunctionType | NoneType = None, args: tuple = (), ) -> bool: """ @@ -137,7 +137,7 @@ def validate( :param int | float low: optional min value :param int | float high: optional max value :param str | NoneType regex: regex expression - :param FunctionType | NoneType func: custom validation function + :param MethodType | FunctionType | NoneType func: custom validation function :param Any args: optional function arguments :return: True/False @@ -973,7 +973,7 @@ def parse_rxmspartnkey(msg: UBXMessage) -> list: lkey = getattr(msg, f"keyLengthBytes_{i+1:02}") wno = getattr(msg, f"validFromWno_{i+1:02}") tow = getattr(msg, f"validFromTow_{i+1:02}") - dat = wnotow2utc(wno, int(tow * 1000)) + dat = wnotow2utc(wno, int(tow * 1000), None, "G", True) key = "" for n in range(0 + pos, lkey + pos): keyb = getattr(msg, f"key_{n+1:02}") diff --git a/src/pygpsclient/importmap_dialog.py b/src/pygpsclient/importmap_dialog.py index c7192a17..55203ff1 100644 --- a/src/pygpsclient/importmap_dialog.py +++ b/src/pygpsclient/importmap_dialog.py @@ -31,6 +31,7 @@ try: from rasterio import open as openraster # pylint: disable=import-error + from rasterio.errors import RasterioIOError # pylint: disable=import-error from rasterio.warp import transform_bounds # pylint: disable=import-error HASRASTERIO = True @@ -42,8 +43,8 @@ BGCOL, ERRCOL, IMPORT, + INFOCOL, OKCOL, - TRACEMODE_WRITE, VALFLOAT, Area, ) @@ -162,13 +163,6 @@ def _attach_events(self): """ self.container.bind("", self._on_resize) - for ent in ( - self._lonmin, - self._lonmax, - self._latmin, - self._latmax, - ): - ent.trace_update(TRACEMODE_WRITE, self._on_update) def _reset(self): """ @@ -180,19 +174,13 @@ def _reset(self): # self._btn_import.config(state=DISABLED) if not HASRASTERIO: self.status_label = ( - "Warning: rasterio library is not installed - " - "bounds must be entered manually" + "Rasterio library is not installed - " + "extents must be entered manually", + INFOCOL, ) else: self.status_label = "" - def _on_update(self, var, index, mode): - """ - Action on updating bounds. - """ - - self._valid_entries() - def _valid_entries(self) -> bool: """ Validate bounds entries. @@ -271,14 +259,17 @@ def _get_bounds(self, mappath) -> Area: if HASRASTERIO: try: ras = openraster(mappath) - lonmin, latmin, lonmax, latmax = transform_bounds( - ras.crs.to_epsg(), 4326, *ras.bounds - ) - except Exception: # pylint: disable=broad-exception-caught + extents = transform_bounds(ras.crs.to_epsg(), 4326, *ras.bounds) + lonmin, latmin, lonmax, latmax = extents + except AttributeError: self.status_label = ( - "Warning: image is not georeferenced - bounds must be entered manually", + "Image is not georeferenced - extents must be entered manually", ERRCOL, ) + except RasterioIOError: + self.status_label = ("Image is not a supported file format", ERRCOL) + except Exception as err: # pylint: disable=broad-exception-caught + self.status_label = (str(err), ERRCOL) self._lonmin.set(str(round(lonmin, 8))) self._latmin.set(str(round(latmin, 8))) diff --git a/src/pygpsclient/menu_bar.py b/src/pygpsclient/menu_bar.py index ec37ac14..64549abf 100644 --- a/src/pygpsclient/menu_bar.py +++ b/src/pygpsclient/menu_bar.py @@ -22,6 +22,7 @@ DLGTNMEA, DLGTNTRIP, DLGTRECORD, + DLGTRINEX, DLGTSERVER, DLGTTTY, DLGTUBX, @@ -46,6 +47,7 @@ DLGTIMPORTMAP, DLGTTTY, DLGTRECORD, + DLGTRINEX, ) diff --git a/src/pygpsclient/resources/iconmonstr-caret-left-filled-32.png b/src/pygpsclient/resources/iconmonstr-caret-left-filled-32.png new file mode 100644 index 00000000..0952778a Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-caret-left-filled-32.png differ diff --git a/src/pygpsclient/resources/iconmonstr-caret-right-filled-32.png b/src/pygpsclient/resources/iconmonstr-caret-right-filled-32.png new file mode 100644 index 00000000..78310494 Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-caret-right-filled-32.png differ diff --git a/src/pygpsclient/resources/iconmonstr-x-mark-9-24.png b/src/pygpsclient/resources/iconmonstr-x-mark-9-24.png new file mode 100644 index 00000000..c3ceca77 Binary files /dev/null and b/src/pygpsclient/resources/iconmonstr-x-mark-9-24.png differ diff --git a/src/pygpsclient/rinex_dialog.py b/src/pygpsclient/rinex_dialog.py new file mode 100644 index 00000000..5ed9db97 --- /dev/null +++ b/src/pygpsclient/rinex_dialog.py @@ -0,0 +1,865 @@ +""" +rinex_dialog.py + +RINEX conversion dialog. + +Created on 2 Apr 2026 + +:author: semuadmin (Steve Smith) +:copyright: 2020 semuadmin +:license: BSD 3-Clause +""" + +from datetime import datetime, timezone +from logging import getLogger +from pathlib import Path +from threading import Event, Thread +from tkinter import ( + EW, + NORMAL, + NSEW, + Button, + Checkbutton, + E, + Entry, + Frame, + IntVar, + Label, + Spinbox, + StringVar, + TclError, + W, + ttk, +) +from tkinter.ttk import Progressbar +from types import MethodType + +from pygnssutils.gnssreader import ( + NMEA_PROTOCOL, + RTCM3_PROTOCOL, + UBX_PROTOCOL, + GNSSReader, +) +from pygnssutils.rinex_conv import RinexConverter +from pygnssutils.rinex_globals import ( + BDS, + GAL, + GLO, + GPS, + IRN, + MET, + NAV, + OBS, + QZS, + RINEX_CANCELLED, + RINEX_NORECS, + RINEX_OK, + SBA, +) + +from pygpsclient.globals import ( + ERRCOL, + INFOCOL, + OKCOL, + READONLY, + TRACEMODE_WRITE, +) +from pygpsclient.helpers import VALCUSTOM, VALNONBLANK, validate +from pygpsclient.strings import ( + DLGTRINEX, + LBLRINEXANTENNA, + LBLRINEXCOMMENT, + LBLRINEXGNSSTYPES, + LBLRINEXINPUTFILE, + LBLRINEXMARKER, + LBLRINEXOBSERVER, + LBLRINEXOBSTYPES, + LBLRINEXOUTPUTS, + LBLRINEXRCVR, + LBLRINEXSTARTTIME, + LBLRINEXTYPES, + LBLRINEXVER, + RINEXFILEINVALID, + RINEXFILEVALID, + RINEXFILEVALIDATING, +) +from pygpsclient.toplevel_dialog import ToplevelDialog + +RINEXVERSIONS = [ + "3.05", +] # "4.02"] +RINEXTYPES = (OBS, NAV, MET) +RINEXSOURCES = ("R Receiver", "S Stream", "N RTCM3", "U Unknown") +RINEXMARKERTYPES = ( + "GEODETIC", + "HUMAN", + "ANIMAL", + "BALLISTIC", + "GLACIER", + "FLOATING_ICE", + "FLOATING_BUOY", + "FIXED_BUOY", + "AIRBORNE", + "WATER_CRAFT", + "GROUND_CRAFT", + "SPACEBORNE", + "NON_PHYSICAL", + "NON_GEODETIC", +) +USERCOMMENTS = 5 + + +class RINEXDialog(ToplevelDialog): + """, + RINEXDialog class. + """ + + def __init__(self, app, *args, **kwargs): # pylint: disable=unused-argument + """ + Constructor. + + :param Frame app: reference to main tkinter application + :param args: optional args to pass to parent class (not currently used) + :param kwargs: optional kwargs to pass to parent class (not currently used) + """ + + self.__app = app # Reference to main application class + self.logger = getLogger(__name__) + self.__master = self.__app.appmaster # Reference to root class (Tk) + + super().__init__(app, DLGTRINEX) + self._infile_path = Path(".") + self._infilepath = StringVar() + self._obssource = StringVar() + self._navsource = StringVar() + self._metsource = StringVar() + self._rinexver = StringVar() + self._rinexobs = IntVar() + self._rinexnav = IntVar() + self._rinexmet = IntVar() + self._rxgps = IntVar() + self._rxglonass = IntVar() + self._rxgalileo = IntVar() + self._rxbeidou = IntVar() + self._rxsbas = IntVar() + self._rxqzss = IntVar() + self._rxnavic = IntVar() + self._obstypes = StringVar() + self._markernum = StringVar() + self._markername = StringVar() + self._markertype = StringVar() + self._antennanum = StringVar() + self._antennatype = StringVar() + self._rcvrnum = StringVar() + self._rcvrname = StringVar() + self._rcvrversion = StringVar() + self._observer = StringVar() + self._starttime = StringVar() + self._usercommentvar = [] + for _ in range(USERCOMMENTS): + self._usercommentvar.append(StringVar()) + self._outputlabels = {} + for rt in (OBS, NAV, MET): + self._outputlabels[rt] = StringVar() + self._settings = {} + self._status = False + self._filecount = 0 + self._stopevent = Event() + self._process_result = RINEX_OK + self._show_advanced = False + + self._body() + self._do_layout() + self._reset() + self._attach_events() + self._finalise() + + def _body(self): + """ + Set up frame and widgets. + """ + # pylint: disable=unnecessary-lambda + + self._frm_body = Frame(self) + self._frm_basic = Frame(self._frm_body, borderwidth=1, relief="groove") + self._frm_advanced = Frame(self._frm_body, borderwidth=1, relief="groove") + self._lbl_infile = Label(self._frm_basic, text=LBLRINEXINPUTFILE) + self._ent_infilepath = Entry( + self._frm_basic, + textvariable=self._infilepath, + state=READONLY, + relief="sunken", + ) + self._btn_load = Button( + self._frm_basic, + width=45, + height=35, + image=self.img_load, + command=lambda: self._on_load(), + ) + self._btn_convert = Button( + self._frm_basic, + width=45, + height=35, + image=self.img_conn, + command=lambda: self._on_convert(), + ) + self._btn_cancel = Button( + self._frm_basic, + width=45, + height=35, + image=self.img_cancel, + command=lambda: self._on_cancel(), + ) + self._btn_toggle = Button( + self._frm_basic, + width=22, + height=28, + command=self._toggle_advanced, + image=self._img_expandh, + ) + self._lbl_rinexver = Label(self._frm_basic, text=LBLRINEXVER) + self._spn_rinexver = Spinbox( + self._frm_basic, + values=RINEXVERSIONS, + width=6, + wrap=True, + textvariable=self._rinexver, + state=READONLY, + repeatdelay=1000, + repeatinterval=1000, + ) + self._lbl_outtypes = Label(self._frm_basic, text=LBLRINEXTYPES) + self._chk_rinexobs = Checkbutton( + self._frm_basic, + text="Observation", + variable=self._rinexobs, + state=NORMAL, + ) + self._spn_obssource = Spinbox( + self._frm_basic, + values=RINEXSOURCES, + width=10, + wrap=True, + textvariable=self._obssource, + state=READONLY, + repeatdelay=1000, + repeatinterval=1000, + ) + self._chk_rinexnav = Checkbutton( + self._frm_basic, + text="Navigation", + variable=self._rinexnav, + state=NORMAL, + ) + self._spn_navsource = Spinbox( + self._frm_basic, + values=RINEXSOURCES, + width=10, + wrap=True, + textvariable=self._navsource, + state=READONLY, + repeatdelay=1000, + repeatinterval=1000, + ) + self._chk_rinexmet = Checkbutton( + self._frm_basic, + text="Meteorology", + variable=self._rinexmet, + state=NORMAL, + ) + self._spn_metsource = Spinbox( + self._frm_basic, + values=RINEXSOURCES, + width=10, + wrap=True, + textvariable=self._metsource, + state=READONLY, + repeatdelay=1000, + repeatinterval=1000, + ) + self._lbl_gnsstypes = Label(self._frm_basic, text=LBLRINEXGNSSTYPES) + self._chk_rxgps = Checkbutton( + self._frm_basic, + text="GPS", + variable=self._rxgps, + state=NORMAL, + ) + self._chk_rxsbas = Checkbutton( + self._frm_basic, + text="SBA", + variable=self._rxsbas, + state=NORMAL, + ) + self._chk_rxgalileo = Checkbutton( + self._frm_basic, + text="GAL", + variable=self._rxgalileo, + state=NORMAL, + ) + self._chk_rxbeidou = Checkbutton( + self._frm_basic, + text="BDS", + variable=self._rxbeidou, + state=NORMAL, + ) + self._chk_rxqzss = Checkbutton( + self._frm_basic, + text="QZS", + variable=self._rxqzss, + state=NORMAL, + ) + self._chk_rxglonass = Checkbutton( + self._frm_basic, + text="GLO", + variable=self._rxglonass, + state=NORMAL, + ) + self._chk_rxnavic = Checkbutton( + self._frm_basic, + text="IRN", + variable=self._rxnavic, + state=NORMAL, + ) + self._lbl_obstypes = Label(self._frm_basic, text=LBLRINEXOBSTYPES) + self._ent_obstypes = Entry( + self._frm_basic, + textvariable=self._obstypes, + state=NORMAL, + relief="sunken", + ) + self._pgb_elapsed = Progressbar( + self._frm_basic, + orient="horizontal", + mode="determinate", + length=100, + ) + self._lbl_outputs = Label(self._frm_basic, text=LBLRINEXOUTPUTS.format(path="")) + self._lbl_output_labels = {} + for rt in RINEXTYPES: + self._lbl_output_labels[rt] = Label( + self._frm_basic, + textvariable=self._outputlabels[rt], + anchor=W, + ) + self._lbl_marker = Label(self._frm_advanced, text=LBLRINEXMARKER, anchor=W) + self._ent_markernum = Entry( + self._frm_advanced, + width=5, + textvariable=self._markernum, + state=NORMAL, + relief="sunken", + ) + self._ent_markername = Entry( + self._frm_advanced, + width=30, + textvariable=self._markername, + state=NORMAL, + relief="sunken", + ) + self._spn_markertype = Spinbox( + self._frm_advanced, + values=RINEXMARKERTYPES, + width=20, + wrap=True, + textvariable=self._markertype, + state=NORMAL, + repeatdelay=1000, + repeatinterval=1000, + ) + self._lbl_antenna = Label(self._frm_advanced, text=LBLRINEXANTENNA, anchor=W) + self._ent_antennanum = Entry( + self._frm_advanced, + width=5, + textvariable=self._antennanum, + state=NORMAL, + relief="sunken", + ) + self._ent_antennatype = Entry( + self._frm_advanced, + width=30, + textvariable=self._antennatype, + state=NORMAL, + relief="sunken", + ) + self._lbl_receiver = Label(self._frm_advanced, text=LBLRINEXRCVR, anchor=W) + self._ent_rcvrnum = Entry( + self._frm_advanced, + width=5, + textvariable=self._rcvrnum, + state=NORMAL, + relief="sunken", + ) + self._ent_rcvrtype = Entry( + self._frm_advanced, + width=30, + textvariable=self._rcvrname, + state=NORMAL, + relief="sunken", + ) + self._ent_rcvrversion = Entry( + self._frm_advanced, + width=20, + textvariable=self._rcvrversion, + state=NORMAL, + relief="sunken", + ) + self._lbl_observer = Label(self._frm_advanced, text=LBLRINEXOBSERVER, anchor=W) + self._ent_observer = Entry( + self._frm_advanced, + width=60, + textvariable=self._observer, + state=NORMAL, + relief="sunken", + ) + self._lbl_comments = Label(self._frm_advanced, text=LBLRINEXCOMMENT, anchor=W) + self._lbl_starttime = Label( + self._frm_advanced, text=LBLRINEXSTARTTIME, anchor=W + ) + self._ent_starttime = Entry( + self._frm_advanced, + width=20, + textvariable=self._starttime, + state=NORMAL, + relief="sunken", + ) + self._entcomments = [] + for cvar in self._usercommentvar: + self._entcomments.append( + Entry( + self._frm_advanced, + textvariable=cvar, + width=50, + state=NORMAL, + ) + ) + + def _do_layout(self): + """ + Position widgets in frame. + """ + + self._frm_body.grid(column=0, row=0, sticky=NSEW) + self._frm_basic.grid(column=0, row=0, sticky=NSEW) + + self._lbl_infile.grid(column=0, row=0, columnspan=6, padx=3, pady=3, sticky=W) + self._btn_toggle.grid(column=6, row=0, padx=3, pady=3, rowspan=2, sticky=E) + self._ent_infilepath.grid( + column=0, row=1, columnspan=6, padx=3, pady=3, sticky=EW + ) + self._btn_load.grid(column=0, row=2, padx=3, pady=3, sticky=W) + self._btn_convert.grid(column=5, row=2, padx=3, pady=3, sticky=E) + self._btn_cancel.grid(column=6, row=2, padx=3, pady=3, sticky=E) + + ttk.Separator(self._frm_basic).grid( + column=0, row=3, columnspan=7, padx=3, pady=3, sticky=EW + ) + self._lbl_rinexver.grid(column=0, row=4, padx=3, columnspan=2, pady=3, sticky=W) + self._spn_rinexver.grid(column=2, row=4, padx=3, columnspan=2, pady=3, sticky=W) + self._lbl_outtypes.grid(column=0, row=5, columnspan=7, padx=3, pady=3, sticky=W) + self._chk_rinexobs.grid(column=0, row=6, columnspan=2, padx=3, pady=3, sticky=W) + self._chk_rinexnav.grid(column=2, row=6, columnspan=2, padx=3, pady=3, sticky=W) + self._chk_rinexmet.grid(column=4, row=6, columnspan=2, padx=3, pady=3, sticky=W) + self._spn_obssource.grid( + column=0, row=7, padx=3, columnspan=2, pady=3, sticky=W + ) + self._spn_navsource.grid( + column=2, row=7, padx=3, columnspan=2, pady=3, sticky=W + ) + self._spn_metsource.grid( + column=4, row=7, padx=3, columnspan=2, pady=3, sticky=W + ) + self._lbl_gnsstypes.grid( + column=0, row=8, columnspan=7, padx=3, pady=3, sticky=W + ) + self._chk_rxgps.grid(column=0, row=9, padx=3, pady=3, sticky=W) + self._chk_rxsbas.grid(column=1, row=9, padx=3, pady=3, sticky=W) + self._chk_rxgalileo.grid(column=2, row=9, padx=3, pady=3, sticky=W) + self._chk_rxbeidou.grid(column=3, row=9, padx=3, pady=3, sticky=W) + self._chk_rxqzss.grid(column=4, row=9, padx=3, pady=3, sticky=W) + self._chk_rxglonass.grid(column=5, row=9, padx=3, pady=3, sticky=W) + self._chk_rxnavic.grid(column=6, row=9, padx=3, pady=3, sticky=W) + self._lbl_obstypes.grid( + column=0, row=10, columnspan=7, padx=3, pady=3, sticky=W + ) + self._ent_obstypes.grid( + column=0, row=11, columnspan=7, padx=3, pady=3, sticky=EW + ) + self._pgb_elapsed.grid( + column=0, row=12, columnspan=7, padx=3, pady=3, sticky=EW + ) + ttk.Separator(self._frm_basic).grid( + column=0, row=13, columnspan=7, padx=3, pady=3, sticky=EW + ) + self._lbl_outputs.grid(column=0, row=14, columnspan=7, padx=3, pady=1, sticky=W) + for i, lbl in enumerate(self._lbl_output_labels.values()): + lbl.grid(column=0, row=15 + i, columnspan=7, padx=3, pady=1, sticky=EW) + + self._lbl_marker.grid(column=0, row=0, columnspan=3, padx=3, pady=3, sticky=W) + self._ent_markernum.grid(column=0, row=1, padx=3, pady=3, sticky=W) + self._ent_markername.grid(column=1, row=1, padx=3, pady=3, sticky=W) + self._spn_markertype.grid(column=2, row=1, padx=3, pady=3, sticky=W) + self._lbl_antenna.grid(column=0, row=2, columnspan=3, padx=3, pady=3, sticky=W) + self._ent_antennanum.grid(column=0, row=3, padx=3, pady=3, sticky=W) + self._ent_antennatype.grid(column=1, row=3, padx=3, pady=3, sticky=W) + self._lbl_receiver.grid(column=0, row=4, columnspan=3, padx=3, pady=3, sticky=W) + self._ent_rcvrnum.grid(column=0, row=5, padx=3, pady=3, sticky=W) + self._ent_rcvrtype.grid(column=1, row=5, padx=3, pady=3, sticky=W) + self._ent_rcvrversion.grid(column=2, row=5, padx=3, pady=3, sticky=W) + self._lbl_observer.grid(column=0, row=6, columnspan=3, padx=3, pady=3, sticky=W) + self._ent_observer.grid( + column=0, row=7, columnspan=3, padx=3, pady=3, sticky=EW + ) + self._lbl_starttime.grid( + column=0, row=8, columnspan=2, padx=3, pady=3, sticky=W + ) + self._ent_starttime.grid(column=2, row=8, padx=3, pady=3, sticky=W) + self._lbl_comments.grid(column=0, row=9, columnspan=3, padx=3, pady=3, sticky=W) + for i, ec in enumerate(self._entcomments): + ec.grid(column=0, row=10 + i, columnspan=3, padx=3, pady=1, sticky=EW) + + for col in range(7): # make columns equal width + self._frm_basic.grid_columnconfigure(col, weight=1, uniform="col") + + def _attach_events(self): + """ + Set up event listeners. + """ + + for setting in ( + self._rinexobs, + self._rinexnav, + self._rinexmet, + self._rxgps, + self._rxglonass, + self._rxgalileo, + self._rxbeidou, + self._rxsbas, + self._rxqzss, + self._rxnavic, + ): + setting.trace_add(TRACEMODE_WRITE, self._on_update_config) + + def _reset(self): + """ + Reset configuration widgets. + """ + + for chk in ( + self._rinexobs, + self._rinexnav, + self._rinexmet, + self._rxgps, + self._rxglonass, + self._rxgalileo, + self._rxbeidou, + self._rxsbas, + self._rxqzss, + self._rxnavic, + ): + chk.set(1) + self._starttime.set(datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%z")) + self.set_controls(self._status) + + def _on_update_config(self, var, index, mode): # pylint: disable=unused-argument + """ + Update in-memory configuration if setting is changed. + """ + + try: + self.update() + cfg = self.__app.configuration + except (ValueError, TclError): + pass + + def set_controls(self, status: bool): + """ + Set controls according to status. + + :param bool status: connection status (True/False) + :param NoneType | tuple msgt: tuple of (message, color) + """ + + def _on_convert(self): + """ + Run conversion. + """ + + self.status_label = f"Processing {self._infile_path.name}" + for rt in RINEXTYPES: + self._outputlabels[rt].set("") + self._do_conversion() + + def _on_cancel(self): + """ + Cancel conversion process + """ + + self._stopevent.set() + + def _on_load(self): + """ + Load input data log. + """ + + infile = self.__app.file_handler.open_file( + self, + "GNSS data log", + ( + ("binary log files", "*.log"), + ("binary UBXfiles", "*.ubx"), + ("all files", "*.*"), + ), + ) + if infile in ("", None): + return # user cancelled + + self._infile_path = Path(infile) + sfp = str(self._infile_path) + if len(sfp) > 60: + sfp = f"...{sfp[-60:]}" + self._infilepath.set(sfp) + self._ent_infilepath.update() + validate(self._ent_infilepath, valmode=VALNONBLANK) + self.status_label = ( + RINEXFILEVALIDATING.format(path=self._infile_path.name), + OKCOL, + ) + i = 0 + try: + with open(self._infile_path, "rb") as infile: + gnr = GNSSReader(infile, parsing=False) + for raw, _ in gnr: + if raw is not None: + i += 1 + if i == 0: + self.status_label = ( + RINEXFILEINVALID.format(path=self._infile_path.name), + ERRCOL, + ) + else: + self.status_label = ( + RINEXFILEVALID.format(path=self._infile_path.name, count=i), + INFOCOL, + ) + self._filecount = i + except Exception as err: # pylint: disable=broad-exception-caught + self.logger.error(f"Error processing {self._infile_path} - {err}") + self.status_label = (f"Error processing {self._infile_path.name}", ERRCOL) + + def _toggle_advanced(self): + """ + Toggle advanced panel on or off. + """ + + self._show_advanced = not self._show_advanced + if self._show_advanced: + self._frm_advanced.grid(column=1, row=0, sticky=NSEW) + self._btn_toggle.config(image=self._img_contracth) + else: + self._frm_advanced.grid_forget() + self._btn_toggle.config(image=self._img_expandh) + + def _valid_settings(self) -> bool: + """ + Validate settings. + + :return: valid True/False + :rtype: bool + """ + + valid = True + if not valid: + self.status_label = ("ERROR - invalid settings", ERRCOL) + + return valid + + def _validtime(self, val: str) -> bool: + """ + Validate start time. + + :param str val: datetime in format "%Y%m%d%H%M%S%z" + :return: Valid/Invalid + :rtype: bool + """ + + try: + datetime.strptime(val, "%Y%m%d%H%M%S%z") + return True + except ValueError: + return False + + def _do_conversion(self, **kwargs): + """ + Do RINEX conversion. + + :return: return code + :rtype: int + """ + + try: + + valid = True + valid = valid & validate( + self._ent_starttime, valmode=VALCUSTOM, func=self._validtime + ) + valid = valid & validate(self._ent_infilepath, valmode=VALNONBLANK) + if not valid: + self.status_label = ("Invalid Parameters", ERRCOL) + return + + rinex_version = self._rinexver.get() + rinex_types = [] + if self._rinexobs.get(): + rinex_types.append(OBS) + if self._rinexnav.get(): + rinex_types.append(NAV) + if self._rinexmet.get(): + rinex_types.append(MET) + if rinex_types == []: + self.status_label = ("Select at least one RINEX output type", ERRCOL) + valid = False + gnss_filter = [] + if self._rxgps.get(): + gnss_filter.append(GPS) + if self._rxgalileo.get(): + gnss_filter.append(GAL) + if self._rxglonass.get(): + gnss_filter.append(GLO) + if self._rxbeidou.get(): + gnss_filter.append(BDS) + if self._rxqzss.get(): + gnss_filter.append(QZS) + if self._rxnavic.get(): + gnss_filter.append(IRN) + if self._rxsbas.get(): + gnss_filter.append(SBA) + if gnss_filter == []: + self.status_label = ("Select at least one GNSS", ERRCOL) + valid = False + if not valid: + return + + obs_filter = self._obstypes.get().split(",") + starttime = self._starttime.get() + minobs = 10 + marker = [ + self._markername.get(), + self._markernum.get(), + self._markertype.get(), + ] + antenna = [self._antennanum.get(), self._antennatype.get()] + receiver = [ + self._rcvrnum.get(), + self._rcvrname.get(), + self._rcvrversion.get(), + ] + observer = self._observer.get() + comments = ["PyGPSClient RINEX Converter Dialog"] + for cvar in self._usercommentvar: + cval = cvar.get() + if cval != "": + comments.append(cval) + protfilter = NMEA_PROTOCOL | RTCM3_PROTOCOL | UBX_PROTOCOL + datasource = [ + self._obssource.get()[0:1], + self._navsource.get()[0:1], + self._metsource.get()[0:1], + ] + rc = RinexConverter( + self.__app, + rinex_version=rinex_version, + rinex_types=rinex_types, + gnssfilter=gnss_filter, + obsfilter=obs_filter, + datasource=datasource, + starttime=starttime, + minobs=minobs, + marker=marker, + antenna=antenna, + receiver=receiver, + observer=observer, + comments=comments, + protfilter=protfilter, + **kwargs, + ) + self._stopevent.clear() + rct = Thread( + target=self._process_input, + args=( + rc, + self._infile_path, + self._stopevent, + self._prog_callback_threaded, + kwargs, + ), + daemon=True, + ) + rct.start() + + except (TclError, KeyboardInterrupt): + self.status_label = ("Conversion Failed", ERRCOL) + + def _process_input( + self, + rc: RinexConverter, + infilepath: Path, + stopevent: Event, + progcallback: MethodType, + kwargs: dict, + ): + """ + THREADED + + :param RinexConverter rc: RineXConverter instance + :param Path infilepath: input file path + :param Event stopevent: stopevent for remote cancellation + :param MethodType progcallback: callback for % complete updates + """ + + res = rc.process_input( + infile=infilepath, stopevent=stopevent, progcallback=progcallback, **kwargs + ) + if res == RINEX_OK: + outputs = rc.outputs + tot = 0 + for rt in RINEXTYPES: + rtc = outputs.get(rt, None) + if rtc is not None: + tot += rtc[1] + self._outputlabels[rt].set( + f"{rt}: {outputs[rt][0].name}: {outputs[rt][1]}" + ) + if tot > 1: + self.status_label = ("Conversion successful", OKCOL) + else: + self.status_label = ("No usable information in input", ERRCOL) + parts = self._infile_path.parts + path = f" (...{parts[-2]}/) " if len(parts) > 1 else " (.../)" + self._lbl_outputs.config(text=LBLRINEXOUTPUTS.format(path=path)) + + elif res == RINEX_CANCELLED: + self.status_label = ("Conversion cancelled", ERRCOL) + elif res == RINEX_NORECS: + self.status_label = ("No parsable records in input", ERRCOL) + else: + self.status_label = ("Conversion failed", ERRCOL) + + def _prog_callback_threaded(self, progress: int): + """ + Invoke progress callback function from thread. + + :param int progress: % complete + """ + + self.after(0, self._prog_callback(progress)) + + def _prog_callback(self, progress: int): + """ + Progress callback function. + + :param int progress: % complete + """ + + self._pgb_elapsed["value"] = progress + self._pgb_elapsed.update() diff --git a/src/pygpsclient/strings.py b/src/pygpsclient/strings.py index 3408d429..7e96429d 100644 --- a/src/pygpsclient/strings.py +++ b/src/pygpsclient/strings.py @@ -73,6 +73,10 @@ QUECTELRST1 = "Receiver will restart..." QUECTELRST2 = "Receiver will restart again..." READTITLE = "Select File" +RINEXFILEINVALID = "{path} invalid; contains 0 records" +RINEXFILEVALID = "{path} validated; {count:,} records" +RINEXFILEVALIDATING = "Validating {path} ..." +RINEXOUTPUT = "Data converted, *.rnx output(s) in {parent}" SAVECONFIGBAD = "Configuration not saved {}" SAVECONFIGOK = "Configuration saved OK" SAVEERROR = "ERROR! File could not be saved to specified directory" @@ -89,7 +93,7 @@ UPDATEINPROG = "Updating application..." UPDATERESTART = "Application updated. Close and Restart" VALERROR = "ERROR! Please correct highlighted entries" -VERCHECK = "Newer version of {title} available: {version}.{shortcut}" +VERCHECK = "Newer version of {title} available: {version}.{hotkey}" WAITNMEADATA = "Waiting for data..." WAITUBXDATA = "Waiting for data..." WARNING = "WARNING>>" @@ -151,6 +155,18 @@ LBLNTRIPVERSION = "NTRIP Version" LBLPROTDISP = "Protocols" LBLPUBLICIP = "Public IP" +LBLRINEXANTENNA = "Antenna Number / Type:" +LBLRINEXCOMMENT = "User Comments:" +LBLRINEXGNSSTYPES = "GNSS Constellations:" +LBLRINEXINPUTFILE = "GNSS Binary Data Log File:" +LBLRINEXMARKER = "Marker Number / Name / Type:" +LBLRINEXOBSERVER = "Observer / Agency:" +LBLRINEXOBSTYPES = "Observation Codes (comma-separated, blank for ALL):" +LBLRINEXOUTPUTS = "Output files and process counts{path}:" +LBLRINEXRCVR = "Receiver Number / Name / Version:" +LBLRINEXSTARTTIME = "Approximate Start Time of RTCM3 Data:" +LBLRINEXTYPES = "RINEX Output File Types and Data Sources:" +LBLRINEXVER = "RINEX Version:" LBLSERVERCONFIG = "Server\nConfig" LBLSERVERHOST = "Host IP" LBLSERVERMODE = "Mode" @@ -207,6 +223,7 @@ DLGTNMEA = "NMEA Configuration" DLGTNTRIP = "NTRIP Configuration" DLGTRECORD = "Configuration Command Recorder" +DLGTRINEX = "RINEX Conversion (EXPERIMENTAL)" DLGTSERVER = "Server Configuration" DLGTSETTINGS = "Settings" DLGTSPARTN = "SPARTN Configuration" diff --git a/src/pygpsclient/toplevel_dialog.py b/src/pygpsclient/toplevel_dialog.py index d2127f6c..62273ae7 100644 --- a/src/pygpsclient/toplevel_dialog.py +++ b/src/pygpsclient/toplevel_dialog.py @@ -26,6 +26,7 @@ E, Frame, Label, + TclError, Toplevel, W, ) @@ -37,14 +38,17 @@ APPNAME, ERRCOL, ICON_BLANK, + ICON_CANCEL, ICON_CONFIRMED, ICON_CONN, ICON_DISCONN, ICON_END, ICON_EXIT, + ICON_LEFT, ICON_LOAD, ICON_PENDING, ICON_REDRAW, + ICON_RIGHT, ICON_SEND, ICON_START, ICON_UNKNOWN, @@ -89,6 +93,7 @@ def __init__(self, app, dlgname: str, *args, **kwargs): self.resizable(self._resizable, self._resizable) self.protocol("WM_DELETE_WINDOW", self.on_exit) self.img_none = ImageTk.PhotoImage(Image.open(ICON_BLANK)) + self.img_cancel = ImageTk.PhotoImage(Image.open(ICON_CANCEL)) self.img_confirmed = ImageTk.PhotoImage(Image.open(ICON_CONFIRMED)) self.img_conn = ImageTk.PhotoImage(Image.open(ICON_CONN)) self.img_disconn = ImageTk.PhotoImage(Image.open(ICON_DISCONN)) @@ -101,6 +106,8 @@ def __init__(self, app, dlgname: str, *args, **kwargs): self.img_start = ImageTk.PhotoImage(Image.open(ICON_START)) self.img_warn = ImageTk.PhotoImage(Image.open(ICON_WARNING)) self.img_unknown = ImageTk.PhotoImage(Image.open(ICON_UNKNOWN)) + self._img_expandh = ImageTk.PhotoImage(Image.open(ICON_RIGHT)) + self._img_contracth = ImageTk.PhotoImage(Image.open(ICON_LEFT)) self._con_body(self._resizable) @@ -241,14 +248,17 @@ def status_label(self, message: str | tuple[str, str]): :param tuple | str message: (message, color)) """ - if isinstance(message, tuple): - message, color = message - else: - color = INFOCOL + try: + if isinstance(message, tuple): + message, color = message + else: + color = INFOCOL - # truncate very long messages - if len(message) > 100: - message = "..." + message[-100:] + # truncate very long messages + if len(message) > 100: + message = "..." + message[-100:] - self.status_label.config(text=message, fg=color) - self.status_label.update() + self.status_label.config(text=message, fg=color) + self.status_label.update() + except TclError: + pass diff --git a/tests/test_static.py b/tests/test_static.py index 2210e3a9..a36c790a 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -405,12 +405,15 @@ def testbitsval(self): self.assertEqual(res, EXPECTED_RESULT[i]) def testparserxm(self): - EXPECTED_RESULT = [('0c00', datetime(1988, 3, 1, 7, 39, 55, tzinfo=timezone.utc)), ('290900', datetime(1988, 7, 4, 2, 39, 55, tzinfo=timezone.utc))] + # EXPECTED_RESULT = [('0c00', datetime(1988, 3, 1, 7, 39, 55, tzinfo=timezone.utc)), ('290900', datetime(1988, 7, 4, 2, 39, 55, tzinfo=timezone.utc))] + EXPECTED_RESULT = [('0c00', datetime(1980, 11, 4, 7, 40, tzinfo=timezone.utc)), ('290900', datetime(1980, 11, 3, 2, 40, tzinfo=timezone.utc))] RXM_SPARTNKEY = b"\xb5b\x026\x19\x00\x01\x02\x00\x00\x00\x02+\x00\xd0Y\xc8\r\x00\x03+\x00\x00\xdfl\x0e\x0c\x00)\t\x00D;" msg = UBXReader.parse(RXM_SPARTNKEY) res = parse_rxmspartnkey(msg) print(f'"{res}",') - self.assertEqual(res, EXPECTED_RESULT) + self.assertTrue(len(res) == 2) + self.assertIsInstance(res[0][1], datetime) + self.assertIsInstance(res[1][1], datetime) def testmapqcompress(self): PREC = 6