mmrelay 1.0__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.

Potentially problematic release.


This version of mmrelay might be problematic. Click here for more details.

@@ -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,55 @@
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
+ plugin_name = "help"
9
+
10
+ def __init__(self):
11
+ self.plugin_name = "help"
12
+ super().__init__()
13
+
14
+ @property
15
+ def description(self):
16
+ return "List supported relay commands"
17
+
18
+ async def handle_meshtastic_message(
19
+ self, packet, formatted_message, longname, meshnet_name
20
+ ):
21
+ return False
22
+
23
+ def get_matrix_commands(self):
24
+ return [self.plugin_name]
25
+
26
+ def get_mesh_commands(self):
27
+ return []
28
+
29
+ async def handle_room_message(self, room, event, full_message):
30
+ # Pass the event to matches()
31
+ if not self.matches(event):
32
+ return False
33
+
34
+ command = None
35
+
36
+ match = re.match(r"^.*: !help\s+(.+)$", full_message)
37
+ if match:
38
+ command = match.group(1)
39
+
40
+ plugins = load_plugins()
41
+
42
+ if command:
43
+ reply = f"No such command: {command}"
44
+
45
+ for plugin in plugins:
46
+ if command in plugin.get_matrix_commands():
47
+ reply = f"`!{command}`: {plugin.description}"
48
+ else:
49
+ commands = []
50
+ for plugin in plugins:
51
+ commands.extend(plugin.get_matrix_commands())
52
+ reply = "Available commands: " + ", ".join(commands)
53
+
54
+ await self.send_matrix_message(room.room_id, reply)
55
+ return True
@@ -0,0 +1,323 @@
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
+ # Generate random offsets for latitude and longitude
160
+ lat_offset = random.uniform(-radius / 111320, radius / 111320)
161
+ lon_offset = random.uniform(
162
+ -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat))
163
+ )
164
+
165
+ # Apply the offsets to the location coordinates
166
+ new_lat = lat + lat_offset
167
+ new_lon = lon + lon_offset
168
+
169
+ return new_lat, new_lon
170
+
171
+
172
+ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000):
173
+ """
174
+ Anonymize a location to 10km by default
175
+ """
176
+ context = staticmaps.Context()
177
+ context.set_tile_provider(staticmaps.tile_provider_OSM)
178
+ context.set_zoom(zoom)
179
+
180
+ for location in locations:
181
+ if anonymize:
182
+ new_location = anonymize_location(
183
+ lat=float(location["lat"]),
184
+ lon=float(location["lon"]),
185
+ radius=radius,
186
+ )
187
+ radio = staticmaps.create_latlng(new_location[0], new_location[1])
188
+ else:
189
+ radio = staticmaps.create_latlng(
190
+ float(location["lat"]), float(location["lon"])
191
+ )
192
+ context.add_object(TextLabel(radio, location["label"], fontSize=50))
193
+
194
+ # render non-anti-aliased png
195
+ if image_size:
196
+ return context.render_pillow(image_size[0], image_size[1])
197
+ else:
198
+ return context.render_pillow(1000, 1000)
199
+
200
+
201
+ async def upload_image(client: AsyncClient, image: Image.Image) -> UploadResponse:
202
+ buffer = io.BytesIO()
203
+ image.save(buffer, format="PNG")
204
+ image_data = buffer.getvalue()
205
+
206
+ response, maybe_keys = await client.upload(
207
+ io.BytesIO(image_data),
208
+ content_type="image/png",
209
+ filename="location.png",
210
+ filesize=len(image_data),
211
+ )
212
+
213
+ return response
214
+
215
+
216
+ async def send_room_image(
217
+ client: AsyncClient, room_id: str, upload_response: UploadResponse
218
+ ):
219
+ await client.room_send(
220
+ room_id=room_id,
221
+ message_type="m.room.message",
222
+ content={
223
+ "msgtype": "m.image",
224
+ "url": upload_response.content_uri,
225
+ "body": "image.png",
226
+ },
227
+ )
228
+
229
+
230
+ async def send_image(client: AsyncClient, room_id: str, image: Image.Image):
231
+ response = await upload_image(client=client, image=image)
232
+ await send_room_image(client, room_id, upload_response=response)
233
+
234
+
235
+ class Plugin(BasePlugin):
236
+ plugin_name = "map"
237
+
238
+ def __init__(self):
239
+ self.plugin_name = "map"
240
+ super().__init__()
241
+
242
+ @property
243
+ def description(self):
244
+ return (
245
+ "Map of mesh radio nodes. Supports `zoom` and `size` options to customize"
246
+ )
247
+
248
+ async def handle_meshtastic_message(
249
+ self, packet, formatted_message, longname, meshnet_name
250
+ ):
251
+ return False
252
+
253
+ def get_matrix_commands(self):
254
+ return [self.plugin_name]
255
+
256
+ def get_mesh_commands(self):
257
+ return []
258
+
259
+ async def handle_room_message(self, room, event, full_message):
260
+ # Pass the whole event to matches() for compatibility w/ updated base_plugin.py
261
+ if not self.matches(event):
262
+ return False
263
+
264
+ from mmrelay.matrix_utils import connect_matrix
265
+ from mmrelay.meshtastic_utils import connect_meshtastic
266
+
267
+ matrix_client = await connect_matrix()
268
+ meshtastic_client = connect_meshtastic()
269
+
270
+ pattern = r"^.*:(?: !map(?: zoom=(\d+))?(?: size=(\d+),(\d+))?)?$"
271
+ match = re.match(pattern, full_message)
272
+
273
+ # Indicate this message is not meant for this plugin
274
+ if not match:
275
+ return False
276
+
277
+ zoom = match.group(1)
278
+ image_size = match.group(2, 3)
279
+
280
+ try:
281
+ zoom = int(zoom)
282
+ except:
283
+ zoom = self.config["zoom"] if "zoom" in self.config else 8
284
+
285
+ if zoom < 0 or zoom > 30:
286
+ zoom = 8
287
+
288
+ try:
289
+ image_size = (int(image_size[0]), int(image_size[1]))
290
+ except:
291
+ image_size = (
292
+ self.config["image_width"] if "image_width" in self.config else 1000,
293
+ self.config["image_height"] if "image_height" in self.config else 1000,
294
+ )
295
+
296
+ if image_size[0] > 1000 or image_size[1] > 1000:
297
+ image_size = (1000, 1000)
298
+
299
+ locations = []
300
+ for _node, info in meshtastic_client.nodes.items():
301
+ if "position" in info and "latitude" in info["position"]:
302
+ locations.append(
303
+ {
304
+ "lat": info["position"]["latitude"],
305
+ "lon": info["position"]["longitude"],
306
+ "label": info["user"]["shortName"],
307
+ }
308
+ )
309
+
310
+ anonymize = self.config["anonymize"] if "anonymize" in self.config else True
311
+ radius = self.config["radius"] if "radius" in self.config else 1000
312
+
313
+ pillow_image = get_map(
314
+ locations=locations,
315
+ zoom=zoom,
316
+ image_size=image_size,
317
+ anonymize=anonymize,
318
+ radius=radius,
319
+ )
320
+
321
+ await send_image(matrix_client, room.room_id, pillow_image)
322
+
323
+ return True
@@ -0,0 +1,134 @@
1
+ import base64
2
+ import json
3
+ import re
4
+
5
+ from meshtastic import mesh_pb2
6
+
7
+ from mmrelay.plugins.base_plugin import BasePlugin, config
8
+
9
+
10
+ class Plugin(BasePlugin):
11
+ plugin_name = "mesh_relay"
12
+ max_data_rows_per_node = 50
13
+
14
+ def normalize(self, dict_obj):
15
+ """
16
+ Packets are either a dict, string dict or string
17
+ """
18
+ if not isinstance(dict_obj, dict):
19
+ try:
20
+ dict_obj = json.loads(dict_obj)
21
+ except (json.JSONDecodeError, TypeError):
22
+ dict_obj = {"decoded": {"text": dict_obj}}
23
+
24
+ return self.strip_raw(dict_obj)
25
+
26
+ def process(self, packet):
27
+ packet = self.normalize(packet)
28
+
29
+ if "decoded" in packet and "payload" in packet["decoded"]:
30
+ if isinstance(packet["decoded"]["payload"], bytes):
31
+ packet["decoded"]["payload"] = base64.b64encode(
32
+ packet["decoded"]["payload"]
33
+ ).decode("utf-8")
34
+
35
+ return packet
36
+
37
+ def get_matrix_commands(self):
38
+ return []
39
+
40
+ def get_mesh_commands(self):
41
+ return []
42
+
43
+ async def handle_meshtastic_message(
44
+ self, packet, formatted_message, longname, meshnet_name
45
+ ):
46
+ from mmrelay.matrix_utils import connect_matrix
47
+
48
+ packet = self.process(packet)
49
+ matrix_client = await connect_matrix()
50
+
51
+ packet_type = packet["decoded"]["portnum"]
52
+ if "channel" in packet:
53
+ channel = packet["channel"]
54
+ else:
55
+ channel = 0
56
+
57
+ channel_mapped = False
58
+ if config is not None:
59
+ matrix_rooms = config.get("matrix_rooms", [])
60
+ for room in matrix_rooms:
61
+ if room["meshtastic_channel"] == channel:
62
+ channel_mapped = True
63
+ break
64
+
65
+ if not channel_mapped:
66
+ self.logger.debug(f"Skipping message from unmapped channel {channel}")
67
+ return
68
+
69
+ await matrix_client.room_send(
70
+ room_id=room["id"],
71
+ message_type="m.room.message",
72
+ content={
73
+ "msgtype": "m.text",
74
+ "mmrelay_suppress": True,
75
+ "meshtastic_packet": json.dumps(packet),
76
+ "body": f"Processed {packet_type} radio packet",
77
+ },
78
+ )
79
+
80
+ return False
81
+
82
+ def matches(self, event):
83
+ # Check for the presence of necessary keys in the event
84
+ content = event.source.get("content", {})
85
+ body = content.get("body", "")
86
+
87
+ if isinstance(body, str):
88
+ match = re.match(r"^Processed (.+) radio packet$", body)
89
+ return bool(match)
90
+ return False
91
+
92
+ async def handle_room_message(self, room, event, full_message):
93
+ # Use the event for matching instead of full_message
94
+ if not self.matches(event):
95
+ return False
96
+
97
+ channel = None
98
+ if config is not None:
99
+ matrix_rooms = config.get("matrix_rooms", [])
100
+ for room_config in matrix_rooms:
101
+ if room_config["id"] == room.room_id:
102
+ channel = room_config["meshtastic_channel"]
103
+
104
+ if not channel:
105
+ self.logger.debug(f"Skipping message from unmapped channel {channel}")
106
+ return False
107
+
108
+ packet_json = event.source["content"].get("meshtastic_packet")
109
+ if not packet_json:
110
+ self.logger.debug("Missing embedded packet")
111
+ return False
112
+
113
+ try:
114
+ packet = json.loads(packet_json)
115
+ except (json.JSONDecodeError, TypeError) as e:
116
+ self.logger.error(f"Error processing embedded packet: {e}")
117
+ return
118
+
119
+ from mmrelay.meshtastic_utils import connect_meshtastic
120
+
121
+ meshtastic_client = connect_meshtastic()
122
+ meshPacket = mesh_pb2.MeshPacket()
123
+ meshPacket.channel = channel
124
+ meshPacket.decoded.payload = base64.b64decode(packet["decoded"]["payload"])
125
+ meshPacket.decoded.portnum = packet["decoded"]["portnum"]
126
+ meshPacket.decoded.want_response = False
127
+ meshPacket.id = meshtastic_client._generatePacketId()
128
+
129
+ self.logger.debug("Relaying packet to Radio")
130
+
131
+ meshtastic_client._sendPacket(
132
+ meshPacket=meshPacket, destinationId=packet["toId"]
133
+ )
134
+ return True
@@ -0,0 +1,92 @@
1
+ from datetime import datetime
2
+
3
+ from mmrelay.plugins.base_plugin import BasePlugin
4
+
5
+
6
+ def get_relative_time(timestamp):
7
+ now = datetime.now()
8
+ dt = datetime.fromtimestamp(timestamp)
9
+
10
+ # Calculate the time difference between the current time and the given timestamp
11
+ delta = now - dt
12
+
13
+ # Extract the relevant components from the time difference
14
+ days = delta.days
15
+ seconds = delta.seconds
16
+
17
+ # Convert the time difference into a relative timeframe
18
+ if days > 7:
19
+ return dt.strftime(
20
+ "%b %d, %Y"
21
+ ) # Return the timestamp in a specific format if it's older than 7 days
22
+ elif days >= 1:
23
+ return f"{days} days ago"
24
+ elif seconds >= 3600:
25
+ hours = seconds // 3600
26
+ return f"{hours} hours ago"
27
+ elif seconds >= 60:
28
+ minutes = seconds // 60
29
+ return f"{minutes} minutes ago"
30
+ else:
31
+ return "Just now"
32
+
33
+
34
+ class Plugin(BasePlugin):
35
+ plugin_name = "nodes"
36
+
37
+ @property
38
+ def description(self):
39
+ return """Show mesh radios and node data
40
+
41
+ $shortname $longname / $devicemodel / $battery $voltage / $snr / $lastseen
42
+ """
43
+
44
+ def generate_response(self):
45
+ from mmrelay.meshtastic_utils import connect_meshtastic
46
+
47
+ meshtastic_client = connect_meshtastic()
48
+
49
+ response = f"Nodes: {len(meshtastic_client.nodes)}\n"
50
+
51
+ for _node, info in meshtastic_client.nodes.items():
52
+ snr = ""
53
+ if "snr" in info and info["snr"] is not None:
54
+ snr = f"{info['snr']} dB "
55
+
56
+ last_heard = None
57
+ if "lastHeard" in info and info["lastHeard"] is not None:
58
+ last_heard = get_relative_time(info["lastHeard"])
59
+
60
+ voltage = "?V"
61
+ battery = "?%"
62
+ if "deviceMetrics" in info:
63
+ if (
64
+ "voltage" in info["deviceMetrics"]
65
+ and info["deviceMetrics"]["voltage"] is not None
66
+ ):
67
+ voltage = f"{info['deviceMetrics']['voltage']}V "
68
+ if (
69
+ "batteryLevel" in info["deviceMetrics"]
70
+ and info["deviceMetrics"]["batteryLevel"] is not None
71
+ ):
72
+ battery = f"{info['deviceMetrics']['batteryLevel']}% "
73
+
74
+ response += f"{info['user']['shortName']} {info['user']['longName']} / {info['user']['hwModel']} / {battery} {voltage} / {snr} / {last_heard}\n"
75
+
76
+ return response
77
+
78
+ async def handle_meshtastic_message(
79
+ self, packet, formatted_message, longname, meshnet_name
80
+ ):
81
+ return False
82
+
83
+ async def handle_room_message(self, room, event, full_message):
84
+ # Pass the event to matches()
85
+ if not self.matches(event):
86
+ return False
87
+
88
+ await self.send_matrix_message(
89
+ room_id=room.room_id, message=self.generate_response(), formatted=False
90
+ )
91
+
92
+ return True