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,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