Major changes to the project

- Now the database process and the web process are separate.
- Added Map
- Added new graphing tools
This commit is contained in:
Pablo Revilla
2025-03-05 16:11:40 -08:00
parent 532fd7a56d
commit 5f97274e80
24 changed files with 1380 additions and 1101 deletions
-3
View File
@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (meshview-2)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/meshview-2.iml" filepath="$PROJECT_DIR$/.idea/meshview-2.iml" />
</modules>
</component>
</project>
+5 -2
View File
@@ -6,10 +6,13 @@ acme_challenge =
[mqtt]
server = mqtt.bayme.sh
topics = ['msh/US/bayarea/#', 'msh/US/CA/mrymesh/#']
topics = ["msh/US/bayarea/#", "msh/US/CA/mrymesh/#"]
port = 1883
username = meshdev
password = large4cats
[database]
connection_string = sqlite+aiosqlite:///packets.db
connection_string = sqlite+aiosqlite:///packets.db
[website]
title = San Francisco Bay Area Mesh
+2 -14
View File
@@ -1,18 +1,16 @@
import asyncio
import argparse
import configparser
import json
from meshview import mqtt_reader
from meshview import database
from meshview import store
from meshview import mqtt_store
from meshview import web
from meshview import http
import json
async def load_database_from_mqtt(mqtt_server: str , mqtt_port: int, topic: list, mqtt_user: str | None = None, mqtt_passwd: str | None = None):
async for topic, env in mqtt_reader.get_topic_envelopes(mqtt_server, mqtt_port, topic, mqtt_user, mqtt_passwd):
await store.process_envelope(topic, env)
await mqtt_store.process_envelope(topic, env)
async def main(config):
@@ -28,9 +26,6 @@ async def main(config):
mqtt_topics = json.loads(config["mqtt"]["topics"])
async with asyncio.TaskGroup() as tg:
tg.create_task(
load_database_from_mqtt(config["mqtt"]["server"], int(config["mqtt"]["port"]), mqtt_topics, mqtt_user, mqtt_passwd)
)
tg.create_task(
web.run_server(
config["server"]["bind"],
@@ -38,13 +33,6 @@ async def main(config):
config["server"].get("tls_cert"),
)
)
if config["server"].get("acme_challenge"):
tg.create_task(
http.run_server(
config["server"]["bind"], config["server"]["acme_challenge"]
)
)
def load_config(file_path):
"""Load configuration from an INI-style text file."""
+30 -9
View File
@@ -1,15 +1,36 @@
from meshview import models
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
def init_database(database_connection_string):
engine = None
async_session = None
def init_database(database_connection_string, read_only=False):
global engine, async_session
kwargs = {}
if not database_connection_string.startswith('sqlite'):
kwargs['pool_size'] = 20
kwargs['max_overflow'] = 50
print (**kwargs)
engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 15})
async_session = async_sessionmaker(engine, expire_on_commit=False)
kwargs = {"echo": False}
if database_connection_string.startswith("sqlite"):
if read_only:
# Ensure SQLite is opened in read-only mode
database_connection_string += "?mode=ro"
kwargs["connect_args"] = {"uri": True}
else:
kwargs["connect_args"] = {"timeout": 15}
else:
kwargs["pool_size"] = 20
kwargs["max_overflow"] = 50
print("Database connection settings:", kwargs) # Debugging output
engine = create_async_engine(database_connection_string, **kwargs)
async_session = async_sessionmaker( bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def create_tables():
async with engine.begin() as conn:
-2
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, foreign
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import mapped_column, relationship, Mapped
@@ -69,4 +68,3 @@ class Traceroute(Base):
done: Mapped[bool] = mapped_column(nullable=True)
route: Mapped[bytes] = mapped_column(nullable=True)
import_time: Mapped[datetime] = mapped_column(nullable=True)
+16
View File
@@ -0,0 +1,16 @@
from meshview import models
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
def init_database(database_connection_string):
global engine, async_session
kwargs = {}
if not database_connection_string.startswith('sqlite'):
kwargs['pool_size'] = 20
kwargs['max_overflow'] = 50
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():
async with engine.begin() as conn:
await conn.run_sync(models.Base.metadata.create_all)
+165
View File
@@ -0,0 +1,165 @@
import datetime
from sqlalchemy import select
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
from meshview import mqtt_database
from meshview import decode_payload
from meshview.models import Packet, PacketSeen, Node, Traceroute
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 mqtt_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
async with mqtt_database.async_session() as session:
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
new_packet = False
packet = result.scalar_one_or_none()
if not packet:
new_packet = True
packet = Packet(
id=env.packet.id,
portnum=env.packet.decoded.portnum,
from_node_id=getattr(env.packet, "from"),
to_node_id=env.packet.to,
payload=env.packet.SerializeToString(),
# p.r. Here seems to be where the packet is imported on the Database and import time is set.
import_time=datetime.datetime.now(),
channel=env.channel_id,
)
session.add(packet)
result = await session.execute(
select(PacketSeen).where(
PacketSeen.packet_id == env.packet.id,
PacketSeen.node_id == int(env.gateway_id[1:], 16),
PacketSeen.rx_time == env.packet.rx_time,
)
)
seen = None
if not result.scalar_one_or_none():
seen = PacketSeen(
packet_id=env.packet.id,
node_id=int(env.gateway_id[1:], 16),
channel=env.channel_id,
rx_time=env.packet.rx_time,
rx_snr=env.packet.rx_snr,
rx_rssi=env.packet.rx_rssi,
hop_limit=env.packet.hop_limit,
hop_start=env.packet.hop_start,
topic=topic,
# p.r. Here seems to be where the packet is imported on the Database and import time is set.
import_time=datetime.datetime.now(),
)
session.add(seen)
if env.packet.decoded.portnum == PortNum.NODEINFO_APP:
user = decode_payload.decode_payload(
PortNum.NODEINFO_APP, env.packet.decoded.payload
)
if user:
result = await session.execute(select(Node).where(Node.id == user.id))
if user.id and user.id[0] == "!":
try:
node_id = int(user.id[1:], 16)
except ValueError:
node_id = None
pass
else:
node_id = None
try:
hw_model = HardwareModel.Name(user.hw_model)
except ValueError:
hw_model = "unknown"
try:
role = Config.DeviceConfig.Role.Name(user.role)
except ValueError:
role = "unknown"
if node := result.scalar_one_or_none():
node.node_id = node_id
node.long_name = user.long_name
node.short_name = user.short_name
node.hw_model = hw_model
node.role = role
node.last_update =datetime.datetime.now()
else:
node = Node(
id=user.id,
node_id=node_id,
long_name=user.long_name,
short_name=user.short_name,
hw_model=hw_model,
role=role,
channel=env.channel_id,
# if need to update time of last update it may be here
)
session.add(node)
if env.packet.decoded.portnum == PortNum.POSITION_APP:
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 node:
node.last_lat = position.latitude_i
node.last_long = position.longitude_i
session.add(node)
if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP:
packet_id = None
if env.packet.decoded.want_response:
packet_id = env.packet.id
else:
result = await session.execute(select(Packet).where(Packet.id == env.packet.decoded.request_id))
if result.scalar_one_or_none():
packet_id = env.packet.decoded.request_id
if packet_id is not None:
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),
import_time=datetime.datetime.now(),
))
await session.commit()
if new_packet:
await packet.awaitable_attrs.to_node
await packet.awaitable_attrs.from_node
+3 -301
View File
@@ -1,176 +1,8 @@
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
from meshview import database
from meshview import decode_payload
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
async with database.async_session() as session:
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
new_packet = False
packet = result.scalar_one_or_none()
if not packet:
new_packet = True
packet = Packet(
id=env.packet.id,
portnum=env.packet.decoded.portnum,
from_node_id=getattr(env.packet, "from"),
to_node_id=env.packet.to,
payload=env.packet.SerializeToString(),
# p.r. Here seems to be where the packet is imported on the Database and import time is set.
import_time=datetime.datetime.now(),
channel=env.channel_id,
)
session.add(packet)
result = await session.execute(
select(PacketSeen).where(
PacketSeen.packet_id == env.packet.id,
PacketSeen.node_id == int(env.gateway_id[1:], 16),
PacketSeen.rx_time == env.packet.rx_time,
)
)
seen = None
if not result.scalar_one_or_none():
seen = PacketSeen(
packet_id=env.packet.id,
node_id=int(env.gateway_id[1:], 16),
channel=env.channel_id,
rx_time=env.packet.rx_time,
rx_snr=env.packet.rx_snr,
rx_rssi=env.packet.rx_rssi,
hop_limit=env.packet.hop_limit,
hop_start=env.packet.hop_start,
topic=topic,
# p.r. Here seems to be where the packet is imported on the Database and import time is set.
import_time=datetime.datetime.now(),
)
session.add(seen)
if env.packet.decoded.portnum == PortNum.NODEINFO_APP:
user = decode_payload.decode_payload(
PortNum.NODEINFO_APP, env.packet.decoded.payload
)
if user:
result = await session.execute(select(Node).where(Node.id == user.id))
if user.id and user.id[0] == "!":
try:
node_id = int(user.id[1:], 16)
except ValueError:
node_id = None
pass
else:
node_id = None
try:
hw_model = HardwareModel.Name(user.hw_model)
except ValueError:
hw_model = "unknown"
try:
role = Config.DeviceConfig.Role.Name(user.role)
except ValueError:
role = "unknown"
if node := result.scalar_one_or_none():
node.node_id = node_id
node.long_name = user.long_name
node.short_name = user.short_name
node.hw_model = hw_model
node.role = role
node.last_update =datetime.datetime.now()
else:
node = Node(
id=user.id,
node_id=node_id,
long_name=user.long_name,
short_name=user.short_name,
hw_model=hw_model,
role=role,
channel=env.channel_id,
# if need to update time of last update it may be here
)
session.add(node)
if env.packet.decoded.portnum == PortNum.POSITION_APP:
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 node:
node.last_lat = position.latitude_i
node.last_long = position.longitude_i
session.add(node)
if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP:
packet_id = None
if env.packet.decoded.want_response:
packet_id = env.packet.id
else:
result = await session.execute(select(Packet).where(Packet.id == env.packet.decoded.request_id))
if result.scalar_one_or_none():
packet_id = env.packet.decoded.request_id
if packet_id is not None:
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),
import_time=datetime.datetime.now(),
))
await session.commit()
if new_packet:
await packet.awaitable_attrs.to_node
await packet.awaitable_attrs.from_node
notify.notify_packet(packet.to_node_id, packet)
notify.notify_packet(packet.from_node_id, packet)
notify.notify_packet(None, packet)
if seen:
notify.notify_uplinked(seen.node_id, packet)
async def get_node(node_id):
async with database.async_session() as session:
@@ -189,7 +21,7 @@ async def get_fuzzy_nodes(query):
return result.scalars()
async def get_packets(node_id=None, portnum=None, since=None, limit=500, before=None, after=None):
async def get_packets(node_id=None, portnum=None, since=None, limit=1000, before=None, after=None):
async with database.async_session() as session:
q = select(Packet)
@@ -209,7 +41,8 @@ async def get_packets(node_id=None, portnum=None, since=None, limit=500, before=
q = q.limit(limit)
result = await session.execute(q.order_by(Packet.import_time.desc()))
return result.scalars()
packets = list(result.scalars()) # Convert to list
return packets # Return the list
async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
@@ -300,137 +133,6 @@ async def get_mqtt_neighbors(since):
)
return result
# In order to provide separate network graphs for LongFast and MediumSlow, I am duplicating the procedures.
# 3 procedures are needed. These would have to be replicated for any other network that we may need to use graphs.
#
# get_traceroutes_longfast
# get_packets_longfast
# get_mqtt_neighbors_longfast
#
# p.r.
#
# Get Traceroute for LongFast only
async def get_traceroutes_longfast(since):
async with database.async_session() as session:
result = await session.execute(
select(Traceroute)
.join(Packet)
.where(
(Traceroute.import_time > (datetime.datetime.now() - since))
& (Packet.channel == "LongFast")
)
.order_by(Traceroute.import_time)
)
return result.scalars()
# Get MQTT Neighbors for LongFast only
# p.r.
async def get_mqtt_neighbors_longfast(since):
async with database.async_session() as session:
result = await session.execute(select(PacketSeen, Packet)
.join(Packet)
.where(
(PacketSeen.hop_limit == PacketSeen.hop_start)
& (PacketSeen.hop_start != 0)
& (Packet.channel == "LongFast")
)
.options(
lazyload(Packet.from_node),
lazyload(Packet.to_node),
)
)
return result
# Get Packets for LongFast only
# p.r.
async def get_packets_longfast(node_id=None, portnum=None, since=None, limit=500, before=None, after=None):
async with database.async_session() as session:
q = select(Packet)
# Add condition for channel being "LongFast"
q = q.where(Packet.channel == "LongFast")
if node_id:
q = q.where(
(Packet.from_node_id == node_id) | (Packet.to_node_id == node_id)
)
if portnum:
q = q.where(Packet.portnum == portnum)
if since:
q = q.where(Packet.import_time > (datetime.datetime.now() - since))
if before:
q = q.where(Packet.import_time < before)
if after:
q = q.where(Packet.import_time > after)
if limit is not None:
q = q.limit(limit)
result = await session.execute(q.order_by(Packet.import_time.desc()))
return result.scalars()
# Get Traceroute for mediumslow only
# p.r.
async def get_traceroutes_mediumslow(since):
async with database.async_session() as session:
result = await session.execute(
select(Traceroute)
.join(Packet)
.where(
(Traceroute.import_time > (datetime.datetime.now() - since))
& (Packet.channel == "MediumSlow")
)
.order_by(Traceroute.import_time)
)
return result.scalars()
# Get MQTT Neighbors for mediumslow only
# p.r.
async def get_mqtt_neighbors_mediumslow(since):
async with database.async_session() as session:
result = await session.execute(select(PacketSeen, Packet)
.join(Packet)
.where(
(PacketSeen.hop_limit == PacketSeen.hop_start)
& (PacketSeen.hop_start != 0)
& (Packet.channel == "MediumSlow")
)
.options(
lazyload(Packet.from_node),
lazyload(Packet.to_node),
)
)
return result
# Get Packets for MediumSlow only
# p.r.
async def get_packets_mediumslow(node_id=None, portnum=None, since=None, limit=500, before=None, after=None):
async with database.async_session() as session:
q = select(Packet)
# Add condition for channel being "MediumSlow"
q = q.where(Packet.channel == "MediumSlow")
if node_id:
q = q.where(
(Packet.from_node_id == node_id) | (Packet.to_node_id == node_id)
)
if portnum:
q = q.where(Packet.portnum == portnum)
if since:
q = q.where(Packet.import_time > (datetime.datetime.now() - since))
if before:
q = q.where(Packet.import_time < before)
if after:
q = q.where(Packet.import_time > after)
if limit is not None:
q = q.limit(limit)
result = await session.execute(q.order_by(Packet.import_time.desc()))
return result.scalars()
# We count the total amount of packages
# This is to be used by /stats in web.py
+3 -1
View File
@@ -10,6 +10,8 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
{% block head %}
{% endblock %}
@@ -35,7 +37,7 @@
<body hx-indicator="#spinner">
<br><div style="text-align:center"><strong>Bay Area Mesh - http://bayme.sh</strong></div>
<div style="text-align:center">Quick Links:&nbsp;&nbsp;<a href="/nodelist">Nodes</a>&nbsp;-&nbsp;<a href="/chat">Conversations</a>&nbsp;-&nbsp;<a href="/firehose">See <strong>everything</strong> </a>
&nbsp;-&nbsp;Mesh Graph <a href="/graph/longfast">LF</a>&nbsp;-&nbsp;<a href="/graph/mediumslow">MS </a>&nbsp;-&nbsp;<a href="/stats">Stats </a>
&nbsp;-&nbsp;Mesh Graph <a href="/nodegraph/LongFast">LF</a>&nbsp;-&nbsp;<a href="/nodegraph/MediumSlow">MS </a>&nbsp;-&nbsp;<a href="/stats">Stats </a>
&nbsp;-&nbsp;<a href="/net">Weekly Net</a>&nbsp;-&nbsp;<a href="/map">Map</a></div><br>
<div id="spinner" class="spinner-border secondary-primary htmx-indicator position-absolute top-50 start-50" role="status">
<span class="visually-hidden">Loading...</span>
+7 -1
View File
@@ -20,7 +20,13 @@
{% block body %}
<div class="container" hx-ext="sse" sse-connect="/chat_events" sse-swap="chat_packet" hx-swap="afterbegin">
<script>
setTimeout(function() {
location.reload();
}, 30000); // 10 seconds
</script>
<div class="container" >
{% for packet in packets %}
{% include 'chat_packet.html' %}
{% else %}
+73 -14
View File
@@ -1,7 +1,71 @@
{% extends "base.html" %}
{% block body %}
<div class="container" hx-ext="sse" sse-connect="/events{% if portnum %}?portnum={{portnum}}{% endif%}">
<script>
let refreshInterval;
function updateURLWithPort() {
let selectedPort = document.querySelector('select[name="portnum"]').value;
let url = new URL(window.location.href);
url.searchParams.set('portnum', selectedPort);
// Save scroll position before refreshing
localStorage.setItem("scrollPosition", window.scrollY);
window.location.href = url.toString();
}
function startAutoRefresh() {
refreshInterval = setInterval(updateURLWithPort, 5000);
localStorage.setItem("autoRefresh", "true");
updateButtonState(true);
}
function stopAutoRefresh() {
clearInterval(refreshInterval);
localStorage.setItem("autoRefresh", "false");
updateButtonState(false);
}
function toggleAutoRefresh() {
let isEnabled = localStorage.getItem("autoRefresh") === "true";
if (isEnabled) {
stopAutoRefresh();
} else {
startAutoRefresh();
}
}
function updateButtonState(isEnabled) {
let button = document.getElementById("auto-refresh-button");
button.innerText = isEnabled ? "Disable Auto-Refresh" : "Enable Auto-Refresh";
}
function restoreScrollPosition() {
let scrollPosition = localStorage.getItem("scrollPosition");
if (scrollPosition !== null) {
window.scrollTo(0, parseInt(scrollPosition, 10));
}
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelector('select[name="portnum"]').addEventListener('change', updateURLWithPort);
document.getElementById("auto-refresh-button").addEventListener('click', toggleAutoRefresh);
// Restore auto-refresh state
let isEnabled = localStorage.getItem("autoRefresh") === "true";
updateButtonState(isEnabled);
if (isEnabled) {
startAutoRefresh();
}
// Restore scroll position
restoreScrollPosition();
});
</script>
<div class="container">
<form class="row">
{% set options = {
1: "Text Message",
@@ -9,31 +73,26 @@
4: "Node Info",
67: "Telemetry",
71: "Neighbor Info",
70: "Trace Route",
}
%}
<select name="portnum" class="col-2 m-2">
<option
value = ""
{% if portnum not in options %}selected{% endif %}
>All</option>
<option value="" {% if portnum not in options %}selected{% endif %}>All</option>
{% for value, name in options.items() %}
<option
value="{{value}}"
{% if value == portnum %}selected{% endif %}
>{{ name }}</option>
<option value="{{ value }}" {% if value == portnum %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<input type="Submit" value="Refresh" class="col-2 m-2"/>
<button type="button" id="auto-refresh-button" class="col-2 m-2 btn btn-primary">Enable Auto-Refresh</button>
</form>
<div class="row">
<div class="col-xs" id="packet_list" sse-swap="packet" hx-swap="afterbegin">
<div class="col-xs" id="packet_list">
{% for packet in packets %}
{% include 'packet.html' %}
{% include 'packet.html' %}
{% else %}
No packets found.
No packets found.
{% endfor %}
</div>
<!-- <div class="col-6 sticky-top" id="packet_details" style="height: 95vh; overflow: scroll"> -->
</div>
</div>
{% endblock %}
+4 -4
View File
@@ -93,11 +93,11 @@
let isRouter = node.role.toLowerCase().includes("router");
let markerOptions = {
radius: isRouter ? 8 : 7,
color: isRouter ? "black" : color,
radius: isRouter ? 9 : 7,
color: "white",
fillColor: color,
fillOpacity: 0.6,
weight: isRouter ? 1 : 0
fillOpacity: 1,
weight: .7,
};
var popupContent = `
+129 -20
View File
@@ -1,21 +1,28 @@
{% extends "base.html" %}
{% block css %}
/* Styles for the node info card */
#node_info {
height:100%;
height: 100%;
}
#map{
height:100%;
/* Styles for the map */
#map {
height: 100%;
min-height: 400px;
}
#packet_details{
/* Styles for packet details section */
#packet_details {
height: 95vh;
overflow: scroll;
top: 3em;
}
/* Ensure inline display for details */
div.tab-pane > dl {
display: inline-block;
}
}
{% endblock %}
{% block body %}
@@ -27,17 +34,18 @@
{% if node %}
hx-ext="sse"
sse-connect="/events?node_id={{node_id}}{% if portnum %}&portnum={{portnum}}{% endif %}"
{% endif %}
>
{% endif %}
>
<div class="row">
<div class="col mb-3">
<!-- Node Information Card -->
<div class="card" id="node_info">
{% if node %}
<div class="card-header">
{{node.long_name}} ({{node.node_id|node_id_to_hex}})
</div>
<div class="card-body">
<dl >
<dl>
<dt>ShortName</dt>
<dd>{{node.short_name}}</dd>
<dt>HW Model</dt>
@@ -54,45 +62,146 @@
{% endif %}
</div>
</div>
<div class="col mb-3">
<!-- Map Container -->
<div id="map"></div>
</div>
</div>
<div class="row">
<div class="col">
<!-- {% include "buttons.html" %}-->
<!-- Additional buttons can be included here -->
</div>
</div>
<div class="row">
<div class="col">
{% include 'packet_list.html' %}
{% include 'packet_list.html' %}
</div>
<!-- <div class="col sticky-top" id="packet_details"></div> -->
</div>
</div>
</div>
{% if trace %}
<script>
var trace = {{trace | tojson}};
var map = L.map('map').setView(trace[0], 13);
var markers = L.featureGroup();
var trace = {{ trace | tojson }}; // Load trace data into JavaScript
var map = L.map('map').setView(trace[0], 13); // Initialize map centered at first trace point
var markers = L.featureGroup(); // Create a feature group for markers
markers.addTo(map);
// Add tile layer (OpenStreetMap)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
L.polyline(trace).addTo(map);
L.marker(trace[0]).addTo(markers);
// Draw a polyline along the trace path
L.polyline(trace, { color: 'blue', weight: 1}).addTo(map);
// Add a red circle marker for the starting node with a tooltip
var startMarker = L.circleMarker(trace[0], {
radius: 8,
color: 'red',
weight: 1,
fillColor: 'red',
fillOpacity: 0.5
}).addTo(markers) // Add to feature group
.bindTooltip(`
<b>{{node.long_name}}</b><br/>
<b>Short:</b> {{node.short_name}}<br/>
<b>Channel:</b> {{node.channel}}<br/>
<b>Hardware:</b> {{node.hw_model}}<br/>
<b>Role:</b> {{node.role}}<br/>
<b>Firmware:</b> {{node.firmware}}<br/>
<b>Coordinates:</b> [{{node.last_lat}} , {{node.last_long}}]
`, {permanent: false, direction: 'top', opacity: 0.9});
// Function to calculate distance and convert to miles
function getDistanceInMiles(latlng1, latlng2) {
var meters = latlng1.distanceTo(latlng2); // Get distance in meters
return meters * 0.000621371; // Convert meters to miles
}
{% for n in neighbors %}
var m = L.circleMarker({{n.location | tojson}});
m.bindPopup('SNR: {{n.snr}}<br/><a href="/packet_list/{{n.node_id}}">[{{n.short_name}}] {{n.long_name}} {{n.node_id | node_id_to_hex}}</a>');
m.addTo(markers);
L.polyline([trace[0], {{n.location | tojson}}], {color: 'red'}).addTo(map);
map.fitBounds(markers.getBounds().pad(.7));
var neighborLatLng = L.latLng([{{n.location[0]}}, {{n.location[1]}}]);
var startLatLng = L.latLng(trace[0]);
// Calculate distance in miles with 1 decimal place
var distanceMiles = getDistanceInMiles(startLatLng, neighborLatLng).toFixed(1);
// Create a blue circle marker for each neighbor node
var m = L.circleMarker(neighborLatLng, {
radius: 6,
color: 'blue',
weight: 1,
fillColor: 'blue',
fillOpacity: 0.5
}).addTo(markers) // Add to feature group
.bindTooltip(`
<b>Neighbour: [{{n.short_name}}] {{n.long_name}}</b> <br/>
<b>SNR:</b> {{n.snr}} <br/>
<b>Distance:</b> ${distanceMiles} miles <br/>
`, {permanent: false, direction: 'top', opacity: 0.9});
// Draw a polyline from the first trace point to each neighbor node
L.polyline([startLatLng, neighborLatLng], {
color: 'grey',
weight: 1
}).addTo(map);
{% endfor %}
// Add a legend to the map
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'info legend');
div.style.background = 'white';
div.style.padding = '8px';
div.style.border = '1px solid black';
div.style.borderRadius = '5px';
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
div.style.color = 'black'; // Ensure text is black
div.style.textAlign = 'left'; /* Ensure left alignment */
div.innerHTML = `
<b>Legend</b><br>
<svg width="16" height="16">
<circle cx="8" cy="8" r="6" fill="blue" stroke="blue" stroke-width="1" fill-opacity="0.4"/>
</svg> Neighbor Node<br>
<svg width="20" height="20">
<circle cx="10" cy="10" r="8" fill="red" stroke="red" stroke-width="1" fill-opacity="0.4"/>
</svg> Home Node<br>
<svg width="20" height="4">
<line x1="0" y1="2" x2="20" y2="2" stroke="grey" stroke-width="2"/>
</svg> Connection to Neighbors<br>
<svg width="20" height="4">
<line x1="0" y1="2" x2="20" y2="2" stroke="blue" stroke-width="2"/>
</svg> Path taken by node
`;
return div;
};
legend.addTo(map);
// Ensure the map adjusts to fit all markers and trace points
setTimeout(() => {
if (markers.getLayers().length > 0 || trace.length > 0) {
var bounds = markers.getBounds(); // Get bounds from markers
// Ensure trace points are included in the bounds
trace.forEach(point => {
bounds.extend(point);
});
map.fitBounds(bounds.pad(0.1), { maxZoom: 15 });
}
}, 200); // Slightly longer delay to ensure all elements are fully loaded
</script>
{% endif %}
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block css %}
#node_info {
height:100%;
}
#map{
height:100%;
min-height: 400px;
}
#packet_details{
height: 95vh;
overflow: scroll;
top: 3em;
}
div.tab-pane > dl {
display: inline-block;
}
{% endblock %}
{% block body %}
{% include "search_form.html" %}
<div class="row">
<div class="col mb-3">
<div class="card" id="node_info">
{% if node %}
<div class="card-header">
{{node.long_name}}
</div>
<div class="card-body">
<dl >
<dt>ShortName</dt>
<dd>{{node.short_name}}</dd>
<dt>HW Model</dt>
<dd>{{node.hw_model}}</dd>
<dt>Role</dt>
<dd>{{node.role}}</dd>
</dl>
</div>
{% else %}
<div class="card-body">
A NodeInfo has not been seen.
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
{% include 'packet_list.html' %}
</div>
</div>
<div class="col mb-3">
<div id="map"></div>
</div>
</div>
{% endblock %}
+190
View File
@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block css %}
#mynetwork {
width: 100%;
height: 100vh;
max-width: 2000px;
max-height: 2000px;
border: 1px solid lightgray;
background-color: white;
}
.legend {
position: absolute;
bottom: 10px;
left: 10px;
background-color: rgba(255, 255, 255, 0.8);
padding: 5px;
border-radius: 5px;
border: 1px solid #ccc;
font-size: 12px;
color: #333;
}
#node-info {
position: absolute;
bottom: 10px;
right: 10px;
background-color: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
font-size: 14px;
color: #333;
width: 250px;
max-height: 200px;
overflow-y: auto;
}
{% endblock %}
{% block body %}
<div id="mynetwork"></div>
<!-- Legend -->
<div class="legend">
<div><span style="background-color: #ff5733; width: 20px; height: 20px; display: inline-block; border-radius: 50%; margin-right: 5px;"></span> Traceroute</div>
<div><span style="background-color: #3388ff; width: 20px; height: 20px; display: inline-block; border-radius: 50%; margin-right: 5px;"></span> NeighborInfo</div>
</div>
<!-- Node Information Panel -->
<div id="node-info">
<b>Long Name: </b> <span id="node-long-name"></span></br>
<b>Short Name: </b><span id="node-short-name"></span></br>
<b>Role: </b><span id="node-role"></span></br>
<b>Hardware Model: </b><span id="node-hw-model"></span>
</div>
<<script type="text/javascript">
// Initialize chart
var chart = echarts.init(document.getElementById('mynetwork'));
var nodes = [
{% for node in nodes %}
{
name: '{{ node.node_id }}',
value: '{{ node.long_name | escape }}',
symbol: 'rect',
symbolSize: [null, 40],
label: {
show: true,
position: 'inside',
color: '#000',
padding: [5, 10],
formatter: function(params) { return params.data.value; },
backgroundColor: '#f0f0f0',
borderColor: '#999',
borderWidth: 1,
borderRadius: 5
},
long_name: '{{ node.long_name | escape }}', // Add long name
short_name: '{{ node.short_name | escape }}', // Add short name
role: '{{ node.role | escape }}', // Add role
hw_model: '{{ node.hw_model | escape }}' // Add hardware model
}{% if not loop.last %},{% endif %}
{% endfor %}
];
// Sample edge data (this will be passed from Python backend)
var edges = [
{% for edge in edges %}
{
source: '{{ edge.from }}',
target: '{{ edge.to }}',
originalColor: '{{ edge.originalColor }}', // Store original color
lineStyle: {
color: '#d3d3d3', // Set all edges to light gray by default
width: 2, // Default width for all edges
opacity: 0.5 // Dim edges by default
}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
var option = {
backgroundColor: 'white',
tooltip: {
formatter: function(params) {
// Only show long_name on hover
return params.data.long_name + ' - ' + params.data.short_name;
}
},
animationDurationUpdate: 1500,
animationEasingUpdate: 'quinticInOut',
legend: {
data: ['Traceroute', 'NeighborInfo'],
selectedMode: false, // Disable item selection
left: 'center',
bottom: '5%',
orient: 'vertical', // Stack legend vertically
textStyle: {
fontSize: 12,
color: '#333'
},
itemWidth: 10,
itemHeight: 10,
padding: [5, 15]
},
series: [
{
type: 'graph',
layout: 'force',
data: nodes,
links: edges,
roam: true,
force: {
repulsion: 500,
edgeLength: [100, 200],
gravity: 0.05
},
lineStyle: {
width: 2,
curveness: 0
}
}
]
};
chart.setOption(option);
// Event listener for node clicks
chart.on('click', function(params) {
if (params.dataType === 'node') {
var selectedNode = params.data.name;
// Update edges for the selected node: highlight connected edges
var updatedEdges = edges.map(edge => {
if (edge.source === selectedNode || edge.target === selectedNode) {
return {
...edge,
lineStyle: {
color: edge.originalColor, // Use original color for selected edges (blue or red)
width: 2, // Thinner width for highlighted edges
opacity: 1 // Full opacity for selected edges
}
};
} else {
return edge; // Keep the non-selected edges in light gray
}
});
// Update the chart with highlighted edges
chart.setOption({
series: [{ links: updatedEdges }]
});
// Update the node information panel
document.getElementById('node-long-name').innerText = params.data.long_name;
document.getElementById('node-short-name').innerText = params.data.short_name;
document.getElementById('node-role').innerText = params.data.role;
document.getElementById('node-hw-model').innerText = params.data.hw_model;
}
});
</script>
{% endblock %}
+1 -2
View File
@@ -29,8 +29,7 @@
<div class="card-body">
<div class="card-title">
{{packet.id}}
<a href="/packet_details/{{packet.id}}" hx-target="#packet_details" hx-get="/packet_details/{{packet.id}}" hx-swap="innerHTML scroll:top">🔎</a>
<a href="/packet/{{packet.id}}">🔗</a>
<a href="/packet/{{packet.id}}">🔎</a>
</div>
<div class="card-text text-start">
<dl>
+88 -10
View File
@@ -36,25 +36,103 @@
{% if map_center %}
<script>
var details_map = L.map('details_map').setView({{map_center | tojson}}, 12);
var details_map = L.map('details_map').setView({{ map_center | tojson }}, 12);
var markers = L.featureGroup();
markers.addTo(details_map);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(details_map);
// Function to calculate distance in miles
function getDistanceInMiles(latlng1, latlng2) {
var meters = latlng1.distanceTo(latlng2); // Get distance in meters
return meters * 0.000621371; // Convert meters to miles
}
{% if from_node_cord %}
L.marker({{from_node_cord | tojson}}).addTo(markers);
var fromNodeLatLng = L.latLng({{ from_node_cord | tojson }});
var fromNode = L.circleMarker(fromNodeLatLng, {
radius: 8,
color: 'red',
weight: 1,
fillColor: 'red',
fillOpacity: 0.4
}).addTo(markers);
// Add tooltip for the from_node_cord
fromNode.bindTooltip(`
Sent by: <b>{{node.long_name}}</b><br/>
<b>Short:</b> {{node.short_name}}<br/>
<b>Channel:</b> {{node.channel}}<br/>
<b>Hardware:</b> {{node.hw_model}}<br/>
<b>Role:</b> {{node.role}}<br/>
<b>Firmware:</b> {{node.firmware}}<br/>
<b>Coordinates:</b> [{{node.last_lat}}, {{node.last_long}}]
`, { permanent: false, direction: 'top', opacity: 0.9 });
{% endif %}
var radioTower = L.icon({
iconUrl: 'data:image/png;base64,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',
iconSize: [32, 32],
});
{% for u in uplinked_nodes %}
L.marker([{{u.lat}}, {{u.long}}], {icon: radioTower})
.bindPopup('[{{u.short_name}}] {{u.long_name}}<br/>Hops: {{u.hops}}<br/>SNR: {{u.snr}}<br/>RSSI: {{u.rssi}}')
.addTo(markers);
details_map.fitBounds(markers.getBounds().pad(.03), {animate: false});
var uplinkNodeLatLng = L.latLng([{{ u.lat }}, {{ u.long }}]);
// Calculate distance in miles
var distanceMiles = getDistanceInMiles(fromNodeLatLng, uplinkNodeLatLng).toFixed(1);
var node = L.circleMarker(uplinkNodeLatLng, {
radius: 6,
color: 'blue',
weight: 1,
fillColor: 'blue',
fillOpacity: 0.4
}).addTo(markers);
// Add a tooltip with node details and distance
node.bindTooltip(`
Seen by: <b>[{{ u.short_name }}] {{ u.long_name }}</b><br/>
<b>Hops:</b> {{ u.hops }}<br/>
<b>SNR:</b> {{ u.snr }}<br/>
<b>RSSI:</b> {{ u.rssi }}<br/>
<b>Distance:</b> ${distanceMiles} miles <br/>
<b>Coordinates:</b> [{{u.lat}}, {{u.long}}]
`, { permanent: false, direction: 'top', opacity: 0.9 });
{% endfor %}
// Ensure markers are added before adjusting map bounds
setTimeout(() => {
if (markers.getLayers().length > 0) {
details_map.fitBounds(markers.getBounds().pad(0.1), { animate: true });
}
}, 500); // Delay to ensure markers load
// Add a legend to details_map
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'info legend');
div.style.background = 'white';
div.style.padding = '8px';
div.style.border = '1px solid black';
div.style.borderRadius = '5px';
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
div.style.color = 'black'; // Ensure text is black
div.style.textAlign = 'left'; /* Ensure left alignment */
div.innerHTML = `
<b>Legend</b><br>
<svg width="16" height="16">
<circle cx="8" cy="8" r="6" fill="blue" stroke="blue" stroke-width="1" fill-opacity="0.4"/>
</svg> Receiving Node<br>
<svg width="20" height="20">
<circle cx="10" cy="10" r="8" fill="red" stroke="red" stroke-width="1" fill-opacity="0.4"/>
</svg> Sending Node<br>
`;
return div;
};
legend.addTo(details_map);
</script>
{% endif %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block body %}
<div id="mynetwork" style="width: 100%; height: 600px;"></div>
<script type="text/javascript">
var chart = echarts.init(document.getElementById('mynetwork'));
// Define the nodes and edges passed from the backend
var nodes = {{ chart_data['nodes'] | tojson }};
var edges = {{ chart_data['edges'] | tojson }};
var option = {
backgroundColor: '#ffffff', // Set background color to white
tooltip: {},
series: [
{
type: 'graph',
layout: 'force',
data: nodes,
links: edges,
roam: true,
force: {
repulsion: 500,
edgeLength: [100, 200],
gravity: 0.1
},
label: {
show: true,
position: 'inside',
color: '#000',
padding: [5, 10],
formatter: function(params) { return params.data.value; },
backgroundColor: '#f0f0f0',
borderColor: '#999',
borderWidth: 1,
borderRadius: 5,
z: 5 // Label z-index is now 5, to be below the edges
},
itemStyle: {
normal: {
borderColor: '#1E1E1E',
borderWidth: 2,
}
},
lineStyle: {
width: 2,
color: '#ccc', // Edge color
curveness: 0.1, // Slight curve for edges
type: 'solid',
z: 10 // Edge lines have a higher z-index than the labels
},
edgeSymbol: ['arrow', 'arrow'], // Both ends of the edge will have arrowheads
edgeSymbolSize: [8, 8], // Size of the arrows
z: 15 // Ensure edges (arrows) are on top of both the nodes and labels
}
]
};
chart.setOption(option);
</script>
{% endblock %}
+420 -706
View File
File diff suppressed because it is too large Load Diff
+45 -12
View File
@@ -1,13 +1,46 @@
protobuf
aiomqtt
sqlalchemy[asyncio]
cryptography
aiosqlite
aiohttp
aiodns
Jinja2
protobuf~=5.29.3
aiomqtt~=2.3.0
sqlalchemy[asyncio]~=2.0.38
cryptography~=44.0.1
aiosqlite~=0.21.0
aiohttp~=3.11.12
aiodns~=3.2.0
Jinja2~=3.1.5
aiohttp-sse
asyncpg
seaborn
pydot
plotly
asyncpg~=0.30.0
seaborn~=0.13.2
pydot~=3.0.4
plotly~=6.0.0
numpy~=2.2.3
pillow~=11.1.0
pip~=23.2.1
attrs~=25.1.0
cffi~=1.17.1
paho-mqtt~=2.1.0
pytz~=2025.1
idna~=3.10
multidict~=6.1.0
propcache~=0.2.1
typing_extensions~=4.12.2
pyparsing~=3.2.1
pycares~=4.5.0
MarkupSafe~=3.0.2
pandas~=2.2.3
matplotlib~=3.10.0
python-dateutil~=2.9.0.post0
packaging~=24.2
narwhals~=1.27.1
yarl~=1.18.3
aiosignal~=1.3.2
frozenlist~=1.5.0
aiohappyeyeballs~=2.4.6
cycler~=0.12.1
six~=1.17.0
greenlet~=3.1.1
psutil~=7.0.0
objgraph~=3.6.2
contourpy~=1.3.1
fonttools~=4.56.0
pycparser~=2.22
kiwisolver~=1.4.8
+50
View File
@@ -0,0 +1,50 @@
import asyncio
import argparse
import configparser
from meshview import mqtt_reader
from meshview import mqtt_database
from meshview import mqtt_store
import json
async def load_database_from_mqtt(mqtt_server: str , mqtt_port: int, topic: list, mqtt_user: str | None = None, mqtt_passwd: str | None = None):
async for topic, env in mqtt_reader.get_topic_envelopes(mqtt_server, mqtt_port, topic, mqtt_user, mqtt_passwd):
await mqtt_store.process_envelope(topic, env)
async def main(config):
mqtt_database.init_database(config["database"]["connection_string"])
await mqtt_database.create_tables()
mqtt_user = None
mqtt_passwd = None
if config["mqtt"]["username"] != "":
mqtt_user: str = config["mqtt"]["username"]
if config["mqtt"]["password"] != "":
mqtt_passwd: str = config["mqtt"]["password"]
mqtt_topics = json.loads(config["mqtt"]["topics"])
async with asyncio.TaskGroup() as tg:
tg.create_task(
load_database_from_mqtt(config["mqtt"]["server"], int(config["mqtt"]["port"]), mqtt_topics, mqtt_user, mqtt_passwd)
)
def load_config(file_path):
"""Load configuration from an INI-style text file."""
config_parser = configparser.ConfigParser()
config_parser.read(file_path)
# Convert to a dictionary for easier access
config = {section: dict(config_parser.items(section)) for section in config_parser.sections()}
return config
if __name__ == '__main__':
parser = argparse.ArgumentParser("meshview")
parser.add_argument("--config", help="Path to the configuration file.", default='config.ini')
args = parser.parse_args()
config = load_config(args.config)
asyncio.run(main(config))