mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-07 17:33:13 +02: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 |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
# Meshview
|
# 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
|
### 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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution: '© <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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
|
||||||
|
|||||||
Reference in New Issue
Block a user