Skip to content

Commit 577dfbd

Browse files
committed
Added request/send/broadcast userinfo.
--broadcast-userinfo Broadcasts to ALL nodes One-way (no response expected) Ignores --dest parameter --request-userinfo Sends to specific node (requires --dest) Two-way (expects response back) Request-response communication --send-userinfo Sends to specific node (requires --dest) One-way (no response expected) Point-to-point communication So if you want to: Send to everyone: use --broadcast-userinfo Get info back from someone: use --request-userinfo --dest <nodeid> Send to someone without reply: use --send-userinfo --dest <nodeid>
1 parent dcd077d commit 577dfbd

3 files changed

Lines changed: 206 additions & 3 deletions

File tree

meshtastic/__main__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,36 @@ def onConnected(interface):
568568
telemetryType=telemType,
569569
)
570570

571+
if args.request_userinfo:
572+
if args.dest == BROADCAST_ADDR:
573+
meshtastic.util.our_exit("Warning: Must use a destination node ID.")
574+
else:
575+
channelIndex = mt_config.channel_index or 0
576+
if checkChannel(interface, channelIndex):
577+
print(
578+
f"Sending userinfo request to {args.dest} on channelIndex:{channelIndex} (this could take a while)"
579+
)
580+
interface.request_user_info(destinationId=args.dest, channelIndex=channelIndex)
581+
closeNow = True
582+
583+
if args.broadcast_userinfo:
584+
channelIndex = mt_config.channel_index or 0
585+
if args.dest != BROADCAST_ADDR:
586+
print("Warning: --broadcast-userinfo ignores --dest and always broadcasts to all nodes")
587+
if checkChannel(interface, channelIndex):
588+
print(f"Broadcasting our userinfo to all nodes on channelIndex:{channelIndex}")
589+
interface.request_user_info(destinationId=BROADCAST_ADDR, wantResponse=False, channelIndex=channelIndex)
590+
closeNow = True
591+
592+
if args.send_userinfo:
593+
channelIndex = mt_config.channel_index or 0
594+
if args.dest == BROADCAST_ADDR:
595+
meshtastic.util.our_exit("Error: --send-userinfo requires a destination node ID with --dest")
596+
if checkChannel(interface, channelIndex):
597+
print(f"Sending our userinfo to {args.dest} on channelIndex:{channelIndex}")
598+
interface.request_user_info(destinationId=args.dest, wantResponse=False, channelIndex=channelIndex)
599+
closeNow = True
600+
571601
if args.request_position:
572602
if args.dest == BROADCAST_ADDR:
573603
meshtastic.util.our_exit("Warning: Must use a destination node ID.")
@@ -1876,6 +1906,27 @@ def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
18761906
action="store_true",
18771907
)
18781908

1909+
group.add_argument(
1910+
"--request-userinfo",
1911+
help="Request user information from a specific node. "
1912+
"You need to pass the destination ID as an argument with '--dest'. "
1913+
"For repeaters, the nodeNum is required.",
1914+
action="store_true",
1915+
)
1916+
1917+
group.add_argument(
1918+
"--broadcast-userinfo",
1919+
help="Broadcast your user information to all nodes in the mesh network.",
1920+
action="store_true",
1921+
)
1922+
1923+
group.add_argument(
1924+
"--send-userinfo",
1925+
help="Send your user information to a specific node without requesting a response. "
1926+
"Must be used with --dest to specify the destination node.",
1927+
action="store_true",
1928+
)
1929+
18791930
group.add_argument(
18801931
"--reply", help="Reply to received messages", action="store_true"
18811932
)

