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 @@ + + @@ -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, } )