52 Commits

Author SHA1 Message Date
Pablo Revilla d2d18746ef Fixed bug on edges API 2025-10-01 14:00:14 -07:00
Pablo Revilla 7146f69beb update protobuf 2025-10-01 12:07:43 -07:00
Pablo Revilla db8703919d Merge pull request #53 from jkrauska/jkrauska/mapzoom
Add url parameters to /map to support zoomed view
2025-10-01 11:43:45 -07:00
Pablo Revilla baeaf29df0 Merge pull request #51 from Cloud-121/master
Fix Client_BASE not showing in Mesh Graphs
2025-10-01 09:18:50 -07:00
Pablo Revilla 44ddfe7ed7 update protobuf 2025-10-01 08:08:50 -07:00
Pablo Revilla fc28dcc53e Merge pull request #52 from jkrauska/master
Minor README Tweaks and gitignore add
2025-10-01 08:05:34 -07:00
Pablo Revilla 81a2c0c7ca update protobuf 2025-10-01 08:01:00 -07:00
Joel Krauska c7f5467acb Add url parameters to /map to support zoomed view 2025-09-30 17:56:35 -07:00
Joel Krauska 396e5ccbf1 Minor README Tweaks and gitignore add 2025-09-30 17:17:55 -07:00
Cloud Hayes 0a522f9a19 Fix Client_BASE not showing in Mesh Graphs 2025-09-30 17:40:06 -05:00
Pablo Revilla 40c5d4e291 update protobuf 2025-09-25 13:37:55 -07:00
Pablo Revilla 550a266212 update protobuf 2025-09-24 20:28:33 -07:00
Pablo Revilla 238ac409f8 testing new integration 2025-09-24 20:16:10 -07:00
Pablo Revilla ee640b2cec Update .gitmodules 2025-09-24 20:13:28 -07:00
Pablo Revilla 561d410e6a Updates stats with pie chart and report for channel 2025-09-24 19:55:41 -07:00
Pablo Revilla a20dafe714 Updates stats with pie chart and report for channel 2025-09-24 19:54:33 -07:00
Pablo Revilla 3cd93c08a7 Updates stats with pie chart and report for channel 2025-09-24 19:39:06 -07:00
Pablo Revilla 11537fdef1 Merge remote-tracking branch 'origin/master' 2025-09-24 19:38:55 -07:00
Pablo Revilla 5068f7acb1 Updates stats with pie chart and report for channel 2025-09-24 17:23:12 -07:00
Pablo Revilla 85f04f485e Merge pull request #36 from madeofstown/master
New Install Procedure
2025-09-24 17:17:16 -07:00
Pablo Revilla a094b3edd5 Merge branch 'master' into master 2025-09-24 17:17:01 -07:00
Pablo Revilla 8d7f72ac6e Updates stats with pie chart and report for channel 2025-09-22 10:25:24 -07:00
Pablo Revilla 03e198b80c Updates stats with pie chart and report for channel 2025-09-22 10:18:06 -07:00
Pablo Revilla 86b4fa6cbf Update README.md 2025-09-19 21:48:39 -07:00
Pablo Revilla e6424e3c6d Update README.md 2025-09-19 21:48:13 -07:00
Pablo Revilla e2c1e311b8 Update README.md 2025-09-19 11:11:25 -07:00
Pablo Revilla 02f63fca70 Work on DB cleanup tool 2025-09-19 10:50:15 -07:00
Pablo Revilla f9a6f3dff2 Work on DB cleanup tool 2025-09-19 09:20:43 -07:00
Pablo Revilla 0da2ef841c Work on DB cleanup tool 2025-09-19 09:11:00 -07:00
Pablo Revilla 4ffd287c84 Work on DB cleanup tool 2025-09-19 08:50:10 -07:00
Pablo Revilla ec0dd4ef03 Work on status page 2025-09-18 10:28:55 -07:00
Pablo Revilla 608fde9e9c Work on db cleanup tool 2025-09-18 10:25:27 -07:00
Pablo Revilla 7c40c64de8 Work on db cleanup tool 2025-09-18 09:45:01 -07:00
Pablo Revilla 4f4c18fa14 Work on db cleanup tool 2025-09-18 09:37:24 -07:00
Pablo Revilla 6eb1cdbd2d Work on db cleanup tool 2025-09-18 07:40:25 -07:00
Pablo Revilla cad3051e7f Work on db cleanup tool 2025-09-18 07:38:58 -07:00
Pablo Revilla 2b9422efbc fixed spelling of variable firehouse_interval 2025-09-18 07:34:26 -07:00
Pablo Revilla ddb691d4de fixed spelling of variable firehouse_interval 2025-09-17 23:05:05 -07:00
Pablo Revilla bbab5fefd0 make the /api/config endpoint restrictive to what it provides. It will only show what is needed for the current code. 2025-09-17 23:01:29 -07:00
Pablo Revilla 6e223a066a make the /api/config endpoint restrictive to what it provides. It will only show what is needed for the current code. 2025-09-17 23:00:44 -07:00
Pablo Revilla 61b74473e3 make the /api/config endpoint restrictive to what it provides. It will only show what is needed for the current code. 2025-09-17 22:55:40 -07:00
Pablo Revilla f06fa3a4a3 Added Database cleanup feature to startdb.py 2025-09-17 22:22:35 -07:00
Pablo Revilla 9d4ebc00f6 Added Database cleanup feature to startdb.py 2025-09-17 22:15:12 -07:00
Pablo Revilla a69d1a5729 Added Database cleanup feature to startdb.py 2025-09-17 22:12:04 -07:00
madeofstown 7e3076c0e2 Update README.md
symlink target is relative to link location
2025-08-19 19:24:58 -07:00
madeofstown e3f5c0f006 Delete meshtastic/protobuf 2025-08-19 19:14:48 -07:00
madeofstown 572e79c9ac Update .gitmodules 2025-08-19 19:10:46 -07:00
madeofstown fb70f644e5 Update README.md
Change install procedure to mitigate broken submodule
2025-08-19 19:08:38 -07:00
madeofstown 954d6300de Update .gitignore 2025-08-19 17:35:26 -07:00
madeofstown 9ceca0eea9 Update .gitmodules 2025-08-19 17:31:36 -07:00
madeofstown 24f768f725 Merge pull request #5 from madeofstown/testing
re-add meshtastic/python submodule
2025-08-19 17:14:35 -07:00
madeofstown 89f3eade15 re-add meshtastic/python submodule 2025-08-19 17:12:40 -07:00
25 changed files with 804 additions and 324 deletions
+2
View File
@@ -1,7 +1,9 @@
env/* env/*
__pycache__/* __pycache__/*
meshview/__pycache__/* meshview/__pycache__/*
meshtastic/protobuf/*
packets.db packets.db
/table_details.py /table_details.py
config.ini config.ini
screenshots/* screenshots/*
python/nanopb
+1
View File
@@ -0,0 +1 @@
+73 -18
View File
@@ -2,7 +2,12 @@
# Meshview # Meshview
![Start Page](screenshots/animated.gif) ![Start Page](screenshots/animated.gif)
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into the network's activity, including message traffic, node positions, and telemetry data. 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 2.0.7 update - September 2025
* New database maintenance capability to automatically keep a specific number of days of data.
* Added configuration for update intervals for both the Live Map and the Firehose pages.
### Version 2.0.6 update - August 2025 ### Version 2.0.6 update - August 2025
* New Live Map (Shows packet feed live) * New Live Map (Shows packet feed live)
* New API /api/config (See API documentation) * New API /api/config (See API documentation)
@@ -37,17 +42,19 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
Samples of currently running instances: Samples of currently running instances:
- https://meshview.bayme.sh (SF Bay Area) - https://meshview.bayme.sh (SF Bay Area)
- https://www.svme.sh/ (Sacramento Valley) - https://www.svme.sh (Sacramento Valley)
- https://meshview.nyme.sh/ (New York) - https://meshview.nyme.sh (New York)
- https://map.wpamesh.net/ (Western Pennsylvania) - https://meshview.socalmesh.org (LA Area)
- https://meshview.chicagolandmesh.org/ (Chicago) - https://map.wpamesh.net (Western Pennsylvania)
- https://meshview.chicagolandmesh.org (Chicago)
- https://meshview.mt.gt (Canadaverse) - https://meshview.mt.gt (Canadaverse)
- https://meshview.meshtastic.es (Spain) - https://meshview.meshtastic.es (Spain)
- https://view.mtnme.sh/ (North Georgia / East Tennessee) - https://view.mtnme.sh (North Georgia / East Tennessee)
- https://socalmesh.w4hac.com (Southern California)
- https://meshview.lsinfra.de (Hessen - Germany) - https://meshview.lsinfra.de (Hessen - Germany)
- https://map.nswmesh.au/ (Sydney - Australia) - https://map.nswmesh.au (Sydney - Australia)
- https://meshview.pvmesh.org/ (Pioneer Valley, Massachusetts) - https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
- https://meshview.louisianamesh.org (Louisiana)
- https://meshview.meshcolombia.co/ (Colombia)
--- ---
## Installing ## Installing
@@ -60,23 +67,27 @@ Clone the repo from GitHub:
git clone https://github.com/pablorevilla-meshtastic/meshview.git git clone https://github.com/pablorevilla-meshtastic/meshview.git
``` ```
Create a Python virtual environment:
```bash ```bash
cd meshview cd meshview
python3 -m venv env ```
Create a Python virtual environment:
from the meshview directory...
```bash
uv venv env || python3 -m venv env
``` ```
Install the environment requirements: Install the environment requirements:
```bash ```bash
./env/bin/pip install -r requirements.txt uv pip install -r requirements.txt || ./env/bin/pip install -r requirements.txt
``` ```
Install `graphviz`: Install `graphviz` on MacOS or Debian/Ubuntu Linux:
```bash ```bash
sudo apt-get install graphviz [ "$(uname)" = "Darwin" ] && brew install graphviz
[ "$(uname)" = "Linux" ] && sudo apt-get install graphviz
``` ```
Copy `sample.config.ini` to `config.ini`: Copy `sample.config.ini` to `config.ini`:
@@ -131,6 +142,9 @@ title = Bay Area Mesh
# A brief message shown on the homepage. # A brief message shown on the homepage.
message = Real time data from around the bay area and beyond. message = Real time data from around the bay area and beyond.
# Starting URL when loading the index page.
starting = /chat
# Enable or disable site features (as strings: "True" or "False"). # Enable or disable site features (as strings: "True" or "False").
nodes = True nodes = True
conversations = True conversations = True
@@ -142,16 +156,21 @@ map = True
top = True top = True
# Map boundaries (used for the map view). # Map boundaries (used for the map view).
# Defaults will show the San Francisco Bay Area
map_top_left_lat = 39 map_top_left_lat = 39
map_top_left_lon = -123 map_top_left_lon = -123
map_bottom_right_lat = 36 map_bottom_right_lat = 36
map_bottom_right_lon = -121 map_bottom_right_lon = -121
# Updates intervals in seconds, zero or negative number means no updates
# defaults will be 3 seconds
map_interval=3
firehose_interval=3
# Weekly net details # Weekly net details
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. 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 net_tag = #BayMeshNet
# ------------------------- # -------------------------
# MQTT Broker Configuration # MQTT Broker Configuration
# ------------------------- # -------------------------
@@ -160,7 +179,7 @@ net_tag = #BayMeshNet
server = mqtt.bayme.sh server = mqtt.bayme.sh
# Topics to subscribe to (as JSON-like list, but still a string). # Topics to subscribe to (as JSON-like list, but still a string).
topics = ["msh/US/bayarea/#", "msh/US/CA/mrymesh/#", "msh/US/CA/sacvalley/#"] topics = ["msh/US/bayarea/#", "msh/US/CA/mrymesh/#", "msh/US/CA/sacvalley"]
# Port used by MQTT (typically 1883 for unencrypted). # Port used by MQTT (typically 1883 for unencrypted).
port = 1883 port = 1883
@@ -176,13 +195,28 @@ password = large4cats
[database] [database]
# SQLAlchemy connection string. This one uses SQLite with asyncio support. # SQLAlchemy connection string. This one uses SQLite with asyncio support.
connection_string = sqlite+aiosqlite:///packets.db connection_string = sqlite+aiosqlite:///packets.db
# -------------------------
# Database Cleanup Configuration
# -------------------------
[cleanup]
# Enable or disable daily cleanup
enabled = False
# Number of days to keep records in the database
days_to_keep = 14
# Time to run daily cleanup (24-hour format)
hour = 2
minute = 00
# Run VACUUM after cleanup
vacuum = False
``` ```
--- ---
## Running Meshview ## Running Meshview
Start the database: Start the database manager:
```bash ```bash
./env/bin/python startdb.py ./env/bin/python startdb.py
@@ -303,6 +337,27 @@ sudo systemctl daemon-reload
``` ```
## 5. Database Maintenance ## 5. Database Maintenance
### Database maintnance can now be done via the script itself here is the section from the configuration file.
- Simple to setup
- It will not drop any packets
```
# -------------------------
# Database Cleanup Configuration
# -------------------------
[cleanup]
# Enable or disable daily cleanup
enabled = False
# Number of days to keep records in the database
days_to_keep = 14
# Time to run daily cleanup (24-hour format)
hour = 2
minute = 00
# Run VACUUM after cleanup
vacuum = False
```
Once changes are done you need to restart the script for changes to load.
### Alternatively we can do it via your OS
- Create and save bash script below. (Modify /path/to/file/ to the correct path) - Create and save bash script below. (Modify /path/to/file/ to the correct path)
- Name it cleanup.sh - Name it cleanup.sh
- Make it executable. - Make it executable.
File diff suppressed because one or more lines are too long
+22 -8
View File
@@ -116,6 +116,13 @@ class Config(google.protobuf.message.Message):
but should not be given priority over other routers in order to avoid unnecessaraily but should not be given priority over other routers in order to avoid unnecessaraily
consuming hops. consuming hops.
""" """
CLIENT_BASE: Config.DeviceConfig._Role.ValueType # 12
"""
Description: Treats packets from or to favorited nodes as ROUTER, and all other packets as CLIENT.
Technical Details: Used for stronger attic/roof nodes to distribute messages more widely
from weaker, indoor, or less-well-positioned nodes. Recommended for users with multiple nodes
where one CLIENT_BASE acts as a more powerful base station, such as an attic/roof node.
"""
class Role(_Role, metaclass=_RoleEnumTypeWrapper): class Role(_Role, metaclass=_RoleEnumTypeWrapper):
""" """
@@ -200,6 +207,13 @@ class Config(google.protobuf.message.Message):
but should not be given priority over other routers in order to avoid unnecessaraily but should not be given priority over other routers in order to avoid unnecessaraily
consuming hops. consuming hops.
""" """
CLIENT_BASE: Config.DeviceConfig.Role.ValueType # 12
"""
Description: Treats packets from or to favorited nodes as ROUTER, and all other packets as CLIENT.
Technical Details: Used for stronger attic/roof nodes to distribute messages more widely
from weaker, indoor, or less-well-positioned nodes. Recommended for users with multiple nodes
where one CLIENT_BASE acts as a more powerful base station, such as an attic/roof node.
"""
class _RebroadcastMode: class _RebroadcastMode:
ValueType = typing.NewType("ValueType", builtins.int) ValueType = typing.NewType("ValueType", builtins.int)
@@ -1048,12 +1062,12 @@ class Config(google.protobuf.message.Message):
""" """
OLED_SH1107: Config.DisplayConfig._OledType.ValueType # 3 OLED_SH1107: Config.DisplayConfig._OledType.ValueType # 3
""" """
Can not be auto detected but set by proto. Used for 128x128 screens
"""
OLED_SH1107_128_64: Config.DisplayConfig._OledType.ValueType # 4
"""
Can not be auto detected but set by proto. Used for 128x64 screens Can not be auto detected but set by proto. Used for 128x64 screens
""" """
OLED_SH1107_128_128: Config.DisplayConfig._OledType.ValueType # 4
"""
Can not be auto detected but set by proto. Used for 128x128 screens
"""
class OledType(_OledType, metaclass=_OledTypeEnumTypeWrapper): class OledType(_OledType, metaclass=_OledTypeEnumTypeWrapper):
""" """
@@ -1074,12 +1088,12 @@ class Config(google.protobuf.message.Message):
""" """
OLED_SH1107: Config.DisplayConfig.OledType.ValueType # 3 OLED_SH1107: Config.DisplayConfig.OledType.ValueType # 3
""" """
Can not be auto detected but set by proto. Used for 128x128 screens
"""
OLED_SH1107_128_64: Config.DisplayConfig.OledType.ValueType # 4
"""
Can not be auto detected but set by proto. Used for 128x64 screens Can not be auto detected but set by proto. Used for 128x64 screens
""" """
OLED_SH1107_128_128: Config.DisplayConfig.OledType.ValueType # 4
"""
Can not be auto detected but set by proto. Used for 128x128 screens
"""
class _DisplayMode: class _DisplayMode:
ValueType = typing.NewType("ValueType", builtins.int) ValueType = typing.NewType("ValueType", builtins.int)
+2 -2
View File
@@ -13,7 +13,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/device_ui.proto\x12\x13meshtastic.protobuf\"\xda\x04\n\x0e\x44\x65viceUIConfig\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x19\n\x11screen_brightness\x18\x02 \x01(\r\x12\x16\n\x0escreen_timeout\x18\x03 \x01(\r\x12\x13\n\x0bscreen_lock\x18\x04 \x01(\x08\x12\x15\n\rsettings_lock\x18\x05 \x01(\x08\x12\x10\n\x08pin_code\x18\x06 \x01(\r\x12)\n\x05theme\x18\x07 \x01(\x0e\x32\x1a.meshtastic.protobuf.Theme\x12\x15\n\ralert_enabled\x18\x08 \x01(\x08\x12\x16\n\x0e\x62\x61nner_enabled\x18\t \x01(\x08\x12\x14\n\x0cring_tone_id\x18\n \x01(\r\x12/\n\x08language\x18\x0b \x01(\x0e\x32\x1d.meshtastic.protobuf.Language\x12\x34\n\x0bnode_filter\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.NodeFilter\x12:\n\x0enode_highlight\x18\r \x01(\x0b\x32\".meshtastic.protobuf.NodeHighlight\x12\x18\n\x10\x63\x61libration_data\x18\x0e \x01(\x0c\x12*\n\x08map_data\x18\x0f \x01(\x0b\x32\x18.meshtastic.protobuf.Map\x12\x36\n\x0c\x63ompass_mode\x18\x10 \x01(\x0e\x32 .meshtastic.protobuf.CompassMode\x12\x18\n\x10screen_rgb_color\x18\x11 \x01(\r\x12\x1b\n\x13is_clockface_analog\x18\x12 \x01(\x08\"\xa7\x01\n\nNodeFilter\x12\x16\n\x0eunknown_switch\x18\x01 \x01(\x08\x12\x16\n\x0eoffline_switch\x18\x02 \x01(\x08\x12\x19\n\x11public_key_switch\x18\x03 \x01(\x08\x12\x11\n\thops_away\x18\x04 \x01(\x05\x12\x17\n\x0fposition_switch\x18\x05 \x01(\x08\x12\x11\n\tnode_name\x18\x06 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\x05\"~\n\rNodeHighlight\x12\x13\n\x0b\x63hat_switch\x18\x01 \x01(\x08\x12\x17\n\x0fposition_switch\x18\x02 \x01(\x08\x12\x18\n\x10telemetry_switch\x18\x03 \x01(\x08\x12\x12\n\niaq_switch\x18\x04 \x01(\x08\x12\x11\n\tnode_name\x18\x05 \x01(\t\"=\n\x08GeoPoint\x12\x0c\n\x04zoom\x18\x01 \x01(\x05\x12\x10\n\x08latitude\x18\x02 \x01(\x05\x12\x11\n\tlongitude\x18\x03 \x01(\x05\"U\n\x03Map\x12+\n\x04home\x18\x01 \x01(\x0b\x32\x1d.meshtastic.protobuf.GeoPoint\x12\r\n\x05style\x18\x02 \x01(\t\x12\x12\n\nfollow_gps\x18\x03 \x01(\x08*>\n\x0b\x43ompassMode\x12\x0b\n\x07\x44YNAMIC\x10\x00\x12\x0e\n\nFIXED_RING\x10\x01\x12\x12\n\x0e\x46REEZE_HEADING\x10\x02*%\n\x05Theme\x12\x08\n\x04\x44\x41RK\x10\x00\x12\t\n\x05LIGHT\x10\x01\x12\x07\n\x03RED\x10\x02*\xa9\x02\n\x08Language\x12\x0b\n\x07\x45NGLISH\x10\x00\x12\n\n\x06\x46RENCH\x10\x01\x12\n\n\x06GERMAN\x10\x02\x12\x0b\n\x07ITALIAN\x10\x03\x12\x0e\n\nPORTUGUESE\x10\x04\x12\x0b\n\x07SPANISH\x10\x05\x12\x0b\n\x07SWEDISH\x10\x06\x12\x0b\n\x07\x46INNISH\x10\x07\x12\n\n\x06POLISH\x10\x08\x12\x0b\n\x07TURKISH\x10\t\x12\x0b\n\x07SERBIAN\x10\n\x12\x0b\n\x07RUSSIAN\x10\x0b\x12\t\n\x05\x44UTCH\x10\x0c\x12\t\n\x05GREEK\x10\r\x12\r\n\tNORWEGIAN\x10\x0e\x12\r\n\tSLOVENIAN\x10\x0f\x12\r\n\tUKRAINIAN\x10\x10\x12\r\n\tBULGARIAN\x10\x11\x12\x16\n\x12SIMPLIFIED_CHINESE\x10\x1e\x12\x17\n\x13TRADITIONAL_CHINESE\x10\x1f\x42\x63\n\x13\x63om.geeksville.meshB\x0e\x44\x65viceUIProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3') DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/device_ui.proto\x12\x13meshtastic.protobuf\"\xda\x04\n\x0e\x44\x65viceUIConfig\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x19\n\x11screen_brightness\x18\x02 \x01(\r\x12\x16\n\x0escreen_timeout\x18\x03 \x01(\r\x12\x13\n\x0bscreen_lock\x18\x04 \x01(\x08\x12\x15\n\rsettings_lock\x18\x05 \x01(\x08\x12\x10\n\x08pin_code\x18\x06 \x01(\r\x12)\n\x05theme\x18\x07 \x01(\x0e\x32\x1a.meshtastic.protobuf.Theme\x12\x15\n\ralert_enabled\x18\x08 \x01(\x08\x12\x16\n\x0e\x62\x61nner_enabled\x18\t \x01(\x08\x12\x14\n\x0cring_tone_id\x18\n \x01(\r\x12/\n\x08language\x18\x0b \x01(\x0e\x32\x1d.meshtastic.protobuf.Language\x12\x34\n\x0bnode_filter\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.NodeFilter\x12:\n\x0enode_highlight\x18\r \x01(\x0b\x32\".meshtastic.protobuf.NodeHighlight\x12\x18\n\x10\x63\x61libration_data\x18\x0e \x01(\x0c\x12*\n\x08map_data\x18\x0f \x01(\x0b\x32\x18.meshtastic.protobuf.Map\x12\x36\n\x0c\x63ompass_mode\x18\x10 \x01(\x0e\x32 .meshtastic.protobuf.CompassMode\x12\x18\n\x10screen_rgb_color\x18\x11 \x01(\r\x12\x1b\n\x13is_clockface_analog\x18\x12 \x01(\x08\"\xa7\x01\n\nNodeFilter\x12\x16\n\x0eunknown_switch\x18\x01 \x01(\x08\x12\x16\n\x0eoffline_switch\x18\x02 \x01(\x08\x12\x19\n\x11public_key_switch\x18\x03 \x01(\x08\x12\x11\n\thops_away\x18\x04 \x01(\x05\x12\x17\n\x0fposition_switch\x18\x05 \x01(\x08\x12\x11\n\tnode_name\x18\x06 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\x05\"~\n\rNodeHighlight\x12\x13\n\x0b\x63hat_switch\x18\x01 \x01(\x08\x12\x17\n\x0fposition_switch\x18\x02 \x01(\x08\x12\x18\n\x10telemetry_switch\x18\x03 \x01(\x08\x12\x12\n\niaq_switch\x18\x04 \x01(\x08\x12\x11\n\tnode_name\x18\x05 \x01(\t\"=\n\x08GeoPoint\x12\x0c\n\x04zoom\x18\x01 \x01(\x05\x12\x10\n\x08latitude\x18\x02 \x01(\x05\x12\x11\n\tlongitude\x18\x03 \x01(\x05\"U\n\x03Map\x12+\n\x04home\x18\x01 \x01(\x0b\x32\x1d.meshtastic.protobuf.GeoPoint\x12\r\n\x05style\x18\x02 \x01(\t\x12\x12\n\nfollow_gps\x18\x03 \x01(\x08*>\n\x0b\x43ompassMode\x12\x0b\n\x07\x44YNAMIC\x10\x00\x12\x0e\n\nFIXED_RING\x10\x01\x12\x12\n\x0e\x46REEZE_HEADING\x10\x02*%\n\x05Theme\x12\x08\n\x04\x44\x41RK\x10\x00\x12\t\n\x05LIGHT\x10\x01\x12\x07\n\x03RED\x10\x02*\xb4\x02\n\x08Language\x12\x0b\n\x07\x45NGLISH\x10\x00\x12\n\n\x06\x46RENCH\x10\x01\x12\n\n\x06GERMAN\x10\x02\x12\x0b\n\x07ITALIAN\x10\x03\x12\x0e\n\nPORTUGUESE\x10\x04\x12\x0b\n\x07SPANISH\x10\x05\x12\x0b\n\x07SWEDISH\x10\x06\x12\x0b\n\x07\x46INNISH\x10\x07\x12\n\n\x06POLISH\x10\x08\x12\x0b\n\x07TURKISH\x10\t\x12\x0b\n\x07SERBIAN\x10\n\x12\x0b\n\x07RUSSIAN\x10\x0b\x12\t\n\x05\x44UTCH\x10\x0c\x12\t\n\x05GREEK\x10\r\x12\r\n\tNORWEGIAN\x10\x0e\x12\r\n\tSLOVENIAN\x10\x0f\x12\r\n\tUKRAINIAN\x10\x10\x12\r\n\tBULGARIAN\x10\x11\x12\t\n\x05\x43ZECH\x10\x12\x12\x16\n\x12SIMPLIFIED_CHINESE\x10\x1e\x12\x17\n\x13TRADITIONAL_CHINESE\x10\x1f\x42\x63\n\x13\x63om.geeksville.meshB\x0e\x44\x65viceUIProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals() _globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -26,7 +26,7 @@ if _descriptor._USE_C_DESCRIPTORS == False:
_globals['_THEME']._serialized_start=1177 _globals['_THEME']._serialized_start=1177
_globals['_THEME']._serialized_end=1214 _globals['_THEME']._serialized_end=1214
_globals['_LANGUAGE']._serialized_start=1217 _globals['_LANGUAGE']._serialized_start=1217
_globals['_LANGUAGE']._serialized_end=1514 _globals['_LANGUAGE']._serialized_end=1525
_globals['_DEVICEUICONFIG']._serialized_start=61 _globals['_DEVICEUICONFIG']._serialized_start=61
_globals['_DEVICEUICONFIG']._serialized_end=663 _globals['_DEVICEUICONFIG']._serialized_end=663
_globals['_NODEFILTER']._serialized_start=666 _globals['_NODEFILTER']._serialized_start=666
+8
View File
@@ -165,6 +165,10 @@ class _LanguageEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumT
""" """
Bulgarian Bulgarian
""" """
CZECH: _Language.ValueType # 18
"""
Czech
"""
SIMPLIFIED_CHINESE: _Language.ValueType # 30 SIMPLIFIED_CHINESE: _Language.ValueType # 30
""" """
Simplified Chinese (experimental) Simplified Chinese (experimental)
@@ -251,6 +255,10 @@ BULGARIAN: Language.ValueType # 17
""" """
Bulgarian Bulgarian
""" """
CZECH: Language.ValueType # 18
"""
Czech
"""
SIMPLIFIED_CHINESE: Language.ValueType # 30 SIMPLIFIED_CHINESE: Language.ValueType # 30
""" """
Simplified Chinese (experimental) Simplified Chinese (experimental)
File diff suppressed because one or more lines are too long
+16
View File
@@ -486,6 +486,14 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
MeshSolar is an integrated power management and communication solution designed for outdoor low-power devices. MeshSolar is an integrated power management and communication solution designed for outdoor low-power devices.
https://heltec.org/project/meshsolar/ https://heltec.org/project/meshsolar/
""" """
T_ECHO_LITE: _HardwareModel.ValueType # 109
"""
Lilygo T-Echo Lite
"""
HELTEC_V4: _HardwareModel.ValueType # 110
"""
New Heltec LoRA32 with ESP32-S3 CPU
"""
PRIVATE_HW: _HardwareModel.ValueType # 255 PRIVATE_HW: _HardwareModel.ValueType # 255
""" """
------------------------------------------------------------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------
@@ -955,6 +963,14 @@ HELTEC_MESH_SOLAR: HardwareModel.ValueType # 108
MeshSolar is an integrated power management and communication solution designed for outdoor low-power devices. MeshSolar is an integrated power management and communication solution designed for outdoor low-power devices.
https://heltec.org/project/meshsolar/ https://heltec.org/project/meshsolar/
""" """
T_ECHO_LITE: HardwareModel.ValueType # 109
"""
Lilygo T-Echo Lite
"""
HELTEC_V4: HardwareModel.ValueType # 110
"""
New Heltec LoRA32 with ESP32-S3 CPU
"""
PRIVATE_HW: HardwareModel.ValueType # 255 PRIVATE_HW: HardwareModel.ValueType # 255
""" """
------------------------------------------------------------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -824,6 +824,7 @@ class ModuleConfig(google.protobuf.message.Message):
ENABLED_FIELD_NUMBER: builtins.int ENABLED_FIELD_NUMBER: builtins.int
SENDER_FIELD_NUMBER: builtins.int SENDER_FIELD_NUMBER: builtins.int
SAVE_FIELD_NUMBER: builtins.int SAVE_FIELD_NUMBER: builtins.int
CLEAR_ON_REBOOT_FIELD_NUMBER: builtins.int
enabled: builtins.bool enabled: builtins.bool
""" """
Enable the Range Test Module Enable the Range Test Module
@@ -837,14 +838,20 @@ class ModuleConfig(google.protobuf.message.Message):
Bool value indicating that this node should save a RangeTest.csv file. Bool value indicating that this node should save a RangeTest.csv file.
ESP32 Only ESP32 Only
""" """
clear_on_reboot: builtins.bool
"""
Bool indicating that the node should cleanup / destroy it's RangeTest.csv file.
ESP32 Only
"""
def __init__( def __init__(
self, self,
*, *,
enabled: builtins.bool = ..., enabled: builtins.bool = ...,
sender: builtins.int = ..., sender: builtins.int = ...,
save: builtins.bool = ..., save: builtins.bool = ...,
clear_on_reboot: builtins.bool = ...,
) -> None: ... ) -> None: ...
def ClearField(self, field_name: typing.Literal["enabled", b"enabled", "save", b"save", "sender", b"sender"]) -> None: ... def ClearField(self, field_name: typing.Literal["clear_on_reboot", b"clear_on_reboot", "enabled", b"enabled", "save", b"save", "sender", b"sender"]) -> None: ...
@typing.final @typing.final
class TelemetryConfig(google.protobuf.message.Message): class TelemetryConfig(google.protobuf.message.Message):
File diff suppressed because one or more lines are too long
+8
View File
@@ -199,6 +199,10 @@ class _TelemetrySensorTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wra
""" """
SEN5X PM SENSORS SEN5X PM SENSORS
""" """
TSL2561: _TelemetrySensorType.ValueType # 44
"""
TSL2561 light sensor
"""
class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper): class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper):
""" """
@@ -381,6 +385,10 @@ SEN5X: TelemetrySensorType.ValueType # 43
""" """
SEN5X PM SENSORS SEN5X PM SENSORS
""" """
TSL2561: TelemetrySensorType.ValueType # 44
"""
TSL2561 light sensor
"""
global___TelemetrySensorType = TelemetrySensorType global___TelemetrySensorType = TelemetrySensorType
@typing.final @typing.final
+4 -14
View File
@@ -6,22 +6,12 @@ engine = None
async_session = None async_session = None
def init_database(database_connection_string, read_only=False): def init_database(database_connection_string):
global engine, async_session global engine, async_session
kwargs = {"echo": False} kwargs = {"echo": False}
# Ensure SQLite is opened in read-only mode
if database_connection_string.startswith("sqlite"): database_connection_string += "?mode=ro"
if read_only: kwargs["connect_args"] = {"uri": True}
# Ensure SQLite is opened in read-only mode
database_connection_string += "?mode=ro"
kwargs["connect_args"] = {"uri": True}
else:
kwargs["connect_args"] = {"timeout": 60}
else:
kwargs["pool_size"] = 20
kwargs["max_overflow"] = 50
engine = create_async_engine(database_connection_string, **kwargs) engine = create_async_engine(database_connection_string, **kwargs)
async_session = async_sessionmaker( bind=engine, async_session = async_sessionmaker( bind=engine,
class_=AsyncSession, class_=AsyncSession,
+6
View File
@@ -84,8 +84,10 @@ class PacketSeen(Base):
) )
class Traceroute(Base): class Traceroute(Base):
__tablename__ = "traceroute" __tablename__ = "traceroute"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
packet_id = mapped_column(ForeignKey("packet.id")) packet_id = mapped_column(ForeignKey("packet.id"))
packet: Mapped["Packet"] = relationship( packet: Mapped["Packet"] = relationship(
@@ -95,3 +97,7 @@ class Traceroute(Base):
done: Mapped[bool] = mapped_column(nullable=True) done: Mapped[bool] = mapped_column(nullable=True)
route: Mapped[bytes] = mapped_column(nullable=True) route: Mapped[bytes] = mapped_column(nullable=True)
import_time: Mapped[datetime] = mapped_column(nullable=True) import_time: Mapped[datetime] = mapped_column(nullable=True)
__table_args__ = (
Index("idx_traceroute_import_time", "import_time"),
)
+1 -5
View File
@@ -3,11 +3,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
def init_database(database_connection_string): def init_database(database_connection_string):
global engine, async_session global engine, async_session
kwargs = {} engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 900})
if not database_connection_string.startswith('sqlite'):
kwargs['pool_size'] = 20
kwargs['max_overflow'] = 50
engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 60})
async_session = async_sessionmaker(engine, expire_on_commit=False) async_session = async_sessionmaker(engine, expire_on_commit=False)
async def create_tables(): async def create_tables():
+78 -40
View File
@@ -1,4 +1,5 @@
import datetime import datetime
import re
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import update from sqlalchemy import update
from meshtastic.protobuf.config_pb2 import Config from meshtastic.protobuf.config_pb2 import Config
@@ -9,30 +10,33 @@ from meshview import decode_payload
from meshview.models import Packet, PacketSeen, Node, Traceroute from meshview.models import Packet, PacketSeen, Node, Traceroute
async def process_envelope(topic, env): async def process_envelope(topic, env):
# Checking if the received packet is a MAP_REPORT # Checking if the received packet is a MAP_REPORT
# Update the node table with the firmware version
if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP: if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP:
# Extract the node ID from the packet and format the user ID
node_id = getattr(env.packet, "from") node_id = getattr(env.packet, "from")
user_id = f"!{node_id:0{8}x}" user_id = f"!{node_id:0{8}x}"
# Decode the MAP report payload map_report = decode_payload.decode_payload(
map_report = decode_payload.decode_payload(PortNum.MAP_REPORT_APP, env.packet.decoded.payload) PortNum.MAP_REPORT_APP, env.packet.decoded.payload
)
# Establish an asynchronous database session
async with mqtt_database.async_session() as session: async with mqtt_database.async_session() as session:
try: try:
hw_model = HardwareModel.Name(map_report.hw_model) if hasattr(HardwareModel, 'Name') else "unknown" hw_model = (
role = Config.DeviceConfig.Role.Name(map_report.role) if hasattr(Config.DeviceConfig.Role, HardwareModel.Name(map_report.hw_model)
'Name') else "unknown" if hasattr(HardwareModel, "Name")
node = (await session.execute(select(Node).where(Node.node_id == node_id))).scalar_one_or_none() else "unknown"
)
role = (
Config.DeviceConfig.Role.Name(map_report.role)
if hasattr(Config.DeviceConfig.Role, "Name")
else "unknown"
)
node = (
await session.execute(select(Node).where(Node.node_id == node_id))
).scalar_one_or_none()
# Some nodes might have uplink disabled for the default channel
# and only be sending map reports, so check if it exists yet
if node: if node:
node.node_id = node_id node.node_id = node_id
node.long_name = map_report.long_name node.long_name = map_report.long_name
@@ -46,26 +50,31 @@ async def process_envelope(topic, env):
node.last_update = datetime.datetime.now() node.last_update = datetime.datetime.now()
else: else:
node = Node( node = Node(
id=user_id, node_id=node_id, id=user_id,
long_name=map_report.long_name, short_name=map_report.short_name, node_id=node_id,
hw_model=hw_model, role=role, channel=env.channel_id, long_name=map_report.long_name,
short_name=map_report.short_name,
hw_model=hw_model,
role=role,
channel=env.channel_id,
firmware=map_report.firmware_version, firmware=map_report.firmware_version,
last_lat=map_report.latitude_i, last_long=map_report.longitude_i, last_lat=map_report.latitude_i,
last_long=map_report.longitude_i,
last_update=datetime.datetime.now(), last_update=datetime.datetime.now(),
) )
session.add(node) session.add(node)
except Exception as e: except Exception as e:
print(f"Error processing MAP_REPORT_APP: {e}") print(f"Error processing MAP_REPORT_APP: {e}")
# Commit the changes to the database
await session.commit() await session.commit()
# This ignores any packet that does not have a ID
if not env.packet.id: if not env.packet.id:
return return
async with mqtt_database.async_session() as session: async with mqtt_database.async_session() as session:
result = await session.execute(select(Packet).where(Packet.id == env.packet.id)) result = await session.execute(
select(Packet).where(Packet.id == env.packet.id)
)
new_packet = False new_packet = False
packet = result.scalar_one_or_none() packet = result.scalar_one_or_none()
if not packet: if not packet:
@@ -88,7 +97,6 @@ async def process_envelope(topic, env):
PacketSeen.rx_time == env.packet.rx_time, PacketSeen.rx_time == env.packet.rx_time,
) )
) )
seen = None
if not result.scalar_one_or_none(): if not result.scalar_one_or_none():
seen = PacketSeen( seen = PacketSeen(
packet_id=env.packet.id, packet_id=env.packet.id,
@@ -106,14 +114,32 @@ async def process_envelope(topic, env):
if env.packet.decoded.portnum == PortNum.NODEINFO_APP: if env.packet.decoded.portnum == PortNum.NODEINFO_APP:
try: try:
user = decode_payload.decode_payload(PortNum.NODEINFO_APP, env.packet.decoded.payload) user = decode_payload.decode_payload(
PortNum.NODEINFO_APP, env.packet.decoded.payload
)
if user and user.id: if user and user.id:
node_id = int(user.id[1:], 16) if user.id[0] == "!" else None # ✅ Safe fix: only parse hex IDs, otherwise leave None
hw_model = HardwareModel.Name(user.hw_model) if user.hw_model in HardwareModel.values() else f"unknown({user.hw_model})" if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]):
role = Config.DeviceConfig.Role.Name(user.role) if hasattr(Config.DeviceConfig.Role, node_id = int(user.id[1:], 16)
'Name') else "unknown" else:
node_id = None
node = (await session.execute(select(Node).where(Node.id == user.id))).scalar_one_or_none() hw_model = (
HardwareModel.Name(user.hw_model)
if user.hw_model in HardwareModel.values()
else f"unknown({user.hw_model})"
)
role = (
Config.DeviceConfig.Role.Name(user.role)
if hasattr(Config.DeviceConfig.Role, "Name")
else "unknown"
)
node = (
await session.execute(
select(Node).where(Node.id == user.id)
)
).scalar_one_or_none()
if node: if node:
node.node_id = node_id node.node_id = node_id
@@ -125,9 +151,13 @@ async def process_envelope(topic, env):
node.last_update = datetime.datetime.now() node.last_update = datetime.datetime.now()
else: else:
node = Node( node = Node(
id=user.id, node_id=node_id, id=user.id,
long_name=user.long_name, short_name=user.short_name, node_id=node_id,
hw_model=hw_model, role=role, channel=env.channel_id, long_name=user.long_name,
short_name=user.short_name,
hw_model=hw_model,
role=role,
channel=env.channel_id,
last_update=datetime.datetime.now(), last_update=datetime.datetime.now(),
) )
session.add(node) session.add(node)
@@ -139,8 +169,12 @@ async def process_envelope(topic, env):
PortNum.POSITION_APP, env.packet.decoded.payload PortNum.POSITION_APP, env.packet.decoded.payload
) )
if position and position.latitude_i and position.longitude_i: if position and position.latitude_i and position.longitude_i:
from_node_id = getattr(env.packet, 'from') from_node_id = getattr(env.packet, "from")
node = (await session.execute(select(Node).where(Node.node_id == from_node_id))).scalar_one_or_none() node = (
await session.execute(
select(Node).where(Node.node_id == from_node_id)
)
).scalar_one_or_none()
if node: if node:
node.last_lat = position.latitude_i node.last_lat = position.latitude_i
node.last_long = position.longitude_i node.last_long = position.longitude_i
@@ -151,17 +185,21 @@ async def process_envelope(topic, env):
if env.packet.decoded.want_response: if env.packet.decoded.want_response:
packet_id = env.packet.id packet_id = env.packet.id
else: else:
result = await session.execute(select(Packet).where(Packet.id == env.packet.decoded.request_id)) result = await session.execute(
select(Packet).where(Packet.id == env.packet.decoded.request_id)
)
if result.scalar_one_or_none(): if result.scalar_one_or_none():
packet_id = env.packet.decoded.request_id packet_id = env.packet.decoded.request_id
if packet_id is not None: if packet_id is not None:
session.add(Traceroute( session.add(
packet_id=packet_id, Traceroute(
route=env.packet.decoded.payload, packet_id=packet_id,
done=not env.packet.decoded.want_response, route=env.packet.decoded.payload,
gateway_node_id=int(env.gateway_id[1:], 16), done=not env.packet.decoded.want_response,
import_time=datetime.datetime.now(), gateway_node_id=int(env.gateway_id[1:], 16),
)) import_time=datetime.datetime.now(),
)
)
await session.commit() await session.commit()
if new_packet: if new_packet:
+37 -6
View File
@@ -111,13 +111,16 @@ async def get_traceroute(packet_id):
async def get_traceroutes(since): async def get_traceroutes(since):
async with database.async_session() as session: async with database.async_session() as session:
result = await session.execute( stmt = (
select(Traceroute) select(Traceroute)
.join(Packet) .join(Packet)
.where(Traceroute.import_time > (datetime.now() - since)) .where(Traceroute.import_time > since)
.order_by(Traceroute.import_time) .order_by(Traceroute.import_time)
) )
return result.scalars() stream = await session.stream_scalars(stmt)
async for tr in stream:
yield tr
async def get_mqtt_neighbors(since): async def get_mqtt_neighbors(since):
@@ -340,3 +343,31 @@ async def get_packet_stats(
"from_node": from_node, "from_node": from_node,
"data": data "data": data
} }
async def get_channels_in_period(period_type: str = "hour", length: int = 24):
"""
Returns a list of distinct channels used in packets over a given period.
period_type: "hour" or "day"
length: number of hours or days to look back
"""
now = datetime.now()
if period_type == "hour":
start_time = now - timedelta(hours=length)
elif period_type == "day":
start_time = now - timedelta(days=length)
else:
raise ValueError("period_type must be 'hour' or 'day'")
async with database.async_session() as session:
q = (
select(Packet.channel)
.where(Packet.import_time >= start_time)
.distinct()
.order_by(Packet.channel)
)
result = await session.execute(q)
channels = [row[0] for row in result if row[0] is not None]
return channels
+1 -1
View File
@@ -45,7 +45,7 @@ let portnum = "{{ portnum if portnum is not none else '' }}";
let updatesPaused = false; let updatesPaused = false;
// Use firehose_interval from config (seconds), default to 3s if not set // Use firehose_interval from config (seconds), default to 3s if not set
const firehoseInterval = {{ site_config["site"]["firehose_interal"] | default(3) }}; const firehoseInterval = {{ site_config["site"]["firehose_interval"] | default(3) }};
if (firehoseInterval < 0) firehoseInterval = 0; if (firehoseInterval < 0) firehoseInterval = 0;
function fetchUpdates() { function fetchUpdates() {
+63 -1
View File
@@ -27,6 +27,22 @@
.filter-checkbox { .filter-checkbox {
margin: 0 10px; margin: 0 10px;
} }
#share-button {
margin-left: 20px;
padding: 5px 15px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#share-button:hover {
background-color: #45a049;
}
#share-button:active {
background-color: #3d8b40;
}
.blinking-tooltip { .blinking-tooltip {
background: white; background: white;
color: black; color: black;
@@ -42,6 +58,9 @@
<div id="filter-container"> <div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only <input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
</div> </div>
<div style="text-align: center; margin-top: 5px;">
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
@@ -58,6 +77,17 @@
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map); }).addTo(map);
// Custom view from URL parameters
{% if custom_view %}
var customView = {
lat: {{ custom_view.lat }},
lng: {{ custom_view.lng }},
zoom: {{ custom_view.zoom }}
};
{% else %}
var customView = null;
{% endif %}
// ---- Node Data ---- // ---- Node Data ----
var markers = {}; var markers = {};
var markerById = {}; var markerById = {};
@@ -155,7 +185,13 @@
[{{ site_config["site"]["map_top_left_lat"] }}, {{ site_config["site"]["map_top_left_lon"] }}], [{{ site_config["site"]["map_top_left_lat"] }}, {{ site_config["site"]["map_top_left_lon"] }}],
[{{ site_config["site"]["map_bottom_right_lat"] }}, {{ site_config["site"]["map_bottom_right_lon"] }}] [{{ site_config["site"]["map_bottom_right_lat"] }}, {{ site_config["site"]["map_bottom_right_lon"] }}]
]; ];
map.fitBounds(bayAreaBounds);
// Apply custom view or default bounds
if (customView) {
map.setView([customView.lat, customView.lng], customView.zoom);
} else {
map.fitBounds(bayAreaBounds);
}
// ---- Filters ---- // ---- Filters ----
let filterContainer = document.getElementById("filter-container"); let filterContainer = document.getElementById("filter-container");
@@ -341,6 +377,32 @@
else startPacketFetcher(); else startPacketFetcher();
}); });
// ---- Share Current View ----
function shareCurrentView() {
const center = map.getCenter();
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
// Copy to clipboard
navigator.clipboard.writeText(shareUrl).then(() => {
const button = document.getElementById('share-button');
const originalText = button.textContent;
button.textContent = '✓ Link Copied!';
button.style.backgroundColor = '#2196F3';
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '#4CAF50';
}, 2000);
}).catch(err => {
// Fallback for older browsers
alert('Share this link:\n' + shareUrl);
});
}
// ---- Initialize ---- // ---- Initialize ----
if (mapInterval > 0) startPacketFetcher(); if (mapInterval > 0) startPacketFetcher();
</script> </script>
+6
View File
@@ -114,7 +114,10 @@
<div><span class="legend-box circle" style="background-color: #00c3ff"></span> <code>CLIENT_MUTE</code></div> <div><span class="legend-box circle" style="background-color: #00c3ff"></span> <code>CLIENT_MUTE</code></div>
</div> </div>
<div class="legend-category"> <div class="legend-category">
<div><span class="legend-box circle" style="background-color: #049acd"></span> <code>CLIENT_BASE</code></div>
<div><span class="legend-box circle" style="background-color: #ffbf00"></span> Other</div> <div><span class="legend-box circle" style="background-color: #ffbf00"></span> Other</div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #6c757d"></span> Unknown</div> <div><span class="legend-box circle" style="background-color: #6c757d"></span> Unknown</div>
</div> </div>
</div> </div>
@@ -132,6 +135,7 @@ const colors = {
ROUTER_LATE: '#b65224', ROUTER_LATE: '#b65224',
CLIENT: '#007bff', CLIENT: '#007bff',
CLIENT_MUTE: '#00c3ff', CLIENT_MUTE: '#00c3ff',
CLIENT_BASE: '#049acd',
other: '#ffbf00', other: '#ffbf00',
unknown: '#6c757d', unknown: '#6c757d',
}, },
@@ -148,6 +152,7 @@ function getSymbolSize (role) {
switch (role) { switch (role) {
case 'ROUTER': return 30; case 'ROUTER': return 30;
case 'ROUTER_LATE': return 30; case 'ROUTER_LATE': return 30;
case 'CLIENT_BASE': return 18;
case 'CLIENT': return 15; case 'CLIENT': return 15;
case 'CLIENT_MUTE': return 7; case 'CLIENT_MUTE': return 7;
default: return 15; // Unknown or other roles default: return 15; // Unknown or other roles
@@ -157,6 +162,7 @@ function getSymbolSize (role) {
function getLabel (role, short_name, long_name) { function getLabel (role, short_name, long_name) {
if (role === 'ROUTER') return long_name; if (role === 'ROUTER') return long_name;
if (role === 'ROUTER_LATE') return long_name; if (role === 'ROUTER_LATE') return long_name;
if (role === 'CLIENT_BASE') return short_name;
if (role === 'CLIENT') return short_name; if (role === 'CLIENT') return short_name;
if (role === 'CLIENT_MUTE') return short_name; if (role === 'CLIENT_MUTE') return short_name;
return short_name || ''; return short_name || '';
+149 -70
View File
@@ -62,6 +62,29 @@
.expand-btn:hover { background-color: #666; } .expand-btn:hover { background-color: #666; }
.export-btn:hover { background-color: #777; } .export-btn:hover { background-color: #777; }
/* Summary cards at top */
.summary-card {
background-color: #1f2124;
border: 1px solid #474b4e;
padding: 10px 15px;
margin-bottom: 15px;
border-radius: 8px;
}
.summary-count {
font-size: 18px;
color: #66bb6a;
font-weight: bold;
}
#channelSelect {
margin-bottom: 8px;
padding: 4px 6px;
background:#444;
color:#fff;
border:none;
border-radius:4px;
}
{% endblock %} {% endblock %}
{% block head %} {% block head %}
@@ -70,26 +93,53 @@
{% block body %} {% block body %}
<div class="main-container"> <div class="main-container">
<h2 class="main-header">Mesh Statistics - Hourly Packet Counts (Last 24 Hours)</h2> <h2 class="main-header">Mesh Statistics - Summary (all available in Database)</h2>
{# Daily Charts #} <div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="card-section"> <div class="summary-card" style="flex:1;">
<p class="section-header">Packets per Day - All Ports (Last 14 Days)</p> <p>Total Nodes</p>
<div id="total_daily_all" class="total-count">Total: 0</div> <div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
<button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button> </div>
<button class="export-btn" data-chart="chart_daily_all">Export CSV</button> <div class="summary-card" style="flex:1;">
<div id="chart_daily_all" class="chart"></div> <p>Total Packets</p>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets Seen</p>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
</div>
</div> </div>
<div class="card-section"> <!-- Daily Charts -->
<p class="section-header">Packets per Day - Text Messages (Port 1, Last 14 Days)</p> <div class="card-section">
<div id="total_daily_portnum_1" class="total-count">Total: 0</div> <p class="section-header">Packets per Day - All Ports (Last 14 Days)</p>
<button class="expand-btn" data-chart="chart_daily_portnum_1">Expand Chart</button> <div id="total_daily_all" class="total-count">Total: 0</div>
<button class="export-btn" data-chart="chart_daily_portnum_1">Export CSV</button> <button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button>
<div id="chart_daily_portnum_1" class="chart"></div> <button class="export-btn" data-chart="chart_daily_all">Export CSV</button>
</div> <div id="chart_daily_all" class="chart"></div>
</div>
{# Hourly Charts #} <!-- Packet Types Pie Chart with Channel Selector (moved here) -->
<div class="card-section">
<p class="section-header">Packet Types - Last 24 Hours</p>
<select id="channelSelect">
<option value="">All Channels</option>
</select>
<button class="expand-btn" data-chart="chart_packet_types">Expand Chart</button>
<button class="export-btn" data-chart="chart_packet_types">Export CSV</button>
<div id="chart_packet_types" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_portnum_1">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_portnum_1">Export CSV</button>
<div id="chart_daily_portnum_1" class="chart"></div>
</div>
<!-- Hourly Charts -->
<div class="card-section"> <div class="card-section">
<p class="section-header">Packets per Hour - All Ports</p> <p class="section-header">Packets per Hour - All Ports</p>
<div id="total_hourly_all" class="total-count">Total: 0</div> <div id="total_hourly_all" class="total-count">Total: 0</div>
@@ -106,47 +156,7 @@
<div id="chart_portnum_1" class="chart"></div> <div id="chart_portnum_1" class="chart"></div>
</div> </div>
<div class="card-section"> <!-- Node breakdown charts -->
<p class="section-header">Packets per Hour - Position (Port 3)</p>
<div id="total_portnum_3" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_3">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_3">Export CSV</button>
<div id="chart_portnum_3" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Node Info (Port 4)</p>
<div id="total_portnum_4" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_4">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_4">Export CSV</button>
<div id="chart_portnum_4" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Telemetry (Port 67)</p>
<div id="total_portnum_67" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_67">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_67">Export CSV</button>
<div id="chart_portnum_67" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Traceroute (Port 70)</p>
<div id="total_portnum_70" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_70">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_70">Export CSV</button>
<div id="chart_portnum_70" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Neighbor Info (Port 71)</p>
<div id="total_portnum_71" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_71">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_71">Export CSV</button>
<div id="chart_portnum_71" class="chart"></div>
</div>
{# Node breakdown charts #}
<div class="card-section"> <div class="card-section">
<p class="section-header">Hardware Breakdown</p> <p class="section-header">Hardware Breakdown</p>
<button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button> <button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button>
@@ -169,7 +179,7 @@
</div> </div>
</div> </div>
{# Modal for expanded charts #} <!-- Modal for expanded charts -->
<div id="chartModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; <div id="chartModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.7); z-index:1000; justify-content:center; align-items:center;"> background:rgba(0,0,0,0.7); z-index:1000; justify-content:center; align-items:center;">
<div style="position:relative; width:80%; max-width:1000px; height:80%; <div style="position:relative; width:80%; max-width:1000px; height:80%;
@@ -183,11 +193,21 @@
</div> </div>
<script> <script>
const PORTNUM_LABELS = {
1: "Text Messages",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info"
};
// --- Fetch & Processing --- // --- Fetch & Processing ---
async function fetchStats(period_type,length,portnum=null){ async function fetchStats(period_type,length,portnum=null,channel=null){
try{ try{
let url=`/api/stats?period_type=${period_type}&length=${length}`; let url=`/api/stats?period_type=${period_type}&length=${length}`;
if(portnum!==null) url+=`&portnum=${portnum}`; if(portnum!==null) url+=`&portnum=${portnum}`;
if(channel) url+=`&channel=${channel}`;
const res=await fetch(url); const res=await fetch(url);
if(!res.ok) return []; if(!res.ok) return [];
const json=await res.json(); const json=await res.json();
@@ -203,6 +223,14 @@ async function fetchNodes(){
}catch{return [];} }catch{return [];}
} }
async function fetchChannels(){
try{
const res = await fetch("/api/channels");
const json = await res.json();
return json.channels || [];
}catch{return [];}
}
function processCountField(nodes,field){ function processCountField(nodes,field){
const counts={}; const counts={};
nodes.forEach(n=>{ nodes.forEach(n=>{
@@ -269,12 +297,43 @@ function renderPieChart(elId,data,name){
return chart; return chart;
} }
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
const portnums = [1,3,4,67,70,71];
const requests = portnums.map(async pn => {
const data = await fetchStats('hour',24,pn,channel);
const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
return {portnum: pn, count: total};
});
const allData = await fetchStats('hour',24,null,channel);
const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
const results = await Promise.all(requests);
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
const other = Math.max(totalAll - trackedTotal,0);
if(other>0) results.push({portnum:"other", count:other});
return results;
}
// --- Init --- // --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71; let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1; let chartDailyAll, chartDailyPortnum1;
let chartHwModel, chartRole, chartChannel; let chartHwModel, chartRole, chartChannel;
let chartPacketTypes;
async function init(){ async function init(){
// Populate channels
const channels = await fetchChannels();
const select = document.getElementById("channelSelect");
channels.forEach(ch=>{
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
});
// Daily
const dailyAllData=await fetchStats('day',14); const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData); updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false); chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false);
@@ -283,6 +342,7 @@ async function init(){
updateTotalCount('total_daily_portnum_1',dailyPort1Data); updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false); chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false);
// Hourly
const hourlyAllData=await fetchStats('hour',24); const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData); updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6',true); chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6',true);
@@ -297,16 +357,25 @@ async function init(){
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i],true); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i],true);
} }
// Node Breakdown
const nodes=await fetchNodes(); const nodes=await fetchNodes();
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware"); chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role"); chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel"); chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
// Packet Type Pie Chart
const packetTypesData = await fetchPacketTypeBreakdown();
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
} }
// --- Resize --- // --- Resize ---
window.addEventListener('resize',()=>{ window.addEventListener('resize',()=>{
[chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71,
chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel].forEach(c=>c?.resize()); chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize());
}); });
// --- Modal --- // --- Modal ---
@@ -317,25 +386,23 @@ let modalChart=null;
document.querySelectorAll(".expand-btn").forEach(btn=>{ document.querySelectorAll(".expand-btn").forEach(btn=>{
btn.addEventListener("click",()=>{ btn.addEventListener("click",()=>{
const chartId=btn.getAttribute("data-chart"); const chartId=btn.getAttribute("data-chart");
const chartInstance=echarts.getInstanceByDom(document.getElementById(chartId)); const sourceChart=echarts.getInstanceByDom(document.getElementById(chartId));
if(!chartInstance) return; if(!sourceChart)return;
const chartData=chartInstance.getOption();
modal.style.display="flex"; modal.style.display="flex";
if(modalChart) modalChart.dispose();
modalChart=echarts.init(modalChartEl); modalChart=echarts.init(modalChartEl);
modalChart.setOption(chartData); modalChart.setOption(sourceChart.getOption());
modalChart.resize();
}); });
}); });
document.getElementById("closeModal").addEventListener("click",()=>{ document.getElementById("closeModal").addEventListener("click",()=>{
modal.style.display="none"; modal.style.display="none";
if(modalChart) modalChart.dispose(); modalChart?.dispose();
modalChart=null;
}); });
// --- CSV Export --- // --- CSV Export ---
function downloadCSV(filename,rows){ function downloadCSV(filename,rows){
const csvContent=rows.map(e=>e.join(",")).join("\n"); const csvContent=rows.map(r=>r.map(v=>`"${v}"`).join(",")).join("\n");
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"}); const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
const link=document.createElement("a"); const link=document.createElement("a");
link.href=URL.createObjectURL(blob); link.href=URL.createObjectURL(blob);
@@ -349,13 +416,13 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
btn.addEventListener("click",()=>{ btn.addEventListener("click",()=>{
const chartId=btn.getAttribute("data-chart"); const chartId=btn.getAttribute("data-chart");
const chart=echarts.getInstanceByDom(document.getElementById(chartId)); const chart=echarts.getInstanceByDom(document.getElementById(chartId));
if(!chart) return; if(!chart)return;
const option=chart.getOption(); const option=chart.getOption();
let rows=[]; let rows=[];
if(option.series[0].type==="bar"||option.series[0].type==="line"){ if(option.series[0].type==="bar"||option.series[0].type==="line"){
rows.push(["Period","Count"]);
const xData=option.xAxis[0].data; const xData=option.xAxis[0].data;
const yData=option.series[0].data; const yData=option.series[0].data;
rows.push(["Period","Count"]);
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]); for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
} }
if(option.series[0].type==="pie"){ if(option.series[0].type==="pie"){
@@ -370,6 +437,18 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
}); });
}); });
// --- Channel filter for Packet Types ---
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
const packetTypesData = await fetchPacketTypeBreakdown(channel);
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
chartPacketTypes?.dispose();
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
});
init(); init();
</script> </script>
{% endblock %} {% endblock %}
+74 -41
View File
@@ -24,7 +24,7 @@ import traceback
import pathlib import pathlib
SEQ_REGEX = re.compile(r"seq \d+") SEQ_REGEX = re.compile(r"seq \d+")
SOFTWARE_RELEASE= "2.0.6 ~ 09-15-25" SOFTWARE_RELEASE= "2.0.7 ~ 09-17-25"
CONFIG = config.CONFIG CONFIG = config.CONFIG
env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape()) env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape())
@@ -899,7 +899,7 @@ async def graph_network(request):
node_ids = set() node_ids = set()
traceroutes = [] traceroutes = []
for tr in await store.get_traceroutes(since): async for tr in store.get_traceroutes(since):
node_ids.add(tr.gateway_node_id) node_ids.add(tr.gateway_node_id)
node_ids.add(tr.packet.from_node_id) node_ids.add(tr.packet.from_node_id)
node_ids.add(tr.packet.to_node_id) node_ids.add(tr.packet.to_node_id)
@@ -1135,11 +1135,30 @@ async def map(request):
for node in nodes: for node in nodes:
if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime): if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime):
node.last_update = node.last_update.isoformat() node.last_update = node.last_update.isoformat()
# Parse optional URL parameters for custom view
map_center_lat = request.query.get("lat")
map_center_lng = request.query.get("lng")
map_zoom = request.query.get("zoom")
# Validate and convert parameters if provided
custom_view = None
if map_center_lat and map_center_lng:
try:
lat = float(map_center_lat)
lng = float(map_center_lng)
zoom = int(map_zoom) if map_zoom else 13
custom_view = {"lat": lat, "lng": lng, "zoom": zoom}
except (ValueError, TypeError):
# Invalid parameters, ignore and use defaults
pass
template = env.get_template("map.html") template = env.get_template("map.html")
return web.Response( return web.Response(
text=template.render( text=template.render(
nodes=nodes, nodes=nodes,
custom_view=custom_view,
site_config=CONFIG, site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE), SOFTWARE_RELEASE=SOFTWARE_RELEASE),
content_type="text/html", content_type="text/html",
@@ -1244,7 +1263,7 @@ async def nodegraph(request):
traceroutes = [] traceroutes = []
# Fetch traceroutes # Fetch traceroutes
for tr in await store.get_traceroutes(since): async for tr in store.get_traceroutes(since):
node_ids.add(tr.gateway_node_id) node_ids.add(tr.gateway_node_id)
node_ids.add(tr.packet.from_node_id) node_ids.add(tr.packet.from_node_id)
node_ids.add(tr.packet.to_node_id) node_ids.add(tr.packet.to_node_id)
@@ -1340,6 +1359,17 @@ async def get_config(request):
# The response includes "latest_import_time" for frontend to keep track of the newest message timestamp. # The response includes "latest_import_time" for frontend to keep track of the newest message timestamp.
# The backend fetches extra packets (limit*5) to account for filtering messages like "seq N" and since filtering. # The backend fetches extra packets (limit*5) to account for filtering messages like "seq N" and since filtering.
@routes.get("/api/channels")
async def api_channels(request: web.Request):
period_type = request.query.get("period_type", "hour")
length = int(request.query.get("length", 24))
try:
channels = await store.get_channels_in_period(period_type, length)
return web.json_response({"channels": channels})
except Exception as e:
return web.json_response({"channels": [], "error": str(e)})
@routes.get("/api/chat") @routes.get("/api/chat")
async def api_chat(request): async def api_chat(request):
@@ -1579,59 +1609,62 @@ async def api_stats(request):
return web.json_response(stats) return web.json_response(stats)
@routes.get("/api/config") @routes.get("/api/config")
async def api_config(request): async def api_config(request):
try: try:
# Return CONFIG as JSON site = CONFIG.get("site", {})
return web.json_response(CONFIG) safe_site = {
"map_interval": site.get("map_interval", 3), # default 3 if missing
"firehose_interval": site.get("firehose_interval", 3) # default 3 if missing
}
safe_config = {"site": safe_site}
return web.json_response(safe_config)
except Exception as e: except Exception as e:
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
@routes.get("/api/edges") @routes.get("/api/edges")
async def api_edges(request): async def api_edges(request):
edges_set = set()
edge_type = {}
since = datetime.datetime.now() - datetime.timedelta(hours=48) since = datetime.datetime.now() - datetime.timedelta(hours=48)
filter_type = request.query.get("type")
# Get optional type filter from query string edges = {}
filter_type = request.query.get("type") # None if not provided
# Fetch traceroutes # Only build traceroute edges if requested
for tr in await store.get_traceroutes(since): if filter_type in (None, "traceroute"):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route) async for tr in store.get_traceroutes(since):
path = [tr.packet.from_node_id] + list(route.route) try:
if tr.done: route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
path.append(tr.packet.to_node_id) except Exception as e:
else: print(f"Error decoding Traceroute {tr.id}: {e}")
if path[-1] != tr.gateway_node_id: continue
path.append(tr.gateway_node_id)
for i in range(len(path) - 1): path = [tr.packet.from_node_id] + list(route.route)
edge_pair = (path[i], path[i + 1]) path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
edges_set.add(edge_pair)
edge_type[edge_pair] = "traceroute"
# Fetch NeighborInfo packets for a, b in zip(path, path[1:]):
for packet in await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since): edges[(a, b)] = "traceroute"
try:
_, neighbor_info = decode_payload.decode(packet)
for node in neighbor_info.neighbors:
edge_pair = (node.node_id, packet.from_node_id)
if edge_pair not in edges_set:
edges_set.add(edge_pair)
edge_type[edge_pair] = "neighbor"
except Exception as e:
print(f"Error decoding NeighborInfo packet: {e}")
# Prepare edges with optional filtering by type # Only build neighbor edges if requested
edges = [ if filter_type in (None, "neighbor"):
{"from": frm, "to": to, "type": typ} packets = await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since)
for (frm, to), typ in edge_type.items() for packet in packets:
if filter_type is None or typ == filter_type try:
] _, neighbor_info = decode_payload.decode(packet)
for node in neighbor_info.neighbors:
edges.setdefault((node.node_id, packet.from_node_id), "neighbor")
except Exception as e:
print(f"Error decoding NeighborInfo packet {getattr(packet, 'id', '?')}: {e}")
return web.json_response({
"edges": [
{"from": a, "to": b, "type": typ}
for (a, b), typ in edges.items()
]
})
return web.json_response({"edges": edges})
# Generic static HTML route # Generic static HTML route
+15 -5
View File
@@ -56,11 +56,6 @@ 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. 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 net_tag = #BayMeshNet
# Updates intervals in seconds, zero or negative number means no updates
# defaults will be 3 seconds
map_interval=3
firehose_interal=3
# ------------------------- # -------------------------
# MQTT Broker Configuration # MQTT Broker Configuration
# ------------------------- # -------------------------
@@ -85,3 +80,18 @@ password = large4cats
[database] [database]
# SQLAlchemy connection string. This one uses SQLite with asyncio support. # SQLAlchemy connection string. This one uses SQLite with asyncio support.
connection_string = sqlite+aiosqlite:///packets.db connection_string = sqlite+aiosqlite:///packets.db
# -------------------------
# Database Cleanup Configuration
# -------------------------
[cleanup]
# Enable or disable daily cleanup
enabled = False
# Number of days to keep records in the database
days_to_keep = 14
# Time to run daily cleanup (24-hour format)
hour = 2
minute = 00
# Run VACUUM after cleanup
vacuum = False
+153 -35
View File
@@ -1,51 +1,169 @@
import asyncio import asyncio
import argparse import json
import configparser import datetime
import logging
from sqlalchemy import delete
from meshview import mqtt_reader from meshview import mqtt_reader
from meshview import mqtt_database from meshview import mqtt_database
from meshview import mqtt_store from meshview import mqtt_store
import json from meshview import models
from meshview.config import CONFIG
# -------------------------
# Logging for cleanup
# -------------------------
cleanup_logger = logging.getLogger("dbcleanup")
cleanup_logger.setLevel(logging.INFO)
file_handler = logging.FileHandler("dbcleanup.log")
file_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
file_handler.setFormatter(formatter)
cleanup_logger.addHandler(file_handler)
async def load_database_from_mqtt(mqtt_server: str , mqtt_port: int, topic: list, mqtt_user: str | None = None, mqtt_passwd: str | None = None): # -------------------------
async for topic, env in mqtt_reader.get_topic_envelopes(mqtt_server, mqtt_port, topic, mqtt_user, mqtt_passwd): # Helper functions
await mqtt_store.process_envelope(topic, env) # -------------------------
def get_bool(config, section, key, default=False):
return str(config.get(section, {}).get(key, default)).lower() in ("1", "true", "yes", "on")
def get_int(config, section, key, default=0):
try:
return int(config.get(section, {}).get(key, default))
except ValueError:
return default
async def main(config): # -------------------------
mqtt_database.init_database(config["database"]["connection_string"]) # Shared DB lock
# -------------------------
db_lock = asyncio.Lock()
# -------------------------
# Database cleanup using ORM
# -------------------------
async def daily_cleanup_at(
hour: int = 2,
minute: int = 0,
days_to_keep: int = 14,
vacuum_db: bool = True
):
while True:
now = datetime.datetime.now()
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_run <= now:
next_run += datetime.timedelta(days=1)
delay = (next_run - now).total_seconds()
cleanup_logger.info(f"Next cleanup scheduled at {next_run}")
await asyncio.sleep(delay)
# Local-time cutoff as string for SQLite DATETIME comparison
cutoff = (datetime.datetime.now() - datetime.timedelta(days=days_to_keep)).strftime("%Y-%m-%d %H:%M:%S")
cleanup_logger.info(f"Running cleanup for records older than {cutoff}...")
try:
async with db_lock: # Pause ingestion
cleanup_logger.info("Ingestion paused for cleanup.")
async with mqtt_database.async_session() as session:
# -------------------------
# Packet
# -------------------------
result = await session.execute(
delete(models.Packet).where(models.Packet.import_time < cutoff)
)
cleanup_logger.info(f"Deleted {result.rowcount} rows from Packet")
# -------------------------
# PacketSeen
# -------------------------
result = await session.execute(
delete(models.PacketSeen).where(models.PacketSeen.import_time < cutoff)
)
cleanup_logger.info(f"Deleted {result.rowcount} rows from PacketSeen")
# -------------------------
# Traceroute
# -------------------------
result = await session.execute(
delete(models.Traceroute).where(models.Traceroute.import_time < cutoff)
)
cleanup_logger.info(f"Deleted {result.rowcount} rows from Traceroute")
# -------------------------
# Node
# -------------------------
result = await session.execute(
delete(models.Node).where(models.Node.last_update < cutoff)
)
cleanup_logger.info(f"Deleted {result.rowcount} rows from Node")
await session.commit()
if vacuum_db:
cleanup_logger.info("Running VACUUM...")
async with mqtt_database.engine.begin() as conn:
await conn.exec_driver_sql("VACUUM;")
cleanup_logger.info("VACUUM completed.")
cleanup_logger.info("Cleanup completed successfully.")
cleanup_logger.info("Ingestion resumed after cleanup.")
except Exception as e:
cleanup_logger.error(f"Error during cleanup: {e}")
# -------------------------
# MQTT loading
# -------------------------
async def load_database_from_mqtt(
mqtt_server: str,
mqtt_port: int,
topics: list,
mqtt_user: str | None = None,
mqtt_passwd: str | None = None
):
async for topic, env in mqtt_reader.get_topic_envelopes(
mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd
):
async with db_lock: # Block if cleanup is running
await mqtt_store.process_envelope(topic, env)
# -------------------------
# Main function
# -------------------------
async def main():
# Initialize database
mqtt_database.init_database(CONFIG["database"]["connection_string"])
await mqtt_database.create_tables() await mqtt_database.create_tables()
mqtt_user = None
mqtt_passwd = None mqtt_user = CONFIG["mqtt"].get("username") or None
if config["mqtt"]["username"] != "": mqtt_passwd = CONFIG["mqtt"].get("password") or None
mqtt_user: str = config["mqtt"]["username"] mqtt_topics = json.loads(CONFIG["mqtt"]["topics"])
if config["mqtt"]["password"] != "":
mqtt_passwd: str = config["mqtt"]["password"] cleanup_enabled = get_bool(CONFIG, "cleanup", "enabled", False)
mqtt_topics = json.loads(config["mqtt"]["topics"]) cleanup_days = get_int(CONFIG, "cleanup", "days_to_keep", 14)
vacuum_db = get_bool(CONFIG, "cleanup", "vacuum", False)
cleanup_hour = get_int(CONFIG, "cleanup", "hour", 2)
cleanup_minute = get_int(CONFIG, "cleanup", "minute", 0)
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
tg.create_task( tg.create_task(
load_database_from_mqtt(config["mqtt"]["server"], int(config["mqtt"]["port"]), mqtt_topics, mqtt_user, mqtt_passwd) load_database_from_mqtt(
CONFIG["mqtt"]["server"],
int(CONFIG["mqtt"]["port"]),
mqtt_topics,
mqtt_user,
mqtt_passwd,
)
) )
def load_config(file_path):
"""Load configuration from an INI-style text file."""
config_parser = configparser.ConfigParser()
config_parser.read(file_path)
# Convert to a dictionary for easier access
config = {section: dict(config_parser.items(section)) for section in config_parser.sections()}
return config
if cleanup_enabled:
tg.create_task(
daily_cleanup_at(cleanup_hour, cleanup_minute, cleanup_days, vacuum_db)
)
else:
cleanup_logger.info("Daily cleanup is disabled by configuration.")
# -------------------------
# Entry point
# -------------------------
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser("meshview") asyncio.run(main())
parser.add_argument("--config", help="Path to the configuration file.", default='config.ini')
args = parser.parse_args()
config = load_config(args.config)
asyncio.run(main(config))