18 Commits
0.1 ... 0.5

Author SHA1 Message Date
Geoff Whittington
159b3b097d Added use case config, fixed bugs 2022-12-04 14:25:38 -05:00
Geoff Whittington
dd83f29806 Merge branch 'main' of github.com:geoffwhittington/meshtastic-bridge into main 2022-11-21 14:17:50 -05:00
Geoff Whittington
26d540fead fix type bug 2022-11-21 14:17:36 -05:00
geoffwhittington
b72102c402 Update Dockerfile 2022-11-20 18:53:35 -05:00
geoffwhittington
0930aa917e Update Dockerfile 2022-11-20 18:51:16 -05:00
geoffwhittington
7ad584830a Update Dockerfile 2022-11-20 18:47:10 -05:00
geoffwhittington
fe5e119059 Update Dockerfile 2022-11-20 18:41:47 -05:00
geoffwhittington
23c1250ccb Update Dockerfile 2022-11-20 18:26:30 -05:00
geoffwhittington
c5134fd5b4 Update README.md 2022-11-20 18:17:04 -05:00
geoffwhittington
c3ff3b3a4f Update main.yaml 2022-11-20 18:10:11 -05:00
geoffwhittington
0f1e5a908d Add multiple platform support 2022-11-20 18:08:14 -05:00
Geoff Whittington
74114f1acc Support for 1.3 (2.0) 2022-10-31 17:07:30 -04:00
Geoff Whittington
8e3cd56228 docs update 2022-08-22 17:01:54 -04:00
Geoff Whittington
343d7a377a simplify 2022-08-22 00:36:53 -04:00
Geoff Whittington
86c8491a34 update logging 2022-08-22 00:30:15 -04:00
Geoff Whittington
940542f856 Try to make a dict 2022-08-22 00:19:30 -04:00
Geoff Whittington
30320875a2 Add checks 2022-08-22 00:14:48 -04:00
Geoff Whittington
eac2ab515e Add support for encryption/decryption 2022-08-22 00:04:35 -04:00
8 changed files with 743 additions and 116 deletions

View File

@@ -4,21 +4,16 @@ on:
- "*"
jobs:
build:
name: Build, push
docker-buildx:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@main
- name: Build container
run: docker build --tag gwhittington/meshtastic-bridge:latest .
- name: Log in to Container Registry with short-lived credentials
run: docker login --username=gwhittington --password "${{secrets.DOCKER_HUB}}"
- name: Push image to Container Registry
run: docker push gwhittington/meshtastic-bridge:latest
- name: Logout from Container Registry
run: docker logout
- name: Build and publish image
uses: zmingxie/docker_buildx@master
with:
publish: true
imageName: gwhittington/meshtastic-bridge
dockerHubUser: gwhittington
dockerHubPassword: ${{secrets.DOCKER_HUB}}

View File

@@ -7,7 +7,11 @@ WORKDIR /code
# copy the dependencies file to the working directory
COPY requirements.txt .
RUN apt-get update && apt-get install -y cargo
# install dependencies
RUN pip install -U pip
RUN pip install setuptools_rust wheel
RUN pip install -r requirements.txt
# copy the content of the local src directory to the working directory

276
README.md
View File