meshtastic/mesh_interface.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
protocols,
3737
publishingThread,
3838
)
39-
from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2
39+
from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2, admin_pb2
4040
from meshtastic.util import (
4141
Acknowledgment,
4242
Timeout,
@@ -483,6 +483,60 @@ def sendAlert(
483483
priority=mesh_pb2.MeshPacket.Priority.ALERT
484484
)
485485

486+
def request_user_info(
487+
self,
488+
destinationId: Union[int, str],
489+
wantResponse: bool = True,
490+
channelIndex: int = 0,
491+
) -> mesh_pb2.MeshPacket:
492+
"""Request user information from another node by sending our own user info.
493+
The remote node will respond with their user info when they receive this request.
494+
495+
Arguments:
496+
destinationId {nodeId or nodeNum} -- The node to request info from
497+
498+
Keyword Arguments:
499+
wantResponse {bool} -- Whether to request a response with the target's user info (default: True)
500+
channelIndex {int} -- The channel to use for this request (default: 0)
501+
502+
Returns:
503+
The sent packet. The id field will be populated and can be used to track responses.
504+
"""
505+
# Get our node's user info to send in the request
506+
my_node_info = self.getMyNodeInfo()
507+
logger.debug(f"Local node info: {my_node_info}")
508+
509+
if my_node_info is None or "user" not in my_node_info:
510+
raise MeshInterface.MeshInterfaceError("Could not get local node user info")
511+
512+
# Create a User message with our info
513+
user = mesh_pb2.User()
514+
node_user = my_node_info["user"]
515+
logger.debug(f"Local user info to send: {node_user}")
516+
517+
# Copy fields from our node's user info, matching firmware behavior
518+
user.id = node_user.get("id", "") # Set to nodeDB->getNodeId() in firmware
519+
user.long_name = node_user.get("longName", "")
520+
user.short_name = node_user.get("shortName", "")
521+
user.hw_model = node_user.get("hwModel", 0)
522+
user.is_licensed = node_user.get("is_licensed", False)
523+
user.role = node_user.get("role", 0)
524+
525+
# Handle public key - firmware strips it if node is licensed
526+
if "public_key" in node_user and not user.is_licensed:
527+
user.public_key = node_user["public_key"]
528+
529+
# Send our user info to request the remote user's info
530+
# Using BACKGROUND priority as per firmware default
531+
return self.sendData(
532+
user.SerializeToString(),
533+
destinationId,
534+
portNum=portnums_pb2.PortNum.NODEINFO_APP,
535+
wantResponse=wantResponse,
536+
channelIndex=channelIndex,
537+
priority=mesh_pb2.MeshPacket.Priority.BACKGROUND
538+
)
539+
486540
def sendMqttClientProxyMessage(self, topic: str, data: bytes):
487541
"""Send an MQTT Client Proxy message to the radio.
488542
@@ -1315,8 +1369,14 @@ def _handleFromRadio(self, fromRadioBytes):
13151369

13161370
elif fromRadio.HasField("node_info"):
13171371
logger.debug(f"Received nodeinfo: {asDict['nodeInfo']}")
1318-
1319-
node = self._getOrCreateByNum(asDict["nodeInfo"]["num"])
1372+
1373+
# Track if this is a response to our user info request
1374+
node_num = asDict["nodeInfo"]["num"]
1375+
if "user" in asDict["nodeInfo"]:
1376+
node_id = asDict["nodeInfo"]["user"].get("id", "")
1377+
logger.debug(f"Received node info from node {node_id} (num: {node_num})")
1378+
1379+
node = self._getOrCreateByNum(node_num)
13201380
node.update(asDict["nodeInfo"])
13211381
try:
13221382
newpos = self._fixupPosition(node["position"])
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Test request_user_info functionality."""
2+
3+
import logging
4+
import pytest
5+
from unittest.mock import MagicMock, patch
6+
7+
from meshtastic.mesh_interface import MeshInterface
8+
from meshtastic.protobuf import mesh_pb2, portnums_pb2
9+
from meshtastic import BROADCAST_ADDR
10+
11+
12+
@pytest.mark.unit
13+
@pytest.mark.usefixtures("reset_mt_config")
14+
def test_request_user_info_missing_node_info():
15+
"""Test request_user_info when local node info is not available"""
16+
iface = MeshInterface(noProto=True)
17+
with pytest.raises(MeshInterface.MeshInterfaceError) as exc_info:
18+
iface.request_user_info(destinationId=1)
19+
assert "Could not get local node user info" in str(exc_info.value)
20+
21+
22+
@pytest.mark.unit
23+
@pytest.mark.usefixtures("reset_mt_config")
24+
def test_request_user_info_valid(caplog):
25+
"""Test request_user_info with valid node info"""
26+
with caplog.at_level(logging.DEBUG):
27+
iface = MeshInterface(noProto=True)
28+
29+
# Mock getMyNodeInfo to return valid user data
30+
mock_user = {
31+
"user": {
32+
"id": "!12345678",
33+
"long_name": "Test Node",
34+
"short_name": "TN",
35+
"hw_model": 1,
36+
"is_licensed": False,
37+
"role": 0,
38+
"public_key": b"testkey"
39+
}
40+
}
41+
iface.getMyNodeInfo = MagicMock(return_value=mock_user)
42+
43+
# Call request_user_info
44+
result = iface.request_user_info(destinationId=1)
45+
46+
# Verify a mesh packet was created with correct fields
47+
assert isinstance(result, mesh_pb2.MeshPacket)
48+
assert result.decoded.portnum == portnums_pb2.PortNum.NODEINFO_APP_VALUE
49+
assert result.want_response == True
50+
assert result.to == 1
51+
52+
# Verify the serialized user info was sent as payload
53+
decoded_user = mesh_pb2.User()
54+
decoded_user.ParseFromString(result.decoded.payload)
55+
assert decoded_user.id == "!12345678"
56+
assert decoded_user.long_name == "Test Node"
57+
assert decoded_user.short_name == "TN"
58+
assert decoded_user.hw_model == 1
59+
assert decoded_user.is_licensed == False
60+
assert decoded_user.role == 0
61+
assert decoded_user.public_key == b"testkey"
62+
63+
64+
@pytest.mark.unit
65+
@pytest.mark.usefixtures("reset_mt_config")
66+
def test_request_user_info_response_handling(caplog):
67+
"""Test handling of responses to user info requests"""
68+
with caplog.at_level(logging.DEBUG):
69+
iface = MeshInterface(noProto=True)
70+
iface.nodes = {} # Initialize nodes dict
71+
72+
# Mock user info in response packet
73+
user_info = mesh_pb2.User()
74+
user_info.id = "!abcdef12"
75+
user_info.long_name = "Remote Node"
76+
user_info.short_name = "RN"
77+
78+
# Create response packet
79+
packet = mesh_pb2.MeshPacket()
80+
packet.from_ = 123 # Note: Using from_ to avoid Python keyword
81+
packet.decoded.portnum = portnums_pb2.PortNum.NODEINFO_APP_VALUE
82+
packet.decoded.payload = user_info.SerializeToString()
83+
84+
# Process the received packet
85+
iface._handlePacketFromRadio(packet)
86+
87+
# Verify node info was stored correctly
88+
assert "!abcdef12" in iface.nodes
89+
stored_node = iface.nodes["!abcdef12"]
90+
assert stored_node["user"]["id"] == "!abcdef12"
91+
assert stored_node["user"]["longName"] == "Remote Node"
92+
assert stored_node["user"]["shortName"] == "RN"

0 commit comments

Comments
 (0)