From 577dfbd4ecc2ac9d236f27bf349472e197087153 Mon Sep 17 00:00:00 2001 From: Kellina Date: Mon, 27 Oct 2025 11:45:44 -0600 Subject: [PATCH 1/2] 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 Send to someone without reply: use --send-userinfo --dest --- meshtastic/__main__.py | 51 ++++++++++++ meshtastic/mesh_interface.py | 66 +++++++++++++++- meshtastic/tests/test_request_user_info.py | 92 ++++++++++++++++++++++ 3 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 meshtastic/tests/test_request_user_info.py diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 08ecf57c..dd07e5f4 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -568,6 +568,36 @@ def onConnected(interface): telemetryType=telemType, ) + if args.request_userinfo: + if args.dest == BROADCAST_ADDR: + meshtastic.util.our_exit("Warning: Must use a destination node ID.") + else: + channelIndex = mt_config.channel_index or 0 + if checkChannel(interface, channelIndex): + print( + f"Sending userinfo request to {args.dest} on channelIndex:{channelIndex} (this could take a while)" + ) + interface.request_user_info(destinationId=args.dest, channelIndex=channelIndex) + closeNow = True + + if args.broadcast_userinfo: + channelIndex = mt_config.channel_index or 0 + if args.dest != BROADCAST_ADDR: + print("Warning: --broadcast-userinfo ignores --dest and always broadcasts to all nodes") + if checkChannel(interface, channelIndex): + print(f"Broadcasting our userinfo to all nodes on channelIndex:{channelIndex}") + interface.request_user_info(destinationId=BROADCAST_ADDR, wantResponse=False, channelIndex=channelIndex) + closeNow = True + + if args.send_userinfo: + channelIndex = mt_config.channel_index or 0 + if args.dest == BROADCAST_ADDR: + meshtastic.util.our_exit("Error: --send-userinfo requires a destination node ID with --dest") + if checkChannel(interface, channelIndex): + print(f"Sending our userinfo to {args.dest} on channelIndex:{channelIndex}") + interface.request_user_info(destinationId=args.dest, wantResponse=False, channelIndex=channelIndex) + closeNow = True + if args.request_position: if args.dest == BROADCAST_ADDR: meshtastic.util.our_exit("Warning: Must use a destination node ID.") @@ -1876,6 +1906,27 @@ def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar action="store_true", ) + group.add_argument( + "--request-userinfo", + help="Request user information from a specific node. " + "You need to pass the destination ID as an argument with '--dest'. " + "For repeaters, the nodeNum is required.", + action="store_true", + ) + + group.add_argument( + "--broadcast-userinfo", + help="Broadcast your user information to all nodes in the mesh network.", + action="store_true", + ) + + group.add_argument( + "--send-userinfo", + help="Send your user information to a specific node without requesting a response. " + "Must be used with --dest to specify the destination node.", + action="store_true", + ) + group.add_argument( "--reply", help="Reply to received messages", action="store_true" ) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index c269209d..77ded951 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -36,7 +36,7 @@ protocols, publishingThread, ) -from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2 +from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2, admin_pb2 from meshtastic.util import ( Acknowledgment, Timeout, @@ -483,6 +483,60 @@ def sendAlert( priority=mesh_pb2.MeshPacket.Priority.ALERT ) + def request_user_info( + self, + destinationId: Union[int, str], + wantResponse: bool = True, + channelIndex: int = 0, + ) -> mesh_pb2.MeshPacket: + """Request user information from another node by sending our own user info. + The remote node will respond with their user info when they receive this request. + + Arguments: + destinationId {nodeId or nodeNum} -- The node to request info from + + Keyword Arguments: + wantResponse {bool} -- Whether to request a response with the target's user info (default: True) + channelIndex {int} -- The channel to use for this request (default: 0) + + Returns: + The sent packet. The id field will be populated and can be used to track responses. + """ + # Get our node's user info to send in the request + my_node_info = self.getMyNodeInfo() + logger.debug(f"Local node info: {my_node_info}") + + if my_node_info is None or "user" not in my_node_info: + raise MeshInterface.MeshInterfaceError("Could not get local node user info") + + # Create a User message with our info + user = mesh_pb2.User() + node_user = my_node_info["user"] + logger.debug(f"Local user info to send: {node_user}") + + # Copy fields from our node's user info, matching firmware behavior + user.id = node_user.get("id", "") # Set to nodeDB->getNodeId() in firmware + user.long_name = node_user.get("longName", "") + user.short_name = node_user.get("shortName", "") + user.hw_model = node_user.get("hwModel", 0) + user.is_licensed = node_user.get("is_licensed", False) + user.role = node_user.get("role", 0) + + # Handle public key - firmware strips it if node is licensed + if "public_key" in node_user and not user.is_licensed: + user.public_key = node_user["public_key"] + + # Send our user info to request the remote user's info + # Using BACKGROUND priority as per firmware default + return self.sendData( + user.SerializeToString(), + destinationId, + portNum=portnums_pb2.PortNum.NODEINFO_APP, + wantResponse=wantResponse, + channelIndex=channelIndex, + priority=mesh_pb2.MeshPacket.Priority.BACKGROUND + ) + def sendMqttClientProxyMessage(self, topic: str, data: bytes): """Send an MQTT Client Proxy message to the radio. @@ -1315,8 +1369,14 @@ def _handleFromRadio(self, fromRadioBytes): elif fromRadio.HasField("node_info"): logger.debug(f"Received nodeinfo: {asDict['nodeInfo']}") - - node = self._getOrCreateByNum(asDict["nodeInfo"]["num"]) + + # Track if this is a response to our user info request + node_num = asDict["nodeInfo"]["num"] + if "user" in asDict["nodeInfo"]: + node_id = asDict["nodeInfo"]["user"].get("id", "") + logger.debug(f"Received node info from node {node_id} (num: {node_num})") + + node = self._getOrCreateByNum(node_num) node.update(asDict["nodeInfo"]) try: newpos = self._fixupPosition(node["position"]) diff --git a/meshtastic/tests/test_request_user_info.py b/meshtastic/tests/test_request_user_info.py new file mode 100644 index 00000000..0dfd8322 --- /dev/null +++ b/meshtastic/tests/test_request_user_info.py @@ -0,0 +1,92 @@ +"""Test request_user_info functionality.""" + +import logging +import pytest +from unittest.mock import MagicMock, patch + +from meshtastic.mesh_interface import MeshInterface +from meshtastic.protobuf import mesh_pb2, portnums_pb2 +from meshtastic import BROADCAST_ADDR + + +@pytest.mark.unit +@pytest.mark.usefixtures("reset_mt_config") +def test_request_user_info_missing_node_info(): + """Test request_user_info when local node info is not available""" + iface = MeshInterface(noProto=True) + with pytest.raises(MeshInterface.MeshInterfaceError) as exc_info: + iface.request_user_info(destinationId=1) + assert "Could not get local node user info" in str(exc_info.value) + + +@pytest.mark.unit +@pytest.mark.usefixtures("reset_mt_config") +def test_request_user_info_valid(caplog): + """Test request_user_info with valid node info""" + with caplog.at_level(logging.DEBUG): + iface = MeshInterface(noProto=True) + + # Mock getMyNodeInfo to return valid user data + mock_user = { + "user": { + "id": "!12345678", + "long_name": "Test Node", + "short_name": "TN", + "hw_model": 1, + "is_licensed": False, + "role": 0, + "public_key": b"testkey" + } + } + iface.getMyNodeInfo = MagicMock(return_value=mock_user) + + # Call request_user_info + result = iface.request_user_info(destinationId=1) + + # Verify a mesh packet was created with correct fields + assert isinstance(result, mesh_pb2.MeshPacket) + assert result.decoded.portnum == portnums_pb2.PortNum.NODEINFO_APP_VALUE + assert result.want_response == True + assert result.to == 1 + + # Verify the serialized user info was sent as payload + decoded_user = mesh_pb2.User() + decoded_user.ParseFromString(result.decoded.payload) + assert decoded_user.id == "!12345678" + assert decoded_user.long_name == "Test Node" + assert decoded_user.short_name == "TN" + assert decoded_user.hw_model == 1 + assert decoded_user.is_licensed == False + assert decoded_user.role == 0 + assert decoded_user.public_key == b"testkey" + + +@pytest.mark.unit +@pytest.mark.usefixtures("reset_mt_config") +def test_request_user_info_response_handling(caplog): + """Test handling of responses to user info requests""" + with caplog.at_level(logging.DEBUG): + iface = MeshInterface(noProto=True) + iface.nodes = {} # Initialize nodes dict + + # Mock user info in response packet + user_info = mesh_pb2.User() + user_info.id = "!abcdef12" + user_info.long_name = "Remote Node" + user_info.short_name = "RN" + + # Create response packet + packet = mesh_pb2.MeshPacket() + packet.from_ = 123 # Note: Using from_ to avoid Python keyword + packet.decoded.portnum = portnums_pb2.PortNum.NODEINFO_APP_VALUE + packet.decoded.payload = user_info.SerializeToString() + + # Process the received packet + iface._handlePacketFromRadio(packet) + + # Verify node info was stored correctly + assert "!abcdef12" in iface.nodes + stored_node = iface.nodes["!abcdef12"] + assert stored_node["user"]["id"] == "!abcdef12" + assert stored_node["user"]["longName"] == "Remote Node" + assert stored_node["user"]["shortName"] == "RN" \ No newline at end of file From 0a06d1dcd96ad1dbfc7bb193967172b2c38f326d Mon Sep 17 00:00:00 2001 From: Kellina Date: Mon, 27 Oct 2025 11:56:18 -0600 Subject: [PATCH 2/2] Update mesh_interface.py --- 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 77ded951..e0620bf2 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -36,7 +36,7 @@ protocols, publishingThread, ) -from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2, admin_pb2 +from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2 from meshtastic.util import ( Acknowledgment, Timeout,