66 Commits

Author SHA1 Message Date
Pablo Revilla 707b2ebbbd Merge branch 'master' into 3.0.6 2026-04-30 11:32:37 -07:00
pablorevilla-meshtastic d259230e05 fixed issue reported on issue #143 2026-04-17 15:26:53 -07:00
pablorevilla-meshtastic 7367155fec add note bottom of page 2026-04-03 13:47:29 -07:00
pablorevilla-meshtastic efca531855 remove duplicates from nodes in net.html 2026-04-03 13:44:41 -07:00
pablorevilla-meshtastic d16976e5d1 filter at chat.html 2026-04-03 11:55:50 -07:00
pablorevilla-meshtastic fca376486a Add filter to firehose 2026-04-03 11:49:42 -07:00
pablorevilla-meshtastic 96efe27abc add channel to firehouse 2026-04-03 11:40:56 -07:00
pablorevilla-meshtastic bd9d8ae265 remove file 2026-04-03 10:36:51 -07:00
pablorevilla-meshtastic 698f4ad77f Update protobuf 2026-04-03 10:32:05 -07:00
pablorevilla-meshtastic 1bb84f4c4d update latest list of instances 2026-04-01 12:40:42 -07:00
pablorevilla-meshtastic 17b91bbc7c more changes to report 2026-04-01 11:37:01 -07:00
pablorevilla-meshtastic 2692333fe2 changes to reports 2026-04-01 11:34:04 -07:00
pablorevilla-meshtastic ed18e2003c Fix MQTT store bug. 2026-04-01 10:46:57 -07:00
Dmitriy Q e4384f020c add russian translation (#141) 2026-04-01 10:16:24 -07:00
pablorevilla-meshtastic 65386be327 1 2026-03-05 11:39:36 -08:00
pablorevilla-meshtastic 973dcbfe51 fixed the db and added more reporting 2026-03-05 11:36:26 -08:00
pablorevilla-meshtastic 2490284435 fix ingestion error 2026-03-05 10:05:14 -08:00
pablorevilla-meshtastic 90767f94b4 update schemma 2026-03-05 10:03:17 -08:00
pablorevilla-meshtastic e0023bc8eb Added DB configuration and snapshot for basic detaisl 2026-03-05 09:57:34 -08:00
pablorevilla-meshtastic 432e9f2b67 Adapt PR 102 relay node display to packet page 2026-02-24 13:39:14 -08:00
Paul Picazo a0fbed0a88 Add migration script to introduce relay_node column in packet_seen table 2026-02-24 13:38:48 -08:00
Paul Picazo 8ca388acb8 Add relay_node field to PacketSeen model and update template for display 2026-02-24 13:38:48 -08:00
pablorevilla-meshtastic bdf70da803 fix location of node in node.html 2026-02-24 12:00:08 -08:00
pablorevilla-meshtastic f87831c3a5 small map change 2026-02-18 16:17:18 -08:00
pablorevilla-meshtastic 9c40ce2d59 add world meshview tag 2026-02-18 15:32:16 -08:00
pablorevilla-meshtastic 2cc53dc3b7 Added documentation 2026-02-13 22:50:31 -08:00
pablorevilla-meshtastic bf5f23a0ab fixed sorting error with nodelist 2026-02-13 22:35:32 -08:00
pablorevilla-meshtastic 37851cd7f8 packet.html fix 2026-02-13 20:34:16 -08:00
pablorevilla-meshtastic e0ca9900d3 fix map error 2026-02-13 20:18:04 -08:00
pablorevilla-meshtastic f108197e5f Documentation update 2026-02-13 16:28:52 -08:00
pablorevilla-meshtastic 20c2a3dc62 Update to stats page 2026-02-13 15:48:40 -08:00
pablorevilla-meshtastic 4a7fa1df08 fix error for sqlite 2026-02-13 14:20:12 -08:00
pablorevilla-meshtastic 685dbc9505 Added the ability to track gateways and present them in varous pages 2026-02-13 14:14:46 -08:00
pablorevilla-meshtastic 9aacceda28 fix location of edges now that the node is in a different spot 2026-02-12 13:29:51 -08:00
pablorevilla-meshtastic a7051e7d26 More fixes for maps overlay 2026-02-12 12:01:51 -08:00
pablorevilla-meshtastic 7926e81562 documents updte 2026-02-12 11:34:19 -08:00
pablorevilla-meshtastic 2002e093af Added visivility on maps of nodes that overlap 2026-02-12 11:33:24 -08:00
pablorevilla-meshtastic fc44f49f2d Work on updating protobufs and .gitignore 2026-02-11 17:30:53 -08:00
pablorevilla-meshtastic 89fbc6aeca changes 2026-02-11 07:00:21 -08:00
pablorevilla-meshtastic 20e3f9c104 Added Observed coverage to the node.html page 2026-02-10 15:33:59 -08:00
pablorevilla-meshtastic 17fa92d4cf modified the colors 2026-02-10 12:23:17 -08:00
pablorevilla-meshtastic a48a3a4141 Updating anb fixing errors 2026-02-10 11:07:22 -08:00
pablorevilla-meshtastic 7d5b638eac changes to coverage page 2026-02-10 10:27:07 -08:00
pablorevilla-meshtastic 5f5fe0da90 Merge branch 'pr-128' into 3.0.5 2026-02-10 10:04:40 -08:00
Amy Nagle dd98814b2c Normalize port number lists 2026-02-10 10:33:54 -05:00
pablorevilla-meshtastic 4dd999178c more details 2026-02-09 21:31:34 -08:00
pablorevilla-meshtastic 01dce2a5e0 Update to check for new requirements 2026-02-09 21:29:53 -08:00
pablorevilla-meshtastic 9622092c17 Add basic coverege support. 2026-02-09 21:17:22 -08:00
pablorevilla-meshtastic 29da1487d4 Merge branch 'pr-127' into 3.0.5 2026-02-09 07:48:33 -08:00
Mike Weaver 357fb530e2 Add database health check to include last import time
Retrieve the last import time from the database during health check.

This is useful to setup monitoring for meshview instances that are no longer receiving messages.
2026-02-08 03:07:46 -07:00
pablorevilla-meshtastic b43683a259 Added First Seen time to node.html and /api/nodes 2026-02-06 11:41:15 -08:00
pablorevilla-meshtastic 59379649e2 Add version number 2026-02-06 11:23:07 -08:00
pablorevilla-meshtastic a62bc350c0 fix: update store.py 2026-02-05 18:25:23 -08:00
pablorevilla-meshtastic 82ff4bb0df Merge PR 124: QR/import, impersonation detection, node public keys 2026-02-05 18:02:49 -08:00
pablorevilla-meshtastic c454f2ef3a started work on new traceroute template 2026-02-05 06:53:49 -08:00
pablorevilla-meshtastic b93f640233 load language dictionaries into memory to reduce call to file system 2026-01-31 10:42:47 -08:00
pablorevilla-meshtastic 018e16e9fa updates to language dictionaries 2026-01-26 10:40:20 -08:00
pablorevilla-meshtastic 41397072af Added unmaped packets detials to map.htnl 2026-01-26 09:15:01 -08:00
pablorevilla-meshtastic 43be448100 fix typo in container workflow file 2026-01-25 21:06:46 -08:00
pablorevilla-meshtastic 8c7f181002 Merge branch 'master' of github.com:pablorevilla-meshtastic/meshview 2026-01-25 20:47:03 -08:00
pablorevilla-meshtastic 5195868719 Made latest to follow master 2026-01-25 20:43:43 -08:00
Pablo Revilla a473e32c59 Add initial server and configuration settings 2026-01-25 13:39:14 -08:00
pablorevilla-meshtastic be51dc9c55 working on container 2026-01-25 13:34:26 -08:00
pablorevilla-meshtastic bea6c8cd8e Fix how we show the actual channel on the packet list. 2026-01-24 23:31:57 -08:00
pablorevilla-meshtastic 351c35ef42 Fix issues with decryption of secondary keys 2026-01-24 21:38:14 -08:00
pablorevilla-meshtastic 7f722b6f12 Updated Readme.md 2026-01-24 11:20:34 -08:00
42 changed files with 3063 additions and 341 deletions
+3 -3
View File
@@ -2,6 +2,7 @@ name: Build container
on:
push:
workflow_dispatch:
jobs:
docker:
@@ -17,15 +18,14 @@ jobs:
# list of Docker images to use as base name for tags
images: |
ghcr.io/${{ github.repository }}
# latest tag is only set for semver/tag-based builds (default behavior)
flavor: |
latest=auto
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# publish :latest from the default branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
-1
View File
@@ -2,7 +2,6 @@ env/*
__pycache__/*
meshview/__pycache__/*
alembic/__pycache__/*
meshtastic/protobuf/*
# Database files
packets.db
+1 -1
View File
@@ -35,7 +35,7 @@ RUN uv pip install --no-cache-dir --upgrade pip \
COPY --chown=${APP_USER}:${APP_USER} . .
# Patch config
RUN patch -p1 < container/config.patch
COPY --chown=${APP_USER}:${APP_USER} container/config.ini /app/sample.config.ini
# Clean
RUN rm -rf /app/.git* && \
+104 -72
View File
@@ -4,9 +4,22 @@
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
### Version 3.0.3January 2026
- Added database support for MySQL (not tested, would love to have someone test!) and PostgreSQL (alongside SQLite) for larger or shared deployments.
- Configuration updated to allow selecting the database backend via connection string.
### Version 3.0.5February 2026
- **IMPORTANT:** the predicted coverage feature requires the extra `pyitm` dependency. If it is not installed, the coverage API will return 503.
- Ubuntu install (inside the venv): `./env/bin/pip install pyitm`
- Coverage: predicted coverage overlay (LongleyRice area mode) with perimeter rendering and documentation.
- UI: added QR code display for quick node/app access.
- Gateways: persistent gateway tracking (`is_mqtt_gateway`) and UI indicators in nodes, map popups, and stats.
- Map UX: deterministic jitter for overlapping nodes; edges follow jittered positions.
- Tooling: Meshtastic protobuf updater script with `--check` and `UPSTREAM_REV.txt` tracking.
### Version 3.0.4 — Late January 2026
- Database: multiDB support, PostgreSQL scripts, WAL config for SQLite, cleanup query timing fixes, removal of import time columns, and various timehandling fixes.
- UI/UX: extensive updates to node.html, nodelist.html, top.html, and packet.html (paging, stats, distance, status/favorites), plus net view changes to 12hour window.
- API/logic: weekly mesh query fix, node list performance improvement, backwardscompatibility and other bug fixes.
- MQTT reader: configurable skipnode list and secondary decryption keys.
- Docs/ops: multiple documentation updates, updated site list, container workflow fixes/tests, README updates.
### Version 3.0.2 — January 2026
- Changes to the Database to will make it so that there is a need for space when updating to the latest. SQlite requires to rebuild the database when droping a column. ( we are droping some of the old columns) so make sure you have 1.2x the size of the db of space in your environment. Depending on how big your db is it would take a long time.
@@ -87,24 +100,38 @@ See [README-Docker.md](README-Docker.md) for container deployment and [docs/](do
Samples of currently running instances:
- https://meshview.bayme.sh (SF Bay Area)
- https://www.svme.sh (Sacramento Valley)
- https://meshview.nyme.sh (New York)
- https://meshview.socalmesh.org (LA Area)
- https://map.wpamesh.net (Western Pennsylvania)
- https://meshview.chicagolandmesh.org (Chicago)
- https://meshview.mt.gt (Canada)
- https://canadaverse.org (Canada)
- https://meshview.meshtastic.es (Spain)
- https://view.mtnme.sh (North Georgia / East Tennessee)
- https://meshview.lsinfra.de (Hessen - Germany)
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
- https://meshview.louisianamesh.org (Louisiana)
- https://www.swlamesh.com (Southwest Louisiana)
- https://meshview.meshcolombia.co (Colombia)
- https://meshview-salzburg.jmt.gr (Salzburg / Austria)
- https://map.cromesh.eu (Coatia)
- https://view.meshdresden.eu (Dresden / Germany)
| Area | URL | Version | Nodes | Last Updated |
| --- | --- | --- | ---: | --- |
| Polski Mesh | https://meshview.format.ovh | 3.0.6 (65386be) | 4,827 | 4/1/2026, 12:12:25 PM |
| Poland | https://meshview.szastan.pl | 3.0.5 (unknown) | 4,194 | 4/1/2026, 12:12:39 PM |
| Spain | https://meshview.meshtastic.es | 3.0.5 (unknown) | 2,466 | 4/1/2026, 12:11:17 PM |
| San Francisco Bay Area | https://meshview.bayme.sh | 3.0.5 (bdf70da) | 1,983 | 4/1/2026, 12:11:08 PM |
| Southern California | https://meshview.socalmesh.org | 3.0.5 (unknown) | 1,205 | 4/1/2026, 12:11:50 PM |
| Oregon (USA) | https://meshview.meshoregon.com | 3.0.5 (bdf70da) | 1,167 | 4/1/2026, 12:11:41 PM |
| Australia/ New Zeland | https://my.meshview.world/anz | plus 1.0.0 | 1,165 | 4/1/2026, 12:11:59 PM |
| Hessen - Germany | https://meshview.lsinfra.de | 3.0.5 (unknown) | 1,082 | 4/1/2026, 12:11:30 PM |
| New York City | https://meshview.nyme.sh | 3.0.5 (bdf70da) | 984 | 4/1/2026, 12:11:26 PM |
| Salt Lake City (USA) | https://meshview.freq51.net | — | 801 | 4/1/2026, 12:11:36 PM |
| Salzburg / Austria | https://meshview-salzburg.jmt.gr | — | 748 | 4/1/2026, 12:11:21 PM |
| North Georgia / East Tennessee - USA | https://view.mtnme.sh | 3.0.3 (ff30623) | 730 | 4/1/2026, 12:11:33 PM |
| Canada | https://mv.canadaverse.org | 3.0.1 (unknown) | 674 | 4/1/2026, 12:12:06 PM |
| Pioneer Valley, Massachusetts (USA) | https://meshview.pvmesh.org | 3.0.4 (018e16e) | 560 | 4/1/2026, 12:11:44 PM |
| Western Pennsylvania (USA) | https://map.wpamesh.net | 3.0.5 (unknown) | 447 | 4/1/2026, 12:11:38 PM |
| Great Britan | https://my.meshview.world/gb | plus 1.0.0 | 421 | 4/1/2026, 12:12:03 PM |
| Chicago (USA) | https://meshview.chicagolandmesh.org | 3.0.5 (unknown) | 420 | 4/1/2026, 12:12:41 PM |
| Georgia (USA) | https://meshview.gamesh.net | 3.0.1 (unknown) | 384 | 4/1/2026, 12:11:45 PM |
| Brazil | https://my.meshview.world/brazil | plus 1.0.0 | 364 | 4/1/2026, 12:12:01 PM |
| Mexico | https://my.meshview.world/mexico | plus 1.0.0 | 268 | 4/1/2026, 12:11:59 PM |
| Northeast Ohio | https://meshview.neomesh.org | 3.0.5 (unknown) | 264 | 4/1/2026, 12:11:52 PM |
| South Africa | https://my.meshview.world/za | plus 1.0.0 | 263 | 4/1/2026, 12:12:07 PM |
| Argentina | https://my.meshview.world/argentina | plus 1.0.0 | 258 | 4/1/2026, 12:12:00 PM |
| Northwest Indiana (USA) | https://meshview.nwimesh.net | 3.0.5 (2cc53dc) | 219 | 4/1/2026, 12:12:08 PM |
| Louisiana (USA) | https://meshview.louisianamesh.org | — | 78 | 4/1/2026, 12:11:51 PM |
| Chile | https://my.meshview.world/chile | plus 1.0.0 | 58 | 4/1/2026, 12:12:06 PM |
| Colombia | https://meshview.meshcolombia.co | 3.0.5 (2cc53dc) | 55 | 4/1/2026, 12:11:46 PM |
| Croatia | https://map.cromesh.eu | — | 0 | 4/1/2026, 12:11:45 PM |
| Saint Louis (USA) | https://meshview.meshstl.org | 3.0.5 (2cc53dc) | 0 | 4/1/2026, 12:11:51 PM |
| Southwest Louisiana (USA) | https://www.swlamesh.com | — | 0 | 4/1/2026, 12:11:46 PM |
---
@@ -549,60 +576,76 @@ echo "Database cleanup completed on $(date)"
- If you are using PostgreSQL, use this version instead (adjust credentials/DB name):
```bash
#!/bin/bash
set -euo pipefail
DB_NAME="meshview"
DB_USER="meshview"
DB_HOST="localhost"
DB_PORT="5432"
DB="postgresql://meshview@localhost:5432/meshview"
RETENTION_DAYS=14
BATCH_SIZE=100
# Stop DB service
sudo systemctl stop meshview-db.service
sudo systemctl stop meshview-web.service
PSQL="/usr/bin/psql"
sleep 5
echo "Run cleanup..."
# Run cleanup queries
psql "postgresql://${DB_USER}@${DB_HOST}:${DB_PORT}/${DB_NAME}" <<'EOF'
WITH deleted AS (
DELETE FROM packet
echo "[$(date)] Starting batched cleanup..."
while true; do
DELETED=$(
$PSQL "$DB" -At -v ON_ERROR_STOP=1 <<EOF
WITH cutoff AS (
SELECT (EXTRACT(EPOCH FROM (NOW() - INTERVAL '${RETENTION_DAYS} days')) * 1000000)::bigint AS ts
),
old_packets AS (
SELECT id
FROM packet, cutoff
WHERE import_time_us IS NOT NULL
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
RETURNING 1
)
SELECT 'packet deleted: ' || COUNT(*) FROM deleted;
WITH deleted AS (
AND import_time_us < cutoff.ts
ORDER BY id
LIMIT ${BATCH_SIZE}
),
ps_del AS (
DELETE FROM packet_seen
WHERE import_time_us IS NOT NULL
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
WHERE packet_id IN (SELECT id FROM old_packets)
RETURNING 1
)
SELECT 'packet_seen deleted: ' || COUNT(*) FROM deleted;
WITH deleted AS (
),
tr_del AS (
DELETE FROM traceroute
WHERE import_time_us IS NOT NULL
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
WHERE packet_id IN (SELECT id FROM old_packets)
RETURNING 1
),
p_del AS (
DELETE FROM packet
WHERE id IN (SELECT id FROM old_packets)
RETURNING 1
)
SELECT 'traceroute deleted: ' || COUNT(*) FROM deleted;
SELECT COUNT(*) FROM p_del;
EOF
)
WITH deleted AS (
DELETE FROM node
WHERE last_seen_us IS NULL
OR last_seen_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
RETURNING 1
)
SELECT 'node deleted: ' || COUNT(*) FROM deleted;
if [[ "$DELETED" -eq 0 ]]; then
break
fi
sleep 0.1
done
VACUUM;
echo "[$(date)] Packet cleanup complete"
echo "[$(date)] Cleaning old nodes..."
$PSQL "$DB" -v ON_ERROR_STOP=1 <<EOF
DELETE FROM node
WHERE last_seen_us IS NOT NULL
AND last_seen_us < (
EXTRACT(EPOCH FROM (NOW() - INTERVAL '${RETENTION_DAYS} days')) * 1000000
);
EOF
# Start DB service
sudo systemctl start meshview-db.service
sudo systemctl start meshview-web.service
echo "[$(date)] Node cleanup complete"
echo "Database cleanup completed on $(date)"
$PSQL "$DB" -c "VACUUM (ANALYZE) packet_seen;"
$PSQL "$DB" -c "VACUUM (ANALYZE) traceroute;"
$PSQL "$DB" -c "VACUUM (ANALYZE) packet;"
$PSQL "$DB" -c "VACUUM (ANALYZE) node;"
echo "[$(date)] Cleanup finished"
```
- Schedule running the script on a regular basis.
- In this example it runs every night at 2:00am.
@@ -620,17 +663,6 @@ Check the log file to see it the script run at the specific time.
---
## Testing
MeshView includes a test suite using pytest. For detailed testing documentation, see [README-testing.md](README-testing.md).
Quick start:
```bash
./env/bin/pytest tests/test_api_simple.py -v
```
---
## Technical Documentation
For more detailed technical documentation including database migrations, architecture details, and advanced topics, see the [docs/](docs/) directory.
@@ -0,0 +1,27 @@
"""Add is_mqtt_gateway to node
Revision ID: 23dad03d2e42
Revises: a0c9c13e118f
Create Date: 2026-02-13 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "23dad03d2e42"
down_revision: str | None = "a0c9c13e118f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("node", sa.Column("is_mqtt_gateway", sa.Boolean(), nullable=True))
def downgrade() -> None:
op.drop_column("node", "is_mqtt_gateway")
@@ -0,0 +1,35 @@
"""Add daily_snapshot table
Revision ID: 4f1d2a9c8b71
Revises: 23dad03d2e42
Create Date: 2026-03-05 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4f1d2a9c8b71"
down_revision: str | None = "23dad03d2e42"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"daily_snapshot",
sa.Column("snapshot_date", sa.Date(), nullable=False),
sa.Column("node_count", sa.BigInteger(), nullable=False),
sa.Column("packet_count", sa.BigInteger(), nullable=False),
sa.Column("gateway_count", sa.BigInteger(), nullable=False),
sa.Column("captured_at_us", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("snapshot_date"),
)
def downgrade() -> None:
op.drop_table("daily_snapshot")
@@ -0,0 +1,43 @@
"""Add node_public_key table
Revision ID: a0c9c13e118f
Revises: d4d7b0c2e1a4
Create Date: 2026-02-06 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a0c9c13e118f"
down_revision: str | None = "d4d7b0c2e1a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"node_public_key",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("node_id", sa.BigInteger(), nullable=False),
sa.Column("public_key", sa.String(), nullable=False),
sa.Column("first_seen_us", sa.BigInteger(), nullable=True),
sa.Column("last_seen_us", sa.BigInteger(), nullable=True),
)
op.create_index("idx_node_public_key_node_id", "node_public_key", ["node_id"], unique=False)
op.create_index(
"idx_node_public_key_public_key",
"node_public_key",
["public_key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("idx_node_public_key_public_key", table_name="node_public_key")
op.drop_index("idx_node_public_key_node_id", table_name="node_public_key")
op.drop_table("node_public_key")
@@ -0,0 +1,22 @@
"""Compatibility placeholder for removed revision c6b1d8f2a9e3.
Revision ID: c6b1d8f2a9e3
Revises: 4f1d2a9c8b71
Create Date: 2026-03-05 00:00:00.000000
"""
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "c6b1d8f2a9e3"
down_revision: str | None = "4f1d2a9c8b71"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+90
View File
@@ -0,0 +1,90 @@
# -------------------------
# Server Configuration
# -------------------------
[server]
# The address to bind the server to. Use * to listen on all interfaces.
bind = 0.0.0.0
# Port to run the web server on.
port = 8081
# Path to TLS certificate (leave blank to disable HTTPS).
tls_cert =
# Path for the ACME challenge if using Let's Encrypt.
acme_challenge =
# -------------------------
# Site Appearance & Behavior
# -------------------------
[site]
domain =
language = en
title = Bay Area Mesh
message = Real time data from around the bay area and beyond.
starting = /chat
nodes = True
conversations = True
everything = True
graphs = True
stats = True
net = True
map = True
top = True
map_top_left_lat = 39
map_top_left_lon = -123
map_bottom_right_lat = 36
map_bottom_right_lon = -121
map_interval = 3
firehose_interal = 3
weekly_net_message = Weekly Mesh check-in. We will keep it open on every Wednesday from 5:00pm for checkins. The message format should be (LONG NAME) - (CITY YOU ARE IN) #BayMeshNet.
net_tag = #BayMeshNet
# -------------------------
# MQTT Broker Configuration
# -------------------------
[mqtt]
server = mqtt.meshtastic.org
topics = ["msh/US/bayarea/#", "msh/US/CA/mrymesh/#", "msh/US/CA/sacvalley"]
port = 1883
username = meshdev
password = large4cats
skip_node_ids =
secondary_keys =
# -------------------------
# Database Configuration
# -------------------------
[database]
connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
# -------------------------
# Database Cleanup Configuration
# -------------------------
[cleanup]
enabled = False
days_to_keep = 14
hour = 2
minute = 00
vacuum = False
backup_enabled = False
backup_dir = ./backups
backup_hour = 2
backup_minute = 00
# -------------------------
# Logging Configuration
# -------------------------
[logging]
access_log = False
db_cleanup_logfile = /var/log/meshview/dbcleanup.log
-36
View File
@@ -1,36 +0,0 @@
# MeshView Docker Container
> **Note:** This directory contains legacy Docker build files.
>
> **For current Docker usage instructions, please see [README-Docker.md](../README-Docker.md) in the project root.**
## Current Approach
Pre-built container images are automatically built and published to GitHub Container Registry:
```bash
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
```
See **[README-Docker.md](../README-Docker.md)** for:
- Quick start instructions
- Volume mount configuration
- Docker Compose examples
- Backup configuration
- Troubleshooting
## Legacy Build (Not Recommended)
If you need to build your own image for development:
```bash
# From project root
docker build -f Containerfile -t meshview:local .
```
The current Containerfile uses:
- **Base Image**: `python:3.13-slim` (Debian-based)
- **Build tool**: `uv` for fast dependency installation
- **User**: Non-root user `app` (UID 10001)
- **Exposed Port**: `8081`
- **Volumes**: `/etc/meshview`, `/var/lib/meshview`, `/var/log/meshview`
+37
View File
@@ -0,0 +1,37 @@
# Coverage
## Predicted coverage
Meshview can display a predicted coverage boundary for a node. This is a **model**
estimate, not a guarantee of real-world performance.
### How it works
The coverage boundary is computed using the Longley-Rice / ITM **area mode**
propagation model. Area mode estimates average path loss over generic terrain
and does not use a terrain profile. This means it captures general distance
effects, but **does not** account for terrain shadows, buildings, or foliage.
### What you are seeing
The UI draws a **perimeter** (not a heatmap) that represents the furthest
distance where predicted signal strength is above a threshold (default
`-120 dBm`). The model is run radially from the node in multiple directions,
and the last point above the threshold forms the outline.
### Key parameters
- **Frequency**: default `907 MHz`
- **Transmit power**: default `20 dBm`
- **Antenna heights**: default `5 m` (TX) and `1.5 m` (RX)
- **Reliability**: default `0.5` (median)
- **Terrain irregularity**: default `90 m` (average terrain)
### Limitations
- No terrain or building data is used (area mode only).
- Results are sensitive to power, height, and threshold.
- Environmental factors can cause large real-world deviations.
+1
View File
@@ -0,0 +1 @@
cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0
+2 -2
View File
@@ -3,8 +3,8 @@
import subprocess
from pathlib import Path
__version__ = "3.0.4"
__release_date__ = "2026-1-24"
__version__ = "3.0.6"
__release_date__ = "2026-3-6"
def get_git_revision():
+13
View File
@@ -0,0 +1,13 @@
import logging
from importlib.util import find_spec
logger = logging.getLogger(__name__)
def check_optional_deps() -> None:
if find_spec("pyitm") is None:
logger.warning(
"Optional dependency missing: pyitm. "
"Coverage prediction is disabled. "
"Run: ./env/bin/pip install -r requirements.txt"
)
+71 -10
View File
@@ -13,18 +13,47 @@
"go to node": "Go to Node",
"all": "All",
"portnum_options": {
"0": "Unknown",
"1": "Text Message",
"2": "Remote Hardware",
"3": "Position",
"4": "Node Info",
"5": "Routing",
"6": "Admin",
"7": "Text (Compressed)",
"8": "Waypoint",
"9": "Audio",
"10": "Detection Sensor",
"11": "Alert",
"12": "Key Verification",
"32": "Reply",
"33": "IP Tunnel",
"34": "Paxcounter",
"35": "Store Forward++",
"36": "Node Status",
"64": "Serial",
"65": "Store & Forward",
"66": "Range Test",
"67": "Telemetry",
"68": "ZPS",
"69": "Simulator",
"70": "Traceroute",
"71": "Neighbor Info"
}
"71": "Neighbor Info",
"72": "ATAK",
"73": "Map Report",
"74": "Power Stress",
"76": "Reticulum Tunnel",
"77": "Cayenne",
"256": "Private App",
"257": "ATAK Forwarder"
}
},
"chat": {
"chat_title": "Chats:",
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
"view_packet_details": "View packet details",
"channel": "Channel",
"all_channels": "All Channels"
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
@@ -53,8 +82,11 @@
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"mqtt_gateway": "MQTT",
"last_seen": "Last Seen",
"favorite": "Favorite",
"yes": "Yes",
"no": "No",
"time_just_now": "just now",
"time_min_ago": "min ago",
@@ -69,15 +101,21 @@
"view_packet_details": "More details"
},
"map": {
"show_routers_only": "Show Routers Only",
"share_view": "Share This View",
"reset_filters": "Reset Filters To Defaults",
"channel_label": "Channel:",
"map": {
"show_routers_only": "Show Routers Only",
"show_mqtt_only": "Show MQTT Gateways Only",
"share_view": "Share This View",
"reset_filters": "Reset Filters To Defaults",
"unmapped_packets_title": "Unmapped Packets",
"unmapped_packets_empty": "No recent unmapped packets.",
"channel_label": "Channel:",
"model_label": "Model:",
"role_label": "Role:",
"mqtt_gateway": "MQTT Gateway:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"yes": "Yes",
"no": "No",
"link_copied": "Link Copied!",
"legend_traceroute": "Traceroute (with arrows)",
"legend_neighbor": "Neighbor"
@@ -88,8 +126,11 @@
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
"total_nodes": "Total Nodes",
"total_gateways": "Total Gateways",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
"daily_snapshot_packets_history": "Daily Packet Snapshot History",
"daily_snapshot_nodes_gateways_history": "Daily Node/Gateway Snapshot History",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
@@ -98,6 +139,10 @@
"hardware_breakdown": "Hardware Breakdown",
"role_breakdown": "Role Breakdown",
"channel_breakdown": "Channel Breakdown",
"gateway_channel_breakdown": "Gateway Channel Breakdown",
"gateway_role_breakdown": "Gateway Role Breakdown",
"gateway_firmware_breakdown": "Gateway Firmware Breakdown",
"no_gateways": "No gateways found",
"expand_chart": "Expand Chart",
"export_csv": "Export CSV",
"all_channels": "All Channels",
@@ -137,6 +182,8 @@
"packet_id": "Packet ID",
"from": "From",
"to": "To",
"channel": "Channel",
"all_channels": "All Channels",
"port": "Port",
"links": "Links",
"unknown_app": "UNKNOWN APP",
@@ -163,9 +210,11 @@
"hw_model": "Hardware Model",
"firmware": "Firmware",
"role": "Role",
"mqtt_gateway": "MQTT Gateway",
"channel": "Channel",
"latitude": "Latitude",
"longitude": "Longitude",
"first_update": "First Update",
"last_update": "Last Update",
"battery_voltage": "Battery & Voltage",
"air_channel": "Air & Channel Utilization",
@@ -183,7 +232,19 @@
"statistics": "Statistics",
"last_24h": "24h",
"packets_sent": "Packets sent",
"times_seen": "Times seen"
"times_seen": "Times seen",
"yes": "Yes",
"no": "No",
"copy_import_url": "Copy Import URL",
"show_qr_code": "Show QR Code",
"toggle_coverage": "Predicted Coverage",
"location_required": "Location required for coverage",
"coverage_help": "Coverage Help",
"share_contact_qr": "Share Contact QR",
"copy_url": "Copy URL",
"copied": "Copied!",
"potential_impersonation": "Potential Impersonation Detected",
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device."
},
"packet": {
"loading": "Loading packet information...",
@@ -209,4 +270,4 @@
}
}
}
+69 -7
View File
@@ -13,19 +13,48 @@
"go_to_node": "Ir al nodo",
"all": "Todos",
"portnum_options": {
"0": "Desconocido",
"1": "Mensaje de Texto",
"2": "Hardware Remoto",
"3": "Ubicación",
"4": "Información del Nodo",
"5": "Enrutamiento",
"6": "Administración",
"7": "Texto (Comprimido)",
"8": "Punto de Referencia",
"9": "Audio",
"10": "Sensor de Detección",
"11": "Alerta",
"12": "Verificación de Clave",
"32": "Respuesta",
"33": "Túnel IP",
"34": "Paxcounter",
"35": "Store Forward++",
"36": "Estado del Nodo",
"64": "Serial",
"65": "Store & Forward",
"66": "Prueba de Alcance",
"67": "Telemetría",
"68": "ZPS",
"69": "Simulador",
"70": "Traceroute",
"71": "Información de Vecinos"
"71": "Información de Vecinos",
"72": "ATAK",
"73": "Reporte de Mapa",
"74": "Prueba de Energía",
"76": "Túnel Reticulum",
"77": "Cayenne",
"256": "App Privada",
"257": "ATAK Forwarder"
}
},
"chat": {
"chat_title": "Conversaciones:",
"replying_to": "Respondiendo a:",
"view_packet_details": "Ver detalles del paquete"
"view_packet_details": "Ver detalles del paquete",
"channel": "Canal",
"all_channels": "Todos los canales"
},
"nodelist": {
@@ -51,8 +80,11 @@
"last_lat": "Última latitud",
"last_long": "Última longitud",
"channel": "Canal",
"mqtt_gateway": "MQTT",
"last_seen": "Última vez visto",
"favorite": "Favorito",
"yes": "Sí",
"no": "No",
"time_just_now": "justo ahora",
"time_min_ago": "min atrás",
"time_hr_ago": "h atrás",
@@ -67,14 +99,21 @@
},
"map": {
"filter_routers_only": "Mostrar solo enrutadores",
"share_view": "Compartir esta vista",
"reset_filters": "Restablecer filtros",
"channel_label": "Canal:",
"filter_routers_only": "Mostrar solo enrutadores",
"show_routers_only": "Mostrar solo enrutadores",
"show_mqtt_only": "Mostrar solo gateways MQTT",
"share_view": "Compartir esta vista",
"reset_filters": "Restablecer filtros",
"unmapped_packets_title": "Paquetes sin mapa",
"unmapped_packets_empty": "No hay paquetes sin mapa recientes.",
"channel_label": "Canal:",
"model_label": "Modelo:",
"role_label": "Rol:",
"mqtt_gateway": "Gateway MQTT:",
"last_seen": "Visto por última vez:",
"firmware": "Firmware:",
"yes": "Sí",
"no": "No",
"link_copied": "¡Enlace copiado!",
"legend_traceroute": "Ruta de traceroute (flechas de dirección)",
"legend_neighbor": "Vínculo de vecinos"
@@ -83,8 +122,11 @@
"stats": {
"mesh_stats_summary": "Estadísticas de la Malla - Resumen (completas en la base de datos)",
"total_nodes": "Nodos Totales",
"total_gateways": "Gateways Totales",
"total_packets": "Paquetes Totales",
"total_packets_seen": "Paquetes Totales Vistos",
"daily_snapshot_packets_history": "Historial Diario de Instantáneas de Paquetes (Todos los Días Disponibles)",
"daily_snapshot_nodes_gateways_history": "Historial Diario de Instantáneas de Nodos/Gateways (Todos los Días Disponibles)",
"packets_per_day_all": "Paquetes por Día - Todos los Puertos (Últimos 14 Días)",
"packets_per_day_text": "Paquetes por Día - Mensajes de Texto (Puerto 1, Últimos 14 Días)",
"packets_per_hour_all": "Paquetes por Hora - Todos los Puertos",
@@ -93,6 +135,10 @@
"hardware_breakdown": "Distribución de Hardware",
"role_breakdown": "Distribución de Roles",
"channel_breakdown": "Distribución de Canales",
"gateway_channel_breakdown": "Desglose de canales de gateways",
"gateway_role_breakdown": "Desglose de roles de gateways",
"gateway_firmware_breakdown": "Desglose de firmware de gateways",
"no_gateways": "No se encontraron gateways",
"expand_chart": "Ampliar Gráfico",
"export_csv": "Exportar CSV",
"all_channels": "Todos los Canales"
@@ -135,6 +181,8 @@
"packet_id": "ID de paquete",
"from": "De",
"to": "A",
"channel": "Canal",
"all_channels": "Todos los canales",
"port": "Puerto",
"direct_to_mqtt": "Directo a MQTT",
"all_broadcast": "Todos"
@@ -148,9 +196,11 @@
"hw_model": "Modelo de Hardware",
"firmware": "Firmware",
"role": "Rol",
"mqtt_gateway": "Gateway MQTT",
"channel": "Canal",
"latitude": "Latitud",
"longitude": "Longitud",
"first_update": "Primera Actualización",
"last_update": "Última Actualización",
"battery_voltage": "Batería y voltaje",
"air_channel": "Utilización del aire y del canal",
@@ -168,7 +218,19 @@
"statistics": "Estadísticas",
"last_24h": "24h",
"packets_sent": "Paquetes enviados",
"times_seen": "Veces visto"
"times_seen": "Veces visto",
"yes": "Sí",
"no": "No",
"copy_import_url": "Copiar URL de importación",
"show_qr_code": "Mostrar código QR",
"toggle_coverage": "Cobertura predicha",
"location_required": "Se requiere ubicación para la cobertura",
"coverage_help": "Ayuda de cobertura",
"share_contact_qr": "Compartir contacto QR",
"copy_url": "Copiar URL",
"copied": "¡Copiado!",
"potential_impersonation": "Posible suplantación detectada",
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo."
},
"packet": {
+252
View File
@@ -0,0 +1,252 @@
{
"base": {
"chat": "Чат",
"nodes": "Ноды",
"everything": "Показать все",
"graphs": "Mesh графики",
"net": "Недельная сеть",
"map": "Живая карта",
"stats": "Статистика",
"top": "Крупнейшие ноды трафика",
"footer": "Посетите <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> на GitHub",
"node id": "ID ноды",
"go to node": "Перейти к ноде",
"all": "Все",
"portnum_options": {
"0": "Неизвестно",
"1": "Текстовое сообщение",
"2": "Удалённое оборудование",
"3": "Позиция",
"4": "Информация о ноде",
"5": "Маршрутизация",
"6": "Администрирование",
"7": "Текст (сжатый)",
"8": "Контрольная точка",
"9": "Аудио",
"10": "Датчик обнаружения",
"11": "Тревога",
"12": "Проверка ключа",
"32": "Ответить",
"33": "IP туннель",
"34": "Счетчик прохожих",
"35": "Store Forward++",
"36": "Состояние ноды",
"64": "Последовательный",
"65": "Store & Forward",
"66": "Проверка дальности",
"67": "Телеметрия",
"68": "ZPS",
"69": "Симулятор",
"70": "Трассировка",
"71": "Neighbor Info",
"72": "ATAK",
"73": "Отчёт по карте",
"74": "Power Stress",
"76": "Reticulum туннель",
"77": "Cayenne",
"256": "Частное приложение",
"257": "Пересылка ATAK"
}
},
"chat": {
"chat_title": "Чаты:",
"replying_to": "В ответ на:",
"view_packet_details": "Просмотреть детали пакета"
},
"nodelist": {
"search_placeholder": "Поиск по имени или ID...",
"all_roles": "Все роли",
"all_channels": "Все каналы",
"all_hw": "Все модели устройств",
"all_firmware": "Все прошивки",
"show_favorites": "⭐ Показать избранные",
"show_all": "⭐ Показать все",
"export_csv": "Экспорт CSV",
"clear_filters": "Очистить фильтры",
"showing_nodes": "Показать ноды",
"nodes_suffix": "ноды",
"loading_nodes": "Загрузка нод...",
"error_loading_nodes": "Ошибка загрузки нод",
"no_nodes_found": "Ноды не найдены",
"short_name": "Короткое имя",
"long_name": "Длинное имя",
"hw_model": "Модель оборудования",
"firmware": "Прошивка",
"role": "Роль",
"last_lat": "Последняя широта",
"last_long": "Последняя долгота",
"channel": "Канал",
"mqtt_gateway": "MQTT",
"last_seen": "Был в сети",
"favorite": "Избранные",
"yes": "Да",
"no": "Нет",
"time_just_now": "только что",
"time_min_ago": "минут назад",
"time_hr_ago": "часов назад",
"time_day_ago": "день назад",
"time_days_ago": "дней назад"
},
"net": {
"net_title": "Еженедельная сеть:",
"total_messages": "Количество сообщений:",
"view_packet_details": "Больше деталей"
},
"map": {
"show_routers_only": "Показать только маршрутизаторы",
"show_mqtt_only": "Показать только шлюзы MQTT",
"share_view": "Поделиться этим видом",
"reset_filters": "Сбросить фильтры",
"unmapped_packets_title": "Несопоставленные пакеты",
"unmapped_packets_empty": "Нет последних несопоставленных пакетов.",
"channel_label": "Канал:",
"model_label": "Модель:",
"role_label": "Роль:",
"mqtt_gateway": "Шлюз MQTT:",
"last_seen": "Последний раз слышен:",
"firmware": "Прошивка:",
"yes": "Да",
"no": "Нет",
"link_copied": "Ссылка скопирована!",
"legend_traceroute": "Трассировка (со стрелками)",
"legend_neighbor": "Сосед"
},
"stats": {
"mesh_stats_summary": "Сводка статистики сети (все доступные в базе данных)",
"total_nodes": "Всего нод",
"total_gateways": "Всего шлюзов",
"total_packets": "Всего пакетов",
"total_packets_seen": "Всего пакетов обнаружено",
"packets_per_day_all": "Пакетов в день - все порты (за последние 14 дней)",
"packets_per_day_text": "Пакетов в день - текстовые сообщения (порт 1 за последние 14 дней)",
"packets_per_hour_all": "Пакетов в час - все порты",
"packets_per_hour_text": "Пакетов в час - текстовые сообщения (порт 1)",
"packet_types_last_24h": "Типы пакетов - последние 24 часа",
"hardware_breakdown": "Распределение устройств",
"role_breakdown": "Распределение ролей",
"channel_breakdown": "Распределение каналов",
"gateway_channel_breakdown": "Распределение шлюзов канала",
"gateway_role_breakdown": "Распределение ролей шлюза",
"gateway_firmware_breakdown": "Распределение прошивки шлюза",
"no_gateways": "Шлюзов не найдено",
"expand_chart": "Развернуть диаграмму",
"export_csv": "Экспорт CSV",
"all_channels": "Все каналы",
"node_id": "ID ноды"
},
"top": {
"top_traffic_nodes": " Трафик нод",
"channel": "Канал",
"search": "Поиск",
"search_placeholder": "Поиск нод...",
"long_name": "Длинное имя",
"short_name": "Короткое имя",
"packets_sent": "Отправлено (24ч)",
"times_seen": "Принято (24ч)",
"avg_gateways": "Среднее количество шлюзов",
"showing_nodes": "Показать",
"nodes_suffix": "ноды"
},
"nodegraph": {
"channel_label": "Канал:",
"search_node_placeholder": "Поиск ноды...",
"search_button": "Поиск",
"long_name_label": "Полное имя:",
"short_name_label": "Короткое имя:",
"role_label": "Роль:",
"hw_model_label": "Модель оборудования:",
"node_not_found": "Узел не найден в текущем канале!"
},
"firehose": {
"live_feed": "📡 Прямой эфир",
"pause": "Пауза",
"resume": "Продолжить",
"time": "Время",
"packet_id": "ID пакета",
"from": "От",
"to": "К",
"port": "Порт",
"links": "Ссылки",
"unknown_app": "НЕИЗВ-НОЕ ПРИЛОЖ",
"text_message": "Текстовое сообщение",
"position": "Позиция",
"node_info": "Информация об узле",
"routing": "Маршрутизация",
"administration": "Администрирование",
"waypoint": "Контрольная точка",
"store_forward": "Store Forward",
"telemetry": "Телеметрия",
"trace_route": "Трассировка маршрута",
"neighbor_info": "Neighbor Info",
"direct_to_mqtt": "напрямую к MQTT",
"all": "Все",
"map": "Карта",
"graph": "Диаграмма"
},
"node": {
"specifications": "Технические характеристики",
"node_id": "ID ноды",
"long_name": "Длинное имя",
"short_name": "Короткое имя",
"hw_model": "Модель оборудования",
"firmware": "Прошивка",
"role": "Роль",
"mqtt_gateway": "Шлюз MQTT",
"channel": "Канал",
"latitude": "Широта",
"longitude": "Долгота",
"first_update": "Первое обновление",
"last_update": "Последнее обновление",
"battery_voltage": "Батарея и напряжение",
"air_channel": "Использование сети и каналов",
"environment": "Метрики среды",
"neighbors_chart": "Соседи (Соотношение сигнал/шум)",
"expand": "Расширить",
"export_csv": "Экспорт в CSV",
"time": "Время",
"packet_id": "ID пакета",
"from": "От",
"to": "К",
"port": "Порт",
"direct_to_mqtt": "Напрямую к MQTT",
"all_broadcast": "Все",
"statistics": "Статистика",
"last_24h": "24ч",
"packets_sent": "Отправленные пакеты",
"times_seen": "Просмотрено раз",
"yes": "Да",
"no": "Нет",
"copy_import_url": "Копировать URL для импорта",
"show_qr_code": "Показать QR-код",
"toggle_coverage": "Прогнозируемое покрытие",
"location_required": "Требуется местоположение для покрытия",
"coverage_help": "Помощь по покрытию",
"share_contact_qr": "Поделиться QR контакта",
"copy_url": "Копировать URL",
"copied": "Скопировано!",
"potential_impersonation": "Обнаружено возможное самозванство",
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
},
"packet": {
"loading": "Загрузка информации о пакете...",
"packet_id_label": "ID пакета",
"from_node": "От ноды",
"to_node": "К ноде",
"channel": "Канал",
"port": "Порт",
"raw_payload": "Незашифрованная нагрузка",
"decoded_telemetry": "Раскодированная телеметрия",
"location": "Местоположение",
"seen_by": "Просмотрено",
"gateway": "Шлюз",
"rssi": "RSSI",
"snr": "SNR",
"hops": "Хоп",
"time": "Время",
"packet_source": "Источник пакета",
"distance": "Расстояние",
"node_id_short": "ID ноды",
"all_broadcast": "Все",
"direct_to_mqtt": "Напрямую к MQTT"
}
}
+31 -4
View File
@@ -1,4 +1,6 @@
from sqlalchemy import BigInteger, ForeignKey, Index, desc
from datetime import date
from sqlalchemy import BigInteger, Date, ForeignKey, Index, desc
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -20,6 +22,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] = mapped_column(nullable=True)
is_mqtt_gateway: Mapped[bool] = mapped_column(nullable=True)
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
@@ -100,7 +103,31 @@ class Traceroute(Base):
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (
Index("idx_traceroute_packet_id", "packet_id"),
Index("idx_traceroute_import_time_us", "import_time_us"),
)
Index("idx_traceroute_packet_id", "packet_id"),
Index("idx_traceroute_import_time_us", "import_time_us"),
)
class NodePublicKey(Base):
__tablename__ = "node_public_key"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
node_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
public_key: Mapped[str] = mapped_column(nullable=False)
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (
Index("idx_node_public_key_node_id", "node_id"),
Index("idx_node_public_key_public_key", "public_key"),
)
class DailySnapshot(Base):
__tablename__ = "daily_snapshot"
snapshot_date: Mapped[date] = mapped_column(Date, primary_key=True)
node_count: Mapped[int] = mapped_column(BigInteger, nullable=False)
packet_count: Mapped[int] = mapped_column(BigInteger, nullable=False)
gateway_count: Mapped[int] = mapped_column(BigInteger, nullable=False)
captured_at_us: Mapped[int] = mapped_column(BigInteger, nullable=False)
+1 -1
View File
@@ -1,6 +1,6 @@
from sqlalchemy import event
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy import event
from meshview import models
+28 -15
View File
@@ -8,6 +8,7 @@ import aiomqtt
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from google.protobuf.message import DecodeError
from meshtastic.protobuf.mesh_pb2 import Data
from meshtastic.protobuf.mqtt_pb2 import ServiceEnvelope
from meshview.config import CONFIG
@@ -45,6 +46,12 @@ def _parse_skip_node_ids():
return skip_ids
def _strip_quotes(value):
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
def _parse_secondary_keys():
mqtt_config = CONFIG.get("mqtt", {})
raw_value = mqtt_config.get("secondary_keys", "")
@@ -62,7 +69,9 @@ def _parse_secondary_keys():
keys = []
for value in values:
try:
keys.append(base64.b64decode(value))
cleaned = _strip_quotes(str(value).strip())
if cleaned:
keys.append(base64.b64decode(cleaned))
except (TypeError, ValueError):
logger.warning("Invalid base64 key in mqtt.secondary_keys: %s", value)
return keys
@@ -71,8 +80,18 @@ def _parse_secondary_keys():
SKIP_NODE_IDS = _parse_skip_node_ids()
SECONDARY_KEYS = _parse_secondary_keys()
logger.info("Primary key: %s", PRIMARY_KEY)
if SECONDARY_KEYS:
logger.info("Secondary keys: %s", SECONDARY_KEYS)
else:
logger.info("Secondary keys: []")
def _try_decrypt(packet, key):
# Thank you to "Robert Grizzell" for the decryption code!
# https://github.com/rgrizzell
def decrypt(packet, key):
if packet.HasField("decoded"):
return True
packet_id = packet.id.to_bytes(8, "little")
from_node_id = getattr(packet, "from").to_bytes(8, "little")
nonce = packet_id + from_node_id
@@ -81,24 +100,17 @@ def _try_decrypt(packet, key):
decryptor = cipher.decryptor()
raw_proto = decryptor.update(packet.encrypted) + decryptor.finalize()
try:
packet.decoded.ParseFromString(raw_proto)
data = Data()
data.ParseFromString(raw_proto)
packet.decoded.CopyFrom(data)
except DecodeError:
return False
return True
def decrypt(packet):
if packet.HasField("decoded"):
return
if _try_decrypt(packet, PRIMARY_KEY):
return
for key in SECONDARY_KEYS:
if _try_decrypt(packet, key):
return
async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd):
identifier = str(random.getrandbits(16))
keyring = [PRIMARY_KEY, *SECONDARY_KEYS]
msg_count = 0
start_time = None
while True:
@@ -125,8 +137,9 @@ async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_pa
except DecodeError:
continue
decrypt(envelope.packet)
# print(envelope.packet.decoded)
for key in keyring:
if decrypt(envelope.packet, key):
break
if not envelope.packet.decoded:
continue
+140 -26
View File
@@ -1,9 +1,9 @@
import datetime
import logging
import re
import time
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.exc import IntegrityError
@@ -12,10 +12,68 @@ from meshtastic.protobuf.config_pb2 import Config
from meshtastic.protobuf.mesh_pb2 import HardwareModel
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import decode_payload, mqtt_database
from meshview.models import Node, Packet, PacketSeen, Traceroute
from meshview.models import DailySnapshot, Node, NodePublicKey, Packet, PacketSeen, Traceroute
logger = logging.getLogger(__name__)
MQTT_GATEWAY_CACHE: set[int] = set()
async def capture_daily_snapshot() -> None:
today = datetime.now(UTC).date()
async with mqtt_database.async_session() as session:
node_count = (await session.execute(select(func.count()).select_from(Node))).scalar_one()
packet_count = (
await session.execute(select(func.count()).select_from(Packet))
).scalar_one()
gateway_count = (
await session.execute(
select(func.count()).select_from(Node).where(Node.is_mqtt_gateway.is_(True))
)
).scalar_one()
captured_at_us = int(time.time() * 1_000_000)
values = {
"snapshot_date": today,
"node_count": node_count,
"packet_count": packet_count,
"gateway_count": gateway_count,
"captured_at_us": captured_at_us,
}
dialect = session.get_bind().dialect.name
stmt = None
if dialect == "sqlite":
stmt = (
sqlite_insert(DailySnapshot)
.values(**values)
.on_conflict_do_update(index_elements=["snapshot_date"], set_=values)
)
elif dialect == "postgresql":
stmt = (
pg_insert(DailySnapshot)
.values(**values)
.on_conflict_do_update(index_elements=["snapshot_date"], set_=values)
)
if stmt is not None:
await session.execute(stmt)
else:
snapshot = (
await session.execute(
select(DailySnapshot).where(DailySnapshot.snapshot_date == today)
)
).scalar_one_or_none()
if snapshot is None:
session.add(DailySnapshot(**values))
else:
snapshot.node_count = node_count
snapshot.packet_count = packet_count
snapshot.gateway_count = gateway_count
snapshot.captured_at_us = captured_at_us
await session.commit()
async def process_envelope(topic, env):
# MAP_REPORT_APP
@@ -97,9 +155,9 @@ async def process_envelope(topic, env):
"import_time_us": now_us,
"channel": env.channel_id,
}
utc_time = datetime.datetime.fromtimestamp(now_us / 1_000_000, datetime.UTC)
dialect = session.get_bind().dialect.name
stmt = None
if dialect == "sqlite":
stmt = (
sqlite_insert(Packet)
@@ -123,7 +181,7 @@ async def process_envelope(topic, env):
except IntegrityError:
pass
# --- PacketSeen (no conflict handling here, normal insert)
# --- PacketSeen insert with conflict-safe handling
if not env.gateway_id:
print("WARNING: Missing gateway_id, skipping PacketSeen entry")
@@ -132,28 +190,49 @@ async def process_envelope(topic, env):
else:
node_id = int(env.gateway_id[1:], 16)
result = await session.execute(
select(PacketSeen).where(
PacketSeen.packet_id == env.packet.id,
PacketSeen.node_id == node_id,
PacketSeen.rx_time == env.packet.rx_time,
if node_id not in MQTT_GATEWAY_CACHE:
MQTT_GATEWAY_CACHE.add(node_id)
await session.execute(
update(Node).where(Node.node_id == node_id).values(is_mqtt_gateway=True)
)
)
if not result.scalar_one_or_none():
now_us = int(time.time() * 1_000_000)
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,
import_time_us=now_us,
now_us = int(time.time() * 1_000_000)
seen_values = {
"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,
"import_time_us": now_us,
}
dialect = session.get_bind().dialect.name
seen_stmt = None
if dialect == "sqlite":
seen_stmt = (
sqlite_insert(PacketSeen)
.values(**seen_values)
.on_conflict_do_nothing(index_elements=["packet_id", "node_id", "rx_time"])
)
session.add(seen)
elif dialect == "postgresql":
seen_stmt = (
pg_insert(PacketSeen)
.values(**seen_values)
.on_conflict_do_nothing(index_elements=["packet_id", "node_id", "rx_time"])
)
if seen_stmt is not None:
await session.execute(seen_stmt)
else:
try:
async with session.begin_nested():
session.add(PacketSeen(**seen_values))
await session.flush()
except IntegrityError:
pass
# --- NODEINFO_APP handling
if env.packet.decoded.portnum == PortNum.NODEINFO_APP:
@@ -165,7 +244,12 @@ async def process_envelope(topic, env):
if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]):
node_id = int(user.id[1:], 16)
else:
node_id = None
logger.warning(
"Skipping NODEINFO_APP packet %s: unable to determine node_id",
env.packet.id,
)
await session.commit()
return
hw_model = (
HardwareModel.Name(user.hw_model)
@@ -207,6 +291,28 @@ async def process_envelope(topic, env):
last_seen_us=now_us,
)
session.add(node)
if user.public_key:
public_key_hex = user.public_key.hex()
existing_key = (
await session.execute(
select(NodePublicKey).where(
NodePublicKey.node_id == node_id,
NodePublicKey.public_key == public_key_hex,
)
)
).scalar_one_or_none()
if existing_key:
existing_key.last_seen_us = now_us
else:
new_key = NodePublicKey(
node_id=node_id,
public_key=public_key_hex,
first_seen_us=now_us,
last_seen_us=now_us,
)
session.add(new_key)
except Exception as e:
print(f"Error processing NODEINFO_APP: {e}")
@@ -245,3 +351,11 @@ async def process_envelope(topic, env):
)
await session.commit()
async def load_gateway_cache():
async with mqtt_database.async_session() as session:
result = await session.execute(
select(Node.node_id).where(Node.is_mqtt_gateway == True) # noqa: E712
)
MQTT_GATEWAY_CACHE.update(result.scalars().all())
+146
View File
@@ -0,0 +1,146 @@
import math
from functools import lru_cache
try:
from pyitm import itm
ITM_AVAILABLE = True
except Exception:
itm = None
ITM_AVAILABLE = False
DEFAULT_CLIMATE = 5 # Continental temperate
DEFAULT_GROUND = 0.005 # Average ground conductivity
DEFAULT_EPS_DIELECT = 15.0
DEFAULT_DELTA_H = 90.0
DEFAULT_RELIABILITY = 0.5
DEFAULT_MIN_DBM = -130.0
DEFAULT_MAX_DBM = -80.0
DEFAULT_THRESHOLD_DBM = -120.0
EARTH_RADIUS_KM = 6371.0
BEARING_STEP_DEG = 5
def destination_point(
lat: float, lon: float, bearing_deg: float, distance_km: float
) -> tuple[float, float]:
lat1 = math.radians(lat)
lon1 = math.radians(lon)
bearing = math.radians(bearing_deg)
d = distance_km / EARTH_RADIUS_KM
lat2 = math.asin(
math.sin(lat1) * math.cos(d) + math.cos(lat1) * math.sin(d) * math.cos(bearing)
)
lon2 = lon1 + math.atan2(
math.sin(bearing) * math.sin(d) * math.cos(lat1),
math.cos(d) - math.sin(lat1) * math.sin(lat2),
)
return math.degrees(lat2), math.degrees(lon2)
@lru_cache(maxsize=512)
def compute_coverage(
lat: float,
lon: float,
freq_mhz: float,
tx_dbm: float,
tx_height_m: float,
rx_height_m: float,
radius_km: float,
step_km: float,
reliability: float,
) -> list[tuple[float, float, float]]:
if not ITM_AVAILABLE:
return []
points = []
distance = max(step_km, 1.0)
while distance <= radius_km:
for bearing in range(0, 360, BEARING_STEP_DEG):
rx_lat, rx_lon = destination_point(lat, lon, bearing, distance)
try:
loss_db, _ = itm.area(
ModVar=2,
deltaH=DEFAULT_DELTA_H,
tht_m=tx_height_m,
rht_m=rx_height_m,
dist_km=distance,
TSiteCriteria=0,
RSiteCriteria=0,
eps_dielect=DEFAULT_EPS_DIELECT,
sgm_conductivity=DEFAULT_GROUND,
eno_ns_surfref=301,
frq_mhz=freq_mhz,
radio_climate=DEFAULT_CLIMATE,
pol=1,
pctTime=reliability,
pctLoc=0.5,
pctConf=0.5,
)
except itm.InputError:
continue
rx_dbm = tx_dbm - loss_db
points.append((rx_lat, rx_lon, rx_dbm))
distance += step_km
return points
@lru_cache(maxsize=512)
def compute_perimeter(
lat: float,
lon: float,
freq_mhz: float,
tx_dbm: float,
tx_height_m: float,
rx_height_m: float,
radius_km: float,
step_km: float,
reliability: float,
threshold_dbm: float,
) -> list[tuple[float, float]]:
if not ITM_AVAILABLE:
return []
perimeter = []
distance = max(step_km, 1.0)
for bearing in range(0, 360, BEARING_STEP_DEG):
last_point = None
dist = distance
while dist <= radius_km:
try:
loss_db, _ = itm.area(
ModVar=2,
deltaH=DEFAULT_DELTA_H,
tht_m=tx_height_m,
rht_m=rx_height_m,
dist_km=dist,
TSiteCriteria=0,
RSiteCriteria=0,
eps_dielect=DEFAULT_EPS_DIELECT,
sgm_conductivity=DEFAULT_GROUND,
eno_ns_surfref=301,
frq_mhz=freq_mhz,
radio_climate=DEFAULT_CLIMATE,
pol=1,
pctTime=reliability,
pctLoc=0.5,
pctConf=0.5,
)
except itm.InputError:
dist += step_km
continue
rx_dbm = tx_dbm - loss_db
if rx_dbm >= threshold_dbm:
last_point = destination_point(lat, lon, bearing, dist)
dist += step_km
if last_point:
perimeter.append(last_point)
return perimeter
+2 -1
View File
@@ -44,6 +44,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js" crossorigin></script>
<script src="/static/portmaps.js"></script>
<script>
(async function(){
@@ -97,7 +98,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
const channels = new Set();
const activeBlinks = new Map();
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
const portMap = window.PORT_LABEL_MAP;
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
+55 -14
View File
@@ -1,34 +1,75 @@
// Shared port label/color definitions for UI pages.
// Port numbers defined in: https://github.com/meshtastic/protobufs/blob/master/meshtastic/portnums.proto
window.PORT_LABEL_MAP = {
0: "UNKNOWN",
0: "Unknown",
1: "Text",
2: "Remote Hardware",
3: "Position",
4: "Node Info",
5: "Routing",
6: "Admin",
7: "Text (Compressed)",
8: "Waypoint",
9: "Audio",
10: "Detection Sensor",
11: "Alert",
12: "Key Verification",
32: "Reply",
33: "IP Tunnel",
34: "Paxcounter",
35: "Store Forward++",
36: "Node Status",
64: "Serial",
65: "Store & Forward",
66: "Range Test",
67: "Telemetry",
68: "ZPS",
69: "Simulator",
70: "Traceroute",
71: "Neighbor",
72: "ATAK",
73: "Map Report",
74: "Power Stress",
76: "Reticulum Tunnel",
77: "Cayenne",
256: "Private App",
257: "ATAK Forwarder",
};
window.PORT_COLOR_MAP = {
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
8: "#fd7e14",
35: "#8bc34a",
65: "#6610f2",
67: "#17a2b8",
70: "#ff4444",
71: "#ff66cc",
73: "#9999ff",
0: "#6c757d", // gray - Unknown
1: "#1f77b4", // blue - Text
2: "#795548", // brown - Remote Hardware
3: "#2ca02c", // green - Position
4: "#ffbf00", // yellow - Node Info
5: "#ff7f0e", // orange - Routing
6: "#20c997", // teal - Admin
7: "#6a51a3", // purple - Text (Compressed)
8: "#fd7e14", // orange - Waypoint
9: "#e91e63", // pink - Audio
10: "#ff9800", // amber - Detection Sensor
11: "#f44336", // bright red - Alert
12: "#9c27b0", // purple - Key Verification
32: "#00bcd4", // cyan - Reply
33: "#607d8b", // blue-gray - IP Tunnel
34: "#8d6e63", // brown-gray - Paxcounter
35: "#8bc34a", // light green - Store Forward++
36: "#4caf50", // green - Node Status
64: "#9e9e9e", // gray - Serial
65: "#6610f2", // indigo - Store & Forward
66: "#cddc39", // lime - Range Test
67: "#17a2b8", // info blue - Telemetry
68: "#3f51b5", // indigo - ZPS
69: "#673ab7", // deep purple - Simulator
70: "#f44336", // bright red - Traceroute
71: "#e377c2", // pink - Neighbor
72: "#2196f3", // blue - ATAK
73: "#9999ff", // light purple - Map Report
74: "#ff5722", // deep orange - Power Stress
76: "#009688", // teal - Reticulum Tunnel
77: "#4db6ac", // teal accent - Cayenne
256: "#757575", // dark gray - Private App
257: "#1976d2", // blue - ATAK Forwarder
};
// Aliases for pages that expect different names.
+5 -5
View File
@@ -179,7 +179,7 @@ async def get_mqtt_neighbors(since):
async def get_total_node_count(channel: str = None) -> int:
try:
async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) # noqa: UP017
cutoff_us = now_us - 86400 * 1_000_000
q = select(func.count(Node.id)).where(Node.last_seen_us > cutoff_us)
@@ -196,7 +196,7 @@ async def get_total_node_count(channel: str = None) -> int:
async def get_top_traffic_nodes():
try:
async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) # noqa: UP017
cutoff_us = now_us - 86400 * 1_000_000
total_packets_sent = func.count(func.distinct(Packet.id)).label("total_packets_sent")
total_times_seen = func.count(PacketSeen.packet_id).label("total_times_seen")
@@ -244,7 +244,7 @@ async def get_top_traffic_nodes():
async def get_node_traffic(node_id: int):
try:
async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) # noqa: UP017
cutoff_us = now_us - 86400 * 1_000_000
packet_count = func.count().label("packet_count")
@@ -309,7 +309,7 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
query = query.where(Node.hw_model == hw_model)
if days_active is not None:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) # noqa: UP017
cutoff_us = now_us - int(timedelta(days_active).total_seconds() * 1_000_000)
query = query.where(Node.last_seen_us > cutoff_us)
@@ -337,7 +337,7 @@ async def get_packet_stats(
to_node: int | None = None,
from_node: int | None = None,
):
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc) # noqa: UP017
if period_type == "hour":
start_time = now - timedelta(hours=length)
+1
View File
@@ -176,6 +176,7 @@ async function initializePage() {
items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`);
}
}
items.push('<a href="https://meshview.world" target="_blank" rel="noopener noreferrer">MeshviewWorld</a>');
menu.innerHTML = items.join("&nbsp;-&nbsp;");
}
+62 -4
View File
@@ -46,6 +46,13 @@
padding-left: 10px;
}
.replying-to .reply-preview { color: #aaa; }
.chat-controls {
gap: 12px;
margin-bottom: 10px;
}
.chat-filter {
min-width: 220px;
}
{% endblock %}
{% block body %}
@@ -53,10 +60,19 @@
<!-- ⭐ CHAT TITLE WITH ICON, aligned to container ⭐ -->
<div class="container px-2">
<h2 style="color:white; margin:0 0 10px 0;">
<span class="icon">💬</span>
<span data-translate="chat_title"></span>
</h2>
<div class="d-flex align-items-center justify-content-between chat-controls">
<h2 style="color:white; margin:0;">
<span class="icon">💬</span>
<span data-translate="chat_title"></span>
</h2>
<div class="d-flex align-items-center gap-2">
<label for="channel-filter" class="mb-0" data-translate="channel">Channel</label>
<select id="channel-filter" class="form-select form-select-sm chat-filter">
<option value="" data-translate="all_channels">All Channels</option>
</select>
</div>
</div>
</div>
<div class="container" id="chat-log"></div>
@@ -68,6 +84,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!chatContainer) return console.error("#chat-log not found");
let lastTime = null;
let selectedChannel = "";
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatLang = {};
@@ -99,6 +116,26 @@ document.addEventListener("DOMContentLoaded", async () => {
}
}
async function loadChannels() {
try {
const res = await fetch("/api/channels");
if (!res.ok) return;
const data = await res.json();
const select = document.getElementById("channel-filter");
const channels = data.channels || [];
for (const channel of channels) {
const option = document.createElement("option");
option.value = channel;
option.textContent = channel;
select.appendChild(option);
}
} catch (err) {
console.error("Failed loading channels:", err);
}
}
/* ==========================================================
SAFE HTML
========================================================== */
@@ -191,6 +228,18 @@ document.addEventListener("DOMContentLoaded", async () => {
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
}
function matchesSelectedChannel(packet) {
if (!selectedChannel) return true;
return (packet.channel || "").toLowerCase() === selectedChannel.toLowerCase();
}
function resetChat() {
lastTime = null;
renderedPacketIds.clear();
packetMap.clear();
chatContainer.innerHTML = "";
}
function renderPacketsEnsureDescending(packets, highlight=false) {
if (!Array.isArray(packets) || packets.length===0) return;
const sortedDesc = packets.slice().sort((a,b)=>{
@@ -199,6 +248,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return bTime - aTime;
});
for (let i=sortedDesc.length-1; i>=0; i--) {
if (!matchesSelectedChannel(sortedDesc[i])) continue;
renderPacket(sortedDesc[i], highlight);
}
}
@@ -235,6 +285,14 @@ document.addEventListener("DOMContentLoaded", async () => {
INIT
========================================================== */
await loadChatLang(); // load translations FIRST
await loadChannels();
document.getElementById("channel-filter").addEventListener("change", async (e) => {
selectedChannel = e.target.value;
resetChat();
await fetchInitial();
});
await fetchInitial(); // then fetch initial packets
setInterval(fetchUpdates, 5000);
+64 -8
View File
@@ -11,6 +11,12 @@
font-size: 0.9rem;
border-radius: 6px;
}
.firehose-controls {
gap: 12px;
}
.firehose-filter {
min-width: 220px;
}
.port-tag {
display: inline-block;
@@ -89,15 +95,22 @@
{% block body %}
<div class="container">
<form class="d-flex align-items-center justify-content-between mb-3">
<form class="d-flex align-items-center justify-content-between mb-3 firehose-controls">
<h2 class="mb-0" data-translate-lang="live_feed">📡 Live Feed</h2>
<button type="button"
id="pause-button"
class="btn btn-sm btn-outline-secondary"
data-translate-lang="pause">
Pause
</button>
<div class="d-flex align-items-center gap-2">
<label for="channel-filter" class="mb-0" data-translate-lang="channel">Channel</label>
<select id="channel-filter" class="form-select form-select-sm firehose-filter">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
<button type="button"
id="pause-button"
class="btn btn-sm btn-outline-secondary"
data-translate-lang="pause">
Pause
</button>
</div>
</form>
<table class="packet-table">
@@ -107,6 +120,7 @@
<th data-translate-lang="packet_id">Packet ID</th>
<th data-translate-lang="from">From</th>
<th data-translate-lang="to">To</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="port">Port</th>
</tr>
</thead>
@@ -240,6 +254,7 @@ function logPacketTimes(packet) {
let lastImportTimeUs = null;
let updatesPaused = false;
let updateInterval = 3000;
let selectedChannel = "";
async function configureFirehose() {
try {
@@ -249,6 +264,36 @@ async function configureFirehose() {
} catch {}
}
async function loadChannels() {
try {
const res = await fetch("/api/channels");
if (!res.ok) return;
const data = await res.json();
const select = document.getElementById("channel-filter");
const channels = data.channels || [];
for (const channel of channels) {
const option = document.createElement("option");
option.value = channel;
option.textContent = channel;
select.appendChild(option);
}
} catch (err) {
console.error("Failed loading channels:", err);
}
}
function resetFirehose() {
lastImportTimeUs = null;
document.getElementById("packet_list").innerHTML = "";
}
function matchesSelectedChannel(packet) {
if (!selectedChannel) return true;
return (packet.channel || "").toLowerCase() === selectedChannel.toLowerCase();
}
async function fetchUpdates() {
if (updatesPaused) return;
@@ -269,6 +314,8 @@ async function fetchUpdates() {
const list = document.getElementById("packet_list");
for (const pkt of packets.reverse()) {
if (!matchesSelectedChannel(pkt)) continue;
logPacketTimes(pkt);
/* FROM — includes translation */
@@ -343,12 +390,13 @@ async function fetchUpdates() {
<td>${from}</td>
<td>${to}</td>
<td>${pkt.channel || "—"}</td>
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
</tr>
<tr class="payload-row">
<td colspan="5" class="payload-cell">${safePayload}</td>
<td colspan="6" class="payload-cell">${safePayload}</td>
</tr>
`;
@@ -371,6 +419,7 @@ async function fetchUpdates() {
document.addEventListener("DOMContentLoaded", async () => {
const pauseBtn = document.getElementById("pause-button");
const channelFilter = document.getElementById("channel-filter");
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
@@ -381,6 +430,12 @@ document.addEventListener("DOMContentLoaded", async () => {
: (firehoseTranslations.pause || "Pause");
});
channelFilter.addEventListener("change", async (e) => {
selectedChannel = e.target.value;
resetFirehose();
await fetchUpdates();
});
document.addEventListener("click", e => {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
@@ -395,6 +450,7 @@ document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsFirehose();
await configureFirehose();
await loadNodes();
await loadChannels();
fetchUpdates();
setInterval(fetchUpdates, updateInterval);
+271 -25
View File
@@ -23,13 +23,89 @@
#reset-filters-button:hover { background-color:#da190b; }
#reset-filters-button:active { background-color:#c41e0d; }
.blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; }
.blinking-tooltip {
background: white;
color: black;
border: 1px solid #111;
border-radius: 6px;
padding: 6px 10px;
font-size: 14px;
font-weight: 400;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.blinking-tooltip.text-packet {
animation: textPulse 1.1s ease-in-out 6;
border-color: #ff8c00;
}
@keyframes textPulse {
0% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
50% { box-shadow: 0 4px 14px rgba(255,140,0,0.45); }
100% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
}
#map-wrapper {
position: relative;
width: 100%;
height: calc(100vh - 270px);
}
#map {
width: 100%;
height: 100%;
}
#unmapped-packets {
position: absolute;
bottom: 30px;
right: 15px;
z-index: 600;
width: 220px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #ddd;
border-radius: 6px;
font-size: 12px;
text-align: left;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
pointer-events: auto;
}
#unmapped-packets h3 {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
color: #000;
}
#unmapped-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 120px;
overflow-y: auto;
}
#unmapped-list li {
display: flex;
gap: 6px;
padding: 3px 0;
border-bottom: 1px dotted #e0e0e0;
}
#unmapped-list li:last-child { border-bottom: none; }
.unmapped-node { font-weight: 400; color: #000; }
.unmapped-empty { color: #666; font-style: italic; }
</style>
{% endblock %}
{% block body %}
<div id="map" style="width:100%; height:calc(100vh - 270px)"></div>
<div id="map-wrapper">
<div id="map"></div>
<div id="unmapped-packets">
<h3 data-translate-lang="unmapped_packets_title">Unmapped Packets</h3>
<ul id="unmapped-list">
<li class="unmapped-empty" data-translate-lang="unmapped_packets_empty">
No recent unmapped packets.
</li>
</ul>
</div>
</div>
<div id="map-legend"
class="legend"
@@ -52,6 +128,8 @@
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only">
<span data-translate-lang="show_routers_only">Show Routers Only</span>
<input type="checkbox" class="filter-checkbox" id="filter-mqtt-only">
<span data-translate-lang="show_mqtt_only">Show MQTT Gateways Only</span>
</div>
<div style="text-align:center;margin-top:5px;">
@@ -70,6 +148,7 @@
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js"
integrity="sha384-FhPn/2P/fJGhQLeNWDn9B/2Gml2bPOrKJwFqJXgR3xOPYxWg5mYQ5XZdhUSugZT0"
crossorigin></script>
<script src="/static/portmaps.js"></script>
<script>
/* ======================================================
@@ -117,16 +196,11 @@ var nodes = [], markers = {}, markerById = {}, nodeMap = new Map();
var edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var mapInterval = 0;
var unmappedPackets = [];
const UNMAPPED_LIMIT = 50;
const UNMAPPED_TTL_MS = 5000;
const portMap = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
};
const portMap = window.PORT_LABEL_MAP;
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe",
"#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1",
@@ -154,11 +228,75 @@ function hashToColor(str){
return c;
}
function labelFor(key, fallback){
return mapTranslations[key] || fallback;
}
function buildNodePopup(node){
const labels = {
channel: labelFor("channel_label", "Channel"),
model: labelFor("model_label", "Model"),
role: labelFor("role_label", "Role"),
mqtt: labelFor("mqtt_gateway", "MQTT Gateway"),
lastSeen: labelFor("last_seen", "Last Seen"),
firmware: labelFor("firmware", "Firmware"),
yes: mapTranslations.yes || "Yes",
no: mapTranslations.no || "No"
};
return `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b>${labels.channel}</b> ${node.channel}<br>
<b>${labels.model}</b> ${node.hw_model}<br>
<b>${labels.role}</b> ${node.role}<br>
<b>${labels.mqtt}</b> ${node.is_mqtt_gateway ? labels.yes : labels.no}<br>
${
node.last_seen_us
? `<b>${labels.lastSeen}</b> ${timeAgoFromUs(node.last_seen_us)}<br>`
: ""
}
${
node.firmware
? `<b>${labels.firmware}</b> ${node.firmware}<br>`
: ""
}
`;
}
function hashToUnit(str){
let h = 2166136261;
for(let i=0;i<str.length;i++){
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) / 0xffffffff;
}
function jitterLatLng(lat, lon, key){
const meters = 15; // small, visually separates overlaps
const angle = hashToUnit(String(key)) * Math.PI * 2;
const r = meters * (0.3 + 0.7 * hashToUnit(`r:${key}`));
const dLat = (r * Math.cos(angle)) / 111320;
const dLon = (r * Math.sin(angle)) / (111320 * Math.cos(lat * Math.PI / 180));
return [lat + dLat, lon + dLon];
}
function isInvalidCoord(n){
return !n || !n.lat || !n.long || n.lat === 0 || n.long === 0 ||
Number.isNaN(n.lat) || Number.isNaN(n.long);
}
function getNodeLatLng(n){
const marker = markerById[n.key];
if(marker){
return marker.getLatLng();
}
return { lat: n.lat, lng: n.long };
}
/* ======================================================
PACKET FETCHING (unchanged)
====================================================== */
@@ -191,7 +329,11 @@ function fetchNewPackets(){
const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
if(marker && nodeData) {
blinkNode(marker, nodeData.long_name, pkt.portnum, pkt.payload);
} else {
addUnmappedPacket(pkt, nodeData);
}
});
lastImportTime = latest;
@@ -290,6 +432,7 @@ fetch('/api/nodes?days_active=3')
role: n.role || "",
firmware: n.firmware || "",
last_seen_us: n.last_seen_us || null,
is_mqtt_gateway: n.is_mqtt_gateway === true,
isRouter: (n.role||"").toLowerCase().includes("router")
}));
@@ -313,7 +456,8 @@ function renderNodesOnMap(){
const color = hashToColor(node.channel);
const marker = L.circleMarker([node.lat,node.long], {
const [jLat, jLon] = jitterLatLng(node.lat, node.long, node.key);
const marker = L.circleMarker([jLat,jLon], {
radius: node.isRouter ? 9 : 7,
color: "white",
fillColor: color,
@@ -326,34 +470,102 @@ function renderNodesOnMap(){
markerById[node.key] = marker;
const popup = `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})<br>
<b data-translate-lang="channel_label"></b> ${node.channel}<br>
<b data-translate-lang="model_label"></b> ${node.hw_model}<br>
<b data-translate-lang="role_label"></b> ${node.role}<br>
<span data-translate-lang="channel_label"></span> ${node.channel}<br>
<span data-translate-lang="model_label"></span> ${node.hw_model}<br>
<span data-translate-lang="role_label"></span> ${node.role}<br>
<span data-translate-lang="mqtt_gateway"></span> ${
node.is_mqtt_gateway ? (mapTranslations.yes || "Yes") : (mapTranslations.no || "No")
}<br>
${
node.last_seen_us
? `<b data-translate-lang="last_seen"></b> ${timeAgoFromUs(node.last_seen_us)}<br>`
? `<span data-translate-lang="last_seen"></span> ${timeAgoFromUs(node.last_seen_us)}<br>`
: ""
}
${
node.firmware
? `<b data-translate-lang="firmware"></b> ${node.firmware}<br>`
? `<span data-translate-lang="firmware"></span> ${node.firmware}<br>`
: ""
}
`;
marker.on('click', () => {
onNodeClick(node);
marker.bindPopup(popup).openPopup();
marker.setPopupContent(buildNodePopup(node));
marker.openPopup();
});
});
setTimeout(() => applyTranslationsMap(), 50);
}
/* ======================================================
UNMAPPED PACKETS LIST
====================================================== */
function addUnmappedPacket(pkt, nodeData){
if(nodeData && !isInvalidCoord(nodeData)) return;
const now = Date.now();
const entry = {
id: pkt.id,
key: `${pkt.id ?? "x"}-${pkt.import_time_us ?? now}-${Math.random().toString(16).slice(2)}`,
import_time_us: pkt.import_time_us || 0,
from_node_id: pkt.from_node_id,
long_name: pkt.long_name || (nodeData?.long_name || ""),
portnum: pkt.portnum,
payload: (pkt.payload || "").trim(),
expires_at: now + UNMAPPED_TTL_MS
};
unmappedPackets.unshift(entry);
pruneUnmappedPackets(now);
if(unmappedPackets.length > UNMAPPED_LIMIT){
unmappedPackets = unmappedPackets.slice(0, UNMAPPED_LIMIT);
}
renderUnmappedPackets();
setTimeout(() => {
pruneUnmappedPackets(Date.now());
renderUnmappedPackets();
}, UNMAPPED_TTL_MS + 50);
}
function pruneUnmappedPackets(now){
unmappedPackets = unmappedPackets.filter(p => p.expires_at > now);
}
function renderUnmappedPackets(){
pruneUnmappedPackets(Date.now());
const list = document.getElementById("unmapped-list");
list.innerHTML = "";
if(unmappedPackets.length === 0){
const empty = document.createElement("li");
empty.className = "unmapped-empty";
empty.dataset.translateLang = "unmapped_packets_empty";
empty.textContent = "No recent unmapped packets.";
list.appendChild(empty);
return;
}
unmappedPackets.forEach(p=>{
const li = document.createElement("li");
const node = document.createElement("span");
node.className = "unmapped-node";
const type = portMap[p.portnum] || `Port ${p.portnum ?? "?"}`;
const name = p.long_name || `Node ${p.from_node_id ?? "?"}`;
node.textContent = `${name} (${type})`;
li.appendChild(node);
list.appendChild(li);
});
}
/* ======================================================
⭐ NEW: DYNAMIC EDGE LOADING
====================================================== */
@@ -374,7 +586,9 @@ async function onNodeClick(node){
if(!f || !t || isInvalidCoord(f) || isInvalidCoord(t)) return;
const color = edge.type === "neighbor" ? "gray" : "orange";
const line = L.polyline([[f.lat, f.long], [t.lat, t.long]], {
const fLatLng = getNodeLatLng(f);
const tLatLng = getNodeLatLng(t);
const line = L.polyline([[fLatLng.lat, fLatLng.lng], [tLatLng.lat, tLatLng.lng]], {
color, weight: 3
}).addTo(edgeLayer);
@@ -411,7 +625,20 @@ map.on('click', e=>{
BLINKING
====================================================== */
function blinkNode(marker,longName,portnum){
function escapeHtml(value){
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function isTextPort(portnum){
return portnum === 1 || portnum === 7;
}
function blinkNode(marker,longName,portnum,payload){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){
@@ -421,13 +648,24 @@ function blinkNode(marker,longName,portnum){
}
let blinkCount = 0;
const blinkStart = Date.now();
const blinkDurationMs = 7000;
const safeName = escapeHtml(longName);
const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`;
const payloadText = (payload || "").trim();
const showPayload = isTextPort(portnum) && payloadText && payloadText !== "Did not decode";
const shortPayload = showPayload && payloadText.length > 80
? `${payloadText.slice(0, 77)}...`
: payloadText;
const payloadLine = showPayload ? `<br><span>${escapeHtml(shortPayload)}</span>` : "";
const tooltip = L.tooltip({
permanent:true,
direction:'top',
offset:[0,-marker.options.radius-5],
className:'blinking-tooltip'
className: isTextPort(portnum) ? 'blinking-tooltip text-packet' : 'blinking-tooltip'
})
.setContent(`${longName} (${portMap[portnum] || "Port "+portnum})`)
.setContent(`${safeName} (${escapeHtml(portLabel)})${payloadLine}`)
.setLatLng(marker.getLatLng())
.addTo(map);
@@ -442,7 +680,7 @@ function blinkNode(marker,longName,portnum){
}
blinkCount++;
if(blinkCount>7){
if(Date.now() - blinkStart > blinkDurationMs){
clearInterval(interval);
marker.setStyle({ fillColor: marker.originalColor });
map.removeLayer(tooltip);
@@ -482,10 +720,14 @@ function createChannelFilters(){
});
const routerOnly=document.getElementById("filter-routers-only");
const mqttOnly=document.getElementById("filter-mqtt-only");
routerOnly.checked = saved["routersOnly"] || false;
mqttOnly.checked = saved["mqttOnly"] || false;
routerOnly.addEventListener("change", saveFiltersToLocalStorage);
routerOnly.addEventListener("change", updateNodeVisibility);
mqttOnly.addEventListener("change", saveFiltersToLocalStorage);
mqttOnly.addEventListener("change", updateNodeVisibility);
updateNodeVisibility();
}
@@ -496,12 +738,14 @@ function saveFiltersToLocalStorage(){
state[ch] = document.getElementById(`filter-channel-${ch}`).checked;
});
state["routersOnly"] = document.getElementById("filter-routers-only").checked;
state["mqttOnly"] = document.getElementById("filter-mqtt-only").checked;
localStorage.setItem("mapFilters", JSON.stringify(state));
}
function updateNodeVisibility(){
const routerOnly = document.getElementById("filter-routers-only").checked;
const mqttOnly = document.getElementById("filter-mqtt-only").checked;
const activeChannels = [...channelSet].filter(ch =>
document.getElementById(`filter-channel-${ch}`).checked
);
@@ -511,6 +755,7 @@ function updateNodeVisibility(){
if(marker){
const visible =
(!routerOnly || n.isRouter) &&
(!mqttOnly || n.is_mqtt_gateway) &&
activeChannels.includes(n.channel);
visible ? map.addLayer(marker) : map.removeLayer(marker);
@@ -541,6 +786,7 @@ function shareCurrentView() {
function resetFiltersToDefaults(){
document.getElementById("filter-routers-only").checked = false;
document.getElementById("filter-mqtt-only").checked = false;
channelSet.forEach(ch => {
document.getElementById(`filter-channel-${ch}`).checked = true;
});
+20 -2
View File
@@ -25,6 +25,7 @@
#weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; }
#total-count { margin-bottom: 10px; font-style: italic; color: #ccc; }
#net-note { margin: 16px 0 0; font-size: 0.9rem; color: #aaa; }
{% endblock %}
{% block body %}
@@ -50,6 +51,10 @@
<div id="chat-container">
<div class="container" id="chat-log"></div>
</div>
<div id="net-note">
Only the earliest report from each device is listed.
</div>
</div>
<script>
@@ -65,6 +70,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
const renderedPacketIds = new Set();
const renderedNodeIds = new Set();
let netTranslations = {};
let netTag = "";
@@ -95,7 +101,7 @@ document.addEventListener("DOMContentLoaded", async () => {
Update count
----------------------------------- */
function updateTotalCount() {
totalCountValueEl.textContent = renderedPacketIds.size;
totalCountValueEl.textContent = renderedNodeIds.size;
}
/* -----------------------------------
@@ -104,6 +110,7 @@ document.addEventListener("DOMContentLoaded", async () => {
function renderPacket(packet) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
renderedNodeIds.add(packet.from_node_id);
const date = new Date(packet.import_time_us / 1000);
@@ -161,7 +168,18 @@ document.addEventListener("DOMContentLoaded", async () => {
----------------------------------- */
function renderPacketsEnsureDescending(packets) {
if (!packets || !packets.length) return;
const sorted = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
const earliestByNode = new Map();
for (const packet of packets) {
const existing = earliestByNode.get(packet.from_node_id);
if (!existing || packet.import_time_us < existing.import_time_us) {
earliestByNode.set(packet.from_node_id, packet);
}
}
const sorted = Array.from(earliestByNode.values()).sort(
(a, b) => b.import_time_us - a.import_time_us
);
for (let i = sorted.length - 1; i >= 0; i--) {
renderPacket(sorted[i]);
}
+482 -44
View File
@@ -18,21 +18,23 @@
/* --- Node Info --- */
.node-info {
background-color: #1f2226;
border: 1px solid #3a3f44;
color: #ddd;
font-size: 0.88rem;
padding: 12px 14px;
margin-bottom: 14px;
border-radius: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
grid-column-gap: 14px;
grid-row-gap: 6px;
grid-column-gap: 12px;
grid-row-gap: 12px;
}
.node-info div { padding: 2px 0; }
.node-info-col {
background-color: #1f2226;
border: 1px solid #3a3f44;
border-radius: 8px;
padding: 12px 14px;
}
.node-info-col div { padding: 2px 0; }
.node-info strong {
color: #9fd4ff;
font-weight: 600;
@@ -131,6 +133,195 @@
color: #9fd4ff;
}
.inline-link:hover { color: #c7e6ff; }
/* --- QR Code & Import --- */
.node-actions {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.node-actions {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.node-actions button {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border: 1px solid #4a5568;
border-radius: 8px;
color: #e4e9ee;
padding: 8px 16px;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
transition: all 0.2s;
}
.node-actions button:hover {
background: linear-gradient(135deg, #3d4758 0%, #2a303c 100%);
border-color: #6a7788;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.node-actions button.copied {
background: linear-gradient(135deg, #276749 0%, #22543d 100%);
border-color: #48bb78;
color: #fff;
}
.copy-success {
color: #4ade80 !important;
transition: opacity 0.3s;
}
/* --- QR Modal --- */
#qrModal {
display:none;
position:fixed;
top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.95);
z-index:10000;
align-items:center;
justify-content:center;
backdrop-filter:blur(4px);
}
#qrModal > div {
background:linear-gradient(145deg, #1e2228, #16191d);
border:1px solid #3a4450;
border-radius:16px;
padding:28px;
max-width:380px;
text-align:center;
color:#e4e9ee;
box-shadow:0 25px 80px rgba(0,0,0,0.6);
}
#qrModal .qr-header {
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:16px;
}
#qrModal .qr-title {
font-size:1.3rem;
font-weight:600;
margin:0;
color:#9fd4ff;
}
#qrModal .qr-close {
background:rgba(255,255,255,0.05);
border:1px solid #4a5568;
color:#9ca3af;
width:32px;
height:32px;
border-radius:8px;
cursor:pointer;
font-size:1.2rem;
display:flex;
align-items:center;
justify-content:center;
transition:all 0.2s;
}
#qrModal .qr-close:hover {
background:rgba(255,255,255,0.1);
color:#fff;
border-color:#6a7788;
}
#qrModal .qr-node-name {
font-size:1.15rem;
color:#fff;
margin:12px 0 20px;
font-weight:500;
}
#qrModal .qr-image {
background:#fff;
padding:16px;
border-radius:12px;
display:inline-block;
margin-bottom:16px;
box-shadow:0 8px 30px rgba(0,0,0,0.4);
}
#qrModal .qr-image img {
display:block;
border-radius:4px;
}
#qrModal .qr-url-container {
background:rgba(0,0,0,0.4);
border-radius:8px;
padding:12px;
margin-bottom:18px;
}
#qrModal .qr-url {
font-size:0.65rem;
color:#9ca3af;
word-break:break-all;
font-family:'Monaco', 'Menlo', monospace;
line-height:1.4;
max-height:48px;
overflow-y:auto;
display:block;
}
#qrModal .qr-actions {
display:flex;
gap:12px;
justify-content:center;
}
#qrModal .qr-btn {
background:linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border:1px solid #4a5568;
color:#e4e9ee;
padding:12px 24px;
border-radius:10px;
cursor:pointer;
font-size:0.9rem;
font-weight:500;
transition:all 0.2s;
display:flex;
align-items:center;
gap:8px;
min-width:140px;
justify-content:center;
}
#qrModal .qr-btn:hover {
background:linear-gradient(135deg, #3d4758 0%, #2a303c 100%);
border-color:#6a7788;
transform:translateY(-2px);
box-shadow:0 4px 12px rgba(0,0,0,0.3);
}
#qrModal .qr-btn.copied {
background:linear-gradient(135deg, #276749 0%, #22543d 100%);
border-color:#48bb78;
color:#fff;
}
/* --- Impersonation Warning --- */
.impersonation-warning {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.impersonation-warning .warning-icon {
font-size: 1.2rem;
}
.impersonation-warning .warning-content {
flex: 1;
}
.impersonation-warning .warning-title {
color: #f87171;
font-weight: bold;
margin-bottom: 4px;
}
.impersonation-warning .warning-text {
font-size: 0.85rem;
color: #ccc;
}
{% endblock %}
{% block body %}
@@ -141,31 +332,59 @@
<span id="nodeLabel"></span>
</h5>
<!-- Node Actions -->
<div class="node-actions" id="nodeActions" style="display:none;">
<button onclick="copyImportUrl()" id="copyUrlBtn">
<span>📋</span> <span data-translate-lang="copy_import_url">Copy Import URL</span>
</button>
<button onclick="showQrCode()" id="showQrBtn">
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
</button>
<button onclick="toggleCoverage()" id="toggleCoverageBtn" disabled title="Location required for coverage">
<span>📡</span> <span data-translate-lang="toggle_coverage">Predicted Coverage</span>
</button>
<a class="inline-link" id="coverageHelpLink" href="/docs/COVERAGE.md" target="_blank" rel="noopener" data-translate-lang="coverage_help">
Coverage Help
</a>
</div>
<!-- Impersonation Warning -->
<div id="impersonationWarning" class="impersonation-warning" style="display:none;">
<span class="warning-icon">⚠️</span>
<div class="warning-content">
<div class="warning-title" data-translate-lang="potential_impersonation">Potential Impersonation Detected</div>
<div class="warning-text" id="impersonationText"></div>
</div>
</div>
<!-- Node Info -->
<div id="node-info" class="node-info">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div>
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div>
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div>
<div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
<span id="info-stats"
data-label-24h="24h"
data-label-sent="Packets sent"
data-label-seen="Times seen">—</span>
<div class="node-info-col">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div>
</div>
<div class="node-info-col">
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway"></span></div>
</div>
<div class="node-info-col">
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update"></span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div>
<div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
<span id="info-stats"
data-label-24h="24h"
data-label-sent="Packets sent"
data-label-seen="Times seen">—</span>
</div>
</div>
</div>
<!-- Map. -->
@@ -284,7 +503,31 @@
</div>
</div>
<!-- QR Code Modal -->
<div id="qrModal">
<div>
<div class="qr-header">
<h3 class="qr-title" data-translate-lang="share_contact_qr">Share Contact QR</h3>
<button class="qr-close" onclick="closeQrModal()"></button>
</div>
<div class="qr-node-name" id="qrNodeName">Loading...</div>
<div class="qr-image">
<div id="qrCodeContainer"></div>
</div>
<div class="qr-url-container">
<span class="qr-url" id="qrUrl">Generating...</span>
</div>
<div class="qr-actions">
<button class="qr-btn" onclick="copyQrUrl()" id="copyQrBtn">
<span>📋</span> <span data-translate-lang="copy_url">Copy URL</span>
</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
<script src="/static/portmaps.js"></script>
<script>
@@ -364,6 +607,10 @@ function makeNodePopup(node) {
<b><span data-translate-lang="last_update">
${nodeTranslations.last_update || "Last Update"}:
</span></b> ${formatLastSeen(node.last_seen_us)}
<br>
<b><span data-translate-lang="first_update">
${nodeTranslations.first_update || "First Update"}:
</span></b> ${formatLastSeen(node.first_seen_us)}
</div>
`;
}
@@ -399,6 +646,7 @@ let currentNode = null;
let currentPacketRows = [];
let map, markers = {};
let coverageLayer = null;
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
@@ -466,17 +714,38 @@ async function loadNodeInfo(){
document.getElementById("info-hw-model").textContent = node.hw_model ?? "—";
document.getElementById("info-firmware").textContent = node.firmware ?? "—";
document.getElementById("info-role").textContent = node.role ?? "—";
document.getElementById("info-mqtt-gateway").textContent =
node.is_mqtt_gateway ? (nodeTranslations.yes || "Yes") : (nodeTranslations.no || "No");
document.getElementById("info-channel").textContent = node.channel ?? "—";
document.getElementById("info-lat").textContent =
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
document.getElementById("info-lon").textContent =
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
const coverageBtn = document.getElementById("toggleCoverageBtn");
const coverageHelp = document.getElementById("coverageHelpLink");
if (coverageBtn) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageBtn.disabled = !hasLocation;
coverageBtn.title = hasLocation
? ""
: (nodeTranslations.location_required || "Location required for coverage");
coverageBtn.style.display = hasLocation ? "" : "none";
}
if (coverageHelp) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageHelp.style.display = hasLocation ? "" : "none";
}
let lastSeen = "—";
if (node.last_seen_us) {
lastSeen = formatLastSeen(node.last_seen_us);
}
let firstSeen = "—";
if (node.first_seen_us) {
firstSeen = formatLastSeen(node.first_seen_us);
}
document.getElementById("info-first-update").textContent = firstSeen;
document.getElementById("info-last-update").textContent = lastSeen;
loadNodeStats(node.node_id);
} catch (err) {
@@ -558,6 +827,44 @@ function initMap(){
}).addTo(map);
}
async function toggleCoverage() {
if (!map) initMap();
if (coverageLayer) {
map.removeLayer(coverageLayer);
coverageLayer = null;
return;
}
const nodeId = currentNode?.node_id || fromNodeId;
if (!nodeId) return;
try {
const res = await fetch(`/api/coverage/${encodeURIComponent(nodeId)}?mode=perimeter`);
if (!res.ok) {
console.error("Coverage request failed", res.status);
return;
}
const data = await res.json();
if (!data.perimeter || data.perimeter.length < 3) {
console.warn("Coverage perimeter missing or too small");
return;
}
coverageLayer = L.polygon(data.perimeter, {
color: "#6f42c1",
weight: 2,
opacity: 0.7,
fillColor: "#000000",
fillOpacity: 0.10
}).addTo(map);
map.fitBounds(coverageLayer.getBounds(), { padding: [20, 20] });
map.invalidateSize();
} catch (err) {
console.error("Coverage request failed", err);
}
}
function hideMap(){
const mapDiv = document.getElementById("map");
if (mapDiv) {
@@ -591,13 +898,21 @@ function addMarker(id, lat, lon, color = "red", node = null) {
async function drawNeighbors(src, nids) {
if (!map) return;
// Ensure source node position exists
const srcNode = await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
// Prefer the currently displayed source position (e.g. latest track point),
// then fall back to the node API location.
let srcLat;
let srcLon;
let srcNode = currentNode || nodeCache[src] || null;
const srcLat = srcNode.last_lat / 1e7;
const srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
if (nodePositions[src]) {
[srcLat, srcLon] = nodePositions[src];
} else {
srcNode = srcNode || await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
srcLat = srcNode.last_lat / 1e7;
srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
}
for (const nid of nids) {
const neighbor = await fetchNodeFromApi(nid);
@@ -1135,13 +1450,20 @@ async function loadPacketHistogram() {
const DAYS = 7;
const now = new Date();
const dayKeyFromDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const dayKeys = [];
const dayLabels = [];
for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
dayKeys.push(d.toISOString().slice(0, 10));
dayKeys.push(dayKeyFromDate(d));
dayLabels.push(
d.toLocaleDateString([], { month: "short", day: "numeric" })
);
@@ -1169,9 +1491,7 @@ async function loadPacketHistogram() {
for (const pkt of packets) {
if (!pkt.import_time_us) continue;
const day = new Date(pkt.import_time_us / 1000)
.toISOString()
.slice(0, 10);
const day = dayKeyFromDate(new Date(pkt.import_time_us / 1000));
if (!dayKeys.includes(day)) continue;
@@ -1309,18 +1629,22 @@ document.addEventListener("DOMContentLoaded", async () => {
requestAnimationFrame(async () => {
await loadNodeInfo();
// Load QR code URL and impersonation check
await loadNodeQrAndImpersonation();
// ✅ MAP MUST EXIST FIRST
if (!map) initMap();
// Load the track first so neighbor links anchor to the same
// visible current-node position shown on this page.
await loadTrack();
// ✅ DRAW LATEST NEIGHBORS ONCE
const neighborIds = await loadLatestNeighborIds();
if (neighborIds.length) {
await drawNeighbors(fromNodeId, neighborIds);
}
// ⚠️ Track may add to map, but must not hide it
await loadTrack();
await loadPackets();
initPacketPortFilter();
await loadTelemetryCharts();
@@ -1430,12 +1754,126 @@ async function loadNodeStats(nodeId) {
const csv = rows.map(r => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const link = document.createElement("a");
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `packets_${fromNodeId}_${Date.now()}.csv`;
link.click();
}
/* ======================================================
QR CODE & IMPORT URL
====================================================== */
let currentMeshtasticUrl = "";
async function loadNodeQrAndImpersonation() {
const actionsDiv = document.getElementById("nodeActions");
const warningDiv = document.getElementById("impersonationWarning");
try {
const [qrRes, impRes] = await Promise.all([
fetch(`/api/node/${fromNodeId}/qr`),
fetch(`/api/node/${fromNodeId}/impersonation-check`)
]);
const qrData = await qrRes.json();
if (qrRes.ok && qrData.meshtastic_url) {
currentMeshtasticUrl = qrData.meshtastic_url;
actionsDiv.style.display = "flex";
} else {
actionsDiv.style.display = "none";
}
const impData = await impRes.json();
if (impRes.ok && impData.potential_impersonation) {
warningDiv.style.display = "flex";
document.getElementById("impersonationText").textContent =
impData.warning || `This node has sent ${impData.unique_public_key_count} different public keys. This could indicate impersonation.`;
} else {
warningDiv.style.display = "none";
}
} catch (err) {
console.error("Failed to load QR/impersonation data:", err);
actionsDiv.style.display = "none";
warningDiv.style.display = "none";
}
}
function copyImportUrl() {
if (!currentMeshtasticUrl) return;
navigator.clipboard.writeText(currentMeshtasticUrl).then(() => {
const btn = document.getElementById("copyUrlBtn");
const originalText = btn.innerHTML;
btn.innerHTML = '<span></span> <span data-translate-lang="copied">Copied!</span>';
btn.classList.add("copy-success");
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove("copy-success");
}, 2000);
}).catch(err => {
console.error("Failed to copy:", err);
alert("Failed to copy URL to clipboard");
});
}
function showQrCode() {
if (!currentMeshtasticUrl) return;
const node = currentNode;
document.getElementById("qrNodeName").textContent =
node && node.long_name ? node.long_name : `Node ${fromNodeId}`;
document.getElementById("qrUrl").textContent = currentMeshtasticUrl;
generateQrCode(currentMeshtasticUrl);
document.getElementById("qrModal").style.display = "flex";
}
function closeQrModal() {
document.getElementById("qrModal").style.display = "none";
}
function copyQrUrl() {
navigator.clipboard.writeText(currentMeshtasticUrl).then(() => {
const btn = document.getElementById("copyQrBtn");
const originalHTML = btn.innerHTML;
btn.innerHTML = '<span></span> <span data-translate-lang="copied">Copied!</span>';
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove("copied");
}, 2000);
}).catch(err => {
console.error("Failed to copy:", err);
});
}
function generateQrCode(text) {
const container = document.getElementById("qrCodeContainer");
if (!container) return;
container.innerHTML = "";
try {
new QRCode(container, {
text: text,
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
} catch (e) {
console.error("QR Code generation error:", e);
container.innerHTML = '<div style="padding:20px;color:#f87171;">Failed to generate QR code</div>';
}
}
/* ======================================================
END QR CODE & IMPORT URL
====================================================== */
</script>
+11 -4
View File
@@ -266,13 +266,14 @@ select, .export-btn, .search-box, .clear-btn {
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
<th data-translate-lang="mqtt_gateway">MQTT <span class="sort-icon"></span></th>
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
<th data-translate-lang="favorite"></th>
</tr>
</thead>
<tbody id="node-table-body">
<tr>
<td colspan="10" style="text-align:center; color:white;" data-translate-lang="loading_nodes">
<td colspan="11" style="text-align:center; color:white;" data-translate-lang="loading_nodes">
Loading nodes...
</td>
</tr>
@@ -330,7 +331,7 @@ const minStatusMs = 300;
const headers = document.querySelectorAll("thead th");
const keyMap = [
"short_name","long_name","hw_model","firmware","role",
"last_lat","last_long","channel","last_seen_us"
"last_lat","last_long","channel","is_mqtt_gateway","last_seen_us"
];
function debounce(fn, delay = 250) {
@@ -448,7 +449,7 @@ document.addEventListener("DOMContentLoaded", async function() {
setStatus("");
} catch (err) {
tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:red;">
<td colspan="11" style="text-align:center; color:red;">
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
</td></tr>`;
setStatus("");
@@ -583,7 +584,7 @@ document.addEventListener("DOMContentLoaded", async function() {
if (!nodes.length) {
if (shouldRenderTable) {
tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:white;">
<td colspan="11" style="text-align:center; color:white;">
${nodelistTranslations.no_nodes_found || "No nodes found"}
</td>
</tr>`;
@@ -613,6 +614,7 @@ document.addEventListener("DOMContentLoaded", async function() {
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.channel || "N/A"}</td>
<td>${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")}</td>
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
<td style="text-align:center;">
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
@@ -715,6 +717,11 @@ document.addEventListener("DOMContentLoaded", async function() {
B = B || 0;
}
if (key === "is_mqtt_gateway") {
A = A ? 1 : 0;
B = B ? 1 : 0;
}
// Normalize strings for stable sorting
if (typeof A === "string") A = A.toLowerCase();
if (typeof B === "string") B = B.toLowerCase();
+13 -13
View File
@@ -2,6 +2,10 @@
{% block title %}Packet Details{% endblock %}
{% block head %}
<script src="/static/portmaps.js"></script>
{% endblock %}
{% block css %}
{{ super() }}
<style>
@@ -178,17 +182,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const packetId = match[1];
/* PORT LABELS (NOT TRANSLATED) */
const PORT_NAMES = {
0:"UNKNOWN APP",
1:"Text",
3:"Position",
4:"Node Info",
5:"Routing",
6:"Admin",
67:"Telemetry",
70:"Traceroute",
71:"Neighbor"
};
const PORT_NAMES = window.PORT_LABEL_MAP;
/* ---------------------------------------------
Fetch packet
@@ -414,6 +408,11 @@ seenSorted.forEach(s => {
hopGroups[hopValue].push(s);
});
function formatHopDisplay(hopKey, hopStart){
const startVal = hopStart ?? "—";
return `${hopKey}/${startVal}`;
}
/* ---------------------------------------------
Render grouped gateway table + map markers
----------------------------------------------*/
@@ -430,6 +429,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
const hopDisplay = formatHopDisplay(hopKey, s.hop_start);
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
@@ -488,7 +488,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b data-translate-lang="hops">Hops</b>: ${hopKey}
<b data-translate-lang="hops">Hops</b>: ${hopDisplay}
</div>
`);
@@ -499,7 +499,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
<td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${hopKey}</td>
<td>${hopDisplay}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>
+282 -28
View File
@@ -85,10 +85,20 @@
border:none;
border-radius:4px;
}
.chart-filter {
margin-bottom: 8px;
padding: 4px 6px;
background: #444;
color: #fff;
border: none;
border-radius: 4px;
}
{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="/static/portmaps.js"></script>
{% endblock %}
{% block body %}
@@ -111,17 +121,44 @@
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
<div class="summary-count" id="summary_seen">0</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_gateways">Total Gateways</p>
<div class="summary-count" id="summary_gateways">0</div>
</div>
</div>
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_all">
Packets per Day - All Ports (Last 14 Days)
<p class="section-header" data-translate-lang="daily_snapshot_packets_history">
Daily Packet Snapshot History
</p>
<div id="total_daily_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_all" class="chart"></div>
<select id="snapshotPacketsRange" class="chart-filter" aria-label="Snapshot packet timeframe">
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">1 month</option>
<option value="183">6 months</option>
<option value="365">1 year</option>
<option value="all" selected>All</option>
</select>
<button class="expand-btn" data-chart="chart_daily_snapshot_packets" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot_packets" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot_packets" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="daily_snapshot_nodes_gateways_history">
Daily Node/Gateway Snapshot History
</p>
<select id="snapshotNodesGatewaysRange" class="chart-filter" aria-label="Snapshot node and gateway timeframe">
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">1 month</option>
<option value="183">6 months</option>
<option value="365">1 year</option>
<option value="all" selected>All</option>
</select>
<button class="expand-btn" data-chart="chart_daily_snapshot_nodes_gateways" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot_nodes_gateways" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot_nodes_gateways" class="chart"></div>
</div>
<!-- Packet Types Pie Chart with Channel Selector -->
@@ -189,6 +226,28 @@
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_channel" class="chart"></div>
</div>
<!-- Gateway breakdown charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="gateway_channel_breakdown">Gateway Channel Breakdown</p>
<button class="expand-btn" data-chart="chart_gateway_channel" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_gateway_channel" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_gateway_channel" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="gateway_role_breakdown">Gateway Role Breakdown</p>
<button class="expand-btn" data-chart="chart_gateway_role" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_gateway_role" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_gateway_role" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="gateway_firmware_breakdown">Gateway Firmware Breakdown</p>
<button class="expand-btn" data-chart="chart_gateway_firmware" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_gateway_firmware" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_gateway_firmware" class="chart"></div>
</div>
</div>
<!-- Modal for expanded charts -->
@@ -205,14 +264,7 @@
</div>
<script>
const PORTNUM_LABELS = {
1: "Text Messages",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info"
};
const PORTNUM_LABELS = window.PORT_LABEL_MAP;
// --- Fetch & Processing ---
async function fetchStats(period_type,length,portnum=null,channel=null){
@@ -227,6 +279,15 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
async function fetchDailySnapshots(){
try{
const res=await fetch("/api/snapshots/daily");
if(!res.ok) return [];
const json=await res.json();
return json.data||[];
}catch{return [];}
}
async function fetchNodes(){
try{
const res=await fetch("/api/nodes");
@@ -247,6 +308,18 @@ async function fetchChannels(){
}
}
function buildPacketTypeBreakdownFromSeries(allData, perPortSeries){
const totalAll = (allData || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
const results = perPortSeries.map(({portnum, data}) => ({
portnum,
count: (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0),
}));
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
const other = Math.max(totalAll - trackedTotal,0);
if(other>0) results.push({portnum:"other", count:other});
return results;
}
function processCountField(nodes,field){
const counts={};
nodes.forEach(n=>{
@@ -273,6 +346,22 @@ function prepareTopN(data,n=20){
return top;
}
function filterSnapshotsByRange(data, rangeValue){
if(rangeValue === "all") return data;
const days = Number.parseInt(rangeValue, 10);
if(Number.isNaN(days) || days <= 0 || !data.length) return data;
const latest = data.reduce((maxDate, item) => {
const itemDate = new Date(`${item.date}T00:00:00Z`);
return itemDate > maxDate ? itemDate : maxDate;
}, new Date(`${data[0].date}T00:00:00Z`));
const cutoff = new Date(latest);
cutoff.setUTCDate(cutoff.getUTCDate() - (days - 1));
return data.filter(item => new Date(`${item.date}T00:00:00Z`) >= cutoff);
}
// --- Chart Rendering ---
function renderChart(domId,data,type,color){
const el=document.getElementById(domId);
@@ -345,6 +434,107 @@ function renderPieChart(elId,data,name){
return chart;
}
function makeTelemetryLine(name, color, seriesData) {
return {
name,
type: "line",
smooth: true,
connectNulls: true,
showSymbol: true,
symbol: "circle",
symbolSize: 7,
lineStyle: {
width: 2,
color,
shadowColor: color.replace("1)", "0.35)"),
shadowBlur: 8,
shadowOffsetY: 3
},
itemStyle: {
color,
borderColor: "#000",
borderWidth: 1
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color.replace("1)", "0.5)") },
{ offset: 0.6, color: color.replace("1)", "0.2)") },
{ offset: 1, color: "rgba(0,0,0,0)" }
])
},
data: seriesData
};
}
function renderDailySnapshotPacketsChart(domId,data){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.getInstanceByDom(el) || echarts.init(el);
const labels=data.map(d=>d.date||"");
const packetCounts=data.map(d=>d.packet_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
legend:{
data:['Packets'],
textStyle:{color:'#ccc'}
},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{
type:'category',
data:labels,
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{rotate:45,color:'#ccc'}
},
yAxis:{
type:'value',
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{color:'#ccc'}
},
series:[
makeTelemetryLine("Packets", "rgba(255,214,82,1)", packetCounts)
]
});
return chart;
}
function renderDailySnapshotNodesGatewaysChart(domId,data){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.getInstanceByDom(el) || echarts.init(el);
const labels=data.map(d=>d.date||"");
const nodeCounts=data.map(d=>d.node_count??0);
const gatewayCounts=data.map(d=>d.gateway_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
legend:{
data:['Nodes','Gateways'],
textStyle:{color:'#ccc'}
},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{
type:'category',
data:labels,
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{rotate:45,color:'#ccc'}
},
yAxis:{
type:'value',
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{color:'#ccc'}
},
series:[
makeTelemetryLine("Nodes", "rgba(79,155,255,1)", nodeCounts),
makeTelemetryLine("Gateways", "rgba(138,255,108,1)", gatewayCounts)
]
});
return chart;
}
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
const portnums = [1,3,4,67,70,71];
@@ -367,8 +557,25 @@ async function fetchPacketTypeBreakdown(channel=null) {
// --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1;
let chartDailySnapshotPackets, chartDailySnapshotNodesGateways;
let chartHwModel, chartRole, chartChannel;
let chartGatewayChannel, chartGatewayRole, chartGatewayFirmware;
let chartPacketTypes;
let defaultPacketTypesData = [];
let dailySnapshots = [];
function renderSnapshotCharts(){
const packetsRange = document.getElementById("snapshotPacketsRange")?.value || "all";
const nodesGatewaysRange = document.getElementById("snapshotNodesGatewaysRange")?.value || "all";
chartDailySnapshotPackets = renderDailySnapshotPacketsChart(
"chart_daily_snapshot_packets",
filterSnapshotsByRange(dailySnapshots, packetsRange)
);
chartDailySnapshotNodesGateways = renderDailySnapshotNodesGatewaysChart(
"chart_daily_snapshot_nodes_gateways",
filterSnapshotsByRange(dailySnapshots, nodesGatewaysRange)
);
}
async function init(){
// Channel selector
@@ -381,10 +588,9 @@ async function init(){
select.appendChild(opt);
});
// Daily all ports
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
// Daily snapshot histogram
dailySnapshots = await fetchDailySnapshots();
renderSnapshotCharts();
// Daily port 1
const dailyPort1Data=await fetchStats('day',14,1);
@@ -414,13 +620,36 @@ async function init(){
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
const gateways = nodes.filter(n => n.is_mqtt_gateway);
chartGatewayChannel = renderPieChart(
"chart_gateway_channel",
processCountField(gateways, "channel"),
"Gateway Channel"
);
chartGatewayRole = renderPieChart(
"chart_gateway_role",
processCountField(gateways, "role"),
"Gateway Role"
);
chartGatewayFirmware = renderPieChart(
"chart_gateway_firmware",
processCountField(gateways, "firmware"),
"Gateway Firmware"
);
const summaryNodesEl = document.getElementById("summary_nodes");
if (summaryNodesEl) {
summaryNodesEl.textContent = nodes.length.toLocaleString();
}
const summaryGatewaysEl = document.getElementById("summary_gateways");
if (summaryGatewaysEl) {
summaryGatewaysEl.textContent = gateways.length.toLocaleString();
}
// Packet types pie
const packetTypesData = await fetchPacketTypeBreakdown();
const packetSeries = portnums.map((pn, i) => ({ portnum: pn, data: allData[i] }));
const packetTypesData = buildPacketTypeBreakdownFromSeries(hourlyAllData, packetSeries);
defaultPacketTypesData = packetTypesData;
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
@@ -459,11 +688,16 @@ window.addEventListener('resize',()=>{
chartPortnum67,
chartPortnum70,
chartPortnum71,
chartDailySnapshotPackets,
chartDailySnapshotNodesGateways,
chartDailyAll,
chartDailyPortnum1,
chartHwModel,
chartRole,
chartChannel,
chartGatewayChannel,
chartGatewayRole,
chartGatewayFirmware,
chartPacketTypes
].forEach(c=>c?.resize());
});
@@ -508,10 +742,23 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
const option=chart.getOption();
let rows=[];
if(option.series[0].type==="bar"||option.series[0].type==="line"){
rows.push(["Period","Count"]);
const xData=option.xAxis[0].data;
const yData=option.series[0].data;
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
const series = option.series || [];
if(series.length > 1){
const headers = ["Period", ...series.map(s => s.name || "Series")];
rows.push(headers);
for(let i=0;i<xData.length;i++){
const row=[xData[i]];
series.forEach(s=>{
row.push((s.data && s.data[i]!==undefined) ? s.data[i] : "");
});
rows.push(row);
}
}else{
rows.push(["Period","Count"]);
const yData=series[0].data;
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
}
}
if(option.series[0].type==="pie"){
rows.push(["Name","Value","Percentage"]);
@@ -527,7 +774,7 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
const packetTypesData = await fetchPacketTypeBreakdown(channel);
const packetTypesData = channel ? await fetchPacketTypeBreakdown(channel) : defaultPacketTypesData;
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
@@ -540,6 +787,9 @@ document.getElementById("channelSelect").addEventListener("change", async (e)=>{
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
});
document.getElementById("snapshotPacketsRange")?.addEventListener("change", renderSnapshotCharts);
document.getElementById("snapshotNodesGatewaysRange")?.addEventListener("change", renderSnapshotCharts);
// Kick everything off
init();
@@ -547,10 +797,14 @@ init();
async function loadConfigAndTranslations() {
let langCode = "en";
try {
const resConfig = await fetch("/api/config");
const cfg = await resConfig.json();
window.site_config = cfg;
langCode = cfg?.site?.language || "en";
if (window.site_config?.site) {
langCode = window.site_config.site.language || "en";
} else {
const resConfig = await fetch("/api/config");
const cfg = await resConfig.json();
window.site_config = cfg;
langCode = cfg?.site?.language || "en";
}
} catch(err) {
console.error("Failed to load /api/config:", err);
window.site_config = { site: { language: "en" } };
+138
View File
@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block css %}
#traceroute-graph {
width: 100%;
height: 85vh;
border: 1px solid #2a2f36;
background: linear-gradient(135deg, #0f1216 0%, #171b22 100%);
border-radius: 10px;
}
#traceroute-meta {
padding: 12px 16px;
color: #c8d0da;
}
#traceroute-error {
color: #ff6b6b;
}
{% endblock %}
{% block body %}
<div id="traceroute-meta">
<div><b>Traceroute</b> <span id="traceroute-title"></span></div>
<div id="traceroute-error"></div>
</div>
<div id="traceroute-graph"></div>
<script>
const el = document.getElementById("traceroute-graph");
const chart = echarts.init(el);
function packetIdFromPath() {
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[parts.length - 1];
}
function addPathEdges(path, edges, style) {
for (let i = 0; i < path.length - 1; i++) {
edges.push({
source: String(path[i]),
target: String(path[i + 1]),
lineStyle: style
});
}
}
async function loadTraceroute() {
const packetId = packetIdFromPath();
document.getElementById("traceroute-title").textContent = `#${packetId}`;
const [res, nodesRes] = await Promise.all([
fetch(`/api/traceroute/${packetId}`),
fetch("/api/nodes"),
]);
if (!res.ok) {
document.getElementById("traceroute-error").textContent = "Traceroute not found.";
return;
}
const data = await res.json();
const nodesData = nodesRes.ok ? await nodesRes.json() : { nodes: [] };
const nodeShortNameById = new Map(
(nodesData.nodes || []).map(n => [String(n.node_id), n.short_name || n.long_name || String(n.node_id)])
);
const nodeLongNameById = new Map(
(nodesData.nodes || []).map(n => [String(n.node_id), n.long_name || n.short_name || String(n.node_id)])
);
const nodes = new Map();
const edges = [];
const forwardPaths = data?.winning_paths?.forward || [];
const reversePaths = data?.winning_paths?.reverse || [];
const originId = data?.packet?.from != null ? String(data.packet.from) : null;
const targetId = data?.packet?.to != null ? String(data.packet.to) : null;
forwardPaths.forEach(path => {
path.forEach(id => nodes.set(String(id), { name: String(id) }));
addPathEdges(path, edges, { color: "#ff5733", width: 3 });
});
reversePaths.forEach(path => {
path.forEach(id => nodes.set(String(id), { name: String(id) }));
addPathEdges(path, edges, { color: "#00c3ff", width: 2, type: "dashed" });
});
const graphNodes = Array.from(nodes.values()).map(n => {
const isOrigin = originId && n.name === originId;
const isTarget = targetId && n.name === targetId;
const color = isOrigin ? "#ff3b30" : isTarget ? "#34c759" : "#8aa4c8";
const size = isOrigin || isTarget ? 44 : 36;
return {
id: n.name,
name: nodeShortNameById.get(n.name) || n.name,
symbolSize: size,
itemStyle: { color },
label: {
show: true,
color: "#e7eef7",
fontWeight: "bold"
},
tooltip: {
formatter: () => nodeLongNameById.get(n.name) || n.name
}
};
});
const option = {
backgroundColor: "transparent",
tooltip: { trigger: "item" },
series: [
{
type: "graph",
layout: "force",
roam: true,
zoom: 1.2,
draggable: true,
force: { repulsion: 200, edgeLength: 80 },
data: graphNodes,
edges: edges,
lineStyle: { opacity: 0.8, curveness: 0.1 },
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10
}
]
};
chart.setOption(option);
}
loadTraceroute();
window.addEventListener("resize", () => chart.resize());
</script>
{% endblock %}
+32 -1
View File
@@ -21,6 +21,7 @@ from meshview import config, database, decode_payload, migrations, models, store
from meshview.__version__ import (
__version_string__,
)
from meshview.deps import check_optional_deps
from meshview.web_api import api
logging.basicConfig(
@@ -38,6 +39,7 @@ env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape
# Start Database
database.init_database(CONFIG["database"]["connection_string"])
check_optional_deps()
BASE_DIR = os.path.dirname(__file__)
LANG_DIR = os.path.join(BASE_DIR, "lang")
@@ -55,6 +57,7 @@ class Packet:
from_node: models.Node
to_node_id: int
to_node: models.Node
channel: str
portnum: int
data: str
raw_mesh_packet: object
@@ -102,6 +105,7 @@ class Packet:
from_node_id=packet.from_node_id,
to_node=packet.to_node,
to_node_id=packet.to_node_id,
channel=packet.channel,
portnum=packet.portnum,
data=text_mesh_packet,
payload=text_payload, # now always a string
@@ -193,7 +197,6 @@ routes = web.RouteTableDef()
@routes.get("/")
async def index(request):
"""Redirect root URL to configured starting page."""
"""
Redirect root URL '/' to the page specified in CONFIG['site']['starting'].
Defaults to '/map' if not set.
@@ -209,6 +212,7 @@ async def redirect_packet_list(request):
packet_id = request.match_info["packet_id"]
raise web.HTTPFound(location=f"/node/{packet_id}")
# Generic static HTML route
@routes.get("/{page}")
async def serve_page(request):
@@ -227,6 +231,20 @@ async def serve_page(request):
return web.Response(text=content, content_type="text/html")
@routes.get("/docs/{doc}")
async def serve_doc(request):
"""Serve documentation files from docs/ (markdown)."""
doc = request.match_info["doc"]
docs_root = pathlib.Path(__file__).parent.parent / "docs"
doc_path = (docs_root / doc).resolve()
if not doc_path.is_file() or docs_root not in doc_path.parents:
raise web.HTTPNotFound(text="Document not found")
content = doc_path.read_text(encoding="utf-8")
return web.Response(text=content, content_type="text/markdown")
@routes.get("/net")
async def net(request):
return web.Response(
@@ -312,6 +330,15 @@ async def stats(request):
)
@routes.get("/traceroute/{packet_id}")
async def traceroute_page(request):
template = env.get_template("traceroute.html")
return web.Response(
text=template.render(),
content_type="text/html",
)
# Keep !!
@routes.get("/graph/traceroute/{packet_id}")
async def graph_traceroute(request):
@@ -327,6 +354,8 @@ async def graph_traceroute(request):
node_ids = set()
for tr in traceroutes:
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
node_ids.add(tr.gateway_node_id)
for node_id in route.route:
node_ids.add(node_id)
@@ -352,6 +381,8 @@ async def graph_traceroute(request):
if tr.done and dest:
continue
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
path = [packet.from_node_id]
path.extend(route.route)
if tr.done:
+337 -11
View File
@@ -3,18 +3,28 @@
import datetime
import json
import logging
import math
import os
from aiohttp import web
from sqlalchemy import func, select, text
from sqlalchemy import func, select
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store
from meshview.__version__ import __version__, _git_revision_short, get_version_info
from meshview.config import CONFIG
from meshview.models import Node
from meshview.models import DailySnapshot, Node, NodePublicKey
from meshview.models import Packet as PacketModel
from meshview.models import PacketSeen as PacketSeenModel
from meshview.radio.coverage import (
DEFAULT_MAX_DBM,
DEFAULT_MIN_DBM,
DEFAULT_RELIABILITY,
DEFAULT_THRESHOLD_DBM,
ITM_AVAILABLE,
compute_coverage,
compute_perimeter,
)
logger = logging.getLogger(__name__)
@@ -22,11 +32,35 @@ logger = logging.getLogger(__name__)
Packet = None
SEQ_REGEX = None
LANG_DIR = None
_LANG_CACHE = {}
# Create dedicated route table for API endpoints
routes = web.RouteTableDef()
def _haversine_km(lat1, lon1, lat2, lon2):
r = 6371.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2.0) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _bearing_deg(lat1, lon1, lat2, lon2):
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dlambda = math.radians(lon2 - lon1)
y = math.sin(dlambda) * math.cos(phi2)
x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlambda)
bearing = math.degrees(math.atan2(y, x))
return (bearing + 360.0) % 360.0
OBSERVED_MAX_DISTANCE_KM = 50.0
def init_api_module(packet_class, seq_regex, lang_dir):
"""Initialize API module with dependencies from main web module."""
global Packet, SEQ_REGEX, LANG_DIR
@@ -83,7 +117,9 @@ async def api_nodes(request):
"last_lat": getattr(n, "last_lat", None),
"last_long": getattr(n, "last_long", None),
"channel": n.channel,
"is_mqtt_gateway": getattr(n, "is_mqtt_gateway", None),
# "last_update": n.last_update.isoformat(),
"first_seen_us": n.first_seen_us,
"last_seen_us": n.last_seen_us,
}
)
@@ -129,7 +165,7 @@ async def api_packets(request):
"portnum": int(p.portnum) if p.portnum is not None else None,
"payload": (p.payload or "").strip(),
"import_time_us": p.import_time_us,
"channel": getattr(p.from_node, "channel", ""),
"channel": p.channel,
"long_name": getattr(p.from_node, "long_name", ""),
}
return web.json_response({"packets": [data]})
@@ -214,7 +250,7 @@ async def api_packets(request):
packet_dict = {
"id": p.id,
"import_time_us": p.import_time_us,
"channel": getattr(p.from_node, "channel", ""),
"channel": p.channel,
"from_node_id": p.from_node_id,
"to_node_id": p.to_node_id,
"portnum": int(p.portnum),
@@ -416,6 +452,59 @@ async def api_stats_count(request):
return web.json_response({"total_packets": total_packets, "total_seen": total_seen})
@routes.get("/api/snapshots/daily")
async def api_daily_snapshots(request):
length_raw = request.query.get("length")
length = None
if length_raw is not None:
try:
length = int(length_raw)
except ValueError:
return web.json_response({"error": "length must be an integer"}, status=400)
length = max(1, min(length, 3650))
try:
async with database.async_session() as session:
if length is not None:
rows = (
(
await session.execute(
select(DailySnapshot)
.order_by(DailySnapshot.snapshot_date.desc())
.limit(length)
)
)
.scalars()
.all()
)
rows = list(reversed(rows))
else:
rows = (
(
await session.execute(
select(DailySnapshot).order_by(DailySnapshot.snapshot_date.asc())
)
)
.scalars()
.all()
)
data = [
{
"date": row.snapshot_date.isoformat(),
"node_count": row.node_count,
"packet_count": row.packet_count,
"gateway_count": row.gateway_count,
"captured_at_us": row.captured_at_us,
}
for row in rows
]
return web.json_response({"data": data})
except Exception as e:
logger.error(f"Error in /api/snapshots/daily: {e}")
return web.json_response({"error": "Failed to fetch daily snapshots"}, status=500)
@routes.get("/api/edges")
async def api_edges(request):
since = datetime.datetime.now() - datetime.timedelta(hours=12)
@@ -444,6 +533,8 @@ async def api_edges(request):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
except Exception:
continue
if route is None or tr.packet is None:
continue
path = [tr.packet.from_node_id] + list(route.route)
path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
@@ -593,9 +684,20 @@ async def api_lang(request):
if not os.path.exists(lang_file):
lang_file = os.path.join(LANG_DIR, "en.json")
# Load JSON translations
with open(lang_file, encoding="utf-8") as f:
translations = json.load(f)
# Cache by file + mtime to avoid re-reading on every request
try:
mtime = os.path.getmtime(lang_file)
except OSError:
mtime = None
cache_key = lang_file
cached = _LANG_CACHE.get(cache_key)
if cached and cached.get("mtime") == mtime:
translations = cached["translations"]
else:
with open(lang_file, encoding="utf-8") as f:
translations = json.load(f)
_LANG_CACHE[cache_key] = {"mtime": mtime, "translations": translations}
if section:
section = section.lower()
@@ -623,8 +725,14 @@ async def health_check(request):
# Check database connectivity
try:
async with database.async_session() as session:
await session.execute(text("SELECT 1"))
result = await session.execute(select(func.max(PacketModel.import_time_us)))
last_import_time_us = result.scalar()
health_status["database"] = "connected"
if last_import_time_us is not None:
now_us = int(datetime.datetime.now(datetime.UTC).timestamp() * 1_000_000)
health_status["seconds_since_last_message"] = round(
(now_us - last_import_time_us) / 1_000_000, 1
)
except Exception as e:
logger.error(f"Database health check failed: {e}")
health_status["database"] = "disconnected"
@@ -728,6 +836,8 @@ async def api_traceroute(request):
# --------------------------------------------
for idx, tr in enumerate(traceroutes):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
forward_list = list(route.route)
reverse_list = list(route.route_back)
@@ -749,7 +859,8 @@ async def api_traceroute(request):
forward_paths = []
reverse_paths = []
winning_paths = []
winning_forward_paths = []
winning_reverse_paths = []
for tr in tr_groups:
f = tuple(tr["forward_hops"])
@@ -762,7 +873,10 @@ async def api_traceroute(request):
reverse_paths.append(r)
if tr["done"]:
winning_paths.append(f)
if tr["forward_hops"]:
winning_forward_paths.append(f)
if tr["reverse_hops"]:
winning_reverse_paths.append(r)
# Deduplicate
unique_forward_paths = sorted(set(forward_paths))
@@ -778,7 +892,30 @@ async def api_traceroute(request):
unique_reverse_paths_json = [list(p) for p in unique_reverse_paths]
winning_paths_json = [list(p) for p in set(winning_paths)]
from_node_id = packet.from_node_id
to_node_id = packet.to_node_id
winning_forward_with_endpoints = []
for path in set(winning_forward_paths):
full_path = list(path)
if from_node_id is not None and (not full_path or full_path[0] != from_node_id):
full_path = [from_node_id, *full_path]
if to_node_id is not None and (not full_path or full_path[-1] != to_node_id):
full_path = [*full_path, to_node_id]
winning_forward_with_endpoints.append(full_path)
winning_reverse_with_endpoints = []
for path in set(winning_reverse_paths):
full_path = list(path)
if to_node_id is not None and (not full_path or full_path[0] != to_node_id):
full_path = [to_node_id, *full_path]
if from_node_id is not None and (not full_path or full_path[-1] != from_node_id):
full_path = [*full_path, from_node_id]
winning_reverse_with_endpoints.append(full_path)
winning_paths_json = {
"forward": winning_forward_with_endpoints,
"reverse": winning_reverse_with_endpoints,
}
# --------------------------------------------
# Final API output
@@ -884,3 +1021,192 @@ async def api_stats_top(request):
"nodes": nodes,
}
)
@routes.get("/api/node/{node_id}/qr")
async def api_node_qr(request):
"""
Generate a Meshtastic URL for importing the node as a contact.
Returns the URL that can be used to generate a QR code.
"""
try:
node_id_str = request.match_info["node_id"]
node_id = int(node_id_str, 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
node = await store.get_node(node_id)
if not node:
return web.json_response({"error": "Node not found"}, status=404)
try:
from meshtastic.protobuf.admin_pb2 import SharedContact
from meshtastic.protobuf.mesh_pb2 import User
user = User()
user.id = f"!{node_id:08x}"
if node.long_name:
user.long_name = node.long_name
if node.short_name:
user.short_name = node.short_name
if node.hw_model:
try:
from meshtastic.protobuf.mesh_pb2 import HardwareModel
hw_model_value = getattr(HardwareModel, node.hw_model.upper(), None)
if hw_model_value is not None:
user.hw_model = hw_model_value
except (AttributeError, TypeError):
pass
contact = SharedContact()
contact.node_num = node_id
contact.user.CopyFrom(user)
contact.manually_verified = False
contact_bytes = contact.SerializeToString()
import base64
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
return web.json_response(
{
"node_id": node_id,
"long_name": node.long_name,
"short_name": node.short_name,
"meshtastic_url": meshtastic_url,
}
)
except Exception as e:
import traceback
logger.error(f"Error generating QR URL for node {node_id}: {e}")
logger.error(traceback.format_exc())
return web.json_response({"error": f"Failed to generate URL: {str(e)}"}, status=500)
@routes.get("/api/node/{node_id}/impersonation-check")
async def api_node_impersonation_check(request):
"""
Check if a node has multiple different public keys, which could indicate impersonation.
"""
try:
node_id_str = request.match_info["node_id"]
node_id = int(node_id_str, 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
try:
async with database.async_session() as session:
result = await session.execute(
select(NodePublicKey.public_key).where(NodePublicKey.node_id == node_id).distinct()
)
public_keys = result.scalars().all()
unique_key_count = len(public_keys)
return web.json_response(
{
"node_id": node_id,
"unique_public_key_count": unique_key_count,
"potential_impersonation": unique_key_count > 1,
"public_keys": public_keys
if unique_key_count <= 3
else public_keys[:3] + ["..."],
"warning": "Multiple different public keys detected. This node may be getting impersonated."
if unique_key_count > 1
else None,
}
)
except Exception as e:
logger.error(f"Error checking impersonation for node {node_id}: {e}")
return web.json_response({"error": "Failed to check impersonation"}, status=500)
@routes.get("/api/coverage/{node_id}")
async def api_coverage(request):
try:
node_id = int(request.match_info["node_id"], 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
if not ITM_AVAILABLE:
return web.json_response(
{"error": "Coverage requires pyitm. Run: pip install -r requirements.txt"},
status=503,
)
def parse_float(name, default):
value = request.query.get(name)
if value is None:
return default
try:
return float(value)
except ValueError as exc:
raise web.HTTPBadRequest(
text=json.dumps({"error": f"{name} must be a number"}),
content_type="application/json",
) from exc
try:
freq_mhz = parse_float("freq_mhz", 907.0)
tx_dbm = parse_float("tx_dbm", 20.0)
tx_height_m = parse_float("tx_height_m", 5.0)
rx_height_m = parse_float("rx_height_m", 1.5)
radius_km = parse_float("radius_km", 40.0)
step_km = parse_float("step_km", 0.25)
reliability = parse_float("reliability", DEFAULT_RELIABILITY)
threshold_dbm = parse_float("threshold_dbm", DEFAULT_THRESHOLD_DBM)
except web.HTTPBadRequest as exc:
raise exc
node = await store.get_node(node_id)
if not node or not node.last_lat or not node.last_long:
return web.json_response({"error": "Node not found or missing location"}, status=404)
lat = node.last_lat * 1e-7
lon = node.last_long * 1e-7
mode = request.query.get("mode", "perimeter")
if mode == "perimeter":
perimeter = compute_perimeter(
lat=round(lat, 7),
lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3),
tx_dbm=round(tx_dbm, 2),
tx_height_m=round(tx_height_m, 2),
rx_height_m=round(rx_height_m, 2),
radius_km=round(radius_km, 2),
step_km=round(step_km, 3),
reliability=round(reliability, 3),
threshold_dbm=round(threshold_dbm, 1),
)
return web.json_response(
{"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter}
)
points = compute_coverage(
lat=round(lat, 7),
lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3),
tx_dbm=round(tx_dbm, 2),
tx_height_m=round(tx_height_m, 2),
rx_height_m=round(rx_height_m, 2),
radius_km=round(radius_km, 2),
step_km=round(step_km, 3),
reliability=round(reliability, 3),
)
min_dbm = DEFAULT_MIN_DBM
max_dbm = DEFAULT_MAX_DBM
if points:
vals = [p[2] for p in points]
min_dbm = min(min_dbm, min(vals))
max_dbm = max(max_dbm, max(vals))
return web.json_response(
{"mode": "heatmap", "min_dbm": min_dbm, "max_dbm": max_dbm, "points": points}
)
+2 -2
View File
@@ -48,7 +48,7 @@ dev = [
# Linting
target-version = "py313"
line-length = 100
extend-exclude = ["build", "dist", ".venv"]
extend-exclude = ["build", "dist", ".venv", "meshtastic/protobuf", "nanopb_pb2.py"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # pick your rulesets
@@ -56,4 +56,4 @@ ignore = ["E501"] # example; let formatter handle line len
[tool.ruff.format]
quote-style = "preserve"
indent-style = "space"
indent-style = "space"
+2 -1
View File
@@ -24,6 +24,7 @@ MarkupSafe~=3.0.2
# Graphs / diagrams
pydot~=3.0.4
pyitm~=0.3
#############################
@@ -47,4 +48,4 @@ objgraph~=3.6.2
# Testing
pytest~=8.3.4
pytest-aiohttp~=1.0.5
pytest-asyncio~=0.24.0
pytest-asyncio~=0.24.0
+10
View File
@@ -83,6 +83,7 @@ skip_node_ids =
secondary_keys =
# -------------------------
# Database Configuration
# -------------------------
@@ -117,6 +118,15 @@ backup_dir = ./backups
backup_hour = 2
backup_minute = 00
# -------------------------
# Daily Snapshot Configuration
# -------------------------
[snapshot]
# Time to run daily network snapshot capture (24-hour format)
# Defaults are used if this section is omitted.
hour = 1
minute = 00
# -------------------------
# Logging Configuration
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
import argparse
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
def run(cmd, cwd=None):
subprocess.run(cmd, cwd=cwd, check=True)
def main():
parser = argparse.ArgumentParser(description="Update Meshtastic protobufs")
parser.add_argument(
"--repo",
default="https://github.com/meshtastic/protobufs.git",
help="Meshtastic protobufs repo URL",
)
parser.add_argument(
"--ref",
default="master",
help="Git ref to fetch (branch, tag, or commit)",
)
parser.add_argument(
"--check",
action="store_true",
help="Only check if protobufs are up to date for the given ref",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
out_root = repo_root
with tempfile.TemporaryDirectory(prefix="meshtastic-protobufs-") as tmp:
tmp_path = Path(tmp)
print(f"Cloning {args.repo} ({args.ref}) into {tmp_path}...")
run(["git", "clone", "--depth", "1", "--branch", args.ref, args.repo, str(tmp_path)])
upstream_rev = (
subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tmp_path).decode().strip()
)
rev_file = out_root / "meshtastic" / "protobuf" / "UPSTREAM_REV.txt"
current_rev = None
if rev_file.exists():
current_rev = rev_file.read_text(encoding="utf-8").strip()
if args.check:
if current_rev == upstream_rev:
print(f"Up to date: {current_rev}")
return 0
print(f"Out of date. Local: {current_rev or 'unknown'} / Upstream: {upstream_rev}")
return 1
proto_root = None
# Common locations in the meshtastic/protobufs repo
candidates = [
tmp_path / "meshtastic" / "protobuf",
tmp_path / "protobufs",
tmp_path / "protobuf",
tmp_path / "proto",
]
for candidate in candidates:
if candidate.exists() and list(candidate.glob("*.proto")):
proto_root = candidate
break
if proto_root is None:
# Fallback: search for any directory containing .proto files
for candidate in tmp_path.rglob("*.proto"):
proto_root = candidate.parent
break
if proto_root is None:
print("Proto root not found in cloned repo.", file=sys.stderr)
return 1
protos = sorted(proto_root.glob("*.proto"))
if not protos:
print(f"No .proto files found in {proto_root}", file=sys.stderr)
return 1
rel_protos = [str(p.relative_to(tmp_path)) for p in protos]
protoc = shutil.which("protoc")
if protoc:
cmd = [
protoc,
f"-I{tmp_path}",
f"--python_out={out_root}",
*rel_protos,
]
print("Running protoc...")
run(cmd, cwd=tmp_path)
else:
try:
import grpc_tools.protoc # noqa: F401
except Exception:
print(
"protoc not found. Install it with your package manager, "
"or install grpcio-tools and re-run.",
file=sys.stderr,
)
return 1
cmd = [
sys.executable,
"-m",
"grpc_tools.protoc",
f"-I{tmp_path}",
f"--python_out={out_root}",
*rel_protos,
]
print("Running grpc_tools.protoc...")
run(cmd, cwd=tmp_path)
rev_file.parent.mkdir(parents=True, exist_ok=True)
rev_file.write_text(upstream_rev + "\n", encoding="utf-8")
print("Protobufs updated in meshtastic/protobuf/.")
print("Review changes and commit them if desired.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -11,6 +11,7 @@ from sqlalchemy.engine.url import make_url
from meshview import migrations, models, mqtt_database, mqtt_reader, mqtt_store
from meshview.config import CONFIG
from meshview.deps import check_optional_deps
# -------------------------
# Basic logging configuration
@@ -128,6 +129,27 @@ async def daily_backup_at(hour: int = 2, minute: int = 0, backup_dir: str = ".")
await backup_database(database_url, backup_dir)
# -------------------------
# Daily snapshot scheduler
# -------------------------
async def daily_snapshot_at(hour: int = 1, minute: int = 0):
while True:
now = datetime.datetime.now()
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_run <= now:
next_run += datetime.timedelta(days=1)
delay = (next_run - now).total_seconds()
cleanup_logger.info(f"Next daily snapshot scheduled at {next_run}")
await asyncio.sleep(delay)
try:
async with db_lock:
await mqtt_store.capture_daily_snapshot()
cleanup_logger.info("Daily snapshot captured successfully.")
except Exception as e:
cleanup_logger.error(f"Error capturing daily snapshot: {e}")
# -------------------------
# Database cleanup using ORM
# -------------------------
@@ -237,6 +259,7 @@ async def load_database_from_mqtt(
# Main function
# -------------------------
async def main():
check_optional_deps()
logger = logging.getLogger(__name__)
# Initialize database
@@ -265,6 +288,9 @@ async def main():
await mqtt_database.create_tables()
logger.info("Database tables created")
# Load MQTT gateway cache after DB init/migrations
await mqtt_store.load_gateway_cache()
finally:
# Clear migration in progress flag
logger.info("Clearing migration status...")
@@ -285,6 +311,8 @@ async def main():
backup_dir = CONFIG.get("cleanup", {}).get("backup_dir", "./backups")
backup_hour = get_int(CONFIG, "cleanup", "backup_hour", cleanup_hour)
backup_minute = get_int(CONFIG, "cleanup", "backup_minute", cleanup_minute)
snapshot_hour = get_int(CONFIG, "snapshot", "hour", 1)
snapshot_minute = get_int(CONFIG, "snapshot", "minute", 0)
logger.info(f"Starting MQTT ingestion from {CONFIG['mqtt']['server']}:{CONFIG['mqtt']['port']}")
if cleanup_enabled:
@@ -295,6 +323,7 @@ async def main():
logger.info(
f"Daily backups enabled: storing in {backup_dir} at {backup_hour:02d}:{backup_minute:02d}"
)
logger.info(f"Daily snapshots enabled: capturing at {snapshot_hour:02d}:{snapshot_minute:02d}")
async with asyncio.TaskGroup() as tg:
tg.create_task(
@@ -324,8 +353,11 @@ async def main():
)
)
tg.create_task(daily_snapshot_at(snapshot_hour, snapshot_minute))
if not cleanup_enabled and not backup_enabled:
cleanup_logger.info("Daily cleanup and backups are both disabled by configuration.")
cleanup_logger.info("Daily snapshots remain enabled by schedule configuration.")
# -------------------------