From 6f0f65f2b134d90de568c3603c12f83dde17e922 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sun, 26 Jan 2025 18:30:12 -0800 Subject: [PATCH 01/18] Added the code and HTML syntax to saparate LF from MS on the graphs. --- meshview/templates/base.html | 2 +- meshview/web.py | 115 +++++++++++++++++++---------------- 2 files changed, 64 insertions(+), 53 deletions(-) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index c82bbc8..af948b6 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -34,7 +34,7 @@
Bay Area Mesh - http://bayme.sh
-
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LG - MS - Stats

+
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LG - MS  - Stats

Loading...
diff --git a/meshview/web.py b/meshview/web.py index 52080b3..0c19810 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -474,17 +474,41 @@ async def packet_details(request): @routes.get("/chat") async def chat(request): - packets = await store.get_packets( - node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP - ) - template = env.get_template("chat.html") - ui_packets = (Packet.from_model(p) for p in packets) - return web.Response( - text=template.render( - packets=(p for p in ui_packets if not re.match(r"seq \d+$", p.payload)), - ), - content_type="text/html", - ) + try: + # Fetch packets for the given node ID and port number + #print("Fetching packets...") + packets = await store.get_packets( + node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP + ) + #print(f"Fetched {len(packets)} packets.") + + # Convert packets to UI packets + #print("Processing packets...") + ui_packets = [Packet.from_model(p) for p in packets] + + # Filter packets + #print("Filtering packets...") + filtered_packets = [ + p for p in ui_packets if not re.match(r"seq \d+$", p.payload) + ] + + # Render template + #print("Rendering template...") + template = env.get_template("chat.html") + return web.Response( + text=template.render(packets=filtered_packets), + content_type="text/html", + ) + + except Exception as e: + # Log the error and return an appropriate response + #print(f"Error in chat handler: {e}") + return web.Response( + text="An error occurred while processing your request.", + status=500, + content_type="text/plain", + ) + @routes.get("/packet/{packet_id}") @@ -1053,50 +1077,37 @@ async def graph_network(request): ) -@routes.get("/net") -async def net(request): - if "date" in request.query: - start_date = datetime.date.fromisoformat(request.query["date"]) - else: - start_date = datetime.date.today() - while start_date.weekday() != 2: - start_date = start_date - datetime.timedelta(days=5) - - start_time = datetime.datetime.combine(start_date, datetime.time(0,0)) - - text_packets = [ - Packet.from_model(p) - for p in await store.get_packets( - portnum=PortNum.TEXT_MESSAGE_APP, - after=start_time, - before=start_time + datetime.timedelta(hours=74), - ) - ] - net_packets = [p for p in text_packets if '#baymeshnet' in p.payload.lower()] - - template = env.get_template("net.html") - return web.Response( - text=template.render(net_packets=text_packets), - content_type="text/html", - ) - @routes.get("/stats") async def stats(request): - # Fetch total packet count from the store - total_packets = await store.get_total_packet_count() - total_nodes = await store.get_total_node_count() - total_packets_seen = await store.get_total_packet_seen_count() - total_nodes_longfast = await store.get_total_node_count_longfast() - total_nodes_mediumslow = await store.get_total_node_count_mediumslow() - - - # Render the stats template with the total packet count - template = env.get_template("stats.html") - return web.Response( - text=template.render(total_packets=total_packets, total_nodes=total_nodes,total_packets_seen=total_packets_seen,total_nodes_longfast=total_nodes_longfast, total_nodes_mediumslow=total_nodes_mediumslow ), - content_type="text/html", - ) + try: + # Add logging to track execution + total_packets = await store.get_total_packet_count() + total_nodes = await store.get_total_node_count() + total_packets_seen = await store.get_total_packet_seen_count() + total_nodes_longfast = await store.get_total_node_count_longfast() + total_nodes_mediumslow = await store.get_total_node_count_mediumslow() + # Render template + #print("Rendering template...") + template = env.get_template("stats.html") + return web.Response( + text=template.render( + total_packets=total_packets, + total_nodes=total_nodes, + total_packets_seen=total_packets_seen, + total_nodes_longfast=total_nodes_longfast, + total_nodes_mediumslow=total_nodes_mediumslow, + ), + content_type="text/html", + ) + except Exception as e: + # Log and return error response + #print(f"Error in stats handler: {e}") + return web.Response( + text="An error occurred while processing your request.", + status=500, + content_type="text/plain", + ) @routes.get("/graph/longfast") From 45c26aa713298103cc7f5fa8e27d33cf1f2ddd44 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sun, 26 Jan 2025 18:31:16 -0800 Subject: [PATCH 02/18] Basic documentation of the notify feature. This is still reporting the "Missing return Statement on request handler" --- meshview/notify.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/meshview/notify.py b/meshview/notify.py index d74b383..37153f5 100644 --- a/meshview/notify.py +++ b/meshview/notify.py @@ -37,13 +37,11 @@ def remove_event(node_event): print("removing event") waiting_node_ids_events[node_event.node_id].remove(node_event) - def notify_packet(node_id, packet): for event in waiting_node_ids_events[node_id]: event.packets.append(packet) event.set() - def notify_uplinked(node_id, packet): for event in waiting_node_ids_events[node_id]: event.uplinked.append(packet) @@ -52,8 +50,16 @@ def notify_uplinked(node_id, packet): @contextlib.contextmanager def subscribe(node_id): + """ + Context manager for subscribing to events for a node_id. + Automatically manages event creation and cleanup. + """ event = create_event(node_id) try: yield event + print("adding event...") + except Exception as e: + print(f"Error during subscription for node_id={node_id}: {e}") + raise finally: remove_event(event) From 604fc5c2844702c45a97ec67c7dd0bbc3800a8c0 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Fri, 31 Jan 2025 14:19:28 -0800 Subject: [PATCH 03/18] Multile changes to the code. Important to mentioned that we added another column to the nodes table to report on last_update This can give us when the node was last seen on the mesh and provide some understanding if it is active. --- meshview/database.py | 13 ++++------- meshview/models.py | 2 +- meshview/store.py | 38 ++++++++------------------------ meshview/templates/firehose.html | 2 +- meshview/templates/node.html | 2 +- meshview/templates/stats.html | 2 +- meshview/web.py | 17 ++++++++------ 7 files changed, 27 insertions(+), 49 deletions(-) diff --git a/meshview/database.py b/meshview/database.py index 1d027ee..5451274 100644 --- a/meshview/database.py +++ b/meshview/database.py @@ -1,20 +1,15 @@ -from sqlalchemy.ext.asyncio import async_sessionmaker -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.ext.asyncio import create_async_engine - from meshview import models +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker - -def init_database(database_connetion_string): +def init_database(database_connection_string): global engine, async_session kwargs = {} - if not database_connetion_string.startswith('sqlite'): + if not database_connection_string.startswith('sqlite'): kwargs['pool_size'] = 20 kwargs['max_overflow'] = 50 - engine = create_async_engine(database_connetion_string, echo=False, **kwargs) + engine = create_async_engine(database_connection_string, echo=False, **kwargs) async_session = async_sessionmaker(engine, expire_on_commit=False) - async def create_tables(): async with engine.begin() as conn: await conn.run_sync(models.Base.metadata.create_all) diff --git a/meshview/models.py b/meshview/models.py index 8932032..c1c5b2a 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -21,7 +21,7 @@ class Node(Base): last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) channel: Mapped[str] - + last_update: Mapped[datetime] class Packet(Base): __tablename__ = "packet" diff --git a/meshview/store.py b/meshview/store.py index 97c13d2..c7e3a9b 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -88,6 +88,7 @@ async def process_envelope(topic, env): node.short_name = user.short_name node.hw_model = hw_model node.role = role + node.last_update =datetime.datetime.now() # if need to update time of last update it may be here else: @@ -415,6 +416,7 @@ async def get_total_packet_count(): async def get_total_node_count(): async with database.async_session() as session: q = select(func.count(Node.id)) # Use SQLAlchemy's func to count nodes + q = q.where(Node.last_update > datetime.datetime.now() - datetime.timedelta(days=1)) # Look for nodes with nodeinfo updates in the last 24 hours result = await session.execute(q) return result.scalar() # Return the total count of nodes @@ -427,24 +429,13 @@ async def get_total_packet_seen_count(): async def get_total_node_count_longfast() -> int: - """ - Retrieves the total count of nodes where the channel is equal to 'LongFast'. - - This function queries the database asynchronously to count the number of nodes - in the `Node` table that meet the condition `channel == 'LongFast'`. It uses - SQLAlchemy's asynchronous session management and query construction. - - Returns: - int: The total count of nodes with `channel == 'LongFast'`. - - Raises: - Exception: If an error occurs during the database query execution. - """ try: # Open an asynchronous session with the database async with database.async_session() as session: # Build the query to count nodes where channel == 'LongFast' - q = select(func.count(Node.id)).filter(Node.channel == 'LongFast') + q = select(func.count(Node.id)) + q = q.where(Node.last_update > datetime.datetime.now() - datetime.timedelta( days=1)) # Look for nodes with nodeinfo updates in the last 24 hours + q = q.where(Node.channel == 'LongFast') # # Execute the query asynchronously and fetch the result result = await session.execute(q) @@ -458,25 +449,14 @@ async def get_total_node_count_longfast() -> int: async def get_total_node_count_mediumslow() -> int: - """ - Retrieves the total count of nodes where the channel is equal to 'MediumSlow'. - - This function queries the database asynchronously to count the number of nodes - in the `Node` table that meet the condition `channel == 'MediumSlow'`. It uses - SQLAlchemy's asynchronous session management and query construction. - - Returns: - int: The total count of nodes with `channel == 'MediumSlow'`. - - Raises: - Exception: If an error occurs during the database query execution. - """ try: # Open an asynchronous session with the database async with database.async_session() as session: # Build the query to count nodes where channel == 'LongFast' - q = select(func.count(Node.id)).filter(Node.channel == 'MediumSlow') - + q = select(func.count(Node.id)) + q = q.where(Node.last_update > datetime.datetime.now() - datetime.timedelta( + days=1)) # Look for nodes with nodeinfo updates in the last 24 hours + q = q.where(Node.channel == 'MediumSlow') # # Execute the query asynchronously and fetch the result result = await session.execute(q) diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index c5e36b8..c8da75b 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -23,7 +23,7 @@ >{{ name }} {% endfor %} - +
diff --git a/meshview/templates/node.html b/meshview/templates/node.html index 88f7da6..2bf8c57 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -61,7 +61,7 @@
- {% include "buttons.html" %} +
diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index 3fc73d1..0770699 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -16,7 +16,7 @@

