mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Compare commits
59 Commits
v0.2.1-rc2
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a47a8f8e4 | ||
|
|
c13f3c913f | ||
|
|
2e9b54b6cf | ||
|
|
7e844be627 | ||
|
|
b37e55c29a | ||
|
|
332ba044f2 | ||
|
|
09a2d849ec | ||
|
|
a3fb9b0d5c | ||
|
|
192978acf9 | ||
|
|
581aaea93b | ||
|
|
299752a4f1 | ||
|
|
142c0aa539 | ||
|
|
78168ce3db | ||
|
|
332abbc183 | ||
|
|
c136c5cf26 | ||
|
|
2a65e89eee | ||
|
|
d6f1e7bc80 | ||
|
|
5ac5f3ec3f | ||
|
|
bb4cbfa62c | ||
|
|
f0d600e5d7 | ||
|
|
e0f0a6390d | ||
|
|
d4a27dccf7 | ||
|
|
74c4596dc5 | ||
|
|
1f2328613c | ||
|
|
eeca67f6ea | ||
|
|
4ae8a1cfca | ||
|
|
ff06129a6f | ||
|
|
6d7aa4dd56 | ||
|
|
4548f750d3 | ||
|
|
31f02010d3 | ||
|
|
ec1ea5cbba | ||
|
|
8500c59755 | ||
|
|
556dd6b51c | ||
|
|
3863e2d63d | ||
|
|
9e62621819 | ||
|
|
c8c7c8cc05 | ||
|
|
5116313ab0 | ||
|
|
66389dd27c | ||
|
|
ee6501243f | ||
|
|
8dd912175d | ||
|
|
02f9fb45e2 | ||
|
|
4254dbda91 | ||
|
|
a46bed1c33 | ||
|
|
d711300442 | ||
|
|
98a8203591 | ||
|
|
084c5ae158 | ||
|
|
17018aeb19 | ||
|
|
74b3da6f00 | ||
|
|
ab1217a8bf | ||
|
|
62de1480f7 | ||
|
|
ab2e9b06e1 | ||
|
|
e91ad24cf9 | ||
|
|
2e543b7cd4 | ||
|
|
db4353ccdc | ||
|
|
5a610cf08a | ||
|
|
71b854998c | ||
|
|
0a70ae4b3e | ||
|
|
6e709b0b67 | ||
|
|
a4256cee83 |
@@ -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
|
||||
|
||||
|
||||
19
.github/workflows/docker.yml
vendored
19
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
6
.github/workflows/python.yml
vendored
6
.github/workflows/python.yml
vendored
@@ -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 ./
|
||||
|
||||
4
.github/workflows/ruby.yml
vendored
4
.github/workflows/ruby.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -65,3 +65,4 @@ reports/
|
||||
|
||||
# AI planning and documentation
|
||||
ai_docs/
|
||||
*.log
|
||||
|
||||
129
DOCKER.md
129
DOCKER.md
@@ -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/).
|
||||
|
||||
38
README.md
38
README.md
@@ -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)
|
||||
|
||||

|
||||

|
||||
|
||||
## 🐳 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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
1424
data/mesh.py
1424
data/mesh.py
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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
26
data/neighbors.sql
Normal 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);
|
||||
@@ -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
40
data/positions.sql
Normal 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
43
data/telemetry.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
BIN
scrot-0.3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
77
tests/dump.py
Normal file
77
tests/dump.py
Normal 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)
|
||||
@@ -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
20
tests/neighbors.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
770
tests/nodes.json
770
tests/nodes.json
File diff suppressed because it is too large
Load Diff
84
tests/telemetry.json
Normal file
84
tests/telemetry.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
1372
web/app.rb
File diff suppressed because it is too large
Load Diff
@@ -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
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
1770
web/views/index.erb
1770
web/views/index.erb
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user