diff --git a/alembic/versions/4d2a7c9e1b30_add_packet_from_node_portnum_index.py b/alembic/versions/4d2a7c9e1b30_add_packet_from_node_portnum_index.py
new file mode 100644
index 0000000..30adaee
--- /dev/null
+++ b/alembic/versions/4d2a7c9e1b30_add_packet_from_node_portnum_index.py
@@ -0,0 +1,37 @@
+"""Add packet index on from_node_id, portnum and import_time_us
+
+Revision ID: 4d2a7c9e1b30
+Revises: e8f2c4b6d9a1
+Create Date: 2026-10-26 00:00:00.000000
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "4d2a7c9e1b30"
+down_revision: str | None = "e8f2c4b6d9a1"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+INDEX_NAME = "idx_packet_from_node_portnum_time_us"
+
+
+def upgrade() -> None:
+ # Lets the node QR endpoint read a node's NODEINFO packets without scanning all its packets.
+ inspector = sa.inspect(op.get_bind())
+ if INDEX_NAME not in {index["name"] for index in inspector.get_indexes("packet")}:
+ op.create_index(
+ INDEX_NAME,
+ "packet",
+ ["from_node_id", "portnum", sa.text("import_time_us DESC")],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ op.drop_index(INDEX_NAME, table_name="packet")
diff --git a/meshview/lang/en.json b/meshview/lang/en.json
index 1f8c103..e726e34 100644
--- a/meshview/lang/en.json
+++ b/meshview/lang/en.json
@@ -246,7 +246,9 @@
"share_contact_qr": "Share Contact QR",
"copy_url": "Copy URL",
"copied": "Copied!",
- "scan_qr_to_add": "Scan this QR code to add this node as a contact on another device."
+ "scan_qr_to_add": "Scan this QR code to add this node as a contact on another device.",
+ "qr_no_public_key": "No public key heard from this node yet, so it cannot be shared as a contact.",
+ "qr_key_conflict": "This node has reported more than one public key, so it is not shared as a contact."
},
"packet": {
"loading": "Loading packet information...",
diff --git a/meshview/lang/es.json b/meshview/lang/es.json
index c362ce5..03f3501 100644
--- a/meshview/lang/es.json
+++ b/meshview/lang/es.json
@@ -232,7 +232,9 @@
"share_contact_qr": "Compartir contacto QR",
"copy_url": "Copiar URL",
"copied": "¡Copiado!",
- "scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo."
+ "scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo.",
+ "qr_no_public_key": "Aún no se ha recibido la clave pública de este nodo, por lo que no se puede compartir como contacto.",
+ "qr_key_conflict": "Este nodo ha informado más de una clave pública, por lo que no se comparte como contacto."
},
"packet": {
diff --git a/meshview/lang/ru.json b/meshview/lang/ru.json
index 34c2f13..2b50550 100644
--- a/meshview/lang/ru.json
+++ b/meshview/lang/ru.json
@@ -227,7 +227,9 @@
"share_contact_qr": "Поделиться QR контакта",
"copy_url": "Копировать URL",
"copied": "Скопировано!",
- "scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
+ "scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве.",
+ "qr_no_public_key": "Открытый ключ этой ноды ещё не получен, поэтому её нельзя отправить как контакт.",
+ "qr_key_conflict": "Эта нода сообщила более одного открытого ключа, поэтому она не отправляется как контакт."
},
"packet": {
"loading": "Загрузка информации о пакете...",
diff --git a/meshview/models.py b/meshview/models.py
index 70fbc30..a4beb21 100644
--- a/meshview/models.py
+++ b/meshview/models.py
@@ -59,6 +59,12 @@ class Packet(Base):
Index("idx_packet_to_node_id", "to_node_id"),
Index("idx_packet_import_time_us", desc("import_time_us")),
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
+ Index(
+ "idx_packet_from_node_portnum_time_us",
+ "from_node_id",
+ "portnum",
+ desc("import_time_us"),
+ ),
)
diff --git a/meshview/store.py b/meshview/store.py
index bbec37c..d0e0e3d 100644
--- a/meshview/store.py
+++ b/meshview/store.py
@@ -113,6 +113,17 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
return result.scalars()
+async def get_nodeinfo_payloads(node_id):
+ """Return the payloads of every stored NODEINFO_APP packet from a node, newest first."""
+ async with database.async_session() as session:
+ result = await session.execute(
+ select(Packet.payload)
+ .where(Packet.from_node_id == node_id, Packet.portnum == PortNum.NODEINFO_APP)
+ .order_by(Packet.import_time_us.desc().nulls_last())
+ )
+ return result.scalars().all()
+
+
async def get_packet(packet_id):
async with database.async_session() as session:
q = select(Packet).where(Packet.id == packet_id)
diff --git a/meshview/templates/node.html b/meshview/templates/node.html
index eccc758..895a738 100644
--- a/meshview/templates/node.html
+++ b/meshview/templates/node.html
@@ -173,6 +173,11 @@
border-color: #48bb78;
color: #fff;
}
+.node-actions .qr-unavailable {
+ align-self: center;
+ color: #b0bec5;
+ font-size: 0.85rem;
+}
.copy-success {
color: #4ade80 !important;
transition: opacity 0.3s;
@@ -315,6 +320,12 @@
+
+ No public key heard from this node yet, so it cannot be shared as a contact.
+
+
+ This node has reported more than one public key, so it is not shared as a contact.
+
@@ -1811,8 +1822,16 @@ async function loadNodeQr() {
try {
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
const qrData = await qrRes.json();
- if (qrRes.ok && qrData.meshtastic_url) {
- currentMeshtasticUrl = qrData.meshtastic_url;
+ if (qrRes.ok) {
+ // No URL means no usable public key: none is known, or the node reported several.
+ currentMeshtasticUrl = qrData.meshtastic_url || "";
+ const canShare = Boolean(currentMeshtasticUrl);
+ const keyConflict = Boolean(qrData.public_key_conflict);
+ document.getElementById("copyUrlBtn").style.display = canShare ? "" : "none";
+ document.getElementById("showQrBtn").style.display = canShare ? "" : "none";
+ document.getElementById("qrUnavailable").style.display =
+ canShare || keyConflict ? "none" : "";
+ document.getElementById("qrKeyConflict").style.display = keyConflict ? "" : "none";
actionsDiv.style.display = "flex";
} else {
actionsDiv.style.display = "none";
diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py
index bf33b32..fbf8e2b 100644
--- a/meshview/web_api/api.py
+++ b/meshview/web_api/api.py
@@ -744,7 +744,6 @@ async def api_config(request):
"map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
"map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
"map_interval": get_int(site, "map_interval", 3),
-
"map_channel_colors": {
channel: color.strip()
for channel, color in CONFIG.get("map_channel_colors", {}).items()
@@ -1173,9 +1172,11 @@ async def api_node_qr(request):
return web.json_response({"error": "Node not found"}, status=404)
try:
+ from google.protobuf.message import DecodeError
+
from meshtastic.protobuf.admin_pb2 import SharedContact
from meshtastic.protobuf.config_pb2 import Config
- from meshtastic.protobuf.mesh_pb2 import User
+ from meshtastic.protobuf.mesh_pb2 import MeshPacket, User
user = User()
user.id = f"!{node_id:08x}"
@@ -1200,18 +1201,52 @@ async def api_node_qr(request):
except (AttributeError, TypeError):
pass
- contact = SharedContact()
- contact.node_num = node_id
- contact.user.CopyFrom(user)
- contact.manually_verified = False
+ # Meshview does not store public keys, so read them from the node's NODEINFO packets.
+ latest_nodeinfo = None
+ public_keys = set()
+ for payload in await store.get_nodeinfo_payloads(node_id):
+ if not payload:
+ continue
+ try:
+ mesh_packet = MeshPacket.FromString(payload)
+ except DecodeError:
+ continue
+ nodeinfo = decode_payload.decode_payload(
+ PortNum.NODEINFO_APP, mesh_packet.decoded.payload
+ )
+ if not isinstance(nodeinfo, User):
+ continue
+ if latest_nodeinfo is None:
+ latest_nodeinfo = nodeinfo
+ if len(nodeinfo.public_key) == 32:
+ public_keys.add(nodeinfo.public_key)
+
+ if latest_nodeinfo is not None:
+ if len(latest_nodeinfo.public_key) == 32:
+ user.public_key = latest_nodeinfo.public_key
+ user.is_licensed = latest_nodeinfo.is_licensed
+ if latest_nodeinfo.HasField("is_unmessagable"):
+ user.is_unmessagable = latest_nodeinfo.is_unmessagable
- contact_bytes = contact.SerializeToString()
import base64
- contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
- contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
+ # A contact without a public key is refused by current Meshtastic apps and, on older
+ # firmware, erases the key the importing radio already has. Importing a contact also
+ # replaces a key the radio has pinned, so when the node's packets disagree on its key
+ # (one of them may be forged), share neither.
+ public_key_conflict = len(public_keys) > 1
+ meshtastic_url = None
+ if user.public_key and not public_key_conflict:
+ contact = SharedContact()
+ contact.node_num = node_id
+ contact.user.CopyFrom(user)
+ contact.manually_verified = False
- meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
+ contact_bytes = contact.SerializeToString()
+ contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
+ contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
+
+ meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
return web.json_response(
{
@@ -1219,6 +1254,10 @@ async def api_node_qr(request):
"long_name": node.long_name,
"short_name": node.short_name,
"role": node.role,
+ "public_key": (
+ base64.b64encode(user.public_key).decode("ascii") if user.public_key else None
+ ),
+ "public_key_conflict": public_key_conflict,
"meshtastic_url": meshtastic_url,
}
)