Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions meshtastic/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -1876,6 +1906,27 @@ def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
action="store_true",
)

group.add_argument(
"--request-userinfo",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have this as --request-user too, to match the protobuf name.

Suggested change
"--request-userinfo",
"--request-userinfo", "--request-user",

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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have this be available as --send-user as well, to match the protobuf name more closely.

Suggested change
"--send-userinfo",
"--send-userinfo", "--send-user",

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",
)
Comment on lines +1917 to +1928
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a reason to have these two as separate flags; I think they can both be under --send-userinfo, and simply broadcast when a --dest is not provided.


group.add_argument(
"--reply", help="Reply to received messages", action="store_true"
)
Expand Down
64 changes: 62 additions & 2 deletions meshtastic/mesh_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +486 to +491
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should match the others here in MeshInterface. In particular, it should probably be called sendUser.

"""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"]

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The is_unmessagable flag should be included here.

Suggested change
if "isUnmessagable" in node_user:
user.is_unmessagable = node_user["isUnmessagable"]

# 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.

Expand Down Expand Up @@ -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)
Comment on lines +1372 to +1379
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't belong here. The FromRadio field node_info is for nodeinfo sent from the nodedb to the client on startup; you won't get this for responses to requests over the air.

Suggested change
# 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 = self._getOrCreateByNum(asDict["nodeInfo"]["num"])

node.update(asDict["nodeInfo"])
try:
newpos = self._fixupPosition(node["position"])
Expand Down
92 changes: 92 additions & 0 deletions meshtastic/tests/test_request_user_info.py
Original file line number Diff line number Diff line change
@@ -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"
Loading