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
mmrelay/setup_utils.py
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Setup utilities for MMRelay.
|
|
3
|
+
|
|
4
|
+
This module provides simple functions for managing the systemd user service
|
|
5
|
+
and generating configuration files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.resources
|
|
9
|
+
|
|
10
|
+
# Import version from package
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from mmrelay.constants.database import PROGRESS_COMPLETE, PROGRESS_TOTAL_STEPS
|
|
19
|
+
from mmrelay.tools import get_service_template_path
|
|
20
|
+
|
|
21
|
+
# Resolve systemctl path dynamically with fallback
|
|
22
|
+
SYSTEMCTL = shutil.which("systemctl") or "/usr/bin/systemctl"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _quote_if_needed(path: str) -> str:
|
|
26
|
+
"""Quote executable paths that contain spaces for systemd compatibility."""
|
|
27
|
+
return f'"{path}"' if " " in path else path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_resolved_exec_cmd() -> str:
|
|
31
|
+
"""
|
|
32
|
+
Return the resolved command used to invoke MMRelay.
|
|
33
|
+
|
|
34
|
+
Prefers an mmrelay executable found on PATH; if found returns its filesystem path (quoted if it contains spaces). If not found, returns an invocation that runs the current Python interpreter with `-m mmrelay` (the interpreter path will be quoted if it contains spaces). The returned string is suitable for use as an ExecStart value in a systemd unit.
|
|
35
|
+
"""
|
|
36
|
+
mmrelay_path = shutil.which("mmrelay")
|
|
37
|
+
if mmrelay_path:
|
|
38
|
+
return _quote_if_needed(mmrelay_path)
|
|
39
|
+
py = _quote_if_needed(sys.executable)
|
|
40
|
+
return f"{py} -m mmrelay"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_executable_path():
|
|
44
|
+
"""
|
|
45
|
+
Return the resolved command to invoke the mmrelay executable with user feedback.
|
|
46
|
+
|
|
47
|
+
This is a wrapper around get_resolved_exec_cmd() that adds print statements
|
|
48
|
+
for user feedback during setup operations.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
str: Either the filesystem path to the `mmrelay` executable or a Python module
|
|
52
|
+
invocation string using the current interpreter.
|
|
53
|
+
"""
|
|
54
|
+
resolved_cmd = get_resolved_exec_cmd()
|
|
55
|
+
if " -m mmrelay" in resolved_cmd:
|
|
56
|
+
print(
|
|
57
|
+
"Warning: Could not find mmrelay executable in PATH. Using current Python interpreter.",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
print(f"Found mmrelay executable at: {resolved_cmd}")
|
|
62
|
+
return resolved_cmd
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_resolved_exec_start(
|
|
66
|
+
args_suffix: str = " --config %h/.mmrelay/config.yaml --logfile %h/.mmrelay/logs/mmrelay.log",
|
|
67
|
+
) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Return a complete systemd `ExecStart=` line for the mmrelay service.
|
|
70
|
+
|
|
71
|
+
Parameters:
|
|
72
|
+
args_suffix (str): Command-line arguments appended to the resolved mmrelay command.
|
|
73
|
+
Defaults to `" --config %h/.mmrelay/config.yaml --logfile %h/.mmrelay/logs/mmrelay.log"`.
|
|
74
|
+
Typical values may include systemd specifiers like `%h` for the user home directory.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
str: A single-line string beginning with `ExecStart=` containing the resolved executable
|
|
78
|
+
invocation followed by the provided argument suffix.
|
|
79
|
+
"""
|
|
80
|
+
return f"ExecStart={get_resolved_exec_cmd()}{args_suffix}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_user_service_path():
|
|
84
|
+
"""Get the path to the user service file."""
|
|
85
|
+
service_dir = Path.home() / ".config" / "systemd" / "user"
|
|
86
|
+
return service_dir / "mmrelay.service"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def service_exists():
|
|
90
|
+
"""Check if the service file exists."""
|
|
91
|
+
return get_user_service_path().exists()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_service_commands():
|
|
95
|
+
"""Print the commands for controlling the systemd user service."""
|
|
96
|
+
print(" systemctl --user start mmrelay.service # Start the service")
|
|
97
|
+
print(" systemctl --user stop mmrelay.service # Stop the service")
|
|
98
|
+
print(" systemctl --user restart mmrelay.service # Restart the service")
|
|
99
|
+
print(" systemctl --user status mmrelay.service # Check service status")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def wait_for_service_start():
|
|
103
|
+
"""
|
|
104
|
+
Wait up to ~10 seconds for the user mmrelay systemd service to become active.
|
|
105
|
+
|
|
106
|
+
Blocks while periodically checking is_service_active(). When running interactively (not as a service) a Rich spinner and elapsed-time display are shown; when running as a service the function performs the same timed checks without UI. The wait may finish early if the service becomes active (checks begin allowing early exit after ~5 seconds). This function does not return a value.
|
|
107
|
+
"""
|
|
108
|
+
import time
|
|
109
|
+
|
|
110
|
+
from mmrelay.runtime_utils import is_running_as_service
|
|
111
|
+
|
|
112
|
+
running_as_service = is_running_as_service()
|
|
113
|
+
if not running_as_service:
|
|
114
|
+
try:
|
|
115
|
+
from rich.progress import (
|
|
116
|
+
Progress,
|
|
117
|
+
SpinnerColumn,
|
|
118
|
+
TextColumn,
|
|
119
|
+
TimeElapsedColumn,
|
|
120
|
+
)
|
|
121
|
+
except Exception:
|
|
122
|
+
running_as_service = True
|
|
123
|
+
|
|
124
|
+
# Create a Rich progress display with spinner and elapsed time
|
|
125
|
+
if not running_as_service:
|
|
126
|
+
with Progress(
|
|
127
|
+
SpinnerColumn(),
|
|
128
|
+
TextColumn("[bold green]Starting mmrelay service..."),
|
|
129
|
+
TimeElapsedColumn(),
|
|
130
|
+
transient=True,
|
|
131
|
+
) as progress:
|
|
132
|
+
# Add a task that will run for approximately 10 seconds
|
|
133
|
+
task = progress.add_task("Starting", total=PROGRESS_TOTAL_STEPS)
|
|
134
|
+
|
|
135
|
+
# Update progress over ~10 seconds
|
|
136
|
+
step = max(1, PROGRESS_TOTAL_STEPS // 10)
|
|
137
|
+
for i in range(10):
|
|
138
|
+
time.sleep(1)
|
|
139
|
+
progress.update(
|
|
140
|
+
task, completed=min(PROGRESS_TOTAL_STEPS, step * (i + 1))
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Check if service is active after 5 seconds to potentially finish early
|
|
144
|
+
if i >= 5 and is_service_active():
|
|
145
|
+
progress.update(task, completed=PROGRESS_COMPLETE)
|
|
146
|
+
break
|
|
147
|
+
else:
|
|
148
|
+
# Simple fallback when running as service
|
|
149
|
+
for i in range(10):
|
|
150
|
+
time.sleep(1)
|
|
151
|
+
if i >= 5 and is_service_active():
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def read_service_file():
|
|
156
|
+
"""
|
|
157
|
+
Read and return the contents of the user's mmrelay systemd service file.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
str | None: The file contents decoded as UTF-8 if the service file exists, otherwise None.
|
|
161
|
+
"""
|
|
162
|
+
service_path = get_user_service_path()
|
|
163
|
+
if service_path.exists():
|
|
164
|
+
return service_path.read_text(encoding="utf-8")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_template_service_path():
|
|
169
|
+
"""
|
|
170
|
+
Locate the mmrelay systemd service template on disk.
|
|
171
|
+
|
|
172
|
+
Searches a deterministic list of candidate locations (package directory, package/tools,
|
|
173
|
+
sys.prefix share paths, user local share (~/.local/share), parent-directory development
|
|
174
|
+
paths, and ./tools) and returns the first existing path.
|
|
175
|
+
|
|
176
|
+
If no template is found, the function prints a warning to stderr listing all
|
|
177
|
+
attempted locations and returns None.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
str | None: Path to the found mmrelay.service template, or None if not found.
|
|
181
|
+
"""
|
|
182
|
+
# Try to find the service template file
|
|
183
|
+
package_dir = os.path.dirname(__file__)
|
|
184
|
+
|
|
185
|
+
# Try to find the service template file in various locations
|
|
186
|
+
template_paths = [
|
|
187
|
+
# Check in the package directory (where it should be after installation)
|
|
188
|
+
os.path.join(package_dir, "mmrelay.service"),
|
|
189
|
+
# Check in a tools subdirectory of the package
|
|
190
|
+
os.path.join(package_dir, "tools", "mmrelay.service"),
|
|
191
|
+
# Check in the data files location (where it should be after installation)
|
|
192
|
+
os.path.join(sys.prefix, "share", "mmrelay", "mmrelay.service"),
|
|
193
|
+
os.path.join(sys.prefix, "share", "mmrelay", "tools", "mmrelay.service"),
|
|
194
|
+
# Check in the user site-packages location
|
|
195
|
+
os.path.join(
|
|
196
|
+
os.path.expanduser("~"), ".local", "share", "mmrelay", "mmrelay.service"
|
|
197
|
+
),
|
|
198
|
+
os.path.join(
|
|
199
|
+
os.path.expanduser("~"),
|
|
200
|
+
".local",
|
|
201
|
+
"share",
|
|
202
|
+
"mmrelay",
|
|
203
|
+
"tools",
|
|
204
|
+
"mmrelay.service",
|
|
205
|
+
),
|
|
206
|
+
# Check one level up from the package directory
|
|
207
|
+
os.path.join(os.path.dirname(package_dir), "tools", "mmrelay.service"),
|
|
208
|
+
# Check two levels up from the package directory (for development)
|
|
209
|
+
os.path.join(
|
|
210
|
+
os.path.dirname(os.path.dirname(package_dir)), "tools", "mmrelay.service"
|
|
211
|
+
),
|
|
212
|
+
# Check in the repository root (for development)
|
|
213
|
+
os.path.join(
|
|
214
|
+
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
|
215
|
+
"tools",
|
|
216
|
+
"mmrelay.service",
|
|
217
|
+
),
|
|
218
|
+
# Check in the current directory (fallback)
|
|
219
|
+
os.path.join(os.getcwd(), "tools", "mmrelay.service"),
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
# Try each path until we find one that exists
|
|
223
|
+
for path in template_paths:
|
|
224
|
+
if os.path.exists(path):
|
|
225
|
+
return path
|
|
226
|
+
|
|
227
|
+
# If we get here, we couldn't find the template
|
|
228
|
+
# Warning output to help diagnose issues
|
|
229
|
+
print(
|
|
230
|
+
"Warning: Could not find mmrelay.service in any of these locations:",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
for path in template_paths:
|
|
234
|
+
print(f" - {path}", file=sys.stderr)
|
|
235
|
+
|
|
236
|
+
# If we get here, we couldn't find the template
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_template_service_content():
|
|
241
|
+
"""
|
|
242
|
+
Return the systemd service unit content to install for the user-level mmrelay service.
|
|
243
|
+
|
|
244
|
+
Attempts to load a template in this order:
|
|
245
|
+
1. The external path returned by get_service_template_path() (UTF-8).
|
|
246
|
+
2. The embedded package resource "mmrelay.service" from mmrelay.tools via importlib.resources.
|
|
247
|
+
3. A second filesystem probe using get_template_service_path() (UTF-8).
|
|
248
|
+
|
|
249
|
+
If none of the above can be read, returns a built-in default service unit that includes a resolved ExecStart (from get_resolved_exec_start()), sensible Environment settings (including PYTHONUNBUFFERED and a PATH containing common user-local locations), and standard Unit/Service/Install sections.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
str: Complete service file content to write. Read/access errors are reported to stderr.
|
|
253
|
+
"""
|
|
254
|
+
# Use the helper function to get the service template path
|
|
255
|
+
template_path = get_service_template_path()
|
|
256
|
+
|
|
257
|
+
if template_path and os.path.exists(template_path):
|
|
258
|
+
# Read the template from file
|
|
259
|
+
try:
|
|
260
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
261
|
+
service_template = f.read()
|
|
262
|
+
return service_template
|
|
263
|
+
except (OSError, IOError, UnicodeDecodeError) as e:
|
|
264
|
+
print(f"Error reading service template file: {e}", file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
# If the helper function failed, try using importlib.resources directly
|
|
267
|
+
try:
|
|
268
|
+
service_template = (
|
|
269
|
+
importlib.resources.files("mmrelay.tools")
|
|
270
|
+
.joinpath("mmrelay.service")
|
|
271
|
+
.read_text(encoding="utf-8")
|
|
272
|
+
)
|
|
273
|
+
return service_template
|
|
274
|
+
except (FileNotFoundError, ImportError, OSError, UnicodeDecodeError) as e:
|
|
275
|
+
print(
|
|
276
|
+
f"Error accessing mmrelay.service via importlib.resources: {e}",
|
|
277
|
+
file=sys.stderr,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Fall back to the file path method
|
|
281
|
+
template_path = get_template_service_path()
|
|
282
|
+
if template_path:
|
|
283
|
+
# Read the template from file
|
|
284
|
+
try:
|
|
285
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
286
|
+
service_template = f.read()
|
|
287
|
+
return service_template
|
|
288
|
+
except (OSError, IOError, UnicodeDecodeError) as e:
|
|
289
|
+
print(f"Error reading service template file: {e}", file=sys.stderr)
|
|
290
|
+
|
|
291
|
+
# If we couldn't find or read the template file, use a default template
|
|
292
|
+
print("Using default service template", file=sys.stderr)
|
|
293
|
+
resolved_exec_start = get_resolved_exec_start()
|
|
294
|
+
return f"""[Unit]
|
|
295
|
+
Description=MMRelay - Meshtastic <=> Matrix Relay
|
|
296
|
+
After=network-online.target
|
|
297
|
+
Wants=network-online.target
|
|
298
|
+
|
|
299
|
+
[Service]
|
|
300
|
+
Type=simple
|
|
301
|
+
# The mmrelay binary can be installed via pipx or pip
|
|
302
|
+
{resolved_exec_start}
|
|
303
|
+
WorkingDirectory=%h/.mmrelay
|
|
304
|
+
Restart=on-failure
|
|
305
|
+
RestartSec=10
|
|
306
|
+
Environment=PYTHONUNBUFFERED=1
|
|
307
|
+
Environment=LANG=C.UTF-8
|
|
308
|
+
# Ensure both pipx and pip environments are properly loaded
|
|
309
|
+
Environment=PATH=%h/.local/bin:%h/.local/pipx/venvs/mmrelay/bin:/usr/local/bin:/usr/bin:/bin
|
|
310
|
+
|
|
311
|
+
[Install]
|
|
312
|
+
WantedBy=default.target
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def is_service_enabled():
|
|
317
|
+
"""
|
|
318
|
+
Return whether the user systemd service 'mmrelay.service' is enabled to start at login.
|
|
319
|
+
|
|
320
|
+
Uses the resolved SYSTEMCTL command to run `SYSTEMCTL --user is-enabled mmrelay.service`. Returns True only if the command exits successfully and its stdout equals "enabled"; returns False on any error or non-enabled state.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
result = subprocess.run(
|
|
324
|
+
[SYSTEMCTL, "--user", "is-enabled", "mmrelay.service"],
|
|
325
|
+
check=False, # Don't raise an exception if the service is not enabled
|
|
326
|
+
capture_output=True,
|
|
327
|
+
text=True,
|
|
328
|
+
)
|
|
329
|
+
return result.returncode == 0 and result.stdout.strip() == "enabled"
|
|
330
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
331
|
+
print(f"Warning: Failed to check service enabled status: {e}", file=sys.stderr)
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def is_service_active():
|
|
336
|
+
"""
|
|
337
|
+
Return True if the user systemd unit 'mmrelay.service' is currently active (running).
|
|
338
|
+
|
|
339
|
+
Checks the service state by invoking the resolved systemctl executable with
|
|
340
|
+
'--user is-active mmrelay.service'. On command failure or exceptions (e.g.
|
|
341
|
+
OSError, subprocess errors) the function prints a warning to stderr and returns False.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
bool: True when the service is active; False otherwise or on error.
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
result = subprocess.run(
|
|
348
|
+
[SYSTEMCTL, "--user", "is-active", "mmrelay.service"],
|
|
349
|
+
check=False, # Don't raise an exception if the service is not active
|
|
350
|
+
capture_output=True,
|
|
351
|
+
text=True,
|
|
352
|
+
)
|
|
353
|
+
return result.returncode == 0 and result.stdout.strip() == "active"
|
|
354
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
355
|
+
print(f"Warning: Failed to check service active status: {e}", file=sys.stderr)
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def create_service_file():
|
|
360
|
+
"""
|
|
361
|
+
Create or update the per-user systemd unit file for MMRelay.
|
|
362
|
+
|
|
363
|
+
Ensures the user systemd directory (~/.config/systemd/user) and the MMRelay logs directory (~/.mmrelay/logs) exist, obtains a service unit template using the module's template-loading fallbacks, substitutes known placeholders (working directory, packaged launcher, and config path), normalizes the Unit's ExecStart to the resolved MMRelay invocation (an mmrelay executable on PATH or a Python `-m mmrelay` fallback) while preserving any trailing arguments, and writes the resulting unit to ~/.config/systemd/user/mmrelay.service.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
bool: True if the service file was written successfully; False if a template could not be obtained or writing the file failed.
|
|
367
|
+
"""
|
|
368
|
+
# Get executable paths once to avoid duplicate calls and output
|
|
369
|
+
executable_path = get_executable_path()
|
|
370
|
+
|
|
371
|
+
# Create service directory if it doesn't exist
|
|
372
|
+
service_dir = get_user_service_path().parent
|
|
373
|
+
service_dir.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
|
|
375
|
+
# Create logs directory if it doesn't exist
|
|
376
|
+
logs_dir = Path.home() / ".mmrelay" / "logs"
|
|
377
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
|
|
379
|
+
# Get the template service content
|
|
380
|
+
service_template = get_template_service_content()
|
|
381
|
+
if not service_template:
|
|
382
|
+
print("Error: Could not find service template file", file=sys.stderr)
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
# Replace placeholders with actual values
|
|
386
|
+
service_content = (
|
|
387
|
+
service_template.replace(
|
|
388
|
+
"WorkingDirectory=%h/meshtastic-matrix-relay",
|
|
389
|
+
"# WorkingDirectory is not needed for installed package",
|
|
390
|
+
)
|
|
391
|
+
.replace(
|
|
392
|
+
"%h/meshtastic-matrix-relay/.pyenv/bin/python %h/meshtastic-matrix-relay/main.py",
|
|
393
|
+
executable_path,
|
|
394
|
+
)
|
|
395
|
+
.replace(
|
|
396
|
+
"--config %h/.mmrelay/config/config.yaml",
|
|
397
|
+
"--config %h/.mmrelay/config.yaml",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Normalize ExecStart: replace any mmrelay launcher with resolved command, preserving args
|
|
402
|
+
pattern = re.compile(
|
|
403
|
+
r'(?m)^\s*(ExecStart=)"?(?:'
|
|
404
|
+
r"/usr/bin/env\s+mmrelay"
|
|
405
|
+
r"|(?:\S*?[\\/])?mmrelay\b"
|
|
406
|
+
r"|\S*\bpython(?:\d+(?:\.\d+)*)?(?:\.exe)?\b\s+-m\s+mmrelay"
|
|
407
|
+
r')"?(\s.*)?$'
|
|
408
|
+
)
|
|
409
|
+
service_content = pattern.sub(
|
|
410
|
+
lambda m: f"{m.group(1)}{executable_path}{m.group(2) or ''}",
|
|
411
|
+
service_content,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Write service file
|
|
415
|
+
try:
|
|
416
|
+
get_user_service_path().write_text(service_content, encoding="utf-8")
|
|
417
|
+
print(f"Service file created at {get_user_service_path()}")
|
|
418
|
+
return True
|
|
419
|
+
except (IOError, OSError) as e:
|
|
420
|
+
print(f"Error creating service file: {e}", file=sys.stderr)
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def reload_daemon():
|
|
425
|
+
"""
|
|
426
|
+
Reload the current user's systemd daemon to apply unit file changes.
|
|
427
|
+
|
|
428
|
+
Attempts to run the resolved `SYSTEMCTL` command with `--user daemon-reload`. Returns True if the subprocess exits successfully; returns False on failure (subprocess error or OSError). Side effects: prints a success message to stdout or an error message to stderr.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
# Using resolved systemctl path
|
|
432
|
+
subprocess.run([SYSTEMCTL, "--user", "daemon-reload"], check=True)
|
|
433
|
+
print("Systemd user daemon reloaded")
|
|
434
|
+
return True
|
|
435
|
+
except subprocess.CalledProcessError as e:
|
|
436
|
+
print(f"Error reloading systemd daemon: {e}", file=sys.stderr)
|
|
437
|
+
return False
|
|
438
|
+
except OSError as e:
|
|
439
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def service_needs_update():
|
|
444
|
+
"""
|
|
445
|
+
Return whether the user systemd unit for mmrelay should be updated.
|
|
446
|
+
|
|
447
|
+
Performs checks in this order and returns (needs_update, reason):
|
|
448
|
+
- No installed unit file => update required.
|
|
449
|
+
- Installed unit must contain an ExecStart= line that invokes mmrelay via an acceptable form:
|
|
450
|
+
- an mmrelay executable found on PATH,
|
|
451
|
+
- "/usr/bin/env mmrelay",
|
|
452
|
+
- or the current Python interpreter with "-m mmrelay".
|
|
453
|
+
If none match, an update is recommended.
|
|
454
|
+
- Unit Environment= PATH lines must include common user bin locations (e.g. "%h/.local/pipx/venvs/mmrelay/bin" or "%h/.local/bin"); if missing, an update is recommended.
|
|
455
|
+
- If a template service file is available on disk, its modification time is compared to the installed unit; if the template is newer, an update is recommended.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
tuple: (needs_update: bool, reason: str) — True when an update is recommended or required; reason explains the decision or why the check failed (e.g., missing ExecStart, missing PATH entries, stat error).
|
|
459
|
+
"""
|
|
460
|
+
# Check if service already exists
|
|
461
|
+
existing_service = read_service_file()
|
|
462
|
+
if not existing_service:
|
|
463
|
+
return True, "No existing service file found"
|
|
464
|
+
|
|
465
|
+
# Get the template service path
|
|
466
|
+
template_path = get_template_service_path()
|
|
467
|
+
|
|
468
|
+
# Get the acceptable executable paths
|
|
469
|
+
mmrelay_path = shutil.which("mmrelay")
|
|
470
|
+
acceptable_execs = [
|
|
471
|
+
f"{_quote_if_needed(sys.executable)} -m mmrelay",
|
|
472
|
+
"/usr/bin/env mmrelay",
|
|
473
|
+
]
|
|
474
|
+
if mmrelay_path:
|
|
475
|
+
acceptable_execs.append(_quote_if_needed(mmrelay_path))
|
|
476
|
+
|
|
477
|
+
# Check if the ExecStart line in the existing service file contains an acceptable executable form
|
|
478
|
+
exec_start_line = next(
|
|
479
|
+
(
|
|
480
|
+
line
|
|
481
|
+
for line in existing_service.splitlines()
|
|
482
|
+
if line.strip().startswith("ExecStart=")
|
|
483
|
+
),
|
|
484
|
+
None,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
if not exec_start_line:
|
|
488
|
+
return True, "Service file is missing ExecStart line"
|
|
489
|
+
|
|
490
|
+
if not any(exec_str in exec_start_line for exec_str in acceptable_execs):
|
|
491
|
+
return (
|
|
492
|
+
True,
|
|
493
|
+
"Service file does not use an acceptable executable "
|
|
494
|
+
f"({ ' or '.join(acceptable_execs) }).",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Check if the PATH environment includes common user-bin locations
|
|
498
|
+
# Look specifically in Environment lines, not the entire file
|
|
499
|
+
environment_lines = [
|
|
500
|
+
line
|
|
501
|
+
for line in existing_service.splitlines()
|
|
502
|
+
if line.strip().startswith("Environment=")
|
|
503
|
+
]
|
|
504
|
+
path_in_environment = any(
|
|
505
|
+
"%h/.local/pipx/venvs/mmrelay/bin" in line or "%h/.local/bin" in line
|
|
506
|
+
for line in environment_lines
|
|
507
|
+
)
|
|
508
|
+
if not path_in_environment:
|
|
509
|
+
return True, "Service PATH does not include common user-bin locations"
|
|
510
|
+
|
|
511
|
+
# Check if the service file has been modified recently
|
|
512
|
+
service_path = get_user_service_path()
|
|
513
|
+
if template_path and os.path.exists(template_path) and os.path.exists(service_path):
|
|
514
|
+
try:
|
|
515
|
+
template_mtime = os.path.getmtime(template_path)
|
|
516
|
+
service_mtime = os.path.getmtime(service_path)
|
|
517
|
+
except OSError:
|
|
518
|
+
return True, "Unable to stat template or service file"
|
|
519
|
+
if template_mtime > service_mtime:
|
|
520
|
+
return True, "Template service file is newer than installed service file"
|
|
521
|
+
|
|
522
|
+
return False, "Service file is up to date"
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def check_loginctl_available():
|
|
526
|
+
"""
|
|
527
|
+
Return True if `loginctl` is available and runnable on PATH.
|
|
528
|
+
|
|
529
|
+
This locates `loginctl` using the PATH (shutil.which) and attempts to run `loginctl --version`.
|
|
530
|
+
Returns False if the executable is not found or if invoking it fails/returns a non-zero exit code.
|
|
531
|
+
"""
|
|
532
|
+
path = shutil.which("loginctl")
|
|
533
|
+
if not path:
|
|
534
|
+
return False
|
|
535
|
+
try:
|
|
536
|
+
result = subprocess.run(
|
|
537
|
+
[path, "--version"], check=False, capture_output=True, text=True
|
|
538
|
+
)
|
|
539
|
+
return result.returncode == 0
|
|
540
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
541
|
+
print(f"Warning: Failed to check loginctl availability: {e}", file=sys.stderr)
|
|
542
|
+
return False
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def check_lingering_enabled():
|
|
546
|
+
"""
|
|
547
|
+
Return whether systemd user "lingering" is enabled for the current user.
|
|
548
|
+
|
|
549
|
+
Checks for a usable `loginctl` executable, queries `loginctl show-user <user> --property=Linger`
|
|
550
|
+
(using the environment variable USER or USERNAME to determine the account), and returns True
|
|
551
|
+
only if the command succeeds and reports `Linger=yes`. If `loginctl` is not found, the command
|
|
552
|
+
fails, or an unexpected error occurs, the function returns False.
|
|
553
|
+
"""
|
|
554
|
+
try:
|
|
555
|
+
import getpass
|
|
556
|
+
|
|
557
|
+
username = (
|
|
558
|
+
os.environ.get("USER") or os.environ.get("USERNAME") or getpass.getuser()
|
|
559
|
+
)
|
|
560
|
+
if not username:
|
|
561
|
+
print(
|
|
562
|
+
"Error checking lingering status: could not determine current user",
|
|
563
|
+
file=sys.stderr,
|
|
564
|
+
)
|
|
565
|
+
return False
|
|
566
|
+
loginctl = shutil.which("loginctl")
|
|
567
|
+
if not loginctl:
|
|
568
|
+
return False
|
|
569
|
+
result = subprocess.run(
|
|
570
|
+
[loginctl, "show-user", username, "--property=Linger"],
|
|
571
|
+
check=False,
|
|
572
|
+
capture_output=True,
|
|
573
|
+
text=True,
|
|
574
|
+
)
|
|
575
|
+
return result.returncode == 0 and "Linger=yes" in result.stdout
|
|
576
|
+
except (OSError, subprocess.SubprocessError, KeyError, RuntimeError) as e:
|
|
577
|
+
print(f"Error checking lingering status: {e}", file=sys.stderr)
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def enable_lingering():
|
|
582
|
+
"""
|
|
583
|
+
Enable systemd "lingering" for the current user by running `sudo loginctl enable-linger <user>`.
|
|
584
|
+
|
|
585
|
+
Determines the username from environment variables or getpass.getuser(), invokes the privileged `loginctl` command to enable lingering, and returns True if the command exits successfully. On failure (non-zero exit, missing username, or subprocess/OSError), returns False and prints an error message to stderr.
|
|
586
|
+
"""
|
|
587
|
+
try:
|
|
588
|
+
import getpass
|
|
589
|
+
|
|
590
|
+
username = (
|
|
591
|
+
os.environ.get("USER") or os.environ.get("USERNAME") or getpass.getuser()
|
|
592
|
+
)
|
|
593
|
+
if not username:
|
|
594
|
+
print(
|
|
595
|
+
"Error enabling lingering: could not determine current user",
|
|
596
|
+
file=sys.stderr,
|
|
597
|
+
)
|
|
598
|
+
return False
|
|
599
|
+
print(f"Enabling lingering for user {username}...")
|
|
600
|
+
result = subprocess.run(
|
|
601
|
+
["sudo", "loginctl", "enable-linger", username],
|
|
602
|
+
check=False,
|
|
603
|
+
capture_output=True,
|
|
604
|
+
text=True,
|
|
605
|
+
)
|
|
606
|
+
if result.returncode == 0:
|
|
607
|
+
print("Lingering enabled successfully")
|
|
608
|
+
return True
|
|
609
|
+
else:
|
|
610
|
+
print(f"Error enabling lingering: {result.stderr}", file=sys.stderr)
|
|
611
|
+
return False
|
|
612
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
613
|
+
print(f"Error enabling lingering: {e}", file=sys.stderr)
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def install_service():
|
|
618
|
+
"""
|
|
619
|
+
Install or update the MMRelay systemd user service, guiding the user through creation, updating, enabling, and starting the service as needed.
|
|
620
|
+
|
|
621
|
+
Prompts the user for confirmation before updating an existing service file, enabling user lingering, enabling the service to start at boot, and starting or restarting the service. Handles user interruptions gracefully and prints a summary of the service status and management commands upon completion.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
bool: True if the installation or update process completes successfully, False otherwise.
|
|
625
|
+
"""
|
|
626
|
+
# Check if service already exists
|
|
627
|
+
existing_service = read_service_file()
|
|
628
|
+
service_path = get_user_service_path()
|
|
629
|
+
|
|
630
|
+
# Check if the service needs to be updated
|
|
631
|
+
update_needed, reason = service_needs_update()
|
|
632
|
+
|
|
633
|
+
# Check if the service is already installed and if it needs updating
|
|
634
|
+
if existing_service:
|
|
635
|
+
print(f"A service file already exists at {service_path}")
|
|
636
|
+
|
|
637
|
+
if update_needed:
|
|
638
|
+
print(f"The service file needs to be updated: {reason}")
|
|
639
|
+
try:
|
|
640
|
+
user_input = input("Do you want to update the service file? (y/n): ")
|
|
641
|
+
if not user_input.lower().startswith("y"):
|
|
642
|
+
print("Service update cancelled.")
|
|
643
|
+
print_service_commands()
|
|
644
|
+
return True
|
|
645
|
+
except (EOFError, KeyboardInterrupt):
|
|
646
|
+
print("\nInput cancelled. Proceeding with default behavior.")
|
|
647
|
+
print("Service update cancelled.")
|
|
648
|
+
print_service_commands()
|
|
649
|
+
return True
|
|
650
|
+
else:
|
|
651
|
+
print(f"No update needed for the service file: {reason}")
|
|
652
|
+
else:
|
|
653
|
+
print(f"No service file found at {service_path}")
|
|
654
|
+
print("A new service file will be created.")
|
|
655
|
+
|
|
656
|
+
# Create or update service file if needed
|
|
657
|
+
if not existing_service or update_needed:
|
|
658
|
+
if not create_service_file():
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
# Reload daemon (continue even if this fails)
|
|
662
|
+
if not reload_daemon():
|
|
663
|
+
print(
|
|
664
|
+
"Warning: Failed to reload systemd daemon. You may need to run 'systemctl --user daemon-reload' manually.",
|
|
665
|
+
file=sys.stderr,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if existing_service:
|
|
669
|
+
print("Service file updated successfully")
|
|
670
|
+
else:
|
|
671
|
+
print("Service file created successfully")
|
|
672
|
+
|
|
673
|
+
# We don't need to validate the config here as it will be validated when the service starts
|
|
674
|
+
|
|
675
|
+
# Check if loginctl is available
|
|
676
|
+
loginctl_available = check_loginctl_available()
|
|
677
|
+
if loginctl_available:
|
|
678
|
+
# Check if user lingering is enabled
|
|
679
|
+
lingering_enabled = check_lingering_enabled()
|
|
680
|
+
if not lingering_enabled:
|
|
681
|
+
print(
|
|
682
|
+
"\nUser lingering is not enabled. This is required for the service to start automatically at boot."
|
|
683
|
+
)
|
|
684
|
+
print(
|
|
685
|
+
"Lingering allows user services to run even when you're not logged in."
|
|
686
|
+
)
|
|
687
|
+
try:
|
|
688
|
+
user_input = input(
|
|
689
|
+
"Do you want to enable lingering for your user? (requires sudo) (y/n): "
|
|
690
|
+
)
|
|
691
|
+
should_enable_lingering = user_input.lower().startswith("y")
|
|
692
|
+
except (EOFError, KeyboardInterrupt):
|
|
693
|
+
print("\nInput cancelled. Skipping lingering setup.")
|
|
694
|
+
should_enable_lingering = False
|
|
695
|
+
|
|
696
|
+
if should_enable_lingering:
|
|
697
|
+
enable_lingering()
|
|
698
|
+
|
|
699
|
+
# Check if the service is already enabled
|
|
700
|
+
service_enabled = is_service_enabled()
|
|
701
|
+
if service_enabled:
|
|
702
|
+
print("The service is already enabled to start at boot.")
|
|
703
|
+
else:
|
|
704
|
+
print("The service is not currently enabled to start at boot.")
|
|
705
|
+
try:
|
|
706
|
+
user_input = input(
|
|
707
|
+
"Do you want to enable the service to start at boot? (y/n): "
|
|
708
|
+
)
|
|
709
|
+
enable_service = user_input.lower().startswith("y")
|
|
710
|
+
except (EOFError, KeyboardInterrupt):
|
|
711
|
+
print("\nInput cancelled. Skipping service enable.")
|
|
712
|
+
enable_service = False
|
|
713
|
+
|
|
714
|
+
if enable_service:
|
|
715
|
+
try:
|
|
716
|
+
subprocess.run(
|
|
717
|
+
[SYSTEMCTL, "--user", "enable", "mmrelay.service"],
|
|
718
|
+
check=True,
|
|
719
|
+
)
|
|
720
|
+
print("Service enabled successfully")
|
|
721
|
+
service_enabled = True
|
|
722
|
+
except subprocess.CalledProcessError as e:
|
|
723
|
+
print(f"Error enabling service: {e}", file=sys.stderr)
|
|
724
|
+
except OSError as e:
|
|
725
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
726
|
+
|
|
727
|
+
# Check if the service is already running
|
|
728
|
+
service_active = is_service_active()
|
|
729
|
+
if service_active:
|
|
730
|
+
print("The service is already running.")
|
|
731
|
+
try:
|
|
732
|
+
user_input = input("Do you want to restart the service? (y/n): ")
|
|
733
|
+
restart_service = user_input.lower().startswith("y")
|
|
734
|
+
except (EOFError, KeyboardInterrupt):
|
|
735
|
+
print("\nInput cancelled. Skipping service restart.")
|
|
736
|
+
restart_service = False
|
|
737
|
+
|
|
738
|
+
if restart_service:
|
|
739
|
+
try:
|
|
740
|
+
subprocess.run(
|
|
741
|
+
[SYSTEMCTL, "--user", "restart", "mmrelay.service"],
|
|
742
|
+
check=True,
|
|
743
|
+
)
|
|
744
|
+
print("Service restarted successfully")
|
|
745
|
+
# Wait for the service to restart
|
|
746
|
+
wait_for_service_start()
|
|
747
|
+
# Show service status
|
|
748
|
+
show_service_status()
|
|
749
|
+
except subprocess.CalledProcessError as e:
|
|
750
|
+
print(f"Error restarting service: {e}", file=sys.stderr)
|
|
751
|
+
except OSError as e:
|
|
752
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
753
|
+
else:
|
|
754
|
+
print("The service is not currently running.")
|
|
755
|
+
try:
|
|
756
|
+
user_input = input("Do you want to start the service now? (y/n): ")
|
|
757
|
+
start_now = user_input.lower().startswith("y")
|
|
758
|
+
except (EOFError, KeyboardInterrupt):
|
|
759
|
+
print("\nInput cancelled. Skipping service start.")
|
|
760
|
+
start_now = False
|
|
761
|
+
|
|
762
|
+
if start_now:
|
|
763
|
+
if start_service():
|
|
764
|
+
# Wait for the service to start
|
|
765
|
+
wait_for_service_start()
|
|
766
|
+
# Show service status
|
|
767
|
+
show_service_status()
|
|
768
|
+
print("Service started successfully")
|
|
769
|
+
else:
|
|
770
|
+
print("\nWarning: Failed to start the service. Please check the logs.")
|
|
771
|
+
|
|
772
|
+
# Print a summary of the service status
|
|
773
|
+
print("\nService Status Summary:")
|
|
774
|
+
print(f" Service File: {service_path}")
|
|
775
|
+
print(f" Enabled at Boot: {'Yes' if service_enabled else 'No'}")
|
|
776
|
+
if loginctl_available:
|
|
777
|
+
print(f" User Lingering: {'Yes' if check_lingering_enabled() else 'No'}")
|
|
778
|
+
print(f" Currently Running: {'Yes' if is_service_active() else 'No'}")
|
|
779
|
+
print("\nService Management Commands:")
|
|
780
|
+
print_service_commands()
|
|
781
|
+
|
|
782
|
+
return True
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def start_service():
|
|
786
|
+
"""
|
|
787
|
+
Start the user-level systemd service for MMRelay.
|
|
788
|
+
|
|
789
|
+
Attempts to run `SYSTEMCTL --user start mmrelay.service`. Returns True if the command exits successfully.
|
|
790
|
+
On failure the function prints an error message to stderr and returns False.
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
bool: True when the service was started successfully; False on error.
|
|
794
|
+
"""
|
|
795
|
+
try:
|
|
796
|
+
subprocess.run([SYSTEMCTL, "--user", "start", "mmrelay.service"], check=True)
|
|
797
|
+
return True
|
|
798
|
+
except subprocess.CalledProcessError as e:
|
|
799
|
+
print(f"Error starting service: {e}", file=sys.stderr)
|
|
800
|
+
return False
|
|
801
|
+
except OSError as e:
|
|
802
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
803
|
+
return False
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def show_service_status():
|
|
807
|
+
"""
|
|
808
|
+
Show the systemd user status for the mmrelay.service and print it to stdout.
|
|
809
|
+
|
|
810
|
+
Runs `SYSTEMCTL --user status mmrelay.service`, prints the command's stdout when successful,
|
|
811
|
+
and returns True. On failure (command error or OSError) prints an error message and returns False.
|
|
812
|
+
"""
|
|
813
|
+
try:
|
|
814
|
+
result = subprocess.run(
|
|
815
|
+
[SYSTEMCTL, "--user", "status", "mmrelay.service"],
|
|
816
|
+
check=True,
|
|
817
|
+
capture_output=True,
|
|
818
|
+
text=True,
|
|
819
|
+
)
|
|
820
|
+
print("\nService Status:")
|
|
821
|
+
print(result.stdout)
|
|
822
|
+
return True
|
|
823
|
+
except subprocess.CalledProcessError as e:
|
|
824
|
+
print(f"Could not get service status: {e}", file=sys.stderr)
|
|
825
|
+
return False
|
|
826
|
+
except OSError as e:
|
|
827
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
828
|
+
return False
|