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,312 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+
4
+ import requests
5
+ from meshtastic.mesh_interface import BROADCAST_NUM
6
+
7
+ from mmrelay.constants.formats import TEXT_MESSAGE_APP
8
+ from mmrelay.plugins.base_plugin import BasePlugin
9
+
10
+
11
+ class Plugin(BasePlugin):
12
+ plugin_name = "weather"
13
+
14
+ # No __init__ method needed with the simplified plugin system
15
+ # The BasePlugin will automatically use the class-level plugin_name
16
+
17
+ @property
18
+ def description(self):
19
+ return "Show weather forecast for a radio node using GPS location"
20
+
21
+ def generate_forecast(self, latitude, longitude):
22
+ """
23
+ Generate a concise one-line weather forecast for the given GPS coordinates.
24
+
25
+ Queries the Open-Meteo API for current conditions and hour-aligned forecasts at approximately +2h and +5h, formats temperatures according to self.config.get("units", "metric") ("metric" -> °C, "imperial" -> °F), and returns a single-line summary like:
26
+ "Now: ☀️ Clear sky - 12.3°C | +2h: 🌧️ Light rain - 13.1°C 20% | +5h: ⛅️ Partly cloudy - 10.8°C 5%".
27
+
28
+ Parameters:
29
+ latitude (float): Latitude in decimal degrees.
30
+ longitude (float): Longitude in decimal degrees.
31
+
32
+ Returns:
33
+ str: A one-line forecast string on success. On recoverable failures returns one of:
34
+ - "Weather data temporarily unavailable." (missing hourly data),
35
+ - "Error fetching weather data." (network/HTTP/request errors),
36
+ - "Error parsing weather data." (malformed or unexpected API response).
37
+
38
+ Notes:
39
+ - The function attempts to anchor forecasts to hourly timestamps when available; if a timestamp match cannot be found it falls back to hour-of-day indexing.
40
+ - Network/request-related errors and parsing errors are handled as described above; unexpected exceptions are re-raised.
41
+ """
42
+ units = self.config.get("units", "metric") # Default to metric
43
+ temperature_unit = "°C" if units == "metric" else "°F"
44
+
45
+ url = (
46
+ f"https://api.open-meteo.com/v1/forecast?"
47
+ f"latitude={latitude}&longitude={longitude}&"
48
+ f"hourly=temperature_2m,precipitation_probability,weathercode,is_day&"
49
+ f"forecast_days=2&timezone=auto&current_weather=true"
50
+ )
51
+
52
+ try:
53
+ response = requests.get(url, timeout=10)
54
+ response.raise_for_status()
55
+ data = response.json()
56
+
57
+ # Extract relevant weather data
58
+ current_temp = data["current_weather"]["temperature"]
59
+ current_weather_code = data["current_weather"]["weathercode"]
60
+ is_day = data["current_weather"]["is_day"]
61
+ current_time_str = data["current_weather"]["time"]
62
+
63
+ # Parse current time to get the hour with defensive handling
64
+ current_hour = 0
65
+ current_time = None
66
+ try:
67
+ current_time = datetime.fromisoformat(
68
+ current_time_str.replace("Z", "+00:00")
69
+ )
70
+ current_hour = current_time.hour
71
+ except ValueError as ex:
72
+ self.logger.warning(
73
+ f"Unexpected current_weather.time '{current_time_str}': {ex}. Defaulting to hour=0."
74
+ )
75
+
76
+ # Calculate indices for +2h and +5h forecasts
77
+ # Try to anchor to hourly timestamps for robustness, fall back to hour-of-day
78
+ base_index = current_hour
79
+ hourly_times = data["hourly"].get("time", [])
80
+ if hourly_times and current_time:
81
+ try:
82
+ # Normalize current time to the hour and find it in hourly timestamps
83
+ base_key = current_time.replace(
84
+ minute=0, second=0, microsecond=0
85
+ ).strftime("%Y-%m-%dT%H:00")
86
+ base_index = hourly_times.index(base_key)
87
+ except (ValueError, AttributeError):
88
+ # Fall back to hour-of-day if hourly timestamps are unavailable/mismatched
89
+ self.logger.warning(
90
+ "Could not find current time in hourly timestamps. "
91
+ "Falling back to hour-of-day indexing, which may be inaccurate."
92
+ )
93
+
94
+ forecast_2h_index = base_index + 2
95
+ forecast_5h_index = base_index + 5
96
+
97
+ # Guard against empty hourly series before clamping
98
+ temps = data["hourly"].get("temperature_2m") or []
99
+ if not temps:
100
+ self.logger.warning("No hourly temperature data returned.")
101
+ return "Weather data temporarily unavailable."
102
+ max_index = len(temps) - 1
103
+ forecast_2h_index = min(forecast_2h_index, max_index)
104
+ forecast_5h_index = min(forecast_5h_index, max_index)
105
+
106
+ forecast_2h_temp = data["hourly"]["temperature_2m"][forecast_2h_index]
107
+ forecast_2h_precipitation = data["hourly"]["precipitation_probability"][
108
+ forecast_2h_index
109
+ ]
110
+ forecast_2h_weather_code = data["hourly"]["weathercode"][forecast_2h_index]
111
+ # Get hour-specific day/night flag for +2h forecast
112
+ forecast_2h_is_day = (
113
+ data["hourly"]["is_day"][forecast_2h_index]
114
+ if data["hourly"].get("is_day")
115
+ else is_day
116
+ )
117
+
118
+ forecast_5h_temp = data["hourly"]["temperature_2m"][forecast_5h_index]
119
+ forecast_5h_precipitation = data["hourly"]["precipitation_probability"][
120
+ forecast_5h_index
121
+ ]
122
+ forecast_5h_weather_code = data["hourly"]["weathercode"][forecast_5h_index]
123
+ # Get hour-specific day/night flag for +5h forecast
124
+ forecast_5h_is_day = (
125
+ data["hourly"]["is_day"][forecast_5h_index]
126
+ if data["hourly"].get("is_day")
127
+ else is_day
128
+ )
129
+
130
+ if units == "imperial":
131
+ # Convert temperatures from Celsius to Fahrenheit
132
+ current_temp = current_temp * 9 / 5 + 32
133
+ forecast_2h_temp = forecast_2h_temp * 9 / 5 + 32
134
+ forecast_5h_temp = forecast_5h_temp * 9 / 5 + 32
135
+
136
+ current_temp = round(current_temp, 1)
137
+ forecast_2h_temp = round(forecast_2h_temp, 1)
138
+ forecast_5h_temp = round(forecast_5h_temp, 1)
139
+
140
+ def weather_code_to_text(weather_code, is_day):
141
+ weather_mapping = {
142
+ 0: "☀️ Clear sky" if is_day else "🌙 Clear sky",
143
+ 1: "🌤️ Mainly clear" if is_day else "🌙🌤️ Mainly clear",
144
+ 2: "⛅️ Partly cloudy" if is_day else "🌙⛅️ Partly cloudy",
145
+ 3: "☁️ Overcast" if is_day else "🌙☁️ Overcast",
146
+ 45: "🌫️ Fog" if is_day else "🌙🌫️ Fog",
147
+ 48: (
148
+ "🌫️ Depositing rime fog" if is_day else "🌙🌫️ Depositing rime fog"
149
+ ),
150
+ 51: "🌧️ Light drizzle",
151
+ 53: "🌧️ Moderate drizzle",
152
+ 55: "🌧️ Dense drizzle",
153
+ 56: "🌧️ Light freezing drizzle",
154
+ 57: "🌧️ Dense freezing drizzle",
155
+ 61: "🌧️ Light rain",
156
+ 63: "🌧️ Moderate rain",
157
+ 65: "🌧️ Heavy rain",
158
+ 66: "🌧️ Light freezing rain",
159
+ 67: "🌧️ Heavy freezing rain",
160
+ 71: "❄️ Light snow fall",
161
+ 73: "❄️ Moderate snow fall",
162
+ 75: "❄️ Heavy snow fall",
163
+ 77: "❄️ Snow grains",
164
+ 80: "🌧️ Light rain showers",
165
+ 81: "🌧️ Moderate rain showers",
166
+ 82: "🌧️ Violent rain showers",
167
+ 85: "❄️ Light snow showers",
168
+ 86: "❄️ Heavy snow showers",
169
+ 95: "⛈️ Thunderstorm",
170
+ 96: "⛈️ Thunderstorm with slight hail",
171
+ 99: "⛈️ Thunderstorm with heavy hail",
172
+ }
173
+
174
+ return weather_mapping.get(weather_code, "❓ Unknown")
175
+
176
+ # Generate one-line weather forecast
177
+ forecast = (
178
+ f"Now: {weather_code_to_text(current_weather_code, is_day)} - "
179
+ f"{current_temp}{temperature_unit} | "
180
+ )
181
+ forecast += (
182
+ f"+2h: {weather_code_to_text(forecast_2h_weather_code, forecast_2h_is_day)} - "
183
+ f"{forecast_2h_temp}{temperature_unit} {forecast_2h_precipitation}% | "
184
+ )
185
+ forecast += (
186
+ f"+5h: {weather_code_to_text(forecast_5h_weather_code, forecast_5h_is_day)} - "
187
+ f"{forecast_5h_temp}{temperature_unit} {forecast_5h_precipitation}%"
188
+ )
189
+
190
+ return forecast
191
+
192
+ except Exception as e:
193
+ # Handle HTTP/network errors from requests
194
+ # Handle requests-related exceptions using safe attribute checking
195
+ try:
196
+ # Check if this is a requests exception by checking the module
197
+ if hasattr(requests, "RequestException") and isinstance(
198
+ e, requests.RequestException
199
+ ):
200
+ self.logger.exception("Error fetching weather data")
201
+ return "Error fetching weather data."
202
+ except (AttributeError, TypeError):
203
+ # Fallback to string-based detection if isinstance fails
204
+ exception_module = getattr(type(e), "__module__", "")
205
+ if "requests" in exception_module:
206
+ self.logger.exception("Error fetching weather data")
207
+ return "Error fetching weather data."
208
+
209
+ # Handle data parsing errors
210
+ if isinstance(
211
+ e, (KeyError, IndexError, TypeError, ValueError, AttributeError)
212
+ ):
213
+ self.logger.exception("Malformed weather data")
214
+ return "Error parsing weather data."
215
+ else:
216
+ # Re-raise unexpected exceptions
217
+ raise
218
+
219
+ async def handle_meshtastic_message(
220
+ self, packet, formatted_message, longname, meshnet_name
221
+ ):
222
+ """
223
+ Processes incoming Meshtastic text messages and responds with a weather forecast if the plugin command is detected.
224
+
225
+ Checks if the message is a valid text message on the expected port, verifies channel and command enablement, retrieves the sender's GPS location, generates a weather forecast, and sends the response either as a direct message or broadcast depending on the message type.
226
+
227
+ Returns:
228
+ bool: True if the message was handled and a response was sent; False otherwise.
229
+ """
230
+ if (
231
+ "decoded" in packet
232
+ and "portnum" in packet["decoded"]
233
+ and packet["decoded"]["portnum"] == TEXT_MESSAGE_APP
234
+ and "text" in packet["decoded"]
235
+ ):
236
+ message = packet["decoded"]["text"].strip()
237
+ channel = packet.get("channel", 0) # Default to channel 0 if not provided
238
+
239
+ from mmrelay.meshtastic_utils import connect_meshtastic
240
+
241
+ meshtastic_client = connect_meshtastic()
242
+
243
+ # Determine if the message is a direct message
244
+ toId = packet.get("to")
245
+ myId = meshtastic_client.myInfo.my_node_num # Get relay's own node number
246
+
247
+ if toId == myId:
248
+ # Direct message to us
249
+ is_direct_message = True
250
+ elif toId == BROADCAST_NUM:
251
+ is_direct_message = False
252
+ else:
253
+ # Message to someone else; we may ignore it
254
+ is_direct_message = False
255
+
256
+ # Pass is_direct_message to is_channel_enabled
257
+ if not self.is_channel_enabled(
258
+ channel, is_direct_message=is_direct_message
259
+ ):
260
+ # Channel not enabled for plugin
261
+ return False
262
+
263
+ if f"!{self.plugin_name}" not in message.lower():
264
+ return False
265
+
266
+ # Log that the plugin is processing the message
267
+ self.logger.info(
268
+ f"Processing message from {longname} on channel {channel} with plugin '{self.plugin_name}'"
269
+ )
270
+
271
+ fromId = packet.get("fromId")
272
+ if fromId in meshtastic_client.nodes:
273
+ weather_notice = "Cannot determine location"
274
+ requesting_node = meshtastic_client.nodes.get(fromId)
275
+ if (
276
+ requesting_node
277
+ and "position" in requesting_node
278
+ and "latitude" in requesting_node["position"]
279
+ and "longitude" in requesting_node["position"]
280
+ ):
281
+ weather_notice = self.generate_forecast(
282
+ latitude=requesting_node["position"]["latitude"],
283
+ longitude=requesting_node["position"]["longitude"],
284
+ )
285
+
286
+ # Wait for the response delay
287
+ await asyncio.sleep(self.get_response_delay())
288
+
289
+ if is_direct_message:
290
+ # Respond via DM
291
+ meshtastic_client.sendText(
292
+ text=weather_notice,
293
+ destinationId=fromId,
294
+ )
295
+ else:
296
+ # Respond in the same channel (broadcast)
297
+ meshtastic_client.sendText(
298
+ text=weather_notice,
299
+ channelIndex=channel,
300
+ )
301
+ return True
302
+ else:
303
+ return False # Not a text message or port does not match
304
+
305
+ def get_matrix_commands(self):
306
+ return []
307
+
308
+ def get_mesh_commands(self):
309
+ return [self.plugin_name]
310
+
311
+ async def handle_room_message(self, room, event, full_message):
312
+ return False # Not handling Matrix messages in this plugin
@@ -0,0 +1,35 @@
1
+ """Runtime environment helpers for MMRelay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from mmrelay.constants.network import SYSTEMD_INIT_SYSTEM
8
+
9
+
10
+ def is_running_as_service() -> bool:
11
+ """
12
+ Return True if the current process appears to be running as a systemd service.
13
+
14
+ Checks whether the INVOCATION_ID environment variable is set (systemd-provided) and, if not,
15
+ inspects /proc/self/status to find the parent PID and then /proc/<ppid>/comm to compare the
16
+ parent process name against the expected systemd init binary name. If any file access,
17
+ permission, or parsing errors occur, the function returns False.
18
+ Returns:
19
+ bool: True when running under a systemd service, otherwise False.
20
+ """
21
+
22
+ if os.environ.get("INVOCATION_ID"):
23
+ return True
24
+
25
+ try:
26
+ with open("/proc/self/status", encoding="utf-8") as status_file:
27
+ for line in status_file:
28
+ if line.startswith("PPid:"):
29
+ ppid = int(line.split()[1])
30
+ with open(f"/proc/{ppid}/comm", encoding="utf-8") as comm_file:
31
+ return comm_file.read().strip() == SYSTEMD_INIT_SYSTEM
32
+ except (FileNotFoundError, PermissionError, ValueError):
33
+ pass
34
+
35
+ return False