diff --git a/meshview/models.py b/meshview/models.py index ab62e2e..4c2215b 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -51,3 +51,13 @@ class PacketSeen(Base): rx_rssi: Mapped[int] = mapped_column(nullable=True) topic: Mapped[str] import_time: Mapped[datetime] + + +class Traceroute(Base): + __tablename__ = "traceroute" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + packet_id = mapped_column(ForeignKey("packet.id")) + gateway_node_id: Mapped[int] = mapped_column(BigInteger) + done: Mapped[bool] + route: Mapped[bytes] + diff --git a/meshview/store.py b/meshview/store.py index fb67463..c1d51d0 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -6,7 +6,7 @@ from meshtastic.portnums_pb2 import PortNum from meshtastic.mesh_pb2 import User, HardwareModel from meshview import database from meshview import decode_payload -from meshview.models import Packet, PacketSeen, Node +from meshview.models import Packet, PacketSeen, Node, Traceroute from meshview import notify @@ -99,6 +99,18 @@ async def process_envelope(topic, env): node.last_long = position.longitude_i session.add(node) + if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP: + if env.packet.decoded.want_response: + packet_id = env.packet.id + else: + packet_id = env.packet.decoded.request_id + session.add(Traceroute( + packet_id=packet_id, + route=env.packet.decoded.payload, + done=not env.packet.decoded.want_response, + gateway_node_id=int(env.gateway_id[1:], 16), + )) + await session.commit() if new_packet: await packet.awaitable_attrs.to_node @@ -188,3 +200,12 @@ async def has_packets(node_id, portnum): select(Packet.id).where(Packet.from_node_id == node_id).limit(1) )).scalar() ) + + +async def get_traceroute(packet_id): + async with database.async_session() as session: + result = await session.execute( + select(Traceroute) + .where(Traceroute.packet_id == packet_id) + ) + return result.scalars() diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html index 57bdfea..3ef01f5 100644 --- a/meshview/templates/packet.html +++ b/meshview/templates/packet.html @@ -53,6 +53,9 @@ {% endfor %} + {% if packet.raw_mesh_packet.decoded.want_response %} + graph + {% endif %} {% endif %}
{{packet.payload}}
diff --git a/meshview/web.py b/meshview/web.py
index c842aad..3aa1058 100644
--- a/meshview/web.py
+++ b/meshview/web.py
@@ -7,6 +7,7 @@ from aiohttp_sse import sse_response
import ssl
import re
+import pydot
from pandas import DataFrame
import seaborn as sns
import matplotlib.pyplot as plt
@@ -528,6 +529,90 @@ async def graph_neighbors(request):
)
+@routes.get("/graph/traceroute/{packet_id}")
+async def graph_traceroute(request):
+ packet_id = int(request.match_info['packet_id'])
+ traceroutes = list(await store.get_traceroute(packet_id))
+
+ packet = await store.get_packet(packet_id)
+
+ node_ids = set()
+ for tr in traceroutes:
+ route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
+ node_ids.add(tr.gateway_node_id)
+ for node_id in route.route:
+ node_ids.add(node_id)
+ node_ids.add(packet.from_node_id)
+ node_ids.add(packet.to_node_id)
+
+ nodes = {}
+ async with asyncio.TaskGroup() as tg:
+ for node_id in node_ids:
+ nodes[node_id] = tg.create_task(store.get_node(node_id))
+
+ graph = pydot.Dot('traceroute', graph_type="digraph")
+
+ paths = set()
+ node_color = {}
+ mqtt_nodes = set()
+ saw_reply = set()
+ dest = None
+ for tr in traceroutes:
+ if tr.done:
+ saw_reply.add(tr.gateway_node_id)
+ if tr.done and dest:
+ continue
+ route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
+ path = [packet.from_node_id]
+ path.extend(route.route)
+ if tr.done:
+ dest = packet.to_node_id
+ path.append(packet.to_node_id)
+ elif path[-1] != tr.gateway_node_id:
+ # It seems some nodes add them self to the list before uplinking
+ path.append(tr.gateway_node_id)
+ mqtt_nodes.add(tr.gateway_node_id)
+ node_color[path[-1]] = '#' + hex(hash(tuple(path)))[3:9]
+ paths.add(tuple(path))
+
+ used_nodes = set()
+ for path in paths:
+ used_nodes.update(path)
+
+ for node_id in used_nodes:
+ node = await nodes[node_id]
+ if not node:
+ node_name = node_id_to_hex(node_id)
+ else:
+ node_name = f'[{node.short_name}] {node.long_name} - {node_id_to_hex(node_id)}'
+ style = 'dashed'
+ if node_id == dest:
+ style = 'filled'
+ elif node_id in mqtt_nodes:
+ style = 'solid'
+
+ if node_id in saw_reply:
+ style += ', diagonals'
+
+ graph.add_node(pydot.Node(
+ str(node_id),
+ label=node_name,
+ shape='box',
+ color=node_color.get(node_id, 'black'),
+ style=style,
+ ))
+
+ for path in paths:
+ color = '#' + hex(hash(tuple(path)))[3:9]
+ for src, dest in zip(path, path[1:]):
+ graph.add_edge(pydot.Edge(src, dest, color=color))
+
+ return web.Response(
+ body=graph.create_svg(),
+ content_type="image/svg+xml",
+ )
+
+
async def run_server(bind, port, tls_cert):
app = web.Application()
app.add_routes(routes)
diff --git a/requirements.txt b/requirements.txt
index 6bf4d90..f6f308a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,3 +9,4 @@ Jinja2
aiohttp-sse
asyncpg
seaborn
+pydot