55 Commits

Author SHA1 Message Date
pablorevilla-meshtastic
9912f6b181 testing commit message functionality 2026-01-08 18:39:01 -08:00
pablorevilla-meshtastic
cb4cc281c6 fix speed of node list rendering 2026-01-08 17:38:56 -08:00
pablorevilla-meshtastic
571559114d Add node status indicator and improve favorites handling in nodelist 2026-01-08 17:38:12 -08:00
pablorevilla-meshtastic
df26df07f1 Changes to node.html. fix some of the data 2026-01-08 14:59:45 -08:00
pablorevilla-meshtastic
ffc7340bc9 Changes to nodelist.html. fix some of the data 2026-01-07 17:19:32 -08:00
pablorevilla-meshtastic
1d58aaba83 Changes to nodelist.html. fix some of the data 2026-01-07 13:35:58 -08:00
pablorevilla-meshtastic
b2bb9345fe Changes to nodelist.html. fix some of the data 2026-01-07 13:29:56 -08:00
pablorevilla-meshtastic
9686622b56 Changes to node.html. fix some of the data 2026-01-07 10:01:02 -08:00
pablorevilla-meshtastic
f7644a9573 Changes to node.html. fix some of the data 2026-01-07 09:48:26 -08:00
Pablo Revilla
e48e9464d7 Modify packet.html to add distance 2026-01-03 21:48:19 -08:00
Pablo Revilla
b72bc5d52b Modify packet.html to add distance 2026-01-03 21:44:26 -08:00
Pablo Revilla
1220f0bcbd Modify node.html to add statistics 2026-01-03 21:28:33 -08:00
Pablo Revilla
539410d5bb Modify node.html to add statistics 2026-01-03 21:26:39 -08:00
Pablo Revilla
383b576d18 Modify node.html to add statistics 2026-01-03 21:12:24 -08:00
Pablo Revilla
64a55a3ef3 Modify node.html to add statistics 2026-01-03 20:51:17 -08:00
Pablo Revilla
9408201e57 Modify node.html to add statistics 2026-01-03 19:27:00 -08:00
Pablo Revilla
f75d6bf749 Modify node.html to add statistics 2026-01-03 19:00:39 -08:00
Pablo Revilla
924d223866 Modify node.html to add statistics 2026-01-03 18:13:57 -08:00
Pablo Revilla
e9dcca1f19 Modify node.html to add statistics 2025-12-31 11:58:45 -08:00
Pablo Revilla
00cc2abd23 Modify node.html to add statistics 2025-12-31 11:56:18 -08:00
Pablo Revilla
b76477167d Modify top.html to add paging 2025-12-31 11:13:52 -08:00
Pablo Revilla
b41b249a6d Modify top.html to add paging 2025-12-31 10:38:13 -08:00
Pablo Revilla
71fcda2dd6 Modify top.html to add paging 2025-12-30 09:27:51 -08:00
Pablo Revilla
c4453fbb31 Modify packet.html to sort by hop count. 2025-12-24 10:54:09 -08:00
Pablo Revilla
79fa3f66a8 Fix chart on node.html. 2025-12-24 10:06:17 -08:00
Pablo Revilla
0ce64ac975 Fix chart on node.html. 2025-12-10 09:56:30 -08:00
Pablo Revilla
350aa9e4a3 Fix chart on node.html. 2025-12-09 17:40:49 -08:00
Pablo Revilla
e5bbf972c7 Fix chart on node.html. 2025-12-09 17:35:52 -08:00
Pablo Revilla
4326e12e88 Fix chart on node.html. 2025-12-09 16:58:38 -08:00
Pablo Revilla
00aa3216ff Fix chart on node.html. 2025-12-09 16:19:55 -08:00
Pablo Revilla
3d6c01f020 minor fix on node.html table of tackets shows to and from not just from. 2025-12-08 10:45:33 -08:00
Pablo Revilla
d3bf0ede67 minor fix on node.html table of tackets shows to and from not just from. 2025-12-08 10:29:24 -08:00
Pablo Revilla
2b02166d82 minor fix on node.html table of tackets shows to and from not just from. 2025-12-07 20:02:33 -08:00
Pablo Revilla
2fd36b4b11 minor fix on node.html table of tackets shows to and from not just from. 2025-12-07 17:29:01 -08:00
Pablo Revilla
8aa1c59873 minor fix to langauge dictionary 2025-12-06 11:30:25 -08:00
Pablo Revilla
cd036b8004 efficiency improvement node.html now it only queries the needed node info rather than all the nodes. 2025-12-06 11:26:36 -08:00
Pablo Revilla
989da239fb efficiency improvement for map.html. Now it only download the edges that need to be drawn. 2025-12-04 14:15:46 -08:00
Pablo Revilla
31626494d3 Fix README.md details 2025-12-04 10:38:58 -08:00
Pablo Revilla
960a7ef075 Fix README.md details 2025-12-04 09:41:59 -08:00
Pablo Revilla
60c4d22d2d Update multi-language support. So far Spanish and english. 2025-12-04 09:39:27 -08:00
Pablo Revilla
13a094be00 Update multi-language support. So far Spanish and english. 2025-12-04 09:38:18 -08:00
Pablo Revilla
7744cedd8c Update multi-language support. So far Spanish and english. 2025-12-04 09:35:34 -08:00
Pablo Revilla
ad42c1aeaf Update multi-language support. So far Spanish and english. 2025-12-02 16:03:25 -08:00
Pablo Revilla
41f7bf42a3 Update multi-language support. So far Spanish and english. 2025-12-02 14:45:31 -08:00
Pablo Revilla
0543aeb650 Update multi-language support. So far Spanish and english. 2025-12-02 14:24:10 -08:00
Pablo Revilla
679071cc14 Update multi-language support. So far Spanish and english. 2025-12-02 13:54:39 -08:00
Pablo Revilla
198afcc7d8 Update multi-language support. So far Spanish and english. 2025-12-02 13:51:18 -08:00
Pablo Revilla
191a01a03c update version date 2025-12-01 09:48:51 -08:00
Pablo Revilla
fd653f8234 Fixed Sort nodes by firmware in nodelist.html 2025-12-01 09:38:08 -08:00
Pablo Revilla
2149fed8c5 Fixed Sort nodes by firmware in nodelist.html 2025-11-30 10:38:18 -08:00
Pablo Revilla
5609d18284 worked on making map and base all API driven 2025-11-29 19:27:57 -08:00
Pablo Revilla
705b0b79fc worked on making map and base all API driven 2025-11-29 19:12:53 -08:00
Joel Krauska
32ad8e3a9c Fix search 2 (#108)
Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
2025-11-29 19:07:58 -08:00
Joel Krauska
e77428661c Version 3.0.0 Feature Release - Target Before Thanksgiving! (#96)
* Add alembic DB schema management (#86)

* Use alembic
* add creation helper
* example migration tool

* Store UTC int time in DB (#81)

* use UTC int time

* Remove old index notes script -- no longer needed

* modify alembic to support cleaner migrations

* add /version json endpoint

* move technical docs

* remove old migrate script

* add readme in docs:

* more doc tidy

* rm

* update api docs

* ignore other database files

* health endpoint

* alembic log format

* break out api calls in to their own file to reduce footprint

* ruff and docs

* vuln

* Improves arguments in mvrun.py

* Set dbcleanup.log location configurable

* mvrun work

* fallback if missing config

* remove unused loop

* improve migrations and fix logging problem with mqtt

* Container using slim/uv

* auto build containers

* symlink

* fix symlink

* checkout and containerfile

* make /app owned by ap0p

* Traceroute Return Path logged and displayed (#97)


* traceroute returns are now logged and /packetlist now graphs the correct data for a return route
* now using alembic to update schema
* HOWTO - Alembic

---------

Co-authored-by: Joel Krauska <jkrauska@gmail.com>

* DB Backups

* backups and cleanups are different

* ruff

* Docker Docs

* setup-dev

* graphviz for dot in Container

* Summary of 3.0.0 stuff

* Alembic was blocking mqtt logs

* Add us first/last timestamps to node table too

* Worked on /api/packet. Needed to modify
- Store.py to read the new time data
- api.py to present the new time data
- firehose.html chat.html and map.html now use the new apis and the time is the browser local time

* Worked on /api/packet. Needed to modify
- Store.py to read the new time data
- api.py to present the new time data
- firehose.html chat.html and map.html now use the new apis and the time is the browser local time

* Improves container build (#94)

* Worked on /api/packet. Needed to modify
- Store.py to read the new time data
- api.py to present the new time data
- firehose.html chat.html and map.html now use the new apis and the time is the browser local time

* Worked on /api/packet. Needed to modify
- Store.py to read the new time data
- api.py to present the new time data
- firehose.html chat.html and map.html now use the new apis and the time is the browser local time

* Worked on /api/packet. Needed to modify
- Added new api endpoint /api/packets_seen
- Modified web.py and store.py to support changes to APIs.
- Started to work on new_node.html and new_packet.html for presentation of data.

* Worked on /api/packet. Needed to modify
- Added new api endpoint /api/packets_seen
- Modified web.py and store.py to support changes to APIs.
- Started to work on new_node.html and new_packet.html for presentation of data.

* Finishing up all the pages for the 3.0 release.

Now all pages are functional.

* Finishing up all the pages for the 3.0 release.

Now all pages are functional.

* fix ruff format

* more ruff

* Finishing up all the pages for the 3.0 release.

Now all pages are functional.

* Finishing up all the pages for the 3.0 release.

Now all pages are functional.

* pyproject.toml requirements

* use sys.executable

* fix 0 epoch dates in /chat

* Make the robots do our bidding

* another compatibility fix when _us is empty and we need to sort by BOTH old and new

* Finishing up all the pages for the 3.0 release.

Now all pages are functional.

* Finishing up all the pages for the 3.0 release.

Now all pages are functional.

* Remamed new_node to node. shorter and descriptive.

* Remamed new_node to node. shorter and descriptive.

* Remamed new_node to node. shorter and descriptive.

* Remamed new_node to node. shorter and descriptive.

* Remamed new_node to node. shorter and descriptive.

* Remamed new_node to node. shorter and descriptive.

* More changes... almost ready for release.

Ranamed 2 pages for easy or reading.

* Fix the net page as it was not showing the date information

* Fix the net page as it was not showing the date information

* Fix the net page as it was not showing the date information

* Fix the net page as it was not showing the date information

* ruff

---------

Co-authored-by: Óscar García Amor <ogarcia@connectical.com>
Co-authored-by: Jim Schrempp <jschrempp@users.noreply.github.com>
Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
2025-11-28 11:17:20 -08:00
Joel Krauska
e68cdf8cc1 test commit
Added information about the new statistic page and API.
2025-11-03 12:43:07 -08:00
108 changed files with 9671 additions and 5612 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
# This keeps Docker from including hostOS virtual environment folders
env/
.venv/
# Database files and backups
*.db
*.db-shm
*.db-wal
backups/
*.db.gz

52
.github/workflows/container.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Build container
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=v\d.\d.\d,value=latest
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Containerfile
push: ${{ github.event_name != 'pull_request' }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
# optional cache (speeds up rebuilds)
cache-from: type=gha
cache-to: type=gha,mode=max

36
.gitignore vendored
View File

@@ -1,11 +1,47 @@
env/*
__pycache__/*
meshview/__pycache__/*
alembic/__pycache__/*
meshtastic/protobuf/*
# Database files
packets.db
packets*.db
*.db
*.db-shm
*.db-wal
# Database backups
backups/
*.db.gz
# Process files
meshview-db.pid
meshview-web.pid
# Config and logs
/table_details.py
config.ini
*.log
# Screenshots
screenshots/*
# Python
python/nanopb
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

204
AGENTS.md Normal file
View File

@@ -0,0 +1,204 @@
# AI Agent Guidelines for Meshview
This document provides context and guidelines for AI coding assistants working on the Meshview project.
## Project Overview
Meshview is a real-time monitoring and diagnostic tool for Meshtastic mesh networks. It provides web-based visualization and analysis of network activity, including:
- Real-time packet monitoring from MQTT streams
- Interactive map visualization of node locations
- Network topology graphs showing connectivity
- Message traffic analysis and conversation tracking
- Node statistics and telemetry data
- Packet inspection and traceroute analysis
## Architecture
### Core Components
1. **MQTT Reader** (`meshview/mqtt_reader.py`) - Subscribes to MQTT topics and receives mesh packets
2. **Database Manager** (`meshview/database.py`, `startdb.py`) - Handles database initialization and migrations
3. **MQTT Store** (`meshview/mqtt_store.py`) - Processes and stores packets in the database
4. **Web Server** (`meshview/web.py`, `main.py`) - Serves the web interface and API endpoints
5. **API Layer** (`meshview/web_api/api.py`) - REST API endpoints for data access
6. **Models** (`meshview/models.py`) - SQLAlchemy database models
7. **Decode Payload** (`meshview/decode_payload.py`) - Protobuf message decoding
### Technology Stack
- **Python 3.13+** - Main language
- **aiohttp** - Async web framework
- **aiomqtt** - Async MQTT client
- **SQLAlchemy (async)** - ORM with async support
- **Alembic** - Database migrations
- **Jinja2** - Template engine
- **Protobuf** - Message serialization (Meshtastic protocol)
- **SQLite/PostgreSQL** - Database backends (SQLite default, PostgreSQL via asyncpg)
### Key Patterns
- **Async/Await** - All I/O operations are asynchronous
- **Database Migrations** - Use Alembic for schema changes (see `docs/Database-Changes-With-Alembic.md`)
- **Configuration** - INI file-based config (`config.ini`, see `sample.config.ini`)
- **Modular API** - API routes separated into `meshview/web_api/` module
## Project Structure
```
meshview/
├── alembic/ # Database migration scripts
├── docs/ # Technical documentation
├── meshview/ # Main application package
│ ├── static/ # Static web assets (HTML, JS, CSS)
│ ├── templates/ # Jinja2 HTML templates
│ ├── web_api/ # API route handlers
│ └── *.py # Core modules
├── main.py # Web server entry point
├── startdb.py # Database manager entry point
├── mvrun.py # Combined runner (starts both services)
├── config.ini # Runtime configuration
└── requirements.txt # Python dependencies
```
## Development Workflow
### Setup
1. Use Python 3.13+ virtual environment
### Running
- **Database**: `./env/bin/python startdb.py`
- **Web Server**: `./env/bin/python main.py`
- **Both**: `./env/bin/python mvrun.py`
## Code Style
- **Line length**: 100 characters (see `pyproject.toml`)
- **Linting**: Ruff (configured in `pyproject.toml`)
- **Formatting**: Ruff formatter
- **Type hints**: Preferred but not strictly required
- **Async**: Use `async def` and `await` for I/O operations
## Important Files
### Configuration
- `config.ini` - Runtime configuration (server, MQTT, database, cleanup)
- `sample.config.ini` - Template configuration file
- `alembic.ini` - Alembic migration configuration
### Database
- `meshview/models.py` - SQLAlchemy models (Packet, Node, Traceroute, etc.)
- `meshview/database.py` - Database initialization and session management
- `alembic/versions/` - Migration scripts
### Core Logic
- `meshview/mqtt_reader.py` - MQTT subscription and message reception
- `meshview/mqtt_store.py` - Packet processing and storage
- `meshview/decode_payload.py` - Protobuf decoding
- `meshview/web.py` - Web server routes and handlers
- `meshview/web_api/api.py` - REST API endpoints
### Templates
- `meshview/templates/` - Jinja2 HTML templates
- `meshview/static/` - Static files (HTML pages, JS, CSS)
## Common Tasks
### Adding a New API Endpoint
1. Add route handler in `meshview/web_api/api.py`
2. Register route in `meshview/web.py` (if needed)
3. Update `docs/API_Documentation.md` if public API
### Database Schema Changes
1. Modify models in `meshview/models.py`
2. Create migration: `alembic revision --autogenerate -m "description"`
3. Review generated migration in `alembic/versions/`
4. Test migration: `alembic upgrade head`
5. **Never** modify existing migration files after they've been applied
### Adding a New Web Page
1. Create template in `meshview/templates/`
2. Add route in `meshview/web.py`
3. Add navigation link if needed (check existing templates for pattern)
4. Add static assets if needed in `meshview/static/`
### Processing New Packet Types
1. Check `meshview/decode_payload.py` for existing decoders
2. Add decoder function if new type
3. Update `meshview/mqtt_store.py` to handle new packet type
4. Update database models if new data needs storage
## Key Concepts
### Meshtastic Protocol
- Uses Protobuf for message serialization
- Packets contain various message types (text, position, telemetry, etc.)
- MQTT topics follow pattern: `msh/{region}/{subregion}/#`
### Database Schema
- **packet** - Raw packet data
- **node** - Mesh node information
- **traceroute** - Network path information
- **packet_seen** - Packet observation records
### Real-time Updates
- Web pages use Server-Sent Events (SSE) for live updates
- Map and firehose pages auto-refresh based on config intervals
- API endpoints return JSON for programmatic access
## Best Practices
1. **Always use async/await** for database and network operations
2. **Use Alembic** for all database schema changes
3. **Follow existing patterns** - check similar code before adding new features
4. **Update documentation** - keep `docs/` and README current
5. **Test migrations** - verify migrations work both up and down
6. **Handle errors gracefully** - log errors, don't crash on bad packets
7. **Respect configuration** - use `config.ini` values, don't hardcode
## Common Pitfalls
- **Don't modify applied migrations** - create new ones instead
- **Don't block the event loop** - use async I/O, not sync
- **Don't forget timezone handling** - timestamps are stored in UTC
- **Don't hardcode paths** - use configuration values
- **Don't ignore MQTT reconnection** - handle connection failures gracefully
## Resources
- **Main README**: `README.md` - Installation and basic usage
- **Docker Guide**: `README-Docker.md` - Container deployment
- **API Docs**: `docs/API_Documentation.md` - API endpoint reference
- **Migration Guide**: `docs/Database-Changes-With-Alembic.md` - Database workflow
- **Contributing**: `CONTRIBUTING.md` - Contribution guidelines
## Version Information
- **Current Version**: 3.0.0 (November 2025)
- **Python Requirement**: 3.13+
- **Key Features**: Alembic migrations, automated backups, Docker support, traceroute return paths
## Rules for robots
- Always run ruff check and ruff format after making changes (only on python changes)
---
When working on this project, prioritize:
1. Maintaining async patterns
2. Following existing code structure
3. Using proper database migrations
4. Keeping documentation updated
5. Testing changes thoroughly

80
Containerfile Normal file
View File

@@ -0,0 +1,80 @@
# Build Image
# Uses python:3.13-slim because no native dependencies are needed for meshview itself
# (everything is available as a wheel)
FROM docker.io/python:3.13-slim AS meshview-build
RUN apt-get update && \
apt-get install -y --no-install-recommends curl patch && \
rm -rf /var/lib/apt/lists/*
# Add a non-root user/group
ARG APP_USER=app
RUN useradd -m -u 10001 -s /bin/bash ${APP_USER}
# Install uv and put it on PATH system-wide
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& install -m 0755 /root/.local/bin/uv /usr/local/bin/uv
WORKDIR /app
RUN chown -R ${APP_USER}:${APP_USER} /app
# Copy deps first for caching
COPY --chown=${APP_USER}:${APP_USER} pyproject.toml uv.lock* requirements*.txt ./
# Optional: wheels-only to avoid slow source builds
ENV UV_NO_BUILD=1
RUN uv venv /opt/venv
# RUN uv sync --frozen
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN uv pip install --no-cache-dir --upgrade pip \
&& if [ -f requirements.txt ]; then uv pip install --only-binary=:all: -r requirements.txt; fi
# Copy app code
COPY --chown=${APP_USER}:${APP_USER} . .
# Patch config
RUN patch sample.config.ini < container/config.patch
# Clean
RUN rm -rf /app/.git* && \
rm -rf /app/.pre-commit-config.yaml && \
rm -rf /app/*.md && \
rm -rf /app/COPYING && \
rm -rf /app/Containerfile && \
rm -rf /app/Dockerfile && \
rm -rf /app/container && \
rm -rf /app/docker && \
rm -rf /app/docs && \
rm -rf /app/pyproject.toml && \
rm -rf /app/requirements.txt && \
rm -rf /app/screenshots
# Prepare /app and /opt to copy
RUN mkdir -p /meshview && \
mv /app /opt /meshview
# Use a clean container for install
FROM docker.io/python:3.13-slim
ARG APP_USER=app
COPY --from=meshview-build /meshview /
RUN apt-get update && \
apt-get install -y --no-install-recommends graphviz && \
rm -rf /var/lib/apt/lists/* && \
useradd -m -u 10001 -s /bin/bash ${APP_USER} && \
mkdir -p /etc/meshview /var/lib/meshview /var/log/meshview && \
mv /app/sample.config.ini /etc/meshview/config.ini && \
chown -R ${APP_USER}:${APP_USER} /var/lib/meshview /var/log/meshview
# Drop privileges
USER ${APP_USER}
WORKDIR /app
ENTRYPOINT [ "/opt/venv/bin/python", "mvrun.py"]
CMD ["--pid_dir", "/tmp", "--py_exec", "/opt/venv/bin/python", "--config", "/etc/meshview/config.ini" ]
EXPOSE 8081
VOLUME [ "/etc/meshview", "/var/lib/meshview", "/var/log/meshview" ]

1
Dockerfile Symbolic link
View File

@@ -0,0 +1 @@
Containerfile

View File

@@ -1,203 +0,0 @@
# /top Endpoint Performance Optimization
## Problem
The `/top` endpoint was taking over 1 second to execute due to inefficient database queries. The query joins three tables (node, packet, packet_seen) and performs COUNT aggregations on large result sets without proper indexes.
## Root Cause Analysis
The `get_top_traffic_nodes()` query in `meshview/store.py` executes:
```sql
SELECT
n.node_id,
n.long_name,
n.short_name,
n.channel,
COUNT(DISTINCT p.id) AS total_packets_sent,
COUNT(ps.packet_id) AS total_times_seen
FROM node n
LEFT JOIN packet p ON n.node_id = p.from_node_id
AND p.import_time >= DATETIME('now', 'localtime', '-24 hours')
LEFT JOIN packet_seen ps ON p.id = ps.packet_id
GROUP BY n.node_id, n.long_name, n.short_name
HAVING total_packets_sent > 0
ORDER BY total_times_seen DESC;
```
### Performance Bottlenecks Identified:
1. **Missing composite index on packet(from_node_id, import_time)**
- The query filters packets by BOTH `from_node_id` AND `import_time >= -24 hours`
- Without a composite index, SQLite must:
- Scan using `idx_packet_from_node_id` index
- Then filter each result by `import_time` (expensive!)
2. **Missing index on packet_seen(packet_id)**
- The LEFT JOIN to packet_seen uses `packet_id` as the join key
- Without an index, SQLite performs a table scan for each packet
- With potentially millions of packet_seen records, this is very slow
## Solution
### 1. Added Database Indexes
Modified `meshview/models.py` to include two new indexes:
```python
# In Packet class
Index("idx_packet_from_node_time", "from_node_id", desc("import_time"))
# In PacketSeen class
Index("idx_packet_seen_packet_id", "packet_id")
```
### 2. Added Performance Profiling
Modified `meshview/web.py` `/top` endpoint to include:
- Timing instrumentation for database queries
- Timing for data processing
- Detailed logging with `[PROFILE /top]` prefix
- On-page performance metrics display
### 3. Created Migration Script
Created `add_db_indexes.py` to add indexes to existing databases.
## Implementation Steps
### Step 1: Stop the Database Writer
```bash
# Stop startdb.py if it's running
pkill -f startdb.py
```
### Step 2: Run Migration Script
```bash
python add_db_indexes.py
```
Expected output:
```
======================================================================
Database Index Migration for /top Endpoint Performance
======================================================================
Connecting to database: sqlite+aiosqlite:///path/to/packets.db
======================================================================
Checking for index: idx_packet_from_node_time
======================================================================
Creating index idx_packet_from_node_time...
Table: packet
Columns: from_node_id, import_time DESC
Purpose: Speeds up filtering packets by sender and time range
✓ Index created successfully in 2.34 seconds
======================================================================
Checking for index: idx_packet_seen_packet_id
======================================================================
Creating index idx_packet_seen_packet_id...
Table: packet_seen
Columns: packet_id
Purpose: Speeds up joining packet_seen with packet table
✓ Index created successfully in 3.12 seconds
... (index listings)
======================================================================
Migration completed successfully!
======================================================================
```
### Step 3: Restart Services
```bash
# Restart server
python mvrun.py &
```
### Step 4: Verify Performance Improvement
1. Visit `/top` endpoint eg http://127.0.0.1:8081/top?perf=true
2. Scroll to bottom of page
3. Check the Performance Metrics panel
4. Compare DB query time before and after
**Expected Results:**
- **Before:** 1000-2000ms query time
- **After:** 50-200ms query time
- **Improvement:** 80-95% reduction
## Performance Metrics
The `/top` page now displays at the bottom:
```
⚡ Performance Metrics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Database Query: 45.23ms
Data Processing: 2.15ms
Total Time: 47.89ms
Nodes Processed: 156
Total Packets: 45,678
Times Seen: 123,456
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
## Technical Details
### Why Composite Index Works
SQLite can use a composite index `(from_node_id, import_time DESC)` to:
1. Quickly find all packets for a specific `from_node_id`
2. Filter by `import_time` without additional I/O (data is already sorted)
3. Both operations use a single index lookup
### Why packet_id Index Works
The `packet_seen` table can have millions of rows. Without an index:
- Each packet requires a full table scan of packet_seen
- O(n * m) complexity where n=packets, m=packet_seen rows
With the index:
- Each packet uses an index lookup
- O(n * log m) complexity - dramatically faster
### Index Size Impact
- `idx_packet_from_node_time`: ~10-20% of packet table size
- `idx_packet_seen_packet_id`: ~5-10% of packet_seen table size
- Total additional disk space: typically 50-200MB depending on data volume
- Performance gain: 80-95% query time reduction
## Future Optimizations
If query is still slow after indexes:
1. **Add ANALYZE**: Run `ANALYZE;` to update SQLite query planner statistics
2. **Consider materialized view**: Pre-compute traffic stats in a background job
3. **Add caching**: Cache results for 5-10 minutes using Redis/memcached
4. **Partition data**: Archive old packet_seen records
## Rollback
If needed, indexes can be removed:
```sql
DROP INDEX IF EXISTS idx_packet_from_node_time;
DROP INDEX IF EXISTS idx_packet_seen_packet_id;
```
## Files Modified
- `meshview/models.py` - Added index definitions
- `meshview/web.py` - Added performance profiling
- `meshview/templates/top.html` - Added metrics display
- `add_db_indexes.py` - Migration script (NEW)
- `PERFORMANCE_OPTIMIZATION.md` - This documentation (NEW)
## Support
For questions or issues:
1. Verify indexes exist: `python add_db_indexes.py` (safe to re-run)
2. Review SQLite EXPLAIN QUERY PLAN for the query

243
README-Docker.md Normal file
View File

@@ -0,0 +1,243 @@
# Running MeshView with Docker
MeshView container images are built automatically and published to GitHub Container Registry.
## Quick Start
Pull and run the latest image:
```bash
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
docker run -d \
--name meshview \
-p 8081:8081 \
-v ./config:/etc/meshview \
-v ./data:/var/lib/meshview \
-v ./logs:/var/log/meshview \
ghcr.io/pablorevilla-meshtastic/meshview:latest
```
Access the web interface at: http://localhost:8081
## Volume Mounts
The container uses three volumes for persistent data:
| Volume | Purpose | Required |
|--------|---------|----------|
| `/etc/meshview` | Configuration files | Yes |
| `/var/lib/meshview` | Database storage | Recommended |
| `/var/log/meshview` | Log files | Optional |
### Configuration Volume
Mount a directory containing your `config.ini` file:
```bash
-v /path/to/your/config:/etc/meshview
```
If no config is provided, the container will use the default `sample.config.ini`.
### Database Volume
Mount a directory to persist the SQLite database:
```bash
-v /path/to/your/data:/var/lib/meshview
```
**Important:** Without this mount, your database will be lost when the container stops.
### Logs Volume
Mount a directory to access logs from the host:
```bash
-v /path/to/your/logs:/var/log/meshview
```
## Complete Example
Create a directory structure and run:
```bash
# Create directories
mkdir -p meshview/{config,data,logs,backups}
# Copy sample config (first time only)
docker run --rm ghcr.io/pablorevilla-meshtastic/meshview:latest \
cat /etc/meshview/config.ini > meshview/config/config.ini
# Edit config.ini with your MQTT settings
nano meshview/config/config.ini
# Run the container
docker run -d \
--name meshview \
--restart unless-stopped \
-p 8081:8081 \
-v $(pwd)/meshview/config:/etc/meshview \
-v $(pwd)/meshview/data:/var/lib/meshview \
-v $(pwd)/meshview/logs:/var/log/meshview \
ghcr.io/pablorevilla-meshtastic/meshview:latest
```
## Docker Compose
Create a `docker-compose.yml`:
```yaml
version: '3.8'
services:
meshview:
image: ghcr.io/pablorevilla-meshtastic/meshview:latest
container_name: meshview
restart: unless-stopped
ports:
- "8081:8081"
volumes:
- ./config:/etc/meshview
- ./data:/var/lib/meshview
- ./logs:/var/log/meshview
- ./backups:/var/lib/meshview/backups # For database backups
environment:
- TZ=America/Los_Angeles # Set your timezone
```
Run with:
```bash
docker-compose up -d
```
## Configuration
### Minimum Configuration
Edit your `config.ini` to configure MQTT connection:
```ini
[mqtt]
server = mqtt.meshtastic.org
topics = ["msh/US/#"]
port = 1883
username =
password =
[database]
connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db
```
### Database Backups
To enable automatic daily backups inside the container:
```ini
[cleanup]
backup_enabled = True
backup_dir = /var/lib/meshview/backups
backup_hour = 2
backup_minute = 00
```
Then mount the backups directory:
```bash
-v $(pwd)/meshview/backups:/var/lib/meshview/backups
```
## Available Tags
| Tag | Description |
|-----|-------------|
| `latest` | Latest build from the main branch |
| `dev-v3` | Development branch |
| `v1.2.3` | Specific version tags |
## Updating
Pull the latest image and restart:
```bash
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
docker restart meshview
```
Or with docker-compose:
```bash
docker-compose pull
docker-compose up -d
```
## Logs
View container logs:
```bash
docker logs meshview
# Follow logs
docker logs -f meshview
# Last 100 lines
docker logs --tail 100 meshview
```
## Troubleshooting
### Container won't start
Check logs:
```bash
docker logs meshview
```
### Database permission issues
Ensure the data directory is writable:
```bash
chmod -R 755 meshview/data
```
### Can't connect to MQTT
1. Check your MQTT configuration in `config.ini`
2. Verify network connectivity from the container:
```bash
docker exec meshview ping mqtt.meshtastic.org
```
### Port already in use
Change the host port (left side):
```bash
-p 8082:8081
```
Then access at: http://localhost:8082
## Building Your Own Image
If you want to build from source:
```bash
git clone https://github.com/pablorevilla-meshtastic/meshview.git
cd meshview
docker build -f Containerfile -t meshview:local .
```
## Security Notes
- The container runs as a non-root user (`app`, UID 10001)
- No privileged access required
- Only port 8081 is exposed
- All data stored in mounted volumes
## Support
- GitHub Issues: https://github.com/pablorevilla-meshtastic/meshview/issues
- Documentation: https://github.com/pablorevilla-meshtastic/meshview

138
README.md
View File

@@ -4,6 +4,44 @@
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
### Version 3.0.1 — December 2025
#### 🌐 Multi-Language Support (i18n)
- New `/api/lang` endpoint for serving translations
- Section-based translation loading (e.g., `?section=firehose`)
- Default language controlled via config file language section
- JSON-based translation files for easy expansion
- Core pages updated to support `data-translate-lang` attributes
### 🛠 Improvements
- Updated UI elements across multiple templates for localization readiness
- General cleanup to support future language additions
### Version 3.0.0 update - November 2025
**Major Infrastructure Improvements:**
* **Database Migrations**: Alembic integration for safe schema upgrades and database versioning
* **Automated Backups**: Independent database backup system with gzip compression (separate from cleanup)
* **Development Tools**: Quick setup script (`setup-dev.sh`) with pre-commit hooks for code quality
* **Docker Support**: Pre-built containers now available on GitHub Container Registry with automatic builds - ogarcia
**New Features:**
* **Traceroute Return Path**: Log and display return path data for traceroute packets - jschrempp
* **Microsecond Timestamps**: Added `import_time_us` columns for higher precision time tracking
**Technical Improvements:**
* Migration from manual SQL to Alembic-managed schema
* Container images use `uv` for faster dependency installation
* Python 3.13 support with slim Debian-based images
* Documentation collection in `docs/` directory
* API routes moved to separate modules for better organization
* /version and /health endpoints added for monitoring
See [README-Docker.md](README-Docker.md) for container deployment and [docs/](docs/) for technical documentation.
### 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.
@@ -14,6 +52,7 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
* New API /api/edges (See API documentation)
* Adds edges to the map (click to see traceroute and neighbours)
### Version 2.0.4 update - August 2025
* New statistic page with more data.
* New API /api/stats (See API documentation).
@@ -48,32 +87,69 @@ Samples of currently running instances:
- https://map.wpamesh.net (Western Pennsylvania)
- https://meshview.chicagolandmesh.org (Chicago)
- https://meshview.mt.gt (Canadaverse)
- https://canadaverse.org (Canadaverse)
- https://meshview.meshtastic.es (Spain)
- 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://meshview.louisianamesh.org (Louisiana)
- https://www.swlamesh.com/map (Southwest Louisiana)
- https://meshview.meshcolombia.co/ (Colombia)
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
---
### Updating from 2.x to 3.x
We are adding the use of Alembic. If using GitHub
Update your codebase by running the pull command
```bash
cd meshview
git pull origin master
```
Install Alembic in your environment
```bash
./env/bin/pip install alembic
```
Start your scripts or services. This process will update your database with the latest schema.
## Installing
Requires **`python3.11`** or above.
### Using Docker (Recommended)
The easiest way to run MeshView is using Docker. Pre-built images are available from GitHub Container Registry.
See **[README-Docker.md](README-Docker.md)** for complete Docker installation and usage instructions.
### Manual Installation
Requires **`python3.13`** or above.
Clone the repo from GitHub:
```bash
git clone https://github.com/pablorevilla-meshtastic/meshview.git
```
```bash
cd meshview
```
#### Quick Setup (Recommended)
Run the development setup script:
```bash
./setup-dev.sh
```
This will:
- Create Python virtual environment
- Install all requirements
- Install development tools (pre-commit, pytest)
- Set up pre-commit hooks for code formatting
- Create config.ini from sample
#### Manual Setup
Create a Python virtual environment:
from the meshview directory...
```bash
python3 -m venv env
```
@@ -136,6 +212,9 @@ acme_challenge =
# The domain name of your site.
domain =
# Select language (this represents the name of the json file in the /lang directory)
language = es
# Site title to show in the browser title bar and headers.
title = Bay Area Mesh
@@ -221,6 +300,8 @@ vacuum = False
# Application logs (errors, startup messages, etc.) are unaffected
# Set to True to enable, False to disable (default: False)
access_log = False
# Database cleanup logfile location
db_cleanup_logfile = dbcleanup.log
```
---
@@ -254,12 +335,29 @@ Open in your browser: http://localhost:8081/
## Running Meshview with `mvrun.py`
- `mvrun.py` starts both `startdb.py` and `main.py` in separate threads and merges the output.
- It accepts the `--config` argument like the others.
- It accepts several command-line arguments for flexible deployment.
```bash
./env/bin/python mvrun.py
```
**Command-line options:**
- `--config CONFIG` - Path to the configuration file (default: `config.ini`)
- `--pid_dir PID_DIR` - Directory for PID files (default: `.`)
- `--py_exec PY_EXEC` - Path to the Python executable (default: `./env/bin/python`)
**Examples:**
```bash
# Use a specific config file
./env/bin/python mvrun.py --config /etc/meshview/config.ini
# Store PID files in a specific directory
./env/bin/python mvrun.py --pid_dir /var/run/meshview
# Use a different Python executable
./env/bin/python mvrun.py --py_exec /usr/bin/python3
```
---
## Setting Up Systemd Services (Ubuntu)
@@ -365,6 +463,15 @@ hour = 2
minute = 00
# Run VACUUM after cleanup
vacuum = False
# -------------------------
# Logging Configuration
# -------------------------
[logging]
# Enable or disable HTTP access logs from the web server
access_log = False
# Database cleanup logfile location
db_cleanup_logfile = dbcleanup.log
```
Once changes are done you need to restart the script for changes to load.
@@ -413,3 +520,20 @@ Add schedule to the bottom of the file (modify /path/to/file/ to the correct pat
```
Check the log file to see it the script run at the specific time.
---
## Testing
MeshView includes a test suite using pytest. For detailed testing documentation, see [README-testing.md](README-testing.md).
Quick start:
```bash
./env/bin/pytest tests/test_api_simple.py -v
```
---
## Technical Documentation
For more detailed technical documentation including database migrations, architecture details, and advanced topics, see the [docs/](docs/) directory.

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env python3
"""
Migration script to add performance indexes
This script adds two critical indexes:
1. idx_packet_from_node_time: Composite index on packet(from_node_id, import_time DESC)
2. idx_packet_seen_packet_id: Index on packet_seen(packet_id)
These indexes significantly improve the performance of the get_top_traffic_nodes() query.
Usage:
python add_db_indexes.py
The script will:
- Connect to your database in WRITE mode
- Check if indexes already exist
- Create missing indexes
- Report timing for each operation
"""
import asyncio
import time
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from meshview.config import CONFIG
async def add_indexes():
# Get database connection string and remove read-only flag
db_string = CONFIG["database"]["connection_string"]
if "?mode=ro" in db_string:
db_string = db_string.replace("?mode=ro", "")
print(f"Connecting to database: {db_string}")
# Create engine with write access
engine = create_async_engine(db_string, echo=False, connect_args={"uri": True})
try:
async with engine.begin() as conn:
# Check and create idx_packet_from_node_time
print("\n" + "=" * 70)
print("Checking for index: idx_packet_from_node_time")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name FROM sqlite_master
WHERE type='index' AND name='idx_packet_from_node_time'
""")
)
if result.fetchone():
print("✓ Index idx_packet_from_node_time already exists")
else:
print("Creating index idx_packet_from_node_time...")
print(" Table: packet")
print(" Columns: from_node_id, import_time DESC")
print(" Purpose: Speeds up filtering packets by sender and time range")
start_time = time.perf_counter()
await conn.execute(
text("""
CREATE INDEX idx_packet_from_node_time
ON packet(from_node_id, import_time DESC)
""")
)
elapsed = time.perf_counter() - start_time
print(f"✓ Index created successfully in {elapsed:.2f} seconds")
# Check and create idx_packet_seen_packet_id
print("\n" + "=" * 70)
print("Checking for index: idx_packet_seen_packet_id")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name FROM sqlite_master
WHERE type='index' AND name='idx_packet_seen_packet_id'
""")
)
if result.fetchone():
print("✓ Index idx_packet_seen_packet_id already exists")
else:
print("Creating index idx_packet_seen_packet_id...")
print(" Table: packet_seen")
print(" Columns: packet_id")
print(" Purpose: Speeds up joining packet_seen with packet table")
start_time = time.perf_counter()
await conn.execute(
text("""
CREATE INDEX idx_packet_seen_packet_id
ON packet_seen(packet_id)
""")
)
elapsed = time.perf_counter() - start_time
print(f"✓ Index created successfully in {elapsed:.2f} seconds")
# Show index info
print("\n" + "=" * 70)
print("Current indexes on packet table:")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name, sql FROM sqlite_master
WHERE type='index' AND tbl_name='packet'
ORDER BY name
""")
)
for row in result:
if row[1]: # Skip auto-indexes (they have NULL sql)
print(f"{row[0]}")
print("\n" + "=" * 70)
print("Current indexes on packet_seen table:")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name, sql FROM sqlite_master
WHERE type='index' AND tbl_name='packet_seen'
ORDER BY name
""")
)
for row in result:
if row[1]: # Skip auto-indexes
print(f"{row[0]}")
print("\n" + "=" * 70)
print("Migration completed successfully!")
print("=" * 70)
print("\nNext steps:")
print("1. Restart your web server (mvrun.py)")
print("2. Visit /top endpoint and check the performance metrics")
print("3. Compare DB query time with previous measurements")
print("\nExpected improvement: 50-90% reduction in query time")
except Exception as e:
print(f"\n❌ Error during migration: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
print("=" * 70)
print("Database Index Migration for Endpoint Performance")
print("=" * 70)
asyncio.run(add_indexes())

120
alembic.ini Normal file
View File

@@ -0,0 +1,120 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# sqlalchemy.url will be set programmatically from meshview config
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(filename)s:%(lineno)d [pid:%(process)d] %(levelname)s - %(message)s
datefmt = %Y-%m-%d %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

102
alembic/env.py Normal file
View File

@@ -0,0 +1,102 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import models metadata for autogenerate support
from meshview.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
# Use disable_existing_loggers=False to preserve app logging configuration
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Add your model's MetaData object here for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with the given connection."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in async mode."""
# Get configuration section
configuration = config.get_section(config.config_ini_section, {})
# If sqlalchemy.url is not set in alembic.ini, try to get it from meshview config
if "sqlalchemy.url" not in configuration:
try:
from meshview.config import CONFIG
configuration["sqlalchemy.url"] = CONFIG["database"]["connection_string"]
except Exception:
# Fallback to a default for initial migration creation
configuration["sqlalchemy.url"] = "sqlite+aiosqlite:///packets.db"
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode with async support."""
try:
# Event loop is already running, schedule and run the coroutine
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
pool.submit(lambda: asyncio.run(run_async_migrations())).result()
except RuntimeError:
# No event loop running, create one
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,45 @@
"""Add example table
Revision ID: 1717fa5c6545
Revises: c88468b7ab0b
Create Date: 2025-10-26 20:59:04.347066
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1717fa5c6545'
down_revision: str | None = 'add_time_us_cols'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create example table with sample columns."""
op.create_table(
'example',
sa.Column('id', sa.Integer(), nullable=False, primary_key=True, autoincrement=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('value', sa.Float(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column(
'created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')
),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
# Create an index on the name column for faster lookups
op.create_index('idx_example_name', 'example', ['name'])
def downgrade() -> None:
"""Remove example table."""
op.drop_index('idx_example_name', table_name='example')
op.drop_table('example')

View File

@@ -0,0 +1,35 @@
"""Add first_seen_us and last_seen_us to node table
Revision ID: 2b5a61bb2b75
Revises: ac311b3782a1
Create Date: 2025-11-05 15:19:13.446724
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '2b5a61bb2b75'
down_revision: str | None = 'ac311b3782a1'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Add microsecond epoch timestamp columns for first and last seen times
op.add_column('node', sa.Column('first_seen_us', sa.BigInteger(), nullable=True))
op.add_column('node', sa.Column('last_seen_us', sa.BigInteger(), nullable=True))
op.create_index('idx_node_first_seen_us', 'node', ['first_seen_us'], unique=False)
op.create_index('idx_node_last_seen_us', 'node', ['last_seen_us'], unique=False)
def downgrade() -> None:
# Remove the microsecond epoch timestamp columns and their indexes
op.drop_index('idx_node_last_seen_us', table_name='node')
op.drop_index('idx_node_first_seen_us', table_name='node')
op.drop_column('node', 'last_seen_us')
op.drop_column('node', 'first_seen_us')

View File

@@ -0,0 +1,31 @@
"""add route_return to traceroute
Revision ID: ac311b3782a1
Revises: 1717fa5c6545
Create Date: 2025-11-04 20:28:33.174137
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'ac311b3782a1'
down_revision: str | None = '1717fa5c6545'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Add route_return column to traceroute table
with op.batch_alter_table('traceroute', schema=None) as batch_op:
batch_op.add_column(sa.Column('route_return', sa.LargeBinary(), nullable=True))
def downgrade() -> None:
# Remove route_return column from traceroute table
with op.batch_alter_table('traceroute', schema=None) as batch_op:
batch_op.drop_column('route_return')

View File

@@ -0,0 +1,74 @@
"""add import_time_us columns
Revision ID: add_time_us_cols
Revises: c88468b7ab0b
Create Date: 2025-11-03 14:10:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'add_time_us_cols'
down_revision: str | None = 'c88468b7ab0b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Check if columns already exist, add them if they don't
conn = op.get_bind()
inspector = sa.inspect(conn)
# Add import_time_us to packet table
packet_columns = [col['name'] for col in inspector.get_columns('packet')]
if 'import_time_us' not in packet_columns:
with op.batch_alter_table('packet', schema=None) as batch_op:
batch_op.add_column(sa.Column('import_time_us', sa.BigInteger(), nullable=True))
op.create_index(
'idx_packet_import_time_us', 'packet', [sa.text('import_time_us DESC')], unique=False
)
op.create_index(
'idx_packet_from_node_time_us',
'packet',
['from_node_id', sa.text('import_time_us DESC')],
unique=False,
)
# Add import_time_us to packet_seen table
packet_seen_columns = [col['name'] for col in inspector.get_columns('packet_seen')]
if 'import_time_us' not in packet_seen_columns:
with op.batch_alter_table('packet_seen', schema=None) as batch_op:
batch_op.add_column(sa.Column('import_time_us', sa.BigInteger(), nullable=True))
op.create_index(
'idx_packet_seen_import_time_us', 'packet_seen', ['import_time_us'], unique=False
)
# Add import_time_us to traceroute table
traceroute_columns = [col['name'] for col in inspector.get_columns('traceroute')]
if 'import_time_us' not in traceroute_columns:
with op.batch_alter_table('traceroute', schema=None) as batch_op:
batch_op.add_column(sa.Column('import_time_us', sa.BigInteger(), nullable=True))
op.create_index(
'idx_traceroute_import_time_us', 'traceroute', ['import_time_us'], unique=False
)
def downgrade() -> None:
# Drop indexes and columns
op.drop_index('idx_traceroute_import_time_us', table_name='traceroute')
with op.batch_alter_table('traceroute', schema=None) as batch_op:
batch_op.drop_column('import_time_us')
op.drop_index('idx_packet_seen_import_time_us', table_name='packet_seen')
with op.batch_alter_table('packet_seen', schema=None) as batch_op:
batch_op.drop_column('import_time_us')
op.drop_index('idx_packet_from_node_time_us', table_name='packet')
op.drop_index('idx_packet_import_time_us', table_name='packet')
with op.batch_alter_table('packet', schema=None) as batch_op:
batch_op.drop_column('import_time_us')

View File

@@ -0,0 +1,160 @@
"""Initial migration
Revision ID: c88468b7ab0b
Revises:
Create Date: 2025-10-26 20:56:50.285200
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c88468b7ab0b'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Get connection and inspector to check what exists
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = inspector.get_table_names()
# Create node table if it doesn't exist
if 'node' not in existing_tables:
op.create_table(
'node',
sa.Column('id', sa.String(), nullable=False),
sa.Column('node_id', sa.BigInteger(), nullable=True),
sa.Column('long_name', sa.String(), nullable=True),
sa.Column('short_name', sa.String(), nullable=True),
sa.Column('hw_model', sa.String(), nullable=True),
sa.Column('firmware', sa.String(), nullable=True),
sa.Column('role', sa.String(), nullable=True),
sa.Column('last_lat', sa.BigInteger(), nullable=True),
sa.Column('last_long', sa.BigInteger(), nullable=True),
sa.Column('channel', sa.String(), nullable=True),
sa.Column('last_update', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('node_id'),
)
op.create_index('idx_node_node_id', 'node', ['node_id'], unique=False)
# Create packet table if it doesn't exist
if 'packet' not in existing_tables:
op.create_table(
'packet',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('portnum', sa.Integer(), nullable=True),
sa.Column('from_node_id', sa.BigInteger(), nullable=True),
sa.Column('to_node_id', sa.BigInteger(), nullable=True),
sa.Column('payload', sa.LargeBinary(), nullable=True),
sa.Column('import_time', sa.DateTime(), nullable=True),
sa.Column('import_time_us', sa.BigInteger(), nullable=True),
sa.Column('channel', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('idx_packet_from_node_id', 'packet', ['from_node_id'], unique=False)
op.create_index('idx_packet_to_node_id', 'packet', ['to_node_id'], unique=False)
op.create_index(
'idx_packet_import_time', 'packet', [sa.text('import_time DESC')], unique=False
)
op.create_index(
'idx_packet_import_time_us', 'packet', [sa.text('import_time_us DESC')], unique=False
)
op.create_index(
'idx_packet_from_node_time',
'packet',
['from_node_id', sa.text('import_time DESC')],
unique=False,
)
op.create_index(
'idx_packet_from_node_time_us',
'packet',
['from_node_id', sa.text('import_time_us DESC')],
unique=False,
)
# Create packet_seen table if it doesn't exist
if 'packet_seen' not in existing_tables:
op.create_table(
'packet_seen',
sa.Column('packet_id', sa.BigInteger(), nullable=False),
sa.Column('node_id', sa.BigInteger(), nullable=False),
sa.Column('rx_time', sa.BigInteger(), nullable=False),
sa.Column('hop_limit', sa.Integer(), nullable=True),
sa.Column('hop_start', sa.Integer(), nullable=True),
sa.Column('channel', sa.String(), nullable=True),
sa.Column('rx_snr', sa.Float(), nullable=True),
sa.Column('rx_rssi', sa.Integer(), nullable=True),
sa.Column('topic', sa.String(), nullable=True),
sa.Column('import_time', sa.DateTime(), nullable=True),
sa.Column('import_time_us', sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(
['packet_id'],
['packet.id'],
),
sa.PrimaryKeyConstraint('packet_id', 'node_id', 'rx_time'),
)
op.create_index('idx_packet_seen_node_id', 'packet_seen', ['node_id'], unique=False)
op.create_index('idx_packet_seen_packet_id', 'packet_seen', ['packet_id'], unique=False)
op.create_index(
'idx_packet_seen_import_time_us', 'packet_seen', ['import_time_us'], unique=False
)
# Create traceroute table if it doesn't exist
if 'traceroute' not in existing_tables:
op.create_table(
'traceroute',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('packet_id', sa.BigInteger(), nullable=True),
sa.Column('gateway_node_id', sa.BigInteger(), nullable=True),
sa.Column('done', sa.Boolean(), nullable=True),
sa.Column('route', sa.LargeBinary(), nullable=True),
sa.Column('import_time', sa.DateTime(), nullable=True),
sa.Column('import_time_us', sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(
['packet_id'],
['packet.id'],
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('idx_traceroute_import_time', 'traceroute', ['import_time'], unique=False)
op.create_index(
'idx_traceroute_import_time_us', 'traceroute', ['import_time_us'], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Drop traceroute table and indexes
op.drop_index('idx_traceroute_import_time_us', table_name='traceroute')
op.drop_index('idx_traceroute_import_time', table_name='traceroute')
op.drop_table('traceroute')
# Drop packet_seen table and indexes
op.drop_index('idx_packet_seen_import_time_us', table_name='packet_seen')
op.drop_index('idx_packet_seen_packet_id', table_name='packet_seen')
op.drop_index('idx_packet_seen_node_id', table_name='packet_seen')
op.drop_table('packet_seen')
# Drop packet table and indexes
op.drop_index('idx_packet_from_node_time_us', table_name='packet')
op.drop_index('idx_packet_from_node_time', table_name='packet')
op.drop_index('idx_packet_import_time_us', table_name='packet')
op.drop_index('idx_packet_import_time', table_name='packet')
op.drop_index('idx_packet_to_node_id', table_name='packet')
op.drop_index('idx_packet_from_node_id', table_name='packet')
op.drop_table('packet')
# Drop node table and indexes
op.drop_index('idx_node_node_id', table_name='node')
op.drop_table('node')
# ### end Alembic commands ###

57
container/build-container.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/sh
#
# build-container.sh
#
# Script to build MeshView container images
set -e
# Default values
IMAGE_NAME="meshview"
TAG="latest"
CONTAINERFILE="Containerfile"
# Parse arguments
while [ $# -gt 0 ]; do
case "$1" in
--tag|-t)
TAG="$2"
shift 2
;;
--name|-n)
IMAGE_NAME="$2"
shift 2
;;
--file|-f)
CONTAINERFILE="$2"
shift 2
;;
--help|-h)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -t, --tag TAG Tag for the image (default: latest)"
echo " -n, --name NAME Image name (default: meshview)"
echo " -f, --file FILE Containerfile path (default: Containerfile)"
echo " -h, --help Show this help"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
echo "Building MeshView container image..."
echo " Image: ${IMAGE_NAME}:${TAG}"
echo " Containerfile: ${CONTAINERFILE}"
echo ""
# Build the container
docker build -f "${CONTAINERFILE}" -t "${IMAGE_NAME}:${TAG}" .
echo ""
echo "Build complete!"
echo "Run with: docker run --rm -p 8081:8081 ${IMAGE_NAME}:${TAG}"

37
container/config.patch Normal file
View File

@@ -0,0 +1,37 @@
diff --git a/sample.config.ini b/sample.config.ini
index 0e64980..494685c 100644
--- a/sample.config.ini
+++ b/sample.config.ini
@@ -3,7 +3,7 @@
# -------------------------
[server]
# The address to bind the server to. Use * to listen on all interfaces.
-bind = *
+bind = 0.0.0.0
# Port to run the web server on.
port = 8081
@@ -64,7 +64,7 @@ net_tag = #BayMeshNet
# -------------------------
[mqtt]
# MQTT server hostname or IP.
-server = mqtt.bayme.sh
+server = mqtt.meshtastic.org
# Topics to subscribe to (as JSON-like list, but still a string).
topics = ["msh/US/bayarea/#", "msh/US/CA/mrymesh/#", "msh/US/CA/sacvalley"]
@@ -82,7 +82,7 @@ password = large4cats
# -------------------------
[database]
# SQLAlchemy connection string. This one uses SQLite with asyncio support.
-connection_string = sqlite+aiosqlite:///packets.db
+connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
# -------------------------
@@ -110,4 +110,4 @@ vacuum = False
# Set to True to enable, False to disable (default: False)
access_log = False
# Database cleanup logfile
-db_cleanup_logfile = dbcleanup.log
+db_cleanup_logfile = /var/log/meshview/dbcleanup.log

52
create_example_migration.py Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Script to create a blank migration for manual editing.
Usage:
./env/bin/python create_example_migration.py
This creates an empty migration file that you can manually edit to add
custom migration logic (data migrations, complex schema changes, etc.)
Unlike create_migration.py which auto-generates from model changes,
this creates a blank template for you to fill in.
"""
import os
import sys
# Add current directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from alembic.config import Config
from alembic import command
# Create Alembic config
alembic_cfg = Config("alembic.ini")
# Set database URL from meshview config
try:
from meshview.config import CONFIG
database_url = CONFIG["database"]["connection_string"]
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
print(f"Using database URL from config: {database_url}")
except Exception as e:
print(f"Warning: Could not load meshview config: {e}")
print("Using default database URL")
alembic_cfg.set_main_option("sqlalchemy.url", "sqlite+aiosqlite:///packets.db")
# Generate blank migration
try:
print("Creating blank migration for manual editing...")
command.revision(alembic_cfg, autogenerate=False, message="Manual migration")
print("✓ Successfully created blank migration!")
print("\nNow edit the generated file in alembic/versions/")
print("Add your custom upgrade() and downgrade() logic")
except Exception as e:
print(f"✗ Error creating migration: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

58
create_migration.py Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Helper script to create Alembic migrations from SQLAlchemy model changes.
Usage:
./env/bin/python create_migration.py
This will:
1. Load your current models from meshview/models.py
2. Compare them to the current database schema
3. Auto-generate a migration with the detected changes
4. Save the migration to alembic/versions/
After running this, review the generated migration file before committing!
"""
import os
import sys
# Add current directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from alembic.config import Config
from alembic import command
# Create Alembic config
alembic_cfg = Config("alembic.ini")
# Set database URL from meshview config
try:
from meshview.config import CONFIG
database_url = CONFIG["database"]["connection_string"]
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
print(f"Using database URL from config: {database_url}")
except Exception as e:
print(f"Warning: Could not load meshview config: {e}")
print("Using default database URL")
alembic_cfg.set_main_option("sqlalchemy.url", "sqlite+aiosqlite:///packets.db")
# Generate migration
try:
print("\nComparing models to current database schema...")
print("Generating migration...\n")
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration")
print("\n✓ Successfully created migration!")
print("\nNext steps:")
print("1. Review the generated file in alembic/versions/")
print("2. Edit the migration message/logic if needed")
print("3. Test the migration: ./env/bin/alembic upgrade head")
print("4. Commit the migration file to version control")
except Exception as e:
print(f"\n✗ Error creating migration: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@@ -1,28 +0,0 @@
FROM python:3.12-slim
# Set work directory
WORKDIR /app
# Install system dependencies (graphviz required, git for cloning)
RUN apt-get update && \
apt-get install -y --no-install-recommends git graphviz && \
rm -rf /var/lib/apt/lists/*
# Clone the repo with submodules
RUN git clone --recurse-submodules https://github.com/pablorevilla-meshtastic/meshview.git /app
# Create virtual environment
RUN python -m venv /app/env
# Upgrade pip and install requirements in venv
RUN /app/env/bin/pip install --no-cache-dir --upgrade pip && \
/app/env/bin/pip install --no-cache-dir -r /app/requirements.txt
# Copy sample config
RUN cp /app/sample.config.ini /app/config.ini
# Expose port
EXPOSE 8081
# Run the app via venv
CMD ["/app/env/bin/python", "/app/mvrun.py"]

View File

@@ -1,44 +1,36 @@
# MeshView Docker Container
This Dockerfile builds a containerized version of the [MeshView](https://github.com/pablorevilla-meshtastic/meshview) application. It uses a lightweight Python environment and sets up the required virtual environment as expected by the application.
> **Note:** This directory contains legacy Docker build files.
>
> **For current Docker usage instructions, please see [README-Docker.md](../README-Docker.md) in the project root.**
## Image Details
## Current Approach
- **Base Image**: `python:3.12-slim`
- **Working Directory**: `/app`
- **Python Virtual Environment**: `/app/env`
Pre-built container images are automatically built and published to GitHub Container Registry:
```bash
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
```
See **[README-Docker.md](../README-Docker.md)** for:
- Quick start instructions
- Volume mount configuration
- Docker Compose examples
- Backup configuration
- Troubleshooting
## Legacy Build (Not Recommended)
If you need to build your own image for development:
```bash
# From project root
docker build -f Containerfile -t meshview:local .
```
The current Containerfile uses:
- **Base Image**: `python:3.13-slim` (Debian-based)
- **Build tool**: `uv` for fast dependency installation
- **User**: Non-root user `app` (UID 10001)
- **Exposed Port**: `8081`
## Build Instructions
Build the Docker image:
```bash
docker build -t meshview-docker .
```
## Run Instructions
Run the container:
```bash
docker run -d --name meshview-docker -p 8081:8081 meshview-docker
```
This maps container port `8081` to your host. The application runs via:
```bash
/app/env/bin/python /app/mvrun.py
```
## Web Interface
Once the container is running, you can access the MeshView web interface by visiting:
http://localhost:8081
If running on a remote server, replace `localhost` with the host's IP or domain name:
http://<host>:8081
Ensure that port `8081` is open and not blocked by a firewall or security group.
- **Volumes**: `/etc/meshview`, `/var/lib/meshview`, `/var/log/meshview`

361
docs/ALEMBIC_SETUP.md Normal file
View File

@@ -0,0 +1,361 @@
# Alembic Database Migration Setup
This document describes the automatic database migration system implemented for MeshView using Alembic.
## Overview
The system provides automatic database schema migrations with coordination between the writer app (startdb.py) and reader app (web.py):
- **Writer App**: Automatically runs pending migrations on startup
- **Reader App**: Waits for migrations to complete before starting
## Architecture
### Key Components
1. **`meshview/migrations.py`** - Migration management utilities
- `run_migrations()` - Runs pending migrations (writer app)
- `wait_for_migrations()` - Waits for schema to be current (reader app)
- `is_database_up_to_date()` - Checks schema version
- Migration status tracking table
2. **`alembic/`** - Alembic migration directory
- `env.py` - Configured for async SQLAlchemy support
- `versions/` - Migration scripts directory
- `alembic.ini` - Alembic configuration
3. **Modified Apps**:
- `startdb.py` - Writer app that runs migrations before MQTT ingestion
- `meshview/web.py` - Reader app that waits for schema updates
## How It Works - Automatic In-Place Updates
### ✨ Fully Automatic Operation
**No manual migration commands needed!** The database schema updates automatically when you:
1. Deploy new code with migration files
2. Restart the applications
### Writer App (startdb.py) Startup Sequence
1. Initialize database connection
2. Create migration status tracking table
3. Set "migration in progress" flag
4. **🔄 Automatically run any pending Alembic migrations** (synchronously)
- Detects current schema version
- Compares to latest available migration
- Runs all pending migrations in sequence
- Updates database schema in place
5. Clear "migration in progress" flag
6. Start MQTT ingestion and other tasks
### Reader App (web.py) Startup Sequence
1. Initialize database connection
2. **Check database schema version**
3. If not up to date:
- Wait up to 60 seconds (30 retries × 2 seconds)
- Check every 2 seconds for schema updates
- Automatically proceeds once writer completes migrations
4. Once schema is current, start web server
### 🎯 Key Point: Zero Manual Steps
When you deploy new code with migrations:
```bash
# Just start the apps - migrations happen automatically!
./env/bin/python startdb.py # Migrations run here automatically
./env/bin/python main.py # Waits for migrations, then starts
```
**The database updates itself!** No need to run `alembic upgrade` manually.
### Coordination
The apps coordinate using:
- **Alembic version table** (`alembic_version`) - Tracks current schema version
- **Migration status table** (`migration_status`) - Optional flag for "in progress" state
## Creating New Migrations
### Using the helper script:
```bash
./env/bin/python create_migration.py
```
### Manual creation:
```bash
./env/bin/alembic revision --autogenerate -m "Description of changes"
```
This will:
1. Compare current database schema with SQLAlchemy models
2. Generate a migration script in `alembic/versions/`
3. Automatically detect most schema changes
### Manual migration (advanced):
```bash
./env/bin/alembic revision -m "Manual migration"
```
Then edit the generated file to add custom migration logic.
## Running Migrations
### Automatic (Recommended)
Migrations run automatically when the writer app starts:
```bash
./env/bin/python startdb.py
```
### Manual
To run migrations manually:
```bash
./env/bin/alembic upgrade head
```
To downgrade:
```bash
./env/bin/alembic downgrade -1 # Go back one version
./env/bin/alembic downgrade base # Go back to beginning
```
## Checking Migration Status
Check current database version:
```bash
./env/bin/alembic current
```
View migration history:
```bash
./env/bin/alembic history
```
## Benefits
1. **Zero Manual Intervention**: Migrations run automatically on startup
2. **Safe Coordination**: Reader won't connect to incompatible schema
3. **Version Control**: All schema changes tracked in git
4. **Rollback Capability**: Can downgrade if needed
5. **Auto-generation**: Most migrations created automatically from model changes
## Migration Workflow
### Development Process
1. **Modify SQLAlchemy models** in `meshview/models.py`
2. **Create migration**:
```bash
./env/bin/python create_migration.py
```
3. **Review generated migration** in `alembic/versions/`
4. **Test migration**:
- Stop all apps
- Start writer app (migrations run automatically)
- Start reader app (waits for schema to be current)
5. **Commit migration** to version control
### Production Deployment
1. **Deploy new code** with migration scripts
2. **Start writer app** - Migrations run automatically
3. **Start reader app** - Waits for migrations, then starts
4. **Monitor logs** for migration success
## Troubleshooting
### Migration fails
Check logs in writer app for error details. To manually fix:
```bash
./env/bin/alembic current # Check current version
./env/bin/alembic history # View available versions
./env/bin/alembic upgrade head # Try manual upgrade
```
### Reader app won't start (timeout)
Check if writer app is running and has completed migrations:
```bash
./env/bin/alembic current
```
### Reset to clean state
⚠️ **Warning: This will lose all data**
```bash
rm packets.db # Or your database file
./env/bin/alembic upgrade head # Create fresh schema
```
## File Structure
```
meshview/
├── alembic.ini # Alembic configuration
├── alembic/
│ ├── env.py # Async-enabled migration runner
│ ├── script.py.mako # Migration template
│ └── versions/ # Migration scripts
│ └── c88468b7ab0b_initial_migration.py
├── meshview/
│ ├── models.py # SQLAlchemy models (source of truth)
│ ├── migrations.py # Migration utilities
│ ├── mqtt_database.py # Writer database connection
│ └── database.py # Reader database connection
├── startdb.py # Writer app (runs migrations)
├── main.py # Entry point for reader app
└── create_migration.py # Helper script for creating migrations
```
## Configuration
Database URL is read from `config.ini`:
```ini
[database]
connection_string = sqlite+aiosqlite:///packets.db
```
Alembic automatically uses this configuration through `meshview/migrations.py`.
## Important Notes
1. **Always test migrations** in development before deploying to production
2. **Backup database** before running migrations in production
3. **Check for data loss** - Some migrations may require data migration logic
4. **Coordinate deployments** - Start writer before readers in multi-instance setups
5. **Monitor logs** during first startup after deployment
## Example Migrations
### Example 1: Generated Initial Migration
Here's what an auto-generated migration looks like (from comparing models to database):
```python
"""Initial migration
Revision ID: c88468b7ab0b
Revises:
Create Date: 2025-01-26 20:56:50.123456
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'c88468b7ab0b'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Upgrade operations
op.create_table('node',
sa.Column('id', sa.String(), nullable=False),
sa.Column('node_id', sa.BigInteger(), nullable=True),
# ... more columns
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
# Downgrade operations
op.drop_table('node')
```
### Example 2: Manual Migration Adding a New Table
We've included an example migration (`1717fa5c6545_add_example_table.py`) that demonstrates how to manually create a new table:
```python
"""Add example table
Revision ID: 1717fa5c6545
Revises: c88468b7ab0b
Create Date: 2025-10-26 20:59:04.347066
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
"""Create example table with sample columns."""
op.create_table(
'example',
sa.Column('id', sa.Integer(), nullable=False, primary_key=True, autoincrement=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('value', sa.Float(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('created_at', sa.DateTime(), nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Create an index on the name column for faster lookups
op.create_index('idx_example_name', 'example', ['name'])
def downgrade() -> None:
"""Remove example table."""
op.drop_index('idx_example_name', table_name='example')
op.drop_table('example')
```
**Key features demonstrated:**
- Various column types (Integer, String, Text, Float, Boolean, DateTime)
- Primary key with autoincrement
- Nullable and non-nullable columns
- Server defaults (for timestamps and booleans)
- Creating indexes
- Proper downgrade that reverses all changes
**To test this migration:**
```bash
# Apply the migration
./env/bin/alembic upgrade head
# Check it was applied
./env/bin/alembic current
# Verify table was created
sqlite3 packetsPL.db "SELECT sql FROM sqlite_master WHERE type='table' AND name='example';"
# Roll back the migration
./env/bin/alembic downgrade -1
# Verify table was removed
sqlite3 packetsPL.db "SELECT name FROM sqlite_master WHERE type='table' AND name='example';"
```
**To remove this example migration** (after testing):
```bash
# First make sure you're not on this revision
./env/bin/alembic downgrade c88468b7ab0b
# Then delete the migration file
rm alembic/versions/1717fa5c6545_add_example_table.py
```
## References
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
- [Async SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)

View File

@@ -111,12 +111,29 @@ Returns a list of packets with optional filters.
---
### Notes
- All timestamps (`import_time`, `last_seen`) are returned in ISO 8601 format.
- `portnum` is an integer representing the packet type.
- `payload` is always a UTF-8 decoded string.
---
## 4 Statistics API: GET `/api/stats`
## 4. Channels API
### GET `/api/channels`
Returns a list of channels seen in a given time period.
**Query Parameters**
- `period_type` (optional, string): Time granularity (`hour` or `day`). Default: `hour`.
- `length` (optional, int): Number of periods to look back. Default: `24`.
**Response Example**
```json
{
"channels": ["LongFast", "MediumFast", "ShortFast"]
}
```
---
## 5. Statistics API
### GET `/api/stats`
Retrieve packet statistics aggregated by time periods, with optional filtering.
@@ -157,3 +174,171 @@ Retrieve packet statistics aggregated by time periods, with optional filtering.
// more entries...
]
}
```
---
## 6. Edges API
### GET `/api/edges`
Returns network edges (connections between nodes) based on traceroutes and neighbor info.
**Query Parameters**
- `type` (optional, string): Filter by edge type (`traceroute` or `neighbor`). If omitted, returns both types.
**Response Example**
```json
{
"edges": [
{
"from": 12345678,
"to": 87654321,
"type": "traceroute"
},
{
"from": 11111111,
"to": 22222222,
"type": "neighbor"
}
]
}
```
---
## 7. Configuration API
### GET `/api/config`
Returns the current site configuration (safe subset exposed to clients).
**Response Example**
```json
{
"site": {
"domain": "meshview.example.com",
"language": "en",
"title": "Bay Area Mesh",
"message": "Real time data from around the bay area",
"starting": "/chat",
"nodes": "true",
"conversations": "true",
"everything": "true",
"graphs": "true",
"stats": "true",
"net": "true",
"map": "true",
"top": "true",
"map_top_left_lat": 39.0,
"map_top_left_lon": -123.0,
"map_bottom_right_lat": 36.0,
"map_bottom_right_lon": -121.0,
"map_interval": 3,
"firehose_interval": 3,
"weekly_net_message": "Weekly Mesh check-in message.",
"net_tag": "#BayMeshNet",
"version": "2.0.8 ~ 10-22-25"
},
"mqtt": {
"server": "mqtt.bayme.sh",
"topics": ["msh/US/bayarea/#"]
},
"cleanup": {
"enabled": "false",
"days_to_keep": "14",
"hour": "2",
"minute": "0",
"vacuum": "false"
}
}
```
---
## 8. Language/Translations API
### GET `/api/lang`
Returns translation strings for the UI.
**Query Parameters**
- `lang` (optional, string): Language code (e.g., `en`, `es`). Defaults to site language setting.
- `section` (optional, string): Specific section to retrieve translations for.
**Response Example (full)**
```json
{
"chat": {
"title": "Chat",
"send": "Send"
},
"map": {
"title": "Map",
"zoom_in": "Zoom In"
}
}
```
**Response Example (section-specific)**
Request: `/api/lang?section=chat`
```json
{
"title": "Chat",
"send": "Send"
}
```
---
## 9. Health Check API
### GET `/health`
Health check endpoint for monitoring, load balancers, and orchestration systems.
**Response Example (Healthy)**
```json
{
"status": "healthy",
"timestamp": "2025-11-03T14:30:00.123456Z",
"version": "3.0.0",
"git_revision": "6416978",
"database": "connected",
"database_size": "853.03 MB",
"database_size_bytes": 894468096
}
```
**Response Example (Unhealthy)**
Status Code: `503 Service Unavailable`
```json
{
"status": "unhealthy",
"timestamp": "2025-11-03T14:30:00.123456Z",
"version": "2.0.8",
"git_revision": "6416978",
"database": "disconnected"
}
```
---
## 10. Version API
### GET `/version`
Returns detailed version information including semver, release date, and git revision.
**Response Example**
```json
{
"version": "2.0.8",
"release_date": "2025-10-22",
"git_revision": "6416978a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q",
"git_revision_short": "6416978"
}
```
---
## Notes
- All timestamps (`import_time`, `last_seen`) are returned in ISO 8601 format.
- `portnum` is an integer representing the packet type.
- `payload` is always a UTF-8 decoded string.
- Node IDs are integers (e.g., `12345678`).

View File

@@ -0,0 +1,146 @@
# Database Changes With Alembic
This guide explains how to make database schema changes in MeshView using Alembic migrations.
## Overview
When you need to add, modify, or remove columns from database tables, you must:
1. Update the SQLAlchemy model
2. Create an Alembic migration
3. Let the system automatically apply the migration
## Step-by-Step Process
### 1. Update the Model
Edit `meshview/models.py` to add/modify the column in the appropriate model class:
```python
class Traceroute(Base):
__tablename__ = "traceroute"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# ... existing columns ...
route_return: Mapped[bytes] = mapped_column(nullable=True) # New column
```
### 2. Create an Alembic Migration
Generate a new migration file with a descriptive message:
```bash
./env/bin/alembic revision -m "add route_return to traceroute"
```
This creates a new file in `alembic/versions/` with a unique revision ID.
### 3. Fill in the Migration
Edit the generated migration file to implement the actual database changes:
```python
def upgrade() -> None:
# Add route_return column to traceroute table
with op.batch_alter_table('traceroute', schema=None) as batch_op:
batch_op.add_column(sa.Column('route_return', sa.LargeBinary(), nullable=True))
def downgrade() -> None:
# Remove route_return column from traceroute table
with op.batch_alter_table('traceroute', schema=None) as batch_op:
batch_op.drop_column('route_return')
```
### 4. Migration Runs Automatically
When you restart the application with `mvrun.py`:
1. The writer process (`startdb.py`) starts up
2. It checks if the database schema is up to date
3. If new migrations are pending, it runs them automatically
4. The reader process (web server) waits for migrations to complete before starting
**No manual migration command is needed** - the application handles this automatically on startup.
### 5. Commit Both Files
Add both files to git:
```bash
git add meshview/models.py
git add alembic/versions/ac311b3782a1_add_route_return_to_traceroute.py
git commit -m "Add route_return column to traceroute table"
```
## Important Notes
### SQLite Compatibility
Always use `batch_alter_table` for SQLite compatibility:
```python
with op.batch_alter_table('table_name', schema=None) as batch_op:
batch_op.add_column(...)
```
SQLite has limited ALTER TABLE support, and `batch_alter_table` works around these limitations.
### Migration Process
- **Writer process** (`startdb.py`): Runs migrations on startup
- **Reader process** (web server in `main.py`): Waits for migrations to complete
- Migrations are checked and applied every time the application starts
- The system uses a migration status table to coordinate between processes
### Common Column Types
```python
# Integer
column: Mapped[int] = mapped_column(BigInteger, nullable=True)
# String
column: Mapped[str] = mapped_column(nullable=True)
# Bytes/Binary
column: Mapped[bytes] = mapped_column(nullable=True)
# DateTime
column: Mapped[datetime] = mapped_column(nullable=True)
# Boolean
column: Mapped[bool] = mapped_column(nullable=True)
# Float
column: Mapped[float] = mapped_column(nullable=True)
```
### Migration File Location
Migrations are stored in: `alembic/versions/`
Each migration file includes:
- Revision ID (unique identifier)
- Down revision (previous migration in chain)
- Create date
- `upgrade()` function (applies changes)
- `downgrade()` function (reverts changes)
## Troubleshooting
### Migration Not Running
If migrations don't run automatically:
1. Check that the database is writable
2. Look for errors in the startup logs
3. Verify the migration chain is correct (each migration references the previous one)
### Manual Migration (Not Recommended)
If you need to manually run migrations for debugging:
```bash
./env/bin/alembic upgrade head
```
However, the application normally handles this automatically.

14
docs/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Technical Documentation
This directory contains technical documentation for MeshView that goes beyond initial setup and basic usage.
These documents are intended for developers, contributors, and advanced users who need deeper insight into the system's architecture, database migrations, API endpoints, and internal workings.
## Contents
- [ALEMBIC_SETUP.md](ALEMBIC_SETUP.md) - Database migration setup and management
- [TIMESTAMP_MIGRATION.md](TIMESTAMP_MIGRATION.md) - Details on timestamp schema changes
- [API_Documentation.md](API_Documentation.md) - REST API endpoints and usage
- [CODE_IMPROVEMENTS.md](CODE_IMPROVEMENTS.md) - Suggested code improvements and refactoring ideas
For initial setup and basic usage instructions, please see the main [README.md](../README.md) in the root directory.

193
docs/TIMESTAMP_MIGRATION.md Normal file
View File

@@ -0,0 +1,193 @@
# High-Resolution Timestamp Migration
This document describes the implementation of GitHub issue #55: storing high-resolution timestamps as integers in the database for improved performance and query efficiency.
## Overview
The meshview database now stores timestamps in two formats:
1. **TEXT format** (`import_time`): Human-readable ISO8601 format with microseconds (e.g., `2025-03-12 04:15:56.058038`)
2. **INTEGER format** (`import_time_us`): Microseconds since Unix epoch (1970-01-01 00:00:00 UTC)
The dual format approach provides:
- **Backward compatibility**: Existing `import_time` TEXT columns remain unchanged
- **Performance**: Fast integer comparisons and math operations
- **Precision**: Microsecond resolution for accurate timing
- **Efficiency**: Compact storage and fast indexed lookups
## Database Changes
### New Columns Added
Three tables have new `import_time_us` columns:
1. **packet.import_time_us** (INTEGER)
- Stores when the packet was imported into the database
- Indexed for fast queries
2. **packet_seen.import_time_us** (INTEGER)
- Stores when the packet_seen record was imported
- Indexed for performance
3. **traceroute.import_time_us** (INTEGER)
- Stores when the traceroute was imported
- Indexed for fast lookups
### New Indexes
The following indexes were created for optimal query performance:
```sql
CREATE INDEX idx_packet_import_time_us ON packet(import_time_us DESC);
CREATE INDEX idx_packet_from_node_time_us ON packet(from_node_id, import_time_us DESC);
CREATE INDEX idx_packet_seen_import_time_us ON packet_seen(import_time_us);
CREATE INDEX idx_traceroute_import_time_us ON traceroute(import_time_us);
```
## Migration Process
### For Existing Databases
Run the migration script to add the new columns and populate them from existing data:
```bash
python migrate_add_timestamp_us.py [database_path]
```
If no path is provided, it defaults to `packets.db` in the current directory.
The migration script:
1. Checks if migration is needed (idempotent)
2. Adds `import_time_us` columns to the three tables
3. Populates the new columns from existing `import_time` values
4. Creates indexes for optimal performance
5. Verifies the migration completed successfully
### For New Databases
New databases created with the updated schema will automatically include the `import_time_us` columns. The MQTT store module populates both columns when inserting new records.
## Code Changes
### Models (meshview/models.py)
The ORM models now include the new `import_time_us` fields:
```python
class Packet(Base):
import_time: Mapped[datetime] = mapped_column(nullable=True)
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
```
### MQTT Store (meshview/mqtt_store.py)
The data ingestion logic now populates both timestamp columns using UTC time:
```python
now = datetime.datetime.now(datetime.timezone.utc)
now_us = int(now.timestamp() * 1_000_000)
# Both columns are populated
import_time=now,
import_time_us=now_us,
```
**Important**: All new timestamps use UTC (Coordinated Universal Time) for consistency across time zones.
## Using the New Timestamps
### Example Queries
**Query packets from the last 7 days:**
```sql
-- Old way (slower)
SELECT * FROM packet
WHERE import_time >= datetime('now', '-7 days');
-- New way (faster)
SELECT * FROM packet
WHERE import_time_us >= (strftime('%s', 'now', '-7 days') * 1000000);
```
**Query packets in a specific time range:**
```sql
SELECT * FROM packet
WHERE import_time_us BETWEEN 1759254380000000 AND 1759254390000000;
```
**Calculate time differences (in microseconds):**
```sql
SELECT
id,
(import_time_us - LAG(import_time_us) OVER (ORDER BY import_time_us)) / 1000000.0 as seconds_since_last
FROM packet
LIMIT 10;
```
### Converting Timestamps
**From datetime to microseconds (UTC):**
```python
import datetime
now = datetime.datetime.now(datetime.timezone.utc)
now_us = int(now.timestamp() * 1_000_000)
```
**From microseconds to datetime:**
```python
import datetime
timestamp_us = 1759254380813451
dt = datetime.datetime.fromtimestamp(timestamp_us / 1_000_000)
```
**In SQL queries:**
```sql
-- Datetime to microseconds
SELECT CAST((strftime('%s', import_time) || substr(import_time, 21, 6)) AS INTEGER);
-- Microseconds to datetime (approximate)
SELECT datetime(import_time_us / 1000000, 'unixepoch');
```
## Performance Benefits
The integer timestamp columns provide significant performance improvements:
1. **Faster comparisons**: Integer comparisons are much faster than string/datetime comparisons
2. **Smaller index size**: Integer indexes are more compact than datetime indexes
3. **Range queries**: BETWEEN operations on integers are highly optimized
4. **Math operations**: Easy to calculate time differences, averages, etc.
5. **Sorting**: Integer sorting is faster than datetime sorting
## Backward Compatibility
The original `import_time` TEXT columns remain unchanged:
- Existing code continues to work
- Human-readable timestamps still available
- Gradual migration to new columns possible
- No breaking changes for existing queries
## Future Work
Future improvements could include:
- Migrating queries to use `import_time_us` columns
- Deprecating the TEXT `import_time` columns (after transition period)
- Adding helper functions for timestamp conversion
- Creating views that expose both formats
## Testing
The migration was tested on a production database with:
- 132,466 packet records
- 1,385,659 packet_seen records
- 28,414 traceroute records
All records were successfully migrated with microsecond precision preserved.
## References
- GitHub Issue: #55 - Storing High-Resolution Timestamps in SQLite
- SQLite datetime functions: https://www.sqlite.org/lang_datefunc.html
- Python datetime module: https://docs.python.org/3/library/datetime.html

File diff suppressed because one or more lines are too long

View File

@@ -770,6 +770,7 @@ class SharedContact(google.protobuf.message.Message):
NODE_NUM_FIELD_NUMBER: builtins.int
USER_FIELD_NUMBER: builtins.int
SHOULD_IGNORE_FIELD_NUMBER: builtins.int
MANUALLY_VERIFIED_FIELD_NUMBER: builtins.int
node_num: builtins.int
"""
The node number of the contact
@@ -778,6 +779,10 @@ class SharedContact(google.protobuf.message.Message):
"""
Add this contact to the blocked / ignored list
"""
manually_verified: builtins.bool
"""
Set the IS_KEY_MANUALLY_VERIFIED bit
"""
@property
def user(self) -> meshtastic.protobuf.mesh_pb2.User:
"""
@@ -790,9 +795,10 @@ class SharedContact(google.protobuf.message.Message):
node_num: builtins.int = ...,
user: meshtastic.protobuf.mesh_pb2.User | None = ...,
should_ignore: builtins.bool = ...,
manually_verified: builtins.bool = ...,
) -> None: ...
def HasField(self, field_name: typing.Literal["user", b"user"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["node_num", b"node_num", "should_ignore", b"should_ignore", "user", b"user"]) -> None: ...
def ClearField(self, field_name: typing.Literal["manually_verified", b"manually_verified", "node_num", b"node_num", "should_ignore", b"should_ignore", "user", b"user"]) -> None: ...
global___SharedContact = SharedContact

View File

@@ -15,14 +15,14 @@ from meshtastic.protobuf import channel_pb2 as meshtastic_dot_protobuf_dot_chann
from meshtastic.protobuf import config_pb2 as meshtastic_dot_protobuf_dot_config__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/apponly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\"\x81\x01\n\nChannelSet\x12\x36\n\x08settings\x18\x01 \x03(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12;\n\x0blora_config\x18\x02 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfigBb\n\x13\x63om.geeksville.meshB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/apponly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\"\x81\x01\n\nChannelSet\x12\x36\n\x08settings\x18\x01 \x03(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12;\n\x0blora_config\x18\x02 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfigBc\n\x14org.meshtastic.protoB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.apponly_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_CHANNELSET']._serialized_start=128
_globals['_CHANNELSET']._serialized_end=257
# @@protoc_insertion_point(module_scope)

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/atak.proto\x12\x13meshtastic.protobuf\"\xa5\x02\n\tTAKPacket\x12\x15\n\ris_compressed\x18\x01 \x01(\x08\x12-\n\x07\x63ontact\x18\x02 \x01(\x0b\x32\x1c.meshtastic.protobuf.Contact\x12)\n\x05group\x18\x03 \x01(\x0b\x32\x1a.meshtastic.protobuf.Group\x12+\n\x06status\x18\x04 \x01(\x0b\x32\x1b.meshtastic.protobuf.Status\x12\'\n\x03pli\x18\x05 \x01(\x0b\x32\x18.meshtastic.protobuf.PLIH\x00\x12,\n\x04\x63hat\x18\x06 \x01(\x0b\x32\x1c.meshtastic.protobuf.GeoChatH\x00\x12\x10\n\x06\x64\x65tail\x18\x07 \x01(\x0cH\x00\x42\x11\n\x0fpayload_variant\"\\\n\x07GeoChat\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0f\n\x02to\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0bto_callsign\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x05\n\x03_toB\x0e\n\x0c_to_callsign\"_\n\x05Group\x12-\n\x04role\x18\x01 \x01(\x0e\x32\x1f.meshtastic.protobuf.MemberRole\x12\'\n\x04team\x18\x02 \x01(\x0e\x32\x19.meshtastic.protobuf.Team\"\x19\n\x06Status\x12\x0f\n\x07\x62\x61ttery\x18\x01 \x01(\r\"4\n\x07\x43ontact\x12\x10\n\x08\x63\x61llsign\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65vice_callsign\x18\x02 \x01(\t\"_\n\x03PLI\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\r\n\x05speed\x18\x04 \x01(\r\x12\x0e\n\x06\x63ourse\x18\x05 \x01(\r*\xc0\x01\n\x04Team\x12\x14\n\x10Unspecifed_Color\x10\x00\x12\t\n\x05White\x10\x01\x12\n\n\x06Yellow\x10\x02\x12\n\n\x06Orange\x10\x03\x12\x0b\n\x07Magenta\x10\x04\x12\x07\n\x03Red\x10\x05\x12\n\n\x06Maroon\x10\x06\x12\n\n\x06Purple\x10\x07\x12\r\n\tDark_Blue\x10\x08\x12\x08\n\x04\x42lue\x10\t\x12\x08\n\x04\x43yan\x10\n\x12\x08\n\x04Teal\x10\x0b\x12\t\n\x05Green\x10\x0c\x12\x0e\n\nDark_Green\x10\r\x12\t\n\x05\x42rown\x10\x0e*\x7f\n\nMemberRole\x12\x0e\n\nUnspecifed\x10\x00\x12\x0e\n\nTeamMember\x10\x01\x12\x0c\n\x08TeamLead\x10\x02\x12\x06\n\x02HQ\x10\x03\x12\n\n\x06Sniper\x10\x04\x12\t\n\x05Medic\x10\x05\x12\x13\n\x0f\x46orwardObserver\x10\x06\x12\x07\n\x03RTO\x10\x07\x12\x06\n\x02K9\x10\x08\x42_\n\x13\x63om.geeksville.meshB\nATAKProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/atak.proto\x12\x13meshtastic.protobuf\"\xa5\x02\n\tTAKPacket\x12\x15\n\ris_compressed\x18\x01 \x01(\x08\x12-\n\x07\x63ontact\x18\x02 \x01(\x0b\x32\x1c.meshtastic.protobuf.Contact\x12)\n\x05group\x18\x03 \x01(\x0b\x32\x1a.meshtastic.protobuf.Group\x12+\n\x06status\x18\x04 \x01(\x0b\x32\x1b.meshtastic.protobuf.Status\x12\'\n\x03pli\x18\x05 \x01(\x0b\x32\x18.meshtastic.protobuf.PLIH\x00\x12,\n\x04\x63hat\x18\x06 \x01(\x0b\x32\x1c.meshtastic.protobuf.GeoChatH\x00\x12\x10\n\x06\x64\x65tail\x18\x07 \x01(\x0cH\x00\x42\x11\n\x0fpayload_variant\"\\\n\x07GeoChat\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0f\n\x02to\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0bto_callsign\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x05\n\x03_toB\x0e\n\x0c_to_callsign\"_\n\x05Group\x12-\n\x04role\x18\x01 \x01(\x0e\x32\x1f.meshtastic.protobuf.MemberRole\x12\'\n\x04team\x18\x02 \x01(\x0e\x32\x19.meshtastic.protobuf.Team\"\x19\n\x06Status\x12\x0f\n\x07\x62\x61ttery\x18\x01 \x01(\r\"4\n\x07\x43ontact\x12\x10\n\x08\x63\x61llsign\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65vice_callsign\x18\x02 \x01(\t\"_\n\x03PLI\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\r\n\x05speed\x18\x04 \x01(\r\x12\x0e\n\x06\x63ourse\x18\x05 \x01(\r*\xc0\x01\n\x04Team\x12\x14\n\x10Unspecifed_Color\x10\x00\x12\t\n\x05White\x10\x01\x12\n\n\x06Yellow\x10\x02\x12\n\n\x06Orange\x10\x03\x12\x0b\n\x07Magenta\x10\x04\x12\x07\n\x03Red\x10\x05\x12\n\n\x06Maroon\x10\x06\x12\n\n\x06Purple\x10\x07\x12\r\n\tDark_Blue\x10\x08\x12\x08\n\x04\x42lue\x10\t\x12\x08\n\x04\x43yan\x10\n\x12\x08\n\x04Teal\x10\x0b\x12\t\n\x05Green\x10\x0c\x12\x0e\n\nDark_Green\x10\r\x12\t\n\x05\x42rown\x10\x0e*\x7f\n\nMemberRole\x12\x0e\n\nUnspecifed\x10\x00\x12\x0e\n\nTeamMember\x10\x01\x12\x0c\n\x08TeamLead\x10\x02\x12\x06\n\x02HQ\x10\x03\x12\n\n\x06Sniper\x10\x04\x12\t\n\x05Medic\x10\x05\x12\x13\n\x0f\x46orwardObserver\x10\x06\x12\x07\n\x03RTO\x10\x07\x12\x06\n\x02K9\x10\x08\x42`\n\x14org.meshtastic.protoB\nATAKProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.atak_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\nATAKProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\nATAKProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_TEAM']._serialized_start=721
_globals['_TEAM']._serialized_end=913
_globals['_MEMBERROLE']._serialized_start=915

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(meshtastic/protobuf/cannedmessages.proto\x12\x13meshtastic.protobuf\"-\n\x19\x43\x61nnedMessageModuleConfig\x12\x10\n\x08messages\x18\x01 \x01(\tBn\n\x13\x63om.geeksville.meshB\x19\x43\x61nnedMessageConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(meshtastic/protobuf/cannedmessages.proto\x12\x13meshtastic.protobuf\"-\n\x19\x43\x61nnedMessageModuleConfig\x12\x10\n\x08messages\x18\x01 \x01(\tBo\n\x14org.meshtastic.protoB\x19\x43\x61nnedMessageConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.cannedmessages_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\031CannedMessageConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\031CannedMessageConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_CANNEDMESSAGEMODULECONFIG']._serialized_start=65
_globals['_CANNEDMESSAGEMODULECONFIG']._serialized_end=110
# @@protoc_insertion_point(module_scope)

View File

@@ -13,22 +13,22 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/channel.proto\x12\x13meshtastic.protobuf\"\xc1\x01\n\x0f\x43hannelSettings\x12\x17\n\x0b\x63hannel_num\x18\x01 \x01(\rB\x02\x18\x01\x12\x0b\n\x03psk\x18\x02 \x01(\x0c\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\n\n\x02id\x18\x04 \x01(\x07\x12\x16\n\x0euplink_enabled\x18\x05 \x01(\x08\x12\x18\n\x10\x64ownlink_enabled\x18\x06 \x01(\x08\x12<\n\x0fmodule_settings\x18\x07 \x01(\x0b\x32#.meshtastic.protobuf.ModuleSettings\"E\n\x0eModuleSettings\x12\x1a\n\x12position_precision\x18\x01 \x01(\r\x12\x17\n\x0fis_client_muted\x18\x02 \x01(\x08\"\xb3\x01\n\x07\x43hannel\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x36\n\x08settings\x18\x02 \x01(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12/\n\x04role\x18\x03 \x01(\x0e\x32!.meshtastic.protobuf.Channel.Role\"0\n\x04Role\x12\x0c\n\x08\x44ISABLED\x10\x00\x12\x0b\n\x07PRIMARY\x10\x01\x12\r\n\tSECONDARY\x10\x02\x42\x62\n\x13\x63om.geeksville.meshB\rChannelProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/channel.proto\x12\x13meshtastic.protobuf\"\xc1\x01\n\x0f\x43hannelSettings\x12\x17\n\x0b\x63hannel_num\x18\x01 \x01(\rB\x02\x18\x01\x12\x0b\n\x03psk\x18\x02 \x01(\x0c\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\n\n\x02id\x18\x04 \x01(\x07\x12\x16\n\x0euplink_enabled\x18\x05 \x01(\x08\x12\x18\n\x10\x64ownlink_enabled\x18\x06 \x01(\x08\x12<\n\x0fmodule_settings\x18\x07 \x01(\x0b\x32#.meshtastic.protobuf.ModuleSettings\">\n\x0eModuleSettings\x12\x1a\n\x12position_precision\x18\x01 \x01(\r\x12\x10\n\x08is_muted\x18\x02 \x01(\x08\"\xb3\x01\n\x07\x43hannel\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x36\n\x08settings\x18\x02 \x01(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12/\n\x04role\x18\x03 \x01(\x0e\x32!.meshtastic.protobuf.Channel.Role\"0\n\x04Role\x12\x0c\n\x08\x44ISABLED\x10\x00\x12\x0b\n\x07PRIMARY\x10\x01\x12\r\n\tSECONDARY\x10\x02\x42\x63\n\x14org.meshtastic.protoB\rChannelProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.channel_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\rChannelProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\rChannelProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_CHANNELSETTINGS.fields_by_name['channel_num']._options = None
_CHANNELSETTINGS.fields_by_name['channel_num']._serialized_options = b'\030\001'
_globals['_CHANNELSETTINGS']._serialized_start=59
_globals['_CHANNELSETTINGS']._serialized_end=252
_globals['_MODULESETTINGS']._serialized_start=254
_globals['_MODULESETTINGS']._serialized_end=323
_globals['_CHANNEL']._serialized_start=326
_globals['_CHANNEL']._serialized_end=505
_globals['_CHANNEL_ROLE']._serialized_start=457
_globals['_CHANNEL_ROLE']._serialized_end=505
_globals['_MODULESETTINGS']._serialized_end=316
_globals['_CHANNEL']._serialized_start=319
_globals['_CHANNEL']._serialized_end=498
_globals['_CHANNEL_ROLE']._serialized_start=450
_globals['_CHANNEL_ROLE']._serialized_end=498
# @@protoc_insertion_point(module_scope)

View File

@@ -127,23 +127,23 @@ class ModuleSettings(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor
POSITION_PRECISION_FIELD_NUMBER: builtins.int
IS_CLIENT_MUTED_FIELD_NUMBER: builtins.int
IS_MUTED_FIELD_NUMBER: builtins.int
position_precision: builtins.int
"""
Bits of precision for the location sent in position packets.
"""
is_client_muted: builtins.bool
is_muted: builtins.bool
"""
Controls whether or not the phone / clients should mute the current channel
Controls whether or not the client / device should mute the current channel
Useful for noisy public channels you don't necessarily want to disable
"""
def __init__(
self,
*,
position_precision: builtins.int = ...,
is_client_muted: builtins.bool = ...,
is_muted: builtins.bool = ...,
) -> None: ...
def ClearField(self, field_name: typing.Literal["is_client_muted", b"is_client_muted", "position_precision", b"position_precision"]) -> None: ...
def ClearField(self, field_name: typing.Literal["is_muted", b"is_muted", "position_precision", b"position_precision"]) -> None: ...
global___ModuleSettings = ModuleSettings

View File

@@ -15,14 +15,14 @@ from meshtastic.protobuf import localonly_pb2 as meshtastic_dot_protobuf_dot_loc
from meshtastic.protobuf import mesh_pb2 as meshtastic_dot_protobuf_dot_mesh__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/clientonly.proto\x12\x13meshtastic.protobuf\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"\xc4\x03\n\rDeviceProfile\x12\x16\n\tlong_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nshort_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0b\x63hannel_url\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x35\n\x06\x63onfig\x18\x04 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfigH\x03\x88\x01\x01\x12\x42\n\rmodule_config\x18\x05 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfigH\x04\x88\x01\x01\x12:\n\x0e\x66ixed_position\x18\x06 \x01(\x0b\x32\x1d.meshtastic.protobuf.PositionH\x05\x88\x01\x01\x12\x15\n\x08ringtone\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x1c\n\x0f\x63\x61nned_messages\x18\x08 \x01(\tH\x07\x88\x01\x01\x42\x0c\n\n_long_nameB\r\n\x0b_short_nameB\x0e\n\x0c_channel_urlB\t\n\x07_configB\x10\n\x0e_module_configB\x11\n\x0f_fixed_positionB\x0b\n\t_ringtoneB\x12\n\x10_canned_messagesBe\n\x13\x63om.geeksville.meshB\x10\x43lientOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/clientonly.proto\x12\x13meshtastic.protobuf\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"\xc4\x03\n\rDeviceProfile\x12\x16\n\tlong_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nshort_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0b\x63hannel_url\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x35\n\x06\x63onfig\x18\x04 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfigH\x03\x88\x01\x01\x12\x42\n\rmodule_config\x18\x05 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfigH\x04\x88\x01\x01\x12:\n\x0e\x66ixed_position\x18\x06 \x01(\x0b\x32\x1d.meshtastic.protobuf.PositionH\x05\x88\x01\x01\x12\x15\n\x08ringtone\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x1c\n\x0f\x63\x61nned_messages\x18\x08 \x01(\tH\x07\x88\x01\x01\x42\x0c\n\n_long_nameB\r\n\x0b_short_nameB\x0e\n\x0c_channel_urlB\t\n\x07_configB\x10\n\x0e_module_configB\x11\n\x0f_fixed_positionB\x0b\n\t_ringtoneB\x12\n\x10_canned_messagesBf\n\x14org.meshtastic.protoB\x10\x43lientOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.clientonly_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\020ClientOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\020ClientOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_DEVICEPROFILE']._serialized_start=131
_globals['_DEVICEPROFILE']._serialized_end=583
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

View File

@@ -64,6 +64,7 @@ class Config(google.protobuf.message.Message):
Description: Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in Nodes list.
Technical Details: Mesh packets will simply be rebroadcasted over this node. Nodes configured with this role will not originate NodeInfo, Position, Telemetry
or any other packet type. They will simply rebroadcast any mesh packets on the same frequency, channel num, spread factor, and coding rate.
Deprecated in v2.7.11 because it creates "holes" in the mesh rebroadcast chain.
"""
TRACKER: Config.DeviceConfig._Role.ValueType # 5
"""
@@ -155,6 +156,7 @@ class Config(google.protobuf.message.Message):
Description: Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in Nodes list.
Technical Details: Mesh packets will simply be rebroadcasted over this node. Nodes configured with this role will not originate NodeInfo, Position, Telemetry
or any other packet type. They will simply rebroadcast any mesh packets on the same frequency, channel num, spread factor, and coding rate.
Deprecated in v2.7.11 because it creates "holes" in the mesh rebroadcast chain.
"""
TRACKER: Config.DeviceConfig.Role.ValueType # 5
"""
@@ -938,80 +940,20 @@ class Config(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor
class _GpsCoordinateFormat:
class _DeprecatedGpsCoordinateFormat:
ValueType = typing.NewType("ValueType", builtins.int)
V: typing_extensions.TypeAlias = ValueType
class _GpsCoordinateFormatEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Config.DisplayConfig._GpsCoordinateFormat.ValueType], builtins.type):
class _DeprecatedGpsCoordinateFormatEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Config.DisplayConfig._DeprecatedGpsCoordinateFormat.ValueType], builtins.type):
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
DEC: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 0
UNUSED: Config.DisplayConfig._DeprecatedGpsCoordinateFormat.ValueType # 0
class DeprecatedGpsCoordinateFormat(_DeprecatedGpsCoordinateFormat, metaclass=_DeprecatedGpsCoordinateFormatEnumTypeWrapper):
"""
GPS coordinates are displayed in the normal decimal degrees format:
DD.DDDDDD DDD.DDDDDD
"""
DMS: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 1
"""
GPS coordinates are displayed in the degrees minutes seconds format:
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
"""
UTM: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 2
"""
Universal Transverse Mercator format:
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
"""
MGRS: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 3
"""
Military Grid Reference System format:
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
E is easting, N is northing
"""
OLC: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 4
"""
Open Location Code (aka Plus Codes).
"""
OSGR: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 5
"""
Ordnance Survey Grid Reference (the National Grid System of the UK).
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
E is the easting, N is the northing
Deprecated in 2.7.4: Unused
"""
class GpsCoordinateFormat(_GpsCoordinateFormat, metaclass=_GpsCoordinateFormatEnumTypeWrapper):
"""
How the GPS coordinates are displayed on the OLED screen.
"""
DEC: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 0
"""
GPS coordinates are displayed in the normal decimal degrees format:
DD.DDDDDD DDD.DDDDDD
"""
DMS: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 1
"""
GPS coordinates are displayed in the degrees minutes seconds format:
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
"""
UTM: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 2
"""
Universal Transverse Mercator format:
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
"""
MGRS: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 3
"""
Military Grid Reference System format:
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
E is easting, N is northing
"""
OLC: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 4
"""
Open Location Code (aka Plus Codes).
"""
OSGR: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 5
"""
Ordnance Survey Grid Reference (the National Grid System of the UK).
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
E is the easting, N is the northing
"""
UNUSED: Config.DisplayConfig.DeprecatedGpsCoordinateFormat.ValueType # 0
class _DisplayUnits:
ValueType = typing.NewType("ValueType", builtins.int)
@@ -1221,12 +1163,13 @@ class Config(google.protobuf.message.Message):
WAKE_ON_TAP_OR_MOTION_FIELD_NUMBER: builtins.int
COMPASS_ORIENTATION_FIELD_NUMBER: builtins.int
USE_12H_CLOCK_FIELD_NUMBER: builtins.int
USE_LONG_NODE_NAME_FIELD_NUMBER: builtins.int
screen_on_secs: builtins.int
"""
Number of seconds the screen stays on after pressing the user button or receiving a message
0 for default of one minute MAXUINT for always on
"""
gps_format: global___Config.DisplayConfig.GpsCoordinateFormat.ValueType
gps_format: global___Config.DisplayConfig.DeprecatedGpsCoordinateFormat.ValueType
"""
Deprecated in 2.7.4: Unused
How the GPS coordinates are formatted on the OLED screen.
@@ -1274,11 +1217,16 @@ class Config(google.protobuf.message.Message):
If false (default), the device will display the time in 24-hour format on screen.
If true, the device will display the time in 12-hour format on screen.
"""
use_long_node_name: builtins.bool
"""
If false (default), the device will use short names for various display screens.
If true, node names will show in long format
"""
def __init__(
self,
*,
screen_on_secs: builtins.int = ...,
gps_format: global___Config.DisplayConfig.GpsCoordinateFormat.ValueType = ...,
gps_format: global___Config.DisplayConfig.DeprecatedGpsCoordinateFormat.ValueType = ...,
auto_screen_carousel_secs: builtins.int = ...,
compass_north_top: builtins.bool = ...,
flip_screen: builtins.bool = ...,
@@ -1289,8 +1237,9 @@ class Config(google.protobuf.message.Message):
wake_on_tap_or_motion: builtins.bool = ...,
compass_orientation: global___Config.DisplayConfig.CompassOrientation.ValueType = ...,
use_12h_clock: builtins.bool = ...,
use_long_node_name: builtins.bool = ...,
) -> None: ...
def ClearField(self, field_name: typing.Literal["auto_screen_carousel_secs", b"auto_screen_carousel_secs", "compass_north_top", b"compass_north_top", "compass_orientation", b"compass_orientation", "displaymode", b"displaymode", "flip_screen", b"flip_screen", "gps_format", b"gps_format", "heading_bold", b"heading_bold", "oled", b"oled", "screen_on_secs", b"screen_on_secs", "units", b"units", "use_12h_clock", b"use_12h_clock", "wake_on_tap_or_motion", b"wake_on_tap_or_motion"]) -> None: ...
def ClearField(self, field_name: typing.Literal["auto_screen_carousel_secs", b"auto_screen_carousel_secs", "compass_north_top", b"compass_north_top", "compass_orientation", b"compass_orientation", "displaymode", b"displaymode", "flip_screen", b"flip_screen", "gps_format", b"gps_format", "heading_bold", b"heading_bold", "oled", b"oled", "screen_on_secs", b"screen_on_secs", "units", b"units", "use_12h_clock", b"use_12h_clock", "use_long_node_name", b"use_long_node_name", "wake_on_tap_or_motion", b"wake_on_tap_or_motion"]) -> None: ...
@typing.final
class LoRaConfig(google.protobuf.message.Message):

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n+meshtastic/protobuf/connection_status.proto\x12\x13meshtastic.protobuf\"\xd5\x02\n\x16\x44\x65viceConnectionStatus\x12<\n\x04wifi\x18\x01 \x01(\x0b\x32).meshtastic.protobuf.WifiConnectionStatusH\x00\x88\x01\x01\x12\x44\n\x08\x65thernet\x18\x02 \x01(\x0b\x32-.meshtastic.protobuf.EthernetConnectionStatusH\x01\x88\x01\x01\x12\x46\n\tbluetooth\x18\x03 \x01(\x0b\x32..meshtastic.protobuf.BluetoothConnectionStatusH\x02\x88\x01\x01\x12@\n\x06serial\x18\x04 \x01(\x0b\x32+.meshtastic.protobuf.SerialConnectionStatusH\x03\x88\x01\x01\x42\x07\n\x05_wifiB\x0b\n\t_ethernetB\x0c\n\n_bluetoothB\t\n\x07_serial\"p\n\x14WifiConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\x12\x0c\n\x04ssid\x18\x02 \x01(\t\x12\x0c\n\x04rssi\x18\x03 \x01(\x05\"X\n\x18\x45thernetConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\"{\n\x17NetworkConnectionStatus\x12\x12\n\nip_address\x18\x01 \x01(\x07\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x12\x19\n\x11is_mqtt_connected\x18\x03 \x01(\x08\x12\x1b\n\x13is_syslog_connected\x18\x04 \x01(\x08\"L\n\x19\x42luetoothConnectionStatus\x12\x0b\n\x03pin\x18\x01 \x01(\r\x12\x0c\n\x04rssi\x18\x02 \x01(\x05\x12\x14\n\x0cis_connected\x18\x03 \x01(\x08\"<\n\x16SerialConnectionStatus\x12\x0c\n\x04\x62\x61ud\x18\x01 \x01(\r\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x42\x65\n\x13\x63om.geeksville.meshB\x10\x43onnStatusProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n+meshtastic/protobuf/connection_status.proto\x12\x13meshtastic.protobuf\"\xd5\x02\n\x16\x44\x65viceConnectionStatus\x12<\n\x04wifi\x18\x01 \x01(\x0b\x32).meshtastic.protobuf.WifiConnectionStatusH\x00\x88\x01\x01\x12\x44\n\x08\x65thernet\x18\x02 \x01(\x0b\x32-.meshtastic.protobuf.EthernetConnectionStatusH\x01\x88\x01\x01\x12\x46\n\tbluetooth\x18\x03 \x01(\x0b\x32..meshtastic.protobuf.BluetoothConnectionStatusH\x02\x88\x01\x01\x12@\n\x06serial\x18\x04 \x01(\x0b\x32+.meshtastic.protobuf.SerialConnectionStatusH\x03\x88\x01\x01\x42\x07\n\x05_wifiB\x0b\n\t_ethernetB\x0c\n\n_bluetoothB\t\n\x07_serial\"p\n\x14WifiConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\x12\x0c\n\x04ssid\x18\x02 \x01(\t\x12\x0c\n\x04rssi\x18\x03 \x01(\x05\"X\n\x18\x45thernetConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\"{\n\x17NetworkConnectionStatus\x12\x12\n\nip_address\x18\x01 \x01(\x07\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x12\x19\n\x11is_mqtt_connected\x18\x03 \x01(\x08\x12\x1b\n\x13is_syslog_connected\x18\x04 \x01(\x08\"L\n\x19\x42luetoothConnectionStatus\x12\x0b\n\x03pin\x18\x01 \x01(\r\x12\x0c\n\x04rssi\x18\x02 \x01(\x05\x12\x14\n\x0cis_connected\x18\x03 \x01(\x08\"<\n\x16SerialConnectionStatus\x12\x0c\n\x04\x62\x61ud\x18\x01 \x01(\r\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x42\x66\n\x14org.meshtastic.protoB\x10\x43onnStatusProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.connection_status_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\020ConnStatusProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\020ConnStatusProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_DEVICECONNECTIONSTATUS']._serialized_start=69
_globals['_DEVICECONNECTIONSTATUS']._serialized_end=410
_globals['_WIFICONNECTIONSTATUS']._serialized_start=412

View File

@@ -13,28 +13,30 @@ _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*\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')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/device_ui.proto\x12\x13meshtastic.protobuf\"\xff\x05\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\x12K\n\ngps_format\x18\x13 \x01(\x0e\x32\x37.meshtastic.protobuf.DeviceUIConfig.GpsCoordinateFormat\"V\n\x13GpsCoordinateFormat\x12\x07\n\x03\x44\x45\x43\x10\x00\x12\x07\n\x03\x44MS\x10\x01\x12\x07\n\x03UTM\x10\x02\x12\x08\n\x04MGRS\x10\x03\x12\x07\n\x03OLC\x10\x04\x12\x08\n\x04OSGR\x10\x05\x12\x07\n\x03MLS\x10\x06\"\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*\xc0\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\n\n\x06\x44\x41NISH\x10\x13\x12\x16\n\x12SIMPLIFIED_CHINESE\x10\x1e\x12\x17\n\x13TRADITIONAL_CHINESE\x10\x1f\x42\x64\n\x14org.meshtastic.protoB\x0e\x44\x65viceUIProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.device_ui_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016DeviceUIProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_COMPASSMODE']._serialized_start=1113
_globals['_COMPASSMODE']._serialized_end=1175
_globals['_THEME']._serialized_start=1177
_globals['_THEME']._serialized_end=1214
_globals['_LANGUAGE']._serialized_start=1217
_globals['_LANGUAGE']._serialized_end=1525
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016DeviceUIProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_COMPASSMODE']._serialized_start=1278
_globals['_COMPASSMODE']._serialized_end=1340
_globals['_THEME']._serialized_start=1342
_globals['_THEME']._serialized_end=1379
_globals['_LANGUAGE']._serialized_start=1382
_globals['_LANGUAGE']._serialized_end=1702
_globals['_DEVICEUICONFIG']._serialized_start=61
_globals['_DEVICEUICONFIG']._serialized_end=663
_globals['_NODEFILTER']._serialized_start=666
_globals['_NODEFILTER']._serialized_end=833
_globals['_NODEHIGHLIGHT']._serialized_start=835
_globals['_NODEHIGHLIGHT']._serialized_end=961
_globals['_GEOPOINT']._serialized_start=963
_globals['_GEOPOINT']._serialized_end=1024
_globals['_MAP']._serialized_start=1026
_globals['_MAP']._serialized_end=1111
_globals['_DEVICEUICONFIG']._serialized_end=828
_globals['_DEVICEUICONFIG_GPSCOORDINATEFORMAT']._serialized_start=742
_globals['_DEVICEUICONFIG_GPSCOORDINATEFORMAT']._serialized_end=828
_globals['_NODEFILTER']._serialized_start=831
_globals['_NODEFILTER']._serialized_end=998
_globals['_NODEHIGHLIGHT']._serialized_start=1000
_globals['_NODEHIGHLIGHT']._serialized_end=1126
_globals['_GEOPOINT']._serialized_start=1128
_globals['_GEOPOINT']._serialized_end=1189
_globals['_MAP']._serialized_start=1191
_globals['_MAP']._serialized_end=1276
# @@protoc_insertion_point(module_scope)

View File

@@ -169,6 +169,10 @@ class _LanguageEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumT
"""
Czech
"""
DANISH: _Language.ValueType # 19
"""
Danish
"""
SIMPLIFIED_CHINESE: _Language.ValueType # 30
"""
Simplified Chinese (experimental)
@@ -259,6 +263,10 @@ CZECH: Language.ValueType # 18
"""
Czech
"""
DANISH: Language.ValueType # 19
"""
Danish
"""
SIMPLIFIED_CHINESE: Language.ValueType # 30
"""
Simplified Chinese (experimental)
@@ -277,6 +285,91 @@ class DeviceUIConfig(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor
class _GpsCoordinateFormat:
ValueType = typing.NewType("ValueType", builtins.int)
V: typing_extensions.TypeAlias = ValueType
class _GpsCoordinateFormatEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[DeviceUIConfig._GpsCoordinateFormat.ValueType], builtins.type):
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
DEC: DeviceUIConfig._GpsCoordinateFormat.ValueType # 0
"""
GPS coordinates are displayed in the normal decimal degrees format:
DD.DDDDDD DDD.DDDDDD
"""
DMS: DeviceUIConfig._GpsCoordinateFormat.ValueType # 1
"""
GPS coordinates are displayed in the degrees minutes seconds format:
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
"""
UTM: DeviceUIConfig._GpsCoordinateFormat.ValueType # 2
"""
Universal Transverse Mercator format:
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
"""
MGRS: DeviceUIConfig._GpsCoordinateFormat.ValueType # 3
"""
Military Grid Reference System format:
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
E is easting, N is northing
"""
OLC: DeviceUIConfig._GpsCoordinateFormat.ValueType # 4
"""
Open Location Code (aka Plus Codes).
"""
OSGR: DeviceUIConfig._GpsCoordinateFormat.ValueType # 5
"""
Ordnance Survey Grid Reference (the National Grid System of the UK).
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
E is the easting, N is the northing
"""
MLS: DeviceUIConfig._GpsCoordinateFormat.ValueType # 6
"""
Maidenhead Locator System
Described here: https://en.wikipedia.org/wiki/Maidenhead_Locator_System
"""
class GpsCoordinateFormat(_GpsCoordinateFormat, metaclass=_GpsCoordinateFormatEnumTypeWrapper):
"""
How the GPS coordinates are displayed on the OLED screen.
"""
DEC: DeviceUIConfig.GpsCoordinateFormat.ValueType # 0
"""
GPS coordinates are displayed in the normal decimal degrees format:
DD.DDDDDD DDD.DDDDDD
"""
DMS: DeviceUIConfig.GpsCoordinateFormat.ValueType # 1
"""
GPS coordinates are displayed in the degrees minutes seconds format:
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
"""
UTM: DeviceUIConfig.GpsCoordinateFormat.ValueType # 2
"""
Universal Transverse Mercator format:
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
"""
MGRS: DeviceUIConfig.GpsCoordinateFormat.ValueType # 3
"""
Military Grid Reference System format:
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
E is easting, N is northing
"""
OLC: DeviceUIConfig.GpsCoordinateFormat.ValueType # 4
"""
Open Location Code (aka Plus Codes).
"""
OSGR: DeviceUIConfig.GpsCoordinateFormat.ValueType # 5
"""
Ordnance Survey Grid Reference (the National Grid System of the UK).
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
E is the easting, N is the northing
"""
MLS: DeviceUIConfig.GpsCoordinateFormat.ValueType # 6
"""
Maidenhead Locator System
Described here: https://en.wikipedia.org/wiki/Maidenhead_Locator_System
"""
VERSION_FIELD_NUMBER: builtins.int
SCREEN_BRIGHTNESS_FIELD_NUMBER: builtins.int
SCREEN_TIMEOUT_FIELD_NUMBER: builtins.int
@@ -295,6 +388,7 @@ class DeviceUIConfig(google.protobuf.message.Message):
COMPASS_MODE_FIELD_NUMBER: builtins.int
SCREEN_RGB_COLOR_FIELD_NUMBER: builtins.int
IS_CLOCKFACE_ANALOG_FIELD_NUMBER: builtins.int
GPS_FORMAT_FIELD_NUMBER: builtins.int
version: builtins.int
"""
A version integer used to invalidate saved files when we make incompatible changes.
@@ -345,6 +439,10 @@ class DeviceUIConfig(google.protobuf.message.Message):
Clockface analog style
true for analog clockface, false for digital clockface
"""
gps_format: global___DeviceUIConfig.GpsCoordinateFormat.ValueType
"""
How the GPS coordinates are formatted on the OLED screen.
"""
@property
def node_filter(self) -> global___NodeFilter:
"""
@@ -384,9 +482,10 @@ class DeviceUIConfig(google.protobuf.message.Message):
compass_mode: global___CompassMode.ValueType = ...,
screen_rgb_color: builtins.int = ...,
is_clockface_analog: builtins.bool = ...,
gps_format: global___DeviceUIConfig.GpsCoordinateFormat.ValueType = ...,
) -> None: ...
def HasField(self, field_name: typing.Literal["map_data", b"map_data", "node_filter", b"node_filter", "node_highlight", b"node_highlight"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["alert_enabled", b"alert_enabled", "banner_enabled", b"banner_enabled", "calibration_data", b"calibration_data", "compass_mode", b"compass_mode", "is_clockface_analog", b"is_clockface_analog", "language", b"language", "map_data", b"map_data", "node_filter", b"node_filter", "node_highlight", b"node_highlight", "pin_code", b"pin_code", "ring_tone_id", b"ring_tone_id", "screen_brightness", b"screen_brightness", "screen_lock", b"screen_lock", "screen_rgb_color", b"screen_rgb_color", "screen_timeout", b"screen_timeout", "settings_lock", b"settings_lock", "theme", b"theme", "version", b"version"]) -> None: ...
def ClearField(self, field_name: typing.Literal["alert_enabled", b"alert_enabled", "banner_enabled", b"banner_enabled", "calibration_data", b"calibration_data", "compass_mode", b"compass_mode", "gps_format", b"gps_format", "is_clockface_analog", b"is_clockface_analog", "language", b"language", "map_data", b"map_data", "node_filter", b"node_filter", "node_highlight", b"node_highlight", "pin_code", b"pin_code", "ring_tone_id", b"ring_tone_id", "screen_brightness", b"screen_brightness", "screen_lock", b"screen_lock", "screen_rgb_color", b"screen_rgb_color", "screen_timeout", b"screen_timeout", "settings_lock", b"settings_lock", "theme", b"theme", "version", b"version"]) -> None: ...
global___DeviceUIConfig = DeviceUIConfig

View File

@@ -19,14 +19,14 @@ from meshtastic.protobuf import telemetry_pb2 as meshtastic_dot_protobuf_dot_tel
from meshtastic.protobuf import nanopb_pb2 as meshtastic_dot_protobuf_dot_nanopb__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/deviceonly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\x1a#meshtastic/protobuf/telemetry.proto\x1a meshtastic/protobuf/nanopb.proto\"\x99\x01\n\x0cPositionLite\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\x0c\n\x04time\x18\x04 \x01(\x07\x12@\n\x0flocation_source\x18\x05 \x01(\x0e\x32\'.meshtastic.protobuf.Position.LocSource\"\x94\x02\n\x08UserLite\x12\x13\n\x07macaddr\x18\x01 \x01(\x0c\x42\x02\x18\x01\x12\x11\n\tlong_name\x18\x02 \x01(\t\x12\x12\n\nshort_name\x18\x03 \x01(\t\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x13\n\x0bis_licensed\x18\x05 \x01(\x08\x12;\n\x04role\x18\x06 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x12\n\npublic_key\x18\x07 \x01(\x0c\x12\x1c\n\x0fis_unmessagable\x18\t \x01(\x08H\x00\x88\x01\x01\x42\x12\n\x10_is_unmessagable\"\xf0\x02\n\x0cNodeInfoLite\x12\x0b\n\x03num\x18\x01 \x01(\r\x12+\n\x04user\x18\x02 \x01(\x0b\x32\x1d.meshtastic.protobuf.UserLite\x12\x33\n\x08position\x18\x03 \x01(\x0b\x32!.meshtastic.protobuf.PositionLite\x12\x0b\n\x03snr\x18\x04 \x01(\x02\x12\x12\n\nlast_heard\x18\x05 \x01(\x07\x12:\n\x0e\x64\x65vice_metrics\x18\x06 \x01(\x0b\x32\".meshtastic.protobuf.DeviceMetrics\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\r\x12\x10\n\x08via_mqtt\x18\x08 \x01(\x08\x12\x16\n\thops_away\x18\t \x01(\rH\x00\x88\x01\x01\x12\x13\n\x0bis_favorite\x18\n \x01(\x08\x12\x12\n\nis_ignored\x18\x0b \x01(\x08\x12\x10\n\x08next_hop\x18\x0c \x01(\r\x12\x10\n\x08\x62itfield\x18\r \x01(\rB\x0c\n\n_hops_away\"\xa1\x03\n\x0b\x44\x65viceState\x12\x30\n\x07my_node\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.MyNodeInfo\x12(\n\x05owner\x18\x03 \x01(\x0b\x32\x19.meshtastic.protobuf.User\x12\x36\n\rreceive_queue\x18\x05 \x03(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x0f\n\x07version\x18\x08 \x01(\r\x12\x38\n\x0frx_text_message\x18\x07 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x13\n\x07no_save\x18\t \x01(\x08\x42\x02\x18\x01\x12\x19\n\rdid_gps_reset\x18\x0b \x01(\x08\x42\x02\x18\x01\x12\x34\n\x0brx_waypoint\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12M\n\x19node_remote_hardware_pins\x18\r \x03(\x0b\x32*.meshtastic.protobuf.NodeRemoteHardwarePin\"}\n\x0cNodeDatabase\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\\\n\x05nodes\x18\x02 \x03(\x0b\x32!.meshtastic.protobuf.NodeInfoLiteB*\x92?\'\x92\x01$std::vector<meshtastic_NodeInfoLite>\"N\n\x0b\x43hannelFile\x12.\n\x08\x63hannels\x18\x01 \x03(\x0b\x32\x1c.meshtastic.protobuf.Channel\x12\x0f\n\x07version\x18\x02 \x01(\r\"\x86\x02\n\x11\x42\x61\x63kupPreferences\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x11\n\ttimestamp\x18\x02 \x01(\x07\x12\x30\n\x06\x63onfig\x18\x03 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfig\x12=\n\rmodule_config\x18\x04 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfig\x12\x32\n\x08\x63hannels\x18\x05 \x01(\x0b\x32 .meshtastic.protobuf.ChannelFile\x12(\n\x05owner\x18\x06 \x01(\x0b\x32\x19.meshtastic.protobuf.UserBm\n\x13\x63om.geeksville.meshB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x92?\x0b\xc2\x01\x08<vector>b\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/deviceonly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\x1a#meshtastic/protobuf/telemetry.proto\x1a meshtastic/protobuf/nanopb.proto\"\x99\x01\n\x0cPositionLite\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\x0c\n\x04time\x18\x04 \x01(\x07\x12@\n\x0flocation_source\x18\x05 \x01(\x0e\x32\'.meshtastic.protobuf.Position.LocSource\"\x94\x02\n\x08UserLite\x12\x13\n\x07macaddr\x18\x01 \x01(\x0c\x42\x02\x18\x01\x12\x11\n\tlong_name\x18\x02 \x01(\t\x12\x12\n\nshort_name\x18\x03 \x01(\t\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x13\n\x0bis_licensed\x18\x05 \x01(\x08\x12;\n\x04role\x18\x06 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x12\n\npublic_key\x18\x07 \x01(\x0c\x12\x1c\n\x0fis_unmessagable\x18\t \x01(\x08H\x00\x88\x01\x01\x42\x12\n\x10_is_unmessagable\"\xf0\x02\n\x0cNodeInfoLite\x12\x0b\n\x03num\x18\x01 \x01(\r\x12+\n\x04user\x18\x02 \x01(\x0b\x32\x1d.meshtastic.protobuf.UserLite\x12\x33\n\x08position\x18\x03 \x01(\x0b\x32!.meshtastic.protobuf.PositionLite\x12\x0b\n\x03snr\x18\x04 \x01(\x02\x12\x12\n\nlast_heard\x18\x05 \x01(\x07\x12:\n\x0e\x64\x65vice_metrics\x18\x06 \x01(\x0b\x32\".meshtastic.protobuf.DeviceMetrics\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\r\x12\x10\n\x08via_mqtt\x18\x08 \x01(\x08\x12\x16\n\thops_away\x18\t \x01(\rH\x00\x88\x01\x01\x12\x13\n\x0bis_favorite\x18\n \x01(\x08\x12\x12\n\nis_ignored\x18\x0b \x01(\x08\x12\x10\n\x08next_hop\x18\x0c \x01(\r\x12\x10\n\x08\x62itfield\x18\r \x01(\rB\x0c\n\n_hops_away\"\xa1\x03\n\x0b\x44\x65viceState\x12\x30\n\x07my_node\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.MyNodeInfo\x12(\n\x05owner\x18\x03 \x01(\x0b\x32\x19.meshtastic.protobuf.User\x12\x36\n\rreceive_queue\x18\x05 \x03(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x0f\n\x07version\x18\x08 \x01(\r\x12\x38\n\x0frx_text_message\x18\x07 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x13\n\x07no_save\x18\t \x01(\x08\x42\x02\x18\x01\x12\x19\n\rdid_gps_reset\x18\x0b \x01(\x08\x42\x02\x18\x01\x12\x34\n\x0brx_waypoint\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12M\n\x19node_remote_hardware_pins\x18\r \x03(\x0b\x32*.meshtastic.protobuf.NodeRemoteHardwarePin\"}\n\x0cNodeDatabase\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\\\n\x05nodes\x18\x02 \x03(\x0b\x32!.meshtastic.protobuf.NodeInfoLiteB*\x92?\'\x92\x01$std::vector<meshtastic_NodeInfoLite>\"N\n\x0b\x43hannelFile\x12.\n\x08\x63hannels\x18\x01 \x03(\x0b\x32\x1c.meshtastic.protobuf.Channel\x12\x0f\n\x07version\x18\x02 \x01(\r\"\x86\x02\n\x11\x42\x61\x63kupPreferences\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x11\n\ttimestamp\x18\x02 \x01(\x07\x12\x30\n\x06\x63onfig\x18\x03 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfig\x12=\n\rmodule_config\x18\x04 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfig\x12\x32\n\x08\x63hannels\x18\x05 \x01(\x0b\x32 .meshtastic.protobuf.ChannelFile\x12(\n\x05owner\x18\x06 \x01(\x0b\x32\x19.meshtastic.protobuf.UserBn\n\x14org.meshtastic.protoB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x92?\x0b\xc2\x01\x08<vector>b\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.deviceonly_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000\222?\013\302\001\010<vector>'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000\222?\013\302\001\010<vector>'
_USERLITE.fields_by_name['macaddr']._options = None
_USERLITE.fields_by_name['macaddr']._serialized_options = b'\030\001'
_DEVICESTATE.fields_by_name['no_save']._options = None

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%meshtastic/protobuf/interdevice.proto\x12\x13meshtastic.protobuf\"s\n\nSensorData\x12.\n\x04type\x18\x01 \x01(\x0e\x32 .meshtastic.protobuf.MessageType\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x16\n\x0cuint32_value\x18\x03 \x01(\rH\x00\x42\x06\n\x04\x64\x61ta\"_\n\x12InterdeviceMessage\x12\x0e\n\x04nmea\x18\x01 \x01(\tH\x00\x12\x31\n\x06sensor\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.SensorDataH\x00\x42\x06\n\x04\x64\x61ta*\xd5\x01\n\x0bMessageType\x12\x07\n\x03\x41\x43K\x10\x00\x12\x15\n\x10\x43OLLECT_INTERVAL\x10\xa0\x01\x12\x0c\n\x07\x42\x45\x45P_ON\x10\xa1\x01\x12\r\n\x08\x42\x45\x45P_OFF\x10\xa2\x01\x12\r\n\x08SHUTDOWN\x10\xa3\x01\x12\r\n\x08POWER_ON\x10\xa4\x01\x12\x0f\n\nSCD41_TEMP\x10\xb0\x01\x12\x13\n\x0eSCD41_HUMIDITY\x10\xb1\x01\x12\x0e\n\tSCD41_CO2\x10\xb2\x01\x12\x0f\n\nAHT20_TEMP\x10\xb3\x01\x12\x13\n\x0e\x41HT20_HUMIDITY\x10\xb4\x01\x12\x0f\n\nTVOC_INDEX\x10\xb5\x01\x42\x66\n\x13\x63om.geeksville.meshB\x11InterdeviceProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%meshtastic/protobuf/interdevice.proto\x12\x13meshtastic.protobuf\"s\n\nSensorData\x12.\n\x04type\x18\x01 \x01(\x0e\x32 .meshtastic.protobuf.MessageType\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x16\n\x0cuint32_value\x18\x03 \x01(\rH\x00\x42\x06\n\x04\x64\x61ta\"_\n\x12InterdeviceMessage\x12\x0e\n\x04nmea\x18\x01 \x01(\tH\x00\x12\x31\n\x06sensor\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.SensorDataH\x00\x42\x06\n\x04\x64\x61ta*\xd5\x01\n\x0bMessageType\x12\x07\n\x03\x41\x43K\x10\x00\x12\x15\n\x10\x43OLLECT_INTERVAL\x10\xa0\x01\x12\x0c\n\x07\x42\x45\x45P_ON\x10\xa1\x01\x12\r\n\x08\x42\x45\x45P_OFF\x10\xa2\x01\x12\r\n\x08SHUTDOWN\x10\xa3\x01\x12\r\n\x08POWER_ON\x10\xa4\x01\x12\x0f\n\nSCD41_TEMP\x10\xb0\x01\x12\x13\n\x0eSCD41_HUMIDITY\x10\xb1\x01\x12\x0e\n\tSCD41_CO2\x10\xb2\x01\x12\x0f\n\nAHT20_TEMP\x10\xb3\x01\x12\x13\n\x0e\x41HT20_HUMIDITY\x10\xb4\x01\x12\x0f\n\nTVOC_INDEX\x10\xb5\x01\x42g\n\x14org.meshtastic.protoB\x11InterdeviceProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.interdevice_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\021InterdeviceProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\021InterdeviceProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_MESSAGETYPE']._serialized_start=277
_globals['_MESSAGETYPE']._serialized_end=490
_globals['_SENSORDATA']._serialized_start=62

View File

@@ -15,14 +15,14 @@ from meshtastic.protobuf import config_pb2 as meshtastic_dot_protobuf_dot_config
from meshtastic.protobuf import module_config_pb2 as meshtastic_dot_protobuf_dot_module__config__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/localonly.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\'meshtastic/protobuf/module_config.proto\"\xfa\x03\n\x0bLocalConfig\x12\x38\n\x06\x64\x65vice\x18\x01 \x01(\x0b\x32(.meshtastic.protobuf.Config.DeviceConfig\x12<\n\x08position\x18\x02 \x01(\x0b\x32*.meshtastic.protobuf.Config.PositionConfig\x12\x36\n\x05power\x18\x03 \x01(\x0b\x32\'.meshtastic.protobuf.Config.PowerConfig\x12:\n\x07network\x18\x04 \x01(\x0b\x32).meshtastic.protobuf.Config.NetworkConfig\x12:\n\x07\x64isplay\x18\x05 \x01(\x0b\x32).meshtastic.protobuf.Config.DisplayConfig\x12\x34\n\x04lora\x18\x06 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfig\x12>\n\tbluetooth\x18\x07 \x01(\x0b\x32+.meshtastic.protobuf.Config.BluetoothConfig\x12\x0f\n\x07version\x18\x08 \x01(\r\x12<\n\x08security\x18\t \x01(\x0b\x32*.meshtastic.protobuf.Config.SecurityConfig\"\xf0\x07\n\x11LocalModuleConfig\x12:\n\x04mqtt\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.ModuleConfig.MQTTConfig\x12>\n\x06serial\x18\x02 \x01(\x0b\x32..meshtastic.protobuf.ModuleConfig.SerialConfig\x12[\n\x15\x65xternal_notification\x18\x03 \x01(\x0b\x32<.meshtastic.protobuf.ModuleConfig.ExternalNotificationConfig\x12K\n\rstore_forward\x18\x04 \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.StoreForwardConfig\x12\x45\n\nrange_test\x18\x05 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.RangeTestConfig\x12\x44\n\ttelemetry\x18\x06 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.TelemetryConfig\x12M\n\x0e\x63\x61nned_message\x18\x07 \x01(\x0b\x32\x35.meshtastic.protobuf.ModuleConfig.CannedMessageConfig\x12<\n\x05\x61udio\x18\t \x01(\x0b\x32-.meshtastic.protobuf.ModuleConfig.AudioConfig\x12O\n\x0fremote_hardware\x18\n \x01(\x0b\x32\x36.meshtastic.protobuf.ModuleConfig.RemoteHardwareConfig\x12K\n\rneighbor_info\x18\x0b \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.NeighborInfoConfig\x12Q\n\x10\x61mbient_lighting\x18\x0c \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.AmbientLightingConfig\x12Q\n\x10\x64\x65tection_sensor\x18\r \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.DetectionSensorConfig\x12\x46\n\npaxcounter\x18\x0e \x01(\x0b\x32\x32.meshtastic.protobuf.ModuleConfig.PaxcounterConfig\x12\x0f\n\x07version\x18\x08 \x01(\rBd\n\x13\x63om.geeksville.meshB\x0fLocalOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/localonly.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\'meshtastic/protobuf/module_config.proto\"\xfa\x03\n\x0bLocalConfig\x12\x38\n\x06\x64\x65vice\x18\x01 \x01(\x0b\x32(.meshtastic.protobuf.Config.DeviceConfig\x12<\n\x08position\x18\x02 \x01(\x0b\x32*.meshtastic.protobuf.Config.PositionConfig\x12\x36\n\x05power\x18\x03 \x01(\x0b\x32\'.meshtastic.protobuf.Config.PowerConfig\x12:\n\x07network\x18\x04 \x01(\x0b\x32).meshtastic.protobuf.Config.NetworkConfig\x12:\n\x07\x64isplay\x18\x05 \x01(\x0b\x32).meshtastic.protobuf.Config.DisplayConfig\x12\x34\n\x04lora\x18\x06 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfig\x12>\n\tbluetooth\x18\x07 \x01(\x0b\x32+.meshtastic.protobuf.Config.BluetoothConfig\x12\x0f\n\x07version\x18\x08 \x01(\r\x12<\n\x08security\x18\t \x01(\x0b\x32*.meshtastic.protobuf.Config.SecurityConfig\"\xf0\x07\n\x11LocalModuleConfig\x12:\n\x04mqtt\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.ModuleConfig.MQTTConfig\x12>\n\x06serial\x18\x02 \x01(\x0b\x32..meshtastic.protobuf.ModuleConfig.SerialConfig\x12[\n\x15\x65xternal_notification\x18\x03 \x01(\x0b\x32<.meshtastic.protobuf.ModuleConfig.ExternalNotificationConfig\x12K\n\rstore_forward\x18\x04 \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.StoreForwardConfig\x12\x45\n\nrange_test\x18\x05 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.RangeTestConfig\x12\x44\n\ttelemetry\x18\x06 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.TelemetryConfig\x12M\n\x0e\x63\x61nned_message\x18\x07 \x01(\x0b\x32\x35.meshtastic.protobuf.ModuleConfig.CannedMessageConfig\x12<\n\x05\x61udio\x18\t \x01(\x0b\x32-.meshtastic.protobuf.ModuleConfig.AudioConfig\x12O\n\x0fremote_hardware\x18\n \x01(\x0b\x32\x36.meshtastic.protobuf.ModuleConfig.RemoteHardwareConfig\x12K\n\rneighbor_info\x18\x0b \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.NeighborInfoConfig\x12Q\n\x10\x61mbient_lighting\x18\x0c \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.AmbientLightingConfig\x12Q\n\x10\x64\x65tection_sensor\x18\r \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.DetectionSensorConfig\x12\x46\n\npaxcounter\x18\x0e \x01(\x0b\x32\x32.meshtastic.protobuf.ModuleConfig.PaxcounterConfig\x12\x0f\n\x07version\x18\x08 \x01(\rBe\n\x14org.meshtastic.protoB\x0fLocalOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.localonly_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\017LocalOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\017LocalOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_LOCALCONFIG']._serialized_start=136
_globals['_LOCALCONFIG']._serialized_end=642
_globals['_LOCALMODULECONFIG']._serialized_start=645

File diff suppressed because one or more lines are too long

View File

@@ -453,9 +453,9 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
"""
Seeed Tracker L1 EINK driver
"""
QWANTZ_TINY_ARMS: _HardwareModel.ValueType # 101
MUZI_R1_NEO: _HardwareModel.ValueType # 101
"""
Reserved ID for future and past use
Muzi Works R1 Neo
"""
T_DECK_PRO: _HardwareModel.ValueType # 102
"""
@@ -465,9 +465,10 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
"""
Lilygo TLora Pager
"""
GAT562_MESH_TRIAL_TRACKER: _HardwareModel.ValueType # 104
M5STACK_RESERVED: _HardwareModel.ValueType # 104
"""
GAT562 Mesh Trial Tracker
M5Stack Reserved
0x68
"""
WISMESH_TAG: _HardwareModel.ValueType # 105
"""
@@ -494,6 +495,34 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
"""
New Heltec LoRA32 with ESP32-S3 CPU
"""
M5STACK_C6L: _HardwareModel.ValueType # 111
"""
M5Stack C6L
"""
M5STACK_CARDPUTER_ADV: _HardwareModel.ValueType # 112
"""
M5Stack Cardputer Adv
"""
HELTEC_WIRELESS_TRACKER_V2: _HardwareModel.ValueType # 113
"""
ESP32S3 main controller with GPS and TFT screen.
"""
T_WATCH_ULTRA: _HardwareModel.ValueType # 114
"""
LilyGo T-Watch Ultra
"""
THINKNODE_M3: _HardwareModel.ValueType # 115
"""
Elecrow ThinkNode M3
"""
WISMESH_TAP_V2: _HardwareModel.ValueType # 116
"""
RAK WISMESH_TAP_V2 with ESP32-S3 CPU
"""
RAK3401: _HardwareModel.ValueType # 117
"""
RAK3401
"""
PRIVATE_HW: _HardwareModel.ValueType # 255
"""
------------------------------------------------------------------------------------------------------------------------------------------
@@ -930,9 +959,9 @@ SEEED_WIO_TRACKER_L1_EINK: HardwareModel.ValueType # 100
"""
Seeed Tracker L1 EINK driver
"""
QWANTZ_TINY_ARMS: HardwareModel.ValueType # 101
MUZI_R1_NEO: HardwareModel.ValueType # 101
"""
Reserved ID for future and past use
Muzi Works R1 Neo
"""
T_DECK_PRO: HardwareModel.ValueType # 102
"""
@@ -942,9 +971,10 @@ T_LORA_PAGER: HardwareModel.ValueType # 103
"""
Lilygo TLora Pager
"""
GAT562_MESH_TRIAL_TRACKER: HardwareModel.ValueType # 104
M5STACK_RESERVED: HardwareModel.ValueType # 104
"""
GAT562 Mesh Trial Tracker
M5Stack Reserved
0x68
"""
WISMESH_TAG: HardwareModel.ValueType # 105
"""
@@ -971,6 +1001,34 @@ HELTEC_V4: HardwareModel.ValueType # 110
"""
New Heltec LoRA32 with ESP32-S3 CPU
"""
M5STACK_C6L: HardwareModel.ValueType # 111
"""
M5Stack C6L
"""
M5STACK_CARDPUTER_ADV: HardwareModel.ValueType # 112
"""
M5Stack Cardputer Adv
"""
HELTEC_WIRELESS_TRACKER_V2: HardwareModel.ValueType # 113
"""
ESP32S3 main controller with GPS and TFT screen.
"""
T_WATCH_ULTRA: HardwareModel.ValueType # 114
"""
LilyGo T-Watch Ultra
"""
THINKNODE_M3: HardwareModel.ValueType # 115
"""
Elecrow ThinkNode M3
"""
WISMESH_TAP_V2: HardwareModel.ValueType # 116
"""
RAK WISMESH_TAP_V2 with ESP32-S3 CPU
"""
RAK3401: HardwareModel.ValueType # 117
"""
RAK3401
"""
PRIVATE_HW: HardwareModel.ValueType # 255
"""
------------------------------------------------------------------------------------------------------------------------------------------

File diff suppressed because one or more lines are too long

View File

@@ -874,6 +874,7 @@ class ModuleConfig(google.protobuf.message.Message):
HEALTH_MEASUREMENT_ENABLED_FIELD_NUMBER: builtins.int
HEALTH_UPDATE_INTERVAL_FIELD_NUMBER: builtins.int
HEALTH_SCREEN_ENABLED_FIELD_NUMBER: builtins.int
DEVICE_TELEMETRY_ENABLED_FIELD_NUMBER: builtins.int
device_update_interval: builtins.int
"""
Interval in seconds of how often we should try to send our
@@ -934,6 +935,11 @@ class ModuleConfig(google.protobuf.message.Message):
"""
Enable/Disable the health telemetry module on-device display
"""
device_telemetry_enabled: builtins.bool
"""
Enable/Disable the device telemetry module to send metrics to the mesh
Note: We will still send telemtry to the connected phone / client every minute over the API
"""
def __init__(
self,
*,
@@ -950,8 +956,9 @@ class ModuleConfig(google.protobuf.message.Message):
health_measurement_enabled: builtins.bool = ...,
health_update_interval: builtins.int = ...,
health_screen_enabled: builtins.bool = ...,
device_telemetry_enabled: builtins.bool = ...,
) -> None: ...
def ClearField(self, field_name: typing.Literal["air_quality_enabled", b"air_quality_enabled", "air_quality_interval", b"air_quality_interval", "device_update_interval", b"device_update_interval", "environment_display_fahrenheit", b"environment_display_fahrenheit", "environment_measurement_enabled", b"environment_measurement_enabled", "environment_screen_enabled", b"environment_screen_enabled", "environment_update_interval", b"environment_update_interval", "health_measurement_enabled", b"health_measurement_enabled", "health_screen_enabled", b"health_screen_enabled", "health_update_interval", b"health_update_interval", "power_measurement_enabled", b"power_measurement_enabled", "power_screen_enabled", b"power_screen_enabled", "power_update_interval", b"power_update_interval"]) -> None: ...
def ClearField(self, field_name: typing.Literal["air_quality_enabled", b"air_quality_enabled", "air_quality_interval", b"air_quality_interval", "device_telemetry_enabled", b"device_telemetry_enabled", "device_update_interval", b"device_update_interval", "environment_display_fahrenheit", b"environment_display_fahrenheit", "environment_measurement_enabled", b"environment_measurement_enabled", "environment_screen_enabled", b"environment_screen_enabled", "environment_update_interval", b"environment_update_interval", "health_measurement_enabled", b"health_measurement_enabled", "health_screen_enabled", b"health_screen_enabled", "health_update_interval", b"health_update_interval", "power_measurement_enabled", b"power_measurement_enabled", "power_screen_enabled", b"power_screen_enabled", "power_update_interval", b"power_update_interval"]) -> None: ...
@typing.final
class CannedMessageConfig(google.protobuf.message.Message):

View File

@@ -15,14 +15,14 @@ from meshtastic.protobuf import config_pb2 as meshtastic_dot_protobuf_dot_config
from meshtastic.protobuf import mesh_pb2 as meshtastic_dot_protobuf_dot_mesh__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/mqtt.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"j\n\x0fServiceEnvelope\x12/\n\x06packet\x18\x01 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x12\n\nchannel_id\x18\x02 \x01(\t\x12\x12\n\ngateway_id\x18\x03 \x01(\t\"\x83\x04\n\tMapReport\x12\x11\n\tlong_name\x18\x01 \x01(\t\x12\x12\n\nshort_name\x18\x02 \x01(\t\x12;\n\x04role\x18\x03 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x18\n\x10\x66irmware_version\x18\x05 \x01(\t\x12\x41\n\x06region\x18\x06 \x01(\x0e\x32\x31.meshtastic.protobuf.Config.LoRaConfig.RegionCode\x12H\n\x0cmodem_preset\x18\x07 \x01(\x0e\x32\x32.meshtastic.protobuf.Config.LoRaConfig.ModemPreset\x12\x1b\n\x13has_default_channel\x18\x08 \x01(\x08\x12\x12\n\nlatitude_i\x18\t \x01(\x0f\x12\x13\n\x0blongitude_i\x18\n \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x0b \x01(\x05\x12\x1a\n\x12position_precision\x18\x0c \x01(\r\x12\x1e\n\x16num_online_local_nodes\x18\r \x01(\r\x12!\n\x19has_opted_report_location\x18\x0e \x01(\x08\x42_\n\x13\x63om.geeksville.meshB\nMQTTProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/mqtt.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"j\n\x0fServiceEnvelope\x12/\n\x06packet\x18\x01 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x12\n\nchannel_id\x18\x02 \x01(\t\x12\x12\n\ngateway_id\x18\x03 \x01(\t\"\x83\x04\n\tMapReport\x12\x11\n\tlong_name\x18\x01 \x01(\t\x12\x12\n\nshort_name\x18\x02 \x01(\t\x12;\n\x04role\x18\x03 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x18\n\x10\x66irmware_version\x18\x05 \x01(\t\x12\x41\n\x06region\x18\x06 \x01(\x0e\x32\x31.meshtastic.protobuf.Config.LoRaConfig.RegionCode\x12H\n\x0cmodem_preset\x18\x07 \x01(\x0e\x32\x32.meshtastic.protobuf.Config.LoRaConfig.ModemPreset\x12\x1b\n\x13has_default_channel\x18\x08 \x01(\x08\x12\x12\n\nlatitude_i\x18\t \x01(\x0f\x12\x13\n\x0blongitude_i\x18\n \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x0b \x01(\x05\x12\x1a\n\x12position_precision\x18\x0c \x01(\r\x12\x1e\n\x16num_online_local_nodes\x18\r \x01(\r\x12!\n\x19has_opted_report_location\x18\x0e \x01(\x08\x42`\n\x14org.meshtastic.protoB\nMQTTProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.mqtt_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\nMQTTProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\nMQTTProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_SERVICEENVELOPE']._serialized_start=121
_globals['_SERVICEENVELOPE']._serialized_end=227
_globals['_MAPREPORT']._serialized_start=230

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/paxcount.proto\x12\x13meshtastic.protobuf\"5\n\x08Paxcount\x12\x0c\n\x04wifi\x18\x01 \x01(\r\x12\x0b\n\x03\x62le\x18\x02 \x01(\r\x12\x0e\n\x06uptime\x18\x03 \x01(\rBc\n\x13\x63om.geeksville.meshB\x0ePaxcountProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/paxcount.proto\x12\x13meshtastic.protobuf\"5\n\x08Paxcount\x12\x0c\n\x04wifi\x18\x01 \x01(\r\x12\x0b\n\x03\x62le\x18\x02 \x01(\r\x12\x0e\n\x06uptime\x18\x03 \x01(\rBd\n\x14org.meshtastic.protoB\x0ePaxcountProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.paxcount_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016PaxcountProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016PaxcountProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_PAXCOUNT']._serialized_start=59
_globals['_PAXCOUNT']._serialized_end=112
# @@protoc_insertion_point(module_scope)

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/portnums.proto\x12\x13meshtastic.protobuf*\xf6\x04\n\x07PortNum\x12\x0f\n\x0bUNKNOWN_APP\x10\x00\x12\x14\n\x10TEXT_MESSAGE_APP\x10\x01\x12\x17\n\x13REMOTE_HARDWARE_APP\x10\x02\x12\x10\n\x0cPOSITION_APP\x10\x03\x12\x10\n\x0cNODEINFO_APP\x10\x04\x12\x0f\n\x0bROUTING_APP\x10\x05\x12\r\n\tADMIN_APP\x10\x06\x12\x1f\n\x1bTEXT_MESSAGE_COMPRESSED_APP\x10\x07\x12\x10\n\x0cWAYPOINT_APP\x10\x08\x12\r\n\tAUDIO_APP\x10\t\x12\x18\n\x14\x44\x45TECTION_SENSOR_APP\x10\n\x12\r\n\tALERT_APP\x10\x0b\x12\x18\n\x14KEY_VERIFICATION_APP\x10\x0c\x12\r\n\tREPLY_APP\x10 \x12\x11\n\rIP_TUNNEL_APP\x10!\x12\x12\n\x0ePAXCOUNTER_APP\x10\"\x12\x0e\n\nSERIAL_APP\x10@\x12\x15\n\x11STORE_FORWARD_APP\x10\x41\x12\x12\n\x0eRANGE_TEST_APP\x10\x42\x12\x11\n\rTELEMETRY_APP\x10\x43\x12\x0b\n\x07ZPS_APP\x10\x44\x12\x11\n\rSIMULATOR_APP\x10\x45\x12\x12\n\x0eTRACEROUTE_APP\x10\x46\x12\x14\n\x10NEIGHBORINFO_APP\x10G\x12\x0f\n\x0b\x41TAK_PLUGIN\x10H\x12\x12\n\x0eMAP_REPORT_APP\x10I\x12\x13\n\x0fPOWERSTRESS_APP\x10J\x12\x18\n\x14RETICULUM_TUNNEL_APP\x10L\x12\x0f\n\x0b\x43\x41YENNE_APP\x10M\x12\x10\n\x0bPRIVATE_APP\x10\x80\x02\x12\x13\n\x0e\x41TAK_FORWARDER\x10\x81\x02\x12\x08\n\x03MAX\x10\xff\x03\x42]\n\x13\x63om.geeksville.meshB\x08PortnumsZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/portnums.proto\x12\x13meshtastic.protobuf*\xf6\x04\n\x07PortNum\x12\x0f\n\x0bUNKNOWN_APP\x10\x00\x12\x14\n\x10TEXT_MESSAGE_APP\x10\x01\x12\x17\n\x13REMOTE_HARDWARE_APP\x10\x02\x12\x10\n\x0cPOSITION_APP\x10\x03\x12\x10\n\x0cNODEINFO_APP\x10\x04\x12\x0f\n\x0bROUTING_APP\x10\x05\x12\r\n\tADMIN_APP\x10\x06\x12\x1f\n\x1bTEXT_MESSAGE_COMPRESSED_APP\x10\x07\x12\x10\n\x0cWAYPOINT_APP\x10\x08\x12\r\n\tAUDIO_APP\x10\t\x12\x18\n\x14\x44\x45TECTION_SENSOR_APP\x10\n\x12\r\n\tALERT_APP\x10\x0b\x12\x18\n\x14KEY_VERIFICATION_APP\x10\x0c\x12\r\n\tREPLY_APP\x10 \x12\x11\n\rIP_TUNNEL_APP\x10!\x12\x12\n\x0ePAXCOUNTER_APP\x10\"\x12\x0e\n\nSERIAL_APP\x10@\x12\x15\n\x11STORE_FORWARD_APP\x10\x41\x12\x12\n\x0eRANGE_TEST_APP\x10\x42\x12\x11\n\rTELEMETRY_APP\x10\x43\x12\x0b\n\x07ZPS_APP\x10\x44\x12\x11\n\rSIMULATOR_APP\x10\x45\x12\x12\n\x0eTRACEROUTE_APP\x10\x46\x12\x14\n\x10NEIGHBORINFO_APP\x10G\x12\x0f\n\x0b\x41TAK_PLUGIN\x10H\x12\x12\n\x0eMAP_REPORT_APP\x10I\x12\x13\n\x0fPOWERSTRESS_APP\x10J\x12\x18\n\x14RETICULUM_TUNNEL_APP\x10L\x12\x0f\n\x0b\x43\x41YENNE_APP\x10M\x12\x10\n\x0bPRIVATE_APP\x10\x80\x02\x12\x13\n\x0e\x41TAK_FORWARDER\x10\x81\x02\x12\x08\n\x03MAX\x10\xff\x03\x42^\n\x14org.meshtastic.protoB\x08PortnumsZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.portnums_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\010PortnumsZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\010PortnumsZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_PORTNUM']._serialized_start=60
_globals['_PORTNUM']._serialized_end=690
# @@protoc_insertion_point(module_scope)

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/powermon.proto\x12\x13meshtastic.protobuf\"\xe0\x01\n\x08PowerMon\"\xd3\x01\n\x05State\x12\x08\n\x04None\x10\x00\x12\x11\n\rCPU_DeepSleep\x10\x01\x12\x12\n\x0e\x43PU_LightSleep\x10\x02\x12\x0c\n\x08Vext1_On\x10\x04\x12\r\n\tLora_RXOn\x10\x08\x12\r\n\tLora_TXOn\x10\x10\x12\x11\n\rLora_RXActive\x10 \x12\t\n\x05\x42T_On\x10@\x12\x0b\n\x06LED_On\x10\x80\x01\x12\x0e\n\tScreen_On\x10\x80\x02\x12\x13\n\x0eScreen_Drawing\x10\x80\x04\x12\x0c\n\x07Wifi_On\x10\x80\x08\x12\x0f\n\nGPS_Active\x10\x80\x10\"\x88\x03\n\x12PowerStressMessage\x12;\n\x03\x63md\x18\x01 \x01(\x0e\x32..meshtastic.protobuf.PowerStressMessage.Opcode\x12\x13\n\x0bnum_seconds\x18\x02 \x01(\x02\"\x9f\x02\n\x06Opcode\x12\t\n\x05UNSET\x10\x00\x12\x0e\n\nPRINT_INFO\x10\x01\x12\x0f\n\x0b\x46ORCE_QUIET\x10\x02\x12\r\n\tEND_QUIET\x10\x03\x12\r\n\tSCREEN_ON\x10\x10\x12\x0e\n\nSCREEN_OFF\x10\x11\x12\x0c\n\x08\x43PU_IDLE\x10 \x12\x11\n\rCPU_DEEPSLEEP\x10!\x12\x0e\n\nCPU_FULLON\x10\"\x12\n\n\x06LED_ON\x10\x30\x12\x0b\n\x07LED_OFF\x10\x31\x12\x0c\n\x08LORA_OFF\x10@\x12\x0b\n\x07LORA_TX\x10\x41\x12\x0b\n\x07LORA_RX\x10\x42\x12\n\n\x06\x42T_OFF\x10P\x12\t\n\x05\x42T_ON\x10Q\x12\x0c\n\x08WIFI_OFF\x10`\x12\x0b\n\x07WIFI_ON\x10\x61\x12\x0b\n\x07GPS_OFF\x10p\x12\n\n\x06GPS_ON\x10qBc\n\x13\x63om.geeksville.meshB\x0ePowerMonProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/powermon.proto\x12\x13meshtastic.protobuf\"\xe0\x01\n\x08PowerMon\"\xd3\x01\n\x05State\x12\x08\n\x04None\x10\x00\x12\x11\n\rCPU_DeepSleep\x10\x01\x12\x12\n\x0e\x43PU_LightSleep\x10\x02\x12\x0c\n\x08Vext1_On\x10\x04\x12\r\n\tLora_RXOn\x10\x08\x12\r\n\tLora_TXOn\x10\x10\x12\x11\n\rLora_RXActive\x10 \x12\t\n\x05\x42T_On\x10@\x12\x0b\n\x06LED_On\x10\x80\x01\x12\x0e\n\tScreen_On\x10\x80\x02\x12\x13\n\x0eScreen_Drawing\x10\x80\x04\x12\x0c\n\x07Wifi_On\x10\x80\x08\x12\x0f\n\nGPS_Active\x10\x80\x10\"\x88\x03\n\x12PowerStressMessage\x12;\n\x03\x63md\x18\x01 \x01(\x0e\x32..meshtastic.protobuf.PowerStressMessage.Opcode\x12\x13\n\x0bnum_seconds\x18\x02 \x01(\x02\"\x9f\x02\n\x06Opcode\x12\t\n\x05UNSET\x10\x00\x12\x0e\n\nPRINT_INFO\x10\x01\x12\x0f\n\x0b\x46ORCE_QUIET\x10\x02\x12\r\n\tEND_QUIET\x10\x03\x12\r\n\tSCREEN_ON\x10\x10\x12\x0e\n\nSCREEN_OFF\x10\x11\x12\x0c\n\x08\x43PU_IDLE\x10 \x12\x11\n\rCPU_DEEPSLEEP\x10!\x12\x0e\n\nCPU_FULLON\x10\"\x12\n\n\x06LED_ON\x10\x30\x12\x0b\n\x07LED_OFF\x10\x31\x12\x0c\n\x08LORA_OFF\x10@\x12\x0b\n\x07LORA_TX\x10\x41\x12\x0b\n\x07LORA_RX\x10\x42\x12\n\n\x06\x42T_OFF\x10P\x12\t\n\x05\x42T_ON\x10Q\x12\x0c\n\x08WIFI_OFF\x10`\x12\x0b\n\x07WIFI_ON\x10\x61\x12\x0b\n\x07GPS_OFF\x10p\x12\n\n\x06GPS_ON\x10qBd\n\x14org.meshtastic.protoB\x0ePowerMonProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.powermon_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016PowerMonProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016PowerMonProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_POWERMON']._serialized_start=60
_globals['_POWERMON']._serialized_end=284
_globals['_POWERMON_STATE']._serialized_start=73

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)meshtastic/protobuf/remote_hardware.proto\x12\x13meshtastic.protobuf\"\xdf\x01\n\x0fHardwareMessage\x12\x37\n\x04type\x18\x01 \x01(\x0e\x32).meshtastic.protobuf.HardwareMessage.Type\x12\x11\n\tgpio_mask\x18\x02 \x01(\x04\x12\x12\n\ngpio_value\x18\x03 \x01(\x04\"l\n\x04Type\x12\t\n\x05UNSET\x10\x00\x12\x0f\n\x0bWRITE_GPIOS\x10\x01\x12\x0f\n\x0bWATCH_GPIOS\x10\x02\x12\x11\n\rGPIOS_CHANGED\x10\x03\x12\x0e\n\nREAD_GPIOS\x10\x04\x12\x14\n\x10READ_GPIOS_REPLY\x10\x05\x42\x63\n\x13\x63om.geeksville.meshB\x0eRemoteHardwareZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)meshtastic/protobuf/remote_hardware.proto\x12\x13meshtastic.protobuf\"\xdf\x01\n\x0fHardwareMessage\x12\x37\n\x04type\x18\x01 \x01(\x0e\x32).meshtastic.protobuf.HardwareMessage.Type\x12\x11\n\tgpio_mask\x18\x02 \x01(\x04\x12\x12\n\ngpio_value\x18\x03 \x01(\x04\"l\n\x04Type\x12\t\n\x05UNSET\x10\x00\x12\x0f\n\x0bWRITE_GPIOS\x10\x01\x12\x0f\n\x0bWATCH_GPIOS\x10\x02\x12\x11\n\rGPIOS_CHANGED\x10\x03\x12\x0e\n\nREAD_GPIOS\x10\x04\x12\x14\n\x10READ_GPIOS_REPLY\x10\x05\x42\x64\n\x14org.meshtastic.protoB\x0eRemoteHardwareZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.remote_hardware_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016RemoteHardwareZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016RemoteHardwareZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_HARDWAREMESSAGE']._serialized_start=67
_globals['_HARDWAREMESSAGE']._serialized_end=290
_globals['_HARDWAREMESSAGE_TYPE']._serialized_start=182

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fmeshtastic/protobuf/rtttl.proto\x12\x13meshtastic.protobuf\"\x1f\n\x0bRTTTLConfig\x12\x10\n\x08ringtone\x18\x01 \x01(\tBf\n\x13\x63om.geeksville.meshB\x11RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fmeshtastic/protobuf/rtttl.proto\x12\x13meshtastic.protobuf\"\x1f\n\x0bRTTTLConfig\x12\x10\n\x08ringtone\x18\x01 \x01(\tBg\n\x14org.meshtastic.protoB\x11RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.rtttl_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\021RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\021RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_RTTTLCONFIG']._serialized_start=56
_globals['_RTTTLCONFIG']._serialized_end=87
# @@protoc_insertion_point(module_scope)

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&meshtastic/protobuf/storeforward.proto\x12\x13meshtastic.protobuf\"\xc0\x07\n\x0fStoreAndForward\x12@\n\x02rr\x18\x01 \x01(\x0e\x32\x34.meshtastic.protobuf.StoreAndForward.RequestResponse\x12@\n\x05stats\x18\x02 \x01(\x0b\x32/.meshtastic.protobuf.StoreAndForward.StatisticsH\x00\x12?\n\x07history\x18\x03 \x01(\x0b\x32,.meshtastic.protobuf.StoreAndForward.HistoryH\x00\x12\x43\n\theartbeat\x18\x04 \x01(\x0b\x32..meshtastic.protobuf.StoreAndForward.HeartbeatH\x00\x12\x0e\n\x04text\x18\x05 \x01(\x0cH\x00\x1a\xcd\x01\n\nStatistics\x12\x16\n\x0emessages_total\x18\x01 \x01(\r\x12\x16\n\x0emessages_saved\x18\x02 \x01(\r\x12\x14\n\x0cmessages_max\x18\x03 \x01(\r\x12\x0f\n\x07up_time\x18\x04 \x01(\r\x12\x10\n\x08requests\x18\x05 \x01(\r\x12\x18\n\x10requests_history\x18\x06 \x01(\r\x12\x11\n\theartbeat\x18\x07 \x01(\x08\x12\x12\n\nreturn_max\x18\x08 \x01(\r\x12\x15\n\rreturn_window\x18\t \x01(\r\x1aI\n\x07History\x12\x18\n\x10history_messages\x18\x01 \x01(\r\x12\x0e\n\x06window\x18\x02 \x01(\r\x12\x14\n\x0clast_request\x18\x03 \x01(\r\x1a.\n\tHeartbeat\x12\x0e\n\x06period\x18\x01 \x01(\r\x12\x11\n\tsecondary\x18\x02 \x01(\r\"\xbc\x02\n\x0fRequestResponse\x12\t\n\x05UNSET\x10\x00\x12\x10\n\x0cROUTER_ERROR\x10\x01\x12\x14\n\x10ROUTER_HEARTBEAT\x10\x02\x12\x0f\n\x0bROUTER_PING\x10\x03\x12\x0f\n\x0bROUTER_PONG\x10\x04\x12\x0f\n\x0bROUTER_BUSY\x10\x05\x12\x12\n\x0eROUTER_HISTORY\x10\x06\x12\x10\n\x0cROUTER_STATS\x10\x07\x12\x16\n\x12ROUTER_TEXT_DIRECT\x10\x08\x12\x19\n\x15ROUTER_TEXT_BROADCAST\x10\t\x12\x10\n\x0c\x43LIENT_ERROR\x10@\x12\x12\n\x0e\x43LIENT_HISTORY\x10\x41\x12\x10\n\x0c\x43LIENT_STATS\x10\x42\x12\x0f\n\x0b\x43LIENT_PING\x10\x43\x12\x0f\n\x0b\x43LIENT_PONG\x10\x44\x12\x10\n\x0c\x43LIENT_ABORT\x10jB\t\n\x07variantBj\n\x13\x63om.geeksville.meshB\x15StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&meshtastic/protobuf/storeforward.proto\x12\x13meshtastic.protobuf\"\xc0\x07\n\x0fStoreAndForward\x12@\n\x02rr\x18\x01 \x01(\x0e\x32\x34.meshtastic.protobuf.StoreAndForward.RequestResponse\x12@\n\x05stats\x18\x02 \x01(\x0b\x32/.meshtastic.protobuf.StoreAndForward.StatisticsH\x00\x12?\n\x07history\x18\x03 \x01(\x0b\x32,.meshtastic.protobuf.StoreAndForward.HistoryH\x00\x12\x43\n\theartbeat\x18\x04 \x01(\x0b\x32..meshtastic.protobuf.StoreAndForward.HeartbeatH\x00\x12\x0e\n\x04text\x18\x05 \x01(\x0cH\x00\x1a\xcd\x01\n\nStatistics\x12\x16\n\x0emessages_total\x18\x01 \x01(\r\x12\x16\n\x0emessages_saved\x18\x02 \x01(\r\x12\x14\n\x0cmessages_max\x18\x03 \x01(\r\x12\x0f\n\x07up_time\x18\x04 \x01(\r\x12\x10\n\x08requests\x18\x05 \x01(\r\x12\x18\n\x10requests_history\x18\x06 \x01(\r\x12\x11\n\theartbeat\x18\x07 \x01(\x08\x12\x12\n\nreturn_max\x18\x08 \x01(\r\x12\x15\n\rreturn_window\x18\t \x01(\r\x1aI\n\x07History\x12\x18\n\x10history_messages\x18\x01 \x01(\r\x12\x0e\n\x06window\x18\x02 \x01(\r\x12\x14\n\x0clast_request\x18\x03 \x01(\r\x1a.\n\tHeartbeat\x12\x0e\n\x06period\x18\x01 \x01(\r\x12\x11\n\tsecondary\x18\x02 \x01(\r\"\xbc\x02\n\x0fRequestResponse\x12\t\n\x05UNSET\x10\x00\x12\x10\n\x0cROUTER_ERROR\x10\x01\x12\x14\n\x10ROUTER_HEARTBEAT\x10\x02\x12\x0f\n\x0bROUTER_PING\x10\x03\x12\x0f\n\x0bROUTER_PONG\x10\x04\x12\x0f\n\x0bROUTER_BUSY\x10\x05\x12\x12\n\x0eROUTER_HISTORY\x10\x06\x12\x10\n\x0cROUTER_STATS\x10\x07\x12\x16\n\x12ROUTER_TEXT_DIRECT\x10\x08\x12\x19\n\x15ROUTER_TEXT_BROADCAST\x10\t\x12\x10\n\x0c\x43LIENT_ERROR\x10@\x12\x12\n\x0e\x43LIENT_HISTORY\x10\x41\x12\x10\n\x0c\x43LIENT_STATS\x10\x42\x12\x0f\n\x0b\x43LIENT_PING\x10\x43\x12\x0f\n\x0b\x43LIENT_PONG\x10\x44\x12\x10\n\x0c\x43LIENT_ABORT\x10jB\t\n\x07variantBk\n\x14org.meshtastic.protoB\x15StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.storeforward_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\025StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\025StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_STOREANDFORWARD']._serialized_start=64
_globals['_STOREANDFORWARD']._serialized_end=1024
_globals['_STOREANDFORWARD_STATISTICS']._serialized_start=366

File diff suppressed because one or more lines are too long

View File

@@ -203,6 +203,10 @@ class _TelemetrySensorTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wra
"""
TSL2561 light sensor
"""
BH1750: _TelemetrySensorType.ValueType # 45
"""
BH1750 light sensor
"""
class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper):
"""
@@ -389,6 +393,10 @@ TSL2561: TelemetrySensorType.ValueType # 44
"""
TSL2561 light sensor
"""
BH1750: TelemetrySensorType.ValueType # 45
"""
BH1750 light sensor
"""
global___TelemetrySensorType = TelemetrySensorType
@typing.final
@@ -1026,6 +1034,7 @@ class LocalStats(google.protobuf.message.Message):
NUM_TX_RELAY_CANCELED_FIELD_NUMBER: builtins.int
HEAP_TOTAL_BYTES_FIELD_NUMBER: builtins.int
HEAP_FREE_BYTES_FIELD_NUMBER: builtins.int
NUM_TX_DROPPED_FIELD_NUMBER: builtins.int
uptime_seconds: builtins.int
"""
How long the device has been running since the last reboot (in seconds)
@@ -1080,6 +1089,10 @@ class LocalStats(google.protobuf.message.Message):
"""
Number of bytes free in the heap
"""
num_tx_dropped: builtins.int
"""
Number of packets that were dropped because the transmit queue was full.
"""
def __init__(
self,
*,
@@ -1096,8 +1109,9 @@ class LocalStats(google.protobuf.message.Message):
num_tx_relay_canceled: builtins.int = ...,
heap_total_bytes: builtins.int = ...,
heap_free_bytes: builtins.int = ...,
num_tx_dropped: builtins.int = ...,
) -> None: ...
def ClearField(self, field_name: typing.Literal["air_util_tx", b"air_util_tx", "channel_utilization", b"channel_utilization", "heap_free_bytes", b"heap_free_bytes", "heap_total_bytes", b"heap_total_bytes", "num_online_nodes", b"num_online_nodes", "num_packets_rx", b"num_packets_rx", "num_packets_rx_bad", b"num_packets_rx_bad", "num_packets_tx", b"num_packets_tx", "num_rx_dupe", b"num_rx_dupe", "num_total_nodes", b"num_total_nodes", "num_tx_relay", b"num_tx_relay", "num_tx_relay_canceled", b"num_tx_relay_canceled", "uptime_seconds", b"uptime_seconds"]) -> None: ...
def ClearField(self, field_name: typing.Literal["air_util_tx", b"air_util_tx", "channel_utilization", b"channel_utilization", "heap_free_bytes", b"heap_free_bytes", "heap_total_bytes", b"heap_total_bytes", "num_online_nodes", b"num_online_nodes", "num_packets_rx", b"num_packets_rx", "num_packets_rx_bad", b"num_packets_rx_bad", "num_packets_tx", b"num_packets_tx", "num_rx_dupe", b"num_rx_dupe", "num_total_nodes", b"num_total_nodes", "num_tx_dropped", b"num_tx_dropped", "num_tx_relay", b"num_tx_relay", "num_tx_relay_canceled", b"num_tx_relay_canceled", "uptime_seconds", b"uptime_seconds"]) -> None: ...
global___LocalStats = LocalStats

View File

@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n meshtastic/protobuf/xmodem.proto\x12\x13meshtastic.protobuf\"\xbf\x01\n\x06XModem\x12\x34\n\x07\x63ontrol\x18\x01 \x01(\x0e\x32#.meshtastic.protobuf.XModem.Control\x12\x0b\n\x03seq\x18\x02 \x01(\r\x12\r\n\x05\x63rc16\x18\x03 \x01(\r\x12\x0e\n\x06\x62uffer\x18\x04 \x01(\x0c\"S\n\x07\x43ontrol\x12\x07\n\x03NUL\x10\x00\x12\x07\n\x03SOH\x10\x01\x12\x07\n\x03STX\x10\x02\x12\x07\n\x03\x45OT\x10\x04\x12\x07\n\x03\x41\x43K\x10\x06\x12\x07\n\x03NAK\x10\x15\x12\x07\n\x03\x43\x41N\x10\x18\x12\t\n\x05\x43TRLZ\x10\x1a\x42\x61\n\x13\x63om.geeksville.meshB\x0cXmodemProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n meshtastic/protobuf/xmodem.proto\x12\x13meshtastic.protobuf\"\xbf\x01\n\x06XModem\x12\x34\n\x07\x63ontrol\x18\x01 \x01(\x0e\x32#.meshtastic.protobuf.XModem.Control\x12\x0b\n\x03seq\x18\x02 \x01(\r\x12\r\n\x05\x63rc16\x18\x03 \x01(\r\x12\x0e\n\x06\x62uffer\x18\x04 \x01(\x0c\"S\n\x07\x43ontrol\x12\x07\n\x03NUL\x10\x00\x12\x07\n\x03SOH\x10\x01\x12\x07\n\x03STX\x10\x02\x12\x07\n\x03\x45OT\x10\x04\x12\x07\n\x03\x41\x43K\x10\x06\x12\x07\n\x03NAK\x10\x15\x12\x07\n\x03\x43\x41N\x10\x18\x12\t\n\x05\x43TRLZ\x10\x1a\x42\x62\n\x14org.meshtastic.protoB\x0cXmodemProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.xmodem_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\014XmodemProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\014XmodemProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
_globals['_XMODEM']._serialized_start=58
_globals['_XMODEM']._serialized_end=249
_globals['_XMODEM_CONTROL']._serialized_start=166

57
meshview/__version__.py Normal file
View File

@@ -0,0 +1,57 @@
"""Version information for MeshView."""
import subprocess
from pathlib import Path
__version__ = "3.0.1"
__release_date__ = "2025-12-4"
def get_git_revision():
"""Get the current git revision hash."""
try:
repo_dir = Path(__file__).parent.parent
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
cwd=repo_dir,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return "unknown"
def get_git_revision_short():
"""Get the short git revision hash."""
try:
repo_dir = Path(__file__).parent.parent
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
cwd=repo_dir,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return "unknown"
def get_version_info():
"""Get complete version information."""
return {
"version": __version__,
"release_date": __release_date__,
"git_revision": get_git_revision(),
"git_revision_short": get_git_revision_short(),
}
# Cache git info at import time for performance
_git_revision = get_git_revision()
_git_revision_short = get_git_revision_short()
# Full version string for display
__version_string__ = f"{__version__} ~ {__release_date__}"

View File

@@ -1,110 +1,212 @@
{
"base": {
"conversations": "Conversations",
"nodes": "Nodes",
"everything": "See Everything",
"graph": "Mesh Graphs",
"net": "Weekly Net",
"map": "Live Map",
"stats": "Stats",
"top": "Top Traffic Nodes",
"footer": "Visit <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> on GitHub",
"node id": "Node id",
"go to node": "Go to Node",
"all": "All",
"portnum_options": {
"1": "Text Message",
"3": "Position",
"4": "Node Info",
"67": "Telemetry",
"70": "Traceroute",
"71": "Neighbor Info"
}
{
"base": {
"chat": "Chat",
"nodes": "Nodes",
"everything": "See Everything",
"graphs": "Mesh Graphs",
"net": "Weekly Net",
"map": "Live Map",
"stats": "Stats",
"top": "Top Traffic Nodes",
"footer": "Visit <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> on GitHub",
"node id": "Node id",
"go to node": "Go to Node",
"all": "All",
"portnum_options": {
"1": "Text Message",
"3": "Position",
"4": "Node Info",
"67": "Telemetry",
"70": "Traceroute",
"71": "Neighbor Info"
}
},
"chat": {
"chat_title": "Chats:",
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw": "All HW Models",
"all_firmware": "All Firmware",
"show_favorites": "⭐ Show Favorites",
"show_all": "⭐ Show All",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing_nodes": "Showing",
"nodes_suffix": "nodes",
"loading_nodes": "Loading nodes...",
"error_loading_nodes": "Error loading nodes",
"no_nodes_found": "No nodes found",
"short_name": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_seen": "Last Seen",
"favorite": "Favorite",
"time_just_now": "just now",
"time_min_ago": "min ago",
"time_hr_ago": "hr ago",
"time_day_ago": "day ago",
"time_days_ago": "days ago"
},
"chat": {
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw_models": "All HW Models",
"all_firmware": "All Firmware",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing": "Showing",
"nodes": "nodes",
"short": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_update": "Last Update",
"loading_nodes": "Loading nodes...",
"no_nodes": "No nodes found",
"error_nodes": "Error loading nodes"
},
"net": {
"number_of_checkins": "Number of Check-ins:",
"view_packet_details": "View packet details",
"view_all_packets_from_node": "View all packets from this node",
"no_packets_found": "No packets found."
},
"map": {
"channel": "Channel:",
"model": "Model:",
"role": "Role:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"show_routers_only": "Show Routers Only",
"share_view": "Share This View"
},
"stats":
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
"total_nodes": "Total Nodes",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
"packets_per_hour_text": "Packets per Hour - Text Messages (Port 1)",
"packet_types_last_24h": "Packet Types - Last 24 Hours",
"hardware_breakdown": "Hardware Breakdown",
"role_breakdown": "Role Breakdown",
"channel_breakdown": "Channel Breakdown",
"expand_chart": "Expand Chart",
"export_csv": "Export CSV",
"all_channels": "All Channels"
},
"top":
{
"top_traffic_nodes": "Top Traffic Nodes (last 24 hours)",
"chart_description_1": "This chart shows a bell curve (normal distribution) based on the total \"Times Seen\" values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.",
"chart_description_2": "This \"Times Seen\" value is the closest that we can get to Mesh utilization by node.",
"mean_label": "Mean:",
"stddev_label": "Standard Deviation:",
"long_name": "Long Name",
"short_name": "Short Name",
"channel": "Channel",
"packets_sent": "Packets Sent",
"times_seen": "Times Seen",
"seen_percent": "Seen % of Mean",
"no_nodes": "No top traffic nodes available."
},
"nodegraph":
{
"channel_label": "Channel:",
"search_node_placeholder": "Search node...",
"search_button": "Search",
"long_name_label": "Long Name:",
"short_name_label": "Short Name:",
"role_label": "Role:",
"hw_model_label": "Hardware Model:",
"node_not_found": "Node not found in current channel!"
"net": {
"net_title": "Weekly Net:",
"total_messages": "Number of messages:",
"view_packet_details": "More details"
},
"map": {
"show_routers_only": "Show Routers Only",
"share_view": "Share This View",
"reset_filters": "Reset Filters To Defaults",
"channel_label": "Channel:",
"model_label": "Model:",
"role_label": "Role:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"link_copied": "Link Copied!",
"legend_traceroute": "Traceroute (with arrows)",
"legend_neighbor": "Neighbor"
},
"stats":
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
"total_nodes": "Total Nodes",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
"packets_per_hour_text": "Packets per Hour - Text Messages (Port 1)",
"packet_types_last_24h": "Packet Types - Last 24 Hours",
"hardware_breakdown": "Hardware Breakdown",
"role_breakdown": "Role Breakdown",
"channel_breakdown": "Channel Breakdown",
"expand_chart": "Expand Chart",
"export_csv": "Export CSV",
"all_channels": "All Channels",
"node_id": "Node ID"
},
"top": {
"top_traffic_nodes": "Top Nodes Traffic",
"channel": "Channel",
"search": "Search",
"search_placeholder": "Search nodes...",
"long_name": "Long Name",
"short_name": "Short Name",
"packets_sent": "Sent (24h)",
"times_seen": "Seen (24h)",
"avg_gateways": "Avg Gateways",
"showing_nodes": "Showing",
"nodes_suffix": "nodes"
},
"nodegraph":
{
"channel_label": "Channel:",
"search_node_placeholder": "Search node...",
"search_button": "Search",
"long_name_label": "Long Name:",
"short_name_label": "Short Name:",
"role_label": "Role:",
"hw_model_label": "Hardware Model:",
"node_not_found": "Node not found in current channel!"
},
"firehose":
{
"live_feed": "📡 Live Feed",
"pause": "Pause",
"resume": "Resume",
"time": "Time",
"packet_id": "Packet ID",
"from": "From",
"to": "To",
"port": "Port",
"links": "Links",
"unknown_app": "UNKNOWN APP",
"text_message": "Text Message",
"position": "Position",
"node_info": "Node Info",
"routing": "Routing",
"administration": "Administration",
"waypoint": "Waypoint",
"store_forward": "Store Forward",
"telemetry": "Telemetry",
"trace_route": "Trace Route",
"neighbor_info": "Neighbor Info",
"direct_to_mqtt": "direct to MQTT",
"all": "All",
"map": "Map",
"graph": "Graph"
},
"node": {
"specifications": "Specifications",
"node_id": "Node ID",
"long_name": "Long Name",
"short_name": "Short Name",
"hw_model": "Hardware Model",
"firmware": "Firmware",
"role": "Role",
"channel": "Channel",
"latitude": "Latitude",
"longitude": "Longitude",
"last_update": "Last Update",
"battery_voltage": "Battery & Voltage",
"air_channel": "Air & Channel Utilization",
"environment": "Environment Metrics",
"neighbors_chart": "Neighbors (Signal-to-Noise)",
"expand": "Expand",
"export_csv": "Export CSV",
"time": "Time",
"packet_id": "Packet ID",
"from": "From",
"to": "To",
"port": "Port",
"direct_to_mqtt": "Direct to MQTT",
"all_broadcast": "All",
"statistics": "Statistics",
"last_24h": "24h",
"packets_sent": "Packets sent",
"times_seen": "Times seen"
},
"packet": {
"loading": "Loading packet information...",
"packet_id_label": "Packet ID",
"from_node": "From Node",
"to_node": "To Node",
"channel": "Channel",
"port": "Port",
"raw_payload": "Raw Payload",
"decoded_telemetry": "Decoded Telemetry",
"location": "Location",
"seen_by": "Seen By",
"gateway": "Gateway",
"rssi": "RSSI",
"snr": "SNR",
"hops": "Hop",
"time": "Time",
"packet_source": "Packet Source",
"distance": "Distance",
"node_id_short": "Node ID",
"all_broadcast": "All",
"direct_to_mqtt": "Direct to MQTT"
}
}
}

View File

@@ -1,16 +1,16 @@
{
"base": {
"conversations": "Conversaciones",
"chat": "Conversaciones",
"nodes": "Nodos",
"everything": "Mostrar Todo",
"graph": "Gráficos de la Malla",
"everything": "Mostrar todo",
"graphs": "Gráficos de la Malla",
"net": "Red Semanal",
"map": "Mapa en Vivo",
"stats": "Estadísticas",
"top": "Nodos con Mayor Tráfico",
"footer": "Visita <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> en Github.",
"node id": "ID de Nodo",
"go to node": "Ir al nodo",
"node_id": "ID de Nodo",
"go_to_node": "Ir al nodo",
"all": "Todos",
"portnum_options": {
"1": "Mensaje de Texto",
@@ -21,48 +21,65 @@
"71": "Información de Vecinos"
}
},
"chat": {
"replying_to": "Respondiendo a:",
"view_packet_details": "Ver detalles del paquete"
"chat_title": "Conversaciones:",
"replying_to": "Respondiendo a:",
"view_packet_details": "Ver detalles del paquete"
},
"nodelist": {
"search_placeholder": "Buscar por nombre o ID...",
"all_roles": "Todos los Roles",
"all_channels": "Todos los Canales",
"all_hw_models": "Todos los Modelos",
"all_firmware": "Todo el Firmware",
"all_roles": "Todos los roles",
"all_channels": "Todos los canales",
"all_hw": "Todos los modelos",
"all_firmware": "Todo el firmware",
"show_favorites": "⭐ Mostrar favoritos",
"show_all": "⭐ Mostrar todos",
"export_csv": "Exportar CSV",
"clear_filters": "Limpiar Filtros",
"showing": "Mostrando",
"nodes": "nodos",
"short": "Corto",
"long_name": "Largo",
"hw_model": "Modelo",
"clear_filters": "Limpiar filtros",
"showing_nodes": "Mostrando",
"nodes_suffix": "nodos",
"loading_nodes": "Cargando nodos...",
"error_loading_nodes": "Error al cargar nodos",
"no_nodes_found": "No se encontraron nodos",
"short_name": "Corto",
"long_name": "Nombre largo",
"hw_model": "Modelo HW",
"firmware": "Firmware",
"role": "Rol",
"last_lat": "Última Latitud",
"last_long": "Última Longitud",
"last_lat": "Última latitud",
"last_long": "Última longitud",
"channel": "Canal",
"last_update": "Última Actualización",
"loading_nodes": "Cargando nodos...",
"no_nodes": "No se encontraron nodos",
"error_nodes": "Error al cargar nodos"
"last_seen": "Última vez visto",
"favorite": "Favorito",
"time_just_now": "justo ahora",
"time_min_ago": "min atrás",
"time_hr_ago": "h atrás",
"time_day_ago": "día atrás",
"time_days_ago": "días atrás"
},
"net": {
"number_of_checkins": "Número de registros:",
"view_packet_details": "Ver detalles del paquete",
"view_all_packets_from_node": "Ver todos los paquetes de este nodo",
"no_packets_found": "No se encontraron paquetes."
"net_title": "Red Semanal:",
"total_messages": "Número de mensajes:",
"view_packet_details": "Más Detalles"
},
"map": {
"channel": "Canal:",
"model": "Modelo:",
"role": "Rol:",
"filter_routers_only": "Mostrar solo enrutadores",
"share_view": "Compartir esta vista",
"reset_filters": "Restablecer filtros",
"channel_label": "Canal:",
"model_label": "Modelo:",
"role_label": "Rol:",
"last_seen": "Visto por última vez:",
"firmware": "Firmware:",
"show_routers_only": "Mostrar solo enrutadores",
"share_view": "Compartir esta vista"
"link_copied": "¡Enlace copiado!",
"legend_traceroute": "Ruta de traceroute (flechas de dirección)",
"legend_neighbor": "Vínculo de vecinos"
},
"stats": {
"mesh_stats_summary": "Estadísticas de la Malla - Resumen (completas en la base de datos)",
"total_nodes": "Nodos Totales",
@@ -80,22 +97,22 @@
"export_csv": "Exportar CSV",
"all_channels": "Todos los Canales"
},
"top": {
"top_traffic_nodes": "Tráfico (últimas 24 horas)",
"chart_description_1": "Este gráfico muestra una curva normal (distribución normal) basada en el valor total de \"Veces Visto\" para todos los nodos. Ayuda a visualizar con qué frecuencia se detectan los nodos en relación con el promedio.",
"chart_description_2": "Este valor de \"Veces Visto\" es lo más aproximado que tenemos al nivel de uso de la malla por nodo.",
"mean_label": "Media:",
"stddev_label": "Desviación Estándar:",
"top_traffic_nodes": "Tráfico de Nodos (24h)",
"channel": "Canal",
"search": "Buscar",
"search_placeholder": "Buscar nodos...",
"long_name": "Nombre Largo",
"short_name": "Nombre Corto",
"channel": "Canal",
"packets_sent": "Paquetes Enviados",
"times_seen": "Veces Visto",
"seen_percent": "% Visto respecto a la Media",
"no_nodes": "No hay nodos con mayor tráfico disponibles."
"packets_sent": "Enviados (24h)",
"times_seen": "Visto (24h)",
"avg_gateways": "Promedio de Gateways",
"showing_nodes": "Mostrando",
"nodes_suffix": "nodos"
},
"nodegraph":
{
"nodegraph": {
"channel_label": "Canal:",
"search_placeholder": "Buscar nodo...",
"search_button": "Buscar",
@@ -108,5 +125,73 @@
"other": "Otro",
"unknown": "Desconocido",
"node_not_found": "¡Nodo no encontrado en el canal actual!"
},
"firehose": {
"live_feed": "📡 Flujo en vivo",
"pause": "Pausar",
"resume": "Reanudar",
"time": "Hora",
"packet_id": "ID de paquete",
"from": "De",
"to": "A",
"port": "Puerto",
"direct_to_mqtt": "Directo a MQTT",
"all_broadcast": "Todos"
},
"node": {
"specifications": "Especificaciones",
"node_id": "ID de Nodo",
"long_name": "Nombre Largo",
"short_name": "Nombre Corto",
"hw_model": "Modelo de Hardware",
"firmware": "Firmware",
"role": "Rol",
"channel": "Canal",
"latitude": "Latitud",
"longitude": "Longitud",
"last_update": "Última Actualización",
"battery_voltage": "Batería y voltaje",
"air_channel": "Utilización del aire y del canal",
"environment": "Métricas Ambientales",
"neighbors_chart": "Vecinos (Relación Señal/Ruido)",
"expand": "Ampliar",
"export_csv": "Exportar CSV",
"time": "Hora",
"packet_id": "ID del Paquete",
"from": "De",
"to": "A",
"port": "Puerto",
"direct_to_mqtt": "Directo a MQTT",
"all_broadcast": "Todos",
"statistics": "Estadísticas",
"last_24h": "24h",
"packets_sent": "Paquetes enviados",
"times_seen": "Veces visto"
},
"packet": {
"loading": "Cargando información del paquete...",
"packet_id_label": "ID del Paquete",
"from_node": "De",
"to_node": "A",
"channel": "Canal",
"port": "Puerto",
"raw_payload": "Payload sin procesar",
"decoded_telemetry": "Telemetría Decodificada",
"location": "Ubicación",
"seen_by": "Visto por",
"gateway": "Gateway",
"rssi": "RSSI",
"snr": "SNR",
"hops": "Saltos",
"time": "Hora",
"packet_source": "Origen del Paquete",
"distance": "Distancia",
"node_id_short": "ID de Nodo",
"all_broadcast": "Todos",
"direct_to_mqtt": "Directo a MQTT",
"signal": "Señal"
}
}

243
meshview/migrations.py Normal file
View File

@@ -0,0 +1,243 @@
"""
Database migration management for MeshView.
This module provides utilities for:
- Running Alembic migrations programmatically
- Checking database schema versions
- Coordinating migrations between writer and reader apps
"""
import asyncio
import logging
from pathlib import Path
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
from alembic import command
logger = logging.getLogger(__name__)
def get_alembic_config(database_url: str) -> Config:
"""
Get Alembic configuration with the database URL set.
Args:
database_url: SQLAlchemy database connection string
Returns:
Configured Alembic Config object
"""
# Get the alembic.ini path (in project root)
alembic_ini = Path(__file__).parent.parent / "alembic.ini"
config = Config(str(alembic_ini))
config.set_main_option("sqlalchemy.url", database_url)
return config
async def get_current_revision(engine: AsyncEngine) -> str | None:
"""
Get the current database schema revision.
Args:
engine: Async SQLAlchemy engine
Returns:
Current revision string, or None if no migrations applied
"""
async with engine.connect() as connection:
def _get_revision(conn):
context = MigrationContext.configure(conn)
return context.get_current_revision()
revision = await connection.run_sync(_get_revision)
return revision
async def get_head_revision(database_url: str) -> str | None:
"""
Get the head (latest) revision from migration scripts.
Args:
database_url: Database connection string
Returns:
Head revision string, or None if no migrations exist
"""
config = get_alembic_config(database_url)
script_dir = ScriptDirectory.from_config(config)
head = script_dir.get_current_head()
return head
async def is_database_up_to_date(engine: AsyncEngine, database_url: str) -> bool:
"""
Check if database is at the latest schema version.
Args:
engine: Async SQLAlchemy engine
database_url: Database connection string
Returns:
True if database is up to date, False otherwise
"""
current = await get_current_revision(engine)
head = await get_head_revision(database_url)
# If there are no migrations yet, consider it up to date
if head is None:
return True
return current == head
def run_migrations(database_url: str) -> None:
"""
Run all pending migrations to bring database up to date.
This is a synchronous operation that runs Alembic migrations.
Should be called by the writer app on startup.
Args:
database_url: Database connection string
"""
logger.info("Running database migrations...")
import sys
sys.stdout.flush()
config = get_alembic_config(database_url)
try:
# Run migrations to head
logger.info("Calling alembic upgrade command...")
sys.stdout.flush()
command.upgrade(config, "head")
logger.info("Database migrations completed successfully")
sys.stdout.flush()
except Exception as e:
logger.error(f"Error running migrations: {e}")
raise
async def wait_for_migrations(
engine: AsyncEngine, database_url: str, max_retries: int = 30, retry_delay: int = 2
) -> bool:
"""
Wait for database migrations to complete.
This should be called by the reader app to wait until
the database schema is up to date before proceeding.
Args:
engine: Async SQLAlchemy engine
database_url: Database connection string
max_retries: Maximum number of retry attempts
retry_delay: Seconds to wait between retries
Returns:
True if database is up to date, False if max retries exceeded
"""
for attempt in range(max_retries):
try:
if await is_database_up_to_date(engine, database_url):
logger.info("Database schema is up to date")
return True
current = await get_current_revision(engine)
head = await get_head_revision(database_url)
logger.info(
f"Database schema not up to date (current: {current}, head: {head}). "
f"Waiting... (attempt {attempt + 1}/{max_retries})"
)
await asyncio.sleep(retry_delay)
except Exception as e:
logger.warning(
f"Error checking database version (attempt {attempt + 1}/{max_retries}): {e}"
)
await asyncio.sleep(retry_delay)
logger.error(f"Database schema not up to date after {max_retries} attempts")
return False
async def create_migration_status_table(engine: AsyncEngine) -> None:
"""
Create a simple status table for migration coordination.
This table can be used to signal when migrations are in progress.
Args:
engine: Async SQLAlchemy engine
"""
async with engine.begin() as conn:
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS migration_status (
id INTEGER PRIMARY KEY CHECK (id = 1),
in_progress BOOLEAN NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
# Insert initial row if not exists
await conn.execute(
text("""
INSERT OR IGNORE INTO migration_status (id, in_progress)
VALUES (1, 0)
""")
)
async def set_migration_in_progress(engine: AsyncEngine, in_progress: bool) -> None:
"""
Set the migration in-progress flag.
Args:
engine: Async SQLAlchemy engine
in_progress: True if migration is in progress, False otherwise
"""
async with engine.begin() as conn:
await conn.execute(
text("""
UPDATE migration_status
SET in_progress = :in_progress,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
"""),
{"in_progress": in_progress},
)
async def is_migration_in_progress(engine: AsyncEngine) -> bool:
"""
Check if a migration is currently in progress.
Args:
engine: Async SQLAlchemy engine
Returns:
True if migration is in progress, False otherwise
"""
try:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT in_progress FROM migration_status WHERE id = 1")
)
row = result.fetchone()
return bool(row[0]) if row else False
except Exception:
# If table doesn't exist or query fails, assume no migration in progress
return False

View File

@@ -23,8 +23,14 @@ class Node(Base):
last_long: Mapped[int] = mapped_column(BigInteger, nullable=True)
channel: Mapped[str] = mapped_column(nullable=True)
last_update: Mapped[datetime] = mapped_column(nullable=True)
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (Index("idx_node_node_id", "node_id"),)
__table_args__ = (
Index("idx_node_node_id", "node_id"),
Index("idx_node_first_seen_us", "first_seen_us"),
Index("idx_node_last_seen_us", "last_seen_us"),
)
def to_dict(self):
return {
@@ -50,14 +56,17 @@ class Packet(Base):
)
payload: Mapped[bytes] = mapped_column(nullable=True)
import_time: Mapped[datetime] = mapped_column(nullable=True)
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
channel: Mapped[str] = mapped_column(nullable=True)
__table_args__ = (
Index("idx_packet_from_node_id", "from_node_id"),
Index("idx_packet_to_node_id", "to_node_id"),
Index("idx_packet_import_time", desc("import_time")),
Index("idx_packet_import_time_us", desc("import_time_us")),
# Composite index for /top endpoint performance - filters by from_node_id AND import_time
Index("idx_packet_from_node_time", "from_node_id", desc("import_time")),
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
)
@@ -78,11 +87,13 @@ class PacketSeen(Base):
rx_rssi: Mapped[int] = mapped_column(nullable=True)
topic: Mapped[str] = mapped_column(nullable=True)
import_time: Mapped[datetime] = mapped_column(nullable=True)
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (
Index("idx_packet_seen_node_id", "node_id"),
# Index for /top endpoint performance - JOIN on packet_id
Index("idx_packet_seen_packet_id", "packet_id"),
Index("idx_packet_seen_import_time_us", "import_time_us"),
)
@@ -98,5 +109,10 @@ 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)
route_return: Mapped[bytes] = mapped_column(nullable=True)
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (Index("idx_traceroute_import_time", "import_time"),)
__table_args__ = (
Index("idx_traceroute_import_time", "import_time"),
Index("idx_traceroute_import_time_us", "import_time_us"),
)

View File

@@ -37,6 +37,9 @@ async def process_envelope(topic, env):
await session.execute(select(Node).where(Node.node_id == node_id))
).scalar_one_or_none()
now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
if node:
node.node_id = node_id
node.long_name = map_report.long_name
@@ -47,7 +50,10 @@ async def process_envelope(topic, env):
node.last_lat = map_report.latitude_i
node.last_long = map_report.longitude_i
node.firmware = map_report.firmware_version
node.last_update = datetime.datetime.now()
node.last_update = now
node.last_seen_us = now_us
if node.first_seen_us is None:
node.first_seen_us = now_us
else:
node = Node(
id=user_id,
@@ -60,7 +66,9 @@ async def process_envelope(topic, env):
firmware=map_report.firmware_version,
last_lat=map_report.latitude_i,
last_long=map_report.longitude_i,
last_update=datetime.datetime.now(),
last_update=now,
first_seen_us=now_us,
last_seen_us=now_us,
)
session.add(node)
except Exception as e:
@@ -74,12 +82,11 @@ async def process_envelope(topic, env):
async with mqtt_database.async_session() as session:
# --- Packet insert with ON CONFLICT DO NOTHING
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
# FIXME: Not Used
# new_packet = False
packet = result.scalar_one_or_none()
if not packet:
# FIXME: Not Used
# new_packet = True
now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
stmt = (
sqlite_insert(Packet)
.values(
@@ -88,7 +95,8 @@ async def process_envelope(topic, env):
from_node_id=getattr(env.packet, "from"),
to_node_id=env.packet.to,
payload=env.packet.SerializeToString(),
import_time=datetime.datetime.now(),
import_time=now,
import_time_us=now_us,
channel=env.channel_id,
)
.on_conflict_do_nothing(index_elements=["id"])
@@ -112,6 +120,8 @@ async def process_envelope(topic, env):
)
)
if not result.scalar_one_or_none():
now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
seen = PacketSeen(
packet_id=env.packet.id,
node_id=int(env.gateway_id[1:], 16),
@@ -122,7 +132,8 @@ async def process_envelope(topic, env):
hop_limit=env.packet.hop_limit,
hop_start=env.packet.hop_start,
topic=topic,
import_time=datetime.datetime.now(),
import_time=now,
import_time_us=now_us,
)
session.add(seen)
@@ -153,6 +164,9 @@ async def process_envelope(topic, env):
await session.execute(select(Node).where(Node.id == user.id))
).scalar_one_or_none()
now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
if node:
node.node_id = node_id
node.long_name = user.long_name
@@ -160,7 +174,10 @@ async def process_envelope(topic, env):
node.hw_model = hw_model
node.role = role
node.channel = env.channel_id
node.last_update = datetime.datetime.now()
node.last_update = now
node.last_seen_us = now_us
if node.first_seen_us is None:
node.first_seen_us = now_us
else:
node = Node(
id=user.id,
@@ -170,7 +187,9 @@ async def process_envelope(topic, env):
hw_model=hw_model,
role=role,
channel=env.channel_id,
last_update=datetime.datetime.now(),
last_update=now,
first_seen_us=now_us,
last_seen_us=now_us,
)
session.add(node)
except Exception as e:
@@ -187,34 +206,32 @@ async def process_envelope(topic, env):
await session.execute(select(Node).where(Node.node_id == from_node_id))
).scalar_one_or_none()
if node:
now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
node.last_lat = position.latitude_i
node.last_long = position.longitude_i
node.last_update = now
node.last_seen_us = now_us
if node.first_seen_us is None:
node.first_seen_us = now_us
session.add(node)
# --- TRACEROUTE_APP (no conflict handling, normal insert)
if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP:
packet_id = None
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)
)
if result.scalar_one_or_none():
packet_id = env.packet.decoded.request_id
packet_id = env.packet.id
if packet_id is not None:
now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
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(),
import_time=now,
import_time_us=now_us,
)
)
await session.commit()
# if new_packet:
# await packet.awaitable_attrs.to_node
# await packet.awaitable_attrs.from_node

View File

@@ -1,100 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Config</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body { margin: 0; background: #ffffff; color: #000; }
#swagger-ui { background: #ffffff; color: #000; }
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description { color: #000 !important; }
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
.swagger-ui .opblock-section-header { color: #000 !important; }
.swagger-ui .parameters,
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
.swagger-ui table { color: #000 !important; }
.swagger-ui a { color: #1a0dab !important; }
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Site Config API",
version: "1.0.0",
description: "API for retrieving the site configuration. This endpoint does not take any parameters."
},
paths: {
"/api/config": {
get: {
summary: "Get site configuration",
description: "Returns the current site configuration object.",
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
site_config: {
type: "object",
additionalProperties: true,
example: {
site_name: "MeshView",
firehose_interval: 1000,
starting: "/nodes",
theme: "dark"
}
}
}
}
}
}
},
"500": {
description: "Server error",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: { type: "string", example: "Internal server error" }
}
}
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>

View File

@@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Edges</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body {
margin: 0;
background: #ffffff;
color: #000000;
}
#swagger-ui { background: #ffffff; color: #000; }
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description { color: #000 !important; }
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
.swagger-ui .opblock-section-header { color: #000 !important; }
.swagger-ui .parameters,
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
.swagger-ui table { color: #000 !important; }
.swagger-ui a { color: #1a0dab !important; }
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Network Edges API",
version: "1.0.0",
description: "API for retrieving network edges derived from traceroutes and neighbor info packets, with optional type filtering."
},
paths: {
"/api/edges": {
get: {
summary: "Get network edges",
description: "Returns edges between nodes in the network. Optionally filter by type (`traceroute` or `neighbor`).",
parameters: [
{
name: "type",
in: "query",
required: false,
description: "Optional filter to only return edges of this type (`traceroute` or `neighbor`).",
schema: { type: "string", enum: ["traceroute", "neighbor"] }
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
edges: {
type: "array",
items: {
type: "object",
properties: {
from: { type: "integer", example: 101 },
to: { type: "integer", example: 102 },
type: { type: "string", example: "traceroute" }
}
}
}
}
}
}
}
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>

View File

@@ -1,134 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Nodes</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body { margin: 0; background: #ffffff; color: #000; }
#swagger-ui { background: #ffffff; color: #000; }
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description { color: #000 !important; }
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
.swagger-ui .opblock-section-header { color: #000 !important; }
.swagger-ui .parameters,
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
.swagger-ui table { color: #000 !important; }
.swagger-ui a { color: #1a0dab !important; }
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Network Nodes API",
version: "1.0.0",
description: "API for retrieving nodes in the network with optional filters by last seen time."
},
paths: {
"/api/nodes": {
get: {
summary: "Get network nodes",
description: "Returns a list of nodes with optional filtering by recent activity.",
parameters: [
{
name: "hours",
in: "query",
required: false,
description: "Return nodes seen in the last X hours.",
schema: { type: "integer", example: 24 }
},
{
name: "days",
in: "query",
required: false,
description: "Return nodes seen in the last X days.",
schema: { type: "integer", example: 7 }
},
{
name: "last_seen_after",
in: "query",
required: false,
description: "Return nodes last seen after this ISO8601 timestamp.",
schema: { type: "string", format: "date-time", example: "2025-08-25T14:00:00" }
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
nodes: {
type: "array",
items: {
type: "object",
properties: {
node_id: { type: "integer", example: 101 },
long_name: { type: "string", example: "Node Alpha" },
short_name: { type: "string", example: "A" },
channel: { type: "string", example: "2" },
last_seen: { type: "string", format: "date-time", example: "2025-08-25T12:00:00" },
last_lat: { type: "number", format: "float", example: 37.7749 },
last_long: { type: "number", format: "float", example: -122.4194 },
hardware: { type: "string", example: "Heltec V3" },
firmware: { type: "string", example: "1.0.5" },
role: { type: "string", example: "router" }
}
}
}
}
}
}
}
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
},
"500": {
description: "Server error",
content: {
"application/json": {
schema: { type: "object", properties: { error: { type: "string" } } }
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>

View File

@@ -1,167 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Packets</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body {
margin: 0;
background: #ffffff;
color: #000000;
}
#swagger-ui {
background: #ffffff;
color: #000000;
}
/* Override Swagger UI colors for white background */
.swagger-ui {
background-color: #ffffff !important;
color: #000000 !important;
}
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description {
color: #000000 !important;
}
.swagger-ui .opblock {
background-color: #f9f9f9 !important;
border-color: #ddd !important;
}
.swagger-ui .opblock-summary {
background-color: #eaeaea !important;
color: #000 !important;
}
.swagger-ui .opblock-section-header {
color: #000 !important;
}
.swagger-ui .parameters,
.swagger-ui .response {
background-color: #fafafa !important;
color: #000 !important;
}
.swagger-ui table {
color: #000 !important;
}
.swagger-ui a {
color: #1a0dab !important; /* classic link blue */
}
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea {
background-color: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Packets API",
version: "1.0.0",
description: "API for retrieving packet records with optional filters."
},
paths: {
"/api/packets": {
get: {
summary: "Get packets",
description: "Returns a list of recent packets, optionally filtered by a timestamp and limited by count.",
parameters: [
{
name: "limit",
in: "query",
required: false,
description: "Maximum number of packets to return. Default is 200.",
schema: {
type: "integer",
default: 200
}
},
{
name: "since",
in: "query",
required: false,
description: "Only return packets imported after this ISO8601 timestamp (e.g., `2025-08-12T14:15:20`).",
schema: {
type: "string",
format: "date-time"
}
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
packets: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer", example: 196988973 },
from_node_id: { type: "integer", example: 2381019191 },
to_node_id: { type: "integer", example: 1234567890 },
portnum: { type: "integer", example: 1 },
import_time: { type: "string", format: "date-time", example: "2025-08-12T14:15:20.503827" },
payload: { type: "string", example: "Hello Mesh" }
}
}
}
}
}
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: { type: "string", example: "Failed to fetch packets" }
}
}
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>

View File

@@ -1,210 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation - Packet Stats</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body {
margin: 0;
background: #ffffff;
color: #000000;
}
#swagger-ui {
background: #ffffff;
color: #000000;
}
/* Override Swagger UI colors for white background */
.swagger-ui {
background-color: #ffffff !important;
color: #000000 !important;
}
.swagger-ui .topbar,
.swagger-ui .info,
.swagger-ui .opblock-summary-description,
.swagger-ui .parameters-col_description,
.swagger-ui .response-col_description {
color: #000000 !important;
}
.swagger-ui .opblock {
background-color: #f9f9f9 !important;
border-color: #ddd !important;
}
.swagger-ui .opblock-summary {
background-color: #eaeaea !important;
color: #000 !important;
}
.swagger-ui .opblock-section-header {
color: #000 !important;
}
.swagger-ui .parameters,
.swagger-ui .response {
background-color: #fafafa !important;
color: #000 !important;
}
.swagger-ui table {
color: #000 !important;
}
.swagger-ui a {
color: #1a0dab !important; /* classic link blue */
}
.swagger-ui input,
.swagger-ui select,
.swagger-ui textarea {
background-color: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
const spec = {
openapi: "3.0.0",
info: {
title: "Packet Statistics API",
version: "1.0.0",
description: "API for retrieving packet statistics over a given period with optional filters."
},
paths: {
"/api/stats": {
get: {
summary: "Get packet statistics",
description: "Returns packet statistics for a given period type and length, with optional filters.",
parameters: [
{
name: "period_type",
in: "query",
required: false,
description: "Type of period to group by (`hour` or `day`). Default is `hour`.",
schema: {
type: "string",
enum: ["hour", "day"]
}
},
{
name: "length",
in: "query",
required: false,
description: "Number of periods to include. Default is 24.",
schema: {
type: "integer",
default: 24
}
},
{
name: "channel",
in: "query",
required: false,
description: "Filter by channel name.",
schema: {
type: "string"
}
},
{
name: "portnum",
in: "query",
required: false,
description: "Filter by port number.",
schema: {
type: "integer"
}
},
{
name: "to_node",
in: "query",
required: false,
description: "Filter by destination node ID.",
schema: {
type: "integer"
}
},
{
name: "from_node",
in: "query",
required: false,
description: "Filter by source node ID.",
schema: {
type: "integer"
}
}
],
responses: {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
hourly: {
type: "object",
properties: {
period_type: { type: "string" },
length: { type: "integer" },
filters: { type: "object" },
data: {
type: "array",
items: {
type: "object",
properties: {
period: { type: "string", example: "2025-08-06 19:00" },
node_id: { type: "integer" },
long_name: { type: "string" },
short_name: { type: "string" },
packets: { type: "integer" }
}
}
}
}
}
}
}
}
}
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: { type: "string" }
}
}
}
}
}
}
}
}
}
};
window.onload = () => {
SwaggerUIBundle({
spec,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
};
</script>
</body>
</html>

View File

@@ -1,91 +1,273 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Index</title>
<style>
<meta charset="utf-8">
<title>Meshview API Documentation</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #1e1e1e;
color: #eaeaea;
margin: 0;
padding: 0;
background: #121212;
color: #eee;
font-family: monospace;
margin: 20px;
line-height: 1.5;
}
header {
background: #2a2a2a;
padding: 20px;
text-align: center;
font-size: 1.6em;
font-weight: bold;
h1, h2, h3 { color: #79c0ff; }
code {
background: #1e1e1e;
padding: 3px 6px;
border-radius: 4px;
color: #ffd479;
font-size: 0.95rem;
}
.container {
max-width: 800px;
margin: 30px auto;
padding: 20px;
.endpoint {
border: 1px solid #333;
padding: 12px;
margin-bottom: 18px;
border-radius: 8px;
background: #1a1a1a;
}
ul {
list-style: none;
padding: 0;
.method {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
margin-right: 6px;
font-weight: bold;
}
li {
background: #272b2f;
border: 1px solid #474b4e;
padding: 15px 20px;
margin-bottom: 15px;
border-radius: 10px;
transition: background 0.2s;
.get { background: #0066cc; }
.path { font-weight: bold; color: #fff; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
li:hover {
background: #33383d;
th, td {
border: 1px solid #444;
padding: 6px 10px;
}
a {
color: #4cafef;
text-decoration: none;
font-weight: bold;
font-size: 1.1em;
th {
background: #222;
color: #9ddcff;
}
p {
margin: 8px 0 0 0;
font-size: 0.9em;
color: #bbbbbb;
.example {
margin-top: 10px;
padding: 10px;
background: #161616;
border-radius: 6px;
border: 1px solid #333;
}
</style>
</style>
</head>
<body>
<header>
API Index
</header>
<h1>Meshview API Documentation</h1>
<p>This page describes all REST endpoints provided by Meshview.</p>
<div class="container">
<ul>
<li>
<a href="/api-chat">Chat API</a>
<p> View chat messages.</p>
</li>
<li>
<a href="/api-nodes">Node API</a>
<p>Retrieve node information.</p>
</li>
<li>
<a href="/api-packets">Packet API</a>
<p>Access raw packet data.</p>
</li>
<li>
<a href="/api-stats">Statistics API </a>
<p>View system and traffic statistics.</p>
</li>
<li>
<a href="/api-edges">Edges API</a>
<p>Get edges details.</p>
</li>
<li>
<a href="/api-config">Configuration API</a>
<p>Get and update configuration details.</p>
</li>
</ul>
<!------------------------------ NODES ------------------------------>
<h2>/api/nodes</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/nodes</span>
<p>Returns a list of mesh nodes.</p>
<h3>Query Parameters</h3>
<table>
<tr><th>Parameter</th><th>Description</th></tr>
<tr><td>role</td><td>Filter by node role</td></tr>
<tr><td>channel</td><td>Filter by channel</td></tr>
<tr><td>hw_model</td><td>Hardware model filter</td></tr>
<tr><td>days_active</td><td>Only nodes seen within X days</td></tr>
</table>
<div class="example">
<b>Example:</b><br>
<code>/api/nodes?days_active=3</code>
</div>
</div>
<!------------------------------ PACKETS ------------------------------>
<h2>/api/packets</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/packets</span>
<p>Fetch packets with many filters. Returns decoded packet data.</p>
<h3>Query Parameters</h3>
<table>
<tr><th>Parameter</th><th>Description</th></tr>
<tr><td>packet_id</td><td>Return exactly one packet</td></tr>
<tr><td>limit</td><td>Max number of results (1100)</td></tr>
<tr><td>since</td><td>Only packets newer than import_time_us</td></tr>
<tr><td>from_node_id</td><td>Filter by sender node</td></tr>
<tr><td>to_node_id</td><td>Filter by destination node</td></tr>
<tr><td>node_id</td><td>Legacy: match either from or to</td></tr>
<tr><td>portnum</td><td>Filter by port number</td></tr>
<tr><td>contains</td><td>Substring filter for payload</td></tr>
</table>
<div class="example">
<b>Example:</b><br>
<code>/api/packets?from_node_id=123&limit=100</code>
</div>
</div>
<!------------------------------ PACKETS SEEN ------------------------------>
<h2>/api/packets_seen/{packet_id}</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/packets_seen/&lt;packet_id&gt;</span>
<p>Returns list of gateways that heard the packet (RSSI/SNR/hops).</p>
<div class="example">
<b>Example:</b><br>
<code>/api/packets_seen/3314808102</code>
</div>
</div>
<!------------------------------ STATS ------------------------------>
<h2>/api/stats</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/stats</span>
<p>Returns aggregated packet statistics for a node or globally.</p>
<h3>Query Parameters</h3>
<table>
<tr><th>Parameter</th><th>Description</th></tr>
<tr><td>period_type</td><td>"hour" or "day"</td></tr>
<tr><td>length</td><td>How many hours/days</td></tr>
<tr><td>node</td><td>Node ID for combined sent+seen stats</td></tr>
<tr><td>from_node</td><td>Filter by sender</td></tr>
<tr><td>to_node</td><td>Filter by receiver</td></tr>
<tr><td>portnum</td><td>Filter by port</td></tr>
<tr><td>channel</td><td>Filter by channel</td></tr>
</table>
<div class="example">
<b>Example:</b><br>
<code>/api/stats?node=1128180332&period_type=day&length=1</code>
</div>
</div>
<!------------------------------ STATS COUNT ------------------------------>
<h2>/api/stats/count</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/stats/count</span>
<p>
Returns <b>total packets</b> and <b>total packet_seen entries</b>.
When no filters are provided, it returns global totals.
When filters are specified, they narrow the time, channel,
direction, or specific packet.
</p>
<h3>Query Parameters</h3>
<table>
<tr><th>Parameter</th><th>Description</th></tr>
<tr><td>period_type</td><td>"hour" or "day"</td></tr>
<tr><td>length</td><td>Number of hours or days (depends on period_type)</td></tr>
<tr><td>channel</td><td>Filter by channel</td></tr>
<tr><td>from_node</td><td>Only packets sent by this node</td></tr>
<tr><td>to_node</td><td>Only packets received by this node</td></tr>
<tr><td>packet_id</td><td>Filter seen counts for specific packet_id</td></tr>
</table>
<div class="example">
<b>Example:</b><br>
<code>/api/stats/count?from_node=1128180332&period_type=day&length=1</code>
</div>
</div>
<!------------------------------ EDGES ------------------------------>
<h2>/api/edges</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/edges</span>
<p>Returns traceroute and/or neighbor edges for graph rendering.</p>
<h3>Query Parameters</h3>
<table>
<tr><th>Parameter</th><th>Description</th></tr>
<tr><td>type</td><td>"traceroute", "neighbor", or omitted for both</td></tr>
</table>
<div class="example">
<b>Example:</b><br>
<code>/api/edges?type=neighbor</code>
</div>
</div>
<!------------------------------ CONFIG ------------------------------>
<h2>/api/config</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/config</span>
<p>Returns Meshview configuration (site, MQTT, cleanup, etc.).</p>
<div class="example">
<code>/api/config</code>
</div>
</div>
<!------------------------------ LANG ------------------------------>
<h2>/api/lang</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/api/lang</span>
<p>Returns translated text for the UI.</p>
<h3>Parameters</h3>
<table>
<tr><th>lang</th><td>Language code (e.g. "en")</td></tr>
<tr><th>section</th><td>Optional UI section (firehose, map, top...)</td></tr>
</table>
<div class="example">
<code>/api/lang?lang=en&section=firehose</code>
</div>
</div>
<!------------------------------ HEALTH ------------------------------>
<h2>/health</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/health</span>
<p>Returns API + database status.</p>
</div>
<!------------------------------ VERSION ------------------------------>
<h2>/version</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/version</span>
<p>Returns Meshview version and Git revision.</p>
</div>
<br><br>
<hr>
<p style="text-align:center; color:#666;">Meshview API — generated documentation</p>
</body>
</html>

View File

@@ -190,32 +190,51 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
activeBlinks.set(marker,interval);
}
let lastImportTime=null;
let lastImportTimeUs = null;
async function fetchLatestPacket(){
try{
const res=await fetch(`/api/packets?limit=1`);
const data=await res.json();
lastImportTime=data.packets?.[0]?.import_time || new Date().toISOString();
}catch(err){ console.error(err); }
const res = await fetch(`/api/packets?limit=1`);
const data = await res.json();
lastImportTimeUs = data.packets?.[0]?.import_time_us || 0;
}catch(err){
console.error(err);
}
}
async function fetchNewPackets(){
if(!lastImportTime) return;
if (!lastImportTimeUs) return;
try{
const res=await fetch(`/api/packets?since=${lastImportTime}`);
const data=await res.json();
const res = await fetch(`/api/packets?since=${lastImportTimeUs}`);
const data = await res.json();
if(!data.packets || !data.packets.length) return;
let latest=lastImportTime;
data.packets.forEach(packet=>{
if(packet.import_time && packet.import_time>latest) latest=packet.import_time;
const marker=markerById[packet.from_node_id];
const nodeData=nodeMap.get(packet.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
let latest = lastImportTimeUs;
data.packets.forEach(packet => {
// Track newest microsecond timestamp
if (packet.import_time_us && packet.import_time_us > latest) {
latest = packet.import_time_us;
}
// Look up marker and blink it
const marker = markerById[packet.from_node_id];
const nodeData = nodeMap.get(packet.from_node_id);
if (marker && nodeData) {
blinkNode(marker, nodeData.long_name, packet.portnum);
}
});
lastImportTime=latest;
}catch(err){ console.error(err); }
lastImportTimeUs = latest;
}catch(err){
console.error(err);
}
}
if(mapInterval>0){ fetchLatestPacket(); setInterval(fetchNewPackets,mapInterval*1000); }
})();

View File

@@ -1,209 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Mesh Nodes Live Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
body { margin: 0; }
#map { height: 100vh; width: 100%; }
#legend {
position: absolute; bottom: 10px; right: 10px;
background: rgba(0,0,0,0.7);
color: white; padding: 8px 12px;
font-family: monospace; font-size: 13px;
border-radius: 5px; z-index: 1000;
}
.legend-item { display: flex; align-items: center; margin-bottom: 4px; }
.legend-color { width: 16px; height: 16px; margin-right: 6px; border-radius: 4px; }
.pulse-label span {
background: rgba(0,0,0,0.6);
padding: 2px 4px;
border-radius: 3px;
pointer-events: none;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="legend"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map("map");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(map);
const nodeMarkers = new Map();
let lastPacketTime = null;
const portColors = {
1:"red",
67:"cyan",
3:"orange",
70:"purple",
4:"yellow",
71:"brown",
73:"pink"
};
const portLabels = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
};
function getPulseColor(portnum) { return portColors[portnum] || "green"; }
// Legend
const legend = document.getElementById("legend");
for (const [port, color] of Object.entries(portColors)) {
const item = document.createElement("div");
item.className = "legend-item";
const colorBox = document.createElement("div");
colorBox.className = "legend-color";
colorBox.style.background = color;
const label = document.createElement("span");
label.textContent = `${portLabels[port] || "Custom"} (${port})`;
item.appendChild(colorBox);
item.appendChild(label);
legend.appendChild(item);
}
// Pulse marker
function pulseMarker(marker, highlightColor = "red") {
if (!marker || marker.activePulse) return;
marker.activePulse = true;
const originalColor = marker.options.originalColor;
const originalRadius = marker.options.originalRadius;
marker.bringToFront();
const nodeInfo = marker.options.nodeInfo || {};
const portLabel = marker.currentPortLabel || "";
const displayName = `${nodeInfo.long_name || nodeInfo.short_name || "Unknown"}${portLabel ? ` (<i>${portLabel}</i>)` : ""}`;
marker.bindTooltip(displayName, {
permanent: true,
direction: 'top',
className: 'pulse-label',
offset: [0, -10],
html: true
}).openTooltip();
const flashDuration = 2000, fadeDuration = 1000, flashInterval = 100, maxRadius = originalRadius + 5;
let flashTime = 0;
const flashTimer = setInterval(() => {
flashTime += flashInterval;
const isOn = (flashTime / flashInterval) % 2 === 0;
marker.setStyle({ fillColor: isOn ? highlightColor : originalColor, radius: isOn ? maxRadius : originalRadius });
if (flashTime >= flashDuration) {
clearInterval(flashTimer);
const fadeStart = performance.now();
function fade(now) {
const t = Math.min((now - fadeStart) / fadeDuration, 1);
const radius = originalRadius + (maxRadius - originalRadius) * (1 - t);
marker.setStyle({ fillColor: highlightColor, radius: radius, fillOpacity: 1 });
if (t < 1) requestAnimationFrame(fade);
else {
marker.setStyle({ fillColor: originalColor, radius: originalRadius, fillOpacity: 1 });
marker.unbindTooltip();
marker.activePulse = false;
}
}
requestAnimationFrame(fade);
}
}, flashInterval);
}
// --- Load nodes ---
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
const nodes = data.nodes || [];
nodes.forEach(node => {
const lat = node.last_lat / 1e7;
const lng = node.last_long / 1e7;
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
const color = "blue";
const marker = L.circleMarker([lat,lng], {
radius:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7
}).addTo(map);
marker.options.originalColor = color;
marker.options.originalRadius = 7;
marker.options.nodeInfo = node;
marker.bindPopup(`<b>${node.long_name||node.short_name||"Unknown"}</b><br>ID: ${node.node_id}<br>Role: ${node.role}`);
nodeMarkers.set(node.node_id, marker);
} else {
nodeMarkers.set(node.node_id, {options:{nodeInfo:node}});
}
});
const markersWithCoords = Array.from(nodeMarkers.values()).filter(m=>m instanceof L.CircleMarker);
if(markersWithCoords.length>0) await setMapBoundsFromConfig();
else map.setView([37.77,-122.42],9);
} catch(err){
console.error("Failed to load nodes:", err);
}
}
// --- Map bounds ---
async function setMapBoundsFromConfig() {
try {
const res = await fetch("/api/config");
const config = await res.json();
const topLat = parseFloat(config.site.map_top_left_lat);
const topLon = parseFloat(config.site.map_top_left_lon);
const bottomLat = parseFloat(config.site.map_bottom_right_lat);
const bottomLon = parseFloat(config.site.map_bottom_right_lon);
if ([topLat, topLon, bottomLat, bottomLon].some(v => isNaN(v))) {
throw new Error("Map bounds contain NaN");
}
map.fitBounds([[topLat, topLon], [bottomLat, bottomLon]]);
} catch(err) {
console.error("Failed to load map bounds from config:", err);
map.setView([37.77,-122.42],9);
}
}
// --- Poll packets ---
async function pollPackets() {
try {
let url = "/api/packets?limit=10";
if (lastPacketTime) url += `&since=${lastPacketTime}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
const packets = data.packets || [];
if (packets.length) lastPacketTime = packets[0].import_time;
packets.forEach(pkt => {
const marker = nodeMarkers.get(pkt.from_node_id);
if (marker instanceof L.CircleMarker) { // only real markers
marker.currentPortLabel = portLabels[pkt.portnum] || `${pkt.portnum}`;
pulseMarker(marker, getPulseColor(pkt.portnum));
}
});
} catch(err){
console.error("Failed to fetch packets:", err);
}
}
// --- Initialize ---
loadNodes().then(() => setInterval(pollPackets, 1000));
</script>
</body>
</html>

View File

@@ -1,9 +1,8 @@
from datetime import datetime, timedelta
from sqlalchemy import func, select, text
from sqlalchemy import select, and_, or_, func, cast, Text
from sqlalchemy.orm import lazyload
from meshview import database
from meshview import database, models
from meshview.models import Node, Packet, PacketSeen, Traceroute
@@ -24,27 +23,63 @@ async def get_fuzzy_nodes(query):
return result.scalars()
async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None):
async def get_packets(
from_node_id=None,
to_node_id=None,
node_id=None, # legacy
portnum=None,
after=None,
contains=None, # substring search
limit=50,
):
async with database.async_session() as session:
q = select(Packet)
stmt = select(models.Packet)
conditions = []
if node_id:
q = q.where((Packet.from_node_id == node_id) | (Packet.to_node_id == node_id))
if portnum:
q = q.where(Packet.portnum == portnum)
if after:
q = q.where(Packet.import_time > after)
if before:
q = q.where(Packet.import_time < before)
# Strict FROM filter
if from_node_id is not None:
conditions.append(models.Packet.from_node_id == from_node_id)
q = q.order_by(Packet.import_time.desc())
# Strict TO filter
if to_node_id is not None:
conditions.append(models.Packet.to_node_id == to_node_id)
if limit is not None:
q = q.limit(limit)
# Legacy node_id (either direction)
if node_id is not None:
conditions.append(
or_(
models.Packet.from_node_id == node_id,
models.Packet.to_node_id == node_id,
)
)
result = await session.execute(q)
packets = list(result.scalars())
return packets
# Port filter
if portnum is not None:
conditions.append(models.Packet.portnum == portnum)
# Timestamp filter using microseconds
if after is not None:
conditions.append(models.Packet.import_time_us > after)
# Case-insensitive substring search on payload (BLOB → TEXT)
if contains:
contains_lower = f"%{contains.lower()}%"
payload_text = cast(models.Packet.payload, Text)
conditions.append(func.lower(payload_text).like(contains_lower))
# Apply WHERE conditions
if conditions:
stmt = stmt.where(and_(*conditions))
# Order by newest first
stmt = stmt.order_by(models.Packet.import_time_us.desc())
# Limit
stmt = stmt.limit(limit)
# Run query
result = await session.execute(stmt)
return result.scalars().all()
async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
@@ -68,21 +103,6 @@ async def get_packet(packet_id):
return result.scalar_one_or_none()
async def get_uplinked_packets(node_id, portnum=None):
async with database.async_session() as session:
q = (
select(Packet)
.join(PacketSeen)
.where(PacketSeen.node_id == node_id)
.order_by(Packet.import_time.desc())
.limit(500)
)
if portnum:
q = q.where(Packet.portnum == portnum)
result = await session.execute(q)
return result.scalars()
async def get_packets_seen(packet_id):
async with database.async_session() as session:
result = await session.execute(
@@ -145,23 +165,6 @@ async def get_mqtt_neighbors(since):
return result
# We count the total amount of packages
# This is to be used by /stats in web.py
async def get_total_packet_count():
async with database.async_session() as session:
q = select(func.count(Packet.id)) # Use SQLAlchemy's func to count packets
result = await session.execute(q)
return result.scalar() # Return the total count of packets
# We count the total amount of seen packets
async def get_total_packet_seen_count():
async with database.async_session() as session:
q = select(func.count(PacketSeen.node_id)) # Use SQLAlchemy's func to count nodes
result = await session.execute(q)
return result.scalar() # Return the` total count of seen packets
async def get_total_node_count(channel: str = None) -> int:
try:
async with database.async_session() as session:
@@ -257,11 +260,12 @@ async def get_node_traffic(node_id: int):
return []
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_active=None):
"""
Fetches nodes from the database based on optional filtering criteria.
Parameters:
node_id
role (str, optional): The role of the node (converted to uppercase for consistency).
channel (str, optional): The communication channel associated with the node.
hw_model (str, optional): The hardware model of the node.
@@ -277,6 +281,8 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
query = select(Node)
# Apply filters based on provided parameters
if node_id is not None:
query = query.where(Node.node_id == node_id)
if role is not None:
query = query.where(Node.role == role.upper()) # Ensure role is uppercase
if channel is not None:
@@ -356,27 +362,155 @@ async def get_packet_stats(
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.
Returns a sorted 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()
now_us = int(datetime.utcnow().timestamp() * 1_000_000)
if period_type == "hour":
start_time = now - timedelta(hours=length)
delta_us = length * 3600 * 1_000_000
elif period_type == "day":
start_time = now - timedelta(days=length)
delta_us = length * 86400 * 1_000_000
else:
raise ValueError("period_type must be 'hour' or 'day'")
start_us = now_us - delta_us
async with database.async_session() as session:
q = (
stmt = (
select(Packet.channel)
.where(Packet.import_time >= start_time)
.where(Packet.import_time_us >= start_us)
.distinct()
.order_by(Packet.channel)
)
result = await session.execute(q)
channels = [row[0] for row in result if row[0] is not None]
result = await session.execute(stmt)
channels = [ch for ch in result.scalars().all() if ch is not None]
return channels
async def get_total_packet_count(
period_type: str | None = None,
length: int | None = None,
channel: str | None = None,
from_node: int | None = None,
to_node: int | None = None,
):
"""
Count total packets, with ALL filters optional.
If no filters -> return ALL packets ever.
Uses import_time_us (microseconds).
"""
# CASE 1: no filters -> count everything
if (
period_type is None
and length is None
and channel is None
and from_node is None
and to_node is None
):
async with database.async_session() as session:
q = select(func.count(Packet.id))
res = await session.execute(q)
return res.scalar() or 0
# CASE 2: filtered mode -> compute time window using import_time_us
now_us = int(datetime.now().timestamp() * 1_000_000)
if period_type is None:
period_type = "day"
if length is None:
length = 1
if period_type == "hour":
start_time_us = now_us - (length * 3600 * 1_000_000)
elif period_type == "day":
start_time_us = now_us - (length * 86400 * 1_000_000)
else:
raise ValueError("period_type must be 'hour' or 'day'")
async with database.async_session() as session:
q = select(func.count(Packet.id)).where(Packet.import_time_us >= start_time_us)
if channel:
q = q.where(func.lower(Packet.channel) == channel.lower())
if from_node:
q = q.where(Packet.from_node_id == from_node)
if to_node:
q = q.where(Packet.to_node_id == to_node)
res = await session.execute(q)
return res.scalar() or 0
async def get_total_packet_seen_count(
packet_id: int | None = None,
period_type: str | None = None,
length: int | None = None,
channel: str | None = None,
from_node: int | None = None,
to_node: int | None = None,
):
"""
Count total PacketSeen rows.
- If packet_id is provided -> count only that packet's seen entries.
- Otherwise match EXACT SAME FILTERS as get_total_packet_count.
Uses import_time_us for time window.
"""
# SPECIAL CASE: direct packet_id lookup
if packet_id is not None:
async with database.async_session() as session:
q = select(func.count(PacketSeen.packet_id)).where(PacketSeen.packet_id == packet_id)
res = await session.execute(q)
return res.scalar() or 0
# No filters -> return ALL seen entries
if (
period_type is None
and length is None
and channel is None
and from_node is None
and to_node is None
):
async with database.async_session() as session:
q = select(func.count(PacketSeen.packet_id))
res = await session.execute(q)
return res.scalar() or 0
# Compute time window
now_us = int(datetime.now().timestamp() * 1_000_000)
if period_type is None:
period_type = "day"
if length is None:
length = 1
if period_type == "hour":
start_time_us = now_us - (length * 3600 * 1_000_000)
elif period_type == "day":
start_time_us = now_us - (length * 86400 * 1_000_000)
else:
raise ValueError("period_type must be 'hour' or 'day'")
# JOIN Packet so we can apply identical filters
async with database.async_session() as session:
q = (
select(func.count(PacketSeen.packet_id))
.join(Packet, Packet.id == PacketSeen.packet_id)
.where(Packet.import_time_us >= start_time_us)
)
if channel:
q = q.where(func.lower(Packet.channel) == channel.lower())
if from_node:
q = q.where(Packet.from_node_id == from_node)
if to_node:
q = q.where(Packet.to_node_id == to_node)
res = await session.execute(q)
return res.scalar() or 0

View File

@@ -6,11 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Scripts -->
<script src="https://unpkg.com/htmx.org@1.9.11" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.11/dist/ext/sse.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- Stylesheets -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
@@ -25,181 +21,182 @@
body.ready {
opacity: 1;
}
.htmx-indicator { opacity: 0; transition: opacity 500ms ease-in; }
.htmx-request .htmx-indicator { opacity: 1; }
#search_form { z-index: 4000; }
#details_map { width: 100%; height: 500px; }
.htmx-indicator {
opacity: 0;
transition: opacity 500ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
#search_form {
z-index: 4000;
}
#details_map {
width: 100%;
height: 500px;
}
{% block css %}{% endblock %}
</style>
</head>
<body>
<br>
<div style="text-align:center" id="site-header"></div>
<div style="text-align:center" id="site-message"></div>
<div style="text-align:center" id="site-menu"></div>
<br>
<div style="text-align:center" id="site-header"></div>
<div style="text-align:center" id="site-message"></div>
<div style="text-align:center" id="site-menu"></div>
<!-- Search Form -->
<form class="container p-2 sticky-top mx-auto" id="search_form" action="/node_search">
<div class="row">
<input
class="col m-2"
id="q"
type="text"
name="q"
data-translate-lang="node id"
placeholder="Node id"
autocomplete="off"
list="node_options"
value="{{raw_node_id}}"
hx-trigger="input delay:100ms"
hx-get="/node_match"
hx-target="#node_options"
/>
<datalist id="node_options">
{% for option in node_options %}
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
{% endfor %}
</datalist>
{% set options = {
1: "Text Message",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info",
}
%}
<select name="portnum" class="col-2 m-2">
<option
value = ""
{% if portnum not in options %}selected{% endif %}
>All</option>
{% for value, name in options.items() %}
<option
value="{{value}}"
{% if value == portnum %}selected{% endif %}
>{{ name }}</option>
{% endfor %}
</select>
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate-lang="go to node" />
</div>
</form>
<br>
{% block body %}{% endblock %}
{% block body %}{% endblock %}
<br>
<div style="text-align:center" id="footer" data-translate-lang="footer"></div>
<div style="text-align:center"><small id="site-version">ver. unknown</small></div>
<br>
<br>
<div style="text-align:center" id="footer" data-translate="footer"></div>
<div style="text-align:center"><small id="site-version">ver. unknown</small></div>
<br>
<!-- Shared Site Config & Language Loader -->
<script>
// --- Global Promises ---
if (!window._langPromise) {
window._langPromise = (async () => {
try {
const res = await fetch("/api/lang");
const lang = await res.json();
window._lang = lang;
console.log("Loaded language from /api/lang:", lang);
return lang;
} catch (err) {
console.error("Failed to load /api/lang:", err);
return {};
}
})();
}
if (!window._siteConfigPromise) {
window._siteConfigPromise = (async () => {
try {
const res = await fetch("/api/config");
const config = await res.json();
window._siteConfig = config;
console.log("Loaded config from /api/config:", config);
return config;
} catch (err) {
console.error("Failed to load /api/config:", err);
return {};
}
})();
}
// --- Apply Translations ---
function applyTranslations(lang) {
if (!lang || !lang.base) return;
const base = lang.base;
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
const translation = base[key];
if (!translation) return;
if (el.placeholder) {
el.placeholder = translation;
} else if (key === "footer") {
el.innerHTML = translation; // allow HTML links in footer
} else if (el.value && el.tagName === "INPUT") {
el.value = translation;
} else {
el.textContent = translation;
}
});
}
async function initializePage() {
<script>
// --- Shared Promises ---
if (!window._siteConfigPromise) {
window._siteConfigPromise = (async () => {
try {
const [lang, cfg] = await Promise.all([
window._langPromise,
window._siteConfigPromise
]);
const res = await fetch("/api/config");
const cfg = await res.json();
window._siteConfig = cfg;
console.log("Loaded config:", cfg);
return cfg;
} catch (err) {
console.error("Failed to load /api/config:", err);
return {};
}
})();
}
// --- Load language AFTER config ---
if (!window._langPromise) {
window._langPromise = (async () => {
try {
const cfg = await window._siteConfigPromise;
const site = cfg.site || {};
const base = (lang && lang.base) || {};
const userLang = site.language || "en";
const section = "base";
// --- Title ---
document.title = "Meshview - " + (site.title || "");
const url = `/api/lang?lang=${userLang}&section=${section}`;
const res = await fetch(url);
const lang = await res.json();
// --- Header ---
const header = document.getElementById("site-header");
if (header)
header.innerHTML = `<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
window._lang = lang;
console.log(`Loaded language (${userLang}):`, lang);
// --- Message ---
const msg = document.getElementById("site-message");
if (msg) msg.textContent = site.message || "";
return lang;
} catch (err) {
console.error("Failed to load language:", err);
return {};
}
})();
}
// --- Menu ---
const menu = document.getElementById("site-menu");
if (menu) {
let html = "";
if (site.nodes === "true") html += `<a href="/nodelist">${base.nodes || "Nodes"}</a>`;
if (site.conversations === "true") html += `&nbsp;-&nbsp;<a href="/chat">${base.conversations || "Conversations"}</a>`;
if (site.everything === "true") html += `&nbsp;-&nbsp;<a href="/firehose">${base.everything || "See Everything"}</a>`;
if (site.graphs === "true") html += `&nbsp;-&nbsp;<a href="/nodegraph">${base.graph || "Mesh Graphs"}</a>`;
if (site.net === "true") html += `&nbsp;-&nbsp;<a href="/net">${base.net || "Weekly Net"}</a>`;
if (site.map === "true") html += `&nbsp;-&nbsp;<a href="/map">${base.map || "Live Map"}</a>`;
if (site.stats === "true") html += `&nbsp;-&nbsp;<a href="/stats">${base.stats || "Stats"}</a>`;
if (site.top === "true") html += `&nbsp;-&nbsp;<a href="/top">${base.top || "Top Traffic Nodes"}</a>`;
menu.innerHTML = html;
// --- Translation Helper ---
function applyTranslations(dict) {
document.querySelectorAll("[data-translate]").forEach(el => {
const key = el.dataset.translate;
const value = dict[key];
if (!value) return;
if (el.placeholder) {
el.placeholder = value;
} else if (el.tagName === "INPUT" && el.value) {
el.value = value;
} else if (key === "footer") {
el.innerHTML = value;
} else {
el.textContent = value;
}
});
}
// --- Fill portnum select dynamically ---
function fillPortnumSelect(dict, selectedValue) {
const sel = document.getElementById("portnum_select");
if (!sel) return;
const portOptions = dict.portnum_options || {};
sel.innerHTML = "";
const allOption = document.createElement("option");
allOption.value = "";
allOption.textContent = dict.all || "All";
if (!selectedValue) allOption.selected = true;
sel.appendChild(allOption);
for (const [val, label] of Object.entries(portOptions)) {
const opt = document.createElement("option");
opt.value = val;
opt.textContent = label;
if (parseInt(val) === parseInt(selectedValue)) {
opt.selected = true;
}
sel.appendChild(opt);
}
}
// --- Main Init ---
async function initializePage() {
try {
const [cfg, lang] = await Promise.all([
window._siteConfigPromise,
window._langPromise
]);
const dict = lang || {};
const site = cfg.site || {};
// Title
document.title = "Meshview - " + (site.title || "");
// Header & Message
document.getElementById("site-header").innerHTML =
`<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
document.getElementById("site-message").textContent = site.message || "";
// Menu
const menu = document.getElementById("site-menu");
if (menu) {
const items = [];
const keys = ["nodes", "chat", "everything", "graphs", "net", "map", "stats", "top"];
const urls = ["/nodelist", "/chat", "/firehose", "/nodegraph", "/net", "/map", "/stats", "/top"];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (site[key] === "true") {
items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`);
}
}
// --- Version ---
const verEl = document.getElementById("site-version");
if (verEl) verEl.textContent = "ver. " + (site.version || "unknown");
// --- Apply translations to placeholders and footer ---
applyTranslations(lang);
// --- Fade in ---
document.body.classList.add("ready");
} catch (err) {
console.error("Failed to initialize page:", err);
document.body.classList.add("ready");
menu.innerHTML = items.join("&nbsp;-&nbsp;");
}
}
document.addEventListener("DOMContentLoaded", initializePage);
</script>
// Version
document.getElementById("site-version").textContent =
"ver. " + (site.version || "unknown");
// Apply translations
applyTranslations(dict);
fillPortnumSelect(dict, "{{ portnum or '' }}");
document.body.classList.add("ready");
} catch (err) {
console.error("Failed to initialize page:", err);
document.body.classList.add("ready");
}
}
document.addEventListener("DOMContentLoaded", initializePage);
</script>
</body>
</html>

View File

@@ -1,16 +0,0 @@
<div id="buttons" class="btn-group" role="group">
<a
role="button"
class="btn {{ 'btn-primary' if packet_event == 'packet' else 'btn-secondary'}}"
href="/packet_list/{{node_id}}?{{query_string}}"
>
TX/RX
</a>
<a
role="button"
class="btn {{ 'btn-primary' if packet_event == 'uplinked' else 'btn-secondary'}}"
href="/uplinked_list/{{node_id}}?{{query_string}}"
>
Uplinked
</a>
</div>

View File

@@ -1,20 +1,64 @@
{% extends "base.html" %}
{% block css %}
.timestamp { min-width: 10em; }
.timestamp {
min-width: 10em;
color: #ccc;
}
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
.chat-packet { border-bottom: 1px solid #555; padding: 8px; border-radius: 8px; }
.chat-packet {
border-bottom: 1px solid #555;
padding: 3px 6px;
border-radius: 6px;
margin: 0;
}
/* Same column spacing as before */
.chat-packet > [class^="col-"] {
padding-left: 10px !important;
padding-right: 10px !important;
padding-top: 1px !important;
padding-bottom: 1px !important;
}
.chat-packet:nth-of-type(even) { background-color: #333333; }
@keyframes flash { 0% { background-color: #ffe066; } 100% { background-color: inherit; } }
.channel {
font-style: italic;
color: #bbb;
}
.channel a {
font-style: normal;
color: #999;
}
@keyframes flash {
0% { background-color: #ffe066; }
100% { background-color: inherit; }
}
.chat-packet.flash { animation: flash 3.5s ease-out; }
.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; }
.replying-to {
font-size: 0.8em;
color: #aaa;
margin-top: 2px;
padding-left: 10px;
}
.replying-to .reply-preview { color: #aaa; }
{% endblock %}
{% block body %}
<div id="chat-container">
<div id="chat-container" class="mt-3">
<!-- ⭐ CHAT TITLE WITH ICON, aligned to container ⭐ -->
<div class="container px-2">
<h2 style="color:white; margin:0 0 10px 0;">
<span class="icon">💬</span>
<span data-translate="chat_title"></span>
</h2>
</div>
<div class="container" id="chat-log"></div>
</div>
@@ -26,116 +70,175 @@ document.addEventListener("DOMContentLoaded", async () => {
let lastTime = null;
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatTranslations = {};
let chatLang = {};
/* ==========================================================
TRANSLATIONS FOR CHAT PAGE
========================================================== */
function applyTranslations(dict, root=document) {
root.querySelectorAll("[data-translate]").forEach(el => {
const key = el.dataset.translate;
const val = dict[key];
if (!val) return;
if (el.placeholder) el.placeholder = val;
else el.textContent = val;
});
}
async function loadChatLang() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatLang = await res.json();
// Apply to existing DOM
applyTranslations(chatLang);
} catch (err) {
console.error("Chat translation load failed:", err);
}
}
/* ==========================================================
SAFE HTML
========================================================== */
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
function applyTranslations(translations, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
});
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
const key = el.dataset.translateLangTitle;
if (translations[key]) el.title = translations[key];
});
}
/* ==========================================================
RENDERING PACKETS
========================================================== */
function renderPacket(packet, highlight = false) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
packetMap.set(packet.id, packet);
const date = new Date(packet.import_time);
const formattedTime = date.toLocaleTimeString([], { hour:"numeric", minute:"2-digit", second:"2-digit", hour12:true });
const formattedDate = `${(date.getMonth()+1).toString().padStart(2,"0")}/${date.getDate().toString().padStart(2,"0")}/${date.getFullYear()}`;
let date;
if (packet.import_time_us && packet.import_time_us > 0) {
date = new Date(packet.import_time_us / 1000);
} else if (packet.import_time) {
date = new Date(packet.import_time);
} else {
date = new Date();
}
const formattedTime = date.toLocaleTimeString([], {
hour:"numeric",
minute:"2-digit",
second:"2-digit",
hour12:true
});
const formattedDate =
`${(date.getMonth()+1).toString().padStart(2,"0")}/` +
`${date.getDate().toString().padStart(2,"0")}/` +
`${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
let replyHtml = "";
if (packet.reply_id) {
const parent = packetMap.get(packet.reply_id);
const replyPrefix = `<i data-translate="replying_to"></i>`;
if (parent) {
replyHtml = `<div class="replying-to">
<div class="reply-preview">
<i data-translate-lang="replying_to"></i>
replyHtml = `
<div class="replying-to">
${replyPrefix}
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
${escapeHtml(parent.payload || "")}
</div>
</div>`;
</div>`;
} else {
replyHtml = `<div class="replying-to">
<i data-translate-lang="replying_to"></i>
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
replyHtml = `
<div class="replying-to">
${replyPrefix}
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
}
}
const div = document.createElement("div");
div.className = "row chat-packet" + (highlight ? " flash" : "");
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time}">${formattedTimestamp}</span>
<span class="col-2 timestamp" title="${packet.import_time_us}">
${formattedTimestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" data-translate-lang-title="view_packet_details">✉️</a>
<a href="/packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">🔎</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/packet_list/${packet.from_node_id}">
<a href="/node/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
</a>
</span>
<span class="col-5 message">${escapeHtml(packet.payload)}${replyHtml}</span>
<span class="col-5 message">
${escapeHtml(packet.payload)}${replyHtml}
</span>
`;
chatContainer.prepend(div);
applyTranslations(chatTranslations, div);
// Translate newly added DOM
applyTranslations(chatLang, div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
}
function renderPacketsEnsureDescending(packets, highlight=false) {
if (!Array.isArray(packets) || packets.length===0) return;
const sortedDesc = packets.slice().sort((a,b)=>new Date(b.import_time)-new Date(a.import_time));
for (let i=sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight);
const sortedDesc = packets.slice().sort((a,b)=>{
const aTime = a.import_time_us || (new Date(a.import_time).getTime() * 1000);
const bTime = b.import_time_us || (new Date(b.import_time).getTime() * 1000);
return bTime - aTime;
});
for (let i=sortedDesc.length-1; i>=0; i--) {
renderPacket(sortedDesc[i], highlight);
}
}
/* ==========================================================
FETCHING PACKETS
========================================================== */
async function fetchInitial() {
try {
const resp = await fetch("/api/chat?limit=100");
const resp = await fetch("/api/packets?portnum=1&limit=100");
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
lastTime = data?.latest_import_time || lastTime;
} catch(err){ console.error("Initial fetch error:", err); }
} catch(err){
console.error("Initial fetch error:", err);
}
}
async function fetchUpdates() {
try {
const url = new URL("/api/chat", window.location.origin);
const url = new URL("/api/packets?portnum=1", window.location.origin);
url.searchParams.set("limit","100");
if (lastTime) url.searchParams.set("since", lastTime);
const resp = await fetch(url);
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets, true);
lastTime = data?.latest_import_time || lastTime;
} catch(err){ console.error("Fetch updates error:", err); }
} catch(err){
console.error("Fetch updates error:", err);
}
}
async function loadTranslations() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatTranslations = await res.json();
applyTranslations(chatTranslations, document);
} catch(err){ console.error("Chat translation load failed:", err); }
}
/* ==========================================================
INIT
========================================================== */
await loadChatLang(); // load translations FIRST
await fetchInitial(); // then fetch initial packets
await loadTranslations();
await fetchInitial();
setInterval(fetchUpdates, 5000);
});
</script>
{% endblock %}

View File

@@ -1,7 +0,0 @@
<datalist
id="node_options"
>
{% for option in node_options %}
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
{% endfor %}
</datalist>

View File

@@ -2,7 +2,6 @@
{% block css %}
.container {
max-width: 900px;
margin: 0 auto;
padding: 10px;
}
@@ -13,12 +12,23 @@
border-radius: 6px;
}
.port-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
color: #fff;
}
/* Packet table */
.packet-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
color: #e4e9ee;
}
.packet-table th, .packet-table td {
border: 1px solid #3a3f44;
padding: 6px 10px;
@@ -30,6 +40,8 @@
}
.packet-table tr:nth-of-type(odd) { background-color: #272b2f; }
.packet-table tr:nth-of-type(even) { background-color: #212529; }
/* Port tag */
.port-tag {
display: inline-block;
padding: 1px 6px;
@@ -39,76 +51,132 @@
color: #fff;
}
/* --- Color-coded port labels --- */
.port-0 { background-color: #6c757d; }
.port-1 { background-color: #007bff; }
.port-3 { background-color: #28a745; }
.port-4 { background-color: #ffc107; }
.port-5 { background-color: #dc3545; }
.port-6 { background-color: #20c997; }
.port-65 { background-color: #ff66b3; }
.port-67 { background-color: #17a2b8; }
.port-70 { background-color: #6f42c1; }
.port-71 { background-color: #fd7e14; }
.to-mqtt { font-style: italic; color: #aaa; }
.to-mqtt {
font-style: italic;
color: #aaa;
}
/* --- Payload rows --- */
.payload-row {
display: none;
background-color: #1b1e22;
}
/* Payload rows */
.payload-row { display: none; background-color: #1b1e22; }
.payload-cell {
padding: 8px 12px;
font-family: monospace;
white-space: pre-wrap;
color: #b0bec5;
border-top: none;
}
.packet-table tr.expanded + .payload-row {
display: table-row;
}
/* Toggle arrow */
.toggle-btn {
cursor: pointer;
color: #aaa;
margin-right: 6px;
font-weight: bold;
}
.toggle-btn:hover {
color: #fff;
.toggle-btn:hover { color: #fff; }
/* Inline link next to port tag */
.inline-link {
margin-left: 6px;
font-weight: bold;
text-decoration: none;
color: #9fd4ff;
}
.inline-link:hover {
color: #c7e6ff;
}
{% endblock %}
{% block body %}
<div class="container">
<form class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0">📡 Live Feed</h5>
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary">Pause</button>
<h2 class="mb-0" data-translate-lang="live_feed">📡 Live Feed</h2>
<button type="button"
id="pause-button"
class="btn btn-sm btn-outline-secondary"
data-translate-lang="pause">
Pause
</button>
</form>
<table class="packet-table">
<thead>
<tr>
<th>Packet ID</th>
<th>From</th>
<th>To</th>
<th>Port</th>
<th>Links</th>
<th data-translate-lang="time">Time</th>
<th data-translate-lang="packet_id">Packet ID</th>
<th data-translate-lang="from">From</th>
<th data-translate-lang="to">To</th>
<th data-translate-lang="port">Port</th>
</tr>
</thead>
<tbody id="packet_list"></tbody>
</table>
</div>
<script>
let lastImportTime = null;
let updatesPaused = false;
let nodeMap = {};
let updateInterval = 3000;
/* ======================================================
FIREHOSE TRANSLATION SYSTEM (isolated from base)
====================================================== */
let firehoseTranslations = {};
function applyTranslationsFirehose(translations, root=document) {
root
.querySelectorAll("[data-translate-lang]")
.forEach(el => {
const key = el.dataset.translateLang;
if (!translations[key]) return;
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = translations[key];
} else {
el.textContent = translations[key];
}
});
}
async function loadTranslationsFirehose() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=firehose`);
firehoseTranslations = await res.json();
applyTranslationsFirehose(firehoseTranslations);
} catch (err) {
console.error("Firehose translation load failed:", err);
}
}
/* ======================================================
NODE LOOKUP
====================================================== */
let nodeMap = {};
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
const data = await res.json();
for (const n of data.nodes || []) {
nodeMap[n.node_id] = n.long_name || n.short_name || n.id || n.node_id;
}
nodeMap[4294967295] = firehoseTranslations.all_broadcast || "All";
} catch (err) {
console.error("Failed loading nodes:", err);
}
}
function nodeName(id) {
return nodeMap[id] || id;
}
/* ======================================================
PORT COLORS & NAMES
====================================================== */
const PORT_MAP = {
0: "UNKNOWN APP",
1: "Text Message",
@@ -120,80 +188,88 @@ const PORT_MAP = {
65: "Store Forward",
67: "Telemetry",
70: "Trace Route",
71: "Neighbor Info",
71: "Neighbor Info"
};
const PORT_COLORS = {
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
68: "#fd7e14",
69: "#6f42c1",
70: "#ff4444",
71: "#ff66cc",
72: "#00cc99",
73: "#9999ff",
74: "#cc00cc",
75: "#ffbb33",
76: "#00bcd4",
77: "#8bc34a",
78: "#795548"
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
68: "#fd7e14",
69: "#6f42c1",
70: "#ff4444",
71: "#ff66cc",
72: "#00cc99",
73: "#9999ff",
74: "#cc00cc",
75: "#ffbb33",
76: "#00bcd4",
77: "#8bc34a",
78: "#795548"
};
// --- Load node names ---
async function loadNodes() {
const res = await fetch("/api/nodes");
if (!res.ok) return;
const data = await res.json();
for (const n of data.nodes || []) {
const name = n.long_name || n.short_name || n.id || n.node_id;
nodeMap[n.node_id] = name;
}
nodeMap[4294967295] = "All";
}
function nodeName(id) {
if (id === 4294967295) return `<span class="to-mqtt">All</span>`;
return nodeMap[id] || id;
}
function portLabel(portnum, payload) {
function portLabel(portnum, payload, linksHtml) {
const name = PORT_MAP[portnum] || "Unknown";
const color = PORT_COLORS[portnum] || "#6c757d";
const safePayload = payload ? payload.replace(/"/g, "&quot;") : "";
return `<span class="port-tag" style="background-color:${color}" title="${safePayload}">${name}</span>
<span class="text-secondary">(${portnum})</span>`;
const safePayload = payload
? payload.replace(/"/g, "&quot;")
: "";
return `
<span class="port-tag" style="background-color:${color}" title="${safePayload}">
${name}
</span>
<span class="text-secondary">(${portnum})</span>
${linksHtml || ""}
`;
}
// --- Fetch firehose interval from shared site config ---
/* ======================================================
TIME FORMAT
====================================================== */
function formatLocalTime(importTimeUs) {
const ms = importTimeUs / 1000;
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
/* ======================================================
FIREHOSE FETCHING
====================================================== */
let lastImportTimeUs = null;
let updatesPaused = false;
let updateInterval = 3000;
async function configureFirehose() {
try {
const cfg = await window._siteConfigPromise;
const intervalSec = cfg?.site?.firehose_interval;
if (intervalSec && !isNaN(intervalSec)) {
updateInterval = parseInt(intervalSec) * 1000;
}
console.log("Firehose update interval:", updateInterval, "ms");
} catch (err) {
console.warn("Failed to read firehose interval:", err);
}
const sec = cfg?.site?.firehose_interval;
if (sec && !isNaN(sec)) updateInterval = sec * 1000;
} catch {}
}
// --- Fetch and render packets ---
async function fetchUpdates() {
if (updatesPaused) return;
const url = new URL("/api/packets", window.location.origin);
if (lastImportTime) url.searchParams.set("since", lastImportTime);
url.searchParams.set("limit", 50);
if (lastImportTimeUs)
url.searchParams.set("since", lastImportTimeUs);
try {
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const packets = data.packets || [];
if (!packets.length) return;
@@ -201,79 +277,133 @@ async function fetchUpdates() {
const list = document.getElementById("packet_list");
for (const pkt of packets.reverse()) {
const fromNodeId = pkt.from_node_id;
const toNodeId = pkt.to_node_id;
let from = fromNodeId === 4294967295 ? `<span class="to-mqtt">All</span>` :
`<a href="/packet_list/${fromNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[fromNodeId] || fromNodeId}</a>`;
/* FROM — includes translation */
const from =
pkt.from_node_id === 4294967295
? `<span class="to-mqtt" data-translate-lang="all_broadcast">
${firehoseTranslations.all_broadcast || "All"}
</span>`
: `<a href="/node/${pkt.from_node_id}" style="text-decoration:underline; color:inherit;">
${nodeMap[pkt.from_node_id] || pkt.from_node_id}
</a>`;
let to = toNodeId === 1 ? `<span class="to-mqtt">direct to MQTT</span>` :
toNodeId === 4294967295 ? `<span class="to-mqtt">All</span>` :
`<a href="/packet_list/${toNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[toNodeId] || toNodeId}</a>`;
/* TO — includes translation */
const to =
pkt.to_node_id === 1
? `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
${firehoseTranslations.direct_to_mqtt || "direct to MQTT"}
</span>`
: pkt.to_node_id === 4294967295
? `<span class="to-mqtt" data-translate-lang="all_broadcast">
${firehoseTranslations.all_broadcast || "All"}
</span>`
: `<a href="/node/${pkt.to_node_id}" style="text-decoration:underline; color:inherit;">
${nodeMap[pkt.to_node_id] || pkt.to_node_id}
</a>`;
let links = "";
let inlineLinks = "";
// Position link
if (pkt.portnum === 3 && pkt.payload) {
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
if (latMatch && lonMatch) {
const lat = parseInt(latMatch[1]) / 1e7;
const lon = parseInt(lonMatch[1]) / 1e7;
links += `<a href="https://www.google.com/maps?q=${lat},${lon}" target="_blank" rel="noopener noreferrer" style="font-weight:bold; text-decoration:none;">Map</a>`;
inlineLinks += ` <a class="inline-link"
href="https://www.google.com/maps?q=${lat},${lon}"
target="_blank">📍</a>`;
}
}
// Traceroute link
if (pkt.portnum === 70) {
let traceId = pkt.id;
const idMatch = pkt.payload.match(/ID:\s*(\d+)/i);
if (idMatch) traceId = idMatch[1];
if (links) links += "&nbsp;|&nbsp;";
links += `<a href="/graph/traceroute/${traceId}" target="_blank" rel="noopener noreferrer" style="font-weight:bold; text-decoration:none;">Graph</a>`;
const match = pkt.payload.match(/ID:\s*(\d+)/i);
if (match) traceId = match[1];
inlineLinks += ` <a class="inline-link"
href="/graph/traceroute/${traceId}"
target="_blank">⮕</a>`;
}
const safePayload = (pkt.payload || "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safePayload = (pkt.payload || "")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = `
<tr class="packet-row" data-id="${pkt.id}">
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<tr class="packet-row">
<td>${formatLocalTime(pkt.import_time_us)}</td>
<td>
<span class="toggle-btn">▶</span>
<a href="/packet/${pkt.id}"
style="text-decoration:underline; color:inherit;">
${pkt.id}
</a>
</td>
<td>${from}</td>
<td>${to}</td>
<td>${portLabel(pkt.portnum, pkt.payload)}</td>
<td>${links}</td>
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
</tr>
<tr class="payload-row">
<td colspan="5" class="payload-cell">${safePayload}</td>
</tr>`;
</tr>
`;
list.insertAdjacentHTML("afterbegin", html);
}
// Limit table size
while (list.rows.length > 400) list.deleteRow(-1);
lastImportTime = packets[packets.length - 1].import_time;
lastImportTimeUs = packets[packets.length - 1].import_time_us;
} catch (err) {
console.error("Packet fetch failed:", err);
}
}
// --- Initialize ---
/* ======================================================
INITIALIZE PAGE
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
const pauseBtn = document.getElementById("pause-button");
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
pauseBtn.textContent =
updatesPaused
? (firehoseTranslations.resume || "Resume")
: (firehoseTranslations.pause || "Pause");
});
document.addEventListener("click", (e) => {
document.addEventListener("click", e => {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
const row = btn.closest(".packet-row");
row.classList.toggle("expanded");
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
btn.textContent =
row.classList.contains("expanded") ? "▼" : "▶";
});
await loadTranslationsFirehose();
await configureFirehose();
await loadNodes();
fetchUpdates();
setInterval(fetchUpdates, updateInterval);
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -7,78 +7,200 @@
<style>
.legend { background:white;padding:8px;line-height:1.5;border-radius:5px;box-shadow:0 0 10px rgba(0,0,0,0.3);font-size:14px;color:black; }
.legend i { width:12px;height:12px;display:inline-block;margin-right:6px;border-radius:50%; }
#filter-container { text-align:center;margin-top:10px; }
.filter-checkbox { margin:0 10px; }
#share-button, #reset-filters-button {
#share-button,
#reset-filters-button {
padding:5px 15px;border:none;border-radius:4px;font-size:14px;cursor:pointer;color:white;
}
#share-button { margin-left:20px; background-color:#4CAF50; }
#share-button:hover { background-color:#45a049; }
#share-button:active { background-color:#3d8b40; }
#reset-filters-button { margin-left:10px; background-color:#f44336; }
#reset-filters-button:hover { background-color:#da190b; }
#reset-filters-button:active { background-color:#c41e0d; }
.blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; }
</style>
{% endblock %}
{% block body %}
<div id="map" style="width:100%;height:calc(100vh - 270px)"></div>
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
<div id="map" style="width:100%; height:calc(100vh - 270px)"></div>
<div id="map-legend"
class="legend"
style="position:absolute;
bottom:30px;
right:15px;
z-index:500;
pointer-events:none;">
<div>
<i style="background:orange; width:15px; height:3px; border-radius:0;"></i>
<span data-translate-lang="legend_traceroute">Traceroute Path (arrowed)</span>
</div>
<div style="margin-top:6px;">
<i style="background:gray; width:15px; height:3px; border-radius:0;"></i>
<span data-translate-lang="legend_neighbor">Neighbor Link</span>
</div>
</div>
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only">
<span data-translate-lang="show_routers_only">Show Routers Only</span>
</div>
<div style="text-align:center;margin-top:5px;">
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
<button id="share-button" onclick="shareCurrentView()" data-translate-lang="share_view">
🔗 Share This View
</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()" data-translate-lang="reset_filters">
↺ Reset Filters To Defaults
</button>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js"
integrity="sha384-FhPn/2P/fJGhQLeNWDn9B/2Gml2bPOrKJwFqJXgR3xOPYxWg5mYQ5XZdhUSugZT0"
crossorigin></script>
<script>
// ---------------------- Map Initialization ----------------------
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'&copy; OpenStreetMap' }).addTo(map);
/* ======================================================
MAP PAGE TRANSLATION SYSTEM
====================================================== */
// ---------------------- Globals ----------------------
var nodes=[], markers={}, markerById={}, nodeMap = new Map();
var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
let mapTranslations = {};
async function loadTranslationsMap() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=map`);
mapTranslations = await res.json();
applyTranslationsMap();
} catch (err) {
console.error("Map translation load failed:", err);
}
}
function applyTranslationsMap(root = document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
const val = mapTranslations[key];
if (!val) return;
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = val;
} else {
el.textContent = val;
}
});
}
/* ======================================================
EXISTING MAP LOGIC
====================================================== */
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom:19, attribution:'&copy; OpenStreetMap' }).addTo(map);
// Data structures
var nodes = [], markers = {}, markerById = {}, nodeMap = new Map();
var edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var mapInterval = 0;
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const portMap = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
};
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe",
"#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1",
"#000075","#808080"];
const colorMap = new Map(); let nextColorIndex = 0;
const channelSet = new Set();
// ---------------------- Helpers ----------------------
function timeAgo(date){ const diff=Date.now()-new Date(date), s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24); return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s"; }
function hashToColor(str){ if(colorMap.has(str)) return colorMap.get(str); const c=palette[nextColorIndex++%palette.length]; colorMap.set(str,c); return c; }
function isInvalidCoord(n){ return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long); }
map.on("popupopen", function (e) {
const popupEl = e.popup.getElement();
if (popupEl) applyTranslationsMap(popupEl);
});
// ---------------------- Packet Fetching ----------------------
function fetchLatestPacket(){ fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{ lastImportTime=data.packets?.[0]?.import_time||new Date().toISOString(); }).catch(console.error); }
function fetchNewPackets(){
if(mapInterval <= 0) return;
if(!lastImportTime) return;
fetch(`/api/packets?since=${encodeURIComponent(lastImportTime)}`).then(r=>r.json()).then(data=>{
if(!data.packets||data.packets.length===0) return;
let latest = lastImportTime;
data.packets.forEach(pkt=>{
if(pkt.import_time>latest) latest=pkt.import_time;
const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
});
lastImportTime=latest;
}).catch(console.error);
function timeAgo(date){
const diff = Date.now() - new Date(date);
const s = Math.floor(diff/1000), m = Math.floor(s/60),
h = Math.floor(m/60), d = Math.floor(h/24);
return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s";
}
function hashToColor(str){
if(colorMap.has(str)) return colorMap.get(str);
const c = palette[nextColorIndex++ % palette.length];
colorMap.set(str,c);
return c;
}
function isInvalidCoord(n){
return !n || !n.lat || !n.long || n.lat === 0 || n.long === 0 ||
Number.isNaN(n.lat) || Number.isNaN(n.long);
}
/* ======================================================
PACKET FETCHING (unchanged)
====================================================== */
function fetchLatestPacket(){
fetch(`/api/packets?limit=1`)
.then(r=>r.json())
.then(data=>{
lastImportTime=data.packets?.[0]?.import_time_us||0;
})
.catch(console.error);
}
function fetchNewPackets(){
if(mapInterval <= 0) return;
if(lastImportTime===null) return;
const url = new URL(`/api/packets`, window.location.origin);
url.searchParams.set("since", lastImportTime);
url.searchParams.set("limit", 50);
fetch(url)
.then(r=>r.json())
.then(data=>{
if(!data.packets || data.packets.length===0) return;
let latest = lastImportTime;
data.packets.forEach(pkt=>{
if(pkt.import_time_us > latest) latest = pkt.import_time_us;
const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
});
lastImportTime = latest;
})
.catch(console.error);
}
// ---------------------- Polling ----------------------
let packetInterval=null;
function startPacketFetcher(){
if(mapInterval<=0) return;
if(!packetInterval){
@@ -86,65 +208,58 @@ function startPacketFetcher(){
packetInterval=setInterval(fetchNewPackets,mapInterval*1000);
}
}
function stopPacketFetcher(){
if(packetInterval){
clearInterval(packetInterval);
packetInterval=null;
}
}
document.addEventListener("visibilitychange",()=>{
document.hidden?stopPacketFetcher():startPacketFetcher();
});
// ---------------------- WAIT FOR CONFIG ----------------------
async function waitForConfig() {
while (typeof window._siteConfigPromise === "undefined") {
console.log("Waiting for _siteConfigPromise...");
await new Promise(r => setTimeout(r, 100));
}
try {
const cfg = await window._siteConfigPromise;
if (!cfg || !cfg.site) throw new Error("Config missing site object");
return cfg.site;
return cfg.site || {};
} catch (err) {
console.error("Error loading site config:", err);
return {};
}
}
// ---------------------- Load Config & Start Polling ----------------------
async function initMapPolling() {
try {
const site = await waitForConfig();
mapInterval = parseInt(site.map_interval, 10) || 0;
// ---- Check URL params ----
const params = new URLSearchParams(window.location.search);
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
const zoom = parseInt(params.get('zoom'), 10);
if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
map.setView([lat, lng], zoom);
window.configBoundsApplied = true;
setTimeout(() => map.invalidateSize(), 100);
}
else {
const topLeft = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
const bottomRight = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
if (topLeft.every(isFinite) && bottomRight.every(isFinite)) {
map.fitBounds([topLeft, bottomRight]);
const tl = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
const br = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
if (tl.every(isFinite) && br.every(isFinite)) {
map.fitBounds([tl, br]);
window.configBoundsApplied = true;
setTimeout(() => map.invalidateSize(), 100);
}
}
if (mapInterval > 0) {
console.log(`Starting map polling every ${mapInterval}s`);
startPacketFetcher();
} else {
console.log("Map polling disabled (map_interval=0)");
}
if (mapInterval > 0) startPacketFetcher();
} catch (err) {
console.error("Failed to load /api/config:", err);
@@ -153,116 +268,222 @@ async function initMapPolling() {
initMapPolling();
// ---------------------- Load Nodes + Edges ----------------------
fetch('/api/nodes?days_active=3').then(r=>r.json()).then(data=>{
if(!data.nodes) return;
nodes = data.nodes.map(n=>({
key: n.node_id!==null?n.node_id:n.id,
id: n.id,
node_id: n.node_id,
lat: n.last_lat?n.last_lat/1e7:null,
long: n.last_long?n.last_long/1e7:null,
long_name: n.long_name||"",
short_name: n.short_name||"",
channel: n.channel||"",
hw_model: n.hw_model||"",
role: n.role||"",
firmware: n.firmware||"",
last_update: n.last_update||"",
isRouter: n.role? n.role.toLowerCase().includes("router"):false
}));
nodes.forEach(n=>{ nodeMap.set(n.key,n); if(n.channel) channelSet.add(n.channel); });
renderNodesOnMap();
createChannelFilters();
return fetch('/api/edges');
}).then(r=>r?r.json():null).then(data=>{
if(data && data.edges) edgesData=data.edges;
}).catch(console.error);
/* ======================================================
LOAD NODES
====================================================== */
fetch('/api/nodes?days_active=3')
.then(r=>r.json())
.then(data=>{
if(!data.nodes) return;
nodes = data.nodes.map(n=>({
key: n.node_id ?? n.id,
id: n.id,
node_id: n.node_id,
lat: n.last_lat ? n.last_lat/1e7 : null,
long: n.last_long ? n.last_long/1e7 : null,
long_name: n.long_name || "",
short_name: n.short_name || "",
channel: n.channel || "",
hw_model: n.hw_model || "",
role: n.role || "",
firmware: n.firmware || "",
last_update: n.last_update || "",
isRouter: (n.role||"").toLowerCase().includes("router")
}));
nodes.forEach(n=>{
nodeMap.set(n.key, n);
if(n.channel) channelSet.add(n.channel);
});
renderNodesOnMap();
createChannelFilters();
})
.catch(console.error);
/* ======================================================
RENDER NODES
====================================================== */
// ---------------------- Render Nodes ----------------------
function renderNodesOnMap(){
const bounds = L.latLngBounds();
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
const color = hashToColor(node.channel);
const opts = { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 };
const marker = L.circleMarker([node.lat,node.long],opts).addTo(map);
const marker = L.circleMarker([node.lat,node.long], {
radius: node.isRouter ? 9 : 7,
color: "white",
fillColor: color,
fillOpacity: 1,
weight: 0.7
}).addTo(map);
marker.nodeId = node.key;
marker.originalColor = color;
markerById[node.key] = marker;
const popup = `<b><a href="/packet_list/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>
${node.last_update? `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`:""}
${node.firmware? `<b>Firmware:</b> ${node.firmware}<br>`:""}`;
marker.on('click',()=>{ onNodeClick(node); marker.bindPopup(popup).openPopup(); setTimeout(()=>marker.closePopup(),3000); });
bounds.extend(marker.getLatLng());
const popup = `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b data-translate-lang="channel_label"></b> ${node.channel}<br>
<b data-translate-lang="model_label"></b> ${node.hw_model}<br>
<b data-translate-lang="role_label"></b> ${node.role}<br>
${
node.last_update
? `<b data-translate-lang="last_seen"></b> ${timeAgo(node.last_update)}<br>`
: ""
}
${
node.firmware
? `<b data-translate-lang="firmware"></b> ${node.firmware}<br>`
: ""
}
`;
marker.on('click', () => {
onNodeClick(node);
marker.bindPopup(popup).openPopup();
});
});
if(!window.configBoundsApplied && bounds.isValid()){
map.fitBounds(bounds);
setTimeout(()=>map.invalidateSize(),100);
setTimeout(() => applyTranslationsMap(), 50);
}
/* ======================================================
⭐ NEW: DYNAMIC EDGE LOADING
====================================================== */
async function onNodeClick(node){
selectedNodeId = node.key;
edgeLayer.clearLayers();
try {
const res = await fetch(`/api/edges?node_id=${node.key}`);
const data = await res.json();
const edges = data.edges || [];
edges.forEach(edge=>{
const f = nodeMap.get(edge.from);
const t = nodeMap.get(edge.to);
if(!f || !t || isInvalidCoord(f) || isInvalidCoord(t)) return;
const color = edge.type === "neighbor" ? "gray" : "orange";
const line = L.polyline([[f.lat, f.long], [t.lat, t.long]], {
color, weight: 3
}).addTo(edgeLayer);
if(edge.type === "traceroute"){
L.polylineDecorator(line, {
patterns: [
{
offset: '100%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize:5,
polygon:false,
pathOptions:{stroke:true,color}
})
}
]
}).addTo(edgeLayer);
}
});
} catch(err){
console.error("Failed to load edges for node", node.key, err);
}
}
// ---------------------- Render Edges ----------------------
function onNodeClick(node){
selectedNodeId = node.key;
edgeLayer.clearLayers();
edgesData.forEach(edge=>{
if(edge.from!==node.key && edge.to!==node.key) return;
const f=nodeMap.get(edge.from), t=nodeMap.get(edge.to);
if(!f||!t||isInvalidCoord(f)||isInvalidCoord(t)) return;
const color=edge.type==="neighbor"?"gray":"orange";
const l=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
if(edge.type==="traceroute"){
L.polylineDecorator(l,{patterns:[{offset:'100%',repeat:0,symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{stroke:true,color}})}]}).addTo(edgeLayer);
}
});
}
map.on('click',e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; } });
map.on('click', e=>{
if(!e.originalEvent.target.classList.contains('leaflet-interactive')){
edgeLayer.clearLayers();
selectedNodeId=null;
}
});
/* ======================================================
BLINKING
====================================================== */
// ---------------------- Packet Blinking ----------------------
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){ clearInterval(activeBlinks.get(marker)); marker.setStyle({fillColor:marker.originalColor}); if(marker.tooltip) map.removeLayer(marker.tooltip); }
let blinkCount=0;
const portName = portMap[portnum]||`Port ${portnum}`;
const tooltip = L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng()).addTo(map);
if(activeBlinks.has(marker)){
clearInterval(activeBlinks.get(marker));
marker.setStyle({ fillColor: marker.originalColor });
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
let blinkCount = 0;
const tooltip = L.tooltip({
permanent:true,
direction:'top',
offset:[0,-marker.options.radius-5],
className:'blinking-tooltip'
})
.setContent(`${longName} (${portMap[portnum] || "Port "+portnum})`)
.setLatLng(marker.getLatLng())
.addTo(map);
marker.tooltip = tooltip;
const interval = setInterval(()=>{
if(map.hasLayer(marker)){ marker.setStyle({fillColor: blinkCount%2===0?'yellow':marker.originalColor}); marker.bringToFront(); }
if(map.hasLayer(marker)){
marker.setStyle({
fillColor: blinkCount%2===0 ? 'yellow' : marker.originalColor
});
marker.bringToFront();
}
blinkCount++;
if(blinkCount>7){ clearInterval(interval); marker.setStyle({fillColor:marker.originalColor}); map.removeLayer(tooltip); activeBlinks.delete(marker); }
if(blinkCount>7){
clearInterval(interval);
marker.setStyle({ fillColor: marker.originalColor });
map.removeLayer(tooltip);
activeBlinks.delete(marker);
}
},500);
activeBlinks.set(marker,interval);
activeBlinks.set(marker, interval);
}
// ---------------------- Channel Filters ----------------------
/* ======================================================
CHANNEL FILTERS
====================================================== */
function createChannelFilters(){
const filterContainer = document.getElementById("filter-container");
const savedState = JSON.parse(localStorage.getItem("mapFilters") || "{}");
const saved = JSON.parse(localStorage.getItem("mapFilters") || "{}");
channelSet.forEach(channel=>{
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "filter-checkbox";
checkbox.id = `filter-channel-${channel}`;
checkbox.checked = savedState[channel] !== false;
checkbox.addEventListener("change", saveFiltersToLocalStorage);
checkbox.addEventListener("change", updateNodeVisibility);
filterContainer.appendChild(checkbox);
const cb=document.createElement("input");
cb.type="checkbox";
cb.className="filter-checkbox";
cb.id=`filter-channel-${channel}`;
cb.checked = saved[channel] !== false;
const label = document.createElement("label");
label.htmlFor = checkbox.id;
label.innerText = channel;
cb.addEventListener("change", saveFiltersToLocalStorage);
cb.addEventListener("change", updateNodeVisibility);
filterContainer.appendChild(cb);
const label=document.createElement("label");
label.htmlFor=cb.id;
label.innerText=channel;
label.style.color = hashToColor(channel);
filterContainer.appendChild(label);
});
const routerOnly = document.getElementById("filter-routers-only");
routerOnly.checked = savedState["routersOnly"] || false;
const routerOnly=document.getElementById("filter-routers-only");
routerOnly.checked = saved["routersOnly"] || false;
routerOnly.addEventListener("change", saveFiltersToLocalStorage);
routerOnly.addEventListener("change", updateNodeVisibility);
@@ -271,51 +492,69 @@ function createChannelFilters(){
function saveFiltersToLocalStorage(){
const state = {};
channelSet.forEach(ch => {
const el = document.getElementById(`filter-channel-${ch}`);
state[ch] = el.checked;
channelSet.forEach(ch=>{
state[ch] = document.getElementById(`filter-channel-${ch}`).checked;
});
state["routersOnly"] = document.getElementById("filter-routers-only").checked;
localStorage.setItem("mapFilters", JSON.stringify(state));
}
function updateNodeVisibility(){
const showRoutersOnly = document.getElementById("filter-routers-only").checked;
const activeChannels = Array.from(channelSet).filter(ch=>document.getElementById(`filter-channel-${ch}`).checked);
const routerOnly = document.getElementById("filter-routers-only").checked;
const activeChannels = [...channelSet].filter(ch =>
document.getElementById(`filter-channel-${ch}`).checked
);
nodes.forEach(n=>{
const marker = markerById[n.key];
if(marker){
const visible = (!showRoutersOnly || n.isRouter) && activeChannels.includes(n.channel);
if(visible) map.addLayer(marker); else map.removeLayer(marker);
const visible =
(!routerOnly || n.isRouter) &&
activeChannels.includes(n.channel);
visible ? map.addLayer(marker) : map.removeLayer(marker);
}
});
}
// ---------------------- Share / Reset ----------------------
function shareCurrentView() {
const center = map.getCenter();
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
/* ======================================================
SHARE / RESET
====================================================== */
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
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';
function shareCurrentView() {
const c = map.getCenter();
const url = `${window.location.origin}/map?lat=${c.lat.toFixed(6)}&lng=${c.lng.toFixed(6)}&zoom=${map.getZoom()}`;
navigator.clipboard.writeText(url).then(()=>{
const btn = document.getElementById('share-button');
const old = btn.textContent;
btn.textContent = '✓ ' + (mapTranslations.link_copied || 'Link Copied!');
btn.style.backgroundColor = '#2196F3';
setTimeout(()=>{
btn.textContent = old;
btn.style.backgroundColor = '#4CAF50';
}, 2000);
}).catch(() => alert('Share this link:\n' + shareUrl));
});
}
function resetFiltersToDefaults(){
document.getElementById("filter-routers-only").checked = false;
channelSet.forEach(ch=>document.getElementById(`filter-channel-${ch}`).checked = true);
channelSet.forEach(ch => {
document.getElementById(`filter-channel-${ch}`).checked = true;
});
saveFiltersToLocalStorage();
updateNodeVisibility();
}
/* ======================================================
TRANSLATION LOAD
====================================================== */
document.addEventListener("DOMContentLoaded", () => {
loadTranslationsMap();
});
</script>
{% endblock %}

View File

@@ -1,75 +1,233 @@
{% extends "base.html" %}
{% block css %}
.timestamp {
min-width:10em;
}
.chat-packet:nth-of-type(odd){
background-color: #3a3a3a; /* Lighter than #2a2a2a */
}
.timestamp { min-width: 10em; color: #ccc; }
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
.chat-packet {
border-bottom: 1px solid #555;
padding: 8px;
border-radius: 8px; /* Adjust the value to make the corners more or less rounded */
padding: 3px 6px;
border-radius: 6px;
margin: 0;
}
.chat-packet:nth-of-type(even){
background-color: #333333; /* Slightly lighter than the previous #181818 */
.chat-packet > [class^="col-"] {
padding-left: 10px !important;
padding-right: 10px !important;
padding-top: 1px !important;
padding-bottom: 1px !important;
}
.chat-packet:nth-of-type(even) { background-color: #333333; }
.channel { font-style: italic; color: #bbb; }
.channel a { font-style: normal; color: #999; }
#weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; }
#total-count { margin-bottom: 10px; font-style: italic; color: #ccc; }
{% endblock %}
{% block body %}
<div class="container">
<span>{{ site_config["site"]["weekly_net_message"] }}</span> <br><br>
<!-- ⭐ NET TITLE WITH ICON ⭐ -->
<div class="container px-2">
<h2 style="color:white; margin:0 0 10px 0;">
<span class="icon">💬</span>
<span data-translate-lang="net_title"></span>
</h2>
</div>
<h5>
<span data-translate-lang="number_of_checkins">Number of Check-ins:</span> {{ packets|length }}
</h5>
</div>
<!-- Weekly network message -->
<div id="weekly-message"></div>
<div class="container">
{% for packet in packets %}
<div
class="row chat-packet"
data-packet-id="{{ packet.id }}"
role="article"
aria-label="Chat message from {{ packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }}"
>
<span class="col-2 timestamp">
{{ packet.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y') }}
</span>
<span class="col-1 timestamp">
<a href="/packet/{{ packet.id }}" title="View packet details">✉️</a> {{ packet.from_node.channel }}
</span>
<span class="col-2 username">
<a href="/packet_list/{{ packet.from_node_id }}" title="View all packets from this node">
{{ packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }}
</a>
</span>
<span class="col-5 message">
{{ packet.payload }}
</span>
</div>
{% else %}
<span data-translate-lang="no_packets_found">No packets found.</span>
{% endfor %}
<!-- Total message count -->
<div id="total-count">
<span data-translate-lang="total_messages">Total messages:</span>
<span id="total-count-value">0</span>
</div>
<div id="chat-container">
<div class="container" id="chat-log"></div>
</div>
</div>
<script>
async function loadTranslations() {
try {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
const res = await fetch(`/api/lang?lang=${langCode}&section=net`);
const translations = await res.json();
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if(el.placeholder !== undefined && el.placeholder !== "") el.placeholder = translations[key] || el.placeholder;
else el.textContent = translations[key] || el.textContent;
});
} catch(err) {
console.error("Net translations load failed:", err);
}
}
document.addEventListener("DOMContentLoaded", async () => {
document.addEventListener("DOMContentLoaded", loadTranslations());
const chatContainer = document.querySelector("#chat-log");
const weeklyMessageEl = document.querySelector("#weekly-message");
const totalCountValueEl = document.querySelector("#total-count-value");
if (!chatContainer || !weeklyMessageEl || !totalCountValueEl) {
console.error("Required elements missing");
return;
}
const renderedPacketIds = new Set();
let netTranslations = {};
let netTag = "";
/* -----------------------------------
Escape HTML safely
----------------------------------- */
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
/* -----------------------------------
Apply translations
----------------------------------- */
function applyTranslations(trans, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (trans[key]) el.textContent = trans[key];
});
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
const key = el.dataset.translateLangTitle;
if (trans[key]) el.title = trans[key];
});
}
/* -----------------------------------
Update count
----------------------------------- */
function updateTotalCount() {
totalCountValueEl.textContent = renderedPacketIds.size;
}
/* -----------------------------------
Render single packet
----------------------------------- */
function renderPacket(packet) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
const date = new Date(packet.import_time_us / 1000);
const timeStr = date.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true
});
const dateStr =
`${String(date.getMonth()+1).padStart(2,"0")}/`+
`${String(date.getDate()).padStart(2,"0")}/`+
date.getFullYear();
const timestamp = `${timeStr} - ${dateStr}`;
const fromName =
(packet.long_name || "").trim() ||
`${netTranslations.node_fallback} ${packet.from_node_id}`;
const div = document.createElement("div");
div.className = "row chat-packet";
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time_us}">
${timestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}"
data-translate-lang-title="view_packet_details">✉️</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/packet_list/${packet.from_node_id}">
${escapeHtml(fromName)}
</a>
</span>
<span class="col-5 message">
${escapeHtml(packet.payload).replace(/\n/g,"<br>")}
</span>
`;
chatContainer.prepend(div);
applyTranslations(netTranslations, div);
updateTotalCount();
}
/* -----------------------------------
Sort descending by time
----------------------------------- */
function renderPacketsEnsureDescending(packets) {
if (!packets || !packets.length) return;
const sorted = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
for (let i = sorted.length - 1; i >= 0; i--) {
renderPacket(sorted[i]);
}
}
/* -----------------------------------
Fetch initial net-tagged packets
----------------------------------- */
async function fetchInitialPackets(tag) {
if (!tag) return;
try {
const sixDaysAgoMs = Date.now() - 6*24*60*60*1000;
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
const url =
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`;
const resp = await fetch(url);
const data = await resp.json();
if (data?.packets?.length)
renderPacketsEnsureDescending(data.packets);
} catch (err) {
console.error("Initial fetch error:", err);
}
}
/* -----------------------------------
Load translations from section=net
----------------------------------- */
async function loadTranslations(cfg) {
try {
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=net`);
netTranslations = await res.json();
applyTranslations(netTranslations, document);
} catch (err) {
console.error("Failed loading translations", err);
}
}
/* -----------------------------------
MAIN
----------------------------------- */
try {
const cfg = await window._siteConfigPromise;
const site = cfg?.site || {};
netTag = site.net_tag || "";
weeklyMessageEl.textContent = site.weekly_net_message || "";
await loadTranslations(cfg);
await fetchInitialPackets(netTag);
} catch (err) {
console.error("Initialization failed:", err);
weeklyMessageEl.textContent =
netTranslations.failed_to_load_site_config ||
"Failed to load site config.";
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +0,0 @@
{% extends "base.html" %}
{% block css %}
#node_info {
height:100%;
}
#map{
height:100%;
min-height: 400px;
}
#packet_details{
height: 95vh;
overflow: scroll;
top: 3em;
}
div.tab-pane > dl {
display: inline-block;
}
{% endblock %}
{% block body %}
{% include "search_form.html" %}
<div class="row">
<div class="col mb-3">
<div class="card" id="node_info">
{% if node %}
<div class="card-header">
{{node.long_name}}
</div>
<div class="card-body">
<dl >
<dt>ShortName</dt>
<dd>{{node.short_name}}</dd>
<dt>HW Model</dt>
<dd>{{node.hw_model}}</dd>
<dt>Role</dt>
<dd>{{node.role}}</dd>
</dl>
</div>
{% else %}
<div class="card-body">
A NodeInfo has not been seen.
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
{% include 'packet_list.html' %}
</div>
</div>
<div class="col mb-3">
<div id="map"></div>
</div>
</div>
{% endblock %}

View File

@@ -1,263 +0,0 @@
{% macro graph(name) %}
<div id="{{name}}Chart" style="width: 100%; height: 100%;"></div>
{% endmacro %}
<!-- Download and Expand buttons -->
<div class="d-flex justify-content-end mb-2">
<button class="btn btn-sm btn-outline-light me-2" id="downloadCsvBtn">Download CSV</button>
<button class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#fullChartModal">Expand</button>
</div>
<!-- Tab Navigation -->
<ul class="nav nav-tabs" role="tablist">
{% for name in [
"power", "utilization", "temperature", "humidity", "pressure",
"iaq", "wind_speed", "wind_direction", "power_metrics", "neighbors"
] %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if loop.first %}active{% endif %}" data-bs-toggle="tab" data-bs-target="#{{name}}Tab" type="button" role="tab">{{ name | capitalize }}</button>
</li>
{% endfor %}
</ul>
<!-- Tab Content -->
<div class="tab-content mt-3" style="height: 40vh;">
{% for name in [
"power", "utilization", "temperature", "humidity", "pressure",
"iaq", "wind_speed", "wind_direction", "power_metrics", "neighbors"
] %}
<div class="tab-pane fade {% if loop.first %}show active{% endif %}" id="{{name}}Tab" role="tabpanel" style="height: 100%;">
{{ graph(name) | safe }}
</div>
{% endfor %}
</div>
<!-- Fullscreen Modal -->
<div class="modal fade" id="fullChartModal" tabindex="-1" aria-labelledby="fullChartModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="fullChartModalLabel">Full Graph</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="height: 100vh;">
<div id="fullChartContainer" style="width: 100%; height: 100%;"></div>
</div>
</div>
</div>
</div>
<!-- ECharts Library -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
let currentChart = null;
let currentChartName = null;
let currentChartData = null;
let fullChart = null;
async function loadChart(name, targetDiv) {
currentChartName = name;
const chartDiv = document.getElementById(targetDiv);
if (!chartDiv) return;
try {
const resp = await fetch(`/graph/${name}_json/{{ node_id }}`);
if (!resp.ok) throw new Error(`Failed to load data for ${name}`);
const data = await resp.json();
// Reverse for chronological order
data.timestamps.reverse();
data.series.forEach(s => s.data.reverse());
const formattedDates = data.timestamps.map(t => {
const d = new Date(t);
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}-${d.getFullYear().toString().slice(-2)}`;
});
currentChartData = {
...data,
timestamps: formattedDates
};
const chart = echarts.init(chartDiv);
const isDualAxis = name === 'power';
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: function (params) {
return params.map(p => {
const label = p.seriesName.toLowerCase();
const unit = label.includes('volt') ? 'V' : label.includes('battery') ? '%' : '';
return `${p.marker} ${p.seriesName}: ${p.data}${unit}`;
}).join('<br>');
}
},
xAxis: {
type: 'category',
data: formattedDates,
axisLabel: { color: '#fff', rotate: 45 },
},
yAxis: isDualAxis ? [
{
type: 'value',
name: 'Battery (%)',
min: 0,
max: 120,
position: 'left',
axisLabel: { color: '#fff' },
nameTextStyle: { color: '#fff' }
},
{
type: 'value',
name: 'Voltage (V)',
min: 0,
max: 6,
position: 'right',
axisLabel: { color: '#fff' },
nameTextStyle: { color: '#fff' }
}
] : {
type: 'value',
axisLabel: { color: '#fff' },
},
series: data.series.map(s => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
connectNulls: true,
showSymbol: false,
yAxisIndex: isDualAxis && s.name.toLowerCase().includes('volt') ? 1 : 0,
})),
legend: { textStyle: { color: '#fff' } }
});
return chart;
} catch (err) {
console.error(err);
currentChartData = null;
currentChartName = null;
chartDiv.innerHTML = `<div class="text-white text-center mt-5">Error loading ${name} data.</div>`;
return null;
}
}
// Load first chart
const firstTabBtn = document.querySelector('.nav-tabs button.nav-link.active');
if (firstTabBtn) {
const name = firstTabBtn.textContent.toLowerCase();
const chartId = `${name}Chart`;
loadChart(name, chartId).then(chart => currentChart = chart);
}
// On tab switch
document.querySelectorAll('.nav-tabs button.nav-link').forEach(button => {
button.addEventListener('shown.bs.tab', event => {
const name = event.target.textContent.toLowerCase();
const chartId = `${name}Chart`;
loadChart(name, chartId).then(chart => currentChart = chart);
});
});
// CSV Download
document.getElementById('downloadCsvBtn').addEventListener('click', () => {
if (!currentChartData || !currentChartName) {
alert("Chart data not loaded yet.");
return;
}
const { timestamps, series } = currentChartData;
let csv = 'Date,' + series.map(s => s.name).join(',') + '\n';
for (let i = 0; i < timestamps.length; i++) {
const row = [timestamps[i]];
for (const s of series) {
row.push(s.data[i] != null ? s.data[i] : '');
}
csv += row.join(',') + '\n';
}
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentChartName}_{{ node_id }}.csv`;
a.click();
URL.revokeObjectURL(url);
});
// Fullscreen modal chart
document.getElementById('fullChartModal').addEventListener('shown.bs.modal', () => {
if (!currentChartData || !currentChartName) return;
if (!fullChart) {
fullChart = echarts.init(document.getElementById('fullChartContainer'));
}
const isDualAxis = currentChartName === 'power';
fullChart.setOption({
title: { text: currentChartName.charAt(0).toUpperCase() + currentChartName.slice(1), textStyle: { color: '#fff' } },
tooltip: {
trigger: 'axis',
formatter: function (params) {
return params.map(p => {
const label = p.seriesName.toLowerCase();
const unit = label.includes('volt') ? 'V' : label.includes('battery') ? '%' : '';
return `${p.marker} ${p.seriesName}: ${p.data}${unit}`;
}).join('<br>');
}
},
xAxis: {
type: 'category',
data: currentChartData.timestamps,
axisLabel: { color: '#fff', rotate: 45 },
},
yAxis: isDualAxis ? [
{
type: 'value',
name: 'Battery (%)',
min: 0,
max: 120,
position: 'left',
axisLabel: { color: '#fff' },
nameTextStyle: { color: '#fff' }
},
{
type: 'value',
name: 'Voltage (V)',
min: 0,
max: 6,
position: 'right',
axisLabel: { color: '#fff' },
nameTextStyle: { color: '#fff' }
}
] : {
type: 'value',
axisLabel: { color: '#fff' },
},
series: currentChartData.series.map(s => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
connectNulls: true,
showSymbol: false,
yAxisIndex: isDualAxis && s.name.toLowerCase().includes('volt') ? 1 : 0,
})),
legend: { textStyle: { color: '#fff' } }
});
fullChart.resize();
});
window.addEventListener('resize', () => {
if (fullChart) fullChart.resize();
if (currentChart) currentChart.resize();
});
});
</script>

View File

@@ -1,109 +0,0 @@
{% extends "base.html" %}
{% block css %}
.table-title {
font-size: 2rem;
text-align: center;
margin-bottom: 20px;
}
.traffic-table {
width: 50%;
border-collapse: collapse;
margin: 0 auto;
font-family: Arial, sans-serif;
}
.traffic-table th,
.traffic-table td {
padding: 10px 15px;
text-align: left;
border: 1px solid #474b4e;
}
.traffic-table th {
background-color: #272b2f;
color: white;
}
.traffic:nth-of-type(odd) {
background-color: #272b2f; /* Lighter than #2a2a2a */
}
.traffic {
border: 1px solid #474b4e;
padding: 8px;
margin-bottom: 4px;
border-radius: 8px;
}
.traffic:nth-of-type(even) {
background-color: #212529; /* Slightly lighter than the previous #181818 */
}
.footer {
text-align: center;
margin-top: 20px;
}
{% endblock %}
{% block body %}
<section>
<h2 class="table-title">
{% if traffic %}
{{ traffic[0].long_name }} (last 24 hours)
{% else %}
No Traffic Data Available
{% endif %}
</h2>
<table class="traffic-table">
<thead>
<tr>
<th>Port Number</th>
<th>Packet Count</th>
</tr>
</thead>
<tbody>
{% for port in traffic %}
<tr class="traffic">
<td>
{% if port.portnum == 1 %}
TEXT_MESSAGE_APP
{% elif port.portnum == 3 %}
POSITION_APP
{% elif port.portnum == 4 %}
NODEINFO_APP
{% elif port.portnum == 5 %}
ROUTING_APP
{% elif port.portnum == 8 %}
WAYPOINT_APP
{% elif port.portnum == 67 %}
TELEMETRY_APP
{% elif port.portnum == 70 %}
TRACEROUTE_APP
{% elif port.portnum == 71 %}
NEIGHBORINFO_APP
{% elif port.portnum == 73 %}
MAP_REPORT_APP
{% elif port.portnum == 0 %}
UNKNOWN_APP
{% else %}
{{ port.portnum }}
{% endif %}
</td>
<td>{{ port.packet_count }}</td>
</tr>
{% else %}
<tr>
<td colspan="2">No traffic data available for this node.</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<footer class="footer">
<a href="/top">Back to Top Nodes</a>
</footer>
{% endblock %}

View File

@@ -13,11 +13,13 @@
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
/* Search UI */
.search-container {
position: absolute;
bottom: 100px;
left: 10px;
z-index: 10;1
z-index: 10;
display: flex;
flex-direction: column;
gap: 5px;
@@ -37,6 +39,8 @@
.search-container button:hover {
background-color: #0056b3;
}
/* Node info box */
#node-info {
position: absolute;
bottom: 10px;
@@ -52,6 +56,8 @@
max-height: 250px;
overflow-y: auto;
}
/* Legend */
#legend {
position: absolute;
bottom: 10px;
@@ -67,9 +73,6 @@
}
.legend-category {
margin-right: 10px;
code {
color: inherit;
}
}
.legend-box {
display: inline-block;
@@ -77,22 +80,23 @@
height: 12px;
margin-right: 5px;
border-radius: 3px;
&.circle {
border-radius: 6px;
}
}
.circle { border-radius: 6px; }
{% endblock %}
{% block body %}
<div id="mynetwork"></div>
<!-- SEARCH + FILTER -->
<div class="search-container">
<label for="channel-select" style="color:#333;">Channel:</label>
<label style="color:#333;">Channel:</label>
<select id="channel-select" onchange="filterByChannel()"></select>
<input type="text" id="node-search" placeholder="Search node...">
<button onclick="searchNode()">Search</button>
</div>
<!-- INFO BOX -->
<div id="node-info">
<b>Long Name:</b> <span id="node-long-name"></span><br>
<b>Short Name:</b> <span id="node-short-name"></span><br>
@@ -100,196 +104,280 @@
<b>Hardware Model:</b> <span id="node-hw-model"></span>
</div>
<!-- LEGEND -->
<div id="legend">
<div class="legend-category">
<div><span class="legend-box" style="background-color: #ff5733"></span> Traceroute</div>
<div><span class="legend-box" style="background-color: #049acd"></span> Neighbor</div>
<div><span class="legend-box" style="background-color: #ff5733"></span>Traceroute</div>
<div><span class="legend-box" style="background-color: #049acd"></span>Neighbor</div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #ff5733"></span> <code>ROUTER</code></div>
<div><span class="legend-box circle" style="background-color: #b65224"></span> <code>ROUTER_LATE</code></div>
<div><span class="legend-box circle" style="background-color: #ff5733"></span><code>ROUTER</code></div>
<div><span class="legend-box circle" style="background-color: #b65224"></span><code>ROUTER_LATE</code></div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #007bff"></span> <code>CLIENT</code></div>
<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: #007bff"></span><code>CLIENT</code></div>
<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><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><span class="legend-box circle" style="background-color: #6c757d"></span>Unknown</div>
</div>
</div>
<script>
const chart = echarts.init(document.getElementById('mynetwork'));
// Initialize ECharts
const chart = echarts.init(document.getElementById("mynetwork"));
// -----------------------------------
// COLOR + ROLE HELPERS
// -----------------------------------
const colors = {
edge: {
traceroute: '#ff5733',
neighbor: '#049acd',
},
edge: { traceroute:"#ff5733", neighbor:"#049acd" },
role: {
ROUTER: '#ff5733',
ROUTER_LATE: '#b65224',
CLIENT: '#007bff',
CLIENT_MUTE: '#00c3ff',
CLIENT_BASE: '#049acd',
other: '#ffbf00',
unknown: '#6c757d',
ROUTER:"#ff5733",
ROUTER_LATE:"#b65224",
CLIENT:"#007bff",
CLIENT_MUTE:"#00c3ff",
CLIENT_BASE:"#049acd",
other:"#ffbf00",
unknown:"#6c757d"
},
selection: '#ff8c00',
selection:"#ff8c00"
};
function getRoleColor(role) {
if (!role) return colors.role.unknown;
return colors.role[role] || colors.role.other;
}
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
function getRoleColor(role) { return colors.role[role] || colors.role.other; }
function getSymbolSize(role) {
switch(role){
case "ROUTER":
case "ROUTER_LATE": return 30;
case "CLIENT_BASE": return 18;
case "CLIENT": return 15;
case "CLIENT_MUTE": return 7;
default: return 15;
}
}
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 || '';
function getLabel(role, shortName, longName) {
if (role === "ROUTER" || role === "ROUTER_LATE") return longName;
return shortName || "";
}
// --- Nodes ---
const nodes = [
{% for node in nodes %}
{
name: "{{ node.node_id }}", // node_id as string
value: getLabel({{node.role | tojson}}, {{node.short_name | tojson }}, {{node.long_name | tojson}}), // display label
symbol: 'circle',
symbolSize: getSymbolSize({{node.role | tojson}}),
itemStyle: { color: getRoleColor({{node.role | tojson}}), opacity:1 },
label: { show:true, position:'right', color:'#333', fontSize:12, formatter: (p)=>p.data.value },
long_name: {{ node.long_name | tojson }},
short_name: {{ node.short_name | tojson }},
role: {{ node.role | tojson }},
hw_model: {{ node.hw_model | tojson }},
channel: {{ node.channel | tojson }}
},
{% endfor %}
];
// --- Edges ---
const edges = [
{% for edge in edges %}
{
source: "{{ edge.from }}", // edge source as string
target: "{{ edge.to }}", // edge target as string
originalColor: colors.edge[{{edge.type | tojson}}],
lineStyle: {
color: colors.edge[{{edge.type | tojson}}],
width: {{edge.weight | tojson}},
},
},
{% endfor %}
];
// -----------------------------------
// STATE
// -----------------------------------
let nodes = [];
let edges = [];
let filteredNodes = [];
let filteredEdges = [];
let selectedChannel = 'LongFast';
let lastSelectedNode = null;
let selectedChannel = null;
// -----------------------------------
// LOAD NODES + EDGES FROM API
// -----------------------------------
async function loadData() {
// 1. Load nodes
const n = await fetch("/api/nodes").then(r => r.json());
nodes = n.nodes.map(x => ({
name: String(x.node_id),
node_id: x.node_id,
long_name: x.long_name,
short_name: x.short_name,
hw_model: x.hw_model,
role: x.role,
channel: x.channel,
labelValue: getLabel(x.role, x.short_name, x.long_name),
symbolSize: getSymbolSize(x.role),
itemStyle: {
color: getRoleColor(x.role),
opacity: 1
},
label: {
show: true,
position: "right",
color: "#333",
fontSize: 12,
formatter: p => p.data.labelValue
}
}));
const allNodeIDs = new Set(nodes.map(n => n.name));
// 2. Load edges
const e = await fetch("/api/edges").then(r => r.json());
// Only keep edges that reference valid nodes
edges = e.edges
.filter(ed =>
allNodeIDs.has(String(ed.from)) &&
allNodeIDs.has(String(ed.to))
)
.map(ed => ({
source: String(ed.from),
target: String(ed.to),
edgeType: ed.type,
originalColor: colors.edge[ed.type] || "#ccc",
lineStyle: {
width: 2,
color: colors.edge[ed.type] || "#ccc",
opacity: 1
}
}));
// 3. Determine which nodes are actually used in edges
const usedNodeIDs = new Set();
edges.forEach(e => {
usedNodeIDs.add(e.source);
usedNodeIDs.add(e.target);
});
// 4. Remove unused (no-edge) nodes
nodes = nodes.filter(n => usedNodeIDs.has(n.name));
// 5. Double safety: remove any edges referencing removed nodes
edges = edges.filter(e =>
usedNodeIDs.has(e.source) && usedNodeIDs.has(e.target)
);
// 6. Now ready to build dropdown & render
populateChannelDropdown();
}
// -----------------------------------
// CHANNEL FILTER
// -----------------------------------
function populateChannelDropdown() {
const sel = document.getElementById('channel-select');
const unique = [...new Set(nodes.map(n=>n.channel).filter(Boolean))].sort();
unique.forEach(ch=>{
const opt = document.createElement('option');
opt.value=ch; opt.text=ch;
if(ch==='LongFast') opt.selected=true;
const sel = document.getElementById("channel-select");
const chans = [...new Set(nodes.map(n => n.channel))].sort();
chans.forEach(ch => {
const opt = document.createElement("option");
opt.value = ch;
opt.text = ch;
sel.appendChild(opt);
});
selectedChannel = sel.value;
selectedChannel = chans[0];
sel.value = selectedChannel;
filterByChannel();
}
function filterByChannel() {
selectedChannel = document.getElementById('channel-select').value;
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
const nodeSet = new Set(filteredNodes.map(n=>n.name));
filteredEdges = edges.filter(e=>nodeSet.has(e.source) && nodeSet.has(e.target));
lastSelectedNode=null;
selectedChannel = document.getElementById("channel-select").value;
filteredNodes = nodes.filter(n => n.channel === selectedChannel);
const allowed = new Set(filteredNodes.map(n => n.name));
filteredEdges = edges.filter(e => allowed.has(e.source) && allowed.has(e.target));
lastSelectedNode = null;
updateChart();
}
// -----------------------------------
// FORCE GRAPH UPDATE
// -----------------------------------
function updateChart() {
const updatedNodes = filteredNodes.map(node=>{
let opacity=1, color=getRoleColor(node.role), borderColor='transparent', borderWidth=6;
if(lastSelectedNode){
const connected = filteredEdges.some(e=>
(e.source===node.name && e.target===lastSelectedNode) ||
(e.target===node.name && e.source===lastSelectedNode)
const updatedNodes = filteredNodes.map(n => {
let opacity = 1;
let borderColor = "transparent";
if (lastSelectedNode) {
const connected = filteredEdges.some(
e => (e.source === n.name && e.target === lastSelectedNode) ||
(e.target === n.name && e.source === lastSelectedNode)
);
if(node.name === lastSelectedNode) {
opacity=1;
borderColor=colors.selection;
if (n.name === lastSelectedNode) {
borderColor = colors.selection;
} else if (!connected) {
opacity = 0.3;
}
else if(connected) opacity=1;
else opacity=0.4;
}
return {...node, itemStyle:{...node.itemStyle, color,opacity, borderColor, borderWidth}};
return {
...n,
itemStyle: { ...n.itemStyle, opacity, borderColor, borderWidth: 6 }
};
});
const updatedEdges = filteredEdges.map(edge=>{
let opacity=0.1, width=edge.lineStyle.width;
if(lastSelectedNode){
const connected = edge.source===lastSelectedNode || edge.target===lastSelectedNode;
opacity=connected?1:0.05; width=edge.lineStyle.width;
}
return {...edge, lineStyle:{color:edge.originalColor||'#d3d3d3', width, opacity}};
const updatedEdges = filteredEdges.map(e => {
const connected =
lastSelectedNode &&
(e.source === lastSelectedNode || e.target === lastSelectedNode);
return {
...e,
lineStyle: { ...e.lineStyle, opacity: connected ? 1 : 0.1 }
};
});
chart.setOption({series:[{type:'graph', layout:'force', data:updatedNodes, links:updatedEdges, roam:true, force:{repulsion:200, edgeLength:[80,120]}}]});
chart.setOption({
animation: false,
series: [{
type: "graph",
layout: "force",
roam: true,
data: updatedNodes,
links: updatedEdges,
force: { repulsion: 200, edgeLength: [80, 120] }
}]
});
}
chart.on('click', function(params){
if(params.dataType==='node') updateSelectedNode(params.data.name);
else{
lastSelectedNode=null; updateChart();
document.getElementById('node-long-name').innerText='';
document.getElementById('node-short-name').innerText='';
document.getElementById('node-role').innerText='';
document.getElementById('node-hw-model').innerText='';
// -----------------------------------
// CLICK EVENTS
// -----------------------------------
chart.on("click", function(params){
if (params.dataType === "node") {
updateSelectedNode(params.data.name);
} else {
lastSelectedNode = null;
updateChart();
document.getElementById("node-long-name").innerText = "";
document.getElementById("node-short-name").innerText = "";
document.getElementById("node-role").innerText = "";
document.getElementById("node-hw-model").innerText = "";
}
});
function updateSelectedNode(selNode){
lastSelectedNode=selNode; updateChart();
const n = filteredNodes.find(x=>x.name===selNode);
if(n){
document.getElementById('node-long-name').innerText=n.long_name;
document.getElementById('node-short-name').innerText=n.short_name;
document.getElementById('node-role').innerText=n.role;
document.getElementById('node-hw-model').innerText=n.hw_model;
}
function updateSelectedNode(id) {
lastSelectedNode = id;
updateChart();
const n = filteredNodes.find(n => n.name === id);
if (!n) return;
document.getElementById("node-long-name").innerText = n.long_name;
document.getElementById("node-short-name").innerText = n.short_name;
document.getElementById("node-role").innerText = n.role;
document.getElementById("node-hw-model").innerText = n.hw_model;
}
function searchNode(){
const q = document.getElementById('node-search').value.toLowerCase().trim();
if(!q) return;
const found = filteredNodes.find(n=>n.name.toLowerCase().includes(q) || n.long_name.toLowerCase().includes(q) || n.short_name.toLowerCase().includes(q));
if(found) updateSelectedNode(found.name);
// -----------------------------------
// SEARCH
// -----------------------------------
function searchNode() {
const q = document.getElementById("node-search").value.toLowerCase().trim();
if (!q) return;
const found = filteredNodes.find(n =>
n.name.toLowerCase().includes(q) ||
(n.long_name || "").toLowerCase().includes(q) ||
(n.short_name || "").toLowerCase().includes(q)
);
if (found) updateSelectedNode(found.name);
else alert("Node not found in current channel!");
}
populateChannelDropdown();
window.addEventListener('resize', ()=>chart.resize());
// -----------------------------------
loadData();
window.addEventListener("resize", () => chart.resize());
</script>
{% endblock %}

View File

@@ -1,12 +1,31 @@
{% extends "base.html" %}
{% block css %}
<style>
html, body {
overflow-x: auto !important;
}
table {
width: 80%;
/* FIX: allow table to keep natural width so scrolling works */
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 1em auto;
}
/* Desktop scroll wrapper */
#node-list {
width: 100%;
overflow-x: auto; /* allows horizontal scroll */
overflow-y: hidden;
/* !!! removed display:flex because it prevents scrolling */
}
#node-list table {
width: max-content; /* table keeps its natural width */
min-width: 100%; /* won't shrink smaller than viewport */
}
th, td {
padding: 10px;
border: 1px solid #333;
@@ -84,7 +103,23 @@ select, .export-btn, .search-box, .clear-btn {
font-weight: bold;
color: white;
}
.node-status {
margin-left: 10px;
padding: 2px 8px;
border-radius: 12px;
border: 1px solid #2a6a8a;
background: #0d2a3a;
color: #9fd4ff;
font-size: 0.9em;
display: inline-block;
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
.node-status.active {
opacity: 1;
}
/* Favorite stars */
.favorite-star {
cursor: pointer;
font-size: 1.2em;
@@ -100,6 +135,7 @@ select, .export-btn, .search-box, .clear-btn {
color: #ffd700;
}
/* Favorite filter button */
.favorites-btn {
background-color: #ffd700;
color: #000;
@@ -114,257 +150,519 @@ select, .export-btn, .search-box, .clear-btn {
background-color: #ff6b6b;
color: white;
}
/* --------------------------------------------- */
/* MOBILE CARD VIEW */
/* --------------------------------------------- */
@media (max-width: 768px) {
/* Hide desktop view */
#node-list table {
display: none;
}
/* Show mobile cards */
#mobile-node-list {
display: block !important;
width: 100%;
padding: 0 10px;
/* If you want horizontal swiping, uncomment:
overflow-x: auto;
white-space: nowrap; */
}
.node-card {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
padding: 12px 15px;
margin-bottom: 12px;
color: white;
}
.node-card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.2em;
font-weight: bold;
margin-bottom: 8px;
}
.node-card-field {
margin: 4px 0;
font-size: 0.9em;
}
.node-card-field b {
color: #9fd4ff;
}
.favorite-star {
font-size: 1.4em;
}
}
</style>
{% endblock %}
{% block body %}
<div class="filter-container">
<input type="text" id="search-box" class="search-box" placeholder="Search by name or ID..." />
<input
type="text"
id="search-box"
class="search-box"
data-translate-lang="search_placeholder"
placeholder="Search by name or ID or HEX ID..."
/>
<select id="role-filter">
<option value="">All Roles</option>
<option value="" data-translate-lang="all_roles">All Roles</option>
</select>
<select id="channel-filter">
<option value="">All Channels</option>
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
<select id="hw-filter">
<option value="">All HW Models</option>
<option value="" data-translate-lang="all_hw">All HW Models</option>
</select>
<select id="firmware-filter">
<option value="">All Firmware</option>
<option value="" data-translate-lang="all_firmware">All Firmware</option>
</select>
<button class="favorites-btn" id="favorites-btn">⭐ Show Favorites</button>
<button class="export-btn" id="export-btn">Export CSV</button>
<button class="clear-btn" id="clear-btn">Clear Filters</button>
<button class="favorites-btn" id="favorites-btn" data-translate-lang="show_favorites">
⭐ Show Favorites
</button>
<button class="export-btn" id="export-btn" data-translate-lang="export_csv">
Export CSV
</button>
<button class="clear-btn" id="clear-btn" data-translate-lang="clear_filters">
Clear Filters
</button>
</div>
<div class="count-container">
Showing <span id="node-count">0</span> nodes
<span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span>
<span id="node-status" class="node-status" aria-live="polite"></span>
</div>
<!-- Desktop table -->
<div id="node-list">
<table>
<thead>
<tr>
<th>Short<span class="sort-icon"></span></th>
<th>Long Name <span class="sort-icon"></span></th>
<th>HW Model <span class="sort-icon"></span></th>
<th>Firmware <span class="sort-icon"></span></th>
<th>Role <span class="sort-icon"></span></th>
<th>Last Latitude <span class="sort-icon"></span></th>
<th>Last Longitude <span class="sort-icon"></span></th>
<th>Channel <span class="sort-icon"></span></th>
<th>Last Update <span class="sort-icon"></span></th>
<th>Favorite</th>
<th data-translate-lang="short_name">Short <span class="sort-icon"></span></th>
<th data-translate-lang="long_name">Long Name <span class="sort-icon"></span></th>
<th data-translate-lang="hw_model">HW Model <span class="sort-icon"></span></th>
<th data-translate-lang="firmware">Firmware <span class="sort-icon"></span></th>
<th data-translate-lang="role">Role <span class="sort-icon"></span></th>
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
<th data-translate-lang="favorite"></th>
</tr>
</thead>
<tbody id="node-table-body">
<tr><td colspan="9" style="text-align:center; color:white;">Loading nodes...</td></tr>
<tr>
<td colspan="10" style="text-align:center; color:white;" data-translate-lang="loading_nodes">
Loading nodes...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div id="mobile-node-list" style="display:none;"></div>
<script>
let allNodes = [];
let sortColumn = "short_name"; // default sorted column
let sortAsc = true; // default ascending
let showOnlyFavorites = false;
// =====================================================
// TRANSLATIONS
// =====================================================
let nodelistTranslations = {};
// Declare headers and keyMap BEFORE any function that uses them
const headers = document.querySelectorAll("thead th");
const keyMap = ["short_name","long_name","hw_model","firmware","role","last_lat","last_long","channel","last_update"];
// LocalStorage functions for favorites
function getFavorites() {
const favorites = localStorage.getItem('nodelist_favorites');
return favorites ? JSON.parse(favorites) : [];
function applyTranslationsNodelist() {
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (nodelistTranslations[key]) {
if (el.tagName === "INPUT" && el.placeholder) {
el.placeholder = nodelistTranslations[key];
} else {
el.textContent = nodelistTranslations[key];
}
}
});
}
function saveFavorites(favorites) {
localStorage.setItem('nodelist_favorites', JSON.stringify(favorites));
}
function toggleFavorite(nodeId) {
let favorites = getFavorites();
const index = favorites.indexOf(nodeId);
if (index > -1) {
favorites.splice(index, 1);
} else {
favorites.push(nodeId);
async function loadTranslationsNodelist() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=nodelist`);
nodelistTranslations = await res.json();
applyTranslationsNodelist();
} catch (err) {
console.error("Failed to load nodelist translations:", err);
}
saveFavorites(favorites);
applyFilters();
}
// =====================================================
// GLOBALS
// =====================================================
let allNodes = [];
let sortColumn = "short_name";
let sortAsc = true;
let showOnlyFavorites = false;
let favoritesSet = new Set();
let isBusy = false;
let statusHideTimer = null;
let statusShownAt = 0;
const minStatusMs = 300;
const headers = document.querySelectorAll("thead th");
const keyMap = [
"short_name","long_name","hw_model","firmware","role",
"last_lat","last_long","channel","last_seen_us"
];
function debounce(fn, delay = 250) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
function nextFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
function loadFavorites() {
const favorites = localStorage.getItem('nodelist_favorites');
if (!favorites) {
favoritesSet = new Set();
return;
}
try {
const parsed = JSON.parse(favorites);
favoritesSet = new Set(Array.isArray(parsed) ? parsed : []);
} catch (err) {
console.warn("Failed to parse favorites, resetting.", err);
favoritesSet = new Set();
}
}
function saveFavorites() {
localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet]));
}
function toggleFavorite(nodeId) {
if (favoritesSet.has(nodeId)) {
favoritesSet.delete(nodeId);
} else {
favoritesSet.add(nodeId);
}
saveFavorites();
}
function isFavorite(nodeId) {
return getFavorites().includes(nodeId);
return favoritesSet.has(nodeId);
}
function timeAgoFromMs(msTimestamp) {
if (!msTimestamp) return "N/A";
const diff = Date.now() - msTimestamp;
if (diff < 60000) return "just now";
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins} min ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
const days = Math.floor(hrs / 24);
return `${days} days ago`;
}
// =====================================================
// DOM LOADED
// =====================================================
document.addEventListener("DOMContentLoaded", async function() {
await loadTranslationsNodelist();
loadFavorites();
const tbody = document.getElementById("node-table-body");
const mobileList = document.getElementById("mobile-node-list");
const roleFilter = document.getElementById("role-filter");
const channelFilter = document.getElementById("channel-filter");
const hwFilter = document.getElementById("hw-filter");
const firmwareFilter = document.getElementById("firmware-filter");
const searchBox = document.getElementById("search-box");
const countSpan = document.getElementById("node-count");
const statusSpan = document.getElementById("node-status");
const exportBtn = document.getElementById("export-btn");
const clearBtn = document.getElementById("clear-btn");
const favoritesBtn = document.getElementById("favorites-btn");
let lastIsMobile = (window.innerWidth <= 768);
try {
const response = await fetch("/api/nodes?days_active=3");
if (!response.ok) throw new Error("Failed to fetch nodes");
const data = await response.json();
allNodes = data.nodes;
setStatus("Loading nodes…");
await nextFrame();
const res = await fetch("/api/nodes?days_active=3");
if (!res.ok) throw new Error("Failed to fetch nodes");
const data = await res.json();
allNodes = data.nodes.map(n => {
const firmware = n.firmware || n.firmware_version || "";
const last_seen_us = n.last_seen_us || 0;
const last_seen_ms = last_seen_us ? (last_seen_us / 1000) : 0;
return {
...n,
firmware,
last_seen_us,
last_seen_ms,
_search: [
n.node_id,
n.id,
n.long_name,
n.short_name
]
.filter(Boolean)
.join(" ")
.toLowerCase()
};
});
populateFilters(allNodes);
renderTable(allNodes);
applyFilters(); // ensures initial sort + render uses same path
updateSortIcons();
setStatus("");
} catch (err) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center; color:red;">Error loading nodes: ${err.message}</td></tr>`;
tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:red;">
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
</td></tr>`;
setStatus("");
return;
}
roleFilter.addEventListener("change", applyFilters);
channelFilter.addEventListener("change", applyFilters);
hwFilter.addEventListener("change", applyFilters);
firmwareFilter.addEventListener("change", applyFilters);
searchBox.addEventListener("input", applyFilters);
// Debounced only for search typing
searchBox.addEventListener("input", debounce(applyFilters, 250));
exportBtn.addEventListener("click", exportToCSV);
clearBtn.addEventListener("click", clearFilters);
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
// Use event delegation for star clicks
tbody.addEventListener("click", (e) => {
// Favorite star click handler (delegated)
document.addEventListener("click", e => {
if (e.target.classList.contains('favorite-star')) {
const nodeId = parseInt(e.target.getAttribute('data-node-id'));
// Get current favorites
let favorites = getFavorites();
const index = favorites.indexOf(nodeId);
const isNowFavorite = index === -1; // Will it be a favorite after toggle?
// Update the star immediately for instant feedback
if (isNowFavorite) {
e.target.classList.add('active');
e.target.textContent = '★';
const nodeId = parseInt(e.target.dataset.nodeId, 10);
const fav = isFavorite(nodeId);
if (fav) {
e.target.classList.remove("active");
e.target.textContent = "☆";
} else {
e.target.classList.remove('active');
e.target.textContent = '☆';
e.target.classList.add("active");
e.target.textContent = "★";
}
// Save to localStorage
toggleFavorite(nodeId);
applyFilters();
}
});
headers.forEach((th, index) => {
th.addEventListener("click", () => {
const key = keyMap[index];
// ignore clicks on the "favorite" (last header) which has no sort key
if (!key) return;
sortAsc = (sortColumn === key) ? !sortAsc : true;
sortColumn = key;
applyFilters(); // apply filters and sort
applyFilters();
});
});
// Re-render on breakpoint change so mobile/desktop view switches instantly
window.addEventListener("resize", debounce(() => {
const isMobile = (window.innerWidth <= 768);
if (isMobile !== lastIsMobile) {
lastIsMobile = isMobile;
applyFilters();
}
}, 150));
function populateFilters(nodes) {
const roles = new Set();
const channels = new Set();
const hws = new Set();
const firmwares = new Set();
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
nodes.forEach(n => {
if (n.role) roles.add(n.role);
if (n.channel) channels.add(n.channel);
if (n.hw_model) hws.add(n.hw_model);
if (n.firmware) firmwares.add(n.firmware);
if (n.firmware) fws.add(n.firmware);
});
fillSelect(roleFilter, roles);
fillSelect(channelFilter, channels);
fillSelect(hwFilter, hws);
fillSelect(firmwareFilter, firmwares);
fillSelect(firmwareFilter, fws);
}
function fillSelect(select, values) {
[...values].sort().forEach(v => {
const option = document.createElement("option");
option.value = v;
option.textContent = v;
select.appendChild(option);
const opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
select.appendChild(opt);
});
}
function toggleFavoritesFilter() {
showOnlyFavorites = !showOnlyFavorites;
if (showOnlyFavorites) {
favoritesBtn.textContent = "Show All";
favoritesBtn.classList.add("active");
} else {
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
}
favoritesBtn.textContent = showOnlyFavorites
? "Show All"
: "⭐ Show Favorites";
favoritesBtn.classList.toggle("active", showOnlyFavorites);
applyFilters();
}
function applyFilters() {
async function applyFilters() {
setStatus("Updating…");
await nextFrame();
const searchTerm = searchBox.value.trim().toLowerCase();
let filtered = allNodes.filter(node => {
const roleMatch = !roleFilter.value || node.role === roleFilter.value;
const channelMatch = !channelFilter.value || node.channel === channelFilter.value;
const hwMatch = !hwFilter.value || node.hw_model === hwFilter.value;
const firmwareMatch = !firmwareFilter.value || node.firmware === firmwareFilter.value;
let filtered = allNodes.filter(n => {
const roleMatch = !roleFilter.value || n.role === roleFilter.value;
const channelMatch = !channelFilter.value || n.channel === channelFilter.value;
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
const searchMatch = !searchTerm ||
(node.long_name && node.long_name.toLowerCase().includes(searchTerm)) ||
(node.short_name && node.short_name.toLowerCase().includes(searchTerm)) ||
(node.node_id && node.node_id.toString().includes(searchTerm)) ||
(node.id && node.id.toString().includes(searchTerm));
const searchMatch = !searchTerm || n._search.includes(searchTerm);
const favMatch = !showOnlyFavorites || isFavorite(n.node_id);
const favoriteMatch = !showOnlyFavorites || isFavorite(node.node_id);
return roleMatch && channelMatch && hwMatch && firmwareMatch && searchMatch && favoriteMatch;
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
});
if (sortColumn) {
filtered = sortNodes(filtered, sortColumn, sortAsc);
}
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
filtered = sortNodes(filtered, sortColumn, sortAsc);
renderTable(filtered);
updateSortIcons();
setStatus("");
}
function renderTable(nodes) {
tbody.innerHTML = "";
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center; color:white;">No nodes found</td></tr>';
const isMobile = window.innerWidth <= 768;
const shouldRenderTable = !isMobile;
if (shouldRenderTable) {
tbody.innerHTML = "";
} else {
nodes.forEach(node => {
mobileList.innerHTML = "";
}
const tableFrag = shouldRenderTable ? document.createDocumentFragment() : null;
const mobileFrag = shouldRenderTable ? null : document.createDocumentFragment();
if (!nodes.length) {
if (shouldRenderTable) {
tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:white;">
${nodelistTranslations.no_nodes_found || "No nodes found"}
</td>
</tr>`;
} else {
mobileList.innerHTML = `<div style="text-align:center; color:white;">
${nodelistTranslations.no_nodes_found || "No nodes found"}
</div>`;
}
countSpan.textContent = 0;
return;
}
nodes.forEach(node => {
const fav = isFavorite(node.node_id);
const star = fav ? "★" : "☆";
if (shouldRenderTable) {
// DESKTOP TABLE ROW
const row = document.createElement("tr");
const isFav = isFavorite(node.node_id);
const starClass = isFav ? 'favorite-star active' : 'favorite-star';
const starIcon = isFav ? '★' : '☆';
row.innerHTML = `
<td>${node.short_name || "N/A"}</td>
<td><a href="/packet_list/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td>${node.hw_model || "N/A"}</td>
<td>${node.firmware || "N/A"}</td>
<td>${node.role || "N/A"}</td>
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.channel || "N/A"}</td>
<td>${node.last_update ? new Date(node.last_update).toLocaleString() : "N/A"}</td>
<td style="text-align:center;"><span class="${starClass}" data-node-id="${node.node_id}">${starIcon}</span></td>
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
<td style="text-align:center;">
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
${star}
</span>
</td>
`;
tbody.appendChild(row);
});
}
tableFrag.appendChild(row);
} else {
// MOBILE CARD VIEW
const card = document.createElement("div");
card.className = "node-card";
card.innerHTML = `
<div class="node-card-header">
<span>${node.short_name || node.long_name || node.node_id}</span>
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
${star}
</span>
</div>
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
<div class="node-card-field"><b>Location:</b>
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
</div>
<div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div>
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
View Node →
</a>
`;
mobileFrag.appendChild(card);
}
});
// Toggle correct view
mobileList.style.display = isMobile ? "block" : "none";
countSpan.textContent = nodes.length;
if (shouldRenderTable) {
tbody.appendChild(tableFrag);
} else {
mobileList.appendChild(mobileFrag);
}
}
function clearFilters() {
@@ -373,63 +671,103 @@ document.addEventListener("DOMContentLoaded", async function() {
hwFilter.value = "";
firmwareFilter.value = "";
searchBox.value = "";
sortColumn = "short_name";
sortAsc = true;
showOnlyFavorites = false;
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
renderTable(allNodes);
applyFilters();
updateSortIcons();
}
function exportToCSV() {
const rows = [];
const headersText = Array.from(headers).map(th => `"${th.innerText.replace(/▲|▼/g,'')}"`);
rows.push(headersText.join(","));
const headerList = Array.from(headers).map(h =>
`"${h.innerText.replace(/▲|▼/g,'')}"`
);
rows.push(headerList.join(","));
const visibleRows = tbody.querySelectorAll("tr");
visibleRows.forEach(tr => {
if (tr.children.length === 9) {
const row = Array.from(tr.children).map(td => `"${td.innerText.replace(/"/g, '""')}"`);
rows.push(row.join(","));
}
const trs = tbody.querySelectorAll("tr");
trs.forEach(tr => {
const cells = Array.from(tr.children).map(td =>
`"${td.innerText.replace(/"/g,'""')}"`
);
rows.push(cells.join(","));
});
const csvContent = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
const link = document.createElement("a");
link.href = encodeURI(csvContent);
const dateStr = new Date().toISOString().slice(0,10);
link.download = `nodes_list_${dateStr}.csv`;
link.click();
const csv = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
const a = document.createElement("a");
a.href = encodeURI(csv);
a.download = "nodelist.csv";
a.click();
}
function sortNodes(nodes, key, asc) {
return [...nodes].sort((a, b) => {
let valA = a[key] || "";
let valB = b[key] || "";
let A = a[key];
let B = b[key];
if (key === "last_lat" || key === "last_long") {
valA = Number(valA) || 0;
valB = Number(valB) || 0;
}
if (key === "last_update") {
valA = valA ? new Date(valA).getTime() : 0;
valB = valB ? new Date(valB).getTime() : 0;
if (key === "last_seen_us") {
A = A || 0;
B = B || 0;
}
if (valA < valB) return asc ? -1 : 1;
if (valA > valB) return asc ? 1 : -1;
// Normalize strings for stable sorting
if (typeof A === "string") A = A.toLowerCase();
if (typeof B === "string") B = B.toLowerCase();
if (A < B) return asc ? -1 : 1;
if (A > B) return asc ? 1 : -1;
return 0;
});
}
function updateSortIcons() {
headers.forEach((th, index) => {
headers.forEach((th, i) => {
const span = th.querySelector(".sort-icon");
if (!span) return;
span.textContent = (keyMap[index] === sortColumn) ? (sortAsc ? "▲" : "▼") : "";
span.textContent =
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
});
}
function setStatus(message) {
if (!statusSpan) return;
if (statusHideTimer) {
clearTimeout(statusHideTimer);
statusHideTimer = null;
}
if (message) {
statusShownAt = Date.now();
console.log("[nodelist] status:", message);
statusSpan.textContent = message;
statusSpan.classList.add("active");
isBusy = true;
return;
}
const elapsed = Date.now() - statusShownAt;
const remaining = Math.max(0, minStatusMs - elapsed);
if (remaining > 0) {
statusHideTimer = setTimeout(() => {
statusHideTimer = null;
console.log("[nodelist] status: cleared");
statusSpan.textContent = "";
statusSpan.classList.remove("active");
isBusy = false;
}, remaining);
return;
}
console.log("[nodelist] status: cleared");
statusSpan.textContent = "";
statusSpan.classList.remove("active");
isBusy = false;
}
});
</script>
{% endblock %}

View File

@@ -1,69 +1,550 @@
<div class="card mt-2">
<div class="card-header">
{% set from_me = packet.from_node_id == node_id %}
{% set to_me = packet.to_node_id == node_id %}
<span {% if from_me %} class="fw-bold" {% endif %}>
{{packet.from_node.long_name}}(
{%- if not from_me -%}
<a href="/node_search?q={{packet.from_node_id|node_id_to_hex}}">
{%- endif -%}
{{packet.from_node_id|node_id_to_hex}}
{%- if not from_me -%}
</a>
{%- endif -%}
)
</span>
<span {% if to_me %} class="fw-bold" {% endif %}>
{{packet.to_node.long_name}}(
{%- if not to_me -%}
<a hx-target="#node" href="/node_search?q={{packet.to_node_id|node_id_to_hex}}">
{%- endif -%}
{{packet.to_node_id|node_id_to_hex}}
{%- if not to_me -%}
</a>
{%- endif -%}
)
</span>
</div>
<div class="card-body">
<div class="card-title">
{{packet.id}}
<a href="/packet/{{packet.id}}">🔎</a>
</div>
<div class="card-text text-start">
<dl>
<dt>Import Time</dt>
<dd>{{packet.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y')}}</dd>
<dt>packet</dt>
<dd><pre>{{packet.data}}</pre></dd>
<dt>payload</dt>
<dd>
{% if packet.pretty_payload %}
<div>{{packet.pretty_payload}}</div>
{% endif %}
{% if packet.raw_mesh_packet and packet.raw_mesh_packet.decoded and packet.raw_mesh_packet.decoded.reply_id %}
<i>(Replying to: <a href="/packet/{{ packet.raw_mesh_packet.decoded.reply_id }}">{{ packet.raw_mesh_packet.decoded.reply_id }}</a>)</i>
{% endif %}
{% if packet.raw_mesh_packet.decoded and packet.raw_mesh_packet.decoded.portnum == 70 %}
<ul>
{% for node_id in packet.raw_payload.route %}
<li><a
href="/packet_list/{{node_id}}"
>
{{node_id | node_id_to_hex}}
</a>
</li>
{% endfor %}
</ul>
{% if packet.raw_mesh_packet.decoded.want_response %}
<a href="/graph/traceroute/{{packet.id}}">graph</a>
{% else %}
<a href="/graph/traceroute/{{packet.raw_mesh_packet.decoded.request_id}}">graph</a>
{% endif %}
{% endif %}
<pre>{{packet.payload}}</pre>
</dd>
</dl>
{% extends "base.html" %}
{% block title %}Packet Details{% endblock %}
{% block css %}
{{ super() }}
<style>
/* --- Packet page container --- */
.packet-container {
max-width: 900px;
margin: 0 auto;
padding: 20px 15px;
font-family: "JetBrains Mono", monospace;
}
/* --- Packet Details Card --- */
.packet-card .card-body { padding: 26px 30px; }
.packet-card {
background-color: #1e1f22;
border: 1px solid #3a3a3a;
border-radius: 12px;
color: #ddd;
margin-top: 35px;
box-shadow: 0 0 20px rgba(0,0,0,0.35);
overflow: hidden;
}
.packet-card .card-header {
background: linear-gradient(90deg, #2c2f35, #25262a);
border-bottom: 1px solid #3f3f3f;
font-weight: 600;
font-size: 1.1em;
padding: 14px 18px;
color: #e2e6ea;
display: flex;
justify-content: space-between;
align-items: center;
}
/* --- Map --- */
#map {
width: 100%;
height: 640px;
border-radius: 10px;
margin-top: 20px;
border: 1px solid #333;
display: none;
}
/* --- SOURCE MARKER --- */
.source-marker {
width: 24px;
height: 24px;
background: rgba(255,0,0,0.55);
border: 3px solid #ff0000;
border-radius: 50%;
box-shadow: 0 0 6px rgba(255,0,0,0.7);
}
/* --- Seen Table --- */
.seen-table {
border-collapse: separate;
border-spacing: 0 6px;
font-size: 0.92em;
}
.seen-table thead th {
background-color: #2a2b2f;
color: #e2e2e2;
padding: 10px 12px;
border: none !important;
text-transform: uppercase;
font-size: 0.75em;
}
.seen-table tbody td {
background: #323338;
color: #f0f0f0;
border-top: 1px solid #4a4c4f !important;
border-bottom: 1px solid #4a4c4f !important;
padding: 10px 12px !important;
}
.seen-table tbody tr:hover td { background-color: #3a3c41 !important; }
.seen-table tbody tr td:first-child {
border-left: 1px solid #4a4c4f;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.seen-table tbody tr td:last-child {
border-right: 1px solid #4a4c4f;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
</style>
{% endblock %}
{% block body %}
<div class="container mt-4 mb-5 packet-container">
<div id="loading" data-translate-lang="loading">Loading packet information...</div>
<div id="packet-card" class="packet-card d-none"></div>
<div id="map"></div>
<div id="seen-container" class="mt-4 d-none">
<h5 style="color:#ccc; margin:15px 0 10px 0;">
📡 <span data-translate-lang="seen_by">Seen By</span>
<span id="seen-count" style="color:#4da6ff;"></span>
</h5>
<div class="table-responsive">
<table class="table table-dark table-sm seen-table">
<thead>
<tr>
<th data-translate-lang="gateway">Gateway</th>
<th data-translate-lang="rssi">RSSI</th>
<th data-translate-lang="snr">SNR</th>
<th data-translate-lang="hops">Hops</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="time">Time</th>
</tr>
</thead>
<tbody id="seen-table-body"></tbody>
</table>
</div>
</div>
</div>
<script>
/* ======================================================
PACKET PAGE TRANSLATION
====================================================== */
let packetTranslations = {};
async function loadTranslationsPacket() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=packet`);
packetTranslations = await res.json();
applyTranslationsPacket(packetTranslations);
} catch (err) {
console.error("Packet translations failed:", err);
}
}
function applyTranslationsPacket(dict, root = document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (dict[key]) el.textContent = dict[key];
});
}
/* ======================================================
PACKET PAGE MAIN
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsPacket(); // <-- IMPORTANT
const packetCard = document.getElementById("packet-card");
const loading = document.getElementById("loading");
const mapDiv = document.getElementById("map");
const seenContainer = document.getElementById("seen-container");
const seenTableBody = document.getElementById("seen-table-body");
const seenCountSpan = document.getElementById("seen-count");
/* ---------------------------------------------
Identify packet ID
----------------------------------------------*/
const match = window.location.pathname.match(/\/packet\/(\d+)/);
if (!match) {
loading.textContent = packetTranslations.invalid_url || "Invalid packet URL";
return;
}
const packetId = match[1];
/* PORT LABELS (NOT TRANSLATED) */
const PORT_NAMES = {
0:"UNKNOWN APP",
1:"Text",
3:"Position",
4:"Node Info",
5:"Routing",
6:"Admin",
67:"Telemetry",
70:"Traceroute",
71:"Neighbor"
};
/* ---------------------------------------------
Fetch packet
----------------------------------------------*/
const packetRes = await fetch(`/api/packets?packet_id=${packetId}`);
const packetData = await packetRes.json();
if (!packetData.packets.length) {
loading.textContent = packetTranslations.not_found || "Packet not found.";
return;
}
const p = packetData.packets[0];
/* ---------------------------------------------
Load nodes for names & positions
----------------------------------------------*/
const nodesRes = await fetch("/api/nodes");
const nodesData = await nodesRes.json();
const nodeLookup = {};
(nodesData.nodes || []).forEach(n => nodeLookup[n.node_id] = n);
const fromNodeObj = nodeLookup[p.from_node_id];
const toNodeObj = nodeLookup[p.to_node_id];
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
const toNodeLabel =
p.to_node_id == 4294967295
? (packetTranslations.all_broadcast || "All")
: (toNodeObj?.long_name || p.to_node_id);
/* ---------------------------------------------
Parse payload for lat/lon
----------------------------------------------*/
let lat = null, lon = null;
const parsed = {};
if (p.payload?.includes(":")) {
p.payload.split("\n").forEach(line => {
const [k, v] = line.split(":").map(x=>x.trim());
if (k && v !== undefined) {
parsed[k] = v;
if (k === "latitude_i") lat = Number(v) / 1e7;
if (k === "longitude_i") lon = Number(v) / 1e7;
}
});
}
/* ---------------------------------------------
Render card
----------------------------------------------*/
const time = p.import_time_us
? new Date(p.import_time_us / 1000).toLocaleString()
: "—";
const telemetryExtras = [];
if (parsed.PDOP) telemetryExtras.push(`PDOP: ${parsed.PDOP}`);
if (parsed.sats_in_view) telemetryExtras.push(`Sats: ${parsed.sats_in_view}`);
if (parsed.ground_speed) telemetryExtras.push(`Speed: ${parsed.ground_speed}`);
if (parsed.altitude) telemetryExtras.push(`Altitude: ${parsed.altitude}`);
packetCard.innerHTML = `
<div class="card-header">
<span>
<span data-translate-lang="packet_id_label">${packetTranslations.packet_id_label || "Packet ID:"}</span>
<i>${p.id}</i>
</span>
<small>${time}</small>
</div>
<div class="card-body">
<dl>
<dt data-translate-lang="from_node">${packetTranslations.from_node || "From Node"}:</dt>
<dd><a href="/node/${p.from_node_id}">${fromNodeLabel}</a></dd>
<dt data-translate-lang="to_node">${packetTranslations.to_node || "To Node"}:</dt>
<dd>${
p.to_node_id === 4294967295
? `<i data-translate-lang="all_broadcast">${packetTranslations.all_broadcast || "All"}</i>`
: p.to_node_id === 1
? `<i data-translate-lang="direct_to_mqtt">${packetTranslations.direct_to_mqtt || "Direct to MQTT"}</i>`
: `<a href="/node/${p.to_node_id}">${toNodeLabel}</a>`
}</dd>
<dt data-translate-lang="channel">${packetTranslations.channel || "Channel"}:</dt>
<dd>${p.channel ?? "—"}</dd>
<dt data-translate-lang="port">${packetTranslations.port || "Port"}:</dt>
<dd><i>${PORT_NAMES[p.portnum] || "UNKNOWN APP"}</i> (${p.portnum})</dd>
<dt data-translate-lang="raw_payload">${packetTranslations.raw_payload || "From Raw Payload"}:</dt>
<dd><pre>${escapeHtml(p.payload ?? "—")}</pre></dd>
${
telemetryExtras.length
? `<dt data-translate-lang="decoded_telemetry">${packetTranslations.decoded_telemetry || "Decoded Telemetry"}</dt>
<dd><pre>${telemetryExtras.join("\n")}</pre></dd>`
: ""
}
${
lat && lon
? `<dt data-translate-lang="location">${packetTranslations.location || "Location:"}</dt>
<dd>${lat.toFixed(6)}, ${lon.toFixed(6)}</dd>`
: ""
}
</dl>
</div>
`;
loading.classList.add("d-none");
packetCard.classList.remove("d-none");
/* ---------------------------------------------
Map setup
----------------------------------------------*/
const map = L.map("map");
mapDiv.style.display = "block";
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 })
.addTo(map);
const allBounds = [];
/* ---------------------------------------------
Determine packet source location
----------------------------------------------*/
let srcLat = lat;
let srcLon = lon;
if ((!srcLat || !srcLon) && fromNodeObj?.last_lat && fromNodeObj?.last_long) {
srcLat = fromNodeObj.last_lat / 1e7;
srcLon = fromNodeObj.last_long / 1e7;
}
if (srcLat && srcLon) {
allBounds.push([srcLat, srcLon]);
const sourceIcon = L.divIcon({
html: `<div class="source-marker"></div>`,
className: "",
iconSize: [26, 26],
iconAnchor: [13, 13]
});
const sourceMarker = L.marker([srcLat, srcLon], {
icon: sourceIcon,
zIndexOffset: 9999
}).addTo(map);
sourceMarker.bindPopup(`
<div style="font-size:0.9em">
<b data-translate-lang="packet_source">${packetTranslations.packet_source || "Packet Source"}</b><br>
Lat: ${srcLat.toFixed(6)}<br>
Lon: ${srcLon.toFixed(6)}<br>
<span data-translate-lang="from_node">${packetTranslations.from_node || "From Node:"}</span> ${fromNodeLabel}<br>
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${p.channel ?? "—"}<br>
<span data-translate-lang="port">${packetTranslations.port || "Port:"}</span> ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
</div>
`);
} else {
map.setView([0,0], 2);
}
/* ---------------------------------------------
Colors for hops (warm → cold)
----------------------------------------------*/
function hopColor(hopValue){
const colors = [
"#ff3b30","#ff6b22","#ff9f0c","#ffd60a",
"#87d957","#57d9c4","#3db2ff","#1e63ff"
];
let h = Number(hopValue);
if (isNaN(h)) return "#aaa";
return colors[Math.min(Math.max(h, 0), 7)];
}
/* ---------------------------------------------
Distance helper
----------------------------------------------*/
function haversine(lat1, lon1, lat2, lon2){
const R = 6371;
const dLat = (lat2-lat1)*Math.PI/180;
const dLon = (lon2-lon1)*Math.PI/180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1*Math.PI/180)*
Math.cos(lat2*Math.PI/180)*
Math.sin(dLon/2)**2;
return R * (2*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
}
/* ---------------------------------------------
Load packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
/* ---------------------------------------------
Sort by hop count (highest first)
----------------------------------------------*/
const seenSorted = seenList.slice().sort((a,b)=>{
const ha = (a.hop_start ?? 0) - (a.hop_limit ?? 0);
const hb = (b.hop_start ?? 0) - (b.hop_limit ?? 0);
return hb - ha;
});
if (seenSorted.length){
seenContainer.classList.remove("d-none");
seenCountSpan.textContent = `(${seenSorted.length})`;
}
/* ---------------------------------------------
GROUP BY HOP COUNT
----------------------------------------------*/
const hopGroups = {};
seenSorted.forEach(s => {
const hopValue = Math.max(
0,
(s.hop_start ?? 0) - (s.hop_limit ?? 0)
);
if (!hopGroups[hopValue]) hopGroups[hopValue] = [];
hopGroups[hopValue].push(s);
});
/* ---------------------------------------------
Render grouped gateway table + map markers
----------------------------------------------*/
seenTableBody.innerHTML = Object.keys(hopGroups)
.sort((a,b) => Number(a) - Number(b)) // 0 hop first
.map(hopKey => {
const hopLabel =
hopKey === "0"
? (packetTranslations.direct || "Direct (0 hops)")
: `${hopKey} ${packetTranslations.hops || "hops"}`;
const rows = hopGroups[hopKey].map(s => {
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
/* ---------------- MAP MARKERS (UNCHANGED) ---------------- */
if (node?.last_lat && node.last_long){
const rlat = node.last_lat/1e7;
const rlon = node.last_long/1e7;
allBounds.push([rlat, rlon]);
let distanceKm = null;
if (srcLat && srcLon) {
distanceKm = haversine(srcLat, srcLon, rlat, rlon);
}
const distanceMi = distanceKm !== null ? distanceKm * 0.621371 : null;
const color = hopColor(hopKey);
const marker = L.marker([rlat,rlon],{
icon: L.divIcon({
html: `
<div style="
background:${color};
width:24px; height:24px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:11px;
font-weight:700;
border:2px solid rgba(0,0,0,0.35);
box-shadow:0 0 5px rgba(0,0,0,0.45);
">${hopKey}</div>`,
className: "",
iconSize:[24,24],
iconAnchor:[12,12]
})
}).addTo(map);
marker.bindPopup(`
<div style="font-size:0.9em">
<b>${label}</b><br>
<span data-translate-lang="node_id_short">Node ID</span>:
<a href="/node/${s.node_id}">${s.node_id}</a><br>
HW: ${node?.hw_model ?? "—"}<br>
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
${
distanceKm !== null
? `<span data-translate-lang="distance">Distance</span>:
${distanceKm.toFixed(1)} km / ${distanceMi.toFixed(1)} mi<br>`
: ""
}
<br>
<b data-translate-lang="signal">Signal</b><br>
RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b data-translate-lang="hops">Hops</b>: ${hopKey}
</div>
`);
}
return `
<tr>
<td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${hopKey}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>
`;
}).join("");
return `
<tr>
<td colspan="6"
style="
background:#1f2327;
font-weight:700;
color:#9ecbff;
border-top:1px solid #444;
padding:8px 12px;
">
🔁 ${hopLabel} (${hopGroups[hopKey].length})
</td>
</tr>
${rows}
`;
}).join("");
/* ---------------------------------------------
Fit map around all markers
----------------------------------------------*/
if (allBounds.length > 0){
map.fitBounds(allBounds, { padding:[40,40] });
}
/* ---------------------------------------------
Escape HTML helper
----------------------------------------------*/
function escapeHtml(unsafe) {
return (unsafe ?? "").replace(/[&<"'>]/g, m => ({
"&":"&amp;",
"<":"&lt;",
">":"&gt;",
"\"":"&quot;",
"'":"&#039;"
})[m]);
}
});
</script>
{% endblock %}

View File

@@ -1,132 +0,0 @@
<div id="details_map"></div>
{% for seen in packets_seen %}
<div class="card mt-2">
<div class="card-header">
{{seen.node.long_name}}(
<a hx-target="#node" href="/node_search?q={{seen.node_id|node_id_to_hex}}">
{{seen.node_id|node_id_to_hex}}
</a>
)
</div>
<div class="card-body">
<div class="card-text text-start">
<dl>
<dt>Import Time</dt>
<dd>{{seen.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y')}}</dd>
<dt>rx_time</dt>
<dd>{{seen.rx_time|format_timestamp}}</dd>
<dt>hop_limit</dt>
<dd>{{seen.hop_limit}}</dd>
<dt>hop_start</dt>
<dd>{{seen.hop_start}}</dd>
<dt>channel</dt>
<dd>{{seen.channel}}</dd>
<dt>rx_snr</dt>
<dd>{{seen.rx_snr}}</dd>
<dt>rx_rssi</dt>
<dd>{{seen.rx_rssi}}</dd>
<dt>topic</dt>
<dd>{{seen.topic}}</dd>
</dl>
</div>
</div>
</div>
{% endfor %}
{% if map_center %}
<script>
var details_map = L.map('details_map').setView({{ map_center | tojson }}, 8);
var markers = L.featureGroup();
markers.addTo(details_map);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 15,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(details_map);
function getDistanceInMiles(latlng1, latlng2) {
var meters = latlng1.distanceTo(latlng2);
return meters * 0.000621371;
}
{% if from_node_cord %}
var fromNodeLatLng = L.latLng({{ from_node_cord | tojson }});
var fromNode = L.circleMarker(fromNodeLatLng, {
radius: 10,
color: 'red',
weight: 1,
fillColor: 'red',
fillOpacity: .4
}).addTo(markers);
fromNode.bindPopup(`
Sent by: <b>{{node.long_name}}</b><br/>
<b>Short:</b> {{node.short_name}}<br/>
<b>Channel:</b> {{node.channel}}<br/>
<b>Hardware:</b> {{node.hw_model}}<br/>
<b>Role:</b> {{node.role}}<br/>
<b>Firmware:</b> {{node.firmware}}<br/>
<b>Coordinates:</b> [{{node.last_lat}}, {{node.last_long}}]
`, { permanent: false, direction: 'top', opacity: 0.9 });
{% endif %}
{% for u in uplinked_nodes %}
var uplinkNodeLatLng = L.latLng([{{ u.lat }}, {{ u.long }}]);
{% if from_node_cord %}
var distanceMiles = getDistanceInMiles(fromNodeLatLng, uplinkNodeLatLng).toFixed(1);
{% endif %}
var node = L.marker(uplinkNodeLatLng, {
icon: L.divIcon({
className: 'text-icon',
html: `<div style="font-size: 12px; color: white; font-weight: bold; display: flex; justify-content: center; align-items: center; height: 16px; width: 16px; border-radius: 50%; background-color: blue; border: 1px solid blue;">{{u.hops}}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}).addTo(markers);
node.setZIndexOffset({{u.hops}}*-1);
node.bindPopup(`
Heard by: <b>{{u.long_name}}</b><br>
<b>{{ u.short_name }}</b><br/>
<b>Hops:</b> {{ u.hops }}<br/>
<b>SNR:</b> {{ u.snr }}<br/>
<b>RSSI:</b> {{ u.rssi }}<br/>
{% if from_node_cord %}
<b>Distance:</b> ${distanceMiles} miles <br/>
{% endif %}
<b>Coordinates:</b> [{{u.lat}}, {{u.long}}]
`, { permanent: false, direction: 'top', opacity: 0.9 });
{% endfor %}
if (markers.getLayers().length > 0) {
details_map.fitBounds(markers.getBounds().pad(0.1), { animate: true });
}
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'info legend');
div.style.background = 'white';
div.style.padding = '8px';
div.style.border = '1px solid black';
div.style.borderRadius = '5px';
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
div.style.color = 'black';
div.style.textAlign = 'left';
div.innerHTML = `
<b>Legend</b><br>
<svg width="20" height="20">
<circle cx="8" cy="8" r="6" fill="blue" stroke="blue" stroke-width="1" fill-opacity="0.9"/>
</svg> Receiving Node (Number is hop count)<br>
<svg width="20" height="20">
<circle cx="10" cy="10" r="8" fill="red" stroke="red" stroke-width="1" fill-opacity="0.4"/>
</svg> Sending Node<br>
`;
return div;
};
legend.addTo(details_map);
</script>
{% endif %}

View File

@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block css %}
/* Set the maximum width of the page to 900px */
.container {
max-width: 900px;
margin: 0 auto; /* Center the content horizontally */
}
{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div>
{% include 'packet.html' %}
</div>
<div
id="packet_details"
hx-get="/packet_details/{{packet.id}}"
hx-trigger="load"
>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,7 +0,0 @@
<div class="col" id="packet_list">
{% for packet in packets %}
{% include 'packet.html' %}
{% else %}
No packets found.
{% endfor %}
</div>

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% block body %}
{% include "search_form.html" %}
<ul>
{% for node in nodes %}
<li>
<a href="/packet_list/{{node.node_id}}?{{query_string}}">
{{node.node_id | node_id_to_hex}}
{% if node.long_name %}
{{node.short_name}} &mdash; {{node.long_name}}
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -1,44 +0,0 @@
<form
class="container p-2 sticky-top mx-auto"
id="search_form"
action="/node_search"
>
<div class="row">
<input
class="col m-2"
id="q"
type="text"
name="q"
placeholder="Node id"
autocomplete="off"
list="node_options"
value="{{raw_node_id}}"
hx-trigger="input delay:100ms"
hx-get="/node_match"
hx-target="#node_options"
/>
{% include "datalist.html" %}
{% set options = {
1: "Text Message",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info",
}
%}
<select name="portnum" class="col-2 m-2">
<option
value = ""
{% if portnum not in options %}selected{% endif %}
>All</option>
{% for value, name in options.items() %}
<option
value="{{value}}"
{% if value == portnum %}selected{% endif %}
>{{ name }}</option>
{% endfor %}
</select>
<input type="submit" value="Go to Node" class="col-2 m-2" />
</div>
</form>

View File

@@ -93,26 +93,31 @@
{% block body %}
<div class="main-container">
<h2 class="main-header" data-translate-lang="mesh_stats_summary">Mesh Statistics - Summary (all available in Database)</h2>
<h2 class="main-header" data-translate-lang="mesh_stats_summary">
Mesh Statistics - Summary (all available in Database)
</h2>
<!-- Summary cards now fully driven by API + JS -->
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_nodes">Total Nodes</p>
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
<div class="summary-count" id="summary_nodes">0</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_packets">Total Packets</p>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
<div class="summary-count" id="summary_packets">0</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
<div class="summary-count" id="summary_seen">0</div>
</div>
</div>
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_all">Packets per Day - All Ports (Last 14 Days)</p>
<p class="section-header" data-translate-lang="packets_per_day_all">
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" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all" data-translate-lang="export_csv">Export CSV</button>
@@ -121,7 +126,9 @@
<!-- Packet Types Pie Chart with Channel Selector -->
<div class="card-section">
<p class="section-header" data-translate-lang="packet_types_last_24h">Packet Types - Last 24 Hours</p>
<p class="section-header" data-translate-lang="packet_types_last_24h">
Packet Types - Last 24 Hours
</p>
<select id="channelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
@@ -131,7 +138,9 @@
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_text">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
<p class="section-header" data-translate-lang="packets_per_day_text">
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" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_portnum_1" data-translate-lang="export_csv">Export CSV</button>
@@ -140,7 +149,9 @@
<!-- Hourly Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_hour_all">Packets per Hour - All Ports</p>
<p class="section-header" data-translate-lang="packets_per_hour_all">
Packets per Hour - All Ports
</p>
<div id="total_hourly_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_hourly_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_hourly_all" data-translate-lang="export_csv">Export CSV</button>
@@ -148,7 +159,9 @@
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_hour_text">Packets per Hour - Text Messages (Port 1)</p>
<p class="section-header" data-translate-lang="packets_per_hour_text">
Packets per Hour - Text Messages (Port 1)
</p>
<div id="total_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_1" data-translate-lang="export_csv">Export CSV</button>
@@ -214,17 +227,123 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} }
async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
async function fetchNodes(){
try{
const res=await fetch("/api/nodes");
const json=await res.json();
return json.nodes||[];
}catch{
return [];
}
}
function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); }
function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; }
function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; }
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=>{
const key=n[field]||"Unknown";
counts[key]=(counts[key]||0)+1;
});
return Object.entries(counts).map(([name,value])=>({name,value}));
}
function updateTotalCount(domId,data){
const el=document.getElementById(domId);
if(!el||!data.length) return;
const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0);
el.textContent=`Total: ${total.toLocaleString()}`;
}
function prepareTopN(data,n=20){
data.sort((a,b)=>b.value-a.value);
let top=data.slice(0,n);
if(data.length>n){
const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0);
top.push({name:"Other", value:otherValue});
}
return top;
}
// --- Chart Rendering ---
function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; }
function renderChart(domId,data,type,color){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.init(el);
const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():'');
const counts=data.map(d=>d.count??d.packet_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{
type:'category',
data:periods,
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{rotate:45,color:'#ccc'}
},
yAxis:{
type:'value',
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{color:'#ccc'}
},
series:[{
data:counts,
type:type,
smooth:type==='line',
itemStyle:{color:color},
areaStyle:type==='line'?{}:undefined
}]
});
return chart;
}
function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; }
function renderPieChart(elId,data,name){
const el=document.getElementById(elId);
if(!el) return;
const chart=echarts.init(el);
const top20=prepareTopN(data,20);
chart.setOption({
backgroundColor:"#272b2f",
tooltip:{
trigger:"item",
formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`
},
series:[{
name:name,
type:"pie",
radius:["30%","70%"],
center:["50%","50%"],
avoidLabelOverlap:true,
itemStyle:{
borderRadius:6,
borderColor:"#272b2f",
borderWidth:2
},
label:{
show:true,
formatter:"{b}\n{d}%",
color:"#ccc",
fontSize:10
},
labelLine:{
show:true,
length:10,
length2:6
},
data:top20
}]
});
return chart;
}
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
@@ -234,8 +353,10 @@ async function fetchPacketTypeBreakdown(channel=null) {
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);
@@ -250,40 +371,102 @@ let chartHwModel, chartRole, chartChannel;
let chartPacketTypes;
async function init(){
// Channel selector
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); });
channels.forEach(ch=>{
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
});
// Daily all ports
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
// Daily port 1
const dailyPort1Data=await fetchStats('day',14,1);
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
// Hourly all ports
const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
// Hourly per port
const portnums=[1,3,4,67,70,71];
const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50'];
const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71'];
const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71'];
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
for(let i=0;i<portnums.length;i++){ updateTotalCount(totalIds[i],allData[i]); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]); }
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
for(let i=0;i<portnums.length;i++){
updateTotalCount(totalIds[i],allData[i]);
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]);
}
// Nodes for breakdown + summary node count
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");
const summaryNodesEl = document.getElementById("summary_nodes");
if (summaryNodesEl) {
summaryNodesEl.textContent = nodes.length.toLocaleString();
}
// Packet types pie
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 }));
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)");
// Total packet + total seen from /api/stats/count
try {
const countsRes = await fetch("/api/stats/count");
if (countsRes.ok) {
const countsJson = await countsRes.json();
const elPackets = document.getElementById("summary_packets");
const elSeen = document.getElementById("summary_seen");
if (elPackets) {
elPackets.textContent = (countsJson.total_packets || 0).toLocaleString();
}
if (elSeen) {
elSeen.textContent = (countsJson.total_seen || 0).toLocaleString();
}
}
} catch (err) {
console.error("Failed to load /api/stats/count:", err);
}
}
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
window.addEventListener('resize',()=>{
[
chartHourlyAll,
chartPortnum1,
chartPortnum3,
chartPortnum4,
chartPortnum67,
chartPortnum70,
chartPortnum71,
chartDailyAll,
chartDailyPortnum1,
chartHwModel,
chartRole,
chartChannel,
chartPacketTypes
].forEach(c=>c?.resize());
});
const modal=document.getElementById("chartModal");
const modalChartEl=document.getElementById("modalChart");
@@ -345,31 +528,51 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
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 }));
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)");
});
// Kick everything off
init();
// --- Translation Loader ---
async function loadTranslations() {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
// --- Load config and translations ---
async function loadConfigAndTranslations() {
let langCode = "en";
try {
const res = await fetch(`/api/lang?lang=${langCode}&section=stats`);
window.statsTranslations = await res.json();
} catch(err){
const resConfig = await fetch("/api/config");
const cfg = await resConfig.json();
window.site_config = cfg;
langCode = cfg?.site?.language || "en";
} catch(err) {
console.error("Failed to load /api/config:", err);
window.site_config = { site: { language: "en" } };
}
try {
const resLang = await fetch(`/api/lang?lang=${langCode}&section=stats`);
window.statsTranslations = await resLang.json();
} catch(err) {
console.error("Stats translation load failed:", err);
window.statsTranslations = {};
}
}
function applyTranslations() {
// Apply translations
const t = window.statsTranslations || {};
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
const key = el.getAttribute("data-translate-lang");
if(t[key]) el.textContent = t[key];
});
}
loadTranslations().then(applyTranslations);
// Call after init
loadConfigAndTranslations();
</script>
{% endblock %}

View File

@@ -1,80 +0,0 @@
{% extends "base.html" %}
{% block css %}
#packet_details {
height: 95vh;
overflow: auto;
}
.main-container, .container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.card-section {
background-color: #272b2f;
border: 1px solid #474b4e;
padding: 15px 20px;
margin-bottom: 10px;
border-radius: 10px;
transition: background-color 0.2s ease;
}
.card-section:hover {
background-color: #2f3338;
}
.section-header {
font-size: 16px;
margin: 0;
font-weight: 500;
}
.section-value {
font-weight: 700;
color: #03dac6;
}
.percentage {
font-size: 12px;
color: #ffeb3b;
font-weight: 400;
}
.main-header {
font-size: 22px;
margin-bottom: 20px;
font-weight: 600;
}
{% endblock %}
{% block body %}
<div class="main-container">
<h2 class="main-header">Mesh Statistics</h2>
<!-- Section for Total Nodes -->
<div class="card-section">
<p class="section-header">
Total Active Nodes (24 hours): <br>
<span class="section-value">{{ "{:,}".format(total_nodes) }}</span>
</p>
</div>
<!-- Section for Total Packets -->
<div class="card-section">
<p class="section-header">
Total Packets (14 days):
<span class="section-value">{{ "{:,}".format(total_packets) }}</span>
</p>
</div>
<!-- Section for Total MQTT Reports -->
<div class="card-section">
<p class="section-header">
Total MQTT Reports (14 days):
<span class="section-value">{{ "{:,}".format(total_packets_seen) }}</span>
</p>
</div>
</div>
{% endblock %}

View File

@@ -2,287 +2,240 @@
{% block css %}
<style>
/* General table styling */
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
body { background-color: #121212; color: #ddd; }
h1 { text-align: center; margin-top: 20px; color: #fff; }
table th, table td {
padding: 12px;
text-align: left;
border: 1px solid #ddd;
cursor: pointer;
}
table th {
background-color: #333;
color: #fff;
font-weight: bold;
}
table tbody tr:nth-child(odd) {
background-color: #272b2f;
}
table tbody tr:nth-child(even) {
background-color: #212529;
}
table tbody tr:hover {
background-color: #555;
}
table td {
color: #ddd;
}
table th, table td {
border-radius: 8px;
}
@media (max-width: 768px) {
table th, table td {
padding: 8px;
.top-container {
max-width: 1100px;
margin: 25px auto;
padding: 0 15px;
}
}
h1 {
text-align: center;
color: #ddd;
margin-top: 20px;
margin-bottom: 20px;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 20px;
margin-bottom: 15px;
}
#bellCurveChart {
height: 400px;
width: 100%;
max-width: 100%;
margin-top: 30px;
}
.filter-bar select {
background-color: #1f2327;
border: 1px solid #444;
color: #ddd;
}
#stats {
text-align: center;
margin-top: 20px;
color: #fff;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
select {
margin: 10px auto;
display: block;
padding: 6px 10px;
border-radius: 6px;
background-color: #333;
color: #fff;
border: 1px solid #555;
}
table th, table td {
padding: 12px;
border: 1px solid #444;
text-align: left;
color: #ddd;
}
table th { background-color: #333; }
table tbody tr:nth-child(odd) { background-color: #272b2f; }
table tbody tr:nth-child(even) { background-color: #212529; }
table tbody tr:hover { background-color: #555; cursor: pointer; }
.node-link {
color: #9fd4ff;
text-decoration: none;
}
.node-link:hover { text-decoration: underline; }
.good-x { color: #81ff81; font-weight: bold; }
.ok-x { color: #e8e86d; font-weight: bold; }
.bad-x { color: #ff6464; font-weight: bold; }
.pagination {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 15px;
}
</style>
{% endblock %}
{% block body %}
<h1 data-translate-lang="top_traffic_nodes">Top Traffic Nodes (last 24 hours)</h1>
<!-- Channel Filter Dropdown -->
<select id="channelFilter"></select>
<h1 data-translate-lang="top_traffic_nodes">Top Nodes Traffic</h1>
<div id="stats">
<p data-translate-lang="chart_description_1">
This chart shows a bell curve (normal distribution) based on the total <strong>"Times Seen"</strong> values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.
</p>
<p data-translate-lang="chart_description_2">
This "Times Seen" value is the closest that we can get to Mesh utilization by node.
</p>
<p>
<strong data-translate-lang="mean_label">Mean:</strong> <span id="mean"></span> -
<strong data-translate-lang="stddev_label">Standard Deviation:</strong> <span id="stdDev"></span>
</p>
</div>
<div class="top-container">
<!-- Chart -->
<div id="bellCurveChart"></div>
<div class="filter-bar">
<div>
<label data-translate-lang="channel">Channel:</label>
<select id="channelFilter"></select>
</div>
</div>
<!-- Table -->
{% if nodes %}
<div class="container">
<table id="trafficTable">
<div style="margin-bottom:10px;font-weight:bold;">
<span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span>
</div>
<table id="nodesTable">
<thead>
<tr>
<th data-translate-lang="long_name" onclick="sortTable(0)">Long Name</th>
<th data-translate-lang="short_name" onclick="sortTable(1)">Short Name</th>
<th data-translate-lang="channel" onclick="sortTable(2)">Channel</th>
<th data-translate-lang="packets_sent" onclick="sortTable(3)">Packets Sent</th>
<th data-translate-lang="times_seen" onclick="sortTable(4)">Times Seen</th>
<th data-translate-lang="seen_percent" onclick="sortTable(5)">Seen % of Mean</th>
<th data-translate-lang="long_name">Long Name</th>
<th data-translate-lang="short_name">Short Name</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="packets_sent">Sent (24h)</th>
<th data-translate-lang="times_seen">Seen (24h)</th>
<th data-translate-lang="avg_gateways">Avg Gateways</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="prevPage" class="btn btn-sm btn-secondary">Prev</button>
<span id="pageInfo"></span>
<button id="nextPage" class="btn btn-sm btn-secondary">Next</button>
</div>
</div>
{% else %}
<p style="text-align: center;" data-translate-lang="no_nodes">No top traffic nodes available.</p>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.2/dist/echarts.min.js"></script>
<script>
const nodes = {{ nodes | tojson }};
let filteredNodes = [];
/* ======================================================
TRANSLATIONS
====================================================== */
let topTranslations = {};
// --- Language support ---
async function loadTopTranslations() {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
try {
const res = await fetch(`/api/lang?lang=${langCode}&section=top`);
window.topTranslations = await res.json();
} catch(err) {
console.error("Top page translation load failed:", err);
window.topTranslations = {};
}
}
function applyTopTranslations() {
const t = window.topTranslations || {};
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
const key = el.getAttribute("data-translate-lang");
if(t[key]) el.textContent = t[key];
function applyTranslationsTop(dict, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (!dict[key]) return;
el.textContent = dict[key];
});
}
// --- Chart & Table code ---
const chart = echarts.init(document.getElementById('bellCurveChart'));
const meanEl = document.getElementById('mean');
const stdEl = document.getElementById('stdDev');
// Populate channel dropdown
const channelSet = new Set();
nodes.forEach(n => channelSet.add(n.channel));
const dropdown = document.getElementById('channelFilter');
[...channelSet].sort().forEach(channel => {
const option = document.createElement('option');
option.value = channel;
option.textContent = channel;
if (channel === "LongFast") option.selected = true;
dropdown.appendChild(option);
});
// Filter default
filteredNodes = nodes.filter(n => n.channel === "LongFast");
dropdown.addEventListener('change', () => {
const val = dropdown.value;
filteredNodes = nodes.filter(n => n.channel === val);
updateTable();
updateStatsAndChart();
});
// Normal distribution
function normalDistribution(x, mean, stdDev) {
return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * Math.pow((x - mean) / stdDev, 2));
async function loadTranslationsTop() {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=top`);
topTranslations = await res.json();
applyTranslationsTop(topTranslations);
}
// Update table
function updateTable() {
const tbody = document.querySelector('#trafficTable tbody');
/* ======================================================
CONFIG
====================================================== */
const PAGE_SIZE = 20;
let currentPage = 0;
let totalRows = 0;
/* ======================================================
HELPERS
====================================================== */
function avgClass(v) {
if (v >= 10) return "good-x";
if (v >= 2) return "ok-x";
return "bad-x";
}
/* ======================================================
LOAD CHANNELS
====================================================== */
async function loadChannels() {
const res = await fetch("/api/channels");
const data = await res.json();
const sel = document.getElementById("channelFilter");
sel.innerHTML = "";
for (const ch of data.channels || []) {
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
sel.appendChild(opt);
}
sel.value = "MediumFast";
}
/* ======================================================
FETCH + RENDER
====================================================== */
async function renderTable() {
const tbody = document.querySelector("#nodesTable tbody");
tbody.innerHTML = "";
const mean = filteredNodes.reduce((sum, n) => sum + n.total_times_seen, 0) / (filteredNodes.length || 1);
for (const node of filteredNodes) {
const percent = mean > 0 ? ((node.total_times_seen / mean) * 100).toFixed(1) + "%" : "0%";
const row = `<tr>
<td><a href="/packet_list/${node.node_id}">${node.long_name}</a></td>
<td>${node.short_name}</td>
<td>${node.channel}</td>
<td><a href="/top?node_id=${node.node_id}">${node.total_packets_sent}</a></td>
<td>${node.total_times_seen}</td>
<td>${percent}</td>
</tr>`;
tbody.insertAdjacentHTML('beforeend', row);
const channel = document.getElementById("channelFilter").value;
const offset = currentPage * PAGE_SIZE;
const url = new URL("/api/stats/top", window.location.origin);
url.searchParams.set("limit", PAGE_SIZE);
url.searchParams.set("offset", offset);
if (channel) url.searchParams.set("channel", channel);
const res = await fetch(url);
const data = await res.json();
totalRows = data.total || 0;
for (const n of data.nodes || []) {
const tr = document.createElement("tr");
tr.onclick = () => location.href = `/node/${n.node_id}`;
tr.innerHTML = `
<td>
<a class="node-link" href="/node/${n.node_id}"
onclick="event.stopPropagation()">
${n.long_name || n.node_id}
</a>
</td>
<td>${n.short_name || ""}</td>
<td>${n.channel || ""}</td>
<td>${n.sent}</td>
<td>${n.seen}</td>
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
`;
tbody.appendChild(tr);
}
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
document.getElementById("node-count").textContent = totalRows;
document.getElementById("pageInfo").textContent =
`Page ${currentPage + 1} / ${totalPages}`;
document.getElementById("prevPage").disabled = currentPage === 0;
document.getElementById("nextPage").disabled = currentPage >= totalPages - 1;
}
// Update chart & stats
function updateStatsAndChart() {
const timesSeen = filteredNodes.map(n => n.total_times_seen);
const mean = timesSeen.reduce((sum,v)=>sum+v,0)/(timesSeen.length||1);
const stdDev = Math.sqrt(timesSeen.reduce((sum,v)=>sum+Math.pow(v-mean,2),0)/(timesSeen.length||1));
meanEl.textContent = mean.toFixed(2);
stdEl.textContent = stdDev.toFixed(2);
/* ======================================================
INIT
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsTop();
await loadChannels();
await renderTable();
const min = Math.min(...timesSeen);
const max = Math.max(...timesSeen);
const step = (max - min) / 100;
const xData=[], yData=[];
for(let x=min;x<=max;x+=step){ xData.push(x); yData.push(normalDistribution(x,mean,stdDev)); }
channelFilter.onchange = () => {
currentPage = 0;
renderTable();
};
chart.setOption({
animation:false,
tooltip:{ trigger:'axis' },
xAxis:{ name:'Total Times Seen', type:'value', min, max },
yAxis:{ name:'Probability Density', type:'value' },
series:[{ data:xData.map((x,i)=>[x,yData[i]]), type:'line', smooth:true, color:'blue', lineStyle:{ width:3 }}]
});
chart.resize();
}
prevPage.onclick = () => {
if (currentPage > 0) {
currentPage--;
renderTable();
}
};
// Sort table
function sortTable(n) {
const table = document.getElementById("trafficTable");
const rows = Array.from(table.rows).slice(1);
const header = table.rows[0].cells[n];
const isNumeric = !isNaN(rows[0].cells[n].innerText.replace('%',''));
let sortedRows = rows.sort((a,b)=>{
const valA = isNumeric ? parseFloat(a.cells[n].innerText.replace('%','')) : a.cells[n].innerText.toLowerCase();
const valB = isNumeric ? parseFloat(b.cells[n].cells[n].innerText.replace('%','')) : b.cells[n].innerText.toLowerCase();
return valA > valB ? 1 : -1;
});
if(header.getAttribute('data-sort-direction')==='asc'){ sortedRows.reverse(); header.setAttribute('data-sort-direction','desc'); }
else header.setAttribute('data-sort-direction','asc');
const tbody = table.tBodies[0];
sortedRows.forEach(row=>tbody.appendChild(row));
}
// Initialize
(async ()=>{
await loadTopTranslations();
applyTopTranslations();
updateTable();
updateStatsAndChart();
window.addEventListener('resize',()=>chart.resize());
})();
nextPage.onclick = () => {
currentPage++;
renderTable();
};
});
</script>
{% if timing_data %}
<!-- Performance Metrics Summary -->
<div style="background-color: #1a1d21; border: 1px solid #444; border-radius: 8px; padding: 15px; margin: 20px auto; max-width: 800px; color: #fff;">
<h3 style="margin-top: 0; color: #4CAF50;">⚡ Performance Metrics</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div>
<strong>Database Query:</strong><br>
<span style="color: #FFD700; font-size: 1.2em;">{{ timing_data.db_query_ms }}ms</span>
</div>
<div>
<strong>Data Processing:</strong><br>
<span style="color: #FFD700; font-size: 1.2em;">{{ timing_data.processing_ms }}ms</span>
</div>
<div>
<strong>Total Time:</strong><br>
<span style="color: #FFD700; font-size: 1.2em;">{{ timing_data.total_ms }}ms</span>
</div>
<div>
<strong>Nodes Processed:</strong><br>
<span style="color: #4CAF50; font-size: 1.2em;">{{ timing_data.node_count }}</span>
</div>
<div>
<strong>Total Packets:</strong><br>
<span style="color: #4CAF50; font-size: 1.2em;">{{ "{:,}".format(timing_data.total_packets) }}</span>
</div>
<div>
<strong>Times Seen:</strong><br>
<span style="color: #4CAF50; font-size: 1.2em;">{{ "{:,}".format(timing_data.total_seen) }}</span>
</div>
</div>
<p style="margin-bottom: 0; margin-top: 10px; font-size: 0.9em; color: #888;">
📊 Use these metrics to measure performance before and after database index changes
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,94 +0,0 @@
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block body %}
<div id="mynetwork" style="width: 100%; height: 800px;"></div>
<script type="text/javascript">
const chart = echarts.init(document.getElementById('mynetwork'));
const rawNodes = {{ chart_data['nodes'] | tojson }};
const rawEdges = {{ chart_data['edges'] | tojson }};
// Build DAG layout
const layers = {};
const nodeDepth = {};
// Organize nodes into layers by hop count
for (const edge of rawEdges) {
const { source, target } = edge;
if (!(source in nodeDepth)) nodeDepth[source] = 0;
const nextDepth = nodeDepth[source] + 1;
nodeDepth[target] = Math.max(nodeDepth[target] || 0, nextDepth);
}
for (const node of rawNodes) {
const depth = nodeDepth[node.name] || 0;
if (!(depth in layers)) layers[depth] = [];
layers[depth].push(node);
}
// Position nodes manually
const chartNodes = [];
const layerKeys = Object.keys(layers).sort((a, b) => +a - +b);
const verticalSpacing = 100;
const horizontalSpacing = 180;
layerKeys.forEach((depth, layerIndex) => {
const layer = layers[depth];
const y = layerIndex * verticalSpacing;
const xStart = -(layer.length - 1) * horizontalSpacing / 2;
layer.forEach((node, i) => {
chartNodes.push({
...node,
x: xStart + i * horizontalSpacing,
y: y,
itemStyle: {
color: '#dddddd',
borderColor: '#222',
borderWidth: 2,
},
label: {
show: true,
position: 'inside',
color: '#000',
fontSize: 12,
formatter: node.short_name || node.name,
},
});
});
});
const chartEdges = rawEdges.map(edge => ({
source: edge.source,
target: edge.target,
lineStyle: {
color: edge.originalColor || '#ccc',
width: 2,
type: 'solid',
},
}));
const option = {
backgroundColor: '#fff',
tooltip: {},
animation: false,
series: [{
type: 'graph',
layout: 'none',
coordinateSystem: null,
data: chartNodes,
links: chartEdges,
roam: true,
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: [0, 10],
lineStyle: {
curveness: 0,
},
}],
};
chart.setOption(option);
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More