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



+[](https://github.com/semuconsulting/pygpsclient/actions/workflows/deploy.yml)



@@ -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
. 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
- 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.
+ 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
.
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.
|| 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 / 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 |
|| 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 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). |
-|| Levels view widget showing current satellite carrier-to-noise (CNo) levels for each GNSS constellation. Double-click to toggle legend. |
+|| Levels view widget showing current satellite carrier-to-noise (C/No) levels for each GNSS constellation. Double-click to toggle legend. |
|| 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.
|| 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. |
|| 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  a sequence of UBX CFG configuration commands, and to save  this recording to a file (as binary CFG-* messages). Saved files can be reloaded  and the configuration commands replayed . 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` .
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

+---
+## Configuration Command Load/Save/Record Facility
+
+
+
+This allows users to record  a sequence of UBX, NMEA or TTY configuration commands as they are sent to a device, and to save  this recording to a file. Saved files can be reloaded  and the configuration commands replayed . 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` .
+
---
## 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")