- Total Nodes: + Total Nodes (Last 24 hours): {{ total_nodes }}

diff --git a/meshview/web.py b/meshview/web.py index 0c19810..bc8f06c 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -304,7 +304,6 @@ async def _packet_list(request, raw_packets, packet_event): content_type="text/html", ) - @routes.get("/chat_events") async def chat_events(request): chat_packet = env.get_template("chat_packet.html") @@ -408,7 +407,7 @@ class UplinkedNode: snr: float rssi: float - +# Updated code p.r. @routes.get("/packet_details/{packet_id}") async def packet_details(request): packet_id = int(request.match_info["packet_id"]) @@ -416,8 +415,11 @@ async def packet_details(request): packet = await store.get_packet(packet_id) from_node_cord = None - if packet.from_node and packet.from_node.last_lat: - from_node_cord = [packet.from_node.last_lat * 1e-7 , packet.from_node.last_long * 1e-7] + if packet and packet.from_node and packet.from_node.last_lat: + from_node_cord = [ + packet.from_node.last_lat * 1e-7, + packet.from_node.last_long * 1e-7, + ] uplinked_nodes = [] for p in packets_seen: @@ -444,6 +446,7 @@ async def packet_details(request): elif uplinked_nodes: map_center = [uplinked_nodes[0].lat, uplinked_nodes[0].long] + # Render the template and return the response template = env.get_template("packet_details.html") return web.Response( text=template.render( @@ -461,7 +464,7 @@ async def packet_details(request): portnum = request.query.get("portnum") if portnum: portnum = int(portnum) - packets = await store.get_packets(portnum=portnum) + packets = await store.get_packets(portnum=portnum, limit=50) template = env.get_template("firehose.html") return web.Response( text=template.render( @@ -726,7 +729,7 @@ async def graph_power_metrics(request): @routes.get("/graph/neighbors/{node_id}") async def graph_neighbors(request): - oldest = datetime.datetime.utcnow() - datetime.timedelta(days=4) + oldest = datetime.datetime.now() - datetime.timedelta(days=4) data = {} dates =[] @@ -774,7 +777,7 @@ async def graph_neighbors(request): @routes.get("/graph/neighbors2/{node_id}") async def graph_neighbors2(request): - oldest = datetime.datetime.utcnow() - datetime.timedelta(days=30) + oldest = datetime.datetime.now() - datetime.timedelta(days=30) data = [] node_ids = set() From 08313019975d1de7d38a0ad7964e3c3f3bf4cd9f Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Fri, 7 Feb 2025 21:18:02 -0800 Subject: [PATCH 04/18] Multiple changes to the code. Important to mentioned: - We added another column to the nodes table to report on firmware version - Also changed the date and time format to be more readable. - Added a node list report - Modified the Mesh Graphs --- meshview/database.py | 3 +- meshview/decode_payload.py | 2 + meshview/models.py | 1 + meshview/mqtt_reader.py | 2 - meshview/store.py | 45 ++++++++++++++++- meshview/templates/base.html | 2 +- meshview/templates/chat_packet.html | 9 ++-- meshview/templates/net_packet.html | 2 +- meshview/templates/nodelist.html | 69 ++++++++++++++++++++++++++ meshview/templates/packet.html | 4 +- meshview/templates/packet_details.html | 4 +- meshview/web.py | 43 ++++++++++++---- 12 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 meshview/templates/nodelist.html diff --git a/meshview/database.py b/meshview/database.py index 5451274..a20e37b 100644 --- a/meshview/database.py +++ b/meshview/database.py @@ -7,7 +7,8 @@ def init_database(database_connection_string): if not database_connection_string.startswith('sqlite'): kwargs['pool_size'] = 20 kwargs['max_overflow'] = 50 - engine = create_async_engine(database_connection_string, echo=False, **kwargs) + print (**kwargs) + engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 15}) async_session = async_sessionmaker(engine, expire_on_commit=False) async def create_tables(): diff --git a/meshview/decode_payload.py b/meshview/decode_payload.py index 99fe696..f8f9a20 100644 --- a/meshview/decode_payload.py +++ b/meshview/decode_payload.py @@ -1,3 +1,4 @@ +from meshtastic.protobuf.mqtt_pb2 import MapReport from meshtastic.protobuf.portnums_pb2 import PortNum from meshtastic.protobuf.mesh_pb2 import ( Position, @@ -24,6 +25,7 @@ DECODE_MAP = { PortNum.TRACEROUTE_APP: RouteDiscovery.FromString, PortNum.ROUTING_APP: Routing.FromString, PortNum.TEXT_MESSAGE_APP: text_message, + PortNum.MAP_REPORT_APP: MapReport.FromString } diff --git a/meshview/models.py b/meshview/models.py index c1c5b2a..3c0587f 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -17,6 +17,7 @@ class Node(Base): long_name: Mapped[str] short_name: Mapped[str] hw_model: Mapped[str] + firmware: Mapped[str] role: Mapped[str] = mapped_column(nullable=True) last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) diff --git a/meshview/mqtt_reader.py b/meshview/mqtt_reader.py index 1bac492..5453f5c 100644 --- a/meshview/mqtt_reader.py +++ b/meshview/mqtt_reader.py @@ -1,11 +1,9 @@ import base64 import asyncio import random - import aiomqtt from google.protobuf.message import DecodeError from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from meshtastic.protobuf.mqtt_pb2 import ServiceEnvelope KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==") diff --git a/meshview/store.py b/meshview/store.py index c7e3a9b..3c5dec4 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -2,7 +2,7 @@ import datetime from sqlalchemy import select, func from sqlalchemy.orm import lazyload - +from sqlalchemy import update from meshtastic.protobuf.config_pb2 import Config from meshtastic.protobuf.portnums_pb2 import PortNum from meshtastic.protobuf.mesh_pb2 import User, HardwareModel @@ -12,7 +12,34 @@ from meshview.models import Packet, PacketSeen, Node, Traceroute from meshview import notify + async def process_envelope(topic, env): + + # Checking if the received packet is a MAP_REPORT + # Update the node table with the firmware version + if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP: + # Extract the node ID from the packet (renamed from 'id' to 'node_id' to avoid conflicts with Python's built-in id function) + node_id = getattr(env.packet, "from") + + # Decode the MAP report payload to extract the firmware version + map_report = decode_payload.decode_payload(PortNum.MAP_REPORT_APP, env.packet.decoded.payload) + + # Establish an asynchronous database session + async with database.async_session() as session: + # Construct an SQLAlchemy update statement + stmt = ( + update(Node) + .where(Node.node_id == node_id) # Ensure correct column reference + .values(firmware=map_report.firmware_version) # Assign new firmware value + ) + + # Execute the update statement asynchronously + await session.execute(stmt) + + # Commit the changes to the database + await session.commit() + + # This ignores any packet that does not have a ID if not env.packet.id: return @@ -58,6 +85,8 @@ async def process_envelope(topic, env): ) session.add(seen) + + if env.packet.decoded.portnum == PortNum.NODEINFO_APP: user = decode_payload.decode_payload( PortNum.NODEINFO_APP, env.packet.decoded.payload @@ -89,7 +118,6 @@ async def process_envelope(topic, env): node.hw_model = hw_model node.role = role node.last_update =datetime.datetime.now() - # if need to update time of last update it may be here else: node = Node( @@ -481,3 +509,16 @@ async def get_nodes_mediumslow(): return result.scalars() + +async def get_nodes(): + async with database.async_session() as session: + result = await session.execute( + select(Node) + .where(Node.last_update != "") + .order_by(Node.long_name) # Sorting by long_name + ) + return result.scalars() + + + + diff --git a/meshview/templates/base.html b/meshview/templates/base.html index af948b6..0c40b77 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -34,7 +34,7 @@
Bay Area Mesh - http://bayme.sh
-
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LG - MS  - Stats

