Skip to content

Commit 307d8d8

Browse files
authored
Merge pull request #933 from ianmcorvidae/shared-contact
Add support for adding contacts using the CLI, including remotely
2 parents e4f0fb2 + 405a6bb commit 307d8d8

6 files changed

Lines changed: 481 additions & 2 deletions

File tree

meshtastic/__main__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,13 @@ def onConnected(interface):
552552
waitForAckNak = True
553553
interface.getNode(args.dest, False, **getNode_kwargs).resetNodeDb()
554554

555+
if args.add_contact:
556+
closeNow = True
557+
waitForAckNak = True
558+
interface.getNode(args.dest, False, **getNode_kwargs).addContactURL(
559+
args.add_contact
560+
)
561+
555562
if args.sendtext:
556563
closeNow = True
557564
channelIndex = mt_config.channel_index or 0
@@ -1074,6 +1081,20 @@ def setSimpleConfig(modem_preset):
10741081
else:
10751082
print("Install pyqrcode to view a QR code printed to terminal.")
10761083

1084+
if args.contact_qr:
1085+
closeNow = True
1086+
url = interface.getNode(args.dest, True, **getNode_kwargs).getContactURL(
1087+
args.contact_qr,
1088+
should_ignore=args.contact_ignore,
1089+
manually_verified=args.contact_verified,
1090+
)
1091+
print(f"Contact URL: {url}")
1092+
if pyqrcode is not None:
1093+
qr = pyqrcode.create(url)
1094+
print(qr.terminal())
1095+
else:
1096+
print("Install pyqrcode to view a QR code printed to terminal.")
1097+
10771098
log_set: Optional = None # type: ignore[annotation-unchecked]
10781099
# we need to keep a reference to the logset so it doesn't get GCed early
10791100

@@ -1858,6 +1879,24 @@ def addChannelConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPa
18581879
action="store_true",
18591880
)
18601881

1882+
group.add_argument(
1883+
"--contact-qr",
1884+
help="Display a QR code for a node's contact data. "
1885+
"Use the node ID with a '!' or '0x' prefix or the node number. "
1886+
"Also shows the shareable contact URL.",
1887+
metavar="!xxxxxxxx",
1888+
)
1889+
group.add_argument(
1890+
"--contact-verified",
1891+
help="Set the IS_KEY_MANUALLY_VERIFIED bit in the generated contact URL",
1892+
action="store_true",
1893+
)
1894+
group.add_argument(
1895+
"--contact-ignore",
1896+
help="Mark this contact as blocked/ignored in the generated contact URL",
1897+
action="store_true",
1898+
)
1899+
18611900
group.add_argument(
18621901
"--ch-enable",
18631902
help="Enable the specified channel. Use --ch-add instead whenever possible.",
@@ -2095,6 +2134,13 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
20952134
action="store_true",
20962135
)
20972136