@@ -1,38 +1,286 @@
# Meshtastic Bridge
Connect two distinct radio [Meshtastic](https://meshtastic.org) networks using TCP.
Connect [Meshtastic](https://meshtastic.org) radio networks using MQTT and HTTP.
WARNING: Traffic is sent insecure using TCP. Use a VPN to secure traffic between the nodes
WARNING: Work in progress
## Requirements
- Python3
- Two Meshtastic devices:
- Local `LOCAL_NODE_ADDR` The IP address or Serial devPath (micro USB cable needed for serial access) of a local Meshtastic device
- Remote `REMOTE_NODE_ADDR` The IP address of a remote Meshtastic device
- Command-line install
- Python 3.8
- git
- Outbound HTTPS (TCP/443) or SSH (TCP/22) access to github.com
- Docker-based install
- Docker
- Meshtastic radio device:
- The _IP address_ or Serial _devPath_ (micro USB cable needed for serial access) of a Meshtastic device
- MQTT server:
- The _domain name_ of the server
- The _port_ (e.g. 1883)
Refer to <https://meshtastic.org/docs/settings/config/wifi#wifi-client> to configure a Meshtastic device to use wifi and expose a TCP address
Refer to <https://meshtastic.org/docs/settings/config/wifi#wifi-client> for details on how to configure a Meshtastic device to use wifi and expose a TCP address.
## Setup
## Command-line installation
Run the following steps to download and install the software locally
Download the code and install it onto a system:
```
$ git clone https://github.com/geoffwhittington/meshtastic-bridge.git
```
Create a Python virtual environment
```
$ python3 -m venv meshtastic-bridge
```
Install the bridge dependencies
```
$ cd meshtastic-bridge
$ source bin/activate
$ pip install -r requirements.txt
```
## Turn on the Bridge
## Docker installation
In the `meshtastic-bridge` directory run the following - replacing `BRIDGE_DISTANCE_KM`, `LOCAL_NODE_ADDR` and `REMOTE_NODE_ADDR` with the proper values:
There is nothing to install with Docker, the bridge is downloaded at the time it is run
## Configuration
The bridge is configured using a YAML file `config.yaml`. It is composed of three sections, `devices`, `mqtt_servers` and `pipelines`.
An example `config.yaml` is provided below:
```
BRIDGE_DISTANCE_KM=0 LOCAL_NODE_ADDR=/dev/ttyUSB0 REMOTE_NODE_ADDR=182.168.86.123 python main.py
devices:
- name: remote
tcp: 192.168.86.39
active: true
mqtt_servers:
- name: external
server: broker.hivemq.com
port: 1883
topic: meshtastic/radio-network1
pipelines:
mqtt-to-radio:
- decrypt_filter:
key: '/home/user/keys/key.pem'
- radio_message_plugin:
device: remote
pipelines:
radio-to-mqtt:
- encrypt_filter:
key: '/home/user/keys/cert.pem'
- mqtt_plugin:
name: external
topic: mesh/tastic
```
## Bridge Options
`devices` is a list of radios the bridge listens for packets or to where it can send packets.
* `BRIDGE_DISTANCE_KM` Do not bridge messages from nodes that more than BRIDGE_DISTANCE_KM kilometers from the local Meshtastic device. Default `0` (no limit)
- **name** Reference given to a radio that is used elsewhere in the `pipelines` configuration. For example, `my_radio`
- **tcp** The IP address of the radio. For example, `192.168.0.1` (Optional)
- **serial** The name of the serial device attached to the radio. For example, `/dev/ttyUSB0` (Optional)
- **active** Indicator whether this configuration is active. Values: `true` or `false`. Default = `true`.
NOTE: If `tcp` or `serial` are not given the bridge will attempt to detect a radio attached to the serial port. Additional configuration may be needed to use the serial port with the Docker option.
`mqtt_servers` is a list of MQTT servers the bridge listens for shared network traffic.
- **name** Reference given to the MQTT server. For example, `my_mqtt_server`
- **server** The IP address or hostname of a MQTT server. For example, `server.mqttserver.com`
- **port** The port the MQTT server listens on
- **topic** The topic name associated with the network traffic. For example, `mesh/network`
- **insecure** Use a secure connection but do not validate the server certificate
- **pipelines** A set of plugins (filters/actions) that run when a new message emerges for _topic_. Each pipeline is given a name; such as `mqtt-to-radio` (as in the example above)
`pipelines` is a list of ordered plugins (filters/actions) that run when a packet is detected by any connected radio. Each set is given a name; such as `radio-to-mqtt` (as in the example above). Pipelines can run in any order, however plugins run in the order they are defined.
## Plugins
The following plugins can be used in the `pipelines` section of `config.yaml`:
| Plugin | Description |
| ----------------- | -------------------------------------------------------------------- |
| `debugger` | Log the packet to the system console |
| `message_filter` | Filters out packets from the bridge that match a specific criteria |
| `location_filter` | Filters out packets that originate too far from a specified `device` |
| `webhook` | Send HTTP requests with custom payloads using packet information |
| `mqtt_plugin` | Send packets to a MQTT server |
| `encrypt_filter` | Encrypt a packet for a desired MQTT recipient |
| `decrypt_filter` | Decrypt a packet originating from MQTT |
| `radio_message_plugin` | Send a packet to a specified `device` |
### debugger - Output the contents of a packet
- **log_level** `debug` or `info`. Default `info`
For example:
```
debugger:
log_level: debug
```
Useful for troubleshooting.
### message_filter - Allow or block packets based on criteria
- **log_level** `debug` or `info`. Default `info`
- **app** Name of meshtastic application to allow or disallow
- **from** The packet `fromId` values to allow or disallow
- **to** The packet `toId` values to allow or disallow
- **message** The packet `message` values to allow or disallow. Supports Regex.
For example:
```
message_filter:
from:
allow:
- !bd5ba0ec
- !f85bc0bc
disallow:
- !c15ba2ec
message:
disallow:
- Good night
```
### location_filter - Filter packets by location from current node (default) or specific location
- **log_level** `debug` or `info`. Default `info`
- **max_distance_km** Filter packets more than a certain distance
- **comparison** `within` or `outside`. Default `within`
- **compare_latitude** latitude to compare against
- **compare_longitude** longitude to compare against
- **latitude** Set the latitude
- **longitude** Set the longitude
For example
```
location_filter:
max_distance_km: 1000
```
### webhook - Send a HTTP request
- **log_level** `debug` or `info`. Default `info`
- **active** Plugin is active. Values: `true` or `false`. Default = `true`.
- **body** The JSON payload to send
- **url** The target URL
- **headers** HTTP headers to include in the request. Secrets can be passed using ENV variables
- **message** Override the packet message
Placeholders can be used with the **body** value:
- `{LAT}` - Latitude associated with the POSITION packet. Empty if no value available.
- `{LNG}` - Latitude associated with the POSITION packet. Empty if no value available.
- `{MSG}` - Packet text or `message` from the configuration (above)
- `{FID}` - The `fromId` associated with the packet.
- `{TID}` - The `toId` associated with the packet.
For example:
```
webhook:
active: true
body: '{"lat": "{LAT}", "lng": "{LNG}", "text_message": "{MSG}"}'
url: 'https://localhost:8000/message'
headers:
Authorization: Token {AUTH_TOKEN}
Content-type: application/json
```
### mqtt_plugin - Send a packet to a MQTT server
- **log_level** `debug` or `info`. Default `info`
- **active** Plugin is active. Values: `true` or `false`. Default = `true`.
- **name** Reference of an existing MQTT server configured in the top-level `mqtt_servers` configuration
- **message** Override the packet message with a custom value
- **topic** The message topic
For example:
```
mqtt_plugin:
name: external
topic: meshtastic/topic
```
### encrypt_filter - Encrypt a packet before sending it to a MQTT server
- **log_level** `debug` or `info`. Default `info`
- **key** The PEM filename of the public key used to encrypt the message.
For example:
```
encrypt_filter:
key: '/home/user/keys/cert.pem'
```
### decrypt_filter - Decrypt message from a MQTT server
- **log_level** `debug` or `info`. Default `info`
- **key** The PEM filename of the key used to decrypt the message.
For example:
```
decrypt_filter:
key: '/home/user/keys/key.pem'
```
### radio_message_plugin - Send a packet to a radio
- **log_level** `debug` or `info`. Default `info`
- **active** Plugin is active. Values: `true` or `false`. Default `true`.
- **device** Required. Send the packet to a Radio with this name. It should be configured in the top-level `devices` configuration
- **message** Send a text message
- **lat** Send a position message having this latitude
- **lng** Send a position message having this longitude
- **node_mapping** Map the packet `to` value to another value
- **to** Target node reference
- **toId** Target node reference. Ignored if `to` is used.
For example:
Broadcasts all packets to the "remote" radio network that are destined to the node `12354345`.
```
radio_message_plugin:
device: remote
node_mapping:
12354345: ^all
```
## Run the bridge
### Command-line
Create a `config.yaml` in the `meshtastic-bridge` directory. Run:
```
$ source bin/activate
```
And:
```
python main.py
```
### Docker
Create a `config.yaml` with the desired settings and run the following Docker command:
```
docker run -v $(pwd)/config.yaml:/code/config.yaml gwhittington/meshtastic-bridge:latest
```
## Resources
- Example guidance for creating [PEM](https://www.suse.com/support/kb/doc/?id=000018152) key files.

197
main.py
View File

@@ -1,4 +1,6 @@
import json
import logging
from re import I
import meshtastic
import meshtastic.serial_interface
import meshtastic.tcp_interface
@@ -11,63 +13,204 @@ from plugins import plugins
from pubsub import pub
import yaml
from yaml.loader import SafeLoader
import paho.mqtt.client as mqtt
logging.basicConfig()
logger = logging.getLogger(name="meshtastic.bridge")
logger.setLevel(logging.DEBUG)
with open("config.yaml") as f:
bridge_config = yaml.load(f, Loader=SafeLoader)
devices = {}
for device in bridge_config["devices"]:
if "serial" in device:
devices[device["name"]] = meshtastic.serial_interface.SerialInterface(
devPath=device["serial"]
)
elif "tcp" in device:
devices[device["name"]] = meshtastic.tcp_interface.TCPInterface(
hostname=device["tcp"]
)
else:
devices[device["name"]] = meshtastic.serial_interface.SerialInterface()
class CustomTCPInterface(meshtastic.tcp_interface.TCPInterface):
def __init__(self, hostname, device_name):
self.device_name = device_name
self.hostname = hostname
super(CustomTCPInterface, self).__init__(hostname)
def onReceive(packet, interface): # called when a packet arrives
for pipeline in bridge_config["pipelines"]:
nodeInfo = interface.getMyNodeInfo()
pipeline_packet = packet
for pipeline, pipeline_plugins in bridge_config["pipelines"].items():
logger.debug(f"Pipeline {pipeline} initiated")
for key, config in pipeline.items():
p = plugins["packet_filter"]
pipeline_packet = p.do_action(packet)
for plugin in pipeline_plugins:
if not pipeline_packet:
continue
if key not in plugins:
logger.error(f"No such plugin: {key}. Skipping")
continue
for plugin_key, plugin_config in plugin.items():
p = plugins[key]
p.configure(devices, config)
logger.debug(f"Processing plugin: {pipeline}/{plugin_key}")
if not pipeline_packet:
logger.debug("Skipping since the packet is null")
continue
pipeline_packet = p.do_action(pipeline_packet)
if plugin_key not in plugins:
logger.error(f"No such plugin: {plugin_key}. Skipping")
continue
p = plugins[plugin_key]
p.configure(devices, mqtt_servers, plugin_config)
pipeline_packet = p.do_action(pipeline_packet)
logger.debug(f"Pipeline {pipeline} completed")
def onConnection(
interface, topic=pub.AUTO_TOPIC
): # called when we (re)connect to the radio
nodeInfo = interface.getMyNodeInfo()
logger.info(
f"Connected to node: userId={nodeInfo['user']['id']} hwModel={nodeInfo['user']['hwModel']}"
)
def onLost(interface):
logger.debug(f"Connecting to {interface.hostname} ...")
devices[interface.device_name] = CustomTCPInterface(
hostname=interface.hostname, device_name=interface.device_name
)
logger.debug(f"Connected to {interface.hostname}")
pub.subscribe(onReceive, "meshtastic.receive")
pub.subscribe(onConnection, "meshtastic.connection.established")
pub.subscribe(onLost, "meshtastic.connection.lost")
with open("config.yaml") as f:
bridge_config = yaml.load(f, Loader=SafeLoader)
devices = {}
mqtt_servers = {}
for device in bridge_config["devices"]:
if "active" in device and not device["active"]:
continue
if "serial" in device:
devices[device["name"]] = meshtastic.serial_interface.SerialInterface(
devPath=device["serial"]
)
elif "tcp" in device:
logger.debug(f"Connecting to {device['tcp']} ...")
devices[device["name"]] = CustomTCPInterface(
hostname=device["tcp"], device_name=device["name"]
)
logger.debug(f"Connected to {device['tcp']}")
else:
devices[device["name"]] = meshtastic.serial_interface.SerialInterface()
if "mqtt_servers" in bridge_config:
for config in bridge_config["mqtt_servers"]:
required_options = [
"name",
"server",
"port",
]
for option in required_options:
if option not in config:
logger.warning("Missing config: {option}")
client_id = config["client_id"] if "client_id" in config else None
username = config["username"] if "username" in config else None
password = config["password"] if "password" in config else None
logger.info(f"Connected to MQTT {config['name']}")
if client_id:
mqttc = mqtt.Client(client_id)
else:
mqttc = mqtt.Client()
if username and password:
mqttc.username_pw_set(username, password)
def on_connect(mqttc, obj, flags, rc):
logger.debug(f"Connected to MQTT {config['name']}")
def on_message(mqttc, obj, msg):
orig_packet = msg.payload.decode()
logger.debug(f"MQTT {config['name']}: on_message")
logger.debug(f"MQTT {config['name']}: {orig_packet}")
if "pipelines" not in config:
logger.warning(f"MQTT {config['name']}: no pipeline")
return
p = plugins["packet_filter"]
pipeline_packet = p.do_action(orig_packet)
for pipeline, pipeline_plugins in config["pipelines"].items():
packet = pipeline_packet
logger.debug(f"MQTT {config['name']} pipeline {pipeline} initiated")
if not packet:
continue
for plugin in pipeline_plugins:
if not packet:
continue
for plugin_key, plugin_config in plugin.items():
if plugin_key not in plugins:
logger.error(f"No such plugin: {plugin_key}. Skipping")
continue
p = plugins[plugin_key]
p.configure(devices, mqtt_servers, plugin_config)
try:
packet = p.do_action(packet)
except Exception as e:
logger.error(f"Hit an error: {e}", exc_info=True)
logger.debug(f"MQTT {config['name']} pipeline {pipeline} finished")
def on_publish(mqttc, obj, mid):
logger.debug(f"MQTT {config['name']}: on_publish: {mid}")
def on_subscribe(mqttc, obj, mid, granted_qos):
logger.debug(f"MQTT {config['name']}: on_subscribe: {mid}")
mqttc.on_message = on_message
mqttc.on_connect = on_connect
mqttc.on_publish = on_publish
mqttc.on_subscribe = on_subscribe
mqtt_servers[config["name"]] = mqttc
import ssl
if "insecure" in config and config["insecure"]:
mqttc.tls_set(cert_reqs=ssl.CERT_NONE)
mqttc.tls_insecure_set(True)
try:
logger.debug(f"Connecting to MQTT {config['server']}")
mqttc.connect(config["server"], config["port"], 60)
if "topic" in config:
mqttc.subscribe(config["topic"], 0)
mqttc.loop_start()
except Exception as e:
logger.error(f"MQTT {config['name']} could not start: {e}")
pass
while True:
time.sleep(1000)
for device, instance in devices.items():
instance.close()
if devices:
for device, instance in devices.items():
instance.close()
if mqtt_servers:
for server, instance in mqtt_servers.items():
instance.disconnect()

View File

@@ -1,18 +1,26 @@
from haversine import haversine
from meshtastic import mesh_pb2
from meshtastic.__init__ import BROADCAST_ADDR
from random import randrange
import base64
import json
import logging
import os
import re
plugins = {}
class Plugin:
def configure(self, devices, config):
class Plugin(object):
def __init__(self) -> None:
self.logger.setLevel(logging.INFO)
def configure(self, devices, mqtt_servers, config):
self.config = config
self.devices = devices
self.mqtt_servers = mqtt_servers
if "log_level" in config:
if config and "log_level" in config:
if config["log_level"] == "debug":
self.logger.setLevel(logging.DEBUG)
elif config["log_level"] == "info":
@@ -22,13 +30,56 @@ class Plugin:
pass
class PacketFilter(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.filter.packet")
def strip_raw(self, data):
if type(data) is not dict:
return data
if "raw" in data:
del data["raw"]
for k, v in data.items():
data[k] = self.strip_raw(v)
return data
def normalize(self, dict_obj):
"""
Packets are either a dict, string dict or string
"""
if type(dict_obj) is not dict:
try:
dict_obj = json.loads(dict_obj)
except:
dict_obj = {"decoded": {"text": dict_obj}}
return self.strip_raw(dict_obj)
def do_action(self, packet):
self.logger.debug(f"Before normalization: {packet}")
packet = self.normalize(packet)
if "decoded" in packet and "payload" in packet["decoded"]:
if type(packet["decoded"]["payload"]) is bytes:
text = packet["decoded"]["payload"]
packet["decoded"]["payload"] = base64.b64encode(
packet["decoded"]["payload"]
).decode("utf-8")
self.logger.debug(f"After normalization: {packet}")
return packet
plugins["packet_filter"] = PacketFilter()
class DebugFilter(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.plugin.logging")
def do_action(self, packet):
self.logger.info(
f"{packet['id']} | {packet['fromId']}=>{packet['toId']} | {packet['decoded']['portnum']}"
)
self.logger.debug(packet)
return packet
@@ -44,6 +95,33 @@ class MessageFilter(Plugin):
self.logger.error("Missing packet")
return packet
text = packet["decoded"]["text"] if "text" in packet["decoded"] else None
if text and "message" in self.config:
if "allow" in self.config["message"]:
matches = False
for allow_regex in self.config["message"]["allow"]:
if not matches and re.search(allow_regex, text):
matches = True
if not matches:
self.logger.debug(
f"Dropped because it doesn't match message allow filter"
)
return None
if "disallow" in self.config["message"]:
matches = False
for disallow_regex in self.config["message"]["disallow"]:
if not matches and re.search(disallow_regex, text):
matches = True
if matches:
self.logger.debug(
f"Dropped because it matches message disallow filter"
)
return None
filters = {
"app": packet["decoded"]["portnum"],
"from": packet["fromId"],
@@ -53,12 +131,15 @@ class MessageFilter(Plugin):
for filter_key, value in filters.items():
if filter_key in self.config:
filter_val = self.config[filter_key]
if (
"allow" in filter_val
and filter_val["allow"]
and value not in filter_val["allow"]
):
self.logger.debug(f"Dropped because it doesn't match allow filter")
self.logger.debug(
f"Dropped because {value} doesn't match {filter_key} allow filter"
)
return None
if (
@@ -66,7 +147,9 @@ class MessageFilter(Plugin):
and filter_val["disallow"]
and value in filter_val["disallow"]
):
self.logger.debug(f"Dropped because it matches disallow filter")
self.logger.debug(
f"Dropped because {value} matches {filter_key} disallow filter"
)
return None
self.logger.debug(f"Accepted")
@@ -76,21 +159,24 @@ class MessageFilter(Plugin):
plugins["message_filter"] = MessageFilter()
class DistanceFilter(Plugin):
class LocationFilter(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.filter.distance")
def do_action(self, packet):
if 'device' not in self.config:
return packet
if "position" not in packet["decoded"]:
return packet
message_source_position = None
current_local_position = None
if "device" in self.config and self.config["device"] in self.devices:
nodeInfo = self.devices[self.config["device"]].getMyNodeInfo()
current_local_position = (
nodeInfo["position"]["latitude"],
nodeInfo["position"]["longitude"],
)
if (
"latitude" in packet["decoded"]["position"]
"decoded" in packet
and "position" in packet["decoded"]
and "latitude" in packet["decoded"]["position"]
and "longitude" in packet["decoded"]["position"]
):
message_source_position = (
@@ -98,31 +184,43 @@ class DistanceFilter(Plugin):
packet["decoded"]["position"]["longitude"],
)
nodeInfo = self.devices[self.config["device"]].getMyNodeInfo()
if "compare_latitude" in self.config and "compare_longitude" in self.config:
current_local_position = (
nodeInfo["position"]["latitude"],
nodeInfo["position"]["longitude"],
self.config["compare_latitude"],
self.config["compare_longitude"],
)
if message_source_position and current_local_position:
distance_km = haversine(message_source_position, current_local_position)
comparison = (
self.config["comparison"] if "comparison" in self.config else "within"
)
# message originates from too far a distance
if (
"max_distance_km" in self.config
and self.config["max_distance_km"] > 0
and distance_km > self.config["max_distance_km"]
):
logger.debug(
f"Packet from too far: {distance_km} > {SUPPORTED_BRIDGE_DISTANCE_KM}"
)
return None
if "max_distance_km" in self.config and self.config["max_distance_km"] > 0:
acceptable_distance = self.config["max_distance_km"]
if comparison == "within" and distance_km > acceptable_distance:
self.logger.debug(
f"Packet from too far: {distance_km} > {acceptable_distance}"
)
return None
elif comparison == "outside" and distance_km < acceptable_distance:
self.logger.debug(
f"Packet too close: {distance_km} < {acceptable_distance}"
)
return None
if "latitude" in self.config:
packet["decoded"]["position"]["latitude"] = self.config["latitude"]
if "longitude" in self.config:
packet["decoded"]["position"]["longitude"] = self.config["longitude"]
return packet
plugins["distance_filter"] = DistanceFilter()
plugins["location_filter"] = LocationFilter()
class WebhookPlugin(Plugin):
@@ -132,16 +230,21 @@ class WebhookPlugin(Plugin):
if "active" in self.config and not self.config["active"]:
return packet
if "position" not in packet["decoded"]:
if "body" not in self.config:
self.logger.warning("Missing config: body")
return packet
import json
import requests
position = (
packet["decoded"]["position"] if "position" in packet["decoded"] else None
)
text = packet["decoded"]["text"] if "text" in packet["decoded"] else None
macros = {
"{LAT}": packet["decoded"]["position"]["latitude"],
"{LNG}": packet["decoded"]["position"]["longitude"],
"{MSG}": self.config["message"] if "message" in self.config else "",
"{LAT}": position["latitude"] if position else None,
"{LNG}": position["longitude"] if position else None,
"{MSG}": self.config["message"] if "message" in self.config else text,
"{FID}": packet["fromId"],
"{TID}": packet["toId"],
}
@@ -176,38 +279,140 @@ class WebhookPlugin(Plugin):
plugins["webhook"] = WebhookPlugin()
class SendPlugin(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.plugin.send")
class MQTTPlugin(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.plugin.mqtt")
def do_action(self, packet):
required_options = ["name", "topic"]
for option in required_options:
if option not in self.config:
self.logger.warning(f"Missing config: {option}")
return packet
if self.config["name"] not in self.mqtt_servers:
self.logger.warning(f"No server established: {self.config['name']}")
return packet
mqtt_server = self.mqtt_servers[self.config["name"]]
if not mqtt_server.is_connected():
self.logger.error("Not sent, not connected")
return
packet_message = json.dumps(packet)
if "message" in self.config:
message = self.config["message"].replace("{MSG}", packet["decoded"]["text"])
else:
message = packet_message
info = mqtt_server.publish(self.config["topic"], message)
info.wait_for_publish()
self.logger.debug("Message sent")
plugins["mqtt_plugin"] = MQTTPlugin()
class EncryptFilter(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.filter.encrypt")
def do_action(self, packet):
if "key" not in self.config:
return None
from jwcrypto import jwk, jwe
from jwcrypto.common import json_encode, json_decode
with open(self.config["key"], "rb") as pemfile:
encrypt_key = jwk.JWK.from_pem(pemfile.read())
public_key = jwk.JWK()
public_key.import_key(**json_decode(encrypt_key.export_public()))
protected_header = {
"alg": "RSA-OAEP-256",
"enc": "A256CBC-HS512",
"typ": "JWE",
"kid": public_key.thumbprint(),
}
message = json.dumps(packet)
jwetoken = jwe.JWE(
message.encode("utf-8"), recipient=public_key, protected=protected_header
)
self.logger.debug(f"Encrypted message: {packet['id']}")
return jwetoken.serialize()
plugins["encrypt_filter"] = EncryptFilter()
class DecryptFilter(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.filter.decrypt")
def do_action(self, packet):
if "key" not in self.config:
return packet
if type(packet) is not str:
self.logger.warning(f"Packet is not string")
return packet
from jwcrypto import jwk, jwe
with open(self.config["key"], "rb") as pemfile:
private_key = jwk.JWK.from_pem(pemfile.read())
jwetoken = jwe.JWE()
jwetoken.deserialize(packet, key=private_key)
payload = jwetoken.payload
packet = json.loads(payload)
self.logger.debug(f"Decrypted message: {packet['id']}")
return packet
plugins["decrypt_filter"] = DecryptFilter()
class RadioMessagePlugin(Plugin):
logger = logging.getLogger(name="meshtastic.bridge.plugin.send")
def do_action(self, packet):
if self.config["device"] not in self.devices:
self.logger.error(f"Missing interface for device {self.config['device']}")
return packet
if "to" not in packet:
self.logger.debug("Not a message")
return packet
# Broadcast messages or specific
if (
"node_mapping" in self.config
and packet["to"] in self.config["node_mapping"]
):
destinationId = self.config["node_mapping"][packet["to"]]
else:
destinationId = packet["to"]
destinationId = None
if "to" in self.config:
destinationId = self.config["to"]
elif "toId" in self.config:
destinationId = self.config["toId"]
elif "node_mapping" in self.config and "to" in packet:
destinationId = self.config["node_mapping"][packet["to"]]
elif "to" in packet:
destinationId = packet["to"]
elif "toId" in packet:
destinationId = packet["toId"]
if not destinationId:
self.logger.error("Missing 'to' property in config or packet")
return packet
device_name = self.config["device"]
device = self.devices[device_name]
self.logger.debug(f"Sending packet to {device_name}")
# Not a radio packet
if "decoded" in packet and "text" in packet["decoded"] and "from" not in packet:
self.logger.debug(f"Sending text to Radio {device_name}")
device.sendText(text=packet["decoded"]["text"], destinationId=destinationId)
if "message" in self.config and self.config["message"]:
device.sendText(text=self.config["message"], destinationId=destinationId)
elif (
"lat" in self.config
and self.config["lat"] > 0
@@ -218,26 +423,31 @@ class SendPlugin(Plugin):
lng = self.config["lng"]
altitude = self.config["alt"] if "alt" in self.config else 0
self.logger.debug(f"Sending position to Radio {device_name}")
device.sendPosition(
latitude=lat,
longitude=lng,
altitude=altitude,
destinationId=destinationId,
)
else:
elif (
"decoded" in packet
and "payload" in packet["decoded"]
and "portnum" in packet["decoded"]
):
meshPacket = mesh_pb2.MeshPacket()
meshPacket.channel = 0
meshPacket.decoded.payload = packet["decoded"]["payload"]
meshPacket.decoded.payload = base64.b64decode(packet["decoded"]["payload"])
meshPacket.decoded.portnum = packet["decoded"]["portnum"]
meshPacket.decoded.want_response = False
meshPacket.id = device._generatePacketId()
self.logger.debug(
f"Sending packet {meshPacket.id} to {self.config['device']}"
)
self.logger.debug(f"Sending packet to Radio {device_name}")
device._sendPacket(meshPacket=meshPacket, destinationId=destinationId)
return packet
plugins["send_plugin"] = SendPlugin()
plugins["radio_message_plugin"] = RadioMessagePlugin()

View File

@@ -2,3 +2,5 @@ haversine
meshtastic
requests
pyyaml
paho-mqtt
jwcrypto

View File

@@ -1,5 +1,5 @@
devices:
- name: local
- name: local
pipelines:
- debugger:
log_level: info
- debugger:
log_level: info

View File

@@ -0,0 +1,25 @@
devices:
- name: radio1
tcp: 192.168.86.27
mqtt_servers:
- name: external
server: broker.hivemq.com
port: 1883
topic: meshtastic/radio-network1
pipelines:
mqtt-to-radio:
- radio_message_plugin:
device: radio1
to: "^all"
pipelines:
pipeline1:
- debugger:
log_level: debug
radio-to-mqtt:
- message_filter:
app:
allow:
- "TEXT_MESSAGE_APP"
- mqtt_plugin:
name: external
topic: meshtastic/radio-network1