+
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LG - MS  - Nodes - Stats

Loading...
diff --git a/meshview/templates/chat_packet.html b/meshview/templates/chat_packet.html index 1c95df4..9a6d833 100644 --- a/meshview/templates/chat_packet.html +++ b/meshview/templates/chat_packet.html @@ -1,5 +1,6 @@
- {{packet.import_time | format_timestamp}} ✉️ - {{packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }} - {{packet.payload}} -
+ {{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}} + ✉️ {{packet.from_node.channel}} + {{packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }} + {{packet.payload}} +
\ No newline at end of file diff --git a/meshview/templates/net_packet.html b/meshview/templates/net_packet.html index 7582a1f..fe958f7 100644 --- a/meshview/templates/net_packet.html +++ b/meshview/templates/net_packet.html @@ -5,7 +5,7 @@
-
{{packet.import_time | format_timestamp}}
+
{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}}
{{packet.payload}}
diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html new file mode 100644 index 0000000..f175b60 --- /dev/null +++ b/meshview/templates/nodelist.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block css %} +table { + width: 100%; + border-collapse: collapse; + margin-top: 1em; +} + +th, td { + padding: 10px; + border: 1px solid #333; + text-align: left; +} + +th { + background-color: #1f1f1f; + color: white; +} + +tr:nth-child(even) { + background-color: #181818; +} + +tr:nth-child(odd) { + background-color: #222; +} +{% endblock %} + +{% block body %} +
+ {% if nodes %} + + + + + + + + + + + + + + + + + {% for node in nodes %} + + + + + + + + + + + + + {% endfor %} + +
Node IDLong NameShort NameHW ModelFirmwareRoleLast LatitudeLast LongitudeChannelLast Update
{{node.node_id }}{{ node.long_name }}{{ node.short_name }}{{ node.hw_model }}{{ node.firmware }}{{ node.role if node.role else "N/A" }}{{ node.last_lat if node.last_lat else "N/A" }}{{ node.last_long if node.last_long else "N/A" }}{{ node.channel }}{{ node.last_update.strftime('%-I:%M:%S %p - %d-%m-%Y') if node.last_update else "N/A" }}
+ {% else %} +

No nodes found.

+ {% endif %} +
+{% endblock %} diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html index e430d32..a6f7ceb 100644 --- a/meshview/templates/packet.html +++ b/meshview/templates/packet.html @@ -34,8 +34,8 @@
-
import_time
-
{{packet.import_time | format_timestamp}}
+
Import Time
+
{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}}
packet
{{packet.data}}
payload
diff --git a/meshview/templates/packet_details.html b/meshview/templates/packet_details.html index 3de8ebc..d273f0a 100644 --- a/meshview/templates/packet_details.html +++ b/meshview/templates/packet_details.html @@ -12,8 +12,8 @@
-
import_time
-
{{seen.import_time|format_timestamp}}
+
Import Time
+
{{seen.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}}
rx_time
{{seen.rx_time|format_timestamp}}
hop_limit
diff --git a/meshview/web.py b/meshview/web.py index bc8f06c..a77b559 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -474,14 +474,13 @@ async def packet_details(request): content_type="text/html", ) - @routes.get("/chat") async def chat(request): try: # Fetch packets for the given node ID and port number #print("Fetching packets...") packets = await store.get_packets( - node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP + node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, limit=100 ) #print(f"Fetched {len(packets)} packets.") @@ -625,9 +624,6 @@ async def graph_chutil(request): ], ) - - - @routes.get("/graph/wind_speed/{node_id}") async def graph_wind_speed(request): return await graph_telemetry( @@ -1022,7 +1018,10 @@ async def graph_network(request): #graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism", quadtree="2", repulsiveforce="1.5", k="1", overlap_scaling="1.5", concentrate=True) #graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism1000", overlap_scaling="-4", sep="1000", pack="true") - graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + #graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism", esep="+10", nodesep="0.5", + ranksep="1") + for node_id in used_nodes: node = await nodes[node_id] color = '#000000' @@ -1229,20 +1228,21 @@ async def graph_network_longfast(request): edges = new_edges # Create graph - graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="scale", model='subset', splines="true") for node_id in used_nodes: node = await nodes[node_id] color = '#000000' node_name = await get_node_name(node_id) if node and node.role in ('ROUTER', 'ROUTER_CLIENT', 'REPEATER'): color = '#0000FF' - elif node and node.role == 'CLIENT_MUTE': - color = '#00FF00' + #elif node and node.role == 'CLIENT_MUTE': + # color = '#00FF00' graph.add_node(pydot.Node( str(node_id), label=node_name, shape='box', color=color, + fontsize="10", width="0", height="0", href=f"/graph/network?root={node_id}&depth={depth-1}", )) @@ -1276,8 +1276,9 @@ async def graph_network_longfast(request): str(dest), color=color, tooltip=f'{await get_node_name(src)} -> {await get_node_name(dest)}', - penwidth=1.85, + penwidth=.5, dir=edge_dir, + arrowsize=".5", )) return web.Response( @@ -1407,7 +1408,8 @@ async def graph_network_mediumslow(request): edges = new_edges # Create graph - graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="scale", model='subset', esep="+5", splines="true", nodesep="2", ranksep="2") + for node_id in used_nodes: node = await nodes[node_id] color = '#000000' @@ -1467,6 +1469,25 @@ async def graph_network_mediumslow(request): print(f"Error in graph_network_longfast: {e}") return web.Response(status=500, text="Internal Server Error") +@routes.get("/nodelist") +async def nodelist(request): + try: + nodes= await store.get_nodes() + template = env.get_template("nodelist.html") + return web.Response( + text=template.render(nodes=nodes), + content_type="text/html", + ) + except Exception as e: + + return web.Response( + text="An error occurred while processing your request.", + status=500, + content_type="text/plain", + ) + + + async def run_server(bind, port, tls_cert): From 365200dcb89c791ad0c731ee787608d9246dfafc Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 12:53:37 -0800 Subject: [PATCH 05/18] Update README --- README | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README b/README index d4c2f7f..d96f5e8 100644 --- a/README +++ b/README @@ -1,6 +1,8 @@ Meshview ======== +Currently running at http://meshview.bayme.sh + This project watches a MQTT topic for meshtastic messages, imports them to a database and has a web UI to view them. Requires Python 3.12 @@ -26,3 +28,6 @@ Other Options: --topic MQTT Topic, default is 'msh/US/bayarea/#' + + + From 6b76908698c2415ea061c80d64f0f4374a77da7d Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:31:45 -0800 Subject: [PATCH 06/18] Multiple changes to the code. Important to mentioned: - Added ways to show the node data on the page with links to the nodes themselves. - more work on the graphs. --- README | 2 ++ meshview/store.py | 51 ++++++++++++++++++++++++++------ meshview/templates/base.html | 2 +- meshview/templates/nodelist.html | 16 +++++----- meshview/web.py | 18 +++++++---- 5 files changed, 65 insertions(+), 24 deletions(-) diff --git a/README b/README index d4c2f7f..6e3b32e 100644 --- a/README +++ b/README @@ -26,3 +26,5 @@ Other Options: --topic MQTT Topic, default is 'msh/US/bayarea/#' +Screenshots +![Main PAge](/images/main.png) \ No newline at end of file diff --git a/meshview/store.py b/meshview/store.py index 3c5dec4..e0ff9df 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -506,19 +506,52 @@ async def get_nodes_mediumslow(): (Node.channel == "MediumSlow") ) ) + return result.scalars() +async def get_nodes(role=None, channel=None, hw_model=None): + """ + Fetches nodes from the database based on optional filtering criteria. -async def get_nodes(): - async with database.async_session() as session: - result = await session.execute( - select(Node) - .where(Node.last_update != "") - .order_by(Node.long_name) # Sorting by long_name - ) - return result.scalars() - + Parameters: + role (str, optional): The role of the node (converted to uppercase for consistency). + channel (str, optional): The communication channel associated with the node. + hw_model (str, optional): The hardware model of the node. + + Returns: + list: A list of Node objects that match the given criteria. + """ + try: + async with database.async_session() as session: + print(channel) # Debugging output (consider replacing with logging) + + # Start with a base query selecting all nodes + query = select(Node) + + # Apply filters based on provided parameters + if role is not None: + query = query.where(Node.role == role.upper()) # Ensure role is uppercase + if channel is not None: + query = query.where(Node.channel == channel) + if hw_model is not None: + query = query.where(Node.hw_model == hw_model) + + # Exclude nodes where last_update is an empty string + query = query.where(Node.last_update != "") + + # Order results by long_name in ascending order + query = query.order_by(Node.long_name.asc()) + + # Execute the query and retrieve results + result = await session.execute(query) + nodes = result.scalars().all() + + return nodes # Return the list of nodes + + except Exception as e: + print("error reading DB") # Consider using logging instead of print + return [] # Return an empty list in case of failure diff --git a/meshview/templates/base.html b/meshview/templates/base.html index 0c40b77..e99f6e3 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -34,7 +34,7 @@
Bay Area Mesh - http://bayme.sh
-
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LG - MS  - Nodes - Stats

