mmrelay 1.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mmrelay/__init__.py +5 -0
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +2013 -0
- mmrelay/cli_utils.py +746 -0
- mmrelay/config.py +956 -0
- mmrelay/constants/__init__.py +65 -0
- mmrelay/constants/app.py +29 -0
- mmrelay/constants/config.py +78 -0
- mmrelay/constants/database.py +22 -0
- mmrelay/constants/formats.py +20 -0
- mmrelay/constants/messages.py +45 -0
- mmrelay/constants/network.py +45 -0
- mmrelay/constants/plugins.py +42 -0
- mmrelay/constants/queue.py +20 -0
- mmrelay/db_runtime.py +269 -0
- mmrelay/db_utils.py +1017 -0
- mmrelay/e2ee_utils.py +400 -0
- mmrelay/log_utils.py +274 -0
- mmrelay/main.py +439 -0
- mmrelay/matrix_utils.py +3091 -0
- mmrelay/meshtastic_utils.py +1245 -0
- mmrelay/message_queue.py +647 -0
- mmrelay/plugin_loader.py +1933 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +638 -0
- mmrelay/plugins/debug_plugin.py +30 -0
- mmrelay/plugins/drop_plugin.py +127 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +79 -0
- mmrelay/plugins/map_plugin.py +353 -0
- mmrelay/plugins/mesh_relay_plugin.py +222 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +128 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +312 -0
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +828 -0
- mmrelay/tools/__init__.py +27 -0
- mmrelay/tools/mmrelay.service +19 -0
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
- mmrelay/tools/sample-docker-compose.yaml +30 -0
- mmrelay/tools/sample.env +10 -0
- mmrelay/tools/sample_config.yaml +120 -0
- mmrelay/windows_utils.py +346 -0
- mmrelay-1.2.6.dist-info/METADATA +145 -0
- mmrelay-1.2.6.dist-info/RECORD +50 -0
- mmrelay-1.2.6.dist-info/WHEEL +5 -0
- mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
- mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
- mmrelay-1.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from haversine import haversine
|
|
4
|
+
|
|
5
|
+
from mmrelay.constants.database import DEFAULT_DISTANCE_KM_FALLBACK, DEFAULT_RADIUS_KM
|
|
6
|
+
from mmrelay.constants.formats import TEXT_MESSAGE_APP
|
|
7
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
8
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Plugin(BasePlugin):
|
|
12
|
+
plugin_name = "drop"
|
|
13
|
+
special_node = "!NODE_MSGS!"
|
|
14
|
+
|
|
15
|
+
# No __init__ method needed with the simplified plugin system
|
|
16
|
+
# The BasePlugin will automatically use the class-level plugin_name
|
|
17
|
+
|
|
18
|
+
def get_position(self, meshtastic_client, node_id):
|
|
19
|
+
for _node, info in meshtastic_client.nodes.items():
|
|
20
|
+
if info["user"]["id"] == node_id:
|
|
21
|
+
if "position" in info:
|
|
22
|
+
return info["position"]
|
|
23
|
+
else:
|
|
24
|
+
return None
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
async def handle_meshtastic_message(
|
|
28
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Handles incoming Meshtastic packets for the drop message plugin, delivering or storing dropped messages based on packet content and node location.
|
|
32
|
+
|
|
33
|
+
When a packet is received, attempts to deliver any stored dropped messages to the sender if they are within a configured radius of the message's location and are not the original dropper. If the packet contains a properly formatted drop command, extracts the message and stores it with the sender's current location for future delivery.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if a drop command was processed and stored, False otherwise.
|
|
37
|
+
"""
|
|
38
|
+
meshtastic_client = connect_meshtastic()
|
|
39
|
+
nodeInfo = meshtastic_client.getMyNodeInfo()
|
|
40
|
+
|
|
41
|
+
# Attempt message drop to packet originator if not relay
|
|
42
|
+
if "fromId" in packet and packet["fromId"] != nodeInfo["user"]["id"]:
|
|
43
|
+
position = self.get_position(meshtastic_client, packet["fromId"])
|
|
44
|
+
if position and "latitude" in position and "longitude" in position:
|
|
45
|
+
packet_location = (
|
|
46
|
+
position["latitude"],
|
|
47
|
+
position["longitude"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.logger.debug(f"Packet originates from: {packet_location}")
|
|
51
|
+
messages = self.get_node_data(self.special_node)
|
|
52
|
+
unsent_messages = []
|
|
53
|
+
for message in messages:
|
|
54
|
+
# You cannot pickup what you dropped
|
|
55
|
+
if (
|
|
56
|
+
"originator" in message
|
|
57
|
+
and message["originator"] == packet["fromId"]
|
|
58
|
+
):
|
|
59
|
+
unsent_messages.append(message)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
distance_km = haversine(
|
|
64
|
+
(packet_location[0], packet_location[1]),
|
|
65
|
+
message["location"],
|
|
66
|
+
)
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
distance_km = DEFAULT_DISTANCE_KM_FALLBACK
|
|
69
|
+
radius_km = self.config.get("radius_km", DEFAULT_RADIUS_KM)
|
|
70
|
+
if distance_km <= radius_km:
|
|
71
|
+
target_node = packet["fromId"]
|
|
72
|
+
self.logger.debug(f"Sending dropped message to {target_node}")
|
|
73
|
+
meshtastic_client.sendText(
|
|
74
|
+
text=message["text"], destinationId=target_node
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
unsent_messages.append(message)
|
|
78
|
+
self.set_node_data(self.special_node, unsent_messages)
|
|
79
|
+
total_unsent_messages = len(unsent_messages)
|
|
80
|
+
if total_unsent_messages > 0:
|
|
81
|
+
self.logger.debug(f"{total_unsent_messages} message(s) remaining")
|
|
82
|
+
|
|
83
|
+
# Attempt to drop a message
|
|
84
|
+
if (
|
|
85
|
+
"decoded" in packet
|
|
86
|
+
and "portnum" in packet["decoded"]
|
|
87
|
+
and packet["decoded"]["portnum"] == TEXT_MESSAGE_APP
|
|
88
|
+
):
|
|
89
|
+
text = packet["decoded"]["text"] if "text" in packet["decoded"] else None
|
|
90
|
+
if f"!{self.plugin_name}" not in text:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
match = re.search(r"!drop\s+(.+)$", text)
|
|
94
|
+
if not match:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
drop_message = match.group(1)
|
|
98
|
+
|
|
99
|
+
position = {}
|
|
100
|
+
for _node, info in meshtastic_client.nodes.items():
|
|
101
|
+
if info["user"]["id"] == packet["fromId"]:
|
|
102
|
+
if "position" in info:
|
|
103
|
+
position = info["position"]
|
|
104
|
+
else:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if "latitude" not in position or "longitude" not in position:
|
|
108
|
+
self.logger.debug(
|
|
109
|
+
"Position of dropping node is not known. Skipping ..."
|
|
110
|
+
)
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
self.store_node_data(
|
|
114
|
+
self.special_node,
|
|
115
|
+
{
|
|
116
|
+
"location": (position["latitude"], position["longitude"]),
|
|
117
|
+
"text": drop_message,
|
|
118
|
+
"originator": packet["fromId"],
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
self.logger.debug(f"Dropped a message: {drop_message}")
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
async def handle_room_message(self, room, event, full_message):
|
|
125
|
+
# Pass the event to matches() instead of full_message
|
|
126
|
+
if self.matches(event):
|
|
127
|
+
return True
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import statistics
|
|
2
|
+
|
|
3
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Plugin(BasePlugin):
|
|
7
|
+
plugin_name = "health"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def description(self):
|
|
11
|
+
return "Show mesh health using avg battery, SNR, AirUtil"
|
|
12
|
+
|
|
13
|
+
def generate_response(self):
|
|
14
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
15
|
+
|
|
16
|
+
meshtastic_client = connect_meshtastic()
|
|
17
|
+
battery_levels = []
|
|
18
|
+
air_util_tx = []
|
|
19
|
+
snr = []
|
|
20
|
+
|
|
21
|
+
for _node, info in meshtastic_client.nodes.items():
|
|
22
|
+
if "deviceMetrics" in info:
|
|
23
|
+
if "batteryLevel" in info["deviceMetrics"]:
|
|
24
|
+
battery_levels.append(info["deviceMetrics"]["batteryLevel"])
|
|
25
|
+
if "airUtilTx" in info["deviceMetrics"]:
|
|
26
|
+
air_util_tx.append(info["deviceMetrics"]["airUtilTx"])
|
|
27
|
+
if "snr" in info:
|
|
28
|
+
snr.append(info["snr"])
|
|
29
|
+
print(str(snr))
|
|
30
|
+
# filter out none type values from snr and air_util_tx just in case
|
|
31
|
+
air_util_tx = [value for value in air_util_tx if value is not None]
|
|
32
|
+
snr = [value for value in snr if value is not None]
|
|
33
|
+
|
|
34
|
+
low_battery = len([n for n in battery_levels if n <= 10])
|
|
35
|
+
radios = len(meshtastic_client.nodes)
|
|
36
|
+
avg_battery = statistics.mean(battery_levels) if battery_levels else 0
|
|
37
|
+
mdn_battery = statistics.median(battery_levels)
|
|
38
|
+
avg_air = statistics.mean(air_util_tx) if air_util_tx else 0
|
|
39
|
+
mdn_air = statistics.median(air_util_tx)
|
|
40
|
+
avg_snr = statistics.mean(snr) if snr else 0
|
|
41
|
+
mdn_snr = statistics.median(snr)
|
|
42
|
+
|
|
43
|
+
return f"""Nodes: {radios}
|
|
44
|
+
Battery: {avg_battery:.1f}% / {mdn_battery:.1f}% (avg / median)
|
|
45
|
+
Nodes with Low Battery (< 10): {low_battery}
|
|
46
|
+
Air Util: {avg_air:.2f} / {mdn_air:.2f} (avg / median)
|
|
47
|
+
SNR: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
async def handle_meshtastic_message(
|
|
51
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
52
|
+
):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
async def handle_room_message(self, room, event, full_message):
|
|
56
|
+
|
|
57
|
+
if not self.matches(event):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
await self.send_matrix_message(
|
|
61
|
+
room.room_id, self.generate_response(), formatted=False
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return True
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from mmrelay.plugin_loader import load_plugins
|
|
4
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Plugin(BasePlugin):
|
|
8
|
+
"""Help command plugin for listing available commands.
|
|
9
|
+
|
|
10
|
+
Provides users with information about available relay commands
|
|
11
|
+
and plugin functionality.
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
!help: List all available commands
|
|
15
|
+
!help <command>: Show detailed help for a specific command
|
|
16
|
+
|
|
17
|
+
Dynamically discovers available commands from all loaded plugins
|
|
18
|
+
and their descriptions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
plugin_name = "help"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def description(self):
|
|
25
|
+
"""Get plugin description.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: Description of help functionality
|
|
29
|
+
"""
|
|
30
|
+
return "List supported relay commands"
|
|
31
|
+
|
|
32
|
+
async def handle_meshtastic_message(
|
|
33
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
34
|
+
):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def get_matrix_commands(self):
|
|
38
|
+
"""Get Matrix commands handled by this plugin.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
list: List containing the help command
|
|
42
|
+
"""
|
|
43
|
+
return [self.plugin_name]
|
|
44
|
+
|
|
45
|
+
def get_mesh_commands(self):
|
|
46
|
+
"""Get mesh commands handled by this plugin.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
list: Empty list (help only works via Matrix)
|
|
50
|
+
"""
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
async def handle_room_message(self, room, event, full_message):
|
|
54
|
+
# Pass the event to matches()
|
|
55
|
+
if not self.matches(event):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
command = None
|
|
59
|
+
|
|
60
|
+
match = re.match(r"^.*: !help\s+(.+)$", full_message)
|
|
61
|
+
if match:
|
|
62
|
+
command = match.group(1)
|
|
63
|
+
|
|
64
|
+
plugins = load_plugins()
|
|
65
|
+
|
|
66
|
+
if command:
|
|
67
|
+
reply = f"No such command: {command}"
|
|
68
|
+
|
|
69
|
+
for plugin in plugins:
|
|
70
|
+
if command in plugin.get_matrix_commands():
|
|
71
|
+
reply = f"`!{command}`: {plugin.description}"
|
|
72
|
+
else:
|
|
73
|
+
commands = []
|
|
74
|
+
for plugin in plugins:
|
|
75
|
+
commands.extend(plugin.get_matrix_commands())
|
|
76
|
+
reply = "Available commands: " + ", ".join(commands)
|
|
77
|
+
|
|
78
|
+
await self.send_matrix_message(room.room_id, reply)
|
|
79
|
+
return True
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import math
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import PIL.ImageDraw
|
|
7
|
+
import s2sphere
|
|
8
|
+
import staticmaps
|
|
9
|
+
from nio import AsyncClient, UploadResponse
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def textsize(self: PIL.ImageDraw.ImageDraw, *args, **kwargs):
|
|
16
|
+
x, y, w, h = self.textbbox((0, 0), *args, **kwargs)
|
|
17
|
+
return w, h
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Monkeypatch fix for https://github.com/flopp/py-staticmaps/issues/39
|
|
21
|
+
PIL.ImageDraw.ImageDraw.textsize = textsize
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TextLabel(staticmaps.Object):
|
|
25
|
+
def __init__(self, latlng: s2sphere.LatLng, text: str, fontSize: int = 12) -> None:
|
|
26
|
+
staticmaps.Object.__init__(self)
|
|
27
|
+
self._latlng = latlng
|
|
28
|
+
self._text = text
|
|
29
|
+
self._margin = 4
|
|
30
|
+
self._arrow = 16
|
|
31
|
+
self._font_size = fontSize
|
|
32
|
+
|
|
33
|
+
def latlng(self) -> s2sphere.LatLng:
|
|
34
|
+
return self._latlng
|
|
35
|
+
|
|
36
|
+
def bounds(self) -> s2sphere.LatLngRect:
|
|
37
|
+
return s2sphere.LatLngRect.from_point(self._latlng)
|
|
38
|
+
|
|
39
|
+
def extra_pixel_bounds(self) -> staticmaps.PixelBoundsT:
|
|
40
|
+
# Guess text extents.
|
|
41
|
+
tw = len(self._text) * self._font_size * 0.5
|
|
42
|
+
th = self._font_size * 1.2
|
|
43
|
+
w = max(self._arrow, tw + 2.0 * self._margin)
|
|
44
|
+
return (int(w / 2.0), int(th + 2.0 * self._margin + self._arrow), int(w / 2), 0)
|
|
45
|
+
|
|
46
|
+
def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None:
|
|
47
|
+
x, y = renderer.transformer().ll2pixel(self.latlng())
|
|
48
|
+
x = x + renderer.offset_x()
|
|
49
|
+
|
|
50
|
+
# Updated to use textbbox instead of textsize
|
|
51
|
+
bbox = renderer.draw().textbbox((0, 0), self._text)
|
|
52
|
+
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
53
|
+
w = max(self._arrow, tw + 2 * self._margin)
|
|
54
|
+
h = th + 2 * self._margin
|
|
55
|
+
|
|
56
|
+
path = [
|
|
57
|
+
(x, y),
|
|
58
|
+
(x + self._arrow / 2, y - self._arrow),
|
|
59
|
+
(x + w / 2, y - self._arrow),
|
|
60
|
+
(x + w / 2, y - self._arrow - h),
|
|
61
|
+
(x - w / 2, y - self._arrow - h),
|
|
62
|
+
(x - w / 2, y - self._arrow),
|
|
63
|
+
(x - self._arrow / 2, y - self._arrow),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
renderer.draw().polygon(path, fill=(255, 255, 255, 255))
|
|
67
|
+
renderer.draw().line(path, fill=(255, 0, 0, 255))
|
|
68
|
+
renderer.draw().text(
|
|
69
|
+
(x - tw / 2, y - self._arrow - h / 2 - th / 2),
|
|
70
|
+
self._text,
|
|
71
|
+
fill=(0, 0, 0, 255),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None:
|
|
75
|
+
x, y = renderer.transformer().ll2pixel(self.latlng())
|
|
76
|
+
|
|
77
|
+
ctx = renderer.context()
|
|
78
|
+
ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
|
79
|
+
|
|
80
|
+
ctx.set_font_size(self._font_size)
|
|
81
|
+
x_bearing, y_bearing, tw, th, _, _ = ctx.text_extents(self._text)
|
|
82
|
+
|
|
83
|
+
w = max(self._arrow, tw + 2 * self._margin)
|
|
84
|
+
h = th + 2 * self._margin
|
|
85
|
+
|
|
86
|
+
path = [
|
|
87
|
+
(x, y),
|
|
88
|
+
(x + self._arrow / 2, y - self._arrow),
|
|
89
|
+
(x + w / 2, y - self._arrow),
|
|
90
|
+
(x + w / 2, y - self._arrow - h),
|
|
91
|
+
(x - w / 2, y - self._arrow - h),
|
|
92
|
+
(x - w / 2, y - self._arrow),
|
|
93
|
+
(x - self._arrow / 2, y - self._arrow),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
ctx.set_source_rgb(1, 1, 1)
|
|
97
|
+
ctx.new_path()
|
|
98
|
+
for p in path:
|
|
99
|
+
ctx.line_to(*p)
|
|
100
|
+
ctx.close_path()
|
|
101
|
+
ctx.fill()
|
|
102
|
+
|
|
103
|
+
ctx.set_source_rgb(1, 0, 0)
|
|
104
|
+
ctx.set_line_width(1)
|
|
105
|
+
ctx.new_path()
|
|
106
|
+
for p in path:
|
|
107
|
+
ctx.line_to(*p)
|
|
108
|
+
ctx.close_path()
|
|
109
|
+
ctx.stroke()
|
|
110
|
+
|
|
111
|
+
ctx.set_source_rgb(0, 0, 0)
|
|
112
|
+
ctx.set_line_width(1)
|
|
113
|
+
ctx.move_to(
|
|
114
|
+
x - tw / 2 - x_bearing, y - self._arrow - h / 2 - y_bearing - th / 2
|
|
115
|
+
)
|
|
116
|
+
ctx.show_text(self._text)
|
|
117
|
+
ctx.stroke()
|
|
118
|
+
|
|
119
|
+
def render_svg(self, renderer: staticmaps.SvgRenderer) -> None:
|
|
120
|
+
x, y = renderer.transformer().ll2pixel(self.latlng())
|
|
121
|
+
|
|
122
|
+
# guess text extents
|
|
123
|
+
tw = len(self._text) * self._font_size * 0.5
|
|
124
|
+
th = self._font_size * 1.2
|
|
125
|
+
|
|
126
|
+
w = max(self._arrow, tw + 2 * self._margin)
|
|
127
|
+
h = th + 2 * self._margin
|
|
128
|
+
|
|
129
|
+
path = renderer.drawing().path(
|
|
130
|
+
fill="#ffffff",
|
|
131
|
+
stroke="#ff0000",
|
|
132
|
+
stroke_width=1,
|
|
133
|
+
opacity=1.0,
|
|
134
|
+
)
|
|
135
|
+
path.push(f"M {x} {y}")
|
|
136
|
+
path.push(f" l {self._arrow / 2} {-self._arrow}")
|
|
137
|
+
path.push(f" l {w / 2 - self._arrow / 2} 0")
|
|
138
|
+
path.push(f" l 0 {-h}")
|
|
139
|
+
path.push(f" l {-w} 0")
|
|
140
|
+
path.push(f" l 0 {h}")
|
|
141
|
+
path.push(f" l {w / 2 - self._arrow / 2} 0")
|
|
142
|
+
path.push("Z")
|
|
143
|
+
renderer.group().add(path)
|
|
144
|
+
|
|
145
|
+
renderer.group().add(
|
|
146
|
+
renderer.drawing().text(
|
|
147
|
+
self._text,
|
|
148
|
+
text_anchor="middle",
|
|
149
|
+
dominant_baseline="central",
|
|
150
|
+
insert=(x, y - self._arrow - h / 2),
|
|
151
|
+
font_family="sans-serif",
|
|
152
|
+
font_size=f"{self._font_size}px",
|
|
153
|
+
fill="#000000",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def anonymize_location(lat, lon, radius=1000):
|
|
159
|
+
"""Add random offset to GPS coordinates for privacy protection.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
lat (float): Original latitude
|
|
163
|
+
lon (float): Original longitude
|
|
164
|
+
radius (int): Maximum offset distance in meters (default: 1000)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
tuple: (new_lat, new_lon) with random offset applied
|
|
168
|
+
|
|
169
|
+
Adds random offset within specified radius to obscure exact locations
|
|
170
|
+
while maintaining general geographic area for mapping purposes.
|
|
171
|
+
"""
|
|
172
|
+
# Generate random offsets for latitude and longitude
|
|
173
|
+
lat_offset = random.uniform(-radius / 111320, radius / 111320)
|
|
174
|
+
lon_offset = random.uniform(
|
|
175
|
+
-radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Apply the offsets to the location coordinates
|
|
179
|
+
new_lat = lat + lat_offset
|
|
180
|
+
new_lon = lon + lon_offset
|
|
181
|
+
|
|
182
|
+
return new_lat, new_lon
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000):
|
|
186
|
+
"""
|
|
187
|
+
Anonymize a location to 10km by default
|
|
188
|
+
"""
|
|
189
|
+
context = staticmaps.Context()
|
|
190
|
+
context.set_tile_provider(staticmaps.tile_provider_OSM)
|
|
191
|
+
context.set_zoom(zoom)
|
|
192
|
+
|
|
193
|
+
for location in locations:
|
|
194
|
+
if anonymize:
|
|
195
|
+
new_location = anonymize_location(
|
|
196
|
+
lat=float(location["lat"]),
|
|
197
|
+
lon=float(location["lon"]),
|
|
198
|
+
radius=radius,
|
|
199
|
+
)
|
|
200
|
+
radio = staticmaps.create_latlng(new_location[0], new_location[1])
|
|
201
|
+
else:
|
|
202
|
+
radio = staticmaps.create_latlng(
|
|
203
|
+
float(location["lat"]), float(location["lon"])
|
|
204
|
+
)
|
|
205
|
+
context.add_object(TextLabel(radio, location["label"], fontSize=50))
|
|
206
|
+
|
|
207
|
+
# render non-anti-aliased png
|
|
208
|
+
if image_size:
|
|
209
|
+
return context.render_pillow(image_size[0], image_size[1])
|
|
210
|
+
else:
|
|
211
|
+
return context.render_pillow(1000, 1000)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def upload_image(client: AsyncClient, image: Image.Image) -> UploadResponse:
|
|
215
|
+
buffer = io.BytesIO()
|
|
216
|
+
image.save(buffer, format="PNG")
|
|
217
|
+
image_data = buffer.getvalue()
|
|
218
|
+
|
|
219
|
+
response, maybe_keys = await client.upload(
|
|
220
|
+
io.BytesIO(image_data),
|
|
221
|
+
content_type="image/png",
|
|
222
|
+
filename="location.png",
|
|
223
|
+
filesize=len(image_data),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def send_room_image(
|
|
230
|
+
client: AsyncClient, room_id: str, upload_response: UploadResponse
|
|
231
|
+
):
|
|
232
|
+
await client.room_send(
|
|
233
|
+
room_id=room_id,
|
|
234
|
+
message_type="m.room.message",
|
|
235
|
+
content={
|
|
236
|
+
"msgtype": "m.image",
|
|
237
|
+
"url": upload_response.content_uri,
|
|
238
|
+
"body": "image.png",
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def send_image(client: AsyncClient, room_id: str, image: Image.Image):
|
|
244
|
+
response = await upload_image(client=client, image=image)
|
|
245
|
+
await send_room_image(client, room_id, upload_response=response)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Plugin(BasePlugin):
|
|
249
|
+
"""Static map generation plugin for mesh node locations.
|
|
250
|
+
|
|
251
|
+
Generates static maps showing positions of mesh nodes with labeled markers.
|
|
252
|
+
Supports customizable zoom levels, image sizes, and privacy features.
|
|
253
|
+
|
|
254
|
+
Commands:
|
|
255
|
+
!map: Generate map with default settings
|
|
256
|
+
!map zoom=N: Set zoom level (0-30)
|
|
257
|
+
!map size=W,H: Set image dimensions (max 1000x1000)
|
|
258
|
+
|
|
259
|
+
Configuration:
|
|
260
|
+
zoom (int): Default zoom level (default: 8)
|
|
261
|
+
image_width/image_height (int): Default image size (default: 1000x1000)
|
|
262
|
+
anonymize (bool): Whether to offset coordinates for privacy (default: true)
|
|
263
|
+
radius (int): Anonymization offset radius in meters (default: 1000)
|
|
264
|
+
|
|
265
|
+
Uploads generated maps as images to Matrix rooms.
|
|
266
|
+
"""
|
|
267
|
+
plugin_name = "map"
|
|
268
|
+
|
|
269
|
+
# No __init__ method needed with the simplified plugin system
|
|
270
|
+
# The BasePlugin will automatically use the class-level plugin_name
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def description(self):
|
|
274
|
+
return (
|
|
275
|
+
"Map of mesh radio nodes. Supports `zoom` and `size` options to customize"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def handle_meshtastic_message(
|
|
279
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
280
|
+
):
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
def get_matrix_commands(self):
|
|
284
|
+
return [self.plugin_name]
|
|
285
|
+
|
|
286
|
+
def get_mesh_commands(self):
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
async def handle_room_message(self, room, event, full_message):
|
|
290
|
+
# Pass the whole event to matches() for compatibility w/ updated base_plugin.py
|
|
291
|
+
if not self.matches(event):
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
from mmrelay.matrix_utils import connect_matrix
|
|
295
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
296
|
+
|
|
297
|
+
matrix_client = await connect_matrix()
|
|
298
|
+
meshtastic_client = connect_meshtastic()
|
|
299
|
+
|
|
300
|
+
pattern = r"^.*:(?: !map(?: zoom=(\d+))?(?: size=(\d+),(\d+))?)?$"
|
|
301
|
+
match = re.match(pattern, full_message)
|
|
302
|
+
|
|
303
|
+
# Indicate this message is not meant for this plugin
|
|
304
|
+
if not match:
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
zoom = match.group(1)
|
|
308
|
+
image_size = match.group(2, 3)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
zoom = int(zoom)
|
|
312
|
+
except:
|
|
313
|
+
zoom = self.config["zoom"] if "zoom" in self.config else 8
|
|
314
|
+
|
|
315
|
+
if zoom < 0 or zoom > 30:
|
|
316
|
+
zoom = 8
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
image_size = (int(image_size[0]), int(image_size[1]))
|
|
320
|
+
except:
|
|
321
|
+
image_size = (
|
|
322
|
+
self.config["image_width"] if "image_width" in self.config else 1000,
|
|
323
|
+
self.config["image_height"] if "image_height" in self.config else 1000,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if image_size[0] > 1000 or image_size[1] > 1000:
|
|
327
|
+
image_size = (1000, 1000)
|
|
328
|
+
|
|
329
|
+
locations = []
|
|
330
|
+
for _node, info in meshtastic_client.nodes.items():
|
|
331
|
+
if "position" in info and "latitude" in info["position"]:
|
|
332
|
+
locations.append(
|
|
333
|
+
{
|
|
334
|
+
"lat": info["position"]["latitude"],
|
|
335
|
+
"lon": info["position"]["longitude"],
|
|
336
|
+
"label": info["user"]["shortName"],
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
anonymize = self.config["anonymize"] if "anonymize" in self.config else True
|
|
341
|
+
radius = self.config["radius"] if "radius" in self.config else 1000
|
|
342
|
+
|
|
343
|
+
pillow_image = get_map(
|
|
344
|
+
locations=locations,
|
|
345
|
+
zoom=zoom,
|
|
346
|
+
image_size=image_size,
|
|
347
|
+
anonymize=anonymize,
|
|
348
|
+
radius=radius,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
await send_image(matrix_client, room.room_id, pillow_image)
|
|
352
|
+
|
|
353
|
+
return True
|