forked from iarv/meshtastic-bridge
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72102c402 | ||
|
|
0930aa917e | ||
|
|
7ad584830a | ||
|
|
fe5e119059 | ||
|
|
23c1250ccb | ||
|
|
c5134fd5b4 | ||
|
|
c3ff3b3a4f | ||
|
|
0f1e5a908d | ||
|
|
74114f1acc |
21
.github/workflows/main.yaml
vendored
21
.github/workflows/main.yaml
vendored
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
29
README.md
29
README.md
@@ -66,7 +66,7 @@ mqtt_servers:
|
||||
mqtt-to-radio:
|
||||
- decrypt_filter:
|
||||
key: '/home/user/keys/key.pem'
|
||||
- send_plugin:
|
||||
- radio_message_plugin:
|
||||
device: remote
|
||||
pipelines:
|
||||
radio-to-mqtt:
|
||||
@@ -105,12 +105,12 @@ The following plugins can be used in the `pipelines` section of `config.yaml`:
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `debugger` | Log the packet to the system console |
|
||||
| `message_filter` | Filters out packets from the bridge that match a specific criteria |
|
||||
| `distance_filter` | Filters out packets that originate too far from a specified `device` |
|
||||
| `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 |
|
||||
| `send_plugin` | Send a packet to a specified `device` |
|
||||
| `radio_message_plugin` | Send a packet to a specified `device` |
|
||||
|
||||
### debugger - Output the contents of a packet
|
||||
|
||||
@@ -131,28 +131,37 @@ Useful for troubleshooting.
|
||||
- **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:
|
||||
app:
|
||||
from:
|
||||
allow:
|
||||
- !bd5ba0ec
|
||||
- !f85bc0bc
|
||||
disallow:
|
||||
- !c15ba2ec
|
||||
message:
|
||||
disallow:
|
||||
- Good night
|
||||
```
|
||||
|
||||
### distance_filter - Allow or block packets based on distance from origin to radio
|
||||
### location_filter - Filter packets by location from current node (default) or specific location
|
||||
|
||||
- **log_level** `debug` or `info`. Default `info`
|
||||
- **max_distance_km** Number of kilometers
|
||||
- **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:
|
||||
For example
|
||||
|
||||
```
|
||||
distance_filter:
|
||||
location_filter:
|
||||
max_distance_km: 1000
|
||||
```
|
||||
|
||||
@@ -225,7 +234,7 @@ decrypt_filter:
|
||||
key: '/home/user/keys/key.pem'
|
||||
```
|
||||
|
||||
### send_plugin - Send a packet to a radio
|
||||
### 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`.
|
||||
@@ -242,7 +251,7 @@ For example:
|
||||
Broadcasts all packets to the "remote" radio network that are destined to the node `12354345`.
|
||||
|
||||
```
|
||||
send_plugin:
|
||||
radio_message_plugin:
|
||||
device: remote
|
||||
node_mapping:
|
||||
12354345: ^all
|
||||
|
||||
159
main.py
159
main.py
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
from re import I
|
||||
import meshtastic
|
||||
import meshtastic.serial_interface
|
||||
import meshtastic.tcp_interface
|
||||
@@ -20,7 +21,15 @@ logger = logging.getLogger(name="meshtastic.bridge")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
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
|
||||
nodeInfo = interface.getMyNodeInfo()
|
||||
|
||||
for pipeline, pipeline_plugins in bridge_config["pipelines"].items():
|
||||
logger.debug(f"Pipeline {pipeline} initiated")
|
||||
@@ -29,6 +38,9 @@ def onReceive(packet, interface): # called when a packet arrives
|
||||
pipeline_packet = p.do_action(packet)
|
||||
|
||||
for plugin in pipeline_plugins:
|
||||
if not pipeline_packet:
|
||||
continue
|
||||
|
||||
for plugin_key, plugin_config in plugin.items():
|
||||
|
||||
logger.debug(f"Processing plugin: {pipeline}/{plugin_key}")
|
||||
@@ -57,8 +69,17 @@ def onConnection(
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -75,99 +96,109 @@ for device in bridge_config["devices"]:
|
||||
devPath=device["serial"]
|
||||
)
|
||||
elif "tcp" in device:
|
||||
devices[device["name"]] = meshtastic.tcp_interface.TCPInterface(
|
||||
hostname=device["tcp"]
|
||||
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()
|
||||
|
||||
for config in bridge_config["mqtt_servers"]:
|
||||
required_options = [
|
||||
"name",
|
||||
"server",
|
||||
"port",
|
||||
]
|
||||
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}")
|
||||
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
|
||||
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
|
||||
|
||||
if client_id:
|
||||
mqttc = mqtt.Client(client_id)
|
||||
else:
|
||||
mqttc = mqtt.Client()
|
||||
if client_id:
|
||||
mqttc = mqtt.Client(client_id)
|
||||
else:
|
||||
mqttc = mqtt.Client()
|
||||
|
||||
if username and password:
|
||||
mqttc.username_pw_set(username, password)
|
||||
if username and password:
|
||||
mqttc.username_pw_set(username, password)
|
||||
|
||||
mqtt_servers[config["name"]] = mqttc
|
||||
mqtt_servers[config["name"]] = mqttc
|
||||
|
||||
def on_connect(mqttc, obj, flags, rc):
|
||||
logger.debug(f"Connected to MQTT {config['name']}")
|
||||
def on_connect(mqttc, obj, flags, rc):
|
||||
logger.debug(f"Connected to MQTT {config['name']}")
|
||||
|
||||
def on_message(mqttc, obj, msg):
|
||||
packet = msg.payload.decode()
|
||||
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']}: on_message")
|
||||
|
||||
if "pipelines" not in config:
|
||||
logger.warning(f"MQTT {config['name']}: no pipeline")
|
||||
return
|
||||
if "pipelines" not in config:
|
||||
logger.warning(f"MQTT {config['name']}: no pipeline")
|
||||
return
|
||||
|
||||
for pipeline, pipeline_plugins in config["pipelines"].items():
|
||||
for pipeline, pipeline_plugins in config["pipelines"].items():
|
||||
|
||||
logger.debug(f"MQTT {config['name']} pipeline {pipeline} started")
|
||||
if not packet:
|
||||
continue
|
||||
packet = orig_packet
|
||||
|
||||
for plugin in pipeline_plugins:
|
||||
for plugin_key, plugin_config in plugin.items():
|
||||
if plugin_key not in plugins:
|
||||
logger.error(f"No such plugin: {plugin_key}. Skipping")
|
||||
logger.debug(f"MQTT {config['name']} pipeline {pipeline} started")
|
||||
if not packet:
|
||||
continue
|
||||
|
||||
for plugin in pipeline_plugins:
|
||||
if not packet:
|
||||
continue
|
||||
|
||||
p = plugins[plugin_key]
|
||||
p.configure(devices, mqtt_servers, plugin_config)
|
||||
for plugin_key, plugin_config in plugin.items():
|
||||
if plugin_key not in plugins:
|
||||
logger.error(f"No such plugin: {plugin_key}. Skipping")
|
||||
continue
|
||||
|
||||
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")
|
||||
p = plugins[plugin_key]
|
||||
p.configure(devices, mqtt_servers, plugin_config)
|
||||
|
||||
def on_publish(mqttc, obj, mid):
|
||||
logger.debug(f"MQTT {config['name']}: on_publish: {mid}")
|
||||
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_subscribe(mqttc, obj, mid, granted_qos):
|
||||
logger.debug(f"MQTT {config['name']}: on_subscribe: {mid}")
|
||||
def on_publish(mqttc, obj, mid):
|
||||
logger.debug(f"MQTT {config['name']}: on_publish: {mid}")
|
||||
|
||||
mqttc.on_message = on_message
|
||||
mqttc.on_connect = on_connect
|
||||
mqttc.on_publish = on_publish
|
||||
mqttc.on_subscribe = on_subscribe
|
||||
def on_subscribe(mqttc, obj, mid, granted_qos):
|
||||
logger.debug(f"MQTT {config['name']}: on_subscribe: {mid}")
|
||||
|
||||
import ssl
|
||||
mqttc.on_message = on_message
|
||||
mqttc.on_connect = on_connect
|
||||
mqttc.on_publish = on_publish
|
||||
mqttc.on_subscribe = on_subscribe
|
||||
|
||||
if "insecure" in config and config["insecure"]:
|
||||
mqttc.tls_set(cert_reqs=ssl.CERT_NONE)
|
||||
mqttc.tls_insecure_set(True)
|
||||
import ssl
|
||||
|
||||
mqttc.connect(config["server"], config["port"], 60)
|
||||
if "insecure" in config and config["insecure"]:
|
||||
mqttc.tls_set(cert_reqs=ssl.CERT_NONE)
|
||||
mqttc.tls_insecure_set(True)
|
||||
|
||||
if "topic" in config:
|
||||
mqttc.subscribe(config["topic"], 0)
|
||||
mqttc.connect(config["server"], config["port"], 60)
|
||||
|
||||
mqttc.loop_start()
|
||||
if "topic" in config:
|
||||
mqttc.subscribe(config["topic"], 0)
|
||||
|
||||
mqttc.loop_start()
|
||||
|
||||
while True:
|
||||
time.sleep(1000)
|
||||
|
||||
for device, instance in devices.items():
|
||||
instance.close()
|
||||
if devices:
|
||||
for device, instance in devices.items():
|
||||
instance.close()
|
||||
|
||||
for server, instance in mqtt_servers.items():
|
||||
instance.disconnect()
|
||||
if mqtt_servers:
|
||||
for server, instance in mqtt_servers.items():
|
||||
instance.disconnect()
|
||||
|
||||
100
plugins.py
100
plugins.py
@@ -1,10 +1,11 @@
|
||||
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 = {}
|
||||
@@ -74,6 +75,31 @@ 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 text and "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"],
|
||||
@@ -83,12 +109,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 it doesn't match {filter_key} allow filter"
|
||||
)
|
||||
return None
|
||||
|
||||
if (
|
||||
@@ -96,7 +125,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 it matches {filter_key} disallow filter"
|
||||
)
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Accepted")
|
||||
@@ -106,21 +137,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 = (
|
||||
@@ -128,31 +162,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):
|
||||
@@ -311,7 +357,7 @@ class DecryptFilter(Plugin):
|
||||
plugins["decrypt_filter"] = DecryptFilter()
|
||||
|
||||
|
||||
class SendPlugin(Plugin):
|
||||
class RadioMessagePlugin(Plugin):
|
||||
logger = logging.getLogger(name="meshtastic.bridge.plugin.send")
|
||||
|
||||
def do_action(self, packet):
|
||||
@@ -386,4 +432,4 @@ class SendPlugin(Plugin):
|
||||
return packet
|
||||
|
||||
|
||||
plugins["send_plugin"] = SendPlugin()
|
||||
plugins["radio_message_plugin"] = RadioMessagePlugin()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
devices:
|
||||
- name: local
|
||||
- name: local
|
||||
pipelines:
|
||||
- debugger:
|
||||
log_level: info
|
||||
- debugger:
|
||||
log_level: info
|
||||
|
||||
Reference in New Issue
Block a user