Compare commits

..

59 Commits

Author SHA1 Message Date
l5y
5a47a8f8e4 Reformat neighbor overlay details (#237) 2025-10-06 08:08:24 +02:00
l5y
c13f3c913f Add neighbor lines toggle to map legend (#236) 2025-10-06 08:05:44 +02:00
l5y
2e9b54b6cf Hide Air Util Tx column on mobile (#235) 2025-10-06 08:04:07 +02:00
l5y
7e844be627 Add overlay for clickable neighbor links on map (#234)
* Add overlay for clickable neighbor links on map

* Fix neighbor overlays and include SNR details

* Prevent map neighbor overlay clicks from closing immediately
2025-10-06 07:41:11 +02:00
l5y
b37e55c29a Hide humidity and pressure on mobile (#232) 2025-10-06 06:34:48 +02:00
l5y
332ba044f2 Remove last position timestamp from map info overlay (#233) 2025-10-06 06:34:37 +02:00
l5y
09a2d849ec Improve live node positions and expose precision metadata (#231)
* Fetch latest node positions and precision metadata

* Stop showing position source and precision in UI

* Guard node positions against stale merges
2025-10-05 23:08:57 +02:00
l5y
a3fb9b0d5c Show neighbor short names in info overlays (#228)
* Show neighbor short names in info overlays

* Adjust neighbor info placement
2025-10-05 22:04:29 +02:00
l5y
192978acf9 Add telemetry environmental data to node UI (#227) 2025-10-05 21:49:28 +02:00
l5y
581aaea93b Reduce neighbor line opacity (#226) 2025-10-05 21:45:05 +02:00
l5y
299752a4f1 Visualize neighbor connections on map canvas (#224)
* Visualize neighbor connections on map

* Gracefully handle neighbor fetch failures
2025-10-05 21:27:41 +02:00
l5y
142c0aa539 Add clear control to filter input (#225) 2025-10-05 21:26:37 +02:00
l5y
78168ce3db Handle Bluetooth shutdown hangs gracefully (#221)
* Handle Bluetooth shutdown hangs gracefully

* Make interface close guard compatible with patched Event
2025-10-05 21:07:19 +02:00
l5y
332abbc183 Adjust mesh priorities and receive topics (#220) 2025-10-05 20:50:34 +02:00
l5y
c136c5cf26 Add BLE and fallback mesh interface handling (#219)
* Add BLE and fallback mesh interface support

* Handle SIGINT by propagating KeyboardInterrupt

* Guard optional BLE dependency

* run black
2025-10-05 20:48:23 +02:00
l5y
2a65e89eee Add neighbor info ingestion and API endpoints (#218)
* Add neighbor info ingestion and API support

* Fix neighbor spec and add fixture

* run black

* run rufo
2025-10-05 12:35:13 +02:00
l5y
d6f1e7bc80 Add debug logs for unknown node creation and last-heard updates (#214)
* Add debug logging for unknown nodes and last-heard updates

* Fix debug log syntax
2025-10-04 21:25:23 +02:00
l5y
5ac5f3ec3f Update node last seen when events are received (#212)
* Update node last seen timestamps from event receive times

* run rufo

* fix tests
2025-10-04 21:11:16 +02:00
l5y
bb4cbfa62c Improve debug logging for node and telemetry data (#213)
* Improve debug logging for node and telemetry data

* run black
2025-10-04 21:03:03 +02:00
l5y
f0d600e5d7 Improve stored message debug logging (#211) 2025-10-04 20:53:54 +02:00
l5y
e0f0a6390d Stop repeating ingestor node info snapshot and timestamp debug logs (#210)
* Adjust ingestor node snapshot cadence and debug logging

* Ensure node snapshot waits for data

* run black
2025-10-04 20:41:53 +02:00
l5y
d4a27dccf7 Add telemetry API and ingestion support (#205)
* Add telemetry ingestion and API support

* Flatten telemetry storage and API responses

* Fix telemetry insert placeholder count

* Adjust telemetry node updates

* run black

* run rufo
2025-10-04 18:28:18 +02:00
l5y
74c4596dc5 Add private mode to hide chat and message APIs (#204)
* Add private mode to hide chat and message APIs

* run rufo
2025-10-04 09:36:43 +02:00
l5y
1f2328613c Handle offline-ready map fallback (#202) 2025-10-03 11:24:18 +02:00
l5y
eeca67f6ea Add linux/armv7 images and configuration support (#201) 2025-10-03 11:11:14 +02:00
l5y
4ae8a1cfca Update Docker documentation (#200)
* Update Docker documentation

* docs: reference compose file
2025-10-03 11:03:25 +02:00
l5y
ff06129a6f Update node last seen when ingesting encrypted messages (#198)
* Update node last seen for encrypted messages

* run rufo
2025-10-03 10:59:12 +02:00
l5y
6d7aa4dd56 fix api in readme (#197) 2025-10-01 14:16:54 +00:00
l5y
4548f750d3 Add connection recovery for TCP interface (#186)
* Add connection recovery for TCP interface

* run black
2025-09-27 18:52:56 +02:00
l5y
31f02010d3 bump version to 0.3 (#191)
* bump version to 0.3

* update readme
2025-09-27 18:52:41 +02:00
l5y
ec1ea5cbba pgrade styles and fix interface issues (#190) 2025-09-27 18:46:56 +02:00
l5y
8500c59755 some updates in the front (#188)
* ok, i'm added correct image loader

* and some css

* make zebra in a table and add a background and some little changes in app

* for example you can check how it work on https://vrs.kdd2105.ru

* fix ai comments

---------

Co-authored-by: dkorotkih2014-hub <d.korotkih2014@gmail.com>
2025-09-27 18:18:02 +02:00
l5y
556dd6b51c Update last heard on node entry change (#185) 2025-09-26 20:43:53 +02:00
l5y
3863e2d63d Populate chat metadata for unknown nodes (#182)
* Populate chat metadata for unknown nodes

* run rufo

* fix comments

* run rufo
2025-09-26 16:45:42 +02:00
l5y
9e62621819 Update role colors to new palette (#183) 2025-09-26 16:08:14 +02:00
l5y
c8c7c8cc05 Add placeholder nodes for unknown senders (#181)
* Add placeholder nodes for unknown senders

* run rufo
2025-09-26 14:24:30 +02:00
l5y
5116313ab0 fix: update role colors and ordering for firmware 2.7.10 (#180) 2025-09-26 13:30:34 +02:00
l5y
66389dd27c Handle plain IP addresses in mesh TCP detection (#154)
* Fix TCP target detection for plain IPs

* run black
2025-09-26 13:25:42 +02:00
l5y
ee6501243f Handle encrypted messages (#173)
* Handle encrypted messages

* Remove redundant message node columns

* Preserve original numeric message senders

* Normalize message sender IDs in API responses

* Exclude encrypted messages from API responses

* run rufo
2025-09-24 07:34:28 +02:00
l5y
8dd912175d Add fallback display names for unnamed nodes (#171) 2025-09-23 19:06:28 +02:00
l5y
02f9fb45e2 Ensure routers render above other node types (#169) 2025-09-23 18:59:34 +02:00
l5y
4254dbda91 Reorder lint steps after tests in CI (#168) 2025-09-23 18:31:38 +02:00
l5y
a46bed1c33 Handle proto values in nodeinfo payloads (#167) 2025-09-23 18:31:22 +02:00
l5y
d711300442 Remove raw payload storage from database schema (#166) 2025-09-23 17:29:08 +02:00
l5y
98a8203591 Add POSITION_APP ingestion and API support (#160)
* Add POSITION_APP ingestion and API support

* Adjust mesh receive subscriptions and priorities

* run linters
2025-09-23 16:42:51 +02:00
l5y
084c5ae158 Add support for NODEINFO_APP packets (#159)
* Add support for NODEINFO_APP packets

* run black
2025-09-23 14:40:35 +02:00
l5y
17018aeb19 Derive SEO metadata from existing config (#153) 2025-09-23 08:20:42 +02:00
l5y
74b3da6f00 tests: create helper script to dump all mesh data from serial (#152)
* tests: create helper script to dump all mesh data from serial

* tests: use public callbacks for dump script
2025-09-23 08:09:31 +02:00
l5y
ab1217a8bf Limit chat log to recent entries (#151) 2025-09-22 18:54:09 +02:00
l5y
62de1480f7 Require time library before formatting ISO timestamps (#149)
* Require time library for ISO timestamp formatting

* Default to host networking in Compose
2025-09-22 09:21:04 +02:00
l5y
ab2e9b06e1 Define potatomesh network (#148) 2025-09-22 08:58:39 +02:00
l5y
e91ad24cf9 Fix sqlite3 native extension on Alpine (#146) 2025-09-22 08:12:48 +02:00
l5y
2e543b7cd4 Allow binding to all interfaces in app.sh (#147) 2025-09-22 08:11:36 +02:00
l5y
db4353ccdc Force building sqlite3 gem on Alpine (#145) 2025-09-22 08:10:00 +02:00
l5y
5a610cf08a Support mock serial interface in CI (#143) 2025-09-21 10:00:30 +02:00
l5y
71b854998c Fix Docker workflow to build linux images (#142) 2025-09-21 09:39:09 +02:00
l5y
0a70ae4b3e Add clickable role filters to the map legend (#140)
* Make map legend role entries filter nodes

* Adjust map legend spacing and toggle text
2025-09-21 09:33:48 +02:00
l5y
6e709b0b67 Rebuild chat log on each refresh (#139) 2025-09-21 09:19:07 +02:00
l5y
a4256cee83 fix: retain runtime libs for alpine production (#138) 2025-09-21 09:18:55 +02:00
32 changed files with 7093 additions and 864 deletions

View File

@@ -56,6 +56,15 @@ MATRIX_ROOM='#meshtastic-berlin:matrix.org'
# Debug mode (0=off, 1=on)
DEBUG=0
# Docker image architecture (linux-amd64, linux-arm64, linux-armv7)
POTATOMESH_IMAGE_ARCH=linux-amd64
# Docker Compose networking profile
# Leave unset for Linux hosts (default host networking).
# Set to "bridge" on Docker Desktop (macOS/Windows) if host networking
# is unavailable.
# COMPOSE_PROFILES=bridge
# Meshtastic snapshot interval (seconds)
MESH_SNAPSHOT_SECS=60

View File

@@ -30,14 +30,18 @@ jobs:
strategy:
matrix:
service: [web, ingestor]
architecture:
architecture:
- { name: linux-amd64, platform: linux/amd64, label: "Linux x86_64" }
- { name: windows-amd64, platform: windows/amd64, label: "Windows x86_64" }
- { name: linux-arm64, platform: linux/arm64, label: "Linux ARM64" }
- { name: linux-armv7, platform: linux/arm/v7, label: "Linux ARMv7" }
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU emulation
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -127,6 +131,7 @@ jobs:
docker run --rm --name ingestor-test \
-e POTATOMESH_INSTANCE=http://localhost:41447 \
-e API_TOKEN=test-token \
-e MESH_SERIAL=mock \
-e DEBUG=1 \
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:${{ steps.version.outputs.version }} &
sleep 5
@@ -156,12 +161,14 @@ jobs:
# Web images
echo "### 🌐 Web Application" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-windows-amd64:latest\` - Windows x86_64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Ingestor images
echo "### 📡 Ingestor Service" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-windows-amd64:latest\` - Windows x86_64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY

View File

@@ -22,9 +22,6 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install black pytest pytest-cov meshtastic
- name: Lint with black
run: |
black --check ./
- name: Test with pytest and coverage
run: |
mkdir -p reports
@@ -45,3 +42,6 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
files: reports/python-junit.xml
flags: python-ingestor
- name: Lint with black
run: |
black --check ./

View File

@@ -29,8 +29,6 @@ jobs:
working-directory: ./web
- name: Set up dependencies
run: bundle install
- name: Run rufo
run: bundle exec rufo --check .
- name: Run tests
run: |
mkdir -p tmp/test-results
@@ -53,3 +51,5 @@ jobs:
flags: ruby-${{ matrix.ruby-version }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Run rufo
run: bundle exec rufo --check .

1
.gitignore vendored
View File

@@ -65,3 +65,4 @@ reports/
# AI planning and documentation
ai_docs/
*.log

129
DOCKER.md
View File

@@ -1,88 +1,85 @@
# PotatoMesh Docker Setup
# PotatoMesh Docker Guide
## Quick Start
PotatoMesh publishes ready-to-run container images to the GitHub Packages container
registry (GHCR). You do not need to clone the repository to deploy them—Compose
will pull the latest release images for you.
```bash
./configure.sh
docker-compose up -d
docker-compose logs -f
## Prerequisites
- Docker Engine 24+ or Docker Desktop with the Compose plugin
- Access to `/dev/ttyACM*` (or equivalent) if you plan to attach a Meshtastic
device to the ingestor container
- An API token that authorises the ingestor to post to your PotatoMesh instance
## Images on GHCR
| Service | Image |
|----------|-------------------------------------------------------------------|
| Web UI | `ghcr.io/l5yth/potato-mesh-web-linux-amd64:latest` |
| Ingestor | `ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:latest` |
Images are published for every tagged release. Replace `latest` with a
specific version tag if you prefer pinned deployments.
## Configure environment
Create a `.env` file alongside your Compose file and populate the variables you
need. At a minimum you must set `API_TOKEN` so the ingestor can authenticate
against the web API.
```env
API_TOKEN=replace-with-a-strong-token
SITE_NAME=My Meshtastic Network
MESH_SERIAL=/dev/ttyACM0
```
Access at `http://localhost:41447`
Additional environment variables are optional:
## Configuration
- `DEFAULT_CHANNEL`, `DEFAULT_FREQUENCY`, `MAP_CENTER_LAT`, `MAP_CENTER_LON`,
`MAX_NODE_DISTANCE_KM`, and `MATRIX_ROOM` customise the UI.
- `POTATOMESH_INSTANCE` (defaults to `http://web:41447`) lets the ingestor post
to a remote PotatoMesh instance if you do not run both services together.
- `MESH_CHANNEL_INDEX`, `MESH_SNAPSHOT_SECS`, and `DEBUG` adjust ingestor
behaviour.
Edit `.env` file or run `./configure.sh` to set:
## Docker Compose file
- `API_TOKEN` - Required for ingestor authentication
- `MESH_SERIAL` - Your Meshtastic device path (e.g., `/dev/ttyACM0`)
- `SITE_NAME` - Your mesh network name
- `MAP_CENTER_LAT/LON` - Map center coordinates
Use the `docker-compose.yml` file provided in the repository (or download the
[raw file from GitHub](https://raw.githubusercontent.com/l5yth/potato-mesh/main/docker-compose.yml)).
It already references the published GHCR images, defines persistent volumes for
data and logs, and includes optional bridge-profile services for environments
that require classic port mapping. Place this file in the same directory as
your `.env` file so Compose can pick up both.
## Device Setup
## Start the stack
From the directory containing the Compose file:
**Find your device:**
```bash
# Linux
ls /dev/ttyACM* /dev/ttyUSB*
# macOS
ls /dev/cu.usbserial-*
# Windows
ls /dev/ttyS*
docker compose up -d
```
**Set permissions (Linux/macOS):**
Docker automatically pulls the GHCR images when they are not present locally.
The dashboard becomes available at `http://127.0.0.1:41447`. Use the bridge
profile when you need to map the port explicitly:
```bash
sudo chmod 666 /dev/ttyACM0
# Or add user to dialout group
sudo usermod -a -G dialout $USER
COMPOSE_PROFILES=bridge docker compose up -d
```
## Common Commands
## Updating
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Stop and remove data
docker-compose down -v
# Update images
docker-compose pull && docker-compose up -d
docker compose pull
docker compose up -d
```
## Troubleshooting
**Device access issues:**
```bash
# Check device exists and permissions
ls -la /dev/ttyACM0
- **Serial device permissions (Linux/macOS):** grant access with `sudo chmod 666
/dev/ttyACM0` or add your user to the `dialout` group.
- **Port already in use:** identify the conflicting service with `sudo lsof -i
:41447`.
- **Viewing logs:** `docker compose logs -f` tails output from both services.
# Fix permissions
sudo chmod 666 /dev/ttyACM0
```
**Port conflicts:**
```bash
# Find what's using port 41447
sudo lsof -i :41447
```
**Container issues:**
```bash
# Check logs
docker-compose logs
# Restart services
docker-compose restart
```
For more Docker help, see [Docker Compose documentation](https://docs.docker.com/compose/).
For general Docker support, consult the [Docker Compose documentation](https://docs.docker.com/compose/).

View File

@@ -16,15 +16,8 @@ A simple Meshtastic-powered node dashboard for your local community. _No MQTT cl
Live demo for Berlin #MediumFast: [potatomesh.net](https://potatomesh.net)
![screenshot of the second version](./scrot-0.2.png)
![screenshot of the third version](./scrot-0.3.png)
## 🐳 Quick Start with Docker
```bash
./configure.sh # Configure your setup
docker-compose up -d # Start services
docker-compose logs -f # View logs
```
## Web App
@@ -62,6 +55,11 @@ The web app can be configured with environment variables (defaults shown):
* `MAP_CENTER_LAT` / `MAP_CENTER_LON` - default map center coordinates (default: `52.502889` / `13.404194`)
* `MAX_NODE_DISTANCE_KM` - hide nodes farther than this distance from the center (default: `137`)
* `MATRIX_ROOM` - matrix room id for a footer link (default: `#meshtastic-berlin:matrix.org`)
* `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset)
The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled
logo for Open Graph and Twitter cards.
Example:
@@ -74,9 +72,11 @@ SITE_NAME="Meshtastic Berlin" MAP_CENTER_LAT=52.502889 MAP_CENTER_LON=13.404194
The web app contains an API:
* GET `/api/nodes?limit=100` - returns the latest 100 nodes reported to the app
* GET `/api/messages?limit=100` - returns the latest 100 messages
* GET `/api/positions?limit=100` - returns the latest 100 position data
* GET `/api/messages?limit=100` - returns the latest 100 messages (disabled when `PRIVATE=1`)
* POST `/api/nodes` - upserts nodes provided as JSON object mapping node ids to node data (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/positions` - appends positions provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`; disabled when `PRIVATE=1`)
The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests.
@@ -88,8 +88,9 @@ accepts data through the API POST endpoints. Benefit is, here multiple nodes acr
community can feed the dashboard with data. The web app handles messages and nodes
by ID and there will be no duplication.
For convenience, the directory `./data` contains a Python ingestor. It connects to a local
Meshtastic node via serial port to gather nodes and messages seen by the node.
For convenience, the directory `./data` contains a Python ingestor. It connects to a
Meshtastic node via serial port or to a remote device that exposes the Meshtastic TCP
interface to gather nodes and messages seen by the node.
```bash
pacman -S python
@@ -116,7 +117,18 @@ Mesh daemon: nodes+messages → http://127.0.0.1 | port=41447 | channel=0
Run the script with `POTATOMESH_INSTANCE` and `API_TOKEN` to keep updating
node records and parsing new incoming messages. Enable debug output with `DEBUG=1`,
specify the serial port with `MESH_SERIAL` (default `/dev/ttyACM0`), etc.
specify the serial port with `MESH_SERIAL` (default `/dev/ttyACM0`) or set it to an IP
address (for example `192.168.1.20:4403`) to use the Meshtastic TCP interface.
## Demos
* <https://potatomesh.net/>
* <https://vrs.kdd2105.ru/>
* <https://potatomesh.stratospire.com/>
## Docker
Looking for container deployment instructions? See the [Docker guide](DOCKER.md).
## License

View File

@@ -62,6 +62,7 @@ MAP_CENTER_LON=$(grep "^MAP_CENTER_LON=" .env 2>/dev/null | cut -d'=' -f2- | tr
MAX_NODE_DISTANCE_KM=$(grep "^MAX_NODE_DISTANCE_KM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "50")
MATRIX_ROOM=$(grep "^MATRIX_ROOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
POTATOMESH_IMAGE_ARCH=$(grep "^POTATOMESH_IMAGE_ARCH=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "linux-amd64")
echo "📍 Location Settings"
echo "-------------------"
@@ -81,6 +82,12 @@ echo "💬 Optional Settings"
echo "-------------------"
read_with_default "Matrix Room (optional, e.g., #meshtastic-berlin:matrix.org)" "$MATRIX_ROOM" MATRIX_ROOM
echo ""
echo "🛠 Docker Settings"
echo "------------------"
echo "Specify the Docker image architecture for your host (linux-amd64, linux-arm64, linux-armv7)."
read_with_default "Docker image architecture" "$POTATOMESH_IMAGE_ARCH" POTATOMESH_IMAGE_ARCH
echo ""
echo "🔐 Security Settings"
echo "-------------------"
@@ -124,6 +131,7 @@ update_env "MAP_CENTER_LON" "$MAP_CENTER_LON"
update_env "MAX_NODE_DISTANCE_KM" "$MAX_NODE_DISTANCE_KM"
update_env "MATRIX_ROOM" "\"$MATRIX_ROOM\""
update_env "API_TOKEN" "$API_TOKEN"
update_env "POTATOMESH_IMAGE_ARCH" "$POTATOMESH_IMAGE_ARCH"
# Add other common settings if they don't exist
if ! grep -q "^MESH_SERIAL=" .env; then
@@ -148,6 +156,7 @@ echo " Channel: $DEFAULT_CHANNEL"
echo " Frequency: $DEFAULT_FREQUENCY"
echo " Matrix Room: ${MATRIX_ROOM:-'Not set'}"
echo " API Token: ${API_TOKEN:0:8}..."
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
echo ""
echo "🚀 You can now start PotatoMesh with:"
echo " docker-compose up -d"

View File

@@ -15,7 +15,9 @@ COPY data/requirements.txt ./
RUN set -eux; \
apk add --no-cache \
tzdata \
curl; \
curl \
libstdc++ \
libgcc; \
apk add --no-cache --virtual .build-deps \
gcc \
musl-dev \

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,10 @@ CREATE TABLE IF NOT EXISTS messages (
channel INTEGER,
portnum TEXT,
text TEXT,
encrypted TEXT,
snr REAL,
rssi INTEGER,
hop_limit INTEGER,
raw_json TEXT
hop_limit INTEGER
);
CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time);

View File

@@ -0,0 +1,4 @@
-- Add support for encrypted messages to the existing schema.
BEGIN;
ALTER TABLE messages ADD COLUMN encrypted TEXT;
COMMIT;

26
data/neighbors.sql Normal file
View File

@@ -0,0 +1,26 @@
-- Copyright (C) 2025 l5yth
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
CREATE TABLE IF NOT EXISTS neighbors (
node_id TEXT NOT NULL,
neighbor_id TEXT NOT NULL,
snr REAL,
rx_time INTEGER NOT NULL,
PRIMARY KEY (node_id, neighbor_id),
FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
FOREIGN KEY (neighbor_id) REFERENCES nodes(node_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_neighbors_rx_time ON neighbors(rx_time);
CREATE INDEX IF NOT EXISTS idx_neighbors_neighbor_id ON neighbors(neighbor_id);

View File

@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS nodes (
uptime_seconds INTEGER,
position_time INTEGER,
location_source TEXT,
precision_bits INTEGER,
latitude REAL,
longitude REAL,
altitude REAL

40
data/positions.sql Normal file
View File

@@ -0,0 +1,40 @@
-- Copyright (C) 2025 l5yth
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY,
node_id TEXT,
node_num INTEGER,
rx_time INTEGER NOT NULL,
rx_iso TEXT NOT NULL,
position_time INTEGER,
to_id TEXT,
latitude REAL,
longitude REAL,
altitude REAL,
location_source TEXT,
precision_bits INTEGER,
sats_in_view INTEGER,
pdop REAL,
ground_speed REAL,
ground_track REAL,
snr REAL,
rssi INTEGER,
hop_limit INTEGER,
bitfield INTEGER,
payload_b64 TEXT
);
CREATE INDEX IF NOT EXISTS idx_positions_rx_time ON positions(rx_time);
CREATE INDEX IF NOT EXISTS idx_positions_node_id ON positions(node_id);

43
data/telemetry.sql Normal file
View File

@@ -0,0 +1,43 @@
-- Copyright (C) 2025 l5yth
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY,
node_id TEXT,
node_num INTEGER,
from_id TEXT,
to_id TEXT,
rx_time INTEGER NOT NULL,
rx_iso TEXT NOT NULL,
telemetry_time INTEGER,
channel INTEGER,
portnum TEXT,
hop_limit INTEGER,
snr REAL,
rssi INTEGER,
bitfield INTEGER,
payload_b64 TEXT,
battery_level REAL,
voltage REAL,
channel_utilization REAL,
air_util_tx REAL,
uptime_seconds INTEGER,
temperature REAL,
relative_humidity REAL,
barometric_pressure REAL
);
CREATE INDEX IF NOT EXISTS idx_telemetry_rx_time ON telemetry(rx_time);
CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_time ON telemetry(telemetry_time);

View File

@@ -1,21 +1,34 @@
version: '3.8'
# Development overrides for docker-compose.yml
services:
web:
environment:
- DEBUG=1
DEBUG: 1
volumes:
- ./web:/app
- ./data:/data # Mount data directory for SQL files
- /app/vendor/bundle # Exclude vendor directory from volume mount
- ./data:/data
- /app/vendor/bundle
web-bridge:
environment:
DEBUG: 1
volumes:
- ./web:/app
- ./data:/data
- /app/vendor/bundle
ports:
- "41447:41447"
- "9292:9292" # Additional port for development tools
- "9292:9292"
ingestor:
environment:
- DEBUG=1
DEBUG: 1
volumes:
- ./data:/app
- /app/.local # Exclude Python packages from volume mount
- /app/.local
ingestor-bridge:
environment:
DEBUG: 1
volumes:
- ./data:/app
- /app/.local

View File

@@ -1,33 +1,29 @@
version: '3.8'
# Production overrides for docker-compose.yml
services:
web:
build:
target: production
environment:
- DEBUG=0
DEBUG: 0
restart: always
web-bridge:
build:
target: production
environment:
DEBUG: 0
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
ingestor:
build:
target: production
environment:
- DEBUG=0
DEBUG: 0
restart: always
ingestor-bridge:
build:
target: production
environment:
DEBUG: 0
restart: always
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
reservations:
memory: 128M
cpus: '0.1'

View File

@@ -1,67 +1,85 @@
version: '3.8'
x-web-base: &web-base
image: ghcr.io/l5yth/potato-mesh-web-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:latest
environment:
SITE_NAME: ${SITE_NAME:-My Meshtastic Network}
DEFAULT_CHANNEL: ${DEFAULT_CHANNEL:-#MediumFast}
DEFAULT_FREQUENCY: ${DEFAULT_FREQUENCY:-868MHz}
MAP_CENTER_LAT: ${MAP_CENTER_LAT:-52.502889}
MAP_CENTER_LON: ${MAP_CENTER_LON:-13.404194}
MAX_NODE_DISTANCE_KM: ${MAX_NODE_DISTANCE_KM:-50}
MATRIX_ROOM: ${MATRIX_ROOM:-}
API_TOKEN: ${API_TOKEN}
DEBUG: ${DEBUG:-0}
volumes:
- potatomesh_data:/app/data
- potatomesh_logs:/app/logs
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
x-ingestor-base: &ingestor-base
image: ghcr.io/l5yth/potato-mesh-ingestor-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:latest
environment:
MESH_SERIAL: ${MESH_SERIAL:-/dev/ttyACM0}
MESH_SNAPSHOT_SECS: ${MESH_SNAPSHOT_SECS:-60}
MESH_CHANNEL_INDEX: ${MESH_CHANNEL_INDEX:-0}
POTATOMESH_INSTANCE: ${POTATOMESH_INSTANCE:-http://web:41447}
API_TOKEN: ${API_TOKEN}
DEBUG: ${DEBUG:-0}
volumes:
- potatomesh_data:/app/data
- potatomesh_logs:/app/logs
devices:
- ${MESH_SERIAL:-/dev/ttyACM0}:${MESH_SERIAL:-/dev/ttyACM0}
privileged: false
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
reservations:
memory: 128M
cpus: '0.1'
services:
web:
image: ghcr.io/l5yth/potato-mesh-web-linux-amd64:latest
container_name: potatomesh-web
ports:
- "41447:41447"
environment:
- SITE_NAME=${SITE_NAME:-My Meshtastic Network}
- DEFAULT_CHANNEL=${DEFAULT_CHANNEL:-#MediumFast}
- DEFAULT_FREQUENCY=${DEFAULT_FREQUENCY:-868MHz}
- MAP_CENTER_LAT=${MAP_CENTER_LAT:-52.502889}
- MAP_CENTER_LON=${MAP_CENTER_LON:-13.404194}
- MAX_NODE_DISTANCE_KM=${MAX_NODE_DISTANCE_KM:-50}
- MATRIX_ROOM=${MATRIX_ROOM:-}
- API_TOKEN=${API_TOKEN}
- DEBUG=${DEBUG:-0}
volumes:
- potatomesh_data:/app/data
- potatomesh_logs:/app/logs
networks:
- potatomesh-network
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
<<: *web-base
network_mode: host
ingestor:
image: ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:latest
container_name: potatomesh-ingestor
environment:
- MESH_SERIAL=${MESH_SERIAL:-/dev/ttyACM0}
- MESH_SNAPSHOT_SECS=${MESH_SNAPSHOT_SECS:-60}
- MESH_CHANNEL_INDEX=${MESH_CHANNEL_INDEX:-0}
- POTATOMESH_INSTANCE=${POTATOMESH_INSTANCE:-http://web:41447}
- API_TOKEN=${API_TOKEN}
- DEBUG=${DEBUG:-0}
volumes:
- potatomesh_data:/app/data
- potatomesh_logs:/app/logs
devices:
# Map Meshtastic serial device from host to container
# Common paths: /dev/ttyACM0, /dev/ttyUSB0, /dev/cu.usbserial-*
- ${MESH_SERIAL:-/dev/ttyACM0}:${MESH_SERIAL:-/dev/ttyACM0}
privileged: false
<<: *ingestor-base
network_mode: host
depends_on:
- web
extra_hosts:
- "web:127.0.0.1"
web-bridge:
<<: *web-base
container_name: potatomesh-web-bridge
networks:
- potatomesh-network
ports:
- "41447:41447"
profiles:
- bridge
ingestor-bridge:
<<: *ingestor-base
container_name: potatomesh-ingestor-bridge
networks:
- potatomesh-network
depends_on:
- web
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
reservations:
memory: 128M
cpus: '0.1'
- web-bridge
profiles:
- bridge
volumes:
potatomesh_data:
@@ -69,3 +87,6 @@ volumes:
potatomesh_logs:
driver: local
networks:
potatomesh-network:
driver: bridge

BIN
scrot-0.3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

77
tests/dump.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
import json, os, signal, sys, time, threading
from datetime import datetime, timezone
from meshtastic.serial_interface import SerialInterface
from meshtastic.mesh_interface import MeshInterface
from pubsub import pub
PORT = os.environ.get("MESH_SERIAL", "/dev/ttyACM0")
OUT = os.environ.get("MESH_DUMP_FILE", "meshtastic-dump.ndjson")
# line-buffered append so you can tail -f safely
f = open(OUT, "a", buffering=1, encoding="utf-8")
def now():
return datetime.now(timezone.utc).isoformat()
def write(kind, payload):
rec = {"ts": now(), "kind": kind, **payload}
f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
# Connect to the node
iface: MeshInterface = SerialInterface(PORT)
# Packet callback: every RF/Mesh packet the node receives/decodes lands here
def on_packet(packet, iface):
# 'packet' already includes decoded fields when available (portnum, payload, position, telemetry, etc.)
write("packet", {"packet": packet})
# Node callback: topology/metadata updates (nodeinfo, hops, lastHeard, etc.)
def on_node(node, iface):
write("node", {"node": node})
iface.onReceive = on_packet
pub.subscribe(on_node, "meshtastic.node")
# Write a little header so you know what you captured
try:
my = getattr(iface, "myInfo", None)
write(
"meta",
{
"event": "started",
"port": PORT,
"my_node_num": getattr(my, "my_node_num", None) if my else None,
},
)
except Exception as e:
write("meta", {"event": "started", "port": PORT, "error": str(e)})
# Keep the process alive until Ctrl-C
def _stop(signum, frame):
write("meta", {"event": "stopping"})
try:
try:
pub.unsubscribe(on_node, "meshtastic.node")
except Exception:
pass
iface.close()
finally:
f.close()
sys.exit(0)
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)
# Simple sleep loop; avoids busy-wait
while True:
time.sleep(1)

View File

@@ -13,7 +13,6 @@
"snr": -13.25,
"node": {
"snr": -13.25,
"raw_json": null,
"node_id": "!bba83318",
"num": 3148362520,
"short_name": "BerF",
@@ -53,7 +52,6 @@
"snr": -12.0,
"node": {
"snr": -12.0,
"raw_json": null,
"node_id": "!43b6e530",
"num": 1136059696,
"short_name": "FFSR",
@@ -93,7 +91,6 @@
"snr": -13.5,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!d42e18e8",
"num": 3559790824,
"short_name": "RRun",
@@ -133,7 +130,6 @@
"snr": -13.0,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!d42e18e8",
"num": 3559790824,
"short_name": "RRun",
@@ -173,7 +169,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -213,7 +208,6 @@
"snr": 11.25,
"node": {
"snr": 11.25,
"raw_json": null,
"node_id": "!4ed36bd0",
"num": 1322478544,
"short_name": "RDM",
@@ -253,7 +247,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -293,7 +286,6 @@
"snr": 10.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -333,7 +325,6 @@
"snr": 12.0,
"node": {
"snr": 12.0,
"raw_json": null,
"node_id": "!b03c97a4",
"num": 2956760996,
"short_name": "BLN1",
@@ -373,7 +364,6 @@
"snr": -15.0,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9eeb25ec",
"num": 2666210796,
"short_name": "25ec",
@@ -413,7 +403,6 @@
"snr": 11.25,
"node": {
"snr": 11.25,
"raw_json": null,
"node_id": "!f9b0938c",
"num": 4189098892,
"short_name": "Ed-1",
@@ -453,7 +442,6 @@
"snr": 11.25,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!6c73bf84",
"num": 1819524996,
"short_name": "ts1",
@@ -493,7 +481,6 @@
"snr": 11.25,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -533,7 +520,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -573,7 +559,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!6cf821fb",
"num": 1828200955,
"short_name": "OKP1",
@@ -613,7 +598,6 @@
"snr": 10.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!6cf821fb",
"num": 1828200955,
"short_name": "OKP1",
@@ -653,7 +637,6 @@
"snr": 10.5,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -693,7 +676,6 @@
"snr": 10.25,
"node": {
"snr": 10.25,
"raw_json": null,
"node_id": "!db2b23f4",
"num": 3677037556,
"short_name": "Eagl",
@@ -733,7 +715,6 @@
"snr": 11.25,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!6cf821fb",
"num": 1828200955,
"short_name": "OKP1",
@@ -773,7 +754,6 @@
"snr": 11.0,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -813,7 +793,6 @@
"snr": -11.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!177cfa26",
"num": 394066470,
"short_name": "lun1",
@@ -853,7 +832,6 @@
"snr": 11.25,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!9ea0c780",
"num": 2661336960,
"short_name": "nguE",
@@ -893,7 +871,6 @@
"snr": 10.75,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -933,7 +910,6 @@
"snr": 11.5,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!e80cda12",
"num": 3893156370,
"short_name": "mowW",
@@ -973,7 +949,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -1013,7 +988,6 @@
"snr": 11.5,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -1053,7 +1027,6 @@
"snr": 11.5,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -1093,7 +1066,6 @@
"snr": -11.75,
"node": {
"snr": -9.75,
"raw_json": null,
"node_id": "!a0cb1608",
"num": 2697664008,
"short_name": "KBV5",
@@ -1133,7 +1105,6 @@
"snr": 10.75,
"node": {
"snr": 10.25,
"raw_json": null,
"node_id": "!bcf10936",
"num": 3169913142,
"short_name": "0936",
@@ -1173,7 +1144,6 @@
"snr": 11.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -1213,7 +1183,6 @@
"snr": -13.25,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!a0cc6904",
"num": 2697750788,
"short_name": "Kdû",
@@ -1253,7 +1222,6 @@
"snr": 10.5,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -1293,7 +1261,6 @@
"snr": 11.0,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9eeb25ec",
"num": 2666210796,
"short_name": "25ec",
@@ -1333,7 +1300,6 @@
"snr": -14.0,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!a0cc6904",
"num": 2697750788,
"short_name": "Kdû",
@@ -1373,7 +1339,6 @@
"snr": 11.25,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9eeb25ec",
"num": 2666210796,
"short_name": "25ec",
@@ -1413,7 +1378,6 @@
"snr": 11.5,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9eeb25ec",
"num": 2666210796,
"short_name": "25ec",
@@ -1453,7 +1417,6 @@
"snr": 11.75,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9eeb25ec",
"num": 2666210796,
"short_name": "25ec",
@@ -1493,7 +1456,6 @@
"snr": 11.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -1533,7 +1495,6 @@
"snr": 10.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!03b9ca11",
"num": 62507537,
"short_name": "ca11",
@@ -1573,7 +1534,6 @@
"snr": 7.5,
"node": {
"snr": 10.25,
"raw_json": null,
"node_id": "!db2b23f4",
"num": 3677037556,
"short_name": "Eagl",
@@ -1613,7 +1573,6 @@
"snr": 10.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -1653,7 +1612,6 @@
"snr": 10.75,
"node": {
"snr": 10.25,
"raw_json": null,
"node_id": "!db2b23f4",
"num": 3677037556,
"short_name": "Eagl",
@@ -1693,7 +1651,6 @@
"snr": 10.75,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -1733,7 +1690,6 @@
"snr": 10.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -1773,7 +1729,6 @@
"snr": 10.5,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -1813,7 +1768,6 @@
"snr": 11.0,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!a0cc6904",
"num": 2697750788,
"short_name": "Kdû",
@@ -1853,7 +1807,6 @@
"snr": -12.25,
"node": {
"snr": -12.25,
"raw_json": null,
"node_id": "!2f945044",
"num": 798249028,
"short_name": "BND",
@@ -1893,7 +1846,6 @@
"snr": 11.0,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -1933,7 +1885,6 @@
"snr": 10.5,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9ee71c38",
"num": 2665946168,
"short_name": "1c38",
@@ -1973,7 +1924,6 @@
"snr": 10.75,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -2013,7 +1963,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -2053,7 +2002,6 @@
"snr": 10.5,
"node": {
"snr": -6.25,
"raw_json": null,
"node_id": "!7c5b0920",
"num": 2086340896,
"short_name": "FFTB",
@@ -2093,7 +2041,6 @@
"snr": 10.25,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -2133,7 +2080,6 @@
"snr": 11.25,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!9ea0c780",
"num": 2661336960,
"short_name": "nguE",
@@ -2173,7 +2119,6 @@
"snr": 10.75,
"node": {
"snr": -12.75,
"raw_json": null,
"node_id": "!0910c922",
"num": 152095010,
"short_name": "c922",
@@ -2213,7 +2158,6 @@
"snr": 11.0,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -2253,7 +2197,6 @@
"snr": 11.0,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!9ee71430",
"num": 2665944112,
"short_name": "FiSp",
@@ -2293,7 +2236,6 @@
"snr": 11.5,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -2333,7 +2275,6 @@
"snr": 10.75,
"node": {
"snr": 10.25,
"raw_json": null,
"node_id": "!bcf10936",
"num": 3169913142,
"short_name": "0936",
@@ -2373,7 +2314,6 @@
"snr": 11.0,
"node": {
"snr": 11.25,
"raw_json": null,
"node_id": "!16ced364",
"num": 382653284,
"short_name": "Pat",
@@ -2413,7 +2353,6 @@
"snr": 11.25,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9ee71c38",
"num": 2665946168,
"short_name": "1c38",
@@ -2453,7 +2392,6 @@
"snr": 10.5,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9ee71c38",
"num": 2665946168,
"short_name": "1c38",
@@ -2493,7 +2431,6 @@
"snr": 10.25,
"node": {
"snr": 10.0,
"raw_json": null,
"node_id": "!a3deea53",
"num": 2749295187,
"short_name": "🐸",
@@ -2533,7 +2470,6 @@
"snr": 9.0,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!9ea0c780",
"num": 2661336960,
"short_name": "nguE",
@@ -2573,7 +2509,6 @@
"snr": 11.5,
"node": {
"snr": -13.25,
"raw_json": null,
"node_id": "!bba83318",
"num": 3148362520,
"short_name": "BerF",
@@ -2613,7 +2548,6 @@
"snr": 9.25,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9ee71c38",
"num": 2665946168,
"short_name": "1c38",
@@ -2653,7 +2587,6 @@
"snr": 10.25,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!e80cda12",
"num": 3893156370,
"short_name": "mowW",
@@ -2693,7 +2626,6 @@
"snr": -5.0,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!a0cc6904",
"num": 2697750788,
"short_name": "Kdû",
@@ -2733,7 +2665,6 @@
"snr": 11.0,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!e80cda12",
"num": 3893156370,
"short_name": "mowW",
@@ -2773,7 +2704,6 @@
"snr": 0.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -2813,7 +2743,6 @@
"snr": 11.25,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -2853,7 +2782,6 @@
"snr": 11.5,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -2893,7 +2821,6 @@
"snr": 10.0,
"node": {
"snr": 11.25,
"raw_json": null,
"node_id": "!16ced364",
"num": 382653284,
"short_name": "Pat",
@@ -2933,7 +2860,6 @@
"snr": 11.0,
"node": {
"snr": -9.75,
"raw_json": null,
"node_id": "!a0cb1608",
"num": 2697664008,
"short_name": "KBV5",
@@ -2973,7 +2899,6 @@
"snr": 9.5,
"node": {
"snr": -9.75,
"raw_json": null,
"node_id": "!a0cb1608",
"num": 2697664008,
"short_name": "KBV5",
@@ -3013,7 +2938,6 @@
"snr": 10.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -3053,7 +2977,6 @@
"snr": 11.0,
"node": {
"snr": -12.0,
"raw_json": null,
"node_id": "!43b6e530",
"num": 1136059696,
"short_name": "FFSR",
@@ -3093,7 +3016,6 @@
"snr": 11.0,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!e80cda12",
"num": 3893156370,
"short_name": "mowW",
@@ -3133,7 +3055,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -3173,7 +3094,6 @@
"snr": 10.25,
"node": {
"snr": 11.25,
"raw_json": null,
"node_id": "!16ced364",
"num": 382653284,
"short_name": "Pat",
@@ -3213,7 +3133,6 @@
"snr": 10.5,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -3253,7 +3172,6 @@
"snr": 10.75,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",
@@ -3293,7 +3211,6 @@
"snr": 11.0,
"node": {
"snr": 11.0,
"raw_json": null,
"node_id": "!abbdf3f7",
"num": 2881352695,
"short_name": "f3f7",
@@ -3333,7 +3250,6 @@
"snr": 10.5,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!c0c32348",
"num": 3234014024,
"short_name": "CooP",
@@ -3373,7 +3289,6 @@
"snr": 11.0,
"node": {
"snr": 11.25,
"raw_json": null,
"node_id": "!16ced364",
"num": 382653284,
"short_name": "Pat",
@@ -3413,7 +3328,6 @@
"snr": 10.5,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -3453,7 +3367,6 @@
"snr": -12.5,
"node": {
"snr": -9.75,
"raw_json": null,
"node_id": "!a0cb1608",
"num": 2697664008,
"short_name": "KBV5",
@@ -3493,7 +3406,6 @@
"snr": 11.0,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!da635e24",
"num": 3663945252,
"short_name": "LAN",
@@ -3533,7 +3445,6 @@
"snr": -8.75,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -3573,7 +3484,6 @@
"snr": 10.25,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!5d823fb1",
"num": 1568817073,
"short_name": "3fb1",
@@ -3613,7 +3523,6 @@
"snr": 11.25,
"node": {
"snr": -12.0,
"raw_json": null,
"node_id": "!43b6e530",
"num": 1136059696,
"short_name": "FFSR",
@@ -3653,7 +3562,6 @@
"snr": 11.0,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!849a8ba4",
"num": 2224720804,
"short_name": "MGN1",
@@ -3693,7 +3601,6 @@
"snr": -13.25,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!849a8ba4",
"num": 2224720804,
"short_name": "MGN1",
@@ -3733,7 +3640,6 @@
"snr": 10.75,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!9c93a2df",
"num": 2626921183,
"short_name": "xaRa",
@@ -3773,7 +3679,6 @@
"snr": 11.25,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9ee71c38",
"num": 2665946168,
"short_name": "1c38",
@@ -3813,7 +3718,6 @@
"snr": 11.0,
"node": {
"snr": 11.5,
"raw_json": null,
"node_id": "!9ee71c38",
"num": 2665946168,
"short_name": "1c38",
@@ -3853,7 +3757,6 @@
"snr": 11.0,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!5d823fb1",
"num": 1568817073,
"short_name": "3fb1",
@@ -3893,7 +3796,6 @@
"snr": 11.0,
"node": {
"snr": 10.5,
"raw_json": null,
"node_id": "!6c73bf84",
"num": 1819524996,
"short_name": "ts1",
@@ -3933,7 +3835,6 @@
"snr": 11.25,
"node": {
"snr": null,
"raw_json": null,
"node_id": null,
"num": null,
"short_name": null,
@@ -3973,7 +3874,6 @@
"snr": 11.25,
"node": {
"snr": 10.75,
"raw_json": null,
"node_id": "!194a7351",
"num": 424309585,
"short_name": "l5y7",

20
tests/neighbors.json Normal file
View File

@@ -0,0 +1,20 @@
[
{
"node_id": "!7c5b0920",
"rx_time": 1758884186,
"node_broadcast_interval_secs": 1800,
"last_sent_by": "!9e99f8c0",
"neighbors": [
{ "node_id": "!2b22accc", "snr": -6.5, "rx_time": 1758884106 },
{ "node_id": "!43ba26d0", "snr": -5.0, "rx_time": 1758884120 },
{ "node_id": "!69ba6f71", "snr": -13.0, "rx_time": 1758884135 },
{ "node_id": "!fa848384", "snr": -14.75, "rx_time": 1758884150 },
{ "node_id": "!da6a35b4", "snr": -6.5, "rx_time": 1758884165 }
]
},
{
"node_id": "!cafebabe",
"rx_time": 1758883200,
"neighbors": []
}
]

File diff suppressed because it is too large Load Diff

84
tests/telemetry.json Normal file
View File

@@ -0,0 +1,84 @@
[
{
"id": 1256091342,
"node_id": "!9e95cf60",
"from_id": "!9e95cf60",
"to_id": "^all",
"rx_time": 1758024300,
"rx_iso": "2025-09-16T12:05:00Z",
"telemetry_time": 1758024300,
"channel": 0,
"portnum": "TELEMETRY_APP",
"battery_level": 101,
"bitfield": 1,
"payload_b64": "DTVr0mgSFQhlFQIrh0AdJb8YPyXYFSA9KJTPEg==",
"device_metrics": {
"batteryLevel": 101,
"voltage": 4.224,
"channelUtilization": 0.59666663,
"airUtilTx": 0.03908333,
"uptimeSeconds": 305044
},
"raw": {
"device_metrics": {
"battery_level": 101,
"voltage": 4.224,
"channel_utilization": 0.59666663,
"air_util_tx": 0.03908333,
"uptime_seconds": 305044
}
}
},
{
"id": 2817720548,
"node_id": "!2a2a2a2a",
"from_id": "!2a2a2a2a",
"to_id": "^all",
"rx_time": 1758024400,
"rx_iso": "2025-09-16T12:06:40Z",
"telemetry_time": 1758024390,
"channel": 0,
"portnum": "TELEMETRY_APP",
"bitfield": 1,
"environment_metrics": {
"temperature": 21.98,
"relativeHumidity": 39.475586,
"barometricPressure": 1017.8353
},
"raw": {
"environment_metrics": {
"temperature": 21.98,
"relative_humidity": 39.475586,
"barometric_pressure": 1017.8353
}
}
},
{
"id": 345678901,
"node_id": "!1234abcd",
"from_id": "!1234abcd",
"node_num": 305441741,
"to_id": "^all",
"rx_time": 1758024500,
"rx_iso": "2025-09-16T12:08:20Z",
"telemetry_time": 1758024450,
"channel": 1,
"portnum": "TELEMETRY_APP",
"payload_b64": "AAEC",
"device_metrics": {
"battery_level": 58.5,
"voltage": 3.92,
"channel_utilization": 0.284,
"air_util_tx": 0.051,
"uptime_seconds": 86400
},
"local_stats": {
"numPacketsTx": 1280,
"numPacketsRx": 1425,
"numClients": 6,
"numNodes": 18,
"freeHeap": 21344,
"heapLowWater": 19876
}
}
]

View File

@@ -1,3 +1,4 @@
import base64
import importlib
import sys
import types
@@ -15,6 +16,15 @@ def mesh_module(monkeypatch):
repo_root = Path(__file__).resolve().parents[1]
monkeypatch.syspath_prepend(str(repo_root))
try:
import meshtastic as real_meshtastic # type: ignore
except Exception: # pragma: no cover - dependency may be unavailable in CI
real_meshtastic = None
real_protobuf = (
getattr(real_meshtastic, "protobuf", None) if real_meshtastic else None
)
# Stub meshtastic.serial_interface.SerialInterface
serial_interface_mod = types.ModuleType("meshtastic.serial_interface")
@@ -27,13 +37,43 @@ def mesh_module(monkeypatch):
serial_interface_mod.SerialInterface = DummySerialInterface
tcp_interface_mod = types.ModuleType("meshtastic.tcp_interface")
class DummyTCPInterface:
def __init__(self, *_, **__):
self.closed = False
def close(self):
self.closed = True
tcp_interface_mod.TCPInterface = DummyTCPInterface
ble_interface_mod = types.ModuleType("meshtastic.ble_interface")
class DummyBLEInterface:
def __init__(self, *_, **__):
self.closed = False
def close(self):
self.closed = True
ble_interface_mod.BLEInterface = DummyBLEInterface
meshtastic_mod = types.ModuleType("meshtastic")
meshtastic_mod.serial_interface = serial_interface_mod
meshtastic_mod.tcp_interface = tcp_interface_mod
meshtastic_mod.ble_interface = ble_interface_mod
if real_protobuf is not None:
meshtastic_mod.protobuf = real_protobuf
monkeypatch.setitem(sys.modules, "meshtastic", meshtastic_mod)
monkeypatch.setitem(
sys.modules, "meshtastic.serial_interface", serial_interface_mod
)
monkeypatch.setitem(sys.modules, "meshtastic.tcp_interface", tcp_interface_mod)
monkeypatch.setitem(sys.modules, "meshtastic.ble_interface", ble_interface_mod)
if real_protobuf is not None:
monkeypatch.setitem(sys.modules, "meshtastic.protobuf", real_protobuf)
# Stub pubsub.pub
pubsub_mod = types.ModuleType("pubsub")
@@ -48,36 +88,47 @@ def mesh_module(monkeypatch):
pubsub_mod.pub = DummyPub()
monkeypatch.setitem(sys.modules, "pubsub", pubsub_mod)
# Stub google.protobuf modules used by mesh.py
json_format_mod = types.ModuleType("google.protobuf.json_format")
# Prefer real google.protobuf modules when available, otherwise provide stubs
try:
from google.protobuf import json_format as json_format_mod # type: ignore
from google.protobuf import message as message_mod # type: ignore
except Exception: # pragma: no cover - protobuf may be missing in CI
json_format_mod = types.ModuleType("google.protobuf.json_format")
def message_to_dict(obj, *_, **__):
if hasattr(obj, "to_dict"):
return obj.to_dict()
if hasattr(obj, "__dict__"):
return dict(obj.__dict__)
return {}
def message_to_dict(obj, *_, **__):
if hasattr(obj, "to_dict"):
return obj.to_dict()
if hasattr(obj, "__dict__"):
return dict(obj.__dict__)
return {}
json_format_mod.MessageToDict = message_to_dict
json_format_mod.MessageToDict = message_to_dict
message_mod = types.ModuleType("google.protobuf.message")
message_mod = types.ModuleType("google.protobuf.message")
class DummyProtoMessage:
pass
class DummyProtoMessage:
pass
message_mod.Message = DummyProtoMessage
class DummyDecodeError(Exception):
pass
protobuf_mod = types.ModuleType("google.protobuf")
protobuf_mod.json_format = json_format_mod
protobuf_mod.message = message_mod
message_mod.Message = DummyProtoMessage
message_mod.DecodeError = DummyDecodeError
google_mod = types.ModuleType("google")
google_mod.protobuf = protobuf_mod
protobuf_mod = types.ModuleType("google.protobuf")
protobuf_mod.json_format = json_format_mod
protobuf_mod.message = message_mod
monkeypatch.setitem(sys.modules, "google", google_mod)
monkeypatch.setitem(sys.modules, "google.protobuf", protobuf_mod)
monkeypatch.setitem(sys.modules, "google.protobuf.json_format", json_format_mod)
monkeypatch.setitem(sys.modules, "google.protobuf.message", message_mod)
google_mod = types.ModuleType("google")
google_mod.protobuf = protobuf_mod
monkeypatch.setitem(sys.modules, "google", google_mod)
monkeypatch.setitem(sys.modules, "google.protobuf", protobuf_mod)
monkeypatch.setitem(sys.modules, "google.protobuf.json_format", json_format_mod)
monkeypatch.setitem(sys.modules, "google.protobuf.message", message_mod)
else:
monkeypatch.setitem(sys.modules, "google.protobuf.json_format", json_format_mod)
monkeypatch.setitem(sys.modules, "google.protobuf.message", message_mod)
module_name = "data.mesh"
if module_name in sys.modules:
@@ -102,6 +153,145 @@ def test_snapshot_interval_defaults_to_60_seconds(mesh_module):
assert mesh.SNAPSHOT_SECS == 60
@pytest.mark.parametrize("value", ["mock", "Mock", " disabled "])
def test_create_serial_interface_allows_mock(mesh_module, value):
mesh = mesh_module
iface, resolved = mesh._create_serial_interface(value)
assert resolved == "mock"
assert isinstance(iface.nodes, dict)
iface.close()
def test_create_serial_interface_uses_serial_module(mesh_module, monkeypatch):
mesh = mesh_module
created = {}
sentinel = object()
def fake_interface(*, devPath):
created["devPath"] = devPath
return SimpleNamespace(nodes={"!foo": sentinel}, close=lambda: None)
monkeypatch.setattr(mesh, "SerialInterface", fake_interface)
iface, resolved = mesh._create_serial_interface("/dev/ttyTEST")
assert created["devPath"] == "/dev/ttyTEST"
assert resolved == "/dev/ttyTEST"
assert iface.nodes == {"!foo": sentinel}
def test_create_serial_interface_uses_tcp_for_ip(mesh_module, monkeypatch):
mesh = mesh_module
created = {}
def fake_tcp_interface(*, hostname, portNumber, **_):
created["hostname"] = hostname
created["portNumber"] = portNumber
return SimpleNamespace(nodes={}, close=lambda: None)
monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface)
iface, resolved = mesh._create_serial_interface("192.168.1.25:4500")
assert created == {"hostname": "192.168.1.25", "portNumber": 4500}
assert resolved == "tcp://192.168.1.25:4500"
assert iface.nodes == {}
def test_create_serial_interface_defaults_tcp_port(mesh_module, monkeypatch):
mesh = mesh_module
created = {}
def fake_tcp_interface(*, hostname, portNumber, **_):
created["hostname"] = hostname
created["portNumber"] = portNumber
return SimpleNamespace(nodes={}, close=lambda: None)
monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface)
_, resolved = mesh._create_serial_interface("tcp://10.20.30.40")
assert created["hostname"] == "10.20.30.40"
assert created["portNumber"] == mesh._DEFAULT_TCP_PORT
assert resolved == "tcp://10.20.30.40:4403"
def test_create_serial_interface_plain_ip(mesh_module, monkeypatch):
mesh = mesh_module
created = {}
def fake_tcp_interface(*, hostname, portNumber, **_):
created["hostname"] = hostname
created["portNumber"] = portNumber
return SimpleNamespace(nodes={}, close=lambda: None)
monkeypatch.setattr(mesh, "TCPInterface", fake_tcp_interface)
_, resolved = mesh._create_serial_interface(" 192.168.50.10 ")
assert created["hostname"] == "192.168.50.10"
assert created["portNumber"] == mesh._DEFAULT_TCP_PORT
assert resolved == "tcp://192.168.50.10:4403"
def test_create_serial_interface_ble(mesh_module, monkeypatch):
mesh = mesh_module
created = {}
def fake_ble_interface(*, address=None, **_):
created["address"] = address
return SimpleNamespace(nodes={}, close=lambda: None)
monkeypatch.setattr(mesh, "BLEInterface", fake_ble_interface)
iface, resolved = mesh._create_serial_interface("ed:4d:9e:95:cf:60")
assert created["address"] == "ED:4D:9E:95:CF:60"
assert resolved == "ED:4D:9E:95:CF:60"
assert iface.nodes == {}
def test_create_default_interface_falls_back_to_tcp(mesh_module, monkeypatch):
mesh = mesh_module
attempts = []
def fake_targets():
return ["/dev/ttyFAIL"]
def fake_create(port):
attempts.append(port)
if port.startswith("/dev/tty"):
raise RuntimeError("missing serial device")
return SimpleNamespace(nodes={}, close=lambda: None), "tcp://127.0.0.1:4403"
monkeypatch.setattr(mesh, "_default_serial_targets", fake_targets)
monkeypatch.setattr(mesh, "_create_serial_interface", fake_create)
iface, resolved = mesh._create_default_interface()
assert attempts == ["/dev/ttyFAIL", mesh._DEFAULT_TCP_TARGET]
assert resolved == "tcp://127.0.0.1:4403"
assert iface.nodes == {}
def test_create_default_interface_raises_when_unavailable(mesh_module, monkeypatch):
mesh = mesh_module
monkeypatch.setattr(mesh, "_default_serial_targets", lambda: ["/dev/ttyFAIL"])
def always_fail(port):
raise RuntimeError(f"boom for {port}")
monkeypatch.setattr(mesh, "_create_serial_interface", always_fail)
with pytest.raises(mesh.NoAvailableMeshInterface) as exc_info:
mesh._create_default_interface()
assert "/dev/ttyFAIL" in str(exc_info.value)
def test_node_to_dict_handles_nested_structures(mesh_module):
mesh = mesh_module
@@ -176,6 +366,368 @@ def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch):
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_posts_position(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 200498337,
"rxTime": 1_758_624_186,
"fromId": "!b1fa2b07",
"toId": "^all",
"rxSnr": -9.5,
"rxRssi": -104,
"decoded": {
"portnum": "POSITION_APP",
"bitfield": 1,
"position": {
"latitudeI": int(52.518912 * 1e7),
"longitudeI": int(13.5512064 * 1e7),
"altitude": -16,
"time": 1_758_624_189,
"locationSource": "LOC_INTERNAL",
"precisionBits": 17,
"satsInView": 7,
"PDOP": 211,
"groundSpeed": 2,
"groundTrack": 0,
"raw": {
"latitude_i": int(52.518912 * 1e7),
"longitude_i": int(13.5512064 * 1e7),
"altitude": -16,
"time": 1_758_624_189,
},
},
"payload": {
"__bytes_b64__": "DQDATR8VAMATCBjw//////////8BJb150mgoAljTAXgCgAEAmAEHuAER",
},
},
}
mesh.store_packet_dict(packet)
assert captured, "Expected POST to be triggered for position packet"
path, payload, priority = captured[0]
assert path == "/api/positions"
assert priority == mesh._POSITION_POST_PRIORITY
assert payload["id"] == 200498337
assert payload["node_id"] == "!b1fa2b07"
assert payload["node_num"] == int("b1fa2b07", 16)
assert payload["num"] == payload["node_num"]
assert payload["rx_time"] == 1_758_624_186
assert payload["rx_iso"] == mesh._iso(1_758_624_186)
assert payload["latitude"] == pytest.approx(52.518912)
assert payload["longitude"] == pytest.approx(13.5512064)
assert payload["altitude"] == pytest.approx(-16)
assert payload["position_time"] == 1_758_624_189
assert payload["location_source"] == "LOC_INTERNAL"
assert payload["precision_bits"] == 17
assert payload["sats_in_view"] == 7
assert payload["pdop"] == pytest.approx(211.0)
assert payload["ground_speed"] == pytest.approx(2.0)
assert payload["ground_track"] == pytest.approx(0.0)
assert payload["snr"] == pytest.approx(-9.5)
assert payload["rssi"] == -104
assert payload["hop_limit"] is None
assert payload["bitfield"] == 1
assert (
payload["payload_b64"]
== "DQDATR8VAMATCBjw//////////8BJb150mgoAljTAXgCgAEAmAEHuAER"
)
assert payload["raw"]["time"] == 1_758_624_189
def test_store_packet_dict_posts_neighborinfo(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 2049886869,
"rxTime": 1_758_884_186,
"fromId": "!7c5b0920",
"decoded": {
"portnum": "NEIGHBORINFO_APP",
"neighborinfo": {
"nodeId": 0x7C5B0920,
"lastSentById": 0x9E3AA2F0,
"nodeBroadcastIntervalSecs": 1800,
"neighbors": [
{"nodeId": 0x2B2A4D51, "snr": -6.5},
{"nodeId": 0x437FE3E0, "snr": -2.75, "rxTime": 1_758_884_150},
{"nodeId": "!0badc0de", "snr": None},
],
},
},
}
mesh.store_packet_dict(packet)
assert captured, "Expected POST to be triggered for neighbor info"
path, payload, priority = captured[0]
assert path == "/api/neighbors"
assert priority == mesh._NEIGHBOR_POST_PRIORITY
assert payload["node_id"] == "!7c5b0920"
assert payload["node_num"] == 0x7C5B0920
assert payload["rx_time"] == 1_758_884_186
assert payload["node_broadcast_interval_secs"] == 1800
assert payload["last_sent_by_id"] == "!9e3aa2f0"
neighbors = payload["neighbors"]
assert len(neighbors) == 3
assert neighbors[0]["neighbor_id"] == "!2b2a4d51"
assert neighbors[0]["neighbor_num"] == 0x2B2A4D51
assert neighbors[0]["rx_time"] == 1_758_884_186
assert neighbors[0]["snr"] == pytest.approx(-6.5)
assert neighbors[1]["neighbor_id"] == "!437fe3e0"
assert neighbors[1]["rx_time"] == 1_758_884_150
assert neighbors[1]["snr"] == pytest.approx(-2.75)
assert neighbors[2]["neighbor_id"] == "!0badc0de"
assert neighbors[2]["neighbor_num"] == 0x0BAD_C0DE
def test_store_packet_dict_handles_nodeinfo_packet(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
from meshtastic.protobuf import config_pb2, mesh_pb2
node_info = mesh_pb2.NodeInfo()
node_info.num = 321
user = node_info.user
user.id = "!abcd1234"
user.short_name = "LoRa"
user.long_name = "LoRa Node"
user.role = config_pb2.Config.DeviceConfig.Role.Value("CLIENT")
user.hw_model = mesh_pb2.HardwareModel.Value("TBEAM")
node_info.device_metrics.battery_level = 87
node_info.device_metrics.voltage = 3.91
node_info.device_metrics.channel_utilization = 5.5
node_info.device_metrics.air_util_tx = 0.12
node_info.device_metrics.uptime_seconds = 4321
node_info.position.latitude_i = int(52.5 * 1e7)
node_info.position.longitude_i = int(13.4 * 1e7)
node_info.position.altitude = 48
node_info.position.time = 1_700_000_050
node_info.position.location_source = mesh_pb2.Position.LocSource.Value(
"LOC_INTERNAL"
)
node_info.snr = 9.5
node_info.last_heard = 1_700_000_040
node_info.hops_away = 2
node_info.is_favorite = True
payload_b64 = base64.b64encode(node_info.SerializeToString()).decode()
packet = {
"id": 999,
"rxTime": 1_700_000_200,
"from": int("abcd1234", 16),
"rxSnr": -5.5,
"decoded": {
"portnum": "NODEINFO_APP",
"payload": {"__bytes_b64__": payload_b64},
},
}
mesh.store_packet_dict(packet)
assert captured, "Expected nodeinfo packet to trigger POST"
path, payload, priority = captured[0]
assert path == "/api/nodes"
assert priority == mesh._NODE_POST_PRIORITY
assert "!abcd1234" in payload
node_entry = payload["!abcd1234"]
assert node_entry["num"] == 321
assert node_entry["lastHeard"] == 1_700_000_200
assert node_entry["snr"] == pytest.approx(9.5)
assert node_entry["hopsAway"] == 2
assert node_entry["isFavorite"] is True
assert node_entry["user"]["shortName"] == "LoRa"
assert node_entry["deviceMetrics"]["batteryLevel"] == pytest.approx(87)
assert node_entry["deviceMetrics"]["voltage"] == pytest.approx(3.91)
assert node_entry["deviceMetrics"]["uptimeSeconds"] == 4321
assert node_entry["position"]["latitude"] == pytest.approx(52.5)
assert node_entry["position"]["longitude"] == pytest.approx(13.4)
assert node_entry["position"]["time"] == 1_700_000_050
def test_store_packet_dict_handles_user_only_nodeinfo(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
from meshtastic.protobuf import mesh_pb2
user_msg = mesh_pb2.User()
user_msg.id = "!11223344"
user_msg.short_name = "Test"
user_msg.long_name = "Test Node"
payload_b64 = base64.b64encode(user_msg.SerializeToString()).decode()
packet = {
"id": 42,
"rxTime": 1_234,
"from": int("11223344", 16),
"decoded": {
"portnum": "NODEINFO_APP",
"payload": {"__bytes_b64__": payload_b64},
"user": {
"id": "!11223344",
"shortName": "Test",
"longName": "Test Node",
"hwModel": "HELTEC_V3",
},
},
}
mesh.store_packet_dict(packet)
assert captured
_, payload, _ = captured[0]
node_entry = payload["!11223344"]
assert node_entry["lastHeard"] == 1_234
assert node_entry["user"]["longName"] == "Test Node"
assert "deviceMetrics" not in node_entry
def test_store_packet_dict_nodeinfo_merges_proto_user(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
from meshtastic.protobuf import mesh_pb2
user_msg = mesh_pb2.User()
user_msg.id = "!44556677"
user_msg.short_name = "Proto"
user_msg.long_name = "Proto User"
node_info = mesh_pb2.NodeInfo()
node_info.snr = 2.5
payload_b64 = base64.b64encode(node_info.SerializeToString()).decode()
packet = {
"id": 73,
"rxTime": 5_000,
"fromId": "!44556677",
"decoded": {
"portnum": "NODEINFO_APP",
"payload": {"__bytes_b64__": payload_b64},
"user": user_msg,
},
}
mesh.store_packet_dict(packet)
assert captured
_, payload, _ = captured[0]
node_entry = payload["!44556677"]
assert node_entry["lastHeard"] == 5_000
assert node_entry["user"]["shortName"] == "Proto"
assert node_entry["user"]["longName"] == "Proto User"
def test_store_packet_dict_nodeinfo_sanitizes_nested_proto(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
from meshtastic.protobuf import mesh_pb2
user_msg = mesh_pb2.User()
user_msg.id = "!55667788"
user_msg.short_name = "Nested"
node_info = mesh_pb2.NodeInfo()
node_info.hops_away = 1
payload_b64 = base64.b64encode(node_info.SerializeToString()).decode()
packet = {
"id": 74,
"rxTime": 6_000,
"fromId": "!55667788",
"decoded": {
"portnum": "NODEINFO_APP",
"payload": {"__bytes_b64__": payload_b64},
"user": {
"id": "!55667788",
"shortName": "Nested",
"raw": user_msg,
},
},
}
mesh.store_packet_dict(packet)
assert captured
_, payload, _ = captured[0]
node_entry = payload["!55667788"]
assert node_entry["user"]["shortName"] == "Nested"
assert isinstance(node_entry["user"]["raw"], dict)
assert node_entry["user"]["raw"]["id"] == "!55667788"
def test_store_packet_dict_nodeinfo_uses_from_id_when_user_missing(
mesh_module, monkeypatch
):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
from meshtastic.protobuf import mesh_pb2
node_info = mesh_pb2.NodeInfo()
node_info.snr = 1.5
node_info.last_heard = 100
payload_b64 = base64.b64encode(node_info.SerializeToString()).decode()
packet = {
"id": 7,
"rxTime": 200,
"from": 0x01020304,
"decoded": {"portnum": 5, "payload": {"__bytes_b64__": payload_b64}},
}
mesh.store_packet_dict(packet)
assert captured
_, payload, _ = captured[0]
assert "!01020304" in payload
node_entry = payload["!01020304"]
assert node_entry["num"] == 0x01020304
assert node_entry["lastHeard"] == 200
assert node_entry["snr"] == pytest.approx(1.5)
def test_store_packet_dict_ignores_non_text(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
@@ -192,7 +744,7 @@ def test_store_packet_dict_ignores_non_text(mesh_module, monkeypatch):
"toId": "!def",
"decoded": {
"payload": {"text": "ignored"},
"portnum": "POSITION_APP",
"portnum": "ENVIRONMENTAL_MEASUREMENT",
},
}
@@ -352,6 +904,134 @@ def test_pkt_to_dict_handles_dict_and_proto(mesh_module, monkeypatch):
assert isinstance(fallback["_unparsed"], str)
def test_main_retries_interface_creation(mesh_module, monkeypatch):
mesh = mesh_module
attempts = []
class DummyEvent:
def __init__(self):
self.wait_calls = 0
def is_set(self):
return self.wait_calls >= 3
def set(self):
self.wait_calls = 3
def wait(self, timeout):
self.wait_calls += 1
return self.is_set()
class DummyInterface:
def __init__(self):
self.closed = False
self.nodes = {}
def close(self):
self.closed = True
iface = DummyInterface()
def fake_create(port):
attempts.append(port)
if len(attempts) < 3:
raise RuntimeError("boom")
return iface, port
monkeypatch.setattr(mesh, "PORT", "/dev/ttyTEST")
monkeypatch.setattr(mesh, "_create_serial_interface", fake_create)
monkeypatch.setattr(mesh.threading, "Event", DummyEvent)
monkeypatch.setattr(mesh.signal, "signal", lambda *_, **__: None)
monkeypatch.setattr(mesh, "SNAPSHOT_SECS", 0)
monkeypatch.setattr(mesh, "_RECONNECT_INITIAL_DELAY_SECS", 0)
monkeypatch.setattr(mesh, "_RECONNECT_MAX_DELAY_SECS", 0)
mesh.main()
assert len(attempts) == 3
assert iface.closed is True
def test_main_recreates_interface_after_snapshot_error(mesh_module, monkeypatch):
mesh = mesh_module
class DummyEvent:
def __init__(self):
self.wait_calls = 0
def is_set(self):
return self.wait_calls >= 2
def set(self):
self.wait_calls = 2
def wait(self, timeout):
self.wait_calls += 1
return self.is_set()
interfaces = []
def fake_create(port):
fail_first = not interfaces
class FlakyInterface:
def __init__(self, should_fail):
self.closed = False
self._should_fail = should_fail
self._calls = 0
@property
def nodes(self):
self._calls += 1
if self._should_fail and self._calls == 1:
raise RuntimeError("temporary failure")
return {"!node": {"id": 1}}
def close(self):
self.closed = True
interface = FlakyInterface(fail_first)
interfaces.append(interface)
return interface, port
upsert_calls = []
def record_upsert(node_id, node):
upsert_calls.append(node_id)
monkeypatch.setattr(mesh, "PORT", "/dev/ttyTEST")
monkeypatch.setattr(mesh, "_create_serial_interface", fake_create)
monkeypatch.setattr(mesh, "upsert_node", record_upsert)
monkeypatch.setattr(mesh.threading, "Event", DummyEvent)
monkeypatch.setattr(mesh.signal, "signal", lambda *_, **__: None)
monkeypatch.setattr(mesh, "SNAPSHOT_SECS", 0)
monkeypatch.setattr(mesh, "_RECONNECT_INITIAL_DELAY_SECS", 0)
monkeypatch.setattr(mesh, "_RECONNECT_MAX_DELAY_SECS", 0)
mesh.main()
assert len(interfaces) >= 2
assert interfaces[0].closed is True
assert upsert_calls == ["!node"]
def test_main_exits_when_defaults_unavailable(mesh_module, monkeypatch):
mesh = mesh_module
def fail_default():
raise mesh.NoAvailableMeshInterface("no interface available")
monkeypatch.setattr(mesh, "PORT", None)
monkeypatch.setattr(mesh, "_create_default_interface", fail_default)
monkeypatch.setattr(mesh.signal, "signal", lambda *_, **__: None)
with pytest.raises(SystemExit) as exc_info:
mesh.main()
assert exc_info.value.code == 1
def test_store_packet_dict_uses_top_level_channel(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
@@ -378,6 +1058,7 @@ def test_store_packet_dict_uses_top_level_channel(mesh_module, monkeypatch):
assert payload["channel"] == 5
assert payload["portnum"] == "1"
assert payload["text"] == "hi"
assert payload["encrypted"] is None
assert payload["snr"] is None and payload["rssi"] is None
assert priority == mesh._MESSAGE_POST_PRIORITY
@@ -408,10 +1089,139 @@ def test_store_packet_dict_handles_invalid_channel(mesh_module, monkeypatch):
path, payload, priority = captured[0]
assert path == "/api/messages"
assert payload["channel"] == 0
assert payload["encrypted"] is None
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_post_queue_prioritises_nodes(mesh_module, monkeypatch):
def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 555,
"rxTime": 111,
"from": 2988082812,
"to": "!receiver",
"channel": 8,
"encrypted": "abc123==",
}
mesh.store_packet_dict(packet)
assert captured
path, payload, priority = captured[0]
assert path == "/api/messages"
assert payload["encrypted"] == "abc123=="
assert payload["text"] is None
assert payload["from_id"] == 2988082812
assert payload["to_id"] == "!receiver"
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 1_256_091_342,
"rxTime": 1_758_024_300,
"fromId": "!9e95cf60",
"toId": "^all",
"decoded": {
"portnum": "TELEMETRY_APP",
"bitfield": 1,
"telemetry": {
"time": 1_758_024_300,
"deviceMetrics": {
"batteryLevel": 101,
"voltage": 4.224,
"channelUtilization": 0.59666663,
"airUtilTx": 0.03908333,
"uptimeSeconds": 305044,
},
"localStats": {
"numPacketsTx": 1280,
"numPacketsRx": 1425,
},
},
"payload": {
"__bytes_b64__": "DTVr0mgSFQhlFQIrh0AdJb8YPyXYFSA9KJTPEg==",
},
},
}
mesh.store_packet_dict(packet)
assert captured
path, payload, priority = captured[0]
assert path == "/api/telemetry"
assert priority == mesh._TELEMETRY_POST_PRIORITY
assert payload["id"] == 1_256_091_342
assert payload["node_id"] == "!9e95cf60"
assert payload["from_id"] == "!9e95cf60"
assert payload["rx_time"] == 1_758_024_300
assert payload["telemetry_time"] == 1_758_024_300
assert payload["channel"] == 0
assert payload["bitfield"] == 1
assert payload["payload_b64"] == "DTVr0mgSFQhlFQIrh0AdJb8YPyXYFSA9KJTPEg=="
assert payload["battery_level"] == pytest.approx(101.0)
assert payload["voltage"] == pytest.approx(4.224)
assert payload["channel_utilization"] == pytest.approx(0.59666663)
assert payload["air_util_tx"] == pytest.approx(0.03908333)
assert payload["uptime_seconds"] == 305044
def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
packet = {
"id": 2_817_720_548,
"rxTime": 1_758_024_400,
"from": 3_698_627_780,
"decoded": {
"portnum": "TELEMETRY_APP",
"telemetry": {
"time": 1_758_024_390,
"environmentMetrics": {
"temperature": 21.98,
"relativeHumidity": 39.475586,
"barometricPressure": 1017.8353,
},
},
},
}
mesh.store_packet_dict(packet)
assert captured
path, payload, priority = captured[0]
assert path == "/api/telemetry"
assert payload["id"] == 2_817_720_548
assert payload["node_id"] == "!dc7494c4"
assert payload["from_id"] == "!dc7494c4"
assert payload["telemetry_time"] == 1_758_024_390
assert payload["temperature"] == pytest.approx(21.98)
assert payload["relative_humidity"] == pytest.approx(39.475586)
assert payload["barometric_pressure"] == pytest.approx(1017.8353)
def test_post_queue_prioritises_messages(mesh_module, monkeypatch):
mesh = mesh_module
mesh._clear_post_queue()
calls = []
@@ -428,7 +1238,7 @@ def test_post_queue_prioritises_nodes(mesh_module, monkeypatch):
mesh._drain_post_queue()
assert [path for path, _ in calls] == ["/api/nodes", "/api/messages"]
assert [path for path, _ in calls] == ["/api/messages", "/api/nodes"]
def test_store_packet_dict_requires_id(mesh_module, monkeypatch):

View File

@@ -1,6 +1,10 @@
# Main application builder stage
FROM ruby:3.3-alpine AS builder
# Ensure native extensions are built against musl libc rather than
# using glibc precompiled binaries (which fail on Alpine).
ENV BUNDLE_FORCE_RUBY_PLATFORM=true
# Install build dependencies and SQLite3
RUN apk add --no-cache \
build-base \
@@ -15,7 +19,8 @@ WORKDIR /app
COPY web/Gemfile web/Gemfile.lock* ./
# Install gems with SQLite3 support
RUN bundle config set --local without 'development test' && \
RUN bundle config set --local force_ruby_platform true && \
bundle config set --local without 'development test' && \
bundle install --jobs=4 --retry=3
# Production stage

1372
web/app.rb

File diff suppressed because it is too large Load Diff

View File

@@ -17,4 +17,8 @@
set -euo pipefail
bundle install
exec ruby app.rb -p 41447 -o 127.0.0.1
PORT=${PORT:-41447}
BIND_ADDRESS=${BIND_ADDRESS:-0.0.0.0}
exec ruby app.rb -p "${PORT}" -o "${BIND_ADDRESS}"

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff