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,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¤t_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
|
mmrelay/runtime_utils.py
ADDED
|
@@ -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
|