mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Compare commits
61 Commits
db_updates
...
3.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87831c3a5 | ||
|
|
9c40ce2d59 | ||
|
|
f108197e5f | ||
|
|
20c2a3dc62 | ||
|
|
4a7fa1df08 | ||
|
|
685dbc9505 | ||
|
|
9aacceda28 | ||
|
|
a7051e7d26 | ||
|
|
7926e81562 | ||
|
|
2002e093af | ||
|
|
fc44f49f2d | ||
|
|
89fbc6aeca | ||
|
|
20e3f9c104 | ||
|
|
17fa92d4cf | ||
|
|
a48a3a4141 | ||
|
|
7d5b638eac | ||
|
|
5f5fe0da90 | ||
|
|
dd98814b2c | ||
|
|
4dd999178c | ||
|
|
01dce2a5e0 | ||
|
|
9622092c17 | ||
|
|
29da1487d4 | ||
|
|
357fb530e2 | ||
|
|
b43683a259 | ||
|
|
59379649e2 | ||
|
|
a62bc350c0 | ||
|
|
82ff4bb0df | ||
|
|
c454f2ef3a | ||
|
|
b93f640233 | ||
|
|
018e16e9fa | ||
|
|
41397072af | ||
|
|
43be448100 | ||
|
|
8c7f181002 | ||
|
|
5195868719 | ||
|
|
a473e32c59 | ||
|
|
be51dc9c55 | ||
|
|
bea6c8cd8e | ||
|
|
351c35ef42 | ||
|
|
7f722b6f12 | ||
|
|
52f1a1e788 | ||
|
|
f44a78730a | ||
|
|
a9a5e046ea | ||
|
|
37386f9e28 | ||
|
|
b66bfb1ee9 | ||
|
|
caf9cd1596 | ||
|
|
a4ebd2b23c | ||
|
|
5676ade6b7 | ||
|
|
319f8eac06 | ||
|
|
d85132133a | ||
|
|
b6d8af409c | ||
|
|
896a0980d5 | ||
|
|
7d395e5e27 | ||
|
|
c3cc01d7e7 | ||
|
|
ecbadc6087 | ||
|
|
ff30623bdf | ||
|
|
a43433ccb4 | ||
|
|
4d9db2a52c | ||
|
|
e30b59851f | ||
|
|
36dd91be63 | ||
|
|
4516c84128 | ||
|
|
a882bc22dd |
6
.github/workflows/container.yml
vendored
6
.github/workflows/container.yml
vendored
@@ -2,6 +2,7 @@ name: Build container
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -23,7 +24,8 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=v\d.\d.\d,value=latest
|
||||
# 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
|
||||
@@ -49,4 +51,4 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# optional cache (speeds up rebuilds)
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,7 +2,6 @@ env/*
|
||||
__pycache__/*
|
||||
meshview/__pycache__/*
|
||||
alembic/__pycache__/*
|
||||
meshtastic/protobuf/*
|
||||
|
||||
# Database files
|
||||
packets.db
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN uv pip install --no-cache-dir --upgrade pip \
|
||||
COPY --chown=${APP_USER}:${APP_USER} . .
|
||||
|
||||
# Patch config
|
||||
RUN patch sample.config.ini < container/config.patch
|
||||
COPY --chown=${APP_USER}:${APP_USER} container/config.ini /app/sample.config.ini
|
||||
|
||||
# Clean
|
||||
RUN rm -rf /app/.git* && \
|
||||
@@ -77,4 +77,3 @@ CMD ["--pid_dir", "/tmp", "--py_exec", "/opt/venv/bin/python", "--config", "/etc
|
||||
|
||||
EXPOSE 8081
|
||||
VOLUME [ "/etc/meshview", "/var/lib/meshview", "/var/log/meshview" ]
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ password =
|
||||
# Examples:
|
||||
# sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
|
||||
```
|
||||
|
||||
### Database Backups
|
||||
|
||||
159
README.md
159
README.md
@@ -4,6 +4,25 @@
|
||||
|
||||
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.5 — February 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 (Longley‑Rice area mode) with perimeter rendering and documentation.
|
||||
- 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: multi‑DB support, PostgreSQL scripts, WAL config for SQLite, cleanup query timing fixes, removal of import time columns, and various time‑handling 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 12‑hour window.
|
||||
- API/logic: weekly mesh query fix, node list performance improvement, backwards‑compatibility and other bug fixes.
|
||||
- MQTT reader: configurable skip‑node 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.
|
||||
|
||||
### Version 3.0.1 — December 2025
|
||||
|
||||
#### 🌐 Multi-Language Support (i18n)
|
||||
@@ -80,25 +99,32 @@ 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 (Canadaverse)
|
||||
- https://canadaverse.org (Canadaverse)
|
||||
- https://meshview.bayme.sh (SF Bay Area - USA)
|
||||
- https://www.svme.sh (Sacramento Valley - USA)
|
||||
- https://meshview.nyme.sh (New York - USA)
|
||||
- https://meshview.socalmesh.org (Los Angenles - USA)
|
||||
- https://map.wpamesh.net (Western Pennsylvania - USA)
|
||||
- https://meshview.chicagolandmesh.org (Chicago - USA)
|
||||
- https://meshview.freq51.net/ (Salt Lake City - USA)
|
||||
- https://meshview.mt.gt (Canada)
|
||||
- https://canadaverse.org (Canada)
|
||||
- https://meshview.meshtastic.es (Spain)
|
||||
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
||||
- https://view.mtnme.sh (North Georgia / East Tennessee - USA)
|
||||
- https://meshview.lsinfra.de (Hessen - Germany)
|
||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
||||
- https://meshview.louisianamesh.org (Louisiana)
|
||||
- https://www.swlamesh.com/map (Southwest Louisiana)
|
||||
- https://meshview.meshcolombia.co/ (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
|
||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts - USA)
|
||||
- https://meshview.louisianamesh.org (Louisiana - USA)
|
||||
- https://www.swlamesh.com (Southwest Louisiana- USA)
|
||||
- https://meshview.meshcolombia.co (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr (Salzburg / Austria)
|
||||
- https://map.cromesh.eu (Coatia)
|
||||
- https://view.meshdresden.eu (Dresden / Germany)
|
||||
- https://meshview.meshoregon.com (Oregon - USA)
|
||||
- https://meshview.gamesh.net (Georgia - USA)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Updating from 2.x to 3.x
|
||||
We are adding the use of Alembic. If using GitHub
|
||||
Update your codebase by running the pull command
|
||||
@@ -278,18 +304,6 @@ password = large4cats
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:///packets.db
|
||||
|
||||
> **NOTE (PostgreSQL setup)**
|
||||
> If you want to use PostgreSQL instead of SQLite:
|
||||
>
|
||||
> 1) Install PostgreSQL for your OS.
|
||||
> 2) Create a user and database:
|
||||
> - `CREATE USER meshview WITH PASSWORD 'change_me';`
|
||||
> - `CREATE DATABASE meshview OWNER meshview;`
|
||||
> 3) Update `config.ini`:
|
||||
> - `connection_string = postgresql+asyncpg://meshview:change_me@localhost:5432/meshview`
|
||||
> 4) Initialize the schema:
|
||||
> - `./env/bin/python startdb.py`
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Database Cleanup Configuration
|
||||
@@ -321,6 +335,20 @@ db_cleanup_logfile = dbcleanup.log
|
||||
|
||||
---
|
||||
|
||||
## NOTE (PostgreSQL setup)**
|
||||
If you want to use PostgreSQL instead of SQLite:
|
||||
|
||||
Install PostgreSQL for your OS.
|
||||
Create a user and database:
|
||||
```
|
||||
`CREATE USER meshview WITH PASSWORD 'change_me';`
|
||||
`CREATE DATABASE meshview OWNER meshview;`
|
||||
```
|
||||
Update `config.ini` example:
|
||||
```
|
||||
`connection_string = postgresql+asyncpg://meshview:change_me@localhost:5432/meshview`
|
||||
```
|
||||
|
||||
## Running Meshview
|
||||
|
||||
Start the database manager:
|
||||
@@ -490,16 +518,15 @@ db_cleanup_logfile = dbcleanup.log
|
||||
```
|
||||
Once changes are done you need to restart the script for changes to load.
|
||||
|
||||
### Alternatively we can do it via your OS
|
||||
### Alternatively we can do it via your OS (This example is Ubuntu like OS)
|
||||
- Create and save bash script below. (Modify /path/to/file/ to the correct path)
|
||||
- Name it cleanup.sh
|
||||
- Make it executable.
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/bin/bash
|
||||
|
||||
DB_FILE="/path/to/file/packets.db"
|
||||
|
||||
|
||||
# Stop DB service
|
||||
sudo systemctl stop meshview-db.service
|
||||
sudo systemctl stop meshview-web.service
|
||||
@@ -533,6 +560,80 @@ sudo systemctl start meshview-web.service
|
||||
|
||||
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="postgresql://meshview@localhost:5432/meshview"
|
||||
RETENTION_DAYS=14
|
||||
BATCH_SIZE=100
|
||||
|
||||
PSQL="/usr/bin/psql"
|
||||
|
||||
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 < cutoff.ts
|
||||
ORDER BY id
|
||||
LIMIT ${BATCH_SIZE}
|
||||
),
|
||||
ps_del AS (
|
||||
DELETE FROM packet_seen
|
||||
WHERE packet_id IN (SELECT id FROM old_packets)
|
||||
RETURNING 1
|
||||
),
|
||||
tr_del AS (
|
||||
DELETE FROM traceroute
|
||||
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 COUNT(*) FROM p_del;
|
||||
EOF
|
||||
)
|
||||
|
||||
if [[ "$DELETED" -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
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
|
||||
|
||||
echo "[$(date)] Node cleanup complete"
|
||||
|
||||
$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.
|
||||
|
||||
27
alembic/versions/23dad03d2e42_add_is_mqtt_gateway_to_node.py
Normal file
27
alembic/versions/23dad03d2e42_add_is_mqtt_gateway_to_node.py
Normal file
@@ -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")
|
||||
43
alembic/versions/a0c9c13e118f_add_node_public_key.py
Normal file
43
alembic/versions/a0c9c13e118f_add_node_public_key.py
Normal file
@@ -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")
|
||||
90
container/config.ini
Normal file
90
container/config.ini
Normal 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
|
||||
@@ -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`
|
||||
@@ -200,7 +200,7 @@ Response Example
|
||||
|
||||
### GET `/api/edges`
|
||||
Returns network edges (connections between nodes) based on traceroutes and neighbor info.
|
||||
Traceroute edges are collected over the last 48 hours. Neighbor edges are based on
|
||||
Traceroute edges are collected over the last 12 hours. Neighbor edges are based on
|
||||
port 71 packets.
|
||||
|
||||
Query Parameters
|
||||
@@ -366,7 +366,7 @@ Response Example
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-07-22T12:45:00+00:00",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.3",
|
||||
"git_revision": "abc1234",
|
||||
"database": "connected",
|
||||
"database_size": "12.34 MB",
|
||||
@@ -384,8 +384,9 @@ Returns version metadata.
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.3",
|
||||
"release_date": "2026-1-15",
|
||||
"git_revision": "abc1234",
|
||||
"build_time": "2025-11-01T12:00:00+00:00"
|
||||
"git_revision_short": "abc1234"
|
||||
}
|
||||
```
|
||||
|
||||
37
docs/COVERAGE.md
Normal file
37
docs/COVERAGE.md
Normal 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
meshtastic/protobuf/UPSTREAM_REV.txt
Normal file
1
meshtastic/protobuf/UPSTREAM_REV.txt
Normal file
@@ -0,0 +1 @@
|
||||
e1a6b3a868d735da72cd6c94c574d655129d390a
|
||||
@@ -3,8 +3,8 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.2"
|
||||
__release_date__ = "2026-1-9"
|
||||
__version__ = "3.0.6"
|
||||
__release_date__ = "2026-3-6"
|
||||
|
||||
|
||||
def get_git_revision():
|
||||
|
||||
13
meshview/deps.py
Normal file
13
meshview/deps.py
Normal 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"
|
||||
)
|
||||
@@ -13,13 +13,40 @@
|
||||
"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:",
|
||||
@@ -53,8 +80,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 +99,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,6 +124,7 @@
|
||||
{
|
||||
"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",
|
||||
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
|
||||
@@ -98,6 +135,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",
|
||||
@@ -163,9 +204,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 +226,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 +264,4 @@
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,39 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,8 +78,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 +97,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,6 +120,7 @@
|
||||
"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",
|
||||
"packets_per_day_all": "Paquetes por Día - Todos los Puertos (Últimos 14 Días)",
|
||||
@@ -93,6 +131,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"
|
||||
@@ -148,9 +190,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 +212,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": {
|
||||
|
||||
@@ -20,6 +20,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)
|
||||
|
||||
@@ -99,4 +100,22 @@ class Traceroute(Base):
|
||||
route_return: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (Index("idx_traceroute_import_time_us", "import_time_us"),)
|
||||
__table_args__ = (
|
||||
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"),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
@@ -6,11 +7,26 @@ from meshview import models
|
||||
|
||||
def init_database(database_connection_string):
|
||||
global engine, async_session
|
||||
|
||||
url = make_url(database_connection_string)
|
||||
kwargs = {"echo": False}
|
||||
|
||||
if url.drivername.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"timeout": 900}
|
||||
kwargs["connect_args"] = {"timeout": 900} # seconds
|
||||
|
||||
engine = create_async_engine(url, **kwargs)
|
||||
|
||||
# Enforce SQLite pragmas on every new DB connection
|
||||
if url.drivername.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_conn, _):
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||
cursor.execute("PRAGMA busy_timeout=900000;") # ms
|
||||
cursor.execute("PRAGMA synchronous=NORMAL;")
|
||||
cursor.close()
|
||||
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ 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
|
||||
|
||||
KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
|
||||
PRIMARY_KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -21,24 +23,94 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decrypt(packet):
|
||||
def _parse_skip_node_ids():
|
||||
mqtt_config = CONFIG.get("mqtt", {})
|
||||
raw_value = mqtt_config.get("skip_node_ids", "")
|
||||
if not raw_value:
|
||||
return set()
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return set()
|
||||
values = [v.strip() for v in raw_value.split(",") if v.strip()]
|
||||
else:
|
||||
values = [raw_value]
|
||||
|
||||
skip_ids = set()
|
||||
for value in values:
|
||||
try:
|
||||
skip_ids.add(int(value, 0))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid node id in mqtt.skip_node_ids: %s", value)
|
||||
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", "")
|
||||
if not raw_value:
|
||||
return []
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return []
|
||||
values = [v.strip() for v in raw_value.split(",") if v.strip()]
|
||||
else:
|
||||
values = [raw_value]
|
||||
|
||||
keys = []
|
||||
for value in values:
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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: []")
|
||||
|
||||
|
||||
# Thank you to "Robert Grizzell" for the decryption code!
|
||||
# https://github.com/rgrizzell
|
||||
def decrypt(packet, key):
|
||||
if packet.HasField("decoded"):
|
||||
return
|
||||
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
|
||||
|
||||
cipher = Cipher(algorithms.AES(KEY), modes.CTR(nonce))
|
||||
cipher = Cipher(algorithms.AES(key), modes.CTR(nonce))
|
||||
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:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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:
|
||||
@@ -65,14 +137,14 @@ 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
|
||||
|
||||
# Skip packets from specific node
|
||||
# FIXME: make this configurable as a list of node IDs to skip
|
||||
if getattr(envelope.packet, "from", None) == 2144342101:
|
||||
# Skip packets from configured node IDs
|
||||
if getattr(envelope.packet, "from", None) in SKIP_NODE_IDS:
|
||||
continue
|
||||
|
||||
msg_count += 1
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import 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 +11,12 @@ 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 Node, NodePublicKey, Packet, PacketSeen, Traceroute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MQTT_GATEWAY_CACHE: set[int] = set()
|
||||
|
||||
|
||||
async def process_envelope(topic, env):
|
||||
# MAP_REPORT_APP
|
||||
@@ -97,9 +98,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)
|
||||
@@ -132,6 +133,12 @@ async def process_envelope(topic, env):
|
||||
else:
|
||||
node_id = int(env.gateway_id[1:], 16)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(PacketSeen).where(
|
||||
PacketSeen.packet_id == env.packet.id,
|
||||
@@ -207,6 +214,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 +274,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
meshview/radio/coverage.py
Normal file
146
meshview/radio/coverage.py
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(" - ");
|
||||
}
|
||||
|
||||
@@ -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,37 @@ function hashToColor(str){
|
||||
return c;
|
||||
}
|
||||
|
||||
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 +291,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 +394,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 +418,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,21 +432,24 @@ 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>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
@@ -354,6 +463,70 @@ function renderNodesOnMap(){
|
||||
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 +547,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 +586,20 @@ map.on('click', e=>{
|
||||
BLINKING
|
||||
====================================================== */
|
||||
|
||||
function blinkNode(marker,longName,portnum){
|
||||
function escapeHtml(value){
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 +609,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 +641,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 +681,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 +699,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 +716,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 +747,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;
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
|
||||
|
||||
const url =
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`;
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}&limit=1000`;
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -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) {
|
||||
@@ -1135,13 +1442,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 +1483,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,6 +1621,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
requestAnimationFrame(async () => {
|
||||
await loadNodeInfo();
|
||||
|
||||
// Load QR code URL and impersonation check
|
||||
await loadNodeQrAndImpersonation();
|
||||
|
||||
// ✅ MAP MUST EXIST FIRST
|
||||
if (!map) initMap();
|
||||
|
||||
@@ -1430,12 +1745,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>
|
||||
|
||||
@@ -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</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>
|
||||
@@ -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}">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
|
||||
{% 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,6 +112,10 @@
|
||||
<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 -->
|
||||
@@ -189,6 +194,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 +232,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){
|
||||
@@ -345,6 +365,7 @@ function renderPieChart(elId,data,name){
|
||||
return chart;
|
||||
}
|
||||
|
||||
|
||||
// --- Packet Type Pie Chart ---
|
||||
async function fetchPacketTypeBreakdown(channel=null) {
|
||||
const portnums = [1,3,4,67,70,71];
|
||||
@@ -368,6 +389,7 @@ async function fetchPacketTypeBreakdown(channel=null) {
|
||||
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
|
||||
let chartDailyAll, chartDailyPortnum1;
|
||||
let chartHwModel, chartRole, chartChannel;
|
||||
let chartGatewayChannel, chartGatewayRole, chartGatewayFirmware;
|
||||
let chartPacketTypes;
|
||||
|
||||
async function init(){
|
||||
@@ -414,10 +436,31 @@ 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();
|
||||
@@ -464,6 +507,9 @@ window.addEventListener('resize',()=>{
|
||||
chartHwModel,
|
||||
chartRole,
|
||||
chartChannel,
|
||||
chartGatewayChannel,
|
||||
chartGatewayRole,
|
||||
chartGatewayFirmware,
|
||||
chartPacketTypes
|
||||
].forEach(c=>c?.resize());
|
||||
});
|
||||
|
||||
138
meshview/templates/traceroute.html
Normal file
138
meshview/templates/traceroute.html
Normal 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 %}
|
||||
@@ -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.
|
||||
@@ -203,6 +206,13 @@ async def index(request):
|
||||
raise web.HTTPFound(location=starting_url)
|
||||
|
||||
|
||||
# redirect for backwards compatibility
|
||||
@routes.get("/packet_list/{packet_id}")
|
||||
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):
|
||||
@@ -221,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(
|
||||
@@ -306,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):
|
||||
|
||||
@@ -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 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]})
|
||||
@@ -180,13 +216,17 @@ async def api_packets(request):
|
||||
logger.warning(f"Invalid node_id: {node_id_str}")
|
||||
|
||||
# --- Fetch packets using explicit filters ---
|
||||
contains_for_query = contains
|
||||
if portnum == PortNum.TEXT_MESSAGE_APP and contains:
|
||||
contains_for_query = None
|
||||
|
||||
packets = await store.get_packets(
|
||||
from_node_id=from_node_id,
|
||||
to_node_id=to_node_id,
|
||||
node_id=node_id,
|
||||
portnum=portnum,
|
||||
after=since,
|
||||
contains=contains,
|
||||
contains=contains_for_query,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -210,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),
|
||||
@@ -414,7 +454,7 @@ async def api_stats_count(request):
|
||||
|
||||
@routes.get("/api/edges")
|
||||
async def api_edges(request):
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=48)
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
filter_type = request.query.get("type")
|
||||
|
||||
# NEW → optional single-node filter
|
||||
@@ -589,9 +629,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()
|
||||
@@ -619,8 +670,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"
|
||||
@@ -745,7 +802,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"])
|
||||
@@ -758,7 +816,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))
|
||||
@@ -774,7 +835,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
|
||||
@@ -880,3 +964,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}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,13 @@ port = 1883
|
||||
username = meshdev
|
||||
password = large4cats
|
||||
|
||||
# Optional list of node IDs to ignore. Comma-separated.
|
||||
skip_node_ids =
|
||||
|
||||
# Optional list of secondary AES keys (base64), comma-separated.
|
||||
secondary_keys =
|
||||
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Database Configuration
|
||||
|
||||
126
scripts/update_meshtastic_protobufs.py
Normal file
126
scripts/update_meshtastic_protobufs.py
Normal 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())
|
||||
@@ -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
|
||||
@@ -237,6 +238,7 @@ async def load_database_from_mqtt(
|
||||
# Main function
|
||||
# -------------------------
|
||||
async def main():
|
||||
check_optional_deps()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize database
|
||||
@@ -265,6 +267,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...")
|
||||
|
||||
Reference in New Issue
Block a user