Skip to content

Commit acc4714

Browse files
authored
Merge pull request #736 from dandrzejewski/master
Add optional CLI parameter to specify node info fields to show with --nodes parameter.
2 parents 5837bd0 + dd88037 commit acc4714

File tree

4 files changed

+139
-61
lines changed

4 files changed

+139
-61
lines changed

.vscode/launch.json

+17
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,23 @@
245245
"module": "meshtastic",
246246
"justMyCode": true,
247247
"args": ["--debug", "--nodes"]
248+
},
249+
{
250+
"name": "meshtastic nodes table",
251+
"type": "debugpy",
252+
"request": "launch",
253+
"module": "meshtastic",
254+
"justMyCode": true,
255+
"args": ["--nodes"]
256+
},
257+
{
258+
"name": "meshtastic nodes table with show-fields",
259+
"type": "debugpy",
260+
"request": "launch",
261+
"module": "meshtastic",
262+
"justMyCode": true,
263+
"args": ["--nodes", "--show-fields", "AKA,Pubkey,Role,Role,Role,Latitude,Latitude,deviceMetrics.voltage"]
248264
}
265+
249266
]
250267
}

meshtastic/__main__.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -921,7 +921,11 @@ def setSimpleConfig(modem_preset):
921921
if args.dest != BROADCAST_ADDR:
922922
print("Showing node list of a remote node is not supported.")
923923
return
924-
interface.showNodes()
924+
interface.showNodes(True, args.show_fields)
925+
926+
if args.show_fields and not args.nodes:
927+
print("--show-fields can only be used with --nodes")
928+
return
925929

926930
if args.qr or args.qr_all:
927931
closeNow = True
@@ -1646,6 +1650,13 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
16461650
action="store_true",
16471651
)
16481652

1653+
group.add_argument(
1654+
"--show-fields",
1655+
help="Specify fields to show (comma-separated) when using --nodes",
1656+
type=lambda s: s.split(','),
1657+
default=None
1658+
)
1659+
16491660
return parser
16501661

16511662
def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:

meshtastic/mesh_interface.py

+108-58
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,42 @@ def showInfo(self, file=sys.stdout) -> str: # pylint: disable=W0613
222222
return infos
223223

224224
def showNodes(
225-
self, includeSelf: bool = True
225+
self, includeSelf: bool = True, showFields: Optional[List[str]] = None
226226
) -> str: # pylint: disable=W0613
227-
"""Show table summary of nodes in mesh"""
227+
"""Show table summary of nodes in mesh
228+
229+
Args:
230+
includeSelf (bool): Include ourself in the output?
231+
showFields (List[str]): List of fields to show in output
232+
"""
233+
234+
def get_human_readable(name):
235+
name_map = {
236+
"user.longName": "User",
237+
"user.id": "ID",
238+
"user.shortName": "AKA",
239+
"user.hwModel": "Hardware",
240+
"user.publicKey": "Pubkey",
241+
"user.role": "Role",
242+
"position.latitude": "Latitude",
243+
"position.longitude": "Longitude",
244+
"position.altitude": "Altitude",
245+
"deviceMetrics.batteryLevel": "Battery",
246+
"deviceMetrics.channelUtilization": "Channel util.",
247+
"deviceMetrics.airUtilTx": "Tx air util.",
248+
"snr": "SNR",
249+
"hopsAway": "Hops",
250+
"channel": "Channel",
251+
"lastHeard": "LastHeard",
252+
"since": "Since",
253+
254+
}
255+
256+
if name in name_map:
257+
return name_map.get(name) # Default to a formatted guess
258+
else:
259+
return name
260+
228261

229262
def formatFloat(value, precision=2, unit="") -> Optional[str]:
230263
"""Format a float value with precision."""
@@ -246,6 +279,29 @@ def getTimeAgo(ts) -> Optional[str]:
246279
return None # not handling a timestamp from the future
247280
return _timeago(delta_secs)
248281

282+
def getNestedValue(node_dict: Dict[str, Any], key_path: str) -> Any:
283+
if key_path.index(".") < 0:
284+
logging.debug("getNestedValue was called without a nested path.")
285+
return None
286+
keys = key_path.split(".")
287+
value: Optional[Union[str, dict]] = node_dict
288+
for key in keys:
289+
if isinstance(value, dict):
290+
value = value.get(key)
291+
else:
292+
return None
293+
return value
294+
295+
if showFields is None or len(showFields) == 0:
296+
# The default set of fields to show (e.g., the status quo)
297+
showFields = ["N", "user.longName", "user.id", "user.shortName", "user.hwModel", "user.publicKey",
298+
"user.role", "position.latitude", "position.longitude", "position.altitude",
299+
"deviceMetrics.batteryLevel", "deviceMetrics.channelUtilization",
300+
"deviceMetrics.airUtilTx", "snr", "hopsAway", "channel", "lastHeard", "since"]
301+
else:
302+
# Always at least include the row number.
303+
showFields.insert(0, "N")
304+
249305
rows: List[Dict[str, Any]] = []
250306
if self.nodesByNum:
251307
logging.debug(f"self.nodes:{self.nodes}")
@@ -254,66 +310,60 @@ def getTimeAgo(ts) -> Optional[str]:
254310
continue
255311

