potatomesh-matrix-bridge
A small Rust daemon that bridges PotatoMesh LoRa messages into a Matrix room.
For each PotatoMesh node, the bridge creates (or uses) a Matrix puppet user:
- Matrix localpart:
potato_+ the hex node id (without!), e.g.!67fc83cb→@potato_67fc83cb:example.org - Matrix display name: the node’s
long_namefrom the PotatoMesh API
Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users.
Features
- Polls
https://potatomesh.net/api/messages(deriving/apifrom the configured base domain) - Looks up node metadata via
GET /api/nodes/{hex}and caches it - One Matrix user per node:
- username:
potato_{hex node id} - display name:
long_name
- username:
- Forwards
TEXT_MESSAGE_APPmessages into a single Matrix room - Persists last-seen message ID to avoid duplicates across restarts
Architecture Overview
-
PotatoMesh side
GET /api/messagesreturns an array of messagesGET /api/nodes/{hex}returns node metadata (includinglong_name)
-
Matrix side
- Uses the Matrix Client-Server API with an appservice access token
- Impersonates puppet users via
user_id=@potato_{hex}:{server_name}&access_token={as_token} - Sends
m.room.messageevents into a configured room
This is not a full appservice framework; it just speaks the minimal HTTP needed.
Requirements
-
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.
@potato_[0-9a-f]{8}:example.org) - Provides an
as_tokenthe bridge can use
- Whitelists the puppet user namespace (e.g.
-
Network access from the bridge host to:
https://potatomesh.net/(bridge appends/api)- Your Matrix homeserver (
https://matrix.example.org)
Configuration
Configuration can come from a TOML file, CLI flags, environment variables, or secret files. The bridge merges inputs in this order (highest to lowest):
- CLI flags
- Environment variables
- Secret files (
*_FILEpaths or container defaults) - TOML config file
- Container defaults (paths + poll interval)
If no TOML file is provided, required values must be supplied via CLI/env/secret inputs.
Example TOML:
[potatomesh]
# Base domain (bridge will call {base_url}/api)
base_url = "https://potatomesh.net/"
# Poll interval in seconds
poll_interval_secs = 10
[matrix]
# Homeserver base URL (client API) without trailing slash
homeserver = "https://matrix.example.org"
# Appservice access token (from your registration.yaml)
as_token = "YOUR_APPSERVICE_AS_TOKEN"
# Appservice homeserver token (must match registration hs_token)
hs_token = "SECRET_HS_TOKEN"
# Server name (domain) part of Matrix user IDs
server_name = "example.org"
# Room ID to send into (must be joined by the appservice / puppets)
room_id = "!yourroomid:example.org"
[state]
# Where to persist last seen message id
state_file = "bridge_state.json"
The hs_token is used to validate inbound appservice transactions. Keep it identical in Config.toml and your Matrix appservice registration file.
CLI Flags
Run potatomesh-matrix-bridge --help for the full list. Common flags:
--config PATH--state-file PATH--potatomesh-base-url URL--potatomesh-poll-interval-secs SECS--matrix-homeserver URL--matrix-as-token TOKEN--matrix-as-token-file PATH--matrix-hs-token TOKEN--matrix-hs-token-file PATH--matrix-server-name NAME--matrix-room-id ROOM--container/--no-container--secrets-dir PATH
Environment Variables
POTATOMESH_CONFIGPOTATOMESH_BASE_URLPOTATOMESH_POLL_INTERVAL_SECSMATRIX_HOMESERVERMATRIX_AS_TOKENMATRIX_AS_TOKEN_FILEMATRIX_HS_TOKENMATRIX_HS_TOKEN_FILEMATRIX_SERVER_NAMEMATRIX_ROOM_IDSTATE_FILEPOTATOMESH_CONTAINERPOTATOMESH_SECRETS_DIR
Secret Files
If you supply *_FILE values, the bridge reads the secret contents and trims whitespace. When running inside a container, the bridge also checks the default secrets directory (default: /run/secrets) for:
matrix_as_tokenmatrix_hs_token
Container Defaults
Container detection checks POTATOMESH_CONTAINER, CONTAINER, and /proc/1/cgroup. When detected (or forced with --container), defaults shift to:
- Config path:
/app/Config.toml - State file:
/app/bridge_state.json - Secrets dir:
/run/secrets - Poll interval: 15 seconds (if not otherwise configured)
Set POTATOMESH_CONTAINER=0 or --no-container to opt out of container defaults.
Docker Compose First Run
Before starting Compose, complete this preflight checklist:
- Ensure
matrix/Config.tomlexists as a regular file on the host (not a directory). - Fill required Matrix values in
matrix/Config.toml:matrix.as_tokenmatrix.hs_tokenmatrix.server_namematrix.room_idmatrix.homeserver
This is required because the shared Compose anchor x-matrix-bridge-base mounts ./matrix/Config.toml to /app/Config.toml.
Then follow the token and namespace requirements in Matrix Appservice Setup (Synapse example).
Troubleshooting
| Symptom | Likely cause | What to check |
|---|---|---|
Is a directory (os error 21) |
Host mount source became a directory | matrix/Config.toml was missing at mount time and got created as a directory on host. |
M_UNKNOWN_TOKEN / 401 Unauthorized |
Matrix appservice token mismatch | Verify matrix.as_token matches your appservice registration and setup in Matrix Appservice Setup (Synapse example). |
Recovery from accidental Config.toml directory creation
# from repo root
rm -rf matrix/Config.toml
touch matrix/Config.toml
# then edit matrix/Config.toml and set valid matrix.as_token, matrix.hs_token,
# matrix.server_name, matrix.room_id, and matrix.homeserver before starting compose
PotatoMesh API
The bridge assumes:
-
Messages:
GET {base_url}/api/messages→ JSON array, for example:[ { "id": 2947676906, "rx_time": 1764241436, "rx_iso": "2025-11-27T11:03:56Z", "from_id": "!da6556d4", "to_id": "^all", "channel": 1, "portnum": "TEXT_MESSAGE_APP", "text": "Ping", "rssi": -111, "hop_limit": 1, "lora_freq": 868, "modem_preset": "MediumFast", "channel_name": "TEST", "snr": -9.0, "node_id": "!06871773" } ] -
Nodes:
GET {base_url}/api/nodes/{hex}→ JSON, for example:{ "node_id": "!67fc83cb", "short_name": "83CB", "long_name": "Meshtastic 83CB", "role": "CLIENT_HIDDEN", "last_heard": 1764250515, "first_heard": 1758993817, "last_seen_iso": "2025-11-27T13:35:15Z" }
Node hex ID is derived from node_id by stripping the leading ! and using the remainder inside the puppet localpart prefix (potato_{hex}).
Matrix Appservice Setup (Synapse example)
You need an appservice registration file (e.g. potatomesh-bridge.yaml) configured in Synapse.
A minimal example sketch (you must adjust URLs, secrets, namespaces):
id: potatomesh-bridge
url: "http://your-bridge-host:41448"
as_token: "YOUR_APPSERVICE_AS_TOKEN"
hs_token: "SECRET_HS_TOKEN"
sender_localpart: "potatomesh-bridge"
rate_limited: false
namespaces:
users:
- exclusive: true
regex: "@potato_[0-9a-f]{8}:example.org"
This bridge listens for Synapse appservice callbacks on port 41448 so it can log inbound transaction payloads. It still only forwards messages one way (PotatoMesh → Matrix), so inbound Matrix events are acknowledged but not bridged. The as_token and namespaces.users entries remain required for outbound calls, and the url should point at the listener.
In Synapse’s homeserver.yaml, add the registration file under app_service_config_files, restart, and invite a puppet user to your target room (or use room ID directly).
The bridge validates inbound appservice callbacks by comparing the access_token query param to hs_token in Config.toml, so keep those values in sync.
Build
# clone
git clone https://github.com/YOUR_USER/potatomesh-matrix-bridge.git
cd potatomesh-matrix-bridge
# build
cargo build --release
The resulting binary will be at:
target/release/potatomesh-matrix-bridge
Docker
Build the container from the repo root with the included matrix/Dockerfile:
docker build -f matrix/Dockerfile -t potatomesh-matrix-bridge .
Provide your config at /app/Config.toml (or use CLI/env/secret overrides) and persist the bridge state file by mounting volumes. Minimal example:
docker run --rm \
-p 41448:41448 \
-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:
docker run --rm \
-p 41448:41448 \
-v bridge_state:/app \
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
potatomesh-matrix-bridge
The image ships Config.example.toml for reference. If /app/Config.toml is absent, set the required values via environment variables, CLI flags, or secrets instead.
Run
Ensure Config.toml is present and valid, then:
./target/release/potatomesh-matrix-bridge
Environment variables you may care about:
-
RUST_LOG– for logging, e.g.:RUST_LOG=info,reqwest=warn ./target/release/potatomesh-matrix-bridge
The bridge will:
-
Load state from
bridge_state.json(if present). -
Poll PotatoMesh every
poll_interval_secs. -
For each new
TEXT_MESSAGE_APP:- Fetch node info.
- Ensure puppet is registered (
@potato_{hex}:{server_name}). - Set puppet display name to
long_name. - Send a formatted text message into
room_idas that puppet. - Update and persist
bridge_state.json.
Delete bridge_state.json if you want it to replay all currently available messages.
Development
Run tests:
cargo test
Format code:
cargo fmt
Lint (optional but recommended):
cargo clippy -- -D warnings
GitHub Actions CI
This repository includes a GitHub Actions workflow (.github/workflows/ci.yml) that:
-
runs on pushes and pull requests
-
caches Cargo dependencies
-
runs:
cargo fmt --checkcargo clippycargo test
See the workflow file for details.
Caveats & Future Work
- No E2EE: this bridge posts into unencrypted (or server-side managed) rooms. For encrypted rooms, you’d need real E2EE support and key management.
- No inbound Matrix → PotatoMesh direction yet. This is a one-way bridge (PotatoMesh → Matrix).
- No pagination or
sincesupport on the PotatoMesh API. The bridge simply deduplicates by messageidand stores the highest seen.
If you change the PotatoMesh API, adjust the types in src/potatomesh.rs accordingly.