2137+
group.add_argument(
2138+
"--add-contact",
2139+
help="Add a contact (User) to the NodeDB from a shareable URL. "
2140+
"Example: https://meshtastic.org/v/#<base64>",
2141+
metavar="URL",
2142+
)
2143+
20982144
group.add_argument(
20992145
"--set-time",
21002146
help="Set the time to the provided unix epoch timestamp, or the system's current time if omitted or 0.",

meshtastic/node.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,46 @@ def getURL(self, includeAll: bool = True):
380380
s = s.replace("=", "").replace("+", "-").replace("/", "_")
381381
return f"https://meshtastic.org/e/#{s}"
382382

383+
def getContactURL(self, node_id: Union[int, str], should_ignore: bool = False, manually_verified: bool = False):
384+
"""Generate a shareable contact URL for the specified node"""
385+
nodeNum = to_node_num(node_id)
386+
387+
node = self.iface.nodesByNum.get(nodeNum)
388+
if not node or not node.get("user"):
389+
our_exit(f"Warning: Node {node_id} not found in NodeDB")
390+
391+
contact = admin_pb2.SharedContact()
392+
contact.node_num = nodeNum
393+
394+
u = node["user"]
395+
if u.get("id"):
396+
contact.user.id = u["id"]
397+
if u.get("macaddr"):
398+
contact.user.macaddr = base64.b64decode(u["macaddr"])
399+
if u.get("longName"):
400+
contact.user.long_name = u["longName"]
401+
if u.get("shortName"):
402+
contact.user.short_name = u["shortName"]
403+
if u.get("hwModel") and u["hwModel"] != "UNSET":
404+
contact.user.hw_model = mesh_pb2.HardwareModel.Value(u["hwModel"])
405+
if u.get("role"):
406+
contact.user.role = config_pb2.Config.DeviceConfig.Role.Value(u["role"])
407+
if u.get("publicKey"):
408+
contact.user.public_key = base64.b64decode(u["publicKey"])
409+
if u.get("isLicensed"):
410+
contact.user.is_licensed = u["isLicensed"]
411+
if u.get("isUnmessagable") is not None:
412+
contact.user.is_unmessagable = u["isUnmessagable"]
413+
if should_ignore:
414+
contact.should_ignore = True
415+
if manually_verified:
416+
contact.manually_verified = True
417+
418+
data = contact.SerializeToString()
419+
s = base64.urlsafe_b64encode(data).decode("ascii")
420+
s = s.replace("=", "").replace("+", "-").replace("/", "_")
421+
return f"https://meshtastic.org/v/#{s}"
422+
383423
def setURL(self, url: str, addOnly: bool = False):
384424
"""Set mesh network URL"""
385425
if self.localConfig is None or self.channels is None:
@@ -445,6 +485,32 @@ def setURL(self, url: str, addOnly: bool = False):
445485
self.ensureSessionKey()
446486
self._sendAdmin(p)
447487

488+
def addContactURL(self, url: str):
489+
"""Add a contact (User) to the NodeDB from a shareable URL"""
490+
self.ensureSessionKey()
491+
492+
splitURL = url.split("/#")
493+
if len(splitURL) == 1:
494+
our_exit(f"Warning: Invalid URL '{url}'")
495+
b64 = splitURL[-1]
496+
497+
missing_padding = len(b64) % 4
498+
if missing_padding:
499+
b64 += "=" * (4 - missing_padding)
500+
501+
decodedURL = base64.urlsafe_b64decode(b64)
502+
contact = admin_pb2.SharedContact()
503+
contact.ParseFromString(decodedURL)
504+
505+
p = admin_pb2.AdminMessage()
506+
p.add_contact.CopyFrom(contact)
507+
508+
if self == self.iface.localNode:
509+
onResponse = None
510+
else:
511+
onResponse = self.onAckNak
512+
return self._sendAdmin(p, onResponse=onResponse)
513+
448514
def onResponseRequestRingtone(self, p):
449515
"""Handle the response packet for requesting ringtone part 1"""
450516
logger.debug(f"onResponseRequestRingtone() p:{p}")

meshtastic/tests/test_main.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2998,6 +2998,56 @@ def test_remove_ignored_node():
29982998
main()
29992999

30003000
mocked_node.removeIgnored.assert_called_once_with("!12345678")
3001+
3002+
@pytest.mark.unit
3003+
@pytest.mark.usefixtures("reset_mt_config")
3004+
def test_add_contact_url():
3005+
"""Test --add-contact with a shareable URL"""
3006+
url = "https://meshtastic.org/v/#CKqkvZgIElEKCSE4MzBmNTIyYRIQUm9hZHJ1bm5lciBSaWRnZRoEUktTTiIGAAAAAAAAKAk4AkIgRxo_Fw_ergQIhRqBbrHasLYy3gU-Ay8hrhu4OVnIPQc=" # pylint: disable=line-too-long
3007+
sys.argv = ["", "--add-contact", url]
3008+
mt_config.args = sys.argv
3009+
mocked_node = MagicMock(autospec=Node)
3010+
iface = MagicMock(autospec=SerialInterface)
3011+
iface.getNode.return_value = mocked_node
3012+
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
3013+
main()
3014+
3015+
mocked_node.addContactURL.assert_called_once_with(url)
3016+
3017+
3018+
@pytest.mark.unit
3019+
@pytest.mark.usefixtures("reset_mt_config")
3020+
def test_contact_qr():
3021+
"""Test --contact-qr with a node ID"""
3022+
sys.argv = ["", "--contact-qr", "!830f522a"]
3023+
mt_config.args = sys.argv
3024+
mocked_node = MagicMock(autospec=Node)
3025+
iface = MagicMock(autospec=SerialInterface)
3026+
iface.getNode.return_value = mocked_node
3027+
mocked_node.iface = iface
3028+
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
3029+
main()
3030+
3031+
mocked_node.getContactURL.assert_called_once_with("!830f522a", should_ignore=False, manually_verified=False)
3032+
mocked_node.getContactURL.reset_mock()
3033+
3034+
3035+
@pytest.mark.unit
3036+
@pytest.mark.usefixtures("reset_mt_config")
3037+
def test_contact_qr_with_flags():
3038+
"""Test --contact-qr with --contact-verified and --contact-ignore"""
3039+
sys.argv = ["", "--contact-qr", "!830f522a", "--contact-verified", "--contact-ignore"]
3040+
mt_config.args = sys.argv
3041+
mocked_node = MagicMock(autospec=Node)
3042+
iface = MagicMock(autospec=SerialInterface)
3043+
iface.getNode.return_value = mocked_node
3044+
mocked_node.iface = iface
3045+
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
3046+
main()
3047+
3048+
mocked_node.getContactURL.assert_called_once_with("!830f522a", should_ignore=True, manually_verified=True)
3049+
3050+
30013051
@pytest.mark.unit
30023052
@pytest.mark.usefixtures("reset_mt_config")
30033053
def test_main_set_owner_whitespace_only(capsys):

0 commit comments

Comments
 (0)