+
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LF - MS  - Nodes - Stats

Loading...
diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index f175b60..dd97b38 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -33,7 +33,6 @@ tr:nth-child(odd) { - @@ -48,16 +47,15 @@ tr:nth-child(odd) { {% for node in nodes %} - - + - + - - - - - + + + + + {% endfor %} diff --git a/meshview/web.py b/meshview/web.py index a77b559..05a7658 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -1261,7 +1261,7 @@ async def graph_network_longfast(request): if edge_type[(src, dest)] in ('ni'): color = '#FF0000' elif edge_type[(src, dest)] in ('sni'): - color = '#00FF00' + color = '#040fb3' else: color = '#000000' edge_dir = "forward" @@ -1296,7 +1296,7 @@ async def graph_network_longfast(request): async def graph_network_mediumslow(request): try: root = request.query.get("root") - depth = int(request.query.get("depth", 5)) + depth = int(request.query.get("depth", 3)) hours = int(request.query.get("hours", 24)) minutes = int(request.query.get("minutes", 0)) @@ -1423,6 +1423,7 @@ async def graph_network_mediumslow(request): label=node_name, shape='box', color=color, + fontsize="10", width="0", height="0", href=f"/graph/mediumslow?root={node_id}&depth={depth-1}", )) @@ -1441,7 +1442,7 @@ async def graph_network_mediumslow(request): if edge_type[(src, dest)] in ('ni'): color = '#FF0000' elif edge_type[(src, dest)] in ('sni'): - color = '#00FF00' + color = '#040fb3' else: color = '#000000' edge_dir = "forward" @@ -1456,8 +1457,9 @@ async def graph_network_mediumslow(request): str(dest), color=color, tooltip=f'{await get_node_name(src)} -> {await get_node_name(dest)}', - penwidth=1.85, + penwidth=.5, dir=edge_dir, + arrowsize=".5", )) return web.Response( @@ -1472,7 +1474,13 @@ async def graph_network_mediumslow(request): @routes.get("/nodelist") async def nodelist(request): try: - nodes= await store.get_nodes() + role = request.query.get("role") + #print(role) + channel = request.query.get("channel") + print(channel) + hw_model = request.query.get("hw_model") + print(hw_model) + nodes= await store.get_nodes(role,channel, hw_model) template = env.get_template("nodelist.html") return web.Response( text=template.render(nodes=nodes), From e1228fbb643266d1a53677ab73b896ffd8f01def Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:35:21 -0800 Subject: [PATCH 07/18] Multiple changes to the code. Important to mentioned: - Added ways to show the node data on the page with links to the nodes themselves. - more work on the graphs. --- images/main.png | Bin 0 -> 33199 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 images/main.png diff --git a/images/main.png b/images/main.png new file mode 100644 index 0000000000000000000000000000000000000000..d2aab248290b69ad4a6a633f6ae71d2d316a4613 GIT binary patch literal 33199 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 literal 0 HcmV?d00001 From bdcf0869399a0684c8105e3fc92ab10b58d4c968 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:37:12 -0800 Subject: [PATCH 08/18] Multiple changes to the code. Important to mentioned: - Added ways to show the node data on the page with links to the nodes themselves. - more work on the graphs. --- README | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README b/README index d4c2f7f..1273ae6 100644 --- a/README +++ b/README @@ -1,6 +1,8 @@ Meshview ======== +Now running at https://meshview.bayme.sh + This project watches a MQTT topic for meshtastic messages, imports them to a database and has a web UI to view them. Requires Python 3.12 From 052adb779174d6a34a6ce380d2708a1c6c3f30a9 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:38:54 -0800 Subject: [PATCH 09/18] added screenshot --- README | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README b/README index 1273ae6..26061da 100644 --- a/README +++ b/README @@ -28,3 +28,5 @@ Other Options: --topic MQTT Topic, default is 'msh/US/bayarea/#' + +![Alt text](images/main.png) \ No newline at end of file From e17c288041a73403d19fe096b5e01025af0ed0ae Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:40:38 -0800 Subject: [PATCH 10/18] added screenshot --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index 26061da..d652a8e 100644 --- a/README +++ b/README @@ -29,4 +29,4 @@ Other Options: MQTT Topic, default is 'msh/US/bayarea/#' -![Alt text](images/main.png) \ No newline at end of file +![Main Manu](images/main.png) \ No newline at end of file From 94d803df66e67a23ed51deb789a277b08fdd13ef Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:41:57 -0800 Subject: [PATCH 11/18] added screenshot --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index d652a8e..7ac8311 100644 --- a/README +++ b/README @@ -29,4 +29,4 @@ Other Options: MQTT Topic, default is 'msh/US/bayarea/#' -![Main Manu](images/main.png) \ No newline at end of file +Main Page From 0151bd9513be1787525647c2beb22df471b6a577 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:43:05 -0800 Subject: [PATCH 12/18] Update README --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index 7ac8311..a3f09ec 100644 --- a/README +++ b/README @@ -29,4 +29,4 @@ Other Options: MQTT Topic, default is 'msh/US/bayarea/#' -Main Page +Main Page From 9446c3532c28cbbee9fce52ca5d0f1b1457ef0d0 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 17:49:00 -0800 Subject: [PATCH 14/18] Update README --- README | 2 -- 1 file changed, 2 deletions(-) diff --git a/README b/README index a3f09ec..1273ae6 100644 --- a/README +++ b/README @@ -28,5 +28,3 @@ Other Options: --topic MQTT Topic, default is 'msh/US/bayarea/#' - -Main Page From 9fbe3400eff50937b6ec156f0b0e26eed870c2a4 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Tue, 11 Feb 2025 23:01:15 -0800 Subject: [PATCH 15/18] Added new searching capabilities to our node reports --- README | 2 - meshview/store.py | 3 +- meshview/templates/nodelist.html | 144 ++++++++++++++++++++++++++----- meshview/web.py | 4 +- 4 files changed, 126 insertions(+), 27 deletions(-) diff --git a/README b/README index 7ac8311..1273ae6 100644 --- a/README +++ b/README @@ -28,5 +28,3 @@ Other Options: --topic MQTT Topic, default is 'msh/US/bayarea/#' - -Main Page diff --git a/meshview/store.py b/meshview/store.py index e0ff9df..3ff6118 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -524,7 +524,7 @@ async def get_nodes(role=None, channel=None, hw_model=None): """ try: async with database.async_session() as session: - print(channel) # Debugging output (consider replacing with logging) + #print(channel) # Debugging output (consider replacing with logging) # Start with a base query selecting all nodes query = select(Node) @@ -546,7 +546,6 @@ async def get_nodes(role=None, channel=None, hw_model=None): # Execute the query and retrieve results result = await session.execute(query) nodes = result.scalars().all() - return nodes # Return the list of nodes except Exception as e: diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index dd97b38..5f5436a 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -16,6 +16,7 @@ th, td { th { background-color: #1f1f1f; color: white; + cursor: pointer; } tr:nth-child(even) { @@ -25,37 +26,86 @@ tr:nth-child(even) { tr:nth-child(odd) { background-color: #222; } + +.search-container { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.search, .filter-role, .filter-channel, .export-btn { + padding: 8px; + border: 1px solid #333; + border-radius: 4px; +} + +.filter-role, .filter-channel { + cursor: pointer; +} + +.export-btn { + background: #28a745; + color: white; + border: none; + cursor: pointer; +} + +.export-btn:hover { + background: #218838; +} {% endblock %} {% block body %} -
+
+
+ + + + + + + + + +
+ {% if nodes %} -
Node ID Long Name Short Name HW Model
{{node.node_id }}{{ node.long_name }} {{node.long_name}} {{ node.short_name }}{{ node.hw_model }}{{node.hw_model if node.hw_model else "N/A"}} {{ node.firmware }}{{ node.role if node.role else "N/A" }}{{ node.last_lat if node.last_lat else "N/A" }}{{ node.last_long if node.last_long else "N/A" }}{{ node.channel }}{{ node.last_update.strftime('%-I:%M:%S %p - %d-%m-%Y') if node.last_update else "N/A" }}{{node.role if node.role else "N/A"}}{{ "{:.7f}".format(node.last_lat / 10**7) if node.last_lat else "N/A" }}{{ "{:.7f}".format(node.last_long / 10**7) if node.last_long else "N/A" }}{{node.channel if node.channel else "N/A"}}{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }}
+
- - - - - - - - - + + + + + + + + + - + {% for node in nodes %} - - - - - - - - - + + + + + + + + + {% endfor %} @@ -64,4 +114,56 @@ tr:nth-child(odd) {

No nodes found.

{% endif %} + + + {% endblock %} diff --git a/meshview/web.py b/meshview/web.py index 05a7658..63e42d7 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -1477,9 +1477,9 @@ async def nodelist(request): role = request.query.get("role") #print(role) channel = request.query.get("channel") - print(channel) + #print(channel) hw_model = request.query.get("hw_model") - print(hw_model) + #print(hw_model) nodes= await store.get_nodes(role,channel, hw_model) template = env.get_template("nodelist.html") return web.Response( From 0556706a3051344989e749f35803523033548bdf Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Wed, 12 Feb 2025 08:50:19 -0800 Subject: [PATCH 16/18] Added new searching capabilities to our node reports --- meshview/models.py | 40 ++++++++++++++++---------------- meshview/templates/base.html | 2 +- meshview/templates/nodelist.html | 34 ++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/meshview/models.py b/meshview/models.py index 3c0587f..569943c 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -14,31 +14,31 @@ class Node(Base): __tablename__ = "node" id: Mapped[str] = mapped_column(primary_key=True) node_id: Mapped[int] = mapped_column(BigInteger, nullable=True, unique=True) - long_name: Mapped[str] - short_name: Mapped[str] - hw_model: Mapped[str] - firmware: Mapped[str] + long_name: Mapped[str] = mapped_column(nullable=True) + short_name: Mapped[str] = mapped_column(nullable=True) + hw_model: Mapped[str] = mapped_column(nullable=True) + firmware: Mapped[str] = mapped_column(nullable=True) role: Mapped[str] = mapped_column(nullable=True) last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) - channel: Mapped[str] - last_update: Mapped[datetime] + channel: Mapped[str] = mapped_column(nullable=True) + last_update: Mapped[datetime] = mapped_column(nullable=True) class Packet(Base): __tablename__ = "packet" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - portnum: Mapped[int] - from_node_id: Mapped[int] = mapped_column(BigInteger) + portnum: Mapped[int] = mapped_column(nullable=True) + from_node_id: Mapped[int] = mapped_column(BigInteger, nullable=True) from_node: Mapped["Node"] = relationship( primaryjoin="Packet.from_node_id == foreign(Node.node_id)", lazy="joined" ) - to_node_id: Mapped[int] = mapped_column(BigInteger) + to_node_id: Mapped[int] = mapped_column(BigInteger,nullable=True) to_node: Mapped["Node"] = relationship( primaryjoin="Packet.to_node_id == foreign(Node.node_id)", lazy="joined" ) - payload: Mapped[bytes] - import_time: Mapped[datetime] - channel: Mapped[str] + payload: Mapped[bytes] = mapped_column(nullable=True) + import_time: Mapped[datetime] = mapped_column(nullable=True) + channel: Mapped[str] = mapped_column(nullable=True) class PacketSeen(Base): @@ -49,13 +49,13 @@ class PacketSeen(Base): lazy="joined", primaryjoin="PacketSeen.node_id == foreign(Node.node_id)" ) rx_time: Mapped[int] = mapped_column(BigInteger, primary_key=True) - hop_limit: Mapped[int] + hop_limit: Mapped[int] = mapped_column(nullable=True) hop_start: Mapped[int] = mapped_column(nullable=True) - channel: Mapped[str] + channel: Mapped[str] = mapped_column(nullable=True) rx_snr: Mapped[float] = mapped_column(nullable=True) rx_rssi: Mapped[int] = mapped_column(nullable=True) - topic: Mapped[str] - import_time: Mapped[datetime] + topic: Mapped[str] = mapped_column(nullable=True) + import_time: Mapped[datetime] = mapped_column(nullable=True) class Traceroute(Base): @@ -65,8 +65,8 @@ class Traceroute(Base): packet: Mapped["Packet"] = relationship( primaryjoin="Traceroute.packet_id == foreign(Packet.id)", lazy="joined" ) - gateway_node_id: Mapped[int] = mapped_column(BigInteger) - done: Mapped[bool] - route: Mapped[bytes] - import_time: Mapped[datetime] + gateway_node_id: Mapped[int] = mapped_column(BigInteger, nullable=True) + done: Mapped[bool] = mapped_column(nullable=True) + route: Mapped[bytes] = mapped_column(nullable=True) + import_time: Mapped[datetime] = mapped_column(nullable=True) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index e99f6e3..fa3ae08 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -34,7 +34,7 @@
Bay Area Mesh - http://bayme.sh
-
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LF - MS  - Nodes - Stats

+
Quick Links:  Nodes - Conversations - See everything  - Mesh Graph LF - MS  - Stats

Loading...
diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index 5f5436a..48e545c 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -31,15 +31,16 @@ tr:nth-child(odd) { display: flex; gap: 10px; margin-bottom: 10px; + align-items: center; } -.search, .filter-role, .filter-channel, .export-btn { +.search, .filter-role, .filter-channel, .filter-hw_model, .export-btn { padding: 8px; border: 1px solid #333; border-radius: 4px; } -.filter-role, .filter-channel { +.filter-role, .filter-channel, .filter-hw_model { cursor: pointer; } @@ -53,6 +54,12 @@ tr:nth-child(odd) { .export-btn:hover { background: #218838; } + +.node-count { + font-size: 16px; + font-weight: bold; + color: white; +} {% endblock %} {% block body %} @@ -76,7 +83,18 @@ tr:nth-child(odd) { {% endfor %} + + + + + + Total Nodes: 0 {% if nodes %} @@ -124,17 +142,27 @@ tr:nth-child(odd) { valueNames: ["long_name", "short_name", "hw_model", "firmware", "role", "last_lat", "last_long", "channel", "last_update"] }; nodeList = new List("node-list", options); + updateCount(); // Set initial count }); function applyFilters() { var selectedRole = document.querySelector(".filter-role").value; var selectedChannel = document.querySelector(".filter-channel").value; + var selectedHWModel = document.querySelector(".filter-hw_model").value; nodeList.filter(function (item) { var matchesRole = selectedRole === "" || item.values().role === selectedRole; var matchesChannel = selectedChannel === "" || item.values().channel === selectedChannel; - return matchesRole && matchesChannel; + var matchesHWModel = selectedHWModel === "" || item.values().hw_model === selectedHWModel; + return matchesRole && matchesChannel && matchesHWModel; }); + + updateCount(); // Update the count after filtering + } + + function updateCount() { + var visibleRows = document.querySelectorAll("#node-table tbody tr:not([style*='display: none'])").length; + document.getElementById("node-count-value").innerText = visibleRows; } function exportToCSV() { From 85c708a5a6a32f50b3a45c3a3a49076044743a8a Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Thu, 13 Feb 2025 11:18:11 -0800 Subject: [PATCH 17/18] Added Weekly Mesh reporting --- meshview/notify.py | 2 - meshview/templates/base.html | 4 +- meshview/templates/net.html | 37 +++--- meshview/templates/net_packet.html | 10 +- meshview/templates/nodelist.html | 35 ++++-- meshview/web.py | 189 ++++++++++++++++++++++++----- 6 files changed, 203 insertions(+), 74 deletions(-) diff --git a/meshview/notify.py b/meshview/notify.py index 37153f5..57268e9 100644 --- a/meshview/notify.py +++ b/meshview/notify.py @@ -34,7 +34,6 @@ def create_event(node_id): def remove_event(node_event): - print("removing event") waiting_node_ids_events[node_event.node_id].remove(node_event) def notify_packet(node_id, packet): @@ -57,7 +56,6 @@ def subscribe(node_id): event = create_event(node_id) try: yield event - print("adding event...") except Exception as e: print(f"Error during subscription for node_id={node_id}: {e}") raise diff --git a/meshview/templates/base.html b/meshview/templates/base.html index fa3ae08..c089e4d 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -34,7 +34,9 @@
Bay Area Mesh - http://bayme.sh
-
Quick Links:  Nodes - Conversations - See everything  - Mesh Graph LF - MS  - Stats

+
Quick Links:  Nodes - Conversations - See everything +  - Mesh Graph LF - MS  - Stats +  - Weekly Net

Loading...
diff --git a/meshview/templates/net.html b/meshview/templates/net.html index e0288f0..c6f63e6 100644 --- a/meshview/templates/net.html +++ b/meshview/templates/net.html @@ -1,27 +1,24 @@ {% extends "base.html" %} {% block css %} - #packet_details{ - height: 95vh; - overflow: scroll; - } +.timestamp { + min-width:10em; +} +.chat-packet:nth-of-type(odd){ + background-color:#1f1f1f; +} +.chat-packet:nth-of-type(even){ + background-color:#181818; +} {% endblock %} + {% block body %} -
-
-
- {% for packet in net_packets %} - {% include 'net_packet.html' %} - {% endfor %} -
-
-
+
+ {% for packet in packets %} + {% include 'net_packet.html' %} + {% else %} + No packets found. + {% endfor %}
-
-{% endblock body %} +{% endblock %} diff --git a/meshview/templates/net_packet.html b/meshview/templates/net_packet.html index fe958f7..3a06039 100644 --- a/meshview/templates/net_packet.html +++ b/meshview/templates/net_packet.html @@ -1,12 +1,8 @@
{{packet.from_node.long_name}} - 🔎 -
-
-
-
{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}}
-
{{packet.payload}}
-
+
+
+
{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}} - {{packet.payload}}
diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index 48e545c..00dc104 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -31,7 +31,6 @@ tr:nth-child(odd) { display: flex; gap: 10px; margin-bottom: 10px; - align-items: center; } .search, .filter-role, .filter-channel, .filter-hw_model, .export-btn { @@ -55,8 +54,8 @@ tr:nth-child(odd) { background: #218838; } -.node-count { - font-size: 16px; +.count-container { + margin-bottom: 10px; font-weight: bold; color: white; } @@ -67,7 +66,7 @@ tr:nth-child(odd) {
- + - + - + - - - Total Nodes: 0
+ +
Showing 0 nodes
+ {% if nodes %}
Long NameShort NameHW ModelFirmwareRoleLast LatitudeLast LongitudeChannelLast UpdateLong NameShort NameHW ModelFirmwareRoleLast LatitudeLast LongitudeChannelLast Update
{{node.long_name}}{{ node.short_name }}{{node.hw_model if node.hw_model else "N/A"}}{{ node.firmware }}{{node.role if node.role else "N/A"}}{{ "{:.7f}".format(node.last_lat / 10**7) if node.last_lat else "N/A" }}{{ "{:.7f}".format(node.last_long / 10**7) if node.last_long else "N/A" }}{{node.channel if node.channel else "N/A"}}{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }} {{ node.long_name }}{{ node.short_name }}{{ node.hw_model if node.hw_model else "N/A" }}{{ node.firmware }}{{ node.role if node.role else "N/A" }}{{ "{:.7f}".format(node.last_lat / 10**7) if node.last_lat else "N/A" }}{{ "{:.7f}".format(node.last_long / 10**7) if node.last_long else "N/A" }}{{ node.channel if node.channel else "N/A" }}{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }}
@@ -109,7 +108,7 @@ tr:nth-child(odd) { - + @@ -123,7 +122,9 @@ tr:nth-child(odd) { - + {% endfor %} @@ -139,10 +140,18 @@ tr:nth-child(odd) { document.addEventListener("DOMContentLoaded", function () { var options = { - valueNames: ["long_name", "short_name", "hw_model", "firmware", "role", "last_lat", "last_long", "channel", "last_update"] + valueNames: [ + "long_name", "short_name", "hw_model", "firmware", "role", + "last_lat", "last_long", "channel", { name: "last_update", attr: "data-timestamp" } + ] }; nodeList = new List("node-list", options); - updateCount(); // Set initial count + + updateCount(); // Update count on load + + nodeList.on("updated", function () { + updateCount(); // Update count when search or sort changes + }); }); function applyFilters() { diff --git a/meshview/web.py b/meshview/web.py index 63e42d7..b917882 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -304,73 +304,124 @@ async def _packet_list(request, raw_packets, packet_event): content_type="text/html", ) + @routes.get("/chat_events") async def chat_events(request): + """ + Server-Sent Events (SSE) endpoint for real-time chat packet updates. + + This endpoint listens for new chat packets related to `PortNum.TEXT_MESSAGE_APP` + and streams them to connected clients. Messages matching the pattern `"seq \d+$"` + are filtered out before sending. + + Args: + request (aiohttp.web.Request): The incoming HTTP request. + + Returns: + aiohttp.web.StreamResponse: SSE response streaming chat events. + """ chat_packet = env.get_template("chat_packet.html") + # Precompile regex for filtering out unwanted messages (case insensitive) + seq_pattern = re.compile(r"seq \d+$", re.IGNORECASE) + + # Subscribe to notifications for packets from all nodes (0xFFFFFFFF = broadcast) with notify.subscribe(node_id=0xFFFFFFFF) as event: async with sse_response(request) as resp: - while resp.is_connected(): + while resp.is_connected(): # Keep the connection open while the client is connected try: - async with asyncio.timeout(10): - await event.wait() - except TimeoutError: + # Wait for an event with a timeout of 10 seconds + await asyncio.wait_for(event.wait(), timeout=10) + except asyncio.TimeoutError: + # Timeout reached, continue looping to keep connection alive continue + if event.is_set(): + # Extract relevant packets, ensuring event.packets is not None packets = [ - p - for p in event.packets - if PortNum.TEXT_MESSAGE_APP == p.portnum + p for p in (event.packets or []) + if p.portnum == PortNum.TEXT_MESSAGE_APP ] - event.clear() + event.clear() # Reset event flag + try: for packet in packets: ui_packet = Packet.from_model(packet) - if not re.match(r"seq \d+$", ui_packet.payload): + + # Filter out packets that match "seq " + if not seq_pattern.match(ui_packet.payload): await resp.send( - chat_packet.render( - packet=ui_packet, - ), - event="chat_packet", + chat_packet.render(packet=ui_packet), + event="chat_packet", # SSE event type ) except ConnectionResetError: - return + # Log when a client disconnects unexpectedly + logging.warning("Client disconnected from SSE stream.") + return # Exit the loop and close the connection @routes.get("/events") async def events(request): + """ + Server-Sent Events (SSE) endpoint for real-time packet updates. + + This endpoint listens for new network packets and streams them to connected clients. + Clients can optionally filter packets based on `node_id` and `portnum` query parameters. + + Query Parameters: + - node_id (int, optional): Filter packets for a specific node (default: all nodes). + - portnum (int, optional): Filter packets for a specific port number (default: all ports). + + Args: + request (aiohttp.web.Request): The incoming HTTP request. + + Returns: + aiohttp.web.StreamResponse: SSE response streaming network events. + """ + # Extract and convert query parameters (if provided) node_id = request.query.get("node_id") if node_id: - node_id = int(node_id) + node_id = int(node_id) # Convert node_id to an integer + portnum = request.query.get("portnum") if portnum: - portnum = int(portnum) + portnum = int(portnum) # Convert portnum to an integer + # Load Jinja2 templates for rendering packets packet_template = env.get_template("packet.html") net_packet_template = env.get_template("net_packet.html") + + # Subscribe to packet notifications for the given node_id (or all nodes if None) with notify.subscribe(node_id) as event: async with sse_response(request) as resp: - while resp.is_connected(): + while resp.is_connected(): # Keep connection open while client is connected try: - async with asyncio.timeout(10): - await event.wait() - except TimeoutError: - continue + # Wait for an event with a timeout of 10 seconds + await asyncio.wait_for(event.wait(), timeout=10) + except asyncio.TimeoutError: + continue # No new packets, continue waiting + if event.is_set(): + # Extract relevant packets based on `portnum` filter (if provided) packets = [ - p - for p in event.packets + p for p in (event.packets or []) if portnum is None or portnum == p.portnum ] + + # Extract uplinked packets (if port filter applies) uplinked = [ - u - for u in event.uplinked + u for u in (event.uplinked or []) if portnum is None or portnum == u.portnum ] - event.clear() + + event.clear() # Reset event flag + try: + # Process and send incoming packets for packet in packets: ui_packet = Packet.from_model(packet) + + # Send standard packet event await resp.send( packet_template.render( is_hx_request="HX-Request" in request.headers, @@ -379,12 +430,16 @@ async def events(request): ), event="packet", ) + + # If the packet belongs to `PortNum.TEXT_MESSAGE_APP` and contains "#baymeshnet", + # send it as a network event if ui_packet.portnum == PortNum.TEXT_MESSAGE_APP and '#baymeshnet' in ui_packet.payload.lower(): await resp.send( net_packet_template.render(packet=ui_packet), event="net_packet", ) + # Process and send uplinked packets separately for packet in uplinked: await resp.send( packet_template.render( @@ -394,8 +449,10 @@ async def events(request): ), event="uplinked", ) + except ConnectionResetError: - return + logging.warning("Client disconnected from SSE stream.") + return # Gracefully exit on disconnection @dataclass class UplinkedNode: @@ -1015,10 +1072,7 @@ async def graph_network(request): used_nodes = new_used_nodes edges = new_edges - - #graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism", quadtree="2", repulsiveforce="1.5", k="1", overlap_scaling="1.5", concentrate=True) - #graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism1000", overlap_scaling="-4", sep="1000", pack="true") - #graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + # Create the graph graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism", esep="+10", nodesep="0.5", ranksep="1") @@ -1482,6 +1536,7 @@ async def nodelist(request): #print(hw_model) nodes= await store.get_nodes(role,channel, hw_model) template = env.get_template("nodelist.html") + return web.Response( text=template.render(nodes=nodes), content_type="text/html", @@ -1495,7 +1550,79 @@ async def nodelist(request): ) +@routes.get("/net") +async def net(request): + try: + # Fetch packets for the given node ID and port number + packets = await store.get_packets( + node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, limit=200 + ) + # Convert packets to UI packets + ui_packets = [Packet.from_model(p) for p in packets] + + # Precompile regex for performance + seq_pattern = re.compile(r"seq \d+$") + + # Filter packets: exclude "seq \d+$" but include those containing "pablo-test" + filtered_packets = [ + p for p in ui_packets + if not seq_pattern.match(p.payload) and "baymeshnet" in p.payload.lower() + ] + + # Render template + template = env.get_template("net.html") + return web.Response( + text=template.render(packets=filtered_packets), + content_type="text/html", + ) + + except web.HTTPException as e: + raise # Let aiohttp handle HTTP exceptions properly + + except Exception as e: + logging.exception("Error processing chat request") + return web.Response( + text="An internal server error occurred.", + status=500, + content_type="text/plain", + ) + + +@routes.get("/net_events") +async def net_events(request): + chat_packet = env.get_template("net_packet.html") + + # Precompile regex for performance (case insensitive) + seq_pattern = re.compile(r"seq \d+$") + + with notify.subscribe(node_id=0xFFFFFFFF) as event: + async with sse_response(request) as resp: + while resp.is_connected(): + try: + await asyncio.wait_for(event.wait(), timeout=10) + except asyncio.TimeoutError: + continue # Timeout occurred, loop again + + if event.is_set(): + # Ensure event.packets is valid before accessing it + packets = [ + p for p in (event.packets or []) + if p.portnum == PortNum.TEXT_MESSAGE_APP + ] + event.clear() + + try: + for packet in packets: + ui_packet = Packet.from_model(packet) + if not seq_pattern.match(ui_packet.payload) and "baymeshnet" in ui_packet.payload.lower(): + await resp.send( + chat_packet.render(packet=ui_packet), + event="net_packet", + ) + except ConnectionResetError: + print("Client disconnected from SSE stream.") + return # Gracefully exit on disconnection async def run_server(bind, port, tls_cert): From 55031ad71166d023500c7d545658e8adb921e413 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Thu, 13 Feb 2025 22:57:48 -0800 Subject: [PATCH 18/18] Made the conversation section a bit more modern looking --- meshview/templates/chat.html | 10 ++++++++-- meshview/templates/net.html | 18 ++++++++++++++---- meshview/templates/net_packet.html | 14 ++++++-------- meshview/templates/stats.html | 6 +++--- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index 48eb783..821f601 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -5,11 +5,17 @@ min-width:10em; } .chat-packet:nth-of-type(odd){ - background-color:#1f1f1f; + background-color: #3a3a3a; /* Lighter than #2a2a2a */ +} +.chat-packet { + border-bottom: 1px solid #555; + padding: 8px; + border-radius: 8px; /* Adjust the value to make the corners more or less rounded */ } .chat-packet:nth-of-type(even){ - background-color:#181818; + background-color: #333333; /* Slightly lighter than the previous #181818 */ } + {% endblock %} diff --git a/meshview/templates/net.html b/meshview/templates/net.html index c6f63e6..b16e0db 100644 --- a/meshview/templates/net.html +++ b/meshview/templates/net.html @@ -5,18 +5,28 @@ min-width:10em; } .chat-packet:nth-of-type(odd){ - background-color:#1f1f1f; + background-color: #3a3a3a; /* Lighter than #2a2a2a */ +} +.chat-packet { + border-bottom: 1px solid #555; + padding: 8px; + border-radius: 8px; /* Adjust the value to make the corners more or less rounded */ } .chat-packet:nth-of-type(even){ - background-color:#181818; + background-color: #333333; /* Slightly lighter than the previous #181818 */ } + {% endblock %} {% block body %} -
+ +
Weekly Mesh check-in. We will keep it open on every Wednesday from 5:00pm for checkins so you do not have to rush.
+ The message format should be (LONG NAME) - (CITY YOU ARE IN) #BayMeshNet.

+
+
{% for packet in packets %} - {% include 'net_packet.html' %} + {% include 'chat_packet.html' %} {% else %} No packets found. {% endfor %} diff --git a/meshview/templates/net_packet.html b/meshview/templates/net_packet.html index 3a06039..9a6d833 100644 --- a/meshview/templates/net_packet.html +++ b/meshview/templates/net_packet.html @@ -1,8 +1,6 @@ -
-
- {{packet.from_node.long_name}} -
-
-
{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}} - {{packet.payload}}
-
-
+
+ {{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}} + ✉️ {{packet.from_node.channel}} + {{packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }} + {{packet.payload}} +
\ No newline at end of file diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index 0770699..aca49cf 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -16,14 +16,14 @@

- Total Nodes (Last 24 hours): + Total Active Nodes (Last 24 hours): {{ total_nodes }}

- Total Nodes LongFast: + Total Active Nodes LongFast:
{{ total_nodes_longfast }} ({{ (total_nodes_longfast / total_nodes * 100) | round(2) }}%) @@ -33,7 +33,7 @@

- Total Nodes MediumSlow: + Total Active Nodes MediumSlow:
{{ total_nodes_mediumslow }} ({{ (total_nodes_mediumslow / total_nodes * 100) | round(2) }}%)

Last Latitude Last Longitude ChannelLast UpdateLast Update
{{ "{:.7f}".format(node.last_lat / 10**7) if node.last_lat else "N/A" }} {{ "{:.7f}".format(node.last_long / 10**7) if node.last_long else "N/A" }} {{ node.channel if node.channel else "N/A" }}{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }} + {{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }} +