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,222 @@
|
|
|
1
|
+
# Note: This plugin was experimental and is not functional.
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from meshtastic import mesh_pb2
|
|
8
|
+
|
|
9
|
+
from mmrelay.constants.database import DEFAULT_MAX_DATA_ROWS_PER_NODE_MESH_RELAY
|
|
10
|
+
from mmrelay.plugins.base_plugin import BasePlugin, config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Plugin(BasePlugin):
|
|
14
|
+
"""Core mesh-to-Matrix relay plugin.
|
|
15
|
+
|
|
16
|
+
Handles bidirectional message relay between Meshtastic mesh network
|
|
17
|
+
and Matrix chat rooms. Processes radio packets and forwards them
|
|
18
|
+
to configured Matrix rooms, and vice versa.
|
|
19
|
+
|
|
20
|
+
This plugin is fundamental to the relay's core functionality and
|
|
21
|
+
typically runs with high priority to ensure messages are properly
|
|
22
|
+
bridged between the two networks.
|
|
23
|
+
|
|
24
|
+
Configuration:
|
|
25
|
+
max_data_rows_per_node: 50 (reduced storage for performance)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
plugin_name = "mesh_relay"
|
|
29
|
+
max_data_rows_per_node = DEFAULT_MAX_DATA_ROWS_PER_NODE_MESH_RELAY
|
|
30
|
+
|
|
31
|
+
def normalize(self, dict_obj):
|
|
32
|
+
"""
|
|
33
|
+
Converts packet data in various formats (dict, JSON string, or plain string) into a normalized dictionary with raw data fields removed.
|
|
34
|
+
|
|
35
|
+
Parameters:
|
|
36
|
+
dict_obj: Packet data as a dictionary, JSON string, or plain string.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A dictionary representing the normalized packet with raw fields stripped.
|
|
40
|
+
"""
|
|
41
|
+
if not isinstance(dict_obj, dict):
|
|
42
|
+
try:
|
|
43
|
+
dict_obj = json.loads(dict_obj)
|
|
44
|
+
except (json.JSONDecodeError, TypeError):
|
|
45
|
+
dict_obj = {"decoded": {"text": dict_obj}}
|
|
46
|
+
|
|
47
|
+
return self.strip_raw(dict_obj)
|
|
48
|
+
|
|
49
|
+
def process(self, packet):
|
|
50
|
+
"""Process and prepare packet data for relay.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
packet: Raw packet data to process
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
dict: Processed packet with base64-encoded binary payloads
|
|
57
|
+
|
|
58
|
+
Normalizes packet format and encodes binary payloads as base64
|
|
59
|
+
for JSON serialization and Matrix transmission.
|
|
60
|
+
"""
|
|
61
|
+
packet = self.normalize(packet)
|
|
62
|
+
|
|
63
|
+
if "decoded" in packet and "payload" in packet["decoded"]:
|
|
64
|
+
if isinstance(packet["decoded"]["payload"], bytes):
|
|
65
|
+
packet["decoded"]["payload"] = base64.b64encode(
|
|
66
|
+
packet["decoded"]["payload"]
|
|
67
|
+
).decode("utf-8")
|
|
68
|
+
|
|
69
|
+
return packet
|
|
70
|
+
|
|
71
|
+
def get_matrix_commands(self):
|
|
72
|
+
"""Get Matrix commands handled by this plugin.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
list: Empty list (this plugin handles all traffic, not specific commands)
|
|
76
|
+
"""
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
def get_mesh_commands(self):
|
|
80
|
+
"""Get mesh commands handled by this plugin.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
list: Empty list (this plugin handles all traffic, not specific commands)
|
|
84
|
+
"""
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
async def handle_meshtastic_message(
|
|
88
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Relay a Meshtastic packet to a configured Matrix room.
|
|
92
|
+
|
|
93
|
+
Normalizes and prepares the incoming Meshtastic packet, determines its channel (defaults to 0 if absent), and, if that channel is mapped in plugin configuration, sends the processed packet to the mapped Matrix room. The sent Matrix event includes a JSON-serialized `meshtastic_packet` in the content and sets `mmrelay_suppress` to True to mark it as a bridged packet. If the packet's channel is not mapped, the function returns without sending anything.
|
|
94
|
+
|
|
95
|
+
Parameters:
|
|
96
|
+
packet: Raw Meshtastic packet (dict, JSON string, or other) to be normalized and relayed.
|
|
97
|
+
formatted_message (str): Human-readable message extracted from the packet (not used for routing).
|
|
98
|
+
longname (str): Long name of the sending node (informational).
|
|
99
|
+
meshnet_name (str): Name of the mesh network (informational).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
None
|
|
103
|
+
"""
|
|
104
|
+
from mmrelay.matrix_utils import connect_matrix
|
|
105
|
+
|
|
106
|
+
packet = self.process(packet)
|
|
107
|
+
matrix_client = await connect_matrix()
|
|
108
|
+
|
|
109
|
+
packet_type = packet["decoded"]["portnum"]
|
|
110
|
+
if "channel" in packet:
|
|
111
|
+
channel = packet["channel"]
|
|
112
|
+
else:
|
|
113
|
+
channel = 0
|
|
114
|
+
|
|
115
|
+
channel_mapped = False
|
|
116
|
+
if config is not None:
|
|
117
|
+
matrix_rooms = config.get("matrix_rooms", [])
|
|
118
|
+
for room in matrix_rooms:
|
|
119
|
+
if room["meshtastic_channel"] == channel:
|
|
120
|
+
channel_mapped = True
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
if not channel_mapped:
|
|
124
|
+
self.logger.debug(f"Skipping message from unmapped channel {channel}")
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
await matrix_client.room_send(
|
|
128
|
+
room_id=room["id"],
|
|
129
|
+
message_type="m.room.message",
|
|
130
|
+
content={
|
|
131
|
+
"msgtype": "m.text",
|
|
132
|
+
"mmrelay_suppress": True,
|
|
133
|
+
"meshtastic_packet": json.dumps(packet),
|
|
134
|
+
"body": f"Processed {packet_type} radio packet",
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def matches(self, event):
|
|
141
|
+
"""
|
|
142
|
+
Determine whether a Matrix event's message body contains the bridged-packet marker.
|
|
143
|
+
|
|
144
|
+
Checks event.source["content"]["body"] (when it is a string) against the anchored pattern `^Processed (.+) radio packet$`.
|
|
145
|
+
|
|
146
|
+
Parameters:
|
|
147
|
+
event: Matrix event object whose `.source` mapping is expected to contain a `"content"` dict with a `"body"` string.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if the content body matches `^Processed (.+) radio packet$`, False otherwise.
|
|
151
|
+
"""
|
|
152
|
+
# Check for the presence of necessary keys in the event
|
|
153
|
+
content = event.source.get("content", {})
|
|
154
|
+
body = content.get("body", "")
|
|
155
|
+
|
|
156
|
+
if isinstance(body, str):
|
|
157
|
+
match = re.match(r"^Processed (.+) radio packet$", body)
|
|
158
|
+
return bool(match)
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
async def handle_room_message(self, room, event, full_message):
|
|
162
|
+
"""
|
|
163
|
+
Relay an embedded Meshtastic packet from a Matrix room message to the Meshtastic mesh.
|
|
164
|
+
|
|
165
|
+
If the Matrix event contains an embedded meshtastic packet (detected via self.matches),
|
|
166
|
+
this function looks up the Meshtastic channel mapped to the Matrix room, parses the
|
|
167
|
+
embedded JSON packet from the event content, reconstructs a MeshPacket (decoding the
|
|
168
|
+
base64-encoded payload), and sends it on the radio via the Meshtastic client.
|
|
169
|
+
|
|
170
|
+
Parameters:
|
|
171
|
+
room: Matrix room object where the message was received (used to find room→channel mapping).
|
|
172
|
+
event: Matrix event containing the message; the embedded packet is read from event.source["content"].
|
|
173
|
+
full_message: Unused — matching and extraction are performed against `event`.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
None
|
|
177
|
+
|
|
178
|
+
Side effects:
|
|
179
|
+
Sends a packet onto the Meshtastic radio network when a valid embedded packet and room→channel mapping exist.
|
|
180
|
+
"""
|
|
181
|
+
# Use the event for matching instead of full_message
|
|
182
|
+
if not self.matches(event):
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
channel = None
|
|
186
|
+
if config is not None:
|
|
187
|
+
matrix_rooms = config.get("matrix_rooms", [])
|
|
188
|
+
for room_config in matrix_rooms:
|
|
189
|
+
if room_config["id"] == room.room_id:
|
|
190
|
+
channel = room_config["meshtastic_channel"]
|
|
191
|
+
|
|
192
|
+
if channel is None:
|
|
193
|
+
self.logger.debug(f"Skipping message from unmapped channel {channel}")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
packet_json = event.source["content"].get("meshtastic_packet")
|
|
197
|
+
if not packet_json:
|
|
198
|
+
self.logger.debug("Missing embedded packet")
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
packet = json.loads(packet_json)
|
|
203
|
+
except (json.JSONDecodeError, TypeError):
|
|
204
|
+
self.logger.exception("Error processing embedded packet")
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
208
|
+
|
|
209
|
+
meshtastic_client = connect_meshtastic()
|
|
210
|
+
meshPacket = mesh_pb2.MeshPacket()
|
|
211
|
+
meshPacket.channel = channel
|
|
212
|
+
meshPacket.decoded.payload = base64.b64decode(packet["decoded"]["payload"])
|
|
213
|
+
meshPacket.decoded.portnum = packet["decoded"]["portnum"]
|
|
214
|
+
meshPacket.decoded.want_response = False
|
|
215
|
+
meshPacket.id = meshtastic_client._generatePacketId()
|
|
216
|
+
|
|
217
|
+
self.logger.debug("Relaying packet to Radio")
|
|
218
|
+
|
|
219
|
+
meshtastic_client._sendPacket(
|
|
220
|
+
meshPacket=meshPacket, destinationId=packet["toId"]
|
|
221
|
+
)
|
|
222
|
+
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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
import string
|
|
4
|
+
|
|
5
|
+
from meshtastic.mesh_interface import BROADCAST_NUM
|
|
6
|
+
|
|
7
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def match_case(source, target):
|
|
11
|
+
"""
|
|
12
|
+
Apply the letter-case pattern of `source` onto `target`.
|
|
13
|
+
|
|
14
|
+
Parameters:
|
|
15
|
+
source (str): String whose uppercase/lowercase pattern will be used.
|
|
16
|
+
target (str): String whose characters will be converted to match `source`'s case.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
str: A new string where each character from `target` is uppercased if the corresponding character in `source` is uppercase, otherwise lowercased. The result length is the minimum of the two input lengths.
|
|
20
|
+
"""
|
|
21
|
+
return "".join(
|
|
22
|
+
c.upper() if s.isupper() else c.lower()
|
|
23
|
+
for s, c in zip(source, target, strict=False)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Plugin(BasePlugin):
|
|
28
|
+
plugin_name = "ping"
|
|
29
|
+
punctuation = string.punctuation
|
|
30
|
+
|
|
31
|
+
# No __init__ method needed with the simplified plugin system
|
|
32
|
+
# The BasePlugin will automatically use the class-level plugin_name
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def description(self):
|
|
36
|
+
return "Check connectivity with the relay or respond to pings over the mesh"
|
|
37
|
+
|
|
38
|
+
async def handle_meshtastic_message(
|
|
39
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
40
|
+
):
|
|
41
|
+
if "decoded" in packet and "text" in packet["decoded"]:
|
|
42
|
+
message = packet["decoded"]["text"].strip()
|
|
43
|
+
channel = packet.get("channel", 0) # Default to channel 0 if not provided
|
|
44
|
+
|
|
45
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
46
|
+
|
|
47
|
+
meshtastic_client = connect_meshtastic()
|
|
48
|
+
|
|
49
|
+
# Determine if the message is a direct message
|
|
50
|
+
toId = packet.get("to")
|
|
51
|
+
myId = meshtastic_client.myInfo.my_node_num # Get relay's own node number
|
|
52
|
+
|
|
53
|
+
if toId == myId:
|
|
54
|
+
# Direct message to us
|
|
55
|
+
is_direct_message = True
|
|
56
|
+
elif toId == BROADCAST_NUM:
|
|
57
|
+
is_direct_message = False
|
|
58
|
+
else:
|
|
59
|
+
# Message to someone else; we may ignore it
|
|
60
|
+
is_direct_message = False
|
|
61
|
+
|
|
62
|
+
# Pass is_direct_message to is_channel_enabled
|
|
63
|
+
if not self.is_channel_enabled(
|
|
64
|
+
channel, is_direct_message=is_direct_message
|
|
65
|
+
):
|
|
66
|
+
# Removed unnecessary logging
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Updated regex to match optional punctuation before and after "ping"
|
|
70
|
+
match = re.search(
|
|
71
|
+
r"(?<!\w)([!?]*)(ping)([!?]*)(?!\w)", message, re.IGNORECASE
|
|
72
|
+
)
|
|
73
|
+
if match:
|
|
74
|
+
# Log that the plugin is processing the message
|
|
75
|
+
self.logger.info(
|
|
76
|
+
f"Processing message from {longname} on channel {channel} with plugin '{self.plugin_name}'"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Extract matched text and punctuation
|
|
80
|
+
pre_punc = match.group(1)
|
|
81
|
+
matched_text = match.group(2)
|
|
82
|
+
post_punc = match.group(3)
|
|
83
|
+
|
|
84
|
+
total_punc_length = len(pre_punc) + len(post_punc)
|
|
85
|
+
|
|
86
|
+
# Define the base response
|
|
87
|
+
base_response = "pong"
|
|
88
|
+
|
|
89
|
+
# Adjust base_response to match the case pattern of matched_text
|
|
90
|
+
base_response = match_case(matched_text, base_response)
|
|
91
|
+
|
|
92
|
+
# Construct the reply message
|
|
93
|
+
if total_punc_length > 5:
|
|
94
|
+
reply_message = "Pong..."
|
|
95
|
+
else:
|
|
96
|
+
reply_message = pre_punc + base_response + post_punc
|
|
97
|
+
|
|
98
|
+
# Wait for the response delay
|
|
99
|
+
await asyncio.sleep(self.get_response_delay())
|
|
100
|
+
|
|
101
|
+
fromId = packet.get("fromId")
|
|
102
|
+
|
|
103
|
+
if is_direct_message:
|
|
104
|
+
# Send reply as DM
|
|
105
|
+
meshtastic_client.sendText(
|
|
106
|
+
text=reply_message,
|
|
107
|
+
destinationId=fromId,
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
# Send the reply back to the same channel
|
|
111
|
+
meshtastic_client.sendText(text=reply_message, channelIndex=channel)
|
|
112
|
+
return True
|
|
113
|
+
else:
|
|
114
|
+
return False # No match, do not process
|
|
115
|
+
|
|
116
|
+
def get_matrix_commands(self):
|
|
117
|
+
return [self.plugin_name]
|
|
118
|
+
|
|
119
|
+
def get_mesh_commands(self):
|
|
120
|
+
return [self.plugin_name]
|
|
121
|
+
|
|
122
|
+
async def handle_room_message(self, room, event, full_message):
|
|
123
|
+
# Pass the event to matches()
|
|
124
|
+
if not self.matches(event):
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
await self.send_matrix_message(room.room_id, "pong!")
|
|
128
|
+
return True
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Plugin(BasePlugin):
|
|
13
|
+
plugin_name = "telemetry"
|
|
14
|
+
max_data_rows_per_node = 50
|
|
15
|
+
|
|
16
|
+
def commands(self):
|
|
17
|
+
return ["batteryLevel", "voltage", "airUtilTx"]
|
|
18
|
+
|
|
19
|
+
def description(self):
|
|
20
|
+
return "Graph of avg Mesh telemetry value for last 12 hours"
|
|
21
|
+
|
|
22
|
+
def _generate_timeperiods(self, hours=12):
|
|
23
|
+
# Calculate the start and end times
|
|
24
|
+
end_time = datetime.now()
|
|
25
|
+
start_time = end_time - timedelta(hours=hours)
|
|
26
|
+
|
|
27
|
+
# Create a list of hourly intervals for the last 12 hours
|
|
28
|
+
hourly_intervals = []
|
|
29
|
+
current_time = start_time
|
|
30
|
+
while current_time <= end_time:
|
|
31
|
+
hourly_intervals.append(current_time)
|
|
32
|
+
current_time += timedelta(hours=1)
|
|
33
|
+
return hourly_intervals
|
|
34
|
+
|
|
35
|
+
async def handle_meshtastic_message(
|
|
36
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
37
|
+
):
|
|
38
|
+
# Support deviceMetrics only for now
|
|
39
|
+
if (
|
|
40
|
+
"decoded" in packet
|
|
41
|
+
and "portnum" in packet["decoded"]
|
|
42
|
+
and packet["decoded"]["portnum"] == "TELEMETRY_APP"
|
|
43
|
+
and "telemetry" in packet["decoded"]
|
|
44
|
+
and "deviceMetrics" in packet["decoded"]["telemetry"]
|
|
45
|
+
):
|
|
46
|
+
telemetry_data = []
|
|
47
|
+
data = self.get_node_data(meshtastic_id=packet["fromId"])
|
|
48
|
+
if data:
|
|
49
|
+
telemetry_data = data
|
|
50
|
+
packet_data = packet["decoded"]["telemetry"]
|
|
51
|
+
|
|
52
|
+
telemetry_data.append(
|
|
53
|
+
{
|
|
54
|
+
"time": packet_data["time"],
|
|
55
|
+
"batteryLevel": (
|
|
56
|
+
packet_data["deviceMetrics"]["batteryLevel"]
|
|
57
|
+
if "batteryLevel" in packet_data["deviceMetrics"]
|
|
58
|
+
else 0
|
|
59
|
+
),
|
|
60
|
+
"voltage": (
|
|
61
|
+
packet_data["deviceMetrics"]["voltage"]
|
|
62
|
+
if "voltage" in packet_data["deviceMetrics"]
|
|
63
|
+
else 0
|
|
64
|
+
),
|
|
65
|
+
"airUtilTx": (
|
|
66
|
+
packet_data["deviceMetrics"]["airUtilTx"]
|
|
67
|
+
if "airUtilTx" in packet_data["deviceMetrics"]
|
|
68
|
+
else 0
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
self.set_node_data(meshtastic_id=packet["fromId"], node_data=telemetry_data)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def get_matrix_commands(self):
|
|
76
|
+
return ["batteryLevel", "voltage", "airUtilTx"]
|
|
77
|
+
|
|
78
|
+
def get_mesh_commands(self):
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
def matches(self, event):
|
|
82
|
+
from mmrelay.matrix_utils import bot_command
|
|
83
|
+
|
|
84
|
+
# Use bot_command() to check if any of the commands match
|
|
85
|
+
for command in self.get_matrix_commands():
|
|
86
|
+
if bot_command(command, event):
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
async def handle_room_message(self, room, event, full_message):
|
|
91
|
+
# Pass the event to matches()
|
|
92
|
+
if not self.matches(event):
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
match = re.search(
|
|
96
|
+
r":\s+!(batteryLevel|voltage|airUtilTx)(?:\s+(.+))?$", full_message
|
|
97
|
+
)
|
|
98
|
+
if not match:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
telemetry_option = match.group(1)
|
|
102
|
+
node = match.group(2)
|
|
103
|
+
|
|
104
|
+
hourly_intervals = self._generate_timeperiods()
|
|
105
|
+
from mmrelay.matrix_utils import connect_matrix
|
|
106
|
+
|
|
107
|
+
matrix_client = await connect_matrix()
|
|
108
|
+
|
|
109
|
+
# Compute the hourly averages for each node
|
|
110
|
+
hourly_averages = {}
|
|
111
|
+
|
|
112
|
+
def calculate_averages(node_data_rows):
|
|
113
|
+
for record in node_data_rows:
|
|
114
|
+
record_time = datetime.fromtimestamp(
|
|
115
|
+
record["time"]
|
|
116
|
+
) # Replace with your timestamp field name
|
|
117
|
+
telemetry_value = record[
|
|
118
|
+
telemetry_option
|
|
119
|
+
] # Replace with your battery level field name
|
|
120
|
+
for i in range(len(hourly_intervals) - 1):
|
|
121
|
+
if hourly_intervals[i] <= record_time < hourly_intervals[i + 1]:
|
|
122
|
+
if i not in hourly_averages:
|
|
123
|
+
hourly_averages[i] = []
|
|
124
|
+
hourly_averages[i].append(telemetry_value)
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
if node:
|
|
128
|
+
node_data_rows = self.get_node_data(node)
|
|
129
|
+
calculate_averages(node_data_rows)
|
|
130
|
+
else:
|
|
131
|
+
for node_data_json in self.get_data():
|
|
132
|
+
node_data_rows = json.loads(node_data_json[0])
|
|
133
|
+
calculate_averages(node_data_rows)
|
|
134
|
+
|
|
135
|
+
# Compute the final hourly averages
|
|
136
|
+
final_averages = {}
|
|
137
|
+
for i, interval in enumerate(hourly_intervals[:-1]):
|
|
138
|
+
if i in hourly_averages:
|
|
139
|
+
final_averages[interval] = sum(hourly_averages[i]) / len(
|
|
140
|
+
hourly_averages[i]
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
final_averages[interval] = 0.0
|
|
144
|
+
|
|
145
|
+
# Extract the hourly intervals and average values into separate lists
|
|
146
|
+
hourly_intervals = list(final_averages.keys())
|
|
147
|
+
average_values = list(final_averages.values())
|
|
148
|
+
|
|
149
|
+
# Convert the hourly intervals to strings
|
|
150
|
+
hourly_strings = [hour.strftime("%H") for hour in hourly_intervals]
|
|
151
|
+
|
|
152
|
+
# Create the plot
|
|
153
|
+
fig, ax = plt.subplots()
|
|
154
|
+
ax.plot(hourly_strings, average_values)
|
|
155
|
+
|
|
156
|
+
# Set the plot title and axis labels
|
|
157
|
+
if node:
|
|
158
|
+
title = f"{node} Hourly {telemetry_option} Averages"
|
|
159
|
+
else:
|
|
160
|
+
title = f"Network Hourly {telemetry_option} Averages"
|
|
161
|
+
ax.set_title(title)
|
|
162
|
+
ax.set_xlabel("Hour")
|
|
163
|
+
ax.set_ylabel(f"{telemetry_option}")
|
|
164
|
+
|
|
165
|
+
# Rotate the x-axis labels for readability
|
|
166
|
+
plt.xticks(rotation=45)
|
|
167
|
+
|
|
168
|
+
# Save the plot as a PIL image
|
|
169
|
+
buf = io.BytesIO()
|
|
170
|
+
fig.canvas.print_png(buf)
|
|
171
|
+
buf.seek(0)
|
|
172
|
+
img = Image.open(buf)
|
|
173
|
+
pil_image = Image.frombytes(mode="RGBA", size=img.size, data=img.tobytes())
|
|
174
|
+
|
|
175
|
+
from mmrelay.matrix_utils import send_room_image, upload_image
|
|
176
|
+
|
|
177
|
+
upload_response = await upload_image(matrix_client, pil_image, "graph.png")
|
|
178
|
+
await send_room_image(matrix_client, room.room_id, upload_response)
|
|
179
|
+
return True
|