diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f770668..be7ddb80 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,7 @@ "python.terminal.activateEnvironment": true, "editor.formatOnSave": true, "modulename": "pygpsclient", // "${workspaceFolderBasename}", - "distname": "${workspaceFolderBasename}", + "distname": "pygpsclient", "venv": "${userHome}/pygpsclient", "python.testing.pytestArgs": [ "tests" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 65b8ee5b..f1ec064b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -25,7 +25,7 @@ }, { "label": "Create Venv", - "type": "process", + "type": "shell", "command": "${config:python.systemPath}", "args": [ "-m", @@ -38,9 +38,18 @@ "Delete Venv", ], }, + { + "label": "Activate Venv", + "type": "shell", + "command": "source", + "args": [ + "~/pygpsclient/bin/activate", + ], + "problemMatcher": [], + }, { "label": "Install Deploy Dependencies", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", @@ -54,7 +63,7 @@ }, { "label": "Install Optional Dependencies", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", @@ -96,7 +105,7 @@ }, { "label": "Sort Imports", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", @@ -109,7 +118,7 @@ }, { "label": "Format", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", @@ -143,7 +152,7 @@ }, { "label": "Build", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", @@ -168,23 +177,32 @@ } }, { - "label": "Install", // into venv - "type": "process", + "label": "Install", // into Virtual Environment + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", "pip", "install", - "--upgrade", "--force-reinstall", - "--find-links=${workspaceFolder}/dist", - "${workspaceFolderBasename}", + // wildcard only works on Posix platforms + "${workspaceFolder}/dist/pygpsclient-*-py3-none-any.whl", ], + "windows": { + "command": "${config:python.defaultInterpreterPath}", + "args": [ + "-m", + "pip", + "install", + "--force-reinstall", + "${workspaceFolder}/dist/pygpsclient-1.5.22-py3-none-any.whl", + ] + }, "problemMatcher": [], }, { "label": "Test", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", @@ -193,10 +211,12 @@ "problemMatcher": [], "dependsOrder": "sequence", "dependsOn": [ + "Activate Venv", "Build", "Install", // have to install before running pylint "Pylint", "Security", + "Sphinx HTML", ], "group": { "kind": "test", @@ -205,7 +225,7 @@ }, { "label": "Sphinx", - "type": "process", + "type": "shell", "command": "sphinx-apidoc", "args": [ "--ext-autodoc", @@ -220,7 +240,7 @@ }, { "label": "Sphinx HTML", - "type": "process", + "type": "shell", "command": "/usr/bin/make", "windows": { "command": "${workspaceFolder}/docs/make.bat" @@ -239,7 +259,7 @@ }, { "label": "Sphinx Deploy", // needs AWS S3 credentials - "type": "process", + "type": "shell", "command": "aws", "args": [ "s3", @@ -256,7 +276,7 @@ }, { "label": "Run Installed Version", - "type": "process", + "type": "shell", "command": "${config:python.defaultInterpreterPath}", "args": [ "-m", diff --git a/README.md b/README.md index 7badd0d4..70b22a06 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [UBX Configuration](#ubxconfig) | [NMEA Configuration](#nmeaconfig) | [TTY Commands](#ttycommands) | +[Load/Save/Record Commands](#recorder) | [NTRIP Client](#ntripconfig) | [SPARTN Client](#spartnconfig) | [Socket Server / NTRIP Caster](#socketserver) | @@ -49,6 +50,7 @@ This is an independent project and we have no affiliation whatsoever with any GN ![Status](https://img.shields.io/pypi/status/PyGPSClient) ![Release](https://img.shields.io/github/v/release/semuconsulting/PyGPSClient) ![Build](https://img.shields.io/github/actions/workflow/status/semuconsulting/PyGPSClient/main.yml?branch=master) +[![Deploy](https://github.com/semuconsulting/pygpsclient/actions/workflows/deploy.yml/badge.svg)](https://github.com/semuconsulting/pygpsclient/actions/workflows/deploy.yml) ![Release Date](https://img.shields.io/github/release-date/semuconsulting/PyGPSClient) ![Last Commit](https://img.shields.io/github/last-commit/semuconsulting/PyGPSClient) ![Contributors](https://img.shields.io/github/contributors/semuconsulting/PyGPSClient.svg) @@ -104,7 +106,7 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. 1. To connect to a TCP or UDP socket, enter the server URL and port, select the protocol (defaults to TCP) and click ![connect socket icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/ethernet-1-24.png?raw=true). For encrypted TLS connections, tick the 'TLS' checkbox. Tick the 'Self Sign' checkbox to accommodate self-signed TLS certification (*typically for test or demonstration services*). 1. To stream from a previously-saved binary datalog file, click -![connect-file icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/binary-1-24.png?raw=true) and select the file type (`*.log, *.ubx, *.*`) and path. PyGPSClient datalog files will be named e.g. `pygpsdata-20220427114802.log`, but any binary dump of an GNSS receiver output is acceptable, including `*.ubx` files produced by u-center. +![connect-file icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/binary-1-24.png?raw=true) and select the file type (`*.log, *.ubx, *.*`) and path. PyGPSClient datalog files will be named e.g. `pygpsdata-20220427114802.log`, but any binary dump of an GNSS receiver output is acceptable, including `*.ubx` files produced by u-center. The 'File Delay' spinbox sets the delay in milliseconds between individual file reads, acting as a throttle on file readback. 1. To disconnect from the data stream, click ![disconnect icon](https://github.com/semuconsulting/PyGPSClient/blob/master/src/pygpsclient/resources/iconmonstr-media-control-50-24.png?raw=true). 1. To exit the application, click @@ -118,7 +120,7 @@ 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/N0 = 0 - Include or exclude satellites where carrier to noise ratio (C/N0) = 0. +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. @@ -160,7 +162,7 @@ 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 (CNo) levels for each GNSS constellation. Double-click to toggle legend. | +|![levelsview widget](https://github.com/semuconsulting/PyGPSClient/blob/master/images/graphview_widget.png?raw=true)| Levels view widget showing current satellite carrier-to-noise (C/No) levels for each GNSS constellation. Double-click to toggle legend. | |![world map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/staticmap.png?raw=true)| Map widget with various modes of display - select from "map" / "sat" (online) or "world" / "custom" (offline). Select zoom level 1 - 20. Double-click the zoom level label to reset the zoom to 10. Double-right-click the zoom label to maximise zoom to 20. Tick Track to show track (track will only be recorded while this box is checked). Double-Right-click will clear the map. Map Type = 'world': a static offline Mercator world map showing current global location. |![online map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/webmap_widget.png?raw=true)| Map Type = 'map', 'sat' or 'hyb' (hybrid): Dynamic, online web map or satellite image via MapQuest API (*requires an Internet connection and free [Mapquest API Key](#mapquestapi)*). By default, the web map will automatically refresh every 60 seconds (*indicated by a small timer icon at the top left*). The default refresh rate can be amended by changing the `"mapupdateinterval_n":` value in your json configuration file, but **NB** the facility is not intended to be used for real-time navigation. Double-click anywhere in the map to immediately refresh. | |![offline map](https://github.com/semuconsulting/PyGPSClient/blob/master/images/custommap.png?raw=true)| Map Type = 'custom': One or more 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. | @@ -193,7 +195,6 @@ For more comprehensive installation instructions, please refer to [INSTALLATION. The UBX Configuration Dialog currently provides the following UBX configuration panels: 1. Version panel shows current device hardware/firmware versions (*via MON-VER and MON-HW polls*). -1. CFG Configuration Load/Save/Record facility. 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 CFG configuration commands, 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 (as binary CFG-* messages). 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/Save/Record facility can accept configuration files in either binary UBX format (\*.ubx) or u-center 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 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). 1. Protocol Configuration panel (CFG-PRT) sets baud rate and inbound/outbound protocols across all available ports (*legacy protocols only*). 1. Solution Rate panel (CFG-RATE) sets navigation solution interval in ms (e.g. 1000 = 1/second) and measurement ratio (ratio between the number of measurements and the number of navigation solutions, e.g. 5 = five measurements per navigation solution) (*legacy protocols only*). 1. For each of the panels above, clicking anywhere in the panel background will refresh the displayed information with the current configuration. @@ -251,6 +252,13 @@ The following example illustrates a series of ASCII configuration commands being ![ttyconsole screenshot](https://github.com/semuconsulting/PyGPSClient/blob/master/images/tty_console.png?raw=true) +--- +## Configuration Command Load/Save/Record Facility + +![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). + --- ## NTRIP Client Facilities @@ -319,7 +327,7 @@ By default, the server/caster binds to the host address '0.0.0.0' (IPv4) or '::' **SOCKET SERVER MODE** 1. Select SOCKET SERVER mode and (if necessary) enter the host IP address and port. -1. Select 'TLS' to enable an encrypted TLS (HTTPS) connection. +1. Select 'TLS' to enable an encrypted TLS connection. 1. Check the Socket Server/NTRIP Caster checkbox to activate the server. 1. To stop the server, uncheck the checkbox. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 642f9ffa..0b335799 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,15 @@ # PyGPSClient Release Notes +### RELEASE 1.5.23 + +FIXES: + +1. Fix PUBX003 file input parsing error [#229](https://github.com/semuconsulting/PyGPSClient/issues/229) + +ENHANCEMENTS: + +1. Move Configuration Command Load/Save/Record facility from UBX Configuration Panel to separate dialog, selectable from Options menu. Can now be used to record and replay UBX, NMEA and TTY configuration commands. + ### RELEASE 1.5.22 FIXES: diff --git a/docs/conf.py b/docs/conf.py index bf047b8c..6fd529dc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,12 +13,16 @@ import os import sys -sys.path.insert(0, os.path.abspath("../src")) +# get path to site-packages (source) folder within venv +pypath = ( + f"{os.path.expanduser("~")}/pygpsclient/lib/python" + f"{sys.version_info.major}.{sys.version_info.minor}/site-packages" +) +print(f"\n\033[1mUsing absolute path:\033[0m \033[95m{pypath}\033[0m\n") +sys.path.insert(0, os.path.abspath(pypath)) from pygpsclient import version as VERSION -# sys.path.insert(0, os.path.abspath('../pygpsclient')) - # -- Project information ----------------------------------------------------- project = "PyGPSClient" diff --git a/docs/pygpsclient.rst b/docs/pygpsclient.rst index ba87660f..a1b5c8a3 100644 --- a/docs/pygpsclient.rst +++ b/docs/pygpsclient.rst @@ -180,14 +180,6 @@ pygpsclient.map\_frame module :undoc-members: :show-inheritance: -pygpsclient.mapquest module ---------------------------- - -.. automodule:: pygpsclient.mapquest - :members: - :undoc-members: - :show-inheritance: - pygpsclient.mapquest\_handler module ------------------------------------ @@ -252,6 +244,14 @@ pygpsclient.receiver\_config\_handler module :undoc-members: :show-inheritance: +pygpsclient.recorder\_dialog module +----------------------------------- + +.. automodule:: pygpsclient.recorder_dialog + :members: + :undoc-members: + :show-inheritance: + pygpsclient.rover\_frame module ------------------------------- @@ -500,14 +500,6 @@ pygpsclient.ubx\_preset\_frame module :undoc-members: :show-inheritance: -pygpsclient.ubx\_recorder\_frame module ---------------------------------------- - -.. automodule:: pygpsclient.ubx_recorder_frame - :members: - :undoc-members: - :show-inheritance: - pygpsclient.ubx\_solrate\_frame module -------------------------------------- diff --git a/images/recorder_dialog.png b/images/recorder_dialog.png new file mode 100644 index 00000000..aa058e4f Binary files /dev/null and b/images/recorder_dialog.png differ diff --git a/images/ubxconfig_widget.png b/images/ubxconfig_widget.png index 86a570f9..068595f3 100644 Binary files a/images/ubxconfig_widget.png and b/images/ubxconfig_widget.png differ diff --git a/pygpsclient.json b/pygpsclient.json index c23eba3b..244bf6d1 100644 --- a/pygpsclient.json +++ b/pygpsclient.json @@ -36,6 +36,7 @@ "units_s": "Metric m/s", "autoscroll_b": 1, "maxlines_n": 200, + "maxcolumns_n": 4, "filedelay_n": 20, "consoleformat_s": "Parsed", "maptype_s": "world", diff --git a/src/pygpsclient/__main__.py b/src/pygpsclient/__main__.py index b8bd5b8b..cbd2b861 100644 --- a/src/pygpsclient/__main__.py +++ b/src/pygpsclient/__main__.py @@ -37,7 +37,7 @@ def main(): """The main tkinter loop.""" ap = ArgumentParser( - epilog=EPILOG, + epilog=f"\033[1m\033[91m{EPILOG}\033[0m", formatter_class=ArgumentDefaultsHelpFormatter, description="Command line arguments will override configuration file", ) diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py index 3d6e45dc..0be58edd 100644 --- a/src/pygpsclient/_version.py +++ b/src/pygpsclient/_version.py @@ -8,4 +8,4 @@ :license: BSD 3-Clause """ -__version__ = "1.5.22" +__version__ = "1.5.23" diff --git a/src/pygpsclient/app.py b/src/pygpsclient/app.py index 260735e1..4df2a2a3 100644 --- a/src/pygpsclient/app.py +++ b/src/pygpsclient/app.py @@ -96,6 +96,7 @@ SPARTN_PROTOCOL, STATUSPRIORITY, TTY_PROTOCOL, + UNDO, ) from pygpsclient.gnss_status import GNSSStatus from pygpsclient.helpers import check_latest @@ -111,6 +112,7 @@ DLG, DLGSTOPRTK, DLGTNTRIP, + DLGTRECORD, ENDOFFILE, INACTIVE_TIMEOUT, INTROTXTNOPORTS, @@ -131,6 +133,7 @@ COLSPAN, DEFAULT, HIDE, + MAXCOLS, MAXCOLSPAN, MAXROWSPAN, MENU, @@ -203,6 +206,9 @@ def __init__(self, master, **kwargs): # pylint: disable=too-many-statements self._socket_thread = None self._socket_server = None self.consoledata = [] + self._recorded_commands = [] # captured by RecorderDialog + self.recording = False # RecordDialog status + self.recording_type = 0 # 0 = TTY ONLY, 1 = UBX/NMEA # load config from json file configfile = kwargs.pop("config", CONFIGFILE) @@ -316,6 +322,7 @@ def _widget_grid( :rtype: tuple """ + maxcols = self.configuration.get("maxcolumns_n") # type: ignore wdg = self.widget_state.state[name] dynamic = wdg.get(COL, None) is None frm = getattr(self, wdg[FRAME]) @@ -324,8 +331,10 @@ def _widget_grid( fcol = wdg.get(COL, col) frow = wdg.get(ROW, row) colspan = wdg.get(COLSPAN, 1) + if colspan == MAXCOLS: + colspan = maxcols rowspan = wdg.get(ROWSPAN, 1) - if dynamic and fcol + colspan > MAXCOLSPAN: + if dynamic and fcol + colspan > maxcols: fcol = 0 frow += 1 frm.grid( @@ -340,7 +349,7 @@ def _widget_grid( lbl = HIDE if dynamic: col += colspan - if col >= MAXCOLSPAN: + if col >= maxcols: # type: ignore col = 0 row += rowspan maxcol = max(maxcol, fcol + colspan) @@ -1091,6 +1100,41 @@ def rtk_conn_status(self, status: int): self._rtk_conn_status = status self.frm_banner.update_rtk_status(status) + @property + def recorded_commands(self) -> list: + """ + Getter for RTK connection status. + + :return: connection status + :rtype: list + """ + + return self._recorded_commands + + @recorded_commands.setter + def recorded_commands(self, msg: UBXMessage | NMEAMessage | str | NoneType = None): + """ + Setter for recorded_commands. + + :param UBXMessage | NMEAMessage | str | NoneType msg: configuration command or None + """ + + if msg is None: + self._recorded_commands = [] + self.recording_type = 0 # 0 = TTY ONLY + elif msg == UNDO: + self._recorded_commands.pop() + if len(self._recorded_commands) == 0: + self.recording_type = 0 # 0 = TTY ONLY + else: + if isinstance(msg, (UBXMessage, NMEAMessage)): + self.recording_type = 1 # 0 = TTY ONLY, 1 = UBX/NMEA + self._recorded_commands.append(msg) + + # update RecordDialog command count, if dialog is visible + if hasattr(self.dialog(DLGTRECORD), "update_count"): + self.dialog(DLGTRECORD).update_count() + @property def protocol_mask(self) -> int: """ diff --git a/src/pygpsclient/canvas_map.py b/src/pygpsclient/canvas_map.py index a4feeaca..c9f5842f 100644 --- a/src/pygpsclient/canvas_map.py +++ b/src/pygpsclient/canvas_map.py @@ -21,7 +21,7 @@ :license: BSD 3-Clause """ -# pylint: disable=too-many-positional-arguments, too-many-arguments +# pylint: disable=too-many-positional-arguments, too-many-arguments, unused-argument from http.client import responses from io import BytesIO diff --git a/src/pygpsclient/configuration.py b/src/pygpsclient/configuration.py index 8372c689..dfe078e3 100644 --- a/src/pygpsclient/configuration.py +++ b/src/pygpsclient/configuration.py @@ -60,7 +60,7 @@ LOADCONFIGOK, LOADCONFIGRESAVE, ) -from pygpsclient.widget_state import VISIBLE +from pygpsclient.widget_state import MAXCOLSPAN, VISIBLE INITMARKER = "INIT_PRESETS" PRE_L = "presets_l" @@ -118,6 +118,7 @@ def __init__(self, app): "units_s": UMM, "autoscroll_b": 1, "maxlines_n": 100, + "maxcolumns_n": MAXCOLSPAN, # maximum number of user-selectable widget columns "filedelay_n": 20, # milliseconds "consoleformat_s": FORMAT_PARSED, "maptype_s": WORLD, @@ -364,7 +365,7 @@ def get(self, name: str) -> object: Get individual value. :param str name: name of setting - :return: setting value (or None if not exist) + :return: setting value :rtype: object :raises: KeyError if setting does not exist """ diff --git a/src/pygpsclient/confirm_box.py b/src/pygpsclient/confirm_box.py index 28cb679f..fe6364e4 100644 --- a/src/pygpsclient/confirm_box.py +++ b/src/pygpsclient/confirm_box.py @@ -37,6 +37,7 @@ def __init__(self, parent, title, prompt): self.__master = parent Toplevel.__init__(self, parent) self.title(title) # pylint: disable=E1102 + self.attributes("-topmost", True) # keep on top self.resizable(False, False) Label(self, text=prompt, anchor=W).grid( row=0, column=0, columnspan=2, padx=3, pady=5 diff --git a/src/pygpsclient/dialog_state.py b/src/pygpsclient/dialog_state.py index 9da3deb9..ce863981 100644 --- a/src/pygpsclient/dialog_state.py +++ b/src/pygpsclient/dialog_state.py @@ -22,6 +22,7 @@ from pygpsclient.importmap_dialog import ImportMapDialog from pygpsclient.nmea_config_dialog import NMEAConfigDialog from pygpsclient.ntrip_client_dialog import NTRIPConfigDialog +from pygpsclient.recorder_dialog import RecorderDialog from pygpsclient.spartn_dialog import SPARTNConfigDialog from pygpsclient.strings import ( DLG, @@ -30,6 +31,7 @@ DLGTIMPORTMAP, DLGTNMEA, DLGTNTRIP, + DLGTRECORD, DLGTSPARTN, DLGTTTY, DLGTUBX, @@ -89,5 +91,10 @@ def __init__(self): DLG: None, RESIZE: True, }, + DLGTRECORD: { + CLASS: RecorderDialog, + DLG: None, + RESIZE: False, + }, # add any new dialogs here } diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py index 22e4b5c1..1f44abcd 100644 --- a/src/pygpsclient/globals.py +++ b/src/pygpsclient/globals.py @@ -270,6 +270,7 @@ def create_circle(self: Canvas, x: int, y: int, r: int, **kwargs): TTYMARKER = "TTY<<" UBXPRESETS = "ubxpresets" UBXSIMULATOR = "ubxsimulator" +UNDO = "UNDO" UTF8 = "utf-8" VALBLANK = 1 VALNONBLANK = 2 diff --git a/src/pygpsclient/levelsview_frame.py b/src/pygpsclient/levelsview_frame.py index 911e5f70..e263e972 100644 --- a/src/pygpsclient/levelsview_frame.py +++ b/src/pygpsclient/levelsview_frame.py @@ -108,7 +108,7 @@ def init_frame(self): ydatamax=(MAX_SNR,), xtickmaj=5, ytickmaj=int(MAX_SNR / 10), - ylegend=("C/N0 dBHz",), + ylegend=("C/No dBHz",), ycol=(FGCOL,), ylabels=True, xangle=35, @@ -159,9 +159,9 @@ def update_frame(self): data = self.__app.gnss_status.gsv_data show_unused = self.__app.configuration.get("unusedsat_b") siv = len(data) - if siv == 0: - return siv = siv if show_unused else siv - unused_sats(data) + if siv <= 0: + return w, h = self.width, self.height self.init_frame() diff --git a/src/pygpsclient/menu_bar.py b/src/pygpsclient/menu_bar.py index 0dad5150..f597fc81 100644 --- a/src/pygpsclient/menu_bar.py +++ b/src/pygpsclient/menu_bar.py @@ -21,6 +21,7 @@ DLGTIMPORTMAP, DLGTNMEA, DLGTNTRIP, + DLGTRECORD, DLGTTTY, DLGTUBX, MENUABOUT, @@ -43,6 +44,7 @@ DLGTGPX, DLGTIMPORTMAP, DLGTTTY, + DLGTRECORD, ) diff --git a/src/pygpsclient/nmea_config_dialog.py b/src/pygpsclient/nmea_config_dialog.py index f8a92947..bd9937d7 100644 --- a/src/pygpsclient/nmea_config_dialog.py +++ b/src/pygpsclient/nmea_config_dialog.py @@ -17,7 +17,7 @@ from tkinter import NSEW -from pynmeagps import NMEAMessage +from pynmeagps import SET, NMEAMessage from pygpsclient.dynamic_config_frame import Dynamic_Config_Frame from pygpsclient.globals import ( @@ -177,3 +177,14 @@ def send_command(self, msg: NMEAMessage): """ self.__app.send_to_device(msg.serialize()) + self._record_command(msg) + + def _record_command(self, msg: NMEAMessage): + """ + Record command to memory if in 'record' mode. + + :param bytes msg: configuration message + """ + + if self.__app.recording and msg.msgmode == SET: + self.__app.recorded_commands = msg diff --git a/src/pygpsclient/nmea_handler.py b/src/pygpsclient/nmea_handler.py index 51db874e..79bc9713 100644 --- a/src/pygpsclient/nmea_handler.py +++ b/src/pygpsclient/nmea_handler.py @@ -46,9 +46,6 @@ def __init__(self, app): self._raw_data = None self._parsed_data = None - # Holds array of current satellites in view from NMEA GSV sentences - self.gsv_data = {} - self.gsv_log = {} # Holds cumulative log of all satellites seen def process_data(self, raw_data: bytes, parsed_data: object): """ @@ -305,26 +302,32 @@ def _process_UBX03(self, data: NMEAMessage): """ # pylint: disable=consider-using-dict-items - show_unused = self.__app.configuration.get("unusedsat_b") - self.gsv_data = {} + self.__app.gnss_status.gsv_data = {} + now = time() for i in range(data.numSv): idx = f"_{i+1:02d}" svid = getattr(data, "svid" + idx) gnss = svid2gnssid(svid) elev = getattr(data, "ele" + idx) azim = getattr(data, "azi" + idx) - cno = str(getattr(data, "cno" + idx)) + cno = getattr(data, "cno" + idx) + if not isinstance(cno, (int, float)): + cno = 0 # fudge to make PUBX03 svid numbering consistent with GSV if gnss == 2 and svid > 210: # Galileo svid -= 210 if gnss == 3 and svid > 32: # Beidou svid -= 32 - if cno in ("", "0", 0) and not show_unused: # omit unused sats - continue - self.gsv_data[f"{gnss}-{svid}"] = (gnss, svid, elev, azim, cno) + self.__app.gnss_status.gsv_data[(gnss, svid)] = ( + gnss, + svid, + elev, + azim, + cno, + now, + ) - self.__app.gnss_status.siv = len(self.gsv_data) - self.__app.gnss_status.gsv_data = self.gsv_data + self.__app.gnss_status.siv = len(self.__app.gnss_status.gsv_data) def _process_QTMVERNO(self, data: NMEAMessage): """ diff --git a/src/pygpsclient/recorder_dialog.py b/src/pygpsclient/recorder_dialog.py new file mode 100644 index 00000000..c3db5009 --- /dev/null +++ b/src/pygpsclient/recorder_dialog.py @@ -0,0 +1,530 @@ +""" +recorder_dialog.py + +Configuration command Load/Save/Record dialog for commands +sent via UBX, NMEA or TTY Configuration panels. + +Records commands to memory array and allows user to load or save +this array to or from a binary file. + +Facilitates copying configuration from one device to another. + +Created on 9 Jan 2023 + +:author: semuadmin (Steve Smith) +:copyright: 2020 semuadmin +:license: BSD 3-Clause +""" + +# pylint: disable=unused-argument + +from threading import Event, Thread +from time import sleep +from tkinter import CENTER, EW, NSEW, Button, Frame, Label, TclError, W, filedialog + +from PIL import Image, ImageTk +from pynmeagps import NMEAMessage +from pyubx2 import ( + POLL_LAYER_BBR, + POLL_LAYER_FLASH, + SET, + SET_LAYER_BBR, + SET_LAYER_FLASH, + SET_LAYER_RAM, + TXN_NONE, + U1, + UBXMessage, + UBXReader, + bytes2val, + val2bytes, +) + +from pygpsclient.globals import ( + BGCOL, + ERRCOL, + FGCOL, + HOME, + ICON_DELETE, + ICON_LOAD, + ICON_RECORD, + ICON_SAVE, + ICON_SEND, + ICON_STOP, + ICON_UNDO, + INFOCOL, + OKCOL, + PNTCOL, + UNDO, +) +from pygpsclient.helpers import set_filename +from pygpsclient.strings import DLGTRECORD, SAVETITLE +from pygpsclient.toplevel_dialog import ToplevelDialog + +CFG = b"\x06" +FLASH = 0.7 +MSG = b"\x01" +PLAY = 1 +PRT = b"\x00" +RECORD = 2 +STOP = 0 +TTYONLY = 0 +VALSET = b"\x8a" +VALGET = b"\x8b" + +MINDIM = (500, 300) + + +class RecorderDialog(ToplevelDialog): + """ + Configuration command recorder panel. + """ + + def __init__(self, app, *args, **kwargs): + """ + Constructor. + + :param Frame app: reference to main tkinter application + :param Frame container: reference to container frame (config-dialog) + :param args: optional args to pass to Frame parent class + :param kwargs: optional kwargs to pass to Frame parent class + """ + + self.__app = app + # self.__master = self.__app.appmaster # link to root Tk window + super().__init__(app, DLGTRECORD, MINDIM) + self.width = int(kwargs.get("width", 500)) + self.height = int(kwargs.get("height", 300)) + + self._img_load = ImageTk.PhotoImage(Image.open(ICON_LOAD)) + self._img_save = ImageTk.PhotoImage(Image.open(ICON_SAVE)) + 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_undo = ImageTk.PhotoImage(Image.open(ICON_UNDO)) + self._img_delete = ImageTk.PhotoImage(Image.open(ICON_DELETE)) + self._rec_status = STOP + self._configfile = None + self._stop_event = Event() + self._bg = self.cget("bg") # default background color + self._configfile = None + self._configpath = None + + self._body() + self._do_layout() + self._attach_events() + self._reset() + self._finalise() + + def _body(self): + """ + Set up frame and widgets. + """ + + self._frm_body = Frame(self.container, borderwidth=2, relief="groove", bg=BGCOL) + self._btn_load = Button( + self._frm_body, + image=self._img_load, + width=40, + command=self._on_load, + font=self.__app.font_md, + highlightbackground=BGCOL, + highlightthickness=2, + ) + self._btn_save = Button( + self._frm_body, + image=self._img_save, + width=40, + command=self._on_save, + highlightbackground=BGCOL, + highlightthickness=2, + ) + self._btn_play = Button( + self._frm_body, + image=self._img_play, + width=40, + command=self._on_play, + highlightbackground=BGCOL, + highlightthickness=2, + ) + self._btn_record = Button( + self._frm_body, + image=self._img_record, + width=40, + command=self._on_record, + highlightbackground=BGCOL, + highlightthickness=2, + ) + self._btn_undo = Button( + self._frm_body, + image=self._img_undo, + width=40, + command=self._on_undo, + highlightbackground=BGCOL, + highlightthickness=2, + ) + self._btn_delete = Button( + self._frm_body, + image=self._img_delete, + width=40, + command=self._on_delete, + highlightbackground=BGCOL, + highlightthickness=2, + ) + self._lbl_memory = Label( + self._frm_body, + text="", + anchor=CENTER, + width=4, + fg=PNTCOL, + bg=BGCOL, + font=self.__app.font_lg, + ) + self._lbl_activity = Label( + self._frm_body, text="", anchor=CENTER, bg=BGCOL, fg=FGCOL + ) + + def _do_layout(self): + """ + Layout widgets. + """ + + 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._lbl_activity.grid(column=0, row=2, columnspan=7, padx=3, sticky=EW) + + (cols, rows) = self.grid_size() + for i in range(cols): + self.grid_columnconfigure(i, weight=1) + for i in range(rows): + self.grid_rowconfigure(i, weight=1) + self.option_add("*Font", self.__app.font_sm) + + def _attach_events(self): + """ + Bind events to window. + """ + + self.container.bind("", self._on_resize) + + def _reset(self): + """ + Reset panel to initial settings + """ + + self._rec_status = STOP if self.__app.recording else RECORD + self._on_record() + self.update_count() + + def _set_configfile_path(self, ext: str = "bin") -> tuple: + """ + Set configuration file path. + + :param str ext: file extension + :return: file path + :rtype: tuple + """ + + configpath = filedialog.askdirectory( + parent=self.container, title=SAVETITLE, initialdir=HOME, mustexist=True + ) + if configpath in ((), ""): + return None, None # User cancelled + + return set_filename(configpath, "config", ext) + + def _open_configfile(self): + """ + Open configuration file. + """ + + return self.__app.file_handler.open_file( + self, + "bin", + ( + ("config files", "*.bin"), + ("TTY config files", "*.tty"), + ("u-center UBX config files", "*.txt"), + ("all files", "*.*"), + ), + ) + + def _on_load(self): + """ + Load commands from file into in-memory recording. + """ + + self._configfile = self._open_configfile() + if self._configfile is None: # user cancelled + return + + self.__app.recorded_commands = None + self.status_label = ("Loading commands...", INFOCOL) + + try: + if self._configfile[-3:] == "txt": + i = self._on_load_txt(self._configfile) + elif self._configfile[-3:] == "tty": + i = self._on_load_tty(self._configfile) + else: + i = self._on_load_ubx(self._configfile) + except Exception: # pylint: disable=broad-exception-caught + i = 0 + self.status_label = (f"ERROR parsing {self._configfile}!", ERRCOL) + + self.update_count() + if i > 0: + fname = self._configfile.split("/")[-1] + self.status_label = ( + f"{i} Command{'s' if i > 1 else ''} loaded from {fname}", + OKCOL, + ) + + def _on_load_ubx(self, fname: str) -> int: + """ + Load binary ubx configuration file + + :param str fname: input filename + :return: no of items read + :rtype: int + """ + + i = 0 + with open(fname, "rb") as file: + ubr = UBXReader(file, msgmode=SET) + eof = False + while not eof: + _, parsed = ubr.read() + if parsed is not None: + self.__app.recorded_commands = parsed + i += 1 + else: + eof = True + return i + + def _on_load_tty(self, fname: str) -> int: + """ + Load binary TTY configuration file + + :param str fname: input filename + :return: no of items read + :rtype: int + """ + + i = 0 + with open(fname, "rb") as file: + for line in file: + self.__app.recorded_commands = line + i += 1 + return i + + def _on_load_txt(self, fname: str) -> int: + """ + Load u-center format text configuration file. + + Any messages other than CFG-MSG, CFG-PRT or CFG-VALGET are discarded. + The CFG-VALGET messages are converted into CFG-VALGET. + + :param str fname: input file name + :return: no of items read + :rtype: int + """ + + i = 0 + with open(fname, "r", encoding="utf-8") as file: + for line in file: + parts = line.replace(" ", "").split("-") + data = bytes.fromhex(parts[-1]) + cls = data[0:1] + mid = data[1:2] + if cls != CFG: + continue + if mid == VALGET: + version = data[4:5] + layer = bytes2val(data[5:6], U1) + if layer == POLL_LAYER_BBR: + layers = SET_LAYER_BBR + elif layer == POLL_LAYER_FLASH: + layers = SET_LAYER_FLASH + else: + layers = SET_LAYER_RAM + layers = val2bytes(layers, U1) + transaction = val2bytes(TXN_NONE, U1) # not transactional + reserved0 = b"\x00" + cfgdata = data[8:] + payload = version + layers + transaction + reserved0 + cfgdata + parsed = UBXMessage(CFG, VALSET, SET, payload=payload) + else: # legacy CFG command + parsed = UBXMessage(CFG, mid, SET, payload=data[4:]) + if parsed is not None: + self.__app.recorded_commands = parsed + i += 1 + return i + + def _on_save(self): + """ + Save commands from in-memory recording to file. + """ + + if self._rec_status == RECORD: + return + + if len(self.__app.recorded_commands) == 0: + self.status_label = ("Nothing to save", ERRCOL) + return + + ext = "tty" if self.__app.recording_type == TTYONLY else "bin" + fname, self._configfile = self._set_configfile_path(ext) + if self._configfile is None: # user cancelled + return + + self.status_label = ("Saving commands...", INFOCOL) + i = 0 + with open(self._configfile, "wb") as file: + for i, msg in enumerate(self.__app.recorded_commands): + if isinstance(msg, (UBXMessage, NMEAMessage)): + msg = msg.serialize() + file.write(msg) + self.__app.recorded_commands = None + self.update_count() + self.status_label = ( + f"{i + 1} command{'s' if i > 0 else ''} saved to {fname}", + OKCOL, + ) + + def _on_play(self): + """ + Send commands to device from in-memory recording. + """ + + if self._rec_status == RECORD: + return + + if len(self.__app.recorded_commands) == 0: + self.status_label = ("Nothing to send", ERRCOL) + return + + i = 0 + if self._rec_status == STOP: + self._rec_status = PLAY + for i, msg in enumerate(self.__app.recorded_commands): + mid = getattr(self.__app.recorded_commands[-1], "identity", "tty") + self.status_label = (f"{i} Sending {mid}", INFOCOL) + if isinstance(msg, (UBXMessage, NMEAMessage)): + msg = msg.serialize() + self.__app.send_to_device(msg) + sleep(0.01) + self._rec_status = STOP + self.status_label = ( + f"{i + 1} command{'s' if i > 0 else ''} sent to device", + OKCOL, + ) + + self._update_status() + + def _on_record(self): + """ + Add commands to in-memory recording. + """ + + if self._rec_status == STOP: + self._rec_status = RECORD + self.__app.recording = True + # start flashing record label... + self._stop_event.clear() + Thread( + target=self._flash_record, + daemon=True, + args=(self._stop_event,), + ).start() + elif self._rec_status == RECORD: + self._stop_event.set() + self._rec_status = STOP + self.__app.recording = False + + stat = "started" if self._rec_status else "stopped" + self.status_label = (f"Recording {stat}", INFOCOL) + self._update_status() + + def _on_undo(self): + """ + Remove last record from in-memory recording. + """ + + if len(self.__app.recorded_commands) == 0: + self.status_label = ("Nothing to undo", ERRCOL) + return + + if self._rec_status == STOP: + if len(self.__app.recorded_commands) > 0: + self.__app.recorded_commands = UNDO + self.status_label = ("Last command undone", INFOCOL) + + self.update_count() + + def _on_delete(self): + """ + Delete all records in in-memory recording. + """ + + if self._rec_status == RECORD: + return + + lcs = len(self.__app.recorded_commands) + if lcs == 0: + self.status_label = ("Nothing to delete", ERRCOL) + return + + self.__app.recorded_commands = None + self.status_label = (f"{lcs} command{'s' if lcs > 1 else ''} deleted", INFOCOL) + + self.update_count() + + def _update_status(self): + """ + Update recording status. + """ + + pimg = rimg = None + if self._rec_status == STOP: + pimg = self._img_play + rimg = self._img_record + elif self._rec_status == PLAY: + pimg = self._img_stop + rimg = self._img_record + elif self._rec_status == RECORD: + pimg = self._img_play + rimg = self._img_stop + self._btn_play.config(image=pimg) + self._btn_record.config(image=rimg) + + def update_count(self): + """ + Update command count. + """ + + self._lbl_memory.config(text=len(self.__app.recorded_commands)) + + def _flash_record(self, stop: Event): + """ + THREADED + Flash record indicator for conspicuity. + """ + + try: + cols = [("white", ERRCOL), (ERRCOL, "white")] + i = 0 + while not stop.is_set(): + i = not i + self._lbl_activity.config( + text="RECORDING", fg=cols[i][0], bg=cols[i][1] + ) + sleep(FLASH) + self._lbl_activity.config(text="", fg=FGCOL, bg=BGCOL) + except TclError: # if dialog closed without stopping recording + pass diff --git a/src/pygpsclient/settings_frame.py b/src/pygpsclient/settings_frame.py index 7f7ed5dc..1423fa26 100644 --- a/src/pygpsclient/settings_frame.py +++ b/src/pygpsclient/settings_frame.py @@ -115,7 +115,7 @@ ) MAXLINES = ("200", "500", "1000", "2000", "100") -FILEDELAYS = (10, 20, 50, 100, 200, 500, 1000, 2000) +FILEDELAYS = (2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000) # initial dimensions adjusted for different widget # rendering on different platforms if system() == "Linux": # Wayland diff --git a/src/pygpsclient/skyview_frame.py b/src/pygpsclient/skyview_frame.py index 807be43a..19af71c3 100644 --- a/src/pygpsclient/skyview_frame.py +++ b/src/pygpsclient/skyview_frame.py @@ -108,9 +108,9 @@ def update_frame(self): data = self.__app.gnss_status.gsv_data show_unused = self.__app.configuration.get("unusedsat_b") siv = len(data) - if siv == 0: - return siv = siv if show_unused else siv - unused_sats(data) + if siv <= 0: + return self.init_frame() diff --git a/src/pygpsclient/strings.py b/src/pygpsclient/strings.py index 31791e2d..26ecb299 100644 --- a/src/pygpsclient/strings.py +++ b/src/pygpsclient/strings.py @@ -138,7 +138,7 @@ LBLSERVERMODE = "Mode" LBLSERVERPORT = "Port" LBLSET = "Settings" -LBLSHOWUNUSED = "Include C/N0 = 0" +LBLSHOWUNUSED = "Include C/No = 0" LBLSOCKSERVE = "Socket Server /\nNTRIP Caster " # padded to align LBLSPARTNCONFIG = "SPARTN Client" LBLSPARTNGN = "GNSS RECEIVER CONFIGURATION (F9*)" @@ -184,6 +184,7 @@ DLGTABOUT = f"About {TITLE}" DLGTGPX = "GPX Track Viewer" DLGTNTRIP = "NTRIP Configuration" +DLGTRECORD = "Configuration Command Recorder" DLGTSPARTN = "SPARTN Configuration" DLGTNMEA = "NMEA Configuration" DLGTUBX = "UBX Configuration" diff --git a/src/pygpsclient/tty_preset_dialog.py b/src/pygpsclient/tty_preset_dialog.py index f1bd7761..1f26d60e 100644 --- a/src/pygpsclient/tty_preset_dialog.py +++ b/src/pygpsclient/tty_preset_dialog.py @@ -289,6 +289,7 @@ def _parse_command(self, command: str): if self._crlf.get(): cmd += CRLF self.__app.send_to_device(cmd) + self._record_command(cmd) if self._echo.get(): # echo output command to console self.__app.consoledata.append( (cmd, cmd.decode(ASCII, errors=BSR), TTYMARKER) @@ -297,6 +298,16 @@ def _parse_command(self, command: str): self.status_label = (f"Error {err}", ERRCOL) self._lbl_send_command.config(image=self.img_warn) + def _record_command(self, msg: bytes): + """ + Record command to memory if in 'record' mode. + + :param bytes msg: configuration message + """ + + if self.__app.recording: + self.__app.recorded_commands = msg + def update_status(self, msg: bytes): """ Update pending confirmation status. diff --git a/src/pygpsclient/ubx_config_dialog.py b/src/pygpsclient/ubx_config_dialog.py index bb3a4c4c..8e06ea34 100644 --- a/src/pygpsclient/ubx_config_dialog.py +++ b/src/pygpsclient/ubx_config_dialog.py @@ -25,7 +25,7 @@ from tkinter import NSEW -from pyubx2 import UBXMessage +from pyubx2 import SET, UBXMessage from pygpsclient.dynamic_config_frame import Dynamic_Config_Frame from pygpsclient.globals import ( @@ -51,7 +51,6 @@ from pygpsclient.ubx_msgrate_frame import UBX_MSGRATE_Frame from pygpsclient.ubx_port_frame import UBX_PORT_Frame from pygpsclient.ubx_preset_frame import UBX_PRESET_Frame -from pygpsclient.ubx_recorder_frame import UBX_Recorder_Frame from pygpsclient.ubx_solrate_frame import UBX_RATE_Frame MINDIM = (570, 1076) @@ -94,9 +93,6 @@ def _body(self): self._frm_device_info = Hardware_Info_Frame( self.__app, self, borderwidth=2, relief="groove", protocol="UBX" ) - self._frm_recorder = UBX_Recorder_Frame( - self.__app, self, borderwidth=2, relief="groove" - ) self._frm_config_port = UBX_PORT_Frame( self.__app, self, borderwidth=2, relief="groove" ) @@ -130,7 +126,6 @@ def _do_layout(self): colsp = 0 for frm in ( self._frm_device_info, - self._frm_recorder, self._frm_config_port, self._frm_config_rate, self._frm_config_msg, @@ -264,12 +259,14 @@ def send_command(self, msg: UBXMessage): """ self.__app.send_to_device(msg.serialize()) - self.record_command(msg) + self._record_command(msg) - def record_command(self, msg: UBXMessage): + def _record_command(self, msg: UBXMessage): """ Record command to memory if in 'record' mode. + + :param bytes msg: configuration message """ - if self.recordmode: - self._frm_recorder.update_record(msg) + if self.__app.recording and msg.msgmode == SET: + self.__app.recorded_commands = msg diff --git a/src/pygpsclient/ubx_handler.py b/src/pygpsclient/ubx_handler.py index 560e5f0f..792438d9 100644 --- a/src/pygpsclient/ubx_handler.py +++ b/src/pygpsclient/ubx_handler.py @@ -45,8 +45,6 @@ def __init__(self, app): self._cdb = 0 self._raw_data = None self._parsed_data = None - # Holds array of current satellites in view from NMEA GSV or UBX NAV-SVINFO sentences - self.gsv_data = {} def process_data(self, raw_data: bytes, parsed_data: object): """ @@ -324,7 +322,7 @@ def _process_NAV_SAT(self, data: UBXMessage): :param UBXMessage data: NAV-SAT parsed message """ - self.gsv_data = {} + self.__app.gnss_status.gsv_data = {} num_siv = int(data.numSvs) now = time() @@ -338,10 +336,16 @@ def _process_NAV_SAT(self, data: UBXMessage): elev = getattr(data, "elev" + idx) azim = getattr(data, "azim" + idx) cno = getattr(data, "cno" + idx) - self.gsv_data[(gnssId, svid)] = (gnssId, svid, elev, azim, cno, now) + self.__app.gnss_status.gsv_data[(gnssId, svid)] = ( + gnssId, + svid, + elev, + azim, + cno, + now, + ) - self.__app.gnss_status.siv = len(self.gsv_data) - self.__app.gnss_status.gsv_data = self.gsv_data + self.__app.gnss_status.siv = len(self.__app.gnss_status.gsv_data) def _process_NAV_STATUS(self, data: UBXMessage): """ @@ -376,9 +380,9 @@ def _process_NAV_SVINFO(self, data: UBXMessage): :param UBXMessage data: NAV-SVINFO parsed message """ - show_unused = self.__app.configuration.get("unusedsat_b") - self.gsv_data = {} + self.__app.gnss_status.gsv_data = {} num_siv = int(data.numCh) + now = time() for i in range(num_siv): idx = f"_{i+1:02d}" @@ -387,11 +391,16 @@ def _process_NAV_SVINFO(self, data: UBXMessage): elev = getattr(data, "elev" + idx) azim = getattr(data, "azim" + idx) cno = getattr(data, "cno" + idx) - if cno == 0 and not show_unused: # omit unused sats - continue - self.gsv_data[f"{gnssId}-{svid}"] = (gnssId, svid, elev, azim, cno) + self.__app.gnss_status.gsv_data[(gnssId, svid)] = ( + gnssId, + svid, + elev, + azim, + cno, + now, + ) - self.__app.gnss_status.gsv_data = self.gsv_data + self.__app.gnss_status.siv = len(self.__app.gnss_status.gsv_data) def _process_NAV_SOL(self, data: UBXMessage): """ diff --git a/src/pygpsclient/ubx_msgrate_frame.py b/src/pygpsclient/ubx_msgrate_frame.py index da70e3da..e9aec77b 100644 --- a/src/pygpsclient/ubx_msgrate_frame.py +++ b/src/pygpsclient/ubx_msgrate_frame.py @@ -162,24 +162,24 @@ def _do_layout(self): """ self._lbl_cfg_msg.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) self._lbx_cfg_msg.grid( - column=0, row=1, columnspan=2, rowspan=11, padx=3, pady=3, sticky=EW + column=0, row=1, columnspan=2, rowspan=6, padx=3, pady=3, sticky=EW ) - self._scr_cfg_msg.grid(column=1, row=1, rowspan=11, sticky=(N, S, E)) - self._lbl_usb.grid(column=2, row=1, rowspan=2, padx=0, pady=1, sticky=E) - self._spn_usb.grid(column=3, row=1, rowspan=2, padx=0, pady=0, sticky=W) - self._lbl_uart1.grid(column=2, row=3, rowspan=2, padx=0, pady=1, sticky=E) - self._spn_uart1.grid(column=3, row=3, rowspan=2, padx=0, pady=0, sticky=W) - self._lbl_uart2.grid(column=2, row=5, rowspan=2, padx=0, pady=1, sticky=E) - self._spn_uart2.grid(column=3, row=5, rowspan=2, padx=0, pady=0, sticky=W) - self._lbl_ddc.grid(column=2, row=7, rowspan=2, padx=0, pady=1, sticky=E) - self._spn_ddc.grid(column=3, row=7, rowspan=2, padx=0, pady=0, sticky=W) - self._lbl_spi.grid(column=2, row=9, rowspan=2, padx=0, pady=1, sticky=E) - self._spn_spi.grid(column=3, row=9, rowspan=2, padx=0, pady=0, sticky=W) + self._scr_cfg_msg.grid(column=1, row=1, rowspan=6, sticky=(N, S, E)) + self._lbl_usb.grid(column=2, row=1, padx=0, pady=1, sticky=E) + self._spn_usb.grid(column=3, row=1, padx=0, pady=0, sticky=W) + self._lbl_uart1.grid(column=2, row=2, padx=0, pady=1, sticky=E) + self._spn_uart1.grid(column=3, row=2, padx=0, pady=0, sticky=W) + self._lbl_uart2.grid(column=2, row=3, padx=0, pady=1, sticky=E) + self._spn_uart2.grid(column=3, row=3, padx=0, pady=0, sticky=W) + self._lbl_ddc.grid(column=2, row=4, padx=0, pady=1, sticky=E) + self._spn_ddc.grid(column=3, row=4, padx=0, pady=0, sticky=W) + self._lbl_spi.grid(column=2, row=5, padx=0, pady=1, sticky=E) + self._spn_spi.grid(column=3, row=5, padx=0, pady=0, sticky=W) self._btn_send_command.grid( - column=4, row=1, rowspan=11, ipadx=3, ipady=3, sticky=E + column=4, row=1, rowspan=6, ipadx=3, ipady=3, sticky=E ) self._lbl_send_command.grid( - column=5, row=1, rowspan=11, ipadx=3, ipady=3, sticky=E + column=5, row=1, rowspan=6, ipadx=3, ipady=3, sticky=E ) (cols, rows) = self.grid_size() diff --git a/src/pygpsclient/ubx_recorder_frame.py b/src/pygpsclient/ubx_recorder_frame.py deleted file mode 100644 index 0fe56c95..00000000 --- a/src/pygpsclient/ubx_recorder_frame.py +++ /dev/null @@ -1,483 +0,0 @@ -""" -ubx_recorder_frame.py - -UBX Player/Recorder widget for CFG commands entered by user via UBX -Configuration panel. - -Records commands to memory array and allows user to load or save -this array to or from a file. - -Facilitates copying configuration from one device to another. - -Created on 9 Jan 2023 - -:author: semuadmin (Steve Smith) -:copyright: 2020 semuadmin -:license: BSD 3-Clause -""" - -from threading import Event, Thread -from time import sleep -from tkinter import EW, Button, Frame, Label, TclError, W, filedialog - -from PIL import Image, ImageTk -from pyubx2 import ( - POLL_LAYER_BBR, - POLL_LAYER_FLASH, - SET, - SET_LAYER_BBR, - SET_LAYER_FLASH, - SET_LAYER_RAM, - TXN_NONE, - U1, - UBX_PROTOCOL, - UBXMessage, - UBXReader, - bytes2val, - val2bytes, -) - -from pygpsclient.globals import ( - ERRCOL, - HOME, - ICON_DELETE, - ICON_LOAD, - ICON_RECORD, - ICON_SAVE, - ICON_SEND, - ICON_STOP, - ICON_UNDO, -) -from pygpsclient.helpers import set_filename -from pygpsclient.strings import LBLCFGRECORD, SAVETITLE - -STOP = 0 -PLAY = 1 -RECORD = 2 -FLASH = 0.7 -CFG = b"\x06" -VALSET = b"\x8a" -VALGET = b"\x8b" -MSG = b"\x01" -PRT = b"\x00" - - -class UBX_Recorder_Frame(Frame): - """ - UBX Configuration command recorder panel. - """ - - def __init__(self, app, container, *args, **kwargs): - """ - Constructor. - - :param Frame app: reference to main tkinter application - :param Frame container: reference to container frame (config-dialog) - :param args: optional args to pass to Frame parent class - :param kwargs: optional kwargs to pass to Frame parent class - """ - - self.__app = app # Reference to main application class - self.__master = self.__app.appmaster # Reference to root class (Tk) - self.__container = container # Reference to UBX Configuration dialog - - super().__init__(container.container, *args, **kwargs) - - self._img_load = ImageTk.PhotoImage(Image.open(ICON_LOAD)) - self._img_save = ImageTk.PhotoImage(Image.open(ICON_SAVE)) - 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_undo = ImageTk.PhotoImage(Image.open(ICON_UNDO)) - self._img_delete = ImageTk.PhotoImage(Image.open(ICON_DELETE)) - self._cmds_stored = [] - self._rec_status = STOP - self._configfile = None - self._stop_event = Event() - self._bg = self.cget("bg") # default background color - self._configfile = None - self._configpath = None - - self._body() - self._do_layout() - self.reset() - - def _body(self): - """ - Set up frame and widgets. - """ - - self._lbl_recorder = Label(self, text=LBLCFGRECORD, anchor=W) - - self._btn_load = Button( - self, - image=self._img_load, - width=40, - command=self._on_load, - font=self.__app.font_md, - ) - self._btn_save = Button( - self, - image=self._img_save, - width=40, - command=self._on_save, - font=self.__app.font_md, - ) - self._btn_play = Button( - self, - image=self._img_play, - width=40, - command=self._on_play, - font=self.__app.font_md, - ) - self._btn_record = Button( - self, - image=self._img_record, - width=40, - command=self._on_record, - font=self.__app.font_md, - ) - self._btn_undo = Button( - self, - image=self._img_undo, - width=40, - command=self._on_undo, - font=self.__app.font_md, - ) - self._btn_delete = Button( - self, - image=self._img_delete, - width=40, - command=self._on_delete, - font=self.__app.font_md, - ) - self._lbl_status = Label(self, text="", anchor="center") - self._lbl_activity = Label(self, text="", anchor="center") - - def _do_layout(self): - """ - Layout widgets. - """ - - self._lbl_recorder.grid(column=0, row=0, columnspan=6, padx=3, sticky=EW) - self._btn_load.grid(column=0, row=1, ipadx=3, ipady=3, sticky=W) - self._btn_save.grid(column=1, row=1, ipadx=3, ipady=3, sticky=W) - self._btn_play.grid(column=2, row=1, ipadx=3, ipady=3, sticky=W) - self._btn_record.grid(column=3, row=1, ipadx=3, ipady=3, sticky=W) - self._btn_undo.grid(column=4, row=1, ipadx=3, ipady=3, sticky=W) - self._btn_delete.grid(column=5, row=1, ipadx=3, ipady=3, sticky=W) - self._lbl_status.grid(column=0, row=2, columnspan=6, padx=3, sticky=EW) - self._lbl_activity.grid(column=0, row=3, columnspan=6, padx=3, sticky=EW) - - (cols, rows) = self.grid_size() - for i in range(cols): - self.grid_columnconfigure(i, weight=1) - for i in range(rows): - self.grid_rowconfigure(i, weight=1) - self.option_add("*Font", self.__app.font_sm) - - def reset(self): - """ - Reset panel to initial settings - """ - - self._rec_status = STOP - self._update_status() - - def _set_configfile_path(self) -> tuple: - """ - Set configuration file path. - - :return: file path - :rtype: tuple - """ - - configpath = filedialog.askdirectory( - parent=self.__container, title=SAVETITLE, initialdir=HOME, mustexist=True - ) - if configpath in ((), ""): - return None, None # User cancelled - - return set_filename(configpath, "config", "ubx") - - def _open_configfile(self): - """ - Open configuration file. - """ - - return self.__app.file_handler.open_file( - self, - "ubx", - ( - ("ubx config files", "*.ubx"), - ("u-center config files", "*.txt"), - ("all files", "*.*"), - ), - ) - - def _on_load(self): - """ - Load commands from file into in-memory recording. - """ - - self._configfile = self._open_configfile() - if self._configfile is None: # user cancelled - return - - self._cmds_stored = [] - self._update_activity("Loading commands...") - - if self._configfile[-3:] == "txt": - i = self._on_load_txt(self._configfile) - else: - i = self._on_load_ubx(self._configfile) - - if i > 0: - fname = self._configfile.split("/")[-1] - self._update_activity( - f"{i} Command{'s' if i > 1 else ''} loaded from {fname}" - ) - - self._update_status() - - def _on_load_ubx(self, fname: str) -> int: - """ - Load binary ubx configuration file - - :param str fname: input filename - :return: no of items read - :rtype: int - """ - - try: - with open(fname, "rb") as file: - ubr = UBXReader(file, protfilter=UBX_PROTOCOL, msgmode=SET) - eof = False - i = 0 - while not eof: - _, parsed = ubr.read() - if parsed is not None: - self._cmds_stored.append(parsed) - i += 1 - else: - eof = True - except Exception: # pylint: disable=broad-exception-caught - self._update_activity(f"ERROR parsing {fname}!") - return 0 - - return i - - def _on_load_txt(self, fname: str) -> int: - """ - Load u-center format text configuration file. - - Any messages other than CFG-MSG, CFG-PRT or CFG-VALGET are discarded. - The CFG-VALGET messages are converted into CFG-VALGET. - - :param str fname: input file name - :return: no of items read - :rtype: int - """ - - try: - with open(fname, "r", encoding="utf-8") as file: - i = 0 - for line in file: - parts = line.replace(" ", "").split("-") - data = bytes.fromhex(parts[-1]) - cls = data[0:1] - mid = data[1:2] - if cls != CFG: - continue - if mid == VALGET: - version = data[4:5] - layer = bytes2val(data[5:6], U1) - if layer == POLL_LAYER_BBR: - layers = SET_LAYER_BBR - elif layer == POLL_LAYER_FLASH: - layers = SET_LAYER_FLASH - else: - layers = SET_LAYER_RAM - layers = val2bytes(layers, U1) - transaction = val2bytes(TXN_NONE, U1) # not transactional - reserved0 = b"\x00" - cfgdata = data[8:] - payload = version + layers + transaction + reserved0 + cfgdata - parsed = UBXMessage(CFG, VALSET, SET, payload=payload) - else: # legacy CFG command - parsed = UBXMessage(CFG, mid, SET, payload=data[4:]) - if parsed is not None: - self._cmds_stored.append(parsed) - i += 1 - except Exception: # pylint: disable=broad-exception-caught - self._update_activity(f"ERROR parsing {fname}!") - return 0 - - return i - - def _on_save(self): - """ - Save commands from in-memory recording to file. - """ - - if self._rec_status == RECORD: - return - - if len(self._cmds_stored) == 0: - self._update_activity("Nothing to save") - return - - fname, self._configfile = self._set_configfile_path() - if self._configfile is None: - return - - self._update_activity("Saving commands...") - with open(self._configfile, "wb") as file: - i = 0 - for i, msg in enumerate(self._cmds_stored): - file.write(msg.serialize()) - self._cmds_stored = [] - self._update_activity(f"{i + 1} command{'s' if i > 0 else ''} saved to {fname}") - self._update_status() - - def _on_play(self): - """ - Send commands to device from in-memory recording. - """ - - if self._rec_status == RECORD: - return - - if len(self._cmds_stored) == 0: - self._update_activity("Nothing to send") - return - - if self._rec_status == STOP: - self._rec_status = PLAY - i = 0 - for i, msg in enumerate(self._cmds_stored): - self._update_activity(f"{i} Sending {msg.identity}") - self.__app.send_to_device(msg.serialize()) - sleep(0.01) - self._update_activity( - f"{i + 1} command{'s' if i > 0 else ''} sent to device" - ) - self._rec_status = STOP - self._update_status() - - def _on_record(self): - """ - Add commands to in-memory recording. - """ - - if self._rec_status == STOP: - self._rec_status = RECORD - self.__container.recordmode = True - # start flashing record label... - self._stop_event.clear() - Thread( - target=self._flash_record, - daemon=True, - args=(self._stop_event,), - ).start() - elif self._rec_status == RECORD: - self._stop_event.set() - self._rec_status = STOP - self.__container.recordmode = False - self._update_activity("Recording stopped") - self._update_status() - - def _on_undo(self): - """ - Remove last record from in-memory recording. - """ - - if len(self._cmds_stored) == 0: - self._update_activity("Nothing to undo") - return - - if self._rec_status == STOP: - if len(self._cmds_stored) > 0: - self._cmds_stored.pop() - self._update_activity("Last command undone") - self._update_status() - - def _on_delete(self): - """ - Delete all records in in-memory recording. - """ - - if self._rec_status == RECORD: - return - - if len(self._cmds_stored) == 0: - self._update_activity("Nothing to delete") - return - - i = len(self._cmds_stored) - self._update_activity(f"{i} command{'s' if i > 1 else ''} deleted") - self._cmds_stored = [] - self._update_status() - - def _update_status(self): - """ - Update status label. - """ - - lcs = len(self._cmds_stored) - lst = f". Last command: {self._cmds_stored[-1].identity}" if lcs > 0 else "" - self._lbl_status.config(text=f"Commands in memory: {lcs}{lst}") - pimg = rimg = None - - if self._rec_status == STOP: - pimg = self._img_play - rimg = self._img_record - elif self._rec_status == PLAY: - pimg = self._img_stop - rimg = self._img_record - elif self._rec_status == RECORD: - pimg = self._img_play - rimg = self._img_stop - - self._btn_play.config(image=pimg) - self._btn_record.config(image=rimg) - - def _update_activity(self, msg: str): - """ - Update activity label. - - :param str msg: message - """ - - if len(msg) > 55: - msg = f"{msg[0:30]}...{msg[-22:]}" - self._lbl_activity.config(text=msg) - - def update_record(self, msg: UBXMessage): - """ - Add UBX CFG SET command to in-memory recording. - - :param UBXMessage msg: message to record - """ - - if msg.msgmode == SET: - self._cmds_stored.append(msg) - self._update_status() - - def _flash_record(self, stop: Event): - """ - THREADED - Flash record indicator for conspicuity. - """ - - try: - cols = [("white", ERRCOL), (ERRCOL, "white")] - i = 0 - while not stop.is_set(): - i = not i - self._lbl_activity.config( - text="RECORDING", fg=cols[i][0], bg=cols[i][1] - ) - sleep(FLASH) - except TclError: # if dialog closed without stopping recording - pass diff --git a/src/pygpsclient/widget_state.py b/src/pygpsclient/widget_state.py index ef30defd..2d3ce23e 100644 --- a/src/pygpsclient/widget_state.py +++ b/src/pygpsclient/widget_state.py @@ -44,6 +44,7 @@ class definition and update `ubx_handler` to populate them. DEFAULT = "def" HIDE = "Hide" MAXCOLSPAN = 4 # max no of widget columns +MAXCOLS = 999 # always occupy the full row MAXROWSPAN = 4 # max no of widget rows MENU = "men" RESET = "rst" @@ -118,7 +119,7 @@ def __init__(self): CLASS: ConsoleFrame, FRAME: "frm_console", VISIBLE: True, - COLSPAN: MAXCOLSPAN, + COLSPAN: MAXCOLS, }, WDGSATS: { DEFAULT: True, diff --git a/tests/test_static.py b/tests/test_static.py index 9b9da6bc..d1fc7f34 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), 151) + self.assertEqual(len(cfg.settings), 152) kwargs = {"userport": "/dev/ttyACM0", "spartnport": "/dev/ttyACM1"} cfg.loadcli(**kwargs) self.assertEqual(cfg.get("userport_s"), "/dev/ttyACM0")