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,118 @@
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
+ return "".join(
12
+ c.upper() if s.isupper() else c.lower() for s, c in zip(source, target)
13
+ )
14
+
15
+
16
+ class Plugin(BasePlugin):
17
+ plugin_name = "ping"
18
+ punctuation = string.punctuation
19
+
20
+ def __init__(self):
21
+ self.plugin_name = "ping"
22
+ super().__init__()
23
+
24
+ @property
25
+ def description(self):
26
+ return "Check connectivity with the relay or respond to pings over the mesh"
27
+
28
+ async def handle_meshtastic_message(
29
+ self, packet, formatted_message, longname, meshnet_name
30
+ ):
31
+ if "decoded" in packet and "text" in packet["decoded"]:
32
+ message = packet["decoded"]["text"].strip()
33
+ channel = packet.get("channel", 0) # Default to channel 0 if not provided
34
+
35
+ from mmrelay.meshtastic_utils import connect_meshtastic
36
+
37
+ meshtastic_client = connect_meshtastic()
38
+
39
+ # Determine if the message is a direct message
40
+ toId = packet.get("to")
41
+ myId = meshtastic_client.myInfo.my_node_num # Get relay's own node number
42
+
43
+ if toId == myId:
44
+ # Direct message to us
45
+ is_direct_message = True
46
+ elif toId == BROADCAST_NUM:
47
+ is_direct_message = False
48
+ else:
49
+ # Message to someone else; we may ignore it
50
+ is_direct_message = False
51
+
52
+ # Pass is_direct_message to is_channel_enabled
53
+ if not self.is_channel_enabled(
54
+ channel, is_direct_message=is_direct_message
55
+ ):
56
+ # Removed unnecessary logging
57
+ return False
58
+
59
+ # Updated regex to match optional punctuation before and after "ping"
60
+ match = re.search(
61
+ r"(?<!\w)([!?]*)(ping)([!?]*)(?!\w)", message, re.IGNORECASE
62
+ )
63
+ if match:
64
+ # Log that the plugin is processing the message
65
+ self.logger.info(
66
+ f"Processing message from {longname} on channel {channel} with plugin '{self.plugin_name}'"
67
+ )
68
+
69
+ # Extract matched text and punctuation
70
+ pre_punc = match.group(1)
71
+ matched_text = match.group(2)
72
+ post_punc = match.group(3)
73
+
74
+ total_punc_length = len(pre_punc) + len(post_punc)
75
+
76
+ # Define the base response
77
+ base_response = "pong"
78
+
79
+ # Adjust base_response to match the case pattern of matched_text
80
+ base_response = match_case(matched_text, base_response)
81
+
82
+ # Construct the reply message
83
+ if total_punc_length > 5:
84
+ reply_message = "Pong..."
85
+ else:
86
+ reply_message = pre_punc + base_response + post_punc
87
+
88
+ # Wait for the response delay
89
+ await asyncio.sleep(self.get_response_delay())
90
+
91
+ fromId = packet.get("fromId")
92
+
93
+ if is_direct_message:
94
+ # Send reply as DM
95
+ meshtastic_client.sendText(
96
+ text=reply_message,
97
+ destinationId=fromId,
98
+ )
99
+ else:
100
+ # Send the reply back to the same channel
101
+ meshtastic_client.sendText(text=reply_message, channelIndex=channel)
102
+ return True
103
+ else:
104
+ return False # No match, do not process
105
+
106
+ def get_matrix_commands(self):
107
+ return [self.plugin_name]
108
+
109
+ def get_mesh_commands(self):
110
+ return [self.plugin_name]
111
+
112
+ async def handle_room_message(self, room, event, full_message):
113
+ # Pass the event to matches()
114
+ if not self.matches(event):
115
+ return False
116
+
117
+ await self.send_matrix_message(room.room_id, "pong!")
118
+ 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
@@ -0,0 +1,208 @@
1
+ import asyncio
2
+
3
+ import requests
4
+ from meshtastic.mesh_interface import BROADCAST_NUM
5
+
6
+ from mmrelay.plugins.base_plugin import BasePlugin
7
+
8
+
9
+ class Plugin(BasePlugin):
10
+ plugin_name = "weather"
11
+
12
+ def __init__(self):
13
+ self.plugin_name = "weather"
14
+ super().__init__()
15
+
16
+ @property
17
+ def description(self):
18
+ return "Show weather forecast for a radio node using GPS location"
19
+
20
+ def generate_forecast(self, latitude, longitude):
21
+ units = self.config.get("units", "metric") # Default to metric
22
+ temperature_unit = "°C" if units == "metric" else "°F"
23
+
24
+ url = (
25
+ f"https://api.open-meteo.com/v1/forecast?"
26
+ f"latitude={latitude}&longitude={longitude}&"
27
+ f"hourly=temperature_2m,precipitation_probability,weathercode,cloudcover&"
28
+ f"forecast_days=1&current_weather=true"
29
+ )
30
+
31
+ try:
32
+ response = requests.get(url, timeout=10)
33
+ data = response.json()
34
+
35
+ # Extract relevant weather data
36
+ current_temp = data["current_weather"]["temperature"]
37
+ current_weather_code = data["current_weather"]["weathercode"]
38
+ is_day = data["current_weather"]["is_day"]
39
+
40
+ # Get indices for +2h and +5h forecasts
41
+ # Assuming hourly data starts from current hour
42
+ forecast_2h_index = 2
43
+ forecast_5h_index = 5
44
+
45
+ forecast_2h_temp = data["hourly"]["temperature_2m"][forecast_2h_index]
46
+ forecast_2h_precipitation = data["hourly"]["precipitation_probability"][
47
+ forecast_2h_index
48
+ ]
49
+ forecast_2h_weather_code = data["hourly"]["weathercode"][forecast_2h_index]
50
+
51
+ forecast_5h_temp = data["hourly"]["temperature_2m"][forecast_5h_index]
52
+ forecast_5h_precipitation = data["hourly"]["precipitation_probability"][
53
+ forecast_5h_index
54
+ ]
55
+ forecast_5h_weather_code = data["hourly"]["weathercode"][forecast_5h_index]
56
+
57
+ if units == "imperial":
58
+ # Convert temperatures from Celsius to Fahrenheit
59
+ current_temp = current_temp * 9 / 5 + 32
60
+ forecast_2h_temp = forecast_2h_temp * 9 / 5 + 32
61
+ forecast_5h_temp = forecast_5h_temp * 9 / 5 + 32
62
+
63
+ current_temp = round(current_temp, 1)
64
+ forecast_2h_temp = round(forecast_2h_temp, 1)
65
+ forecast_5h_temp = round(forecast_5h_temp, 1)
66
+
67
+ def weather_code_to_text(weather_code, is_day):
68
+ weather_mapping = {
69
+ 0: "☀️ Clear sky" if is_day else "🌙 Clear sky",
70
+ 1: "🌤️ Mainly clear" if is_day else "🌙🌤️ Mainly clear",
71
+ 2: "⛅️ Partly cloudy" if is_day else "🌙⛅️ Partly cloudy",
72
+ 3: "☁️ Overcast" if is_day else "🌙☁️ Overcast",
73
+ 45: "🌫️ Fog" if is_day else "🌙🌫️ Fog",
74
+ 48: (
75
+ "🌫️ Depositing rime fog" if is_day else "🌙🌫️ Depositing rime fog"
76
+ ),
77
+ 51: "🌧️ Light drizzle",
78
+ 53: "🌧️ Moderate drizzle",
79
+ 55: "🌧️ Dense drizzle",
80
+ 56: "🌧️ Light freezing drizzle",
81
+ 57: "🌧️ Dense freezing drizzle",
82
+ 61: "🌧️ Light rain",
83
+ 63: "🌧️ Moderate rain",
84
+ 65: "🌧️ Heavy rain",
85
+ 66: "🌧️ Light freezing rain",
86
+ 67: "🌧️ Heavy freezing rain",
87
+ 71: "❄️ Light snow fall",
88
+ 73: "❄️ Moderate snow fall",
89
+ 75: "❄️ Heavy snow fall",
90
+ 77: "❄️ Snow grains",
91
+ 80: "🌧️ Light rain showers",
92
+ 81: "🌧️ Moderate rain showers",
93
+ 82: "🌧️ Violent rain showers",
94
+ 85: "❄️ Light snow showers",
95
+ 86: "❄️ Heavy snow showers",
96
+ 95: "⛈️ Thunderstorm",
97
+ 96: "⛈️ Thunderstorm with slight hail",
98
+ 99: "⛈️ Thunderstorm with heavy hail",
99
+ }
100
+
101
+ return weather_mapping.get(weather_code, "❓ Unknown")
102
+
103
+ # Generate one-line weather forecast
104
+ forecast = (
105
+ f"Now: {weather_code_to_text(current_weather_code, is_day)} - "
106
+ f"{current_temp}{temperature_unit} | "
107
+ )
108
+ forecast += (
109
+ f"+2h: {weather_code_to_text(forecast_2h_weather_code, is_day)} - "
110
+ f"{forecast_2h_temp}{temperature_unit} {forecast_2h_precipitation}% | "
111
+ )
112
+ forecast += (
113
+ f"+5h: {weather_code_to_text(forecast_5h_weather_code, is_day)} - "
114
+ f"{forecast_5h_temp}{temperature_unit} {forecast_5h_precipitation}%"
115
+ )
116
+
117
+ return forecast
118
+
119
+ except requests.exceptions.RequestException as e:
120
+ self.logger.error(f"Error fetching weather data: {e}")
121
+ return "Error fetching weather data."
122
+
123
+ async def handle_meshtastic_message(
124
+ self, packet, formatted_message, longname, meshnet_name
125
+ ):
126
+ if (
127
+ "decoded" in packet
128
+ and "portnum" in packet["decoded"]
129
+ and packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP"
130
+ and "text" in packet["decoded"]
131
+ ):
132
+ message = packet["decoded"]["text"].strip()
133
+ channel = packet.get("channel", 0) # Default to channel 0 if not provided
134
+
135
+ from mmrelay.meshtastic_utils import connect_meshtastic
136
+
137
+ meshtastic_client = connect_meshtastic()
138
+
139
+ # Determine if the message is a direct message
140
+ toId = packet.get("to")
141
+ myId = meshtastic_client.myInfo.my_node_num # Get relay's own node number
142
+
143
+ if toId == myId:
144
+ # Direct message to us
145
+ is_direct_message = True
146
+ elif toId == BROADCAST_NUM:
147
+ is_direct_message = False
148
+ else:
149
+ # Message to someone else; we may ignore it
150
+ is_direct_message = False
151
+
152
+ # Pass is_direct_message to is_channel_enabled
153
+ if not self.is_channel_enabled(
154
+ channel, is_direct_message=is_direct_message
155
+ ):
156
+ # Channel not enabled for plugin
157
+ return False
158
+
159
+ if f"!{self.plugin_name}" not in message.lower():
160
+ return False
161
+
162
+ # Log that the plugin is processing the message
163
+ self.logger.info(
164
+ f"Processing message from {longname} on channel {channel} with plugin '{self.plugin_name}'"
165
+ )
166
+
167
+ fromId = packet.get("fromId")
168
+ if fromId in meshtastic_client.nodes:
169
+ weather_notice = "Cannot determine location"
170
+ requesting_node = meshtastic_client.nodes.get(fromId)
171
+ if (
172
+ requesting_node
173
+ and "position" in requesting_node
174
+ and "latitude" in requesting_node["position"]
175
+ and "longitude" in requesting_node["position"]
176
+ ):
177
+ weather_notice = self.generate_forecast(
178
+ latitude=requesting_node["position"]["latitude"],
179
+ longitude=requesting_node["position"]["longitude"],
180
+ )
181
+
182
+ # Wait for the response delay
183
+ await asyncio.sleep(self.get_response_delay())
184
+
185
+ if is_direct_message:
186
+ # Respond via DM
187
+ meshtastic_client.sendText(
188
+ text=weather_notice,
189
+ destinationId=fromId,
190
+ )
191
+ else:
192
+ # Respond in the same channel (broadcast)
193
+ meshtastic_client.sendText(
194
+ text=weather_notice,
195
+ channelIndex=channel,
196
+ )
197
+ return True
198
+ else:
199
+ return False # Not a text message or port does not match
200
+
201
+ def get_matrix_commands(self):
202
+ return []
203
+
204
+ def get_mesh_commands(self):
205
+ return [self.plugin_name]
206
+
207
+ async def handle_room_message(self, room, event, full_message):
208
+ return False # Not handling Matrix messages in this plugin