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.
- mmrelay/__init__.py +9 -0
- mmrelay/cli.py +384 -0
- mmrelay/config.py +218 -0
- mmrelay/config_checker.py +133 -0
- mmrelay/db_utils.py +309 -0
- mmrelay/log_utils.py +107 -0
- mmrelay/main.py +281 -0
- mmrelay/matrix_utils.py +754 -0
- mmrelay/meshtastic_utils.py +569 -0
- mmrelay/plugin_loader.py +336 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +212 -0
- mmrelay/plugins/debug_plugin.py +17 -0
- mmrelay/plugins/drop_plugin.py +120 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +55 -0
- mmrelay/plugins/map_plugin.py +323 -0
- mmrelay/plugins/mesh_relay_plugin.py +134 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +118 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +208 -0
- mmrelay/setup_utils.py +263 -0
- mmrelay-1.0.dist-info/METADATA +160 -0
- mmrelay-1.0.dist-info/RECORD +29 -0
- mmrelay-1.0.dist-info/WHEEL +5 -0
- mmrelay-1.0.dist-info/entry_points.txt +2 -0
- mmrelay-1.0.dist-info/licenses/LICENSE +21 -0
- mmrelay-1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|