mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d18746ef | ||
|
|
7146f69beb | ||
|
|
db8703919d | ||
|
|
baeaf29df0 | ||
|
|
44ddfe7ed7 | ||
|
|
fc28dcc53e | ||
|
|
81a2c0c7ca | ||
|
|
c7f5467acb | ||
|
|
396e5ccbf1 | ||
|
|
0a522f9a19 | ||
|
|
40c5d4e291 | ||
|
|
550a266212 | ||
|
|
238ac409f8 | ||
|
|
ee640b2cec | ||
|
|
561d410e6a | ||
|
|
a20dafe714 | ||
|
|
3cd93c08a7 | ||
|
|
11537fdef1 | ||
|
|
5068f7acb1 | ||
|
|
85f04f485e | ||
|
|
a094b3edd5 | ||
|
|
8d7f72ac6e | ||
|
|
03e198b80c | ||
|
|
86b4fa6cbf | ||
|
|
e6424e3c6d | ||
|
|
e2c1e311b8 | ||
|
|
02f63fca70 | ||
|
|
f9a6f3dff2 | ||
|
|
0da2ef841c | ||
|
|
4ffd287c84 | ||
|
|
ec0dd4ef03 | ||
|
|
608fde9e9c | ||
|
|
7c40c64de8 | ||
|
|
4f4c18fa14 | ||
|
|
6eb1cdbd2d | ||
|
|
cad3051e7f | ||
|
|
2b9422efbc | ||
|
|
ddb691d4de | ||
|
|
bbab5fefd0 | ||
|
|
6e223a066a | ||
|
|
61b74473e3 | ||
|
|
f06fa3a4a3 | ||
|
|
9d4ebc00f6 | ||
|
|
a69d1a5729 | ||
|
|
7e3076c0e2 | ||
|
|
e3f5c0f006 | ||
|
|
572e79c9ac | ||
|
|
fb70f644e5 | ||
|
|
954d6300de | ||
|
|
9ceca0eea9 | ||
|
|
24f768f725 | ||
|
|
89f3eade15 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
env/*
|
||||
__pycache__/*
|
||||
meshview/__pycache__/*
|
||||
meshtastic/protobuf/*
|
||||
packets.db
|
||||
/table_details.py
|
||||
config.ini
|
||||
screenshots/*
|
||||
python/nanopb
|
||||
|
||||
1
.gitmodules
vendored
Normal file
1
.gitmodules
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
91
README.md
91
README.md
@@ -2,7 +2,12 @@
|
||||
# Meshview
|
||||

|
||||
|
||||
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
|
||||
* New Live Map (Shows packet feed live)
|
||||
* 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:
|
||||
|
||||
- https://meshview.bayme.sh (SF Bay Area)
|
||||
- https://www.svme.sh/ (Sacramento Valley)
|
||||
- https://meshview.nyme.sh/ (New York)
|
||||
- https://map.wpamesh.net/ (Western Pennsylvania)
|
||||
- https://meshview.chicagolandmesh.org/ (Chicago)
|
||||
- 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://meshview.meshtastic.es (Spain)
|
||||
- https://view.mtnme.sh/ (North Georgia / East Tennessee)
|
||||
- https://socalmesh.w4hac.com (Southern California)
|
||||
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
||||
- https://meshview.lsinfra.de (Hessen - Germany)
|
||||
- https://map.nswmesh.au/ (Sydney - Australia)
|
||||
- https://meshview.pvmesh.org/ (Pioneer Valley, Massachusetts)
|
||||
- https://map.nswmesh.au (Sydney - Australia)
|
||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
||||
- https://meshview.louisianamesh.org (Louisiana)
|
||||
- https://meshview.meshcolombia.co/ (Colombia)
|
||||
---
|
||||
|
||||
## Installing
|
||||
@@ -60,23 +67,27 @@ Clone the repo from GitHub:
|
||||
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
||||
```
|
||||
|
||||
Create a Python virtual environment:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```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
|
||||
sudo apt-get install graphviz
|
||||
[ "$(uname)" = "Darwin" ] && brew install graphviz
|
||||
[ "$(uname)" = "Linux" ] && sudo apt-get install graphviz
|
||||
```
|
||||
|
||||
Copy `sample.config.ini` to `config.ini`:
|
||||
@@ -131,6 +142,9 @@ title = Bay Area Mesh
|
||||
# A brief message shown on the homepage.
|
||||
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").
|
||||
nodes = True
|
||||
conversations = True
|
||||
@@ -142,16 +156,21 @@ map = True
|
||||
top = True
|
||||
|
||||
# Map boundaries (used for the map view).
|
||||
# Defaults will show the San Francisco Bay Area
|
||||
map_top_left_lat = 39
|
||||
map_top_left_lon = -123
|
||||
map_bottom_right_lat = 36
|
||||
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_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
|
||||
# -------------------------
|
||||
@@ -160,7 +179,7 @@ net_tag = #BayMeshNet
|
||||
server = mqtt.bayme.sh
|
||||
|
||||
# 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 = 1883
|
||||
@@ -176,13 +195,28 @@ password = large4cats
|
||||
[database]
|
||||
# SQLAlchemy connection string. This one uses SQLite with asyncio support.
|
||||
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
|
||||
|
||||
Start the database:
|
||||
Start the database manager:
|
||||
|
||||
```bash
|
||||
./env/bin/python startdb.py
|
||||
@@ -303,6 +337,27 @@ sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
## 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)
|
||||
- Name it cleanup.sh
|
||||
- Make it executable.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -116,6 +116,13 @@ class Config(google.protobuf.message.Message):
|
||||
but should not be given priority over other routers in order to avoid unnecessaraily
|
||||
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):
|
||||
"""
|
||||
@@ -200,6 +207,13 @@ class Config(google.protobuf.message.Message):
|
||||
but should not be given priority over other routers in order to avoid unnecessaraily
|
||||
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:
|
||||
ValueType = typing.NewType("ValueType", builtins.int)
|
||||
@@ -1048,12 +1062,12 @@ class Config(google.protobuf.message.Message):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
@@ -1074,12 +1088,12 @@ class Config(google.protobuf.message.Message):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
OLED_SH1107_128_128: Config.DisplayConfig.OledType.ValueType # 4
|
||||
"""
|
||||
Can not be auto detected but set by proto. Used for 128x128 screens
|
||||
"""
|
||||
|
||||
class _DisplayMode:
|
||||
ValueType = typing.NewType("ValueType", builtins.int)
|
||||
|
||||
@@ -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()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
@@ -26,7 +26,7 @@ if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
_globals['_THEME']._serialized_start=1177
|
||||
_globals['_THEME']._serialized_end=1214
|
||||
_globals['_LANGUAGE']._serialized_start=1217
|
||||
_globals['_LANGUAGE']._serialized_end=1514
|
||||
_globals['_LANGUAGE']._serialized_end=1525
|
||||
_globals['_DEVICEUICONFIG']._serialized_start=61
|
||||
_globals['_DEVICEUICONFIG']._serialized_end=663
|
||||
_globals['_NODEFILTER']._serialized_start=666
|
||||
|
||||
@@ -165,6 +165,10 @@ class _LanguageEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumT
|
||||
"""
|
||||
Bulgarian
|
||||
"""
|
||||
CZECH: _Language.ValueType # 18
|
||||
"""
|
||||
Czech
|
||||
"""
|
||||
SIMPLIFIED_CHINESE: _Language.ValueType # 30
|
||||
"""
|
||||
Simplified Chinese (experimental)
|
||||
@@ -251,6 +255,10 @@ BULGARIAN: Language.ValueType # 17
|
||||
"""
|
||||
Bulgarian
|
||||
"""
|
||||
CZECH: Language.ValueType # 18
|
||||
"""
|
||||
Czech
|
||||
"""
|
||||
SIMPLIFIED_CHINESE: Language.ValueType # 30
|
||||
"""
|
||||
Simplified Chinese (experimental)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
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
|
||||
"""
|
||||
------------------------------------------------------------------------------------------------------------------------------------------
|
||||
@@ -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.
|
||||
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
|
||||
"""
|
||||
------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -824,6 +824,7 @@ class ModuleConfig(google.protobuf.message.Message):
|
||||
ENABLED_FIELD_NUMBER: builtins.int
|
||||
SENDER_FIELD_NUMBER: builtins.int
|
||||
SAVE_FIELD_NUMBER: builtins.int
|
||||
CLEAR_ON_REBOOT_FIELD_NUMBER: builtins.int
|
||||
enabled: builtins.bool
|
||||
"""
|
||||
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.
|
||||
ESP32 Only
|
||||
"""
|
||||
clear_on_reboot: builtins.bool
|
||||
"""
|
||||
Bool indicating that the node should cleanup / destroy it's RangeTest.csv file.
|
||||
ESP32 Only
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
enabled: builtins.bool = ...,
|
||||
sender: builtins.int = ...,
|
||||
save: builtins.bool = ...,
|
||||
clear_on_reboot: builtins.bool = ...,
|
||||
) -> 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
|
||||
class TelemetryConfig(google.protobuf.message.Message):
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -199,6 +199,10 @@ class _TelemetrySensorTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wra
|
||||
"""
|
||||
SEN5X PM SENSORS
|
||||
"""
|
||||
TSL2561: _TelemetrySensorType.ValueType # 44
|
||||
"""
|
||||
TSL2561 light sensor
|
||||
"""
|
||||
|
||||
class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper):
|
||||
"""
|
||||
@@ -381,6 +385,10 @@ SEN5X: TelemetrySensorType.ValueType # 43
|
||||
"""
|
||||
SEN5X PM SENSORS
|
||||
"""
|
||||
TSL2561: TelemetrySensorType.ValueType # 44
|
||||
"""
|
||||
TSL2561 light sensor
|
||||
"""
|
||||
global___TelemetrySensorType = TelemetrySensorType
|
||||
|
||||
@typing.final
|
||||
|
||||
@@ -6,22 +6,12 @@ engine = None
|
||||
async_session = None
|
||||
|
||||
|
||||
def init_database(database_connection_string, read_only=False):
|
||||
def init_database(database_connection_string):
|
||||
global engine, async_session
|
||||
|
||||
kwargs = {"echo": False}
|
||||
|
||||
if database_connection_string.startswith("sqlite"):
|
||||
if read_only:
|
||||
# Ensure SQLite is opened in read-only mode
|
||||
database_connection_string += "?mode=ro"
|
||||
kwargs["connect_args"] = {"uri": True}
|
||||
else:
|
||||
kwargs["connect_args"] = {"timeout": 60}
|
||||
else:
|
||||
kwargs["pool_size"] = 20
|
||||
kwargs["max_overflow"] = 50
|
||||
|
||||
# Ensure SQLite is opened in read-only mode
|
||||
database_connection_string += "?mode=ro"
|
||||
kwargs["connect_args"] = {"uri": True}
|
||||
engine = create_async_engine(database_connection_string, **kwargs)
|
||||
async_session = async_sessionmaker( bind=engine,
|
||||
class_=AsyncSession,
|
||||
|
||||
@@ -84,8 +84,10 @@ class PacketSeen(Base):
|
||||
)
|
||||
|
||||
|
||||
|
||||
class Traceroute(Base):
|
||||
__tablename__ = "traceroute"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
packet_id = mapped_column(ForeignKey("packet.id"))
|
||||
packet: Mapped["Packet"] = relationship(
|
||||
@@ -95,3 +97,7 @@ class Traceroute(Base):
|
||||
done: Mapped[bool] = mapped_column(nullable=True)
|
||||
route: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_traceroute_import_time", "import_time"),
|
||||
)
|
||||
|
||||
@@ -3,11 +3,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
def init_database(database_connection_string):
|
||||
global engine, async_session
|
||||
kwargs = {}
|
||||
if not database_connection_string.startswith('sqlite'):
|
||||
kwargs['pool_size'] = 20
|
||||
kwargs['max_overflow'] = 50
|
||||
engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 60})
|
||||
engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 900})
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def create_tables():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import re
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update
|
||||
from meshtastic.protobuf.config_pb2 import Config
|
||||
@@ -9,30 +10,33 @@ from meshview import decode_payload
|
||||
from meshview.models import Packet, PacketSeen, Node, Traceroute
|
||||
|
||||
|
||||
|
||||
|
||||
async def process_envelope(topic, env):
|
||||
|
||||
# Checking if the received packet is a MAP_REPORT
|
||||
# Update the node table with the firmware version
|
||||
if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP:
|
||||
# Extract the node ID from the packet and format the user ID
|
||||
node_id = getattr(env.packet, "from")
|
||||
user_id = f"!{node_id:0{8}x}"
|
||||
|
||||
# Decode the MAP report payload
|
||||
map_report = decode_payload.decode_payload(PortNum.MAP_REPORT_APP, env.packet.decoded.payload)
|
||||
map_report = decode_payload.decode_payload(
|
||||
PortNum.MAP_REPORT_APP, env.packet.decoded.payload
|
||||
)
|
||||
|
||||
# Establish an asynchronous database session
|
||||
async with mqtt_database.async_session() as session:
|
||||
try:
|
||||
hw_model = HardwareModel.Name(map_report.hw_model) if hasattr(HardwareModel, 'Name') 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()
|
||||
hw_model = (
|
||||
HardwareModel.Name(map_report.hw_model)
|
||||
if hasattr(HardwareModel, "Name")
|
||||
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:
|
||||
node.node_id = node_id
|
||||
node.long_name = map_report.long_name
|
||||
@@ -46,26 +50,31 @@ async def process_envelope(topic, env):
|
||||
node.last_update = datetime.datetime.now()
|
||||
else:
|
||||
node = Node(
|
||||
id=user_id, node_id=node_id,
|
||||
long_name=map_report.long_name, short_name=map_report.short_name,
|
||||
hw_model=hw_model, role=role, channel=env.channel_id,
|
||||
id=user_id,
|
||||
node_id=node_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,
|
||||
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(),
|
||||
)
|
||||
session.add(node)
|
||||
except Exception as e:
|
||||
print(f"Error processing MAP_REPORT_APP: {e}")
|
||||
|
||||
# Commit the changes to the database
|
||||
await session.commit()
|
||||
|
||||
# This ignores any packet that does not have a ID
|
||||
if not env.packet.id:
|
||||
return
|
||||
|
||||
async with mqtt_database.async_session() as session:
|
||||
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
|
||||
result = await session.execute(
|
||||
select(Packet).where(Packet.id == env.packet.id)
|
||||
)
|
||||
new_packet = False
|
||||
packet = result.scalar_one_or_none()
|
||||
if not packet:
|
||||
@@ -88,7 +97,6 @@ async def process_envelope(topic, env):
|
||||
PacketSeen.rx_time == env.packet.rx_time,
|
||||
)
|
||||
)
|
||||
seen = None
|
||||
if not result.scalar_one_or_none():
|
||||
seen = PacketSeen(
|
||||
packet_id=env.packet.id,
|
||||
@@ -106,14 +114,32 @@ async def process_envelope(topic, env):
|
||||
|
||||
if env.packet.decoded.portnum == PortNum.NODEINFO_APP:
|
||||
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:
|
||||
node_id = int(user.id[1:], 16) if user.id[0] == "!" else 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"
|
||||
# ✅ Safe fix: only parse hex IDs, otherwise leave None
|
||||
if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]):
|
||||
node_id = int(user.id[1:], 16)
|
||||
else:
|
||||
node_id = None
|
||||
|
||||
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:
|
||||
node.node_id = node_id
|
||||
@@ -125,9 +151,13 @@ async def process_envelope(topic, env):
|
||||
node.last_update = datetime.datetime.now()
|
||||
else:
|
||||
node = Node(
|
||||
id=user.id, node_id=node_id,
|
||||
long_name=user.long_name, short_name=user.short_name,
|
||||
hw_model=hw_model, role=role, channel=env.channel_id,
|
||||
id=user.id,
|
||||
node_id=node_id,
|
||||
long_name=user.long_name,
|
||||
short_name=user.short_name,
|
||||
hw_model=hw_model,
|
||||
role=role,
|
||||
channel=env.channel_id,
|
||||
last_update=datetime.datetime.now(),
|
||||
)
|
||||
session.add(node)
|
||||
@@ -139,8 +169,12 @@ async def process_envelope(topic, env):
|
||||
PortNum.POSITION_APP, env.packet.decoded.payload
|
||||
)
|
||||
if position and position.latitude_i and position.longitude_i:
|
||||
from_node_id = getattr(env.packet, 'from')
|
||||
node = (await session.execute(select(Node).where(Node.node_id == from_node_id))).scalar_one_or_none()
|
||||
from_node_id = getattr(env.packet, "from")
|
||||
node = (
|
||||
await session.execute(
|
||||
select(Node).where(Node.node_id == from_node_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if node:
|
||||
node.last_lat = position.latitude_i
|
||||
node.last_long = position.longitude_i
|
||||
@@ -151,17 +185,21 @@ async def process_envelope(topic, env):
|
||||
if env.packet.decoded.want_response:
|
||||
packet_id = env.packet.id
|
||||
else:
|
||||
result = await session.execute(select(Packet).where(Packet.id == env.packet.decoded.request_id))
|
||||
result = await session.execute(
|
||||
select(Packet).where(Packet.id == env.packet.decoded.request_id)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
packet_id = env.packet.decoded.request_id
|
||||
if packet_id is not None:
|
||||
session.add(Traceroute(
|
||||
packet_id=packet_id,
|
||||
route=env.packet.decoded.payload,
|
||||
done=not env.packet.decoded.want_response,
|
||||
gateway_node_id=int(env.gateway_id[1:], 16),
|
||||
import_time=datetime.datetime.now(),
|
||||
))
|
||||
session.add(
|
||||
Traceroute(
|
||||
packet_id=packet_id,
|
||||
route=env.packet.decoded.payload,
|
||||
done=not env.packet.decoded.want_response,
|
||||
gateway_node_id=int(env.gateway_id[1:], 16),
|
||||
import_time=datetime.datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
if new_packet:
|
||||
|
||||
@@ -111,13 +111,16 @@ async def get_traceroute(packet_id):
|
||||
|
||||
async def get_traceroutes(since):
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Traceroute)
|
||||
.join(Packet)
|
||||
.where(Traceroute.import_time > (datetime.now() - since))
|
||||
.order_by(Traceroute.import_time)
|
||||
stmt = (
|
||||
select(Traceroute)
|
||||
.join(Packet)
|
||||
.where(Traceroute.import_time > since)
|
||||
.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):
|
||||
@@ -340,3 +343,31 @@ async def get_packet_stats(
|
||||
"from_node": from_node,
|
||||
"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
|
||||
|
||||
@@ -45,7 +45,7 @@ let portnum = "{{ portnum if portnum is not none else '' }}";
|
||||
let updatesPaused = false;
|
||||
|
||||
// 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;
|
||||
|
||||
function fetchUpdates() {
|
||||
|
||||
@@ -27,6 +27,22 @@
|
||||
.filter-checkbox {
|
||||
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 {
|
||||
background: white;
|
||||
color: black;
|
||||
@@ -42,6 +58,9 @@
|
||||
<div id="filter-container">
|
||||
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
|
||||
</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"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
@@ -58,6 +77,17 @@
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).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 ----
|
||||
var markers = {};
|
||||
var markerById = {};
|
||||
@@ -155,7 +185,13 @@
|
||||
[{{ 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"] }}]
|
||||
];
|
||||
map.fitBounds(bayAreaBounds);
|
||||
|
||||
// Apply custom view or default bounds
|
||||
if (customView) {
|
||||
map.setView([customView.lat, customView.lng], customView.zoom);
|
||||
} else {
|
||||
map.fitBounds(bayAreaBounds);
|
||||
}
|
||||
|
||||
// ---- Filters ----
|
||||
let filterContainer = document.getElementById("filter-container");
|
||||
@@ -341,6 +377,32 @@
|
||||
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 ----
|
||||
if (mapInterval > 0) startPacketFetcher();
|
||||
</script>
|
||||
|
||||
@@ -114,7 +114,10 @@
|
||||
<div><span class="legend-box circle" style="background-color: #00c3ff"></span> <code>CLIENT_MUTE</code></div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="legend-category">
|
||||
<div><span class="legend-box circle" style="background-color: #6c757d"></span> Unknown</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,6 +135,7 @@ const colors = {
|
||||
ROUTER_LATE: '#b65224',
|
||||
CLIENT: '#007bff',
|
||||
CLIENT_MUTE: '#00c3ff',
|
||||
CLIENT_BASE: '#049acd',
|
||||
other: '#ffbf00',
|
||||
unknown: '#6c757d',
|
||||
},
|
||||
@@ -148,6 +152,7 @@ function getSymbolSize (role) {
|
||||
switch (role) {
|
||||
case 'ROUTER': return 30;
|
||||
case 'ROUTER_LATE': return 30;
|
||||
case 'CLIENT_BASE': return 18;
|
||||
case 'CLIENT': return 15;
|
||||
case 'CLIENT_MUTE': return 7;
|
||||
default: return 15; // Unknown or other roles
|
||||
@@ -157,6 +162,7 @@ function getSymbolSize (role) {
|
||||
function getLabel (role, short_name, long_name) {
|
||||
if (role === 'ROUTER') 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_MUTE') return short_name;
|
||||
return short_name || '';
|
||||
|
||||
@@ -62,6 +62,29 @@
|
||||
|
||||
.expand-btn:hover { background-color: #666; }
|
||||
.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 %}
|
||||
|
||||
{% block head %}
|
||||
@@ -70,26 +93,53 @@
|
||||
|
||||
{% block body %}
|
||||
<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="card-section">
|
||||
<p class="section-header">Packets per Day - All Ports (Last 14 Days)</p>
|
||||
<div id="total_daily_all" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_daily_all">Export CSV</button>
|
||||
<div id="chart_daily_all" class="chart"></div>
|
||||
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p>Total Nodes</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<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 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>
|
||||
<!-- Daily Charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">Packets per Day - All Ports (Last 14 Days)</p>
|
||||
<div id="total_daily_all" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_daily_all">Export CSV</button>
|
||||
<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">
|
||||
<p class="section-header">Packets per Hour - All Ports</p>
|
||||
<div id="total_hourly_all" class="total-count">Total: 0</div>
|
||||
@@ -106,47 +156,7 @@
|
||||
<div id="chart_portnum_1" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<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 #}
|
||||
<!-- Node breakdown charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">Hardware Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button>
|
||||
@@ -169,7 +179,7 @@
|
||||
</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%;
|
||||
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%;
|
||||
@@ -183,11 +193,21 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PORTNUM_LABELS = {
|
||||
1: "Text Messages",
|
||||
3: "Position",
|
||||
4: "Node Info",
|
||||
67: "Telemetry",
|
||||
70: "Traceroute",
|
||||
71: "Neighbor Info"
|
||||
};
|
||||
|
||||
// --- Fetch & Processing ---
|
||||
async function fetchStats(period_type,length,portnum=null){
|
||||
async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
try{
|
||||
let url=`/api/stats?period_type=${period_type}&length=${length}`;
|
||||
if(portnum!==null) url+=`&portnum=${portnum}`;
|
||||
if(channel) url+=`&channel=${channel}`;
|
||||
const res=await fetch(url);
|
||||
if(!res.ok) return [];
|
||||
const json=await res.json();
|
||||
@@ -203,6 +223,14 @@ async function fetchNodes(){
|
||||
}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){
|
||||
const counts={};
|
||||
nodes.forEach(n=>{
|
||||
@@ -269,12 +297,43 @@ function renderPieChart(elId,data,name){
|
||||
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 ---
|
||||
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
|
||||
let chartDailyAll, chartDailyPortnum1;
|
||||
let chartHwModel, chartRole, chartChannel;
|
||||
let chartPacketTypes;
|
||||
|
||||
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);
|
||||
updateTotalCount('total_daily_all',dailyAllData);
|
||||
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false);
|
||||
@@ -283,6 +342,7 @@ async function init(){
|
||||
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
|
||||
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false);
|
||||
|
||||
// Hourly
|
||||
const hourlyAllData=await fetchStats('hour',24);
|
||||
updateTotalCount('total_hourly_all',hourlyAllData);
|
||||
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);
|
||||
}
|
||||
|
||||
// Node Breakdown
|
||||
const nodes=await fetchNodes();
|
||||
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
|
||||
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
|
||||
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 ---
|
||||
window.addEventListener('resize',()=>{
|
||||
[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 ---
|
||||
@@ -317,25 +386,23 @@ let modalChart=null;
|
||||
document.querySelectorAll(".expand-btn").forEach(btn=>{
|
||||
btn.addEventListener("click",()=>{
|
||||
const chartId=btn.getAttribute("data-chart");
|
||||
const chartInstance=echarts.getInstanceByDom(document.getElementById(chartId));
|
||||
if(!chartInstance) return;
|
||||
const chartData=chartInstance.getOption();
|
||||
const sourceChart=echarts.getInstanceByDom(document.getElementById(chartId));
|
||||
if(!sourceChart)return;
|
||||
modal.style.display="flex";
|
||||
if(modalChart) modalChart.dispose();
|
||||
modalChart=echarts.init(modalChartEl);
|
||||
modalChart.setOption(chartData);
|
||||
modalChart.resize();
|
||||
modalChart.setOption(sourceChart.getOption());
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("closeModal").addEventListener("click",()=>{
|
||||
modal.style.display="none";
|
||||
if(modalChart) modalChart.dispose();
|
||||
modalChart?.dispose();
|
||||
modalChart=null;
|
||||
});
|
||||
|
||||
// --- CSV Export ---
|
||||
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 link=document.createElement("a");
|
||||
link.href=URL.createObjectURL(blob);
|
||||
@@ -349,13 +416,13 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
||||
btn.addEventListener("click",()=>{
|
||||
const chartId=btn.getAttribute("data-chart");
|
||||
const chart=echarts.getInstanceByDom(document.getElementById(chartId));
|
||||
if(!chart) return;
|
||||
if(!chart)return;
|
||||
const option=chart.getOption();
|
||||
let rows=[];
|
||||
if(option.series[0].type==="bar"||option.series[0].type==="line"){
|
||||
rows.push(["Period","Count"]);
|
||||
const xData=option.xAxis[0].data;
|
||||
const yData=option.series[0].data;
|
||||
rows.push(["Period","Count"]);
|
||||
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
|
||||
}
|
||||
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();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
115
meshview/web.py
115
meshview/web.py
@@ -24,7 +24,7 @@ import traceback
|
||||
import pathlib
|
||||
|
||||
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
|
||||
|
||||
env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape())
|
||||
@@ -899,7 +899,7 @@ async def graph_network(request):
|
||||
node_ids = set()
|
||||
|
||||
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.packet.from_node_id)
|
||||
node_ids.add(tr.packet.to_node_id)
|
||||
@@ -1135,11 +1135,30 @@ async def map(request):
|
||||
for node in nodes:
|
||||
if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime):
|
||||
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")
|
||||
|
||||
return web.Response(
|
||||
text=template.render(
|
||||
nodes=nodes,
|
||||
custom_view=custom_view,
|
||||
site_config=CONFIG,
|
||||
SOFTWARE_RELEASE=SOFTWARE_RELEASE),
|
||||
content_type="text/html",
|
||||
@@ -1244,7 +1263,7 @@ async def nodegraph(request):
|
||||
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.packet.from_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 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")
|
||||
async def api_chat(request):
|
||||
@@ -1579,59 +1609,62 @@ async def api_stats(request):
|
||||
return web.json_response(stats)
|
||||
|
||||
|
||||
|
||||
@routes.get("/api/config")
|
||||
async def api_config(request):
|
||||
try:
|
||||
# Return CONFIG as JSON
|
||||
return web.json_response(CONFIG)
|
||||
site = CONFIG.get("site", {})
|
||||
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:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.get("/api/edges")
|
||||
async def api_edges(request):
|
||||
edges_set = set()
|
||||
edge_type = {}
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=48)
|
||||
filter_type = request.query.get("type")
|
||||
|
||||
# Get optional type filter from query string
|
||||
filter_type = request.query.get("type") # None if not provided
|
||||
edges = {}
|
||||
|
||||
# Fetch traceroutes
|
||||
for tr in await store.get_traceroutes(since):
|
||||
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
||||
path = [tr.packet.from_node_id] + list(route.route)
|
||||
if tr.done:
|
||||
path.append(tr.packet.to_node_id)
|
||||
else:
|
||||
if path[-1] != tr.gateway_node_id:
|
||||
path.append(tr.gateway_node_id)
|
||||
# Only build traceroute edges if requested
|
||||
if filter_type in (None, "traceroute"):
|
||||
async for tr in store.get_traceroutes(since):
|
||||
try:
|
||||
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
||||
except Exception as e:
|
||||
print(f"Error decoding Traceroute {tr.id}: {e}")
|
||||
continue
|
||||
|
||||
for i in range(len(path) - 1):
|
||||
edge_pair = (path[i], path[i + 1])
|
||||
edges_set.add(edge_pair)
|
||||
edge_type[edge_pair] = "traceroute"
|
||||
path = [tr.packet.from_node_id] + list(route.route)
|
||||
path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
|
||||
|
||||
# Fetch NeighborInfo packets
|
||||
for packet in await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since):
|
||||
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}")
|
||||
for a, b in zip(path, path[1:]):
|
||||
edges[(a, b)] = "traceroute"
|
||||
|
||||
# Prepare edges with optional filtering by type
|
||||
edges = [
|
||||
{"from": frm, "to": to, "type": typ}
|
||||
for (frm, to), typ in edge_type.items()
|
||||
if filter_type is None or typ == filter_type
|
||||
]
|
||||
# Only build neighbor edges if requested
|
||||
if filter_type in (None, "neighbor"):
|
||||
packets = await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since)
|
||||
for packet in packets:
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
# -------------------------
|
||||
@@ -85,3 +80,18 @@ password = large4cats
|
||||
[database]
|
||||
# SQLAlchemy connection string. This one uses SQLite with asyncio support.
|
||||
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
|
||||
188
startdb.py
188
startdb.py
@@ -1,51 +1,169 @@
|
||||
import asyncio
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
from sqlalchemy import delete
|
||||
from meshview import mqtt_reader
|
||||
from meshview import mqtt_database
|
||||
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):
|
||||
await mqtt_store.process_envelope(topic, env)
|
||||
# -------------------------
|
||||
# Helper functions
|
||||
# -------------------------
|
||||
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()
|
||||
mqtt_user = None
|
||||
mqtt_passwd = None
|
||||
if config["mqtt"]["username"] != "":
|
||||
mqtt_user: str = config["mqtt"]["username"]
|
||||
if config["mqtt"]["password"] != "":
|
||||
mqtt_passwd: str = config["mqtt"]["password"]
|
||||
mqtt_topics = json.loads(config["mqtt"]["topics"])
|
||||
|
||||
|
||||
|
||||
mqtt_user = CONFIG["mqtt"].get("username") or None
|
||||
mqtt_passwd = CONFIG["mqtt"].get("password") or None
|
||||
mqtt_topics = json.loads(CONFIG["mqtt"]["topics"])
|
||||
|
||||
cleanup_enabled = get_bool(CONFIG, "cleanup", "enabled", False)
|
||||
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:
|
||||
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__':
|
||||
parser = argparse.ArgumentParser("meshview")
|
||||
parser.add_argument("--config", help="Path to the configuration file.", default='config.ini')
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(args.config)
|
||||
|
||||
asyncio.run(main(config))
|
||||
asyncio.run(main())
|
||||
|
||||
Reference in New Issue
Block a user