mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-10 07:14:49 +02:00
Compare commits
9 Commits
v0.5.7
..
v0.5.8-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
| e74f985630 | |||
| e4facd7f26 | |||
| f533362f8a | |||
| 175a8f368f | |||
| 872bcbd529 | |||
| 8811f71e53 | |||
| fec649a159 | |||
| 9e3f481401 | |||
| 1a497864a7 |
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
service: [web, ingestor]
|
||||
service: [web, ingestor, matrix-bridge]
|
||||
architecture:
|
||||
- { name: linux-amd64, platform: linux/amd64, label: "Linux x86_64", os: linux, architecture: amd64 }
|
||||
- { name: linux-arm64, platform: linux/arm64, label: "Linux ARM64", os: linux, architecture: arm64 }
|
||||
@@ -109,8 +109,8 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./${{ matrix.service == 'web' && 'web/Dockerfile' || 'data/Dockerfile' }}
|
||||
target: production
|
||||
file: ${{ matrix.service == 'web' && './web/Dockerfile' || matrix.service == 'ingestor' && './data/Dockerfile' || './matrix/Dockerfile' }}
|
||||
target: ${{ matrix.service == 'matrix-bridge' && 'runtime' || 'production' }}
|
||||
platforms: ${{ matrix.architecture.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
@@ -119,12 +119,12 @@ jobs:
|
||||
${{ steps.tagging.outputs.include_latest == 'true' && format('{0}/{1}-{2}-{3}:latest', env.REGISTRY, env.IMAGE_PREFIX, matrix.service, matrix.architecture.name) || '' }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || 'Python Ingestor' }} for ${{ matrix.architecture.label }}
|
||||
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || matrix.service == 'ingestor' && 'Python Ingestor' || 'Matrix Bridge' }} for ${{ matrix.architecture.label }}
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.title=PotatoMesh ${{ matrix.service == 'web' && 'Web' || 'Ingestor' }} (${{ matrix.architecture.label }})
|
||||
org.opencontainers.image.title=PotatoMesh ${{ matrix.service == 'web' && 'Web' || matrix.service == 'ingestor' && 'Ingestor' || 'Matrix Bridge' }} (${{ matrix.architecture.label }})
|
||||
org.opencontainers.image.vendor=PotatoMesh
|
||||
org.opencontainers.image.architecture=${{ matrix.architecture.architecture }}
|
||||
org.opencontainers.image.os=${{ matrix.architecture.os }}
|
||||
@@ -208,6 +208,19 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine tagging strategy
|
||||
id: tagging
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
if echo "$VERSION" | grep -E -- '-(rc|beta|alpha|dev)'; then
|
||||
INCLUDE_LATEST=false
|
||||
else
|
||||
INCLUDE_LATEST=true
|
||||
fi
|
||||
|
||||
echo "include_latest=$INCLUDE_LATEST" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Publish release summary
|
||||
run: |
|
||||
echo "## 🚀 PotatoMesh Images Published to GHCR" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -234,4 +247,13 @@ jobs:
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Matrix bridge images
|
||||
echo "### 🧩 Matrix Bridge" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.tagging.outputs.include_latest }}" = "true" ]; then
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,11 +17,15 @@ The repository splits runtime and ingestion logic. `web/` holds the Sinatra dash
|
||||
|
||||
`data/` hosts the Python Meshtastic ingestor plus migrations and CLI scripts. API fixtures and end-to-end harnesses live in `tests/`. Dockerfiles and compose files support containerized workflows.
|
||||
|
||||
`matrix/` contains the Rust Matrix bridge; build with `cargo build --release` or `docker build -f matrix/Dockerfile .`, and keep bridge config under `matrix/Config.toml` when running locally.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run dependency installs inside `web/`: `bundle install` for gems and `npm ci` for JavaScript tooling. Start the app with `cd web && API_TOKEN=dev ./app.sh` for local work or `bundle exec rackup -p 41447` when integrating elsewhere.
|
||||
|
||||
Prep ingestion with `python -m venv .venv && pip install -r data/requirements.txt`; `./data/mesh.sh` streams from live radios. `docker-compose -f docker-compose.dev.yml up` brings up the full stack.
|
||||
|
||||
Container images publish via `.github/workflows/docker.yml` as `potato-mesh-{service}-linux-$arch` (`web`, `ingestor`, `matrix-bridge`), using the Dockerfiles in `web/`, `data/`, and `matrix/`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Use two-space indentation for Ruby and keep `# frozen_string_literal: true` at the top of new files. Keep Ruby classes/modules in `CamelCase`, filenames in `snake_case.rb`, and feature specs in `*_spec.rb`.
|
||||
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.5.7
|
||||
|
||||
* Data: track ingestors heartbeat by @l5yth in <https://github.com/l5yth/potato-mesh/pull/549>
|
||||
* Harden instance selector navigation URLs by @l5yth in <https://github.com/l5yth/potato-mesh/pull/550>
|
||||
* Data: hide channels that have been flag for ignoring by @l5yth in <https://github.com/l5yth/potato-mesh/pull/548>
|
||||
* Web: fix limit when counting remote nodes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/547>
|
||||
* Web: improve instances map and table view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/546>
|
||||
* Web: fix traces submission with optional fields on udp by @l5yth in <https://github.com/l5yth/potato-mesh/pull/545>
|
||||
* Chore: bump version to 0.5.7 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/542>
|
||||
* Handle zero telemetry aggregates by @l5yth in <https://github.com/l5yth/potato-mesh/pull/538>
|
||||
* Web: fix telemetry api to return current in amperes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/541>
|
||||
* Web: fix traces rendering by @l5yth in <https://github.com/l5yth/potato-mesh/pull/535>
|
||||
* Normalize numeric node roles to canonical labels by @l5yth in <https://github.com/l5yth/potato-mesh/pull/539>
|
||||
* Use INSTANCE_DOMAIN env for ingestor by @l5yth in <https://github.com/l5yth/potato-mesh/pull/536>
|
||||
* Web: further refine the federation page by @l5yth in <https://github.com/l5yth/potato-mesh/pull/534>
|
||||
* Add Federation Map by @apo-mak in <https://github.com/l5yth/potato-mesh/pull/532>
|
||||
* Add contact link to the instance data by @apo-mak in <https://github.com/l5yth/potato-mesh/pull/533>
|
||||
* Matrix: create potato-matrix-bridge by @l5yth in <https://github.com/l5yth/potato-mesh/pull/528>
|
||||
|
||||
## v0.5.6
|
||||
|
||||
* Web: display sats in view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/523>
|
||||
* Web: display air quality in separate chart by @l5yth in <https://github.com/l5yth/potato-mesh/pull/521>
|
||||
* Ci: Add macOS and Ubuntu builds to Flutter workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/519>
|
||||
* Web: add current to charts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/520>
|
||||
* App: fix notification icon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/518>
|
||||
* Spec: update test fixtures by @l5yth in <https://github.com/l5yth/potato-mesh/pull/517>
|
||||
* App: generate proper icons by @l5yth in <https://github.com/l5yth/potato-mesh/pull/516>
|
||||
* Web: fix favicon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/515>
|
||||
* Web: add ?since= parameter to api/messages by @l5yth in <https://github.com/l5yth/potato-mesh/pull/512>
|
||||
* App: implement notifications by @l5yth in <https://github.com/l5yth/potato-mesh/pull/511>
|
||||
* App: add theme selector by @l5yth in <https://github.com/l5yth/potato-mesh/pull/507>
|
||||
* App: further harden refresh logic and prefer local first by @l5yth in <https://github.com/l5yth/potato-mesh/pull/506>
|
||||
* Ci: fix app artifacts for tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/504>
|
||||
* Ci: build app artifacts for tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/503>
|
||||
* App: add persistance by @l5yth in <https://github.com/l5yth/potato-mesh/pull/501>
|
||||
* App: instance and chat mvp by @l5yth in <https://github.com/l5yth/potato-mesh/pull/498>
|
||||
* App: add instance selector to settings by @l5yth in <https://github.com/l5yth/potato-mesh/pull/497>
|
||||
* App: add scaffholding gitignore by @l5yth in <https://github.com/l5yth/potato-mesh/pull/496>
|
||||
* Handle reaction app packets without reply id by @l5yth in <https://github.com/l5yth/potato-mesh/pull/495>
|
||||
* Render reaction multiplier counts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/494>
|
||||
* Add comprehensive tests for Flutter reader by @l5yth in <https://github.com/l5yth/potato-mesh/pull/491>
|
||||
* Map numeric role ids to canonical Meshtastic roles by @l5yth in <https://github.com/l5yth/potato-mesh/pull/489>
|
||||
* Update node detail hydration for traces by @l5yth in <https://github.com/l5yth/potato-mesh/pull/490>
|
||||
* Add mobile Flutter CI workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/488>
|
||||
* Align OCI labels in docker workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/487>
|
||||
* Add Meshtastic reader Flutter app by @l5yth in <https://github.com/l5yth/potato-mesh/pull/483>
|
||||
* Handle pre-release Docker tagging by @l5yth in <https://github.com/l5yth/potato-mesh/pull/486>
|
||||
* Web: remove range from charts labels by @l5yth in <https://github.com/l5yth/potato-mesh/pull/485>
|
||||
* Floor override frequencies to MHz integers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/476>
|
||||
* Prevent message ids from being treated as node identifiers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/475>
|
||||
* Fix 1 after emojis in reply. by @Alexkurd in <https://github.com/l5yth/potato-mesh/pull/464>
|
||||
* Add frequency and preset to node table by @l5yth in <https://github.com/l5yth/potato-mesh/pull/472>
|
||||
* Subscribe to traceroute app pubsub topic by @l5yth in <https://github.com/l5yth/potato-mesh/pull/471>
|
||||
* Aggregate telemetry over the last 7 days by @l5yth in <https://github.com/l5yth/potato-mesh/pull/470>
|
||||
* Address missing id field ingestor bug by @l5yth in <https://github.com/l5yth/potato-mesh/pull/469>
|
||||
* Merge secondary channels by name by @l5yth in <https://github.com/l5yth/potato-mesh/pull/468>
|
||||
* Rate limit host device telemetry by @l5yth in <https://github.com/l5yth/potato-mesh/pull/467>
|
||||
* Add traceroutes to frontend by @l5yth in <https://github.com/l5yth/potato-mesh/pull/466>
|
||||
* Feat: implement traceroute app packet handling across the stack by @l5yth in <https://github.com/l5yth/potato-mesh/pull/463>
|
||||
* Bump version and update changelog by @l5yth in <https://github.com/l5yth/potato-mesh/pull/462>
|
||||
|
||||
## v0.5.5
|
||||
|
||||
* Added comprehensive helper unit tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/457>
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
[](https://github.com/l5yth/potato-mesh/issues)
|
||||
[](https://matrix.to/#/#potatomesh:dod.ngo)
|
||||
|
||||
A federated Meshtastic-powered node dashboard for your local community. _No MQTT clutter, just local LoRa aether._
|
||||
A federated, Meshtastic-powered node dashboard for your local community.
|
||||
_No MQTT clutter, just local LoRa aether._
|
||||
|
||||
* Web app with chat window and map view showing nodes, neighbors, telemetry, and messages.
|
||||
* API to POST (authenticated) and to GET nodes and messages.
|
||||
* Shows new node notifications (first seen) in chat.
|
||||
* Web dashboard with chat window and map view showing nodes, positions, neighbors,
|
||||
trace routes, telemetry, and messages.
|
||||
* API to POST (authenticated) and to GET nodes, messages, and telemetry.
|
||||
* Shows new node notifications (first seen) and telemetry logs in chat.
|
||||
* Allows searching and filtering for nodes in map and table view.
|
||||
* Federated: _automatically_ froms a federation with other communities running
|
||||
Potato Mesh!
|
||||
* Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely.
|
||||
* Supports multiple ingestors per instance.
|
||||
* Matrix bridge that posts Meshtastic messages to a defined matrix channel (no
|
||||
radio required).
|
||||
* Mobile app to _read_ messages on your local aether (no radio required).
|
||||
|
||||
Live demo for Berlin #MediumFast: [potatomesh.net](https://potatomesh.net)
|
||||
@@ -58,6 +65,7 @@ RACK_ENV="production" \
|
||||
APP_ENV="production" \
|
||||
API_TOKEN="SuperSecureTokenReally" \
|
||||
INSTANCE_DOMAIN="https://potatomesh.net" \
|
||||
MAP_CENTER="53.55,13.42" \
|
||||
exec ruby app.rb -p 41447 -o 0.0.0.0
|
||||
```
|
||||
|
||||
@@ -68,6 +76,7 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
|
||||
* Provide a strong `API_TOKEN` value to authorize POST requests against the API.
|
||||
* Configure `INSTANCE_DOMAIN` with the public URL of your deployment so vanity
|
||||
links and generated metadata resolve correctly.
|
||||
* Don't forget to set a `MAP_CENTER` to point to your local region.
|
||||
|
||||
The web app can be configured with environment variables (defaults shown):
|
||||
|
||||
@@ -134,7 +143,9 @@ The web app contains an API:
|
||||
* GET `/api/messages?limit=100&encrypted=false&since=0` - returns the latest 100 messages newer than the provided unix timestamp (defaults to `since=0` to return full history; disabled when `PRIVATE=1`)
|
||||
* GET `/api/telemetry?limit=100` - returns the latest 100 telemetry data
|
||||
* GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples
|
||||
* GET `/api/traces?limit=100` - returns the latest 100 trace-routes caught
|
||||
* GET `/api/instances` - returns known potato-mesh instances in other locations
|
||||
* GET `/api/ingestors` - returns active potato-mesh python ingestors that feed data
|
||||
* GET `/metrics`- metrics for the prometheus endpoint
|
||||
* GET `/version`- information about the potato-mesh instance
|
||||
* POST `/api/nodes` - upserts nodes provided as JSON object mapping node ids to node data (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
@@ -142,6 +153,7 @@ The web app contains an API:
|
||||
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`; disabled when `PRIVATE=1`)
|
||||
* POST `/api/telemetry` - appends telemetry provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
* POST `/api/neighbors` - appends neighbor tuples provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
* POST `/api/traces` - appends caught traces routes provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
|
||||
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.
|
||||
|
||||
@@ -190,9 +202,7 @@ node records and parsing new incoming messages. Enable debug output with `DEBUG=
|
||||
specify the connection target with `CONNECTION` (default `/dev/ttyACM0`) or set it to
|
||||
an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. The
|
||||
ingestor will still honor the legacy `POTATOMESH_INSTANCE` variable when
|
||||
`INSTANCE_DOMAIN` is unset to ease upgrades from earlier deployments. To keep
|
||||
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. To keep
|
||||
private channels out of the web UI, set `HIDDEN_CHANNELS` to a comma-separated
|
||||
list of channel names (for example `HIDDEN_CHANNELS="Secret,Ops"`); packets on
|
||||
those channels are discarded instead of being sent to `/api/messages`.
|
||||
@@ -205,12 +215,19 @@ Docker images are published on Github for each release:
|
||||
docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release
|
||||
docker pull ghcr.io/l5yth/potato-mesh/web:v0.5.5 # pinned historical release
|
||||
docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest
|
||||
docker pull ghcr.io/l5yth/potato-mesh/matrix-bridge:latest
|
||||
```
|
||||
|
||||
Feel free to run the [configure.sh](./configure.sh) script to set up your
|
||||
environment. See the [Docker guide](DOCKER.md) for more details and custom
|
||||
deployment instructions.
|
||||
|
||||
## Matrix Bridge
|
||||
|
||||
A matrix bridge is currently being worked on. It requests messages from a configured
|
||||
potato-mesh instance and forwards it to a specified matrix channel; see
|
||||
[matrix/README.md](./matrix/README.md).
|
||||
|
||||
## Mobile App
|
||||
|
||||
A mobile _reader_ app is currently being worked on. Stay tuned for releases and updates.
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.7</string>
|
||||
<string>0.5.8</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.5.7</string>
|
||||
<string>0.5.8</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: potato_mesh_reader
|
||||
description: Meshtastic Reader — read-only view for PotatoMesh messages.
|
||||
publish_to: "none"
|
||||
version: 0.5.7
|
||||
version: 0.5.8
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// 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.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:potato_mesh_reader/main.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('BootstrapProgress renders stage, counts, and detail', () {
|
||||
const progress = BootstrapProgress(
|
||||
stage: 'Downloading',
|
||||
current: 2,
|
||||
total: 5,
|
||||
detail: 'instances',
|
||||
);
|
||||
|
||||
expect(progress.label, 'Downloading 2/5 • instances');
|
||||
|
||||
const fallback = BootstrapProgress(stage: 'Starting');
|
||||
expect(fallback.label, 'Starting');
|
||||
});
|
||||
|
||||
test('InstanceVersion summary prefers populated fields', () {
|
||||
const populated = InstanceVersion(
|
||||
name: 'BerlinMesh',
|
||||
channel: '#MediumFast',
|
||||
frequency: '868MHz',
|
||||
instanceDomain: 'potatomesh.net',
|
||||
);
|
||||
expect(populated.summary, 'BerlinMesh · #MediumFast · 868MHz');
|
||||
|
||||
const minimal = InstanceVersion(
|
||||
name: '',
|
||||
channel: null,
|
||||
frequency: null,
|
||||
instanceDomain: null,
|
||||
);
|
||||
expect(minimal.summary, 'Unknown');
|
||||
});
|
||||
|
||||
test('sortMessagesByRxTime keeps unknown timestamps in place', () {
|
||||
MeshMessage buildMessage({
|
||||
required int id,
|
||||
required String text,
|
||||
required String rxIso,
|
||||
DateTime? rxTime,
|
||||
}) {
|
||||
return MeshMessage(
|
||||
id: id,
|
||||
rxTime: rxTime,
|
||||
rxIso: rxIso,
|
||||
fromId: '!$id',
|
||||
nodeId: '!$id',
|
||||
toId: '^',
|
||||
channelName: '#general',
|
||||
channel: 1,
|
||||
portnum: 'TEXT',
|
||||
text: text,
|
||||
rssi: -50,
|
||||
snr: 1.0,
|
||||
hopLimit: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final withTime = buildMessage(
|
||||
id: 2,
|
||||
rxTime: DateTime.utc(2024, 1, 1, 12, 1),
|
||||
rxIso: '2024-01-01T12:01:00Z',
|
||||
text: 'timed',
|
||||
);
|
||||
final withoutTime = buildMessage(
|
||||
id: 1,
|
||||
rxTime: null,
|
||||
rxIso: 'unknown',
|
||||
text: 'unknown',
|
||||
);
|
||||
final laterTime = buildMessage(
|
||||
id: 3,
|
||||
rxTime: DateTime.utc(2024, 1, 1, 12, 5),
|
||||
rxIso: '2024-01-01T12:05:00Z',
|
||||
text: 'later',
|
||||
);
|
||||
|
||||
final sorted = sortMessagesByRxTime([withoutTime, laterTime, withTime]);
|
||||
|
||||
expect(sorted.first.id, withoutTime.id,
|
||||
reason: 'messages without rxTime should retain position');
|
||||
expect(sorted[1].id, withTime.id,
|
||||
reason: 'messages with timestamps should be ordered chronologically');
|
||||
expect(sorted.last.id, laterTime.id);
|
||||
});
|
||||
|
||||
testWidgets('LoadingScreen displays progress label and icon', (tester) async {
|
||||
const screen = LoadingScreen(
|
||||
progress: BootstrapProgress(stage: 'Fetching'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: screen));
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Fetching'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('PotatoMesh'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('LoadingScreen surfaces errors', (tester) async {
|
||||
const screen = LoadingScreen(
|
||||
progress: BootstrapProgress(stage: 'Loading'),
|
||||
error: 'boom',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: screen));
|
||||
|
||||
expect(find.textContaining('Failed to load: boom'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
|
||||
message information before forwarding it to the accompanying web application.
|
||||
"""
|
||||
|
||||
VERSION = "0.5.7"
|
||||
VERSION = "0.5.8"
|
||||
"""Semantic version identifier shared with the dashboard and front-end."""
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
@@ -76,6 +76,21 @@ x-ingestor-base: &ingestor-base
|
||||
memory: 128M
|
||||
cpus: '0.1'
|
||||
|
||||
x-matrix-bridge-base: &matrix-bridge-base
|
||||
image: ghcr.io/l5yth/potato-mesh-matrix-bridge-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- potatomesh_matrix_bridge_state:/app
|
||||
- ./matrix/Config.toml:/app/Config.toml:ro
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: '0.1'
|
||||
reservations:
|
||||
memory: 64M
|
||||
cpus: '0.05'
|
||||
|
||||
services:
|
||||
web:
|
||||
<<: *web-base
|
||||
@@ -109,6 +124,24 @@ services:
|
||||
profiles:
|
||||
- bridge
|
||||
|
||||
matrix-bridge:
|
||||
<<: *matrix-bridge-base
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- web
|
||||
extra_hosts:
|
||||
- "web:127.0.0.1"
|
||||
|
||||
matrix-bridge-bridge:
|
||||
<<: *matrix-bridge-base
|
||||
container_name: potatomesh-matrix-bridge
|
||||
networks:
|
||||
- potatomesh-network
|
||||
depends_on:
|
||||
- web-bridge
|
||||
profiles:
|
||||
- bridge
|
||||
|
||||
volumes:
|
||||
potatomesh_data:
|
||||
driver: local
|
||||
@@ -116,6 +149,8 @@ volumes:
|
||||
driver: local
|
||||
potatomesh_logs:
|
||||
driver: local
|
||||
potatomesh_matrix_bridge_state:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
potatomesh-network:
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
coverage.lcov
|
||||
bridge_state.json
|
||||
|
||||
Generated
+2117
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -1,6 +1,20 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# 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.
|
||||
|
||||
[package]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -17,4 +31,4 @@ urlencoding = "2"
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mockito = "1"
|
||||
serial_test = "3"
|
||||
serial_test = "3"
|
||||
|
||||
+7
-6
@@ -1,19 +1,20 @@
|
||||
[potatomesh]
|
||||
# Base URL without trailing slash
|
||||
base_url = "https://potatomesh.net/api"
|
||||
# Base domain (with or without trailing slash)
|
||||
base_url = "https://potatomesh.net"
|
||||
# Poll interval in seconds
|
||||
poll_interval_secs = 60
|
||||
|
||||
[matrix]
|
||||
# Homeserver base URL (client API) without trailing slash
|
||||
homeserver = "https://matrix.example.org"
|
||||
homeserver = "https://matrix.dod.ngo"
|
||||
# Appservice access token (from your registration.yaml)
|
||||
as_token = "YOUR_APPSERVICE_AS_TOKEN"
|
||||
as_token = "INVALID_TOKEN_NOT_WORKING"
|
||||
# Server name (domain) part of Matrix user IDs
|
||||
server_name = "example.org"
|
||||
server_name = "dod.ngo"
|
||||
# Room ID to send into (must be joined by the appservice / puppets)
|
||||
room_id = "!yourroomid:example.org"
|
||||
room_id = "!sXabOBXbVObAlZQEUs:c-base.org" # "#potato-bridge:c-base.org"
|
||||
|
||||
[state]
|
||||
# Where to persist last seen message id (optional but recommended)
|
||||
state_file = "bridge_state.json"
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# 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.
|
||||
|
||||
FROM rust:1.79-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY matrix/Cargo.toml matrix/Cargo.lock ./
|
||||
COPY matrix/src ./src
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
cargo build --release --locked
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin potatomesh
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/target/release/potatomesh-matrix-bridge /usr/local/bin/potatomesh-matrix-bridge
|
||||
COPY matrix/Config.toml /app/Config.example.toml
|
||||
COPY matrix/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
+46
-16
@@ -4,7 +4,7 @@ A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix**
|
||||
|
||||
For each PotatoMesh node, the bridge creates (or uses) a **Matrix puppet user**:
|
||||
|
||||
- Matrix localpart: the hex node id (without `!`), e.g. `!67fc83cb` → `@67fc83cb:example.org`
|
||||
- Matrix localpart: `potato_` + the hex node id (without `!`), e.g. `!67fc83cb` → `@potato_67fc83cb:example.org`
|
||||
- Matrix display name: the node’s `long_name` from the PotatoMesh API
|
||||
|
||||
Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users.
|
||||
@@ -13,10 +13,10 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr
|
||||
|
||||
## Features
|
||||
|
||||
- Polls `https://potatomesh.net/api/messages` (or any configured base URL)
|
||||
- Looks up node metadata via `GET /nodes/{hex}` and caches it
|
||||
- Polls `https://potatomesh.net/api/messages` (deriving `/api` from the configured base domain)
|
||||
- Looks up node metadata via `GET /api/nodes/{hex}` and caches it
|
||||
- One Matrix user per node:
|
||||
- username: hex node id
|
||||
- username: `potato_{hex node id}`
|
||||
- display name: `long_name`
|
||||
- Forwards `TEXT_MESSAGE_APP` messages into a single Matrix room
|
||||
- Persists last-seen message ID to avoid duplicates across restarts
|
||||
@@ -26,12 +26,12 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr
|
||||
## Architecture Overview
|
||||
|
||||
- **PotatoMesh side**
|
||||
- `GET /messages` returns an array of messages
|
||||
- `GET /nodes/{hex}` returns node metadata (including `long_name`)
|
||||
- `GET /api/messages` returns an array of messages
|
||||
- `GET /api/nodes/{hex}` returns node metadata (including `long_name`)
|
||||
|
||||
- **Matrix side**
|
||||
- Uses the Matrix Client-Server API with an **appservice access token**
|
||||
- Impersonates puppet users via `user_id=@{hex}:{server_name}&access_token={as_token}`
|
||||
- Impersonates puppet users via `user_id=@potato_{hex}:{server_name}&access_token={as_token}`
|
||||
- Sends `m.room.message` events into a configured room
|
||||
|
||||
This is **not** a full appservice framework; it just speaks the minimal HTTP needed.
|
||||
@@ -43,11 +43,11 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee
|
||||
- Rust (stable) and `cargo`
|
||||
- A Matrix homeserver you control (e.g. Synapse)
|
||||
- An **application service registration** on your homeserver that:
|
||||
- Whitelists the puppet user namespace (e.g. `@[0-9a-f]{8}:example.org`)
|
||||
- Whitelists the puppet user namespace (e.g. `@potato_[0-9a-f]{8}:example.org`)
|
||||
- Provides an `as_token` the bridge can use
|
||||
|
||||
- Network access from the bridge host to:
|
||||
- `https://potatomesh.net/api` (or your configured PotatoMesh API)
|
||||
- `https://potatomesh.net/` (bridge appends `/api`)
|
||||
- Your Matrix homeserver (`https://matrix.example.org`)
|
||||
|
||||
---
|
||||
@@ -60,8 +60,8 @@ Example:
|
||||
|
||||
```toml
|
||||
[potatomesh]
|
||||
# Base URL without trailing slash
|
||||
base_url = "https://potatomesh.net/api"
|
||||
# Base domain (bridge will call {base_url}/api)
|
||||
base_url = "https://potatomesh.net/"
|
||||
# Poll interval in seconds
|
||||
poll_interval_secs = 10
|
||||
|
||||
@@ -84,7 +84,7 @@ state_file = "bridge_state.json"
|
||||
|
||||
The bridge assumes:
|
||||
|
||||
* Messages: `GET {base_url}/messages` → JSON array, for example:
|
||||
* Messages: `GET {base_url}/api/messages` → JSON array, for example:
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -108,7 +108,7 @@ The bridge assumes:
|
||||
]
|
||||
```
|
||||
|
||||
* Nodes: `GET {base_url}/nodes/{hex}` → JSON, for example:
|
||||
* Nodes: `GET {base_url}/api/nodes/{hex}` → JSON, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -122,7 +122,7 @@ The bridge assumes:
|
||||
}
|
||||
```
|
||||
|
||||
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder as the Matrix localpart.
|
||||
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder inside the puppet localpart prefix (`potato_{hex}`).
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +142,7 @@ rate_limited: false
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: true
|
||||
regex: "@[0-9a-f]{8}:example.org"
|
||||
regex: "@potato_[0-9a-f]{8}:example.org"
|
||||
```
|
||||
|
||||
For this bridge, only the `as_token` and `namespaces.users` actually matter. The bridge does not accept inbound events; it only uses the `as_token` to call the homeserver.
|
||||
@@ -170,6 +170,36 @@ target/release/potatomesh-matrix-bridge
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
Build the container from the repo root with the included `matrix/Dockerfile`:
|
||||
|
||||
```bash
|
||||
docker build -f matrix/Dockerfile -t potatomesh-matrix-bridge .
|
||||
```
|
||||
|
||||
Provide your config at `/app/Config.toml` and persist the bridge state file by mounting volumes. Minimal example:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v bridge_state:/app \
|
||||
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
|
||||
potatomesh-matrix-bridge
|
||||
```
|
||||
|
||||
If you prefer to isolate the state file from the config, mount it directly instead of the whole `/app` directory:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v bridge_state:/app \
|
||||
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
|
||||
potatomesh-matrix-bridge
|
||||
```
|
||||
|
||||
The image ships `Config.example.toml` for reference, but the bridge will exit if `/app/Config.toml` is not provided.
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
Ensure `Config.toml` is present and valid, then:
|
||||
@@ -193,7 +223,7 @@ The bridge will:
|
||||
3. For each new `TEXT_MESSAGE_APP`:
|
||||
|
||||
* Fetch node info.
|
||||
* Ensure puppet is registered (`@{hex}:{server_name}`).
|
||||
* Ensure puppet is registered (`@potato_{hex}:{server_name}`).
|
||||
* Set puppet display name to `long_name`.
|
||||
* Send a formatted text message into `room_id` as that puppet.
|
||||
* Update and persist `bridge_state.json`.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# 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.
|
||||
|
||||
set -e
|
||||
|
||||
# Default state file path from Config.toml unless overridden.
|
||||
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
|
||||
STATE_DIR="$(dirname "$STATE_FILE")"
|
||||
|
||||
# Ensure state directory exists and is writable by the non-root user without
|
||||
# touching the read-only config bind mount.
|
||||
if [ ! -d "$STATE_DIR" ]; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
fi
|
||||
|
||||
# Best-effort ownership fix; ignore if the underlying volume is read-only.
|
||||
chown potatomesh:potatomesh "$STATE_DIR" 2>/dev/null || true
|
||||
touch "$STATE_FILE" 2>/dev/null || true
|
||||
chown potatomesh:potatomesh "$STATE_FILE" 2>/dev/null || true
|
||||
|
||||
exec gosu potatomesh potatomesh-matrix-bridge "$@"
|
||||
+18
-4
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// 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.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -53,7 +67,7 @@ mod tests {
|
||||
fn parse_minimal_config_from_toml_str() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/api"
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
@@ -67,7 +81,7 @@ mod tests {
|
||||
"#;
|
||||
|
||||
let cfg: Config = toml::from_str(toml_str).expect("toml should parse");
|
||||
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/api");
|
||||
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/");
|
||||
assert_eq!(cfg.potatomesh.poll_interval_secs, 10);
|
||||
|
||||
assert_eq!(cfg.matrix.homeserver, "https://matrix.example.org");
|
||||
@@ -88,7 +102,7 @@ mod tests {
|
||||
fn load_from_file_valid_file() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/api"
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
@@ -120,7 +134,7 @@ mod tests {
|
||||
fn from_default_path_found() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/api"
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
|
||||
+246
-39
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// 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.
|
||||
|
||||
mod config;
|
||||
mod matrix;
|
||||
mod potatomesh;
|
||||
@@ -10,11 +24,12 @@ use tracing::{error, info};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::matrix::MatrixAppserviceClient;
|
||||
use crate::potatomesh::{PotatoClient, PotatoMessage};
|
||||
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct BridgeState {
|
||||
last_message_id: Option<u64>,
|
||||
last_checked_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl BridgeState {
|
||||
@@ -48,6 +63,92 @@ impl BridgeState {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fetch_params(state: &BridgeState) -> FetchParams {
|
||||
if state.last_message_id.is_none() {
|
||||
FetchParams {
|
||||
limit: None,
|
||||
since: None,
|
||||
}
|
||||
} else if let Some(ts) = state.last_checked_at {
|
||||
FetchParams {
|
||||
limit: None,
|
||||
since: Some(ts),
|
||||
}
|
||||
} else {
|
||||
FetchParams {
|
||||
limit: Some(10),
|
||||
since: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_checkpoint(state: &mut BridgeState, delivered_all: bool, now_secs: u64) -> bool {
|
||||
if !delivered_all {
|
||||
return false;
|
||||
}
|
||||
|
||||
if state.last_message_id.is_some() {
|
||||
state.last_checked_at = Some(now_secs);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_once(
|
||||
potato: &PotatoClient,
|
||||
matrix: &MatrixAppserviceClient,
|
||||
state: &mut BridgeState,
|
||||
state_path: &str,
|
||||
now_secs: u64,
|
||||
) {
|
||||
let params = build_fetch_params(state);
|
||||
|
||||
match potato.fetch_messages(params).await {
|
||||
Ok(mut msgs) => {
|
||||
// sort by id ascending so we process in order
|
||||
msgs.sort_by_key(|m| m.id);
|
||||
|
||||
let mut delivered_all = true;
|
||||
|
||||
for msg in &msgs {
|
||||
if !state.should_forward(msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to the ports you care about
|
||||
if let Some(port) = &msg.portnum {
|
||||
if port != "TEXT_MESSAGE_APP" {
|
||||
state.update_with(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = handle_message(potato, matrix, state, msg).await {
|
||||
error!("Error handling message {}: {:?}", msg.id, e);
|
||||
delivered_all = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// persist after each processed message
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Only advance checkpoint after successful delivery and a known last_message_id.
|
||||
if update_checkpoint(state, delivered_all, now_secs) {
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error fetching PotatoMesh messages: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Logging: RUST_LOG=info,bridge=debug,reqwest=warn ...
|
||||
@@ -64,7 +165,9 @@ async fn main() -> Result<()> {
|
||||
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
|
||||
potato.health_check().await?;
|
||||
let matrix = MatrixAppserviceClient::new(http.clone(), cfg.matrix.clone());
|
||||
matrix.health_check().await?;
|
||||
|
||||
let state_path = &cfg.state.state_file;
|
||||
let mut state = BridgeState::load(state_path)?;
|
||||
@@ -73,36 +176,12 @@ async fn main() -> Result<()> {
|
||||
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
|
||||
|
||||
loop {
|
||||
match potato.fetch_messages().await {
|
||||
Ok(mut msgs) => {
|
||||
// sort by id ascending so we process in order
|
||||
msgs.sort_by_key(|m| m.id);
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
for msg in msgs {
|
||||
if !state.should_forward(&msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to the ports you care about
|
||||
if msg.portnum != "TEXT_MESSAGE_APP" {
|
||||
state.update_with(&msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = handle_message(&potato, &matrix, &mut state, &msg).await {
|
||||
error!("Error handling message {}: {:?}", msg.id, e);
|
||||
}
|
||||
|
||||
// persist after each processed message
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error fetching PotatoMesh messages: {:?}", e);
|
||||
}
|
||||
}
|
||||
poll_once(&potato, &matrix, &mut state, state_path, now_secs).await;
|
||||
|
||||
sleep(poll_interval).await;
|
||||
}
|
||||
@@ -129,13 +208,19 @@ async fn handle_message(
|
||||
.unwrap_or_else(|| node.long_name.clone());
|
||||
|
||||
let body = format!(
|
||||
"[{short}] {text}\n({from_id} → {to_id}, RSSI {rssi} dB, SNR {snr} dB, {chan}/{preset})",
|
||||
"[{short}] {text}\n({from_id} → {to_id}, {rssi}, {snr}, {chan}/{preset})",
|
||||
short = short,
|
||||
text = msg.text,
|
||||
from_id = msg.from_id,
|
||||
to_id = msg.to_id,
|
||||
rssi = msg.rssi,
|
||||
snr = msg.snr,
|
||||
rssi = msg
|
||||
.rssi
|
||||
.map(|v| format!("RSSI {v} dB"))
|
||||
.unwrap_or_else(|| "RSSI n/a".to_string()),
|
||||
snr = msg
|
||||
.snr
|
||||
.map(|v| format!("SNR {v} dB"))
|
||||
.unwrap_or_else(|| "SNR n/a".to_string()),
|
||||
chan = msg.channel_name,
|
||||
preset = msg.modem_preset,
|
||||
);
|
||||
@@ -161,14 +246,14 @@ mod tests {
|
||||
from_id: "!abcd1234".to_string(),
|
||||
to_id: "^all".to_string(),
|
||||
channel: 1,
|
||||
portnum: "TEXT_MESSAGE_APP".to_string(),
|
||||
portnum: Some("TEXT_MESSAGE_APP".to_string()),
|
||||
text: "Ping".to_string(),
|
||||
rssi: -100,
|
||||
hop_limit: 1,
|
||||
rssi: Some(-100),
|
||||
hop_limit: Some(1),
|
||||
lora_freq: 868,
|
||||
modem_preset: "MediumFast".to_string(),
|
||||
channel_name: "TEST".to_string(),
|
||||
snr: 0.0,
|
||||
snr: Some(0.0),
|
||||
reply_id: None,
|
||||
node_id: "!abcd1234".to_string(),
|
||||
}
|
||||
@@ -209,6 +294,7 @@ mod tests {
|
||||
fn bridge_state_update_is_monotonic() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(50),
|
||||
last_checked_at: None,
|
||||
};
|
||||
let m = sample_msg(40);
|
||||
|
||||
@@ -225,11 +311,13 @@ mod tests {
|
||||
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(12345),
|
||||
last_checked_at: Some(99),
|
||||
};
|
||||
state.save(path_str).unwrap();
|
||||
|
||||
let loaded_state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(loaded_state.last_message_id, Some(12345));
|
||||
assert_eq!(loaded_state.last_checked_at, Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,6 +328,125 @@ mod tests {
|
||||
|
||||
let state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(state.last_message_id, None);
|
||||
assert_eq!(state.last_checked_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_requires_last_message_id() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: None,
|
||||
last_checked_at: Some(10),
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, true, 123);
|
||||
assert!(!saved);
|
||||
assert_eq!(state.last_checked_at, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_skips_when_not_delivered() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(5),
|
||||
last_checked_at: Some(10),
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, false, 123);
|
||||
assert!(!saved);
|
||||
assert_eq!(state.last_checked_at, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_sets_when_safe() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(5),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, true, 123);
|
||||
assert!(saved);
|
||||
assert_eq!(state.last_checked_at, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_respects_missing_last_message_id() {
|
||||
let state = BridgeState {
|
||||
last_message_id: None,
|
||||
last_checked_at: Some(123),
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
assert_eq!(params.limit, None);
|
||||
assert_eq!(params.since, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_uses_since_when_safe() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_checked_at: Some(123),
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
assert_eq!(params.limit, None);
|
||||
assert_eq!(params.since, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_defaults_to_small_window() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
assert_eq!(params.limit, Some(10));
|
||||
assert_eq!(params.since, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_persists_checkpoint_without_messages() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let state_path = tmp_dir.path().join("state.json");
|
||||
let state_str = state_path.to_str().unwrap();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock_msgs = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let potatomesh_cfg = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 1,
|
||||
};
|
||||
let matrix_cfg = MatrixConfig {
|
||||
homeserver: server.url(),
|
||||
as_token: "AS_TOKEN".to_string(),
|
||||
server_name: "example.org".to_string(),
|
||||
room_id: "!roomid:example.org".to_string(),
|
||||
};
|
||||
|
||||
let potato = PotatoClient::new(http_client.clone(), potatomesh_cfg);
|
||||
let matrix = MatrixAppserviceClient::new(http_client, matrix_cfg);
|
||||
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
poll_once(&potato, &matrix, &mut state, state_str, 123).await;
|
||||
|
||||
mock_msgs.assert();
|
||||
|
||||
// Should have advanced checkpoint and saved it.
|
||||
assert_eq!(state.last_checked_at, Some(123));
|
||||
let loaded = BridgeState::load(state_str).unwrap();
|
||||
assert_eq!(loaded.last_checked_at, Some(123));
|
||||
assert_eq!(loaded.last_message_id, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -258,11 +465,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let node_id = "abcd1234";
|
||||
let user_id = format!("@{}:{}", node_id, matrix_cfg.server_name);
|
||||
let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name);
|
||||
let encoded_user = urlencoding::encode(&user_id);
|
||||
|
||||
let mock_get_node = server
|
||||
.mock("GET", "/nodes/abcd1234")
|
||||
.mock("GET", "/api/nodes/abcd1234")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"node_id": "!abcd1234", "long_name": "Test Node", "short_name": "TN"}"#)
|
||||
|
||||
+69
-6
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// 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.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
@@ -27,9 +41,24 @@ impl MatrixAppserviceClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a node_id like "!deadbeef" into Matrix localpart "deadbeef".
|
||||
/// Basic liveness check against the homeserver.
|
||||
pub async fn health_check(&self) -> anyhow::Result<()> {
|
||||
let url = format!("{}/_matrix/client/versions", self.cfg.homeserver);
|
||||
let resp = self.http.get(&url).send().await?;
|
||||
if resp.status().is_success() {
|
||||
tracing::info!("Matrix homeserver healthy at {}", self.cfg.homeserver);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"Matrix homeserver versions check failed with status {}",
|
||||
resp.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a node_id like "!deadbeef" into Matrix localpart "potato_deadbeef".
|
||||
pub fn localpart_from_node_id(node_id: &str) -> String {
|
||||
node_id.trim_start_matches('!').to_string()
|
||||
format!("potato_{}", node_id.trim_start_matches('!'))
|
||||
}
|
||||
|
||||
/// Build a full Matrix user_id from localpart.
|
||||
@@ -175,11 +204,11 @@ mod tests {
|
||||
fn localpart_strips_bang_correctly() {
|
||||
assert_eq!(
|
||||
MatrixAppserviceClient::localpart_from_node_id("!deadbeef"),
|
||||
"deadbeef"
|
||||
"potato_deadbeef"
|
||||
);
|
||||
assert_eq!(
|
||||
MatrixAppserviceClient::localpart_from_node_id("cafebabe"),
|
||||
"cafebabe"
|
||||
"potato_cafebabe"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,8 +217,42 @@ mod tests {
|
||||
let http = reqwest::Client::builder().build().unwrap();
|
||||
let client = MatrixAppserviceClient::new(http, dummy_cfg());
|
||||
|
||||
let uid = client.user_id("deadbeef");
|
||||
assert_eq!(uid, "@deadbeef:example.org");
|
||||
let uid = client.user_id("potato_deadbeef");
|
||||
assert_eq!(uid, "@potato_deadbeef:example.org");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/_matrix/client/versions")
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/_matrix/client/versions")
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+222
-24
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// 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.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -14,19 +28,29 @@ pub struct PotatoMessage {
|
||||
pub from_id: String,
|
||||
pub to_id: String,
|
||||
pub channel: u8,
|
||||
pub portnum: String,
|
||||
#[serde(default)]
|
||||
pub portnum: Option<String>,
|
||||
pub text: String,
|
||||
pub rssi: i16,
|
||||
pub hop_limit: u8,
|
||||
#[serde(default)]
|
||||
pub rssi: Option<i16>,
|
||||
#[serde(default)]
|
||||
pub hop_limit: Option<u8>,
|
||||
pub lora_freq: u32,
|
||||
pub modem_preset: String,
|
||||
pub channel_name: String,
|
||||
pub snr: f32,
|
||||
#[serde(default)]
|
||||
pub snr: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub reply_id: Option<u64>,
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct FetchParams {
|
||||
pub limit: Option<u32>,
|
||||
pub since: Option<u64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct PotatoNode {
|
||||
@@ -67,22 +91,55 @@ impl PotatoClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the API root; accept either a bare domain or one already ending in `/api`.
|
||||
fn api_base(&self) -> String {
|
||||
let trimmed = self.cfg.base_url.trim_end_matches('/');
|
||||
if trimmed.ends_with("/api") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}/api", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn messages_url(&self) -> String {
|
||||
format!("{}/messages", self.cfg.base_url)
|
||||
format!("{}/messages", self.api_base())
|
||||
}
|
||||
|
||||
fn node_url(&self, hex_id: &str) -> String {
|
||||
// e.g. https://potatomesh.net/api/nodes/67fc83cb
|
||||
format!("{}/nodes/{}", self.cfg.base_url, hex_id)
|
||||
format!("{}/nodes/{}", self.api_base(), hex_id)
|
||||
}
|
||||
|
||||
pub async fn fetch_messages(&self) -> anyhow::Result<Vec<PotatoMessage>> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.messages_url())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
/// Basic liveness check against the PotatoMesh API.
|
||||
pub async fn health_check(&self) -> anyhow::Result<()> {
|
||||
let base = self
|
||||
.cfg
|
||||
.base_url
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches("/api");
|
||||
let url = format!("{}/version", base);
|
||||
let resp = self.http.get(&url).send().await?;
|
||||
if resp.status().is_success() {
|
||||
tracing::info!("PotatoMesh API healthy at {}", self.cfg.base_url);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"PotatoMesh health check failed with status {}",
|
||||
resp.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_messages(&self, params: FetchParams) -> anyhow::Result<Vec<PotatoMessage>> {
|
||||
let mut req = self.http.get(self.messages_url());
|
||||
if let Some(limit) = params.limit {
|
||||
req = req.query(&[("limit", limit)]);
|
||||
}
|
||||
if let Some(since) = params.since {
|
||||
req = req.query(&[("since", since)]);
|
||||
}
|
||||
|
||||
let resp = req.send().await?.error_for_status()?;
|
||||
|
||||
let msgs: Vec<PotatoMessage> = resp.json().await?;
|
||||
Ok(msgs)
|
||||
@@ -146,9 +203,38 @@ mod tests {
|
||||
assert_eq!(m.id, 2947676906);
|
||||
assert_eq!(m.from_id, "!da6556d4");
|
||||
assert_eq!(m.node_id, "!06871773");
|
||||
assert_eq!(m.portnum, "TEXT_MESSAGE_APP");
|
||||
assert_eq!(m.portnum.as_deref(), Some("TEXT_MESSAGE_APP"));
|
||||
assert_eq!(m.lora_freq, 868);
|
||||
assert!((m.snr - (-9.0)).abs() < f32::EPSILON);
|
||||
assert!((m.snr.unwrap() - (-9.0)).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_message_with_missing_optional_fields() {
|
||||
let json = r#"
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"rx_time": 0,
|
||||
"rx_iso": "2025-11-27T11:03:56Z",
|
||||
"from_id": "!abcd1234",
|
||||
"to_id": "^all",
|
||||
"channel": 1,
|
||||
"text": "Ping",
|
||||
"lora_freq": 868,
|
||||
"modem_preset": "MediumFast",
|
||||
"channel_name": "TEST",
|
||||
"node_id": "!abcd1234"
|
||||
}
|
||||
]
|
||||
"#;
|
||||
|
||||
let msgs: Vec<PotatoMessage> = serde_json::from_str(json).expect("valid message json");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
let m = &msgs[0];
|
||||
assert!(m.portnum.is_none());
|
||||
assert!(m.rssi.is_none());
|
||||
assert!(m.hop_limit.is_none());
|
||||
assert!(m.snr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -206,7 +292,29 @@ mod tests {
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/messages");
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_url_with_trailing_slash() {
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: "http://localhost:8080/".to_string(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_url_with_existing_api_suffix() {
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: "http://localhost:8080/api/".to_string(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -219,7 +327,7 @@ mod tests {
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(
|
||||
client.node_url("!1234"),
|
||||
"http://localhost:8080/nodes/!1234"
|
||||
"http://localhost:8080/api/nodes/!1234"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +335,8 @@ mod tests {
|
||||
async fn test_fetch_messages_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/messages")
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any) // allow optional query params
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
@@ -251,7 +360,7 @@ mod tests {
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.fetch_messages().await;
|
||||
let result = client.fetch_messages(FetchParams::default()).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
@@ -261,9 +370,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_messages_error() {
|
||||
async fn test_health_check_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/messages").with_status(500).create();
|
||||
let mock = server.mock("GET", "/version").with_status(200).create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
@@ -271,12 +380,97 @@ mod tests {
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.fetch_messages().await;
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_strips_api_suffix() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/version").with_status(200).create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let mut base = server.url();
|
||||
base.push_str("/api");
|
||||
let config = PotatomeshConfig {
|
||||
base_url: base,
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/version").with_status(500).create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_messages_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.fetch_messages(FetchParams::default()).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_messages_with_limit_and_since() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query("limit=10&since=123")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let params = FetchParams {
|
||||
limit: Some(10),
|
||||
since: Some(123),
|
||||
};
|
||||
let result = client.fetch_messages(params).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_node_cache_hit() {
|
||||
let http_client = reqwest::Client::new();
|
||||
@@ -313,7 +507,8 @@ mod tests {
|
||||
async fn test_get_node_cache_miss() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/nodes/1234")
|
||||
.mock("GET", "/api/nodes/1234")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
@@ -348,7 +543,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_node_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/nodes/1234").with_status(500).create();
|
||||
let mock = server
|
||||
.mock("GET", "/api/nodes/1234")
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# 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.
|
||||
"""Unit tests for :mod:`data.mesh_ingestor.daemon`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from data.mesh_ingestor import daemon
|
||||
|
||||
|
||||
class FakeEvent:
|
||||
"""Test double for :class:`threading.Event` that can auto-set itself."""
|
||||
|
||||
instances: list["FakeEvent"] = []
|
||||
|
||||
def __init__(self, *, auto_set_on_wait: bool = False):
|
||||
self._is_set = False
|
||||
self._auto_set_on_wait = auto_set_on_wait
|
||||
self.wait_calls: list[Any] = []
|
||||
FakeEvent.instances.append(self)
|
||||
|
||||
def set(self) -> None:
|
||||
"""Mark the event as set."""
|
||||
|
||||
self._is_set = True
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Return whether the event is currently set."""
|
||||
|
||||
return self._is_set
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool:
|
||||
"""Record waits and optionally auto-set the flag."""
|
||||
|
||||
self.wait_calls.append(timeout)
|
||||
if self._auto_set_on_wait:
|
||||
self._is_set = True
|
||||
return self._is_set
|
||||
|
||||
|
||||
class AutoSetEvent(FakeEvent):
|
||||
"""Event variant that automatically sets on each wait call."""
|
||||
|
||||
def __init__(self): # noqa: D401 - short initializer docstring handled by class
|
||||
super().__init__(auto_set_on_wait=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_fake_events():
|
||||
"""Ensure :class:`FakeEvent` registry is cleared between tests."""
|
||||
|
||||
FakeEvent.instances.clear()
|
||||
yield
|
||||
FakeEvent.instances.clear()
|
||||
|
||||
|
||||
def test_event_wait_default_detection(monkeypatch):
|
||||
"""``_event_wait_allows_default_timeout`` matches defaulted signatures."""
|
||||
|
||||
assert daemon._event_wait_allows_default_timeout() is True
|
||||
|
||||
class _NoDefaultEvent:
|
||||
def wait(self, timeout): # type: ignore[override]
|
||||
return bool(timeout)
|
||||
|
||||
monkeypatch.setattr(
|
||||
daemon, "threading", types.SimpleNamespace(Event=_NoDefaultEvent)
|
||||
)
|
||||
assert daemon._event_wait_allows_default_timeout() is False
|
||||
|
||||
|
||||
def test_subscribe_receive_topics(monkeypatch):
|
||||
"""Subscribing to receive topics returns the exact topic list."""
|
||||
|
||||
subscribed: list[str] = []
|
||||
|
||||
def _record_subscription(_handler, topic):
|
||||
subscribed.append(topic)
|
||||
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=_record_subscription)
|
||||
)
|
||||
assert daemon._subscribe_receive_topics() == list(daemon._RECEIVE_TOPICS)
|
||||
assert subscribed == list(daemon._RECEIVE_TOPICS)
|
||||
|
||||
|
||||
def test_node_items_snapshot_handles_mutation(monkeypatch):
|
||||
"""Snapshots tolerate temporary runtime errors while iterating."""
|
||||
|
||||
class MutatingMapping(dict):
|
||||
def __bool__(self):
|
||||
return True
|
||||
|
||||
def items(self): # type: ignore[override]
|
||||
raise RuntimeError("dictionary changed size during iteration")
|
||||
|
||||
monkeypatch.setattr(daemon.time, "sleep", lambda _: None)
|
||||
assert daemon._node_items_snapshot({"a": 1}) == [("a", 1)]
|
||||
assert daemon._node_items_snapshot(MutatingMapping(), retries=1) is None
|
||||
|
||||
class IteratingMapping:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self._data = {"x": 10, "y": 20}
|
||||
|
||||
def __iter__(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError("dictionary changed size during iteration")
|
||||
return iter(self._data)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._data[key]
|
||||
|
||||
mapping = IteratingMapping()
|
||||
assert daemon._node_items_snapshot(mapping, retries=2) == [("x", 10), ("y", 20)]
|
||||
|
||||
|
||||
def test_close_interface_respects_timeout(monkeypatch):
|
||||
"""Long-running close calls emit a timeout debug log."""
|
||||
|
||||
log_calls = []
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0.01)
|
||||
monkeypatch.setattr(
|
||||
daemon.config, "_debug_log", lambda *args, **kwargs: log_calls.append(kwargs)
|
||||
)
|
||||
blocker = threading.Event()
|
||||
|
||||
class SlowInterface:
|
||||
def close(self):
|
||||
blocker.wait(timeout=0.1)
|
||||
|
||||
daemon._close_interface(SlowInterface())
|
||||
assert any("timeout_seconds" in entry for entry in log_calls)
|
||||
|
||||
|
||||
def test_close_interface_immediate_path(monkeypatch):
|
||||
"""A zero timeout calls ``close`` inline without threading."""
|
||||
|
||||
flags = {"called": False}
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
|
||||
|
||||
class ImmediateInterface:
|
||||
def close(self):
|
||||
flags["called"] = True
|
||||
|
||||
daemon._close_interface(ImmediateInterface())
|
||||
assert flags["called"] is True
|
||||
|
||||
|
||||
def test_ble_interface_detection():
|
||||
"""Detect BLE module names reliably."""
|
||||
|
||||
class BLE:
|
||||
__module__ = "meshtastic.ble_interface"
|
||||
|
||||
class NonBLE:
|
||||
__module__ = "meshtastic.serial"
|
||||
|
||||
assert daemon._is_ble_interface(BLE()) is True
|
||||
assert daemon._is_ble_interface(NonBLE()) is False
|
||||
assert daemon._is_ble_interface(None) is False
|
||||
|
||||
|
||||
def test_process_ingestor_heartbeat_with_extracted_host(monkeypatch):
|
||||
"""Host id extraction triggers heartbeat announcement flag updates."""
|
||||
|
||||
host_ids: list[str | None] = [None]
|
||||
ingestor_ids: list[str | None] = []
|
||||
queued: list[bool] = []
|
||||
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_ids[0])
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!abcd"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers,
|
||||
"register_host_node_id",
|
||||
lambda node: host_ids.__setitem__(0, node),
|
||||
)
|
||||
monkeypatch.setattr(daemon.ingestors, "set_ingestor_node_id", ingestor_ids.append)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors,
|
||||
"queue_ingestor_heartbeat",
|
||||
lambda force: queued.append(force) or True,
|
||||
)
|
||||
|
||||
assert (
|
||||
daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=False)
|
||||
is True
|
||||
)
|
||||
assert host_ids[0] == "!abcd"
|
||||
assert ingestor_ids[-1] == "!abcd"
|
||||
assert queued[-1] is True
|
||||
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!abcd")
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors,
|
||||
"queue_ingestor_heartbeat",
|
||||
lambda force: queued.append(force) or False,
|
||||
)
|
||||
assert (
|
||||
daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=True)
|
||||
is True
|
||||
)
|
||||
assert queued[-1] is False
|
||||
|
||||
|
||||
def test_connected_state_branches(monkeypatch):
|
||||
"""Connection state resolves across multiple attribute forms."""
|
||||
|
||||
event = threading.Event()
|
||||
event.set()
|
||||
assert daemon._connected_state(event) is True
|
||||
|
||||
class CallableCandidate:
|
||||
def __call__(self):
|
||||
return False
|
||||
|
||||
assert daemon._connected_state(CallableCandidate()) is False
|
||||
|
||||
class BooleanCandidate:
|
||||
def __bool__(self):
|
||||
raise RuntimeError("cannot bool")
|
||||
|
||||
assert daemon._connected_state(BooleanCandidate()) is None
|
||||
|
||||
class HasIsSet:
|
||||
def is_set(self):
|
||||
raise RuntimeError("broken")
|
||||
|
||||
assert daemon._connected_state(HasIsSet()) is None
|
||||
|
||||
|
||||
def _configure_common_defaults(
|
||||
monkeypatch, *, energy_saving: bool = False, inactivity: float = 0.0
|
||||
):
|
||||
"""Set fast configuration defaults shared by daemon integration tests."""
|
||||
|
||||
monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "ENERGY_SAVING", energy_saving)
|
||||
monkeypatch.setattr(
|
||||
daemon.config, "_ENERGY_ONLINE_DURATION_SECS", 0 if energy_saving else 0.0
|
||||
)
|
||||
monkeypatch.setattr(daemon.config, "_ENERGY_SLEEP_SECS", 0.0)
|
||||
monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", inactivity)
|
||||
monkeypatch.setattr(daemon.config, "CONNECTION", "serial0")
|
||||
|
||||
|
||||
class DummyInterface:
|
||||
"""Lightweight mesh interface stand-in used for daemon integration tests."""
|
||||
|
||||
def __init__(self, *, nodes=None, is_connected=True, client_present=True):
|
||||
self.nodes = nodes if nodes is not None else {"!node": {"id": 1}}
|
||||
self.isConnected = is_connected
|
||||
self.client = object() if client_present else None
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_main_happy_path(monkeypatch):
|
||||
"""The main loop processes snapshots and heartbeats once before stopping."""
|
||||
|
||||
_configure_common_defaults(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoSetEvent,
|
||||
current_thread=threading.current_thread,
|
||||
main_thread=threading.main_thread,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces,
|
||||
"_create_serial_interface",
|
||||
lambda candidate: (DummyInterface(), candidate),
|
||||
)
|
||||
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
|
||||
)
|
||||
|
||||
host_id = {"value": None}
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers,
|
||||
"register_host_node_id",
|
||||
lambda node: host_id.__setitem__("value", node),
|
||||
)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_id["value"])
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
|
||||
|
||||
heartbeats: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors,
|
||||
"queue_ingestor_heartbeat",
|
||||
lambda force: heartbeats.append(force) or True,
|
||||
)
|
||||
|
||||
daemon.main()
|
||||
assert heartbeats
|
||||
assert host_id["value"] == "!host"
|
||||
assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True
|
||||
|
||||
|
||||
def test_main_energy_saving_disconnect(monkeypatch):
|
||||
"""Energy saving mode disconnects and sleeps when deadlines expire."""
|
||||
|
||||
_configure_common_defaults(monkeypatch, energy_saving=True)
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoSetEvent,
|
||||
current_thread=threading.current_thread,
|
||||
main_thread=threading.main_thread,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces,
|
||||
"_create_serial_interface",
|
||||
lambda candidate: (DummyInterface(), candidate),
|
||||
)
|
||||
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True
|
||||
)
|
||||
|
||||
daemon.main()
|
||||
assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True
|
||||
|
||||
|
||||
def test_main_inactivity_reconnect(monkeypatch):
|
||||
"""Inactivity triggers reconnect attempts and respects stop events."""
|
||||
|
||||
_configure_common_defaults(monkeypatch, inactivity=0.5)
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoSetEvent,
|
||||
current_thread=threading.current_thread,
|
||||
main_thread=threading.main_thread,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
|
||||
)
|
||||
|
||||
interface_cycle = iter(
|
||||
[DummyInterface(is_connected=False), DummyInterface(is_connected=True)]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces,
|
||||
"_create_serial_interface",
|
||||
lambda candidate: (next(interface_cycle), candidate),
|
||||
)
|
||||
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
|
||||
|
||||
monotonic_calls = iter([0.0, 1.0, 2.0, 3.0, 4.0])
|
||||
monkeypatch.setattr(daemon.time, "monotonic", lambda: next(monotonic_calls))
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: 0.0)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True
|
||||
)
|
||||
|
||||
daemon.main()
|
||||
assert any(event.is_set() for event in FakeEvent.instances)
|
||||
@@ -132,6 +132,7 @@ module PotatoMesh
|
||||
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
|
||||
is_private = coerce_boolean(raw_private)
|
||||
signature = string_or_nil(payload["signature"])
|
||||
contact_link = string_or_nil(payload["contactLink"])
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
@@ -145,6 +146,7 @@ module PotatoMesh
|
||||
longitude: longitude,
|
||||
last_update_time: last_update_time,
|
||||
is_private: is_private,
|
||||
contact_link: contact_link,
|
||||
}
|
||||
|
||||
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
|
||||
@@ -157,6 +159,10 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
signature_valid = verify_instance_signature(attributes, signature, attributes[:pubkey])
|
||||
if !signature_valid && contact_link
|
||||
stripped_attributes = attributes.merge(contact_link: nil)
|
||||
signature_valid = verify_instance_signature(stripped_attributes, signature, attributes[:pubkey])
|
||||
end
|
||||
# Some remote peers sign payloads using a canonicalised lowercase
|
||||
# domain while still sending a mixed-case domain. Retry signature
|
||||
# verification with the original casing when the first attempt
|
||||
@@ -164,6 +170,10 @@ module PotatoMesh
|
||||
if !signature_valid && raw_domain && normalized_domain && raw_domain.casecmp?(normalized_domain) && raw_domain != normalized_domain
|
||||
alternate_attributes = attributes.merge(domain: raw_domain)
|
||||
signature_valid = verify_instance_signature(alternate_attributes, signature, attributes[:pubkey])
|
||||
if !signature_valid && contact_link
|
||||
stripped_alternate = alternate_attributes.merge(contact_link: nil)
|
||||
signature_valid = verify_instance_signature(stripped_alternate, signature, attributes[:pubkey])
|
||||
end
|
||||
end
|
||||
|
||||
unless signature_valid
|
||||
|
||||
@@ -175,7 +175,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] semantic version identifier.
|
||||
def version_fallback
|
||||
"0.5.7"
|
||||
"0.5.8"
|
||||
end
|
||||
|
||||
# Default refresh interval for frontend polling routines.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.7",
|
||||
"version": "0.5.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.7",
|
||||
"version": "0.5.8",
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.7",
|
||||
"version": "0.5.8",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1607,6 +1607,161 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts registrations when contactLink is part of the signed payload" do
|
||||
contact_link = "https://example.test/contact"
|
||||
linked_attributes = instance_attributes.merge(contact_link: contact_link)
|
||||
linked_signature_payload = canonical_instance_payload(linked_attributes)
|
||||
linked_signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, linked_signature_payload),
|
||||
)
|
||||
linked_payload = instance_payload.merge(
|
||||
"contactLink" => contact_link,
|
||||
"signature" => linked_signature,
|
||||
)
|
||||
|
||||
post "/api/instances", linked_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link, signature FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(linked_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts instance announcement payloads produced by the application including contactLink" do
|
||||
contact_link = "https://example.test/contact"
|
||||
announcement_attributes = instance_attributes.merge(contact_link: contact_link)
|
||||
announcement_signature = Base64.strict_encode64(
|
||||
instance_key.sign(
|
||||
OpenSSL::Digest::SHA256.new,
|
||||
canonical_instance_payload(announcement_attributes),
|
||||
),
|
||||
)
|
||||
announcement_payload = application_class.instance_announcement_payload(
|
||||
announcement_attributes,
|
||||
announcement_signature,
|
||||
)
|
||||
|
||||
post "/api/instances", announcement_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link, signature FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(announcement_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts signatures that omit contactLink for backwards compatibility" do
|
||||
contact_link = "https://legacy.example/contact"
|
||||
legacy_signature_payload = canonical_instance_payload(instance_attributes)
|
||||
legacy_signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, legacy_signature_payload),
|
||||
)
|
||||
legacy_payload = instance_payload.merge(
|
||||
"contactLink" => contact_link,
|
||||
"signature" => legacy_signature,
|
||||
)
|
||||
|
||||
post "/api/instances", legacy_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link, signature FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(legacy_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts mixed-case domains when the signature omits contactLink but the payload includes it" do
|
||||
raw_domain = "Mesh.Example"
|
||||
normalized_domain = raw_domain.downcase
|
||||
contact_link = "https://mixed.example/contact"
|
||||
mixed_attributes = instance_attributes.merge(domain: raw_domain)
|
||||
mixed_signature_payload = canonical_instance_payload(mixed_attributes)
|
||||
mixed_signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_signature_payload),
|
||||
)
|
||||
mixed_payload = instance_payload.merge(
|
||||
"domain" => raw_domain,
|
||||
"contactLink" => contact_link,
|
||||
"signature" => mixed_signature,
|
||||
)
|
||||
|
||||
mixed_remote_payload = JSON.generate(
|
||||
{
|
||||
"publicKey" => pubkey,
|
||||
"name" => instance_attributes[:name],
|
||||
"version" => instance_attributes[:version],
|
||||
"domain" => normalized_domain,
|
||||
"lastUpdate" => last_update_time,
|
||||
},
|
||||
sort_keys: true,
|
||||
)
|
||||
|
||||
mixed_document = well_known_document.merge(
|
||||
"domain" => normalized_domain,
|
||||
"signedPayload" => Base64.strict_encode64(mixed_remote_payload),
|
||||
"signature" => Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_remote_payload),
|
||||
),
|
||||
)
|
||||
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
|
||||
case path
|
||||
when "/.well-known/potato-mesh"
|
||||
[mixed_document, URI("https://#{host}#{path}")]
|
||||
when "/api/nodes"
|
||||
[remote_nodes, URI("https://#{host}#{path}")]
|
||||
else
|
||||
[nil, []]
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/instances", mixed_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT domain, contact_link, signature FROM instances WHERE id = ?",
|
||||
[mixed_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["domain"]).to eq(normalized_domain)
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(mixed_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects registrations with invalid domains" do
|
||||
invalid_payload = instance_payload.merge("domain" => "mesh-instance")
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
node_id: "!abc12345",
|
||||
start_time: now - 120,
|
||||
last_seen_time: now - 60,
|
||||
version: "0.5.7",
|
||||
version: "0.5.8",
|
||||
lora_freq: 915,
|
||||
modem_preset: "LongFast",
|
||||
}.merge(overrides)
|
||||
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!fresh000", now - 100, now - 10, "0.5.7"],
|
||||
["!fresh000", now - 100, now - 10, "0.5.8"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
|
||||
Reference in New Issue
Block a user