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.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. 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