256312
presumptive_id = f"!{node['num']:08x}"
257-
row = {
258-
"N": 0,
259-
"User": f"Meshtastic {presumptive_id[-4:]}",
260-
"ID": presumptive_id,
261-
}
262-
263-
user = node.get("user")
264-
if user:
265-
row.update(
266-
{
267-
"User": user.get("longName", "N/A"),
268-
"AKA": user.get("shortName", "N/A"),
269-
"ID": user["id"],
270-
"Hardware": user.get("hwModel", "UNSET"),
271-
"Pubkey": user.get("publicKey", "UNSET"),
272-
"Role": user.get("role", "N/A"),
273-
}
274-
)
275-
276-
pos = node.get("position")
277-
if pos:
278-
row.update(
279-
{
280-
"Latitude": formatFloat(pos.get("latitude"), 4, "°"),
281-
"Longitude": formatFloat(pos.get("longitude"), 4, "°"),
282-
"Altitude": formatFloat(pos.get("altitude"), 0, " m"),
283-
}
284-
)
285313

286-
metrics = node.get("deviceMetrics")
287-
if metrics:
288-
batteryLevel = metrics.get("batteryLevel")
289-
if batteryLevel is not None:
290-
if batteryLevel == 0:
291-
batteryString = "Powered"
314+
# This allows the user to specify fields that wouldn't otherwise be included.
315+
fields = {}
316+
for field in showFields:
317+
if "." in field:
318+
raw_value = getNestedValue(node, field)
319+
else:
320+
# The "since" column is synthesized, it's not retrieved from the device. Get the
321+
# lastHeard value here, and then we'll format it properly below.
322+
if field == "since":
323+
raw_value = node.get("lastHeard")
292324
else:
293-
batteryString = str(batteryLevel) + "%"
294-
row.update({"Battery": batteryString})
295-
row.update(
296-
{
297-
"Channel util.": formatFloat(
298-
metrics.get("channelUtilization"), 2, "%"
299-
),
300-
"Tx air util.": formatFloat(
301-
metrics.get("airUtilTx"), 2, "%"
302-
),
303-
}
304-
)
325+
raw_value = node.get(field)
326+
327+
formatted_value: Optional[str] = ""
328+
329+
# Some of these need special formatting or processing.
330+
if field == "channel":
331+
if raw_value is None:
332+
formatted_value = "0"
333+
elif field == "deviceMetrics.channelUtilization":
334+
formatted_value = formatFloat(raw_value, 2, "%")
335+
elif field == "deviceMetrics.airUtilTx":
336+
formatted_value = formatFloat(raw_value, 2, "%")
337+
elif field == "deviceMetrics.batteryLevel":
338+
if raw_value in (0, 101):
339+
formatted_value = "Powered"
340+
else:
341+
formatted_value = formatFloat(raw_value, 0, "%")
342+
elif field == "lastHeard":
343+
formatted_value = getLH(raw_value)
344+
elif field == "position.latitude":
345+
formatted_value = formatFloat(raw_value, 4, "°")
346+
elif field == "position.longitude":
347+
formatted_value = formatFloat(raw_value, 4, "°")
348+
elif field == "position.altitude":
349+
formatted_value = formatFloat(raw_value, 0, "m")
350+
elif field == "since":
351+
formatted_value = getTimeAgo(raw_value) or "N/A"
352+
elif field == "snr":
353+
formatted_value = formatFloat(raw_value, 0, " dB")
354+
elif field == "user.shortName":
355+
formatted_value = raw_value if raw_value is not None else f'Meshtastic {presumptive_id[-4:]}'
356+
elif field == "user.id":
357+
formatted_value = raw_value if raw_value is not None else presumptive_id
358+
else:
359+
formatted_value = raw_value # No special formatting
305360

306-
row.update(
307-
{
308-
"SNR": formatFloat(node.get("snr"), 2, " dB"),
309-
"Hops": node.get("hopsAway", "?"),
310-
"Channel": node.get("channel", 0),
311-
"LastHeard": getLH(node.get("lastHeard")),
312-
"Since": getTimeAgo(node.get("lastHeard")),
313-
}
314-
)
361+
fields[field] = formatted_value
315362

316-
rows.append(row)
363+
# Filter out any field in the data set that was not specified.
364+
filteredData = {get_human_readable(k): v for k, v in fields.items() if k in showFields}
365+
filteredData.update({get_human_readable(k): v for k, v in fields.items()})
366+
rows.append(filteredData)
317367

318368
rows.sort(key=lambda r: r.get("LastHeard") or "0000", reverse=True)
319369
for i, row in enumerate(rows):

meshtastic/tests/test_main.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,8 @@ def test_main_nodes(capsys):
408408

409409
iface = MagicMock(autospec=SerialInterface)
410410

411-
def mock_showNodes():
412-
print("inside mocked showNodes")
411+
def mock_showNodes(includeSelf, showFields):
412+
print(f"inside mocked showNodes: {includeSelf} {showFields}")
413413

414414
iface.showNodes.side_effect = mock_showNodes
415415
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface) as mo:

0 commit comments

Comments
 (0)