From 0657899a6fc57cdb4a0d35505cc4a5c7ffe1df6a Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 16:33:46 -0700 Subject: [PATCH] create heard by packet trace --- meshview/__version__.py | 2 +- meshview/mqtt_store.py | 66 +++++++++++++++++++++++++++++++------ meshview/templates/map.html | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/meshview/__version__.py b/meshview/__version__.py index 50d30b1..c2f2fda 100644 --- a/meshview/__version__.py +++ b/meshview/__version__.py @@ -3,7 +3,7 @@ import subprocess from pathlib import Path -__version__ = "3.0.8" +__version__ = "3.0.9" __release_date__ = "2026-5-12" diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 68acd8e..fc130f5 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -22,6 +22,18 @@ NODE_NAME_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") MQTT_DIRECT_NODE_ID = 1 +def has_valid_location(lat: int | None, lon: int | None) -> bool: + return lat not in (None, 0) and lon not in (None, 0) + + +def node_user_id(node_id: int) -> str: + return f"!{node_id:0{8}x}" + + +def placeholder_short_name(node_id: int) -> str: + return node_user_id(node_id)[-4:] + + def normalize_node_name(name: str) -> str: name = name.strip() if not name or NODE_NAME_CONTROL_CHARS.search(name): @@ -47,6 +59,35 @@ def storage_to_node_id(packet) -> int: return packet.to +async def ensure_node_exists(session, node_id: int | None, channel: str | None) -> Node | None: + if node_id is None: + return None + + node = (await session.execute(select(Node).where(Node.node_id == node_id))).scalar_one_or_none() + now_us = int(time.time() * 1_000_000) + + if node: + node.last_seen_us = now_us + if node.first_seen_us is None: + node.first_seen_us = now_us + if channel and not node.channel: + node.channel = channel + return node + + display_name = str(node_id) + node = Node( + id=node_user_id(node_id), + node_id=node_id, + long_name=display_name, + short_name=placeholder_short_name(node_id), + channel=channel, + first_seen_us=now_us, + last_seen_us=now_us, + ) + session.add(node) + return node + + async def capture_daily_snapshot() -> None: today = datetime.now(UTC).date() @@ -130,6 +171,7 @@ async def process_envelope(topic, env): ).scalar_one_or_none() now_us = int(time.time() * 1_000_000) + has_location = has_valid_location(map_report.latitude_i, map_report.longitude_i) if node: node.node_id = node_id @@ -139,8 +181,9 @@ async def process_envelope(topic, env): node.role = role if not node.channel: node.channel = env.channel_id - node.last_lat = map_report.latitude_i - node.last_long = map_report.longitude_i + if has_location: + node.last_lat = map_report.latitude_i + node.last_long = map_report.longitude_i node.firmware = map_report.firmware_version node.last_seen_us = now_us if node.first_seen_us is None: @@ -155,11 +198,12 @@ async def process_envelope(topic, env): role=role, channel=env.channel_id, firmware=map_report.firmware_version, - last_lat=map_report.latitude_i, - last_long=map_report.longitude_i, first_seen_us=now_us, last_seen_us=now_us, ) + if has_location: + node.last_lat = map_report.latitude_i + node.last_long = map_report.longitude_i session.add(node) except Exception as e: print(f"Error processing MAP_REPORT_APP: {e}") @@ -172,6 +216,9 @@ async def process_envelope(topic, env): async with mqtt_database.async_session() as session: # --- Packet insert with ON CONFLICT DO NOTHING + from_node_id = getattr(env.packet, "from", None) + await ensure_node_exists(session, from_node_id, env.channel_id) + result = await session.execute(select(Packet).where(Packet.id == packet_id)) packet = result.scalar_one_or_none() if not packet: @@ -179,7 +226,7 @@ async def process_envelope(topic, env): packet_values = { "id": packet_id, "portnum": env.packet.decoded.portnum, - "from_node_id": getattr(env.packet, "from"), + "from_node_id": from_node_id, "to_node_id": storage_to_node_id(env.packet), "payload": env.packet.SerializeToString(), "import_time_us": now_us, @@ -332,11 +379,10 @@ async def process_envelope(topic, env): position = decode_payload.decode_payload( PortNum.POSITION_APP, env.packet.decoded.payload ) - if position and position.latitude_i and position.longitude_i: - from_node_id = getattr(env.packet, "from") - node = ( - await session.execute(select(Node).where(Node.node_id == from_node_id)) - ).scalar_one_or_none() + if position and has_valid_location(position.latitude_i, position.longitude_i): + node = await ensure_node_exists( + session, getattr(env.packet, "from", None), env.channel_id + ) if node: now_us = int(time.time() * 1_000_000) node.last_lat = position.latitude_i diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 4844f4c..bf7e07a 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -37,6 +37,16 @@ animation: textPulse 1.1s ease-in-out 6; border-color: #ff8c00; } + .packet-seen-trace { + stroke-dasharray: 34 999; + stroke-linecap: round; + filter: drop-shadow(0 0 4px rgba(0, 183, 255, 0.85)); + animation: packetTrace 6.5s cubic-bezier(0.25, 0.05, 0.15, 1) 1 forwards; + } + @keyframes packetTrace { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -1000; } + } @keyframes textPulse { 0% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); } 50% { box-shadow: 0 4px 14px rgba(255,140,0,0.45); } @@ -207,6 +217,7 @@ L.control.scale({ }).addTo(map); var nodeLayer = L.layerGroup().addTo(map); var edgeLayer = L.layerGroup().addTo(map); +var packetSeenLayer = L.layerGroup().addTo(map); var trafficHeatLayer = L.heatLayer([], { radius: 34, blur: 18, @@ -221,6 +232,7 @@ var trafficHeatLayer = L.heatLayer([], { }); var overlayLayers = { "Nodes": nodeLayer, + "Packet Heard By": packetSeenLayer, "Traffic HeatMap": trafficHeatLayer }; L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map); @@ -229,6 +241,7 @@ L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map); var nodes = [], markers = {}, markerById = {}, nodeMap = new Map(); var selectedNodeId = null; var activeBlinks = new Map(), lastImportTime = null; +var activePacketSeenHighlights = []; var mapInterval = 0; var unmappedPackets = []; var trafficHeatLoaded = false; @@ -395,6 +408,7 @@ function fetchNewPackets(){ } else { addUnmappedPacket(pkt, nodeData); } + showPacketSeenNodes(pkt); }); lastImportTime = latest; @@ -430,6 +444,7 @@ map.on("overlayadd", e => { map.on("overlayremove", e => { if(e.layer === nodeLayer) clearActiveBlinks(); + if(e.layer === packetSeenLayer) clearPacketSeenHighlights(); }); async function waitForConfig() { @@ -769,6 +784,55 @@ function clearActiveBlinks(){ activeBlinks.clear(); } +function clearPacketSeenHighlights(){ + activePacketSeenHighlights.forEach(timeout => clearTimeout(timeout)); + activePacketSeenHighlights = []; + packetSeenLayer.clearLayers(); +} + +async function showPacketSeenNodes(pkt){ + if(!map.hasLayer(packetSeenLayer) || pkt.id == null) return; + + try { + const res = await fetch(`/api/packets_seen/${pkt.id}`); + if(!res.ok) return; + + const data = await res.json(); + const seen = data.seen || []; + const sourceId = pkt.from_node_id; + const sourceNode = nodeMap.get(sourceId); + const sourceLatLng = sourceNode && !isInvalidCoord(sourceNode) + ? getNodeLatLng(sourceNode) + : null; + + seen.forEach(row => { + const node = nodeMap.get(row.node_id); + if(!node || isInvalidCoord(node)) return; + + const isSource = row.node_id === sourceId; + if(!sourceLatLng || isSource) return; + + const markerLatLng = markerById[row.node_id]?.getLatLng(); + const lat = markerLatLng?.lat ?? node.lat; + const lng = markerLatLng?.lng ?? node.long; + const trace = L.polyline([[sourceLatLng.lat, sourceLatLng.lng], [lat, lng]], { + color: "#26d9ff", + weight: 6, + opacity: 0.9, + className: "packet-seen-trace", + interactive: false + }).addTo(packetSeenLayer); + + const timeout = setTimeout(() => { + packetSeenLayer.removeLayer(trace); + }, 6700); + activePacketSeenHighlights.push(timeout); + }); + } catch(err) { + console.error("Failed to load packet seen nodes", pkt.id, err); + } +} + function blinkNode(marker,longName,portnum,payload){ if(!isNodeVisibleForPackets(marker)) return;