From 82554a1f186853f7fc63627194d49719c867c85d Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Sat, 15 Feb 2025 02:23:13 -0500 Subject: [PATCH 1/9] Add optional parameter to specify what fields to show with --nodes --- .vscode/launch.json | 17 ++++++++++++++++ meshtastic/__main__.py | 13 +++++++++++- meshtastic/mesh_interface.py | 38 +++++++++++++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index c179060..4103a6a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -245,6 +245,23 @@ "module": "meshtastic", "justMyCode": true, "args": ["--debug", "--nodes"] + }, + { + "name": "meshtastic nodes table", + "type": "debugpy", + "request": "launch", + "module": "meshtastic", + "justMyCode": true, + "args": ["--nodes"] + }, + { + "name": "meshtastic nodes table with show-fields", + "type": "debugpy", + "request": "launch", + "module": "meshtastic", + "justMyCode": true, + "args": ["--nodes", "--show-fields", "AKA,Pubkey,Role,Role,Role,Latitude,Latitude,deviceMetrics.voltage"] } + ] } diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 8f2aaf9..85c906a 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -921,7 +921,11 @@ def onConnected(interface): if args.dest != BROADCAST_ADDR: print("Showing node list of a remote node is not supported.") return - interface.showNodes() + interface.showNodes(True, args.show_fields) + + if args.show_fields and not args.nodes: + print("--show-fields can only be used with --nodes") + return if args.qr or args.qr_all: closeNow = True @@ -1626,6 +1630,13 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars help="Print Node List in a pretty formatted table", action="store_true", ) + + group.add_argument( + "--show-fields", + help="Specify fields to show (comma-separated) when using --nodes", + type=lambda s: s.split(','), + default=None + ) return parser diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index 57545c6..035f75c 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -222,7 +222,7 @@ class MeshInterface: # pylint: disable=R0902 return infos def showNodes( - self, includeSelf: bool = True + self, includeSelf: bool = True, showFields: Optional[List[str]] = None ) -> str: # pylint: disable=W0613 """Show table summary of nodes in mesh""" @@ -246,6 +246,23 @@ class MeshInterface: # pylint: disable=R0902 return None # not handling a timestamp from the future return _timeago(delta_secs) + def getNestedValue(node_dict: Dict[str, Any], key_path: str) -> Any: + keys = key_path.split(".") + value = node_dict + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + return None + return value + + if showFields is None or showFields.count == 0: + # The default set of fields to show (e.g., the status quo) + showFields = ["N", "User", "ID", "AKA", "Hardware", "Pubkey", "Role", "Latitude", "Longitude", "Altitude", "Battery", "Channel util.", "Tx air util.", "SNR", "Hops", "Channel", "LastHeard", "Since"] + else: + # Always at least include the row number. + showFields.insert(0, "N") + rows: List[Dict[str, Any]] = [] if self.nodesByNum: logging.debug(f"self.nodes:{self.nodes}") @@ -287,11 +304,12 @@ class MeshInterface: # pylint: disable=R0902 if metrics: batteryLevel = metrics.get("batteryLevel") if batteryLevel is not None: - if batteryLevel == 0: + if batteryLevel in (0, 101): # Note: for boards without battery pin or PMU, 101% battery means 'the board is using external power' batteryString = "Powered" else: batteryString = str(batteryLevel) + "%" row.update({"Battery": batteryString}) + row.update( { "Channel util.": formatFloat( @@ -313,7 +331,21 @@ class MeshInterface: # pylint: disable=R0902 } ) - rows.append(row) + # This allows the user to specify fields that wouldn't otherwise be included. + extraFields = {} + for field in showFields: + if field in row: + # We already have it, move along. + continue + elif "." in field: + extraFields[field] = getNestedValue(node, field) + else: + extraFields[field] = node.get(field) + + # Filter out any field in the data set that was not specified. + filteredData = {key: value for key, value in row.items() if key in showFields} + filteredData.update(extraFields) + rows.append(filteredData) rows.sort(key=lambda r: r.get("LastHeard") or "0000", reverse=True) for i, row in enumerate(rows): From 6ebddb67c0655d30dfaf011d15ae9dec26e8fc85 Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Sat, 15 Feb 2025 17:18:14 -0500 Subject: [PATCH 2/9] Refactored showNodes. It now uses a lookup table to determine the human-readable names of the fields. --- meshtastic/mesh_interface.py | 160 +++++++++++++++++++---------------- 1 file changed, 89 insertions(+), 71 deletions(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index 035f75c..8ccb0da 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -224,7 +224,40 @@ class MeshInterface: # pylint: disable=R0902 def showNodes( self, includeSelf: bool = True, showFields: Optional[List[str]] = None ) -> str: # pylint: disable=W0613 - """Show table summary of nodes in mesh""" + """Show table summary of nodes in mesh + + Args: + includeSelf (bool): Include ourself in the output? + showFields (List[str]): List of fields to show in output + """ + + def get_human_readable(name): + name_map = { + "user.longName": "User", + "user.id": "ID", + "user.shortName": "AKA", + "user.hwModel": "Hardware", + "user.publicKey": "Pubkey", + "user.role": "Role", + "position.latitude": "Latitude", + "position.longitude": "Longitude", + "position.altitude": "Altitude", + "deviceMetrics.batteryLevel": "Battery", + "deviceMetrics.channelUtilization": "Channel util.", + "deviceMetrics.airUtilTx": "Tx air util.", + "snr": "SNR", + "hopsAway": "Hops", + "channel": "Channel", + "lastHeard": "LastHeard", + "since": "Since", + + } + + if name in name_map: + return name_map.get(name) # Default to a formatted guess + else: + return name + def formatFloat(value, precision=2, unit="") -> Optional[str]: """Format a float value with precision.""" @@ -247,6 +280,9 @@ class MeshInterface: # pylint: disable=R0902 return _timeago(delta_secs) def getNestedValue(node_dict: Dict[str, Any], key_path: str) -> Any: + if key_path.index(".") < 0: + logging.debug("getNestedValue was called without a nested path.") + return None keys = key_path.split(".") value = node_dict for key in keys: @@ -258,7 +294,10 @@ class MeshInterface: # pylint: disable=R0902 if showFields is None or showFields.count == 0: # The default set of fields to show (e.g., the status quo) - showFields = ["N", "User", "ID", "AKA", "Hardware", "Pubkey", "Role", "Latitude", "Longitude", "Altitude", "Battery", "Channel util.", "Tx air util.", "SNR", "Hops", "Channel", "LastHeard", "Since"] + showFields = ["N", "user.longName", "user.id", "user.shortName", "user.hwModel", "user.publicKey", + "user.role", "position.latitude", "position.longitude", "position.altitude", + "deviceMetrics.batteryLevel", "deviceMetrics.channelUtilization", + "deviceMetrics.airUtilTx", "snr", "hopsAway", "channel", "lastHeard", "since"] else: # Always at least include the row number. showFields.insert(0, "N") @@ -271,80 +310,59 @@ class MeshInterface: # pylint: disable=R0902 continue presumptive_id = f"!{node['num']:08x}" - row = { - "N": 0, - "User": f"Meshtastic {presumptive_id[-4:]}", - "ID": presumptive_id, - } - - user = node.get("user") - if user: - row.update( - { - "User": user.get("longName", "N/A"), - "AKA": user.get("shortName", "N/A"), - "ID": user["id"], - "Hardware": user.get("hwModel", "UNSET"), - "Pubkey": user.get("publicKey", "UNSET"), - "Role": user.get("role", "N/A"), - } - ) - - pos = node.get("position") - if pos: - row.update( - { - "Latitude": formatFloat(pos.get("latitude"), 4, "°"), - "Longitude": formatFloat(pos.get("longitude"), 4, "°"), - "Altitude": formatFloat(pos.get("altitude"), 0, " m"), - } - ) - - metrics = node.get("deviceMetrics") - if metrics: - batteryLevel = metrics.get("batteryLevel") - if batteryLevel is not None: - if batteryLevel in (0, 101): # Note: for boards without battery pin or PMU, 101% battery means 'the board is using external power' - batteryString = "Powered" - else: - batteryString = str(batteryLevel) + "%" - row.update({"Battery": batteryString}) - - row.update( - { - "Channel util.": formatFloat( - metrics.get("channelUtilization"), 2, "%" - ), - "Tx air util.": formatFloat( - metrics.get("airUtilTx"), 2, "%" - ), - } - ) - - row.update( - { - "SNR": formatFloat(node.get("snr"), 2, " dB"), - "Hops": node.get("hopsAway", "?"), - "Channel": node.get("channel", 0), - "LastHeard": getLH(node.get("lastHeard")), - "Since": getTimeAgo(node.get("lastHeard")), - } - ) # This allows the user to specify fields that wouldn't otherwise be included. - extraFields = {} + fields = {} for field in showFields: - if field in row: - # We already have it, move along. - continue - elif "." in field: - extraFields[field] = getNestedValue(node, field) + if "." in field: + raw_value = getNestedValue(node, field) else: - extraFields[field] = node.get(field) - + # The "since" column is synthesized, it's not retrieved from the device. Get the + # lastHeard value here, and then we'll format it properly below. + if field == "since": + raw_value = node.get("lastHeard") + else: + raw_value = node.get(field) + + formatted_value = "" + + # Some of these need special formatting or processing. + if field == "channel": + if raw_value is None: + formatted_value = "0" + elif field == "deviceMetrics.channelUtilization": + formatted_value = formatFloat(raw_value, 2, "%") + elif field == "deviceMetrics.airUtilTx": + formatted_value = formatFloat(raw_value, 2, "%") + elif field == "deviceMetrics.batteryLevel": + if raw_value in (0, 101): + formatted_value = "Powered" + else: + formatted_value = formatFloat(raw_value, 0, "%") + elif field == "lastHeard": + formatted_value = getLH(raw_value) + elif field == "position.latitude": + formatted_value = formatFloat(raw_value, 4, "°") + elif field == "position.longitude": + formatted_value = formatFloat(raw_value, 4, "°") + elif field == "position.altitude": + formatted_value = formatFloat(raw_value, 0, "m") + elif field == "since": + formatted_value = getTimeAgo(raw_value) + elif field == "snr": + formatted_value = formatFloat(raw_value, 0, " dB") + elif field == "user.shortName": + formatted_value = raw_value if raw_value is not None else f'Meshtastic {presumptive_id[-4:]}' + elif field == "user.id": + formatted_value = raw_value if raw_value is not None else presumptive_id + else: + formatted_value = raw_value # No special formatting + + fields[field] = formatted_value + # Filter out any field in the data set that was not specified. - filteredData = {key: value for key, value in row.items() if key in showFields} - filteredData.update(extraFields) + filteredData = {get_human_readable(k): v for k, v in fields.items() if k in showFields} + filteredData.update({get_human_readable(k): v for k, v in fields.items()}) rows.append(filteredData) rows.sort(key=lambda r: r.get("LastHeard") or "0000", reverse=True) From 6bc5f5e3056d953b42d51aa3cfdd1c5b65ba208c Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Tue, 18 Feb 2025 21:15:31 -0500 Subject: [PATCH 3/9] Fix count vs len on a list (https://github.com/meshtastic/python/pull/736#discussion_r1960099918) --- meshtastic/mesh_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index 8ccb0da..e006493 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -292,7 +292,7 @@ class MeshInterface: # pylint: disable=R0902 return None return value - if showFields is None or showFields.count == 0: + if showFields is None or len(showFields) == 0: # The default set of fields to show (e.g., the status quo) showFields = ["N", "user.longName", "user.id", "user.shortName", "user.hwModel", "user.publicKey", "user.role", "position.latitude", "position.longitude", "position.altitude", From 27ac28e300d4139c9f1a572e5e3fb73c0c5448f2 Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Tue, 18 Feb 2025 21:18:22 -0500 Subject: [PATCH 4/9] Fixing indentation per project coding standards. --- meshtastic/mesh_interface.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index e006493..7fde8fc 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -294,13 +294,13 @@ class MeshInterface: # pylint: disable=R0902 if showFields is None or len(showFields) == 0: # The default set of fields to show (e.g., the status quo) - showFields = ["N", "user.longName", "user.id", "user.shortName", "user.hwModel", "user.publicKey", - "user.role", "position.latitude", "position.longitude", "position.altitude", - "deviceMetrics.batteryLevel", "deviceMetrics.channelUtilization", - "deviceMetrics.airUtilTx", "snr", "hopsAway", "channel", "lastHeard", "since"] + showFields = ["N", "user.longName", "user.id", "user.shortName", "user.hwModel", "user.publicKey", + "user.role", "position.latitude", "position.longitude", "position.altitude", + "deviceMetrics.batteryLevel", "deviceMetrics.channelUtilization", + "deviceMetrics.airUtilTx", "snr", "hopsAway", "channel", "lastHeard", "since"] else: - # Always at least include the row number. - showFields.insert(0, "N") + # Always at least include the row number. + showFields.insert(0, "N") rows: List[Dict[str, Any]] = [] if self.nodesByNum: From 53e40d5aab4b26814c1e5c4e0c391aa9866413a9 Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Tue, 18 Feb 2025 22:05:46 -0500 Subject: [PATCH 5/9] Fix a pylint error --- meshtastic/mesh_interface.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index 7fde8fc..3d527ac 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -324,7 +324,7 @@ class MeshInterface: # pylint: disable=R0902 else: raw_value = node.get(field) - formatted_value = "" + formatted_value: Optional[str] = "" # Some of these need special formatting or processing. if field == "channel": @@ -348,7 +348,7 @@ class MeshInterface: # pylint: disable=R0902 elif field == "position.altitude": formatted_value = formatFloat(raw_value, 0, "m") elif field == "since": - formatted_value = getTimeAgo(raw_value) + formatted_value = getTimeAgo(raw_value) or "N/A" elif field == "snr": formatted_value = formatFloat(raw_value, 0, " dB") elif field == "user.shortName": From c7c3c69fc3bd045b358aca2da4cd579771b10ca9 Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Tue, 18 Feb 2025 22:08:26 -0500 Subject: [PATCH 6/9] Fix a pylint error regarding typing. --- meshtastic/mesh_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index 3d527ac..934bedc 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -284,7 +284,7 @@ class MeshInterface: # pylint: disable=R0902 logging.debug("getNestedValue was called without a nested path.") return None keys = key_path.split(".") - value = node_dict + value: Union[str, dict, None] = node_dict for key in keys: if isinstance(value, dict): value = value.get(key) From 2f48594dc3fa91acc73051b1de3c4a9f6d13ad29 Mon Sep 17 00:00:00 2001 From: David Andrzejewski Date: Tue, 18 Feb 2025 22:09:37 -0500 Subject: [PATCH 7/9] Fix a pylint error regarding typing. --- meshtastic/mesh_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index 934bedc..0e4aba9 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -284,7 +284,7 @@ class MeshInterface: # pylint: disable=R0902 logging.debug("getNestedValue was called without a nested path.") return None keys = key_path.split(".") - value: Union[str, dict, None] = node_dict + value: Optional[Union[str, dict]] = node_dict for key in keys: if isinstance(value, dict): value = value.get(key) From 68ec588804e9b083183029a44dc80c53914cc03b Mon Sep 17 00:00:00 2001 From: Ian McEwen Date: Wed, 19 Feb 2025 09:15:45 -0700 Subject: [PATCH 8/9] remove trailing whitespace --- meshtastic/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 8fb7c8f..03de15a 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -1649,7 +1649,7 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars help="Print Node List in a pretty formatted table", action="store_true", ) - + group.add_argument( "--show-fields", help="Specify fields to show (comma-separated) when using --nodes", From dd8803793dbe7c15a535ec1b4bf612928941a6fc Mon Sep 17 00:00:00 2001 From: Ian McEwen Date: Wed, 19 Feb 2025 09:21:51 -0700 Subject: [PATCH 9/9] fix test mock --- meshtastic/tests/test_main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meshtastic/tests/test_main.py b/meshtastic/tests/test_main.py index 0739e07..a4386c9 100644 --- a/meshtastic/tests/test_main.py +++ b/meshtastic/tests/test_main.py @@ -408,8 +408,8 @@ def test_main_nodes(capsys): iface = MagicMock(autospec=SerialInterface) - def mock_showNodes(): - print("inside mocked showNodes") + def mock_showNodes(includeSelf, showFields): + print(f"inside mocked showNodes: {includeSelf} {showFields}") iface.showNodes.side_effect = mock_showNodes with patch("meshtastic.serial_interface.SerialInterface", return_value=iface) as mo: