pymidi-controller 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {pymidi_controller-0.3.1/pymidi_controller.egg-info → pymidi_controller-0.4.0}/PKG-INFO +38 -13
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/README.md +35 -12
- pymidi_controller-0.4.0/pymidi_controller/__main__.py +4 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/data/systemd/pymidi-controller.system.service +3 -2
- pymidi_controller-0.4.0/pymidi_controller/utils/service_manager.py +252 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0/pymidi_controller.egg-info}/PKG-INFO +38 -13
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/SOURCES.txt +3 -1
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/requires.txt +3 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pyproject.toml +4 -1
- pymidi_controller-0.4.0/tests/test_service_unit.py +181 -0
- pymidi_controller-0.3.1/pymidi_controller/utils/service_manager.py +0 -140
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/LICENSE +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/MANIFEST.in +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/__init__.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/__init__.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/elgato.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/elgato_discovery.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/hue.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/hue_discovery.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/cli.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/config.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/core.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/data/default_config.yaml +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/data/systemd/pymidi-controller.service +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/__init__.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/color_cycle.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/config_manager.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/function_manager.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/midi_utils.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/state_manager.py +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/dependency_links.txt +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/entry_points.txt +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/top_level.txt +0 -0
- {pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymidi-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Daemon that maps MIDI events to system actions (Elgato, Hue, or custom functions).
|
|
5
5
|
Author-email: Alex Sandilands <sandilands.alex@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Requires-Dist: python-rtmidi>=1.4.10
|
|
|
20
20
|
Requires-Dist: requests>=2.0
|
|
21
21
|
Requires-Dist: zeroconf>=0.40
|
|
22
22
|
Requires-Dist: PyYAML>=6.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# 🎹 PyMIDI Controller
|
|
@@ -157,15 +159,17 @@ Use `pymidi <group> --help` for details on each context.
|
|
|
157
159
|
|
|
158
160
|
### Service
|
|
159
161
|
|
|
160
|
-
| Command
|
|
161
|
-
|
|
|
162
|
-
| `pymidi service
|
|
163
|
-
| `pymidi service
|
|
164
|
-
| `pymidi service
|
|
165
|
-
| `pymidi service
|
|
166
|
-
| `pymidi service stop`
|
|
167
|
-
| `pymidi service enable`
|
|
168
|
-
| `pymidi service log`
|
|
162
|
+
| Command | Description |
|
|
163
|
+
| -------------------------------------------- | ----------------------------------- |
|
|
164
|
+
| `pymidi service --user install` | Install & start as a user service |
|
|
165
|
+
| `pymidi service --system install` | Install & start system-wide service |
|
|
166
|
+
| `pymidi service --user uninstall` | Stop & remove user service |
|
|
167
|
+
| `pymidi service --system uninstall` | Stop & remove system service |
|
|
168
|
+
| `pymidi service --user\|--system stop` | Stop the running service |
|
|
169
|
+
| `pymidi service --user\|--system enable` | Enable service |
|
|
170
|
+
| `pymidi service --user\|--system log` | Tail service logs |
|
|
171
|
+
|
|
172
|
+
Every service subcommand requires a scope flag before the subcommand. Choose either `--user` or `--system` where the table shows `--user|--system`, for example `pymidi service --user stop`. `--system install` and `--system uninstall` must be run through `sudo`; see [System-wide install](#system-wide-install-headless).
|
|
169
173
|
|
|
170
174
|
### Hue Commands
|
|
171
175
|
|
|
@@ -303,21 +307,42 @@ bindings:
|
|
|
303
307
|
To run automatically at login:
|
|
304
308
|
|
|
305
309
|
```bash
|
|
306
|
-
pymidi service
|
|
310
|
+
pymidi service --user install
|
|
307
311
|
```
|
|
308
312
|
|
|
309
313
|
The user service starts with your graphical session (`graphical-session.target`), after the desktop compositor is up. It does not pull that target in itself, so it can't make other autostart apps start early.
|
|
310
314
|
|
|
311
315
|
If your window manager doesn't start `graphical-session.target` (Plasma and GNOME do; some minimal setups such as i3, sway or Hyprland don't unless configured to), the service won't start at login. Start the target from your session startup, e.g. `systemctl --user start graphical-session.target`.
|
|
312
316
|
|
|
313
|
-
> **Upgrading from 0.3.0 or earlier?** Rerun `pymidi service
|
|
317
|
+
> **Upgrading from 0.3.0 or earlier?** Rerun `pymidi service --user install` (or the `--system` command below for a system-wide install) once. It replaces the old unit and removes its stale `default.target` link. `pymidi service --user enable` alone doesn't refresh the unit file.
|
|
314
318
|
|
|
315
319
|
Logs:
|
|
316
320
|
|
|
317
321
|
```bash
|
|
318
|
-
pymidi service log
|
|
322
|
+
pymidi service --user log
|
|
319
323
|
```
|
|
320
324
|
|
|
325
|
+
### System-wide install (headless)
|
|
326
|
+
|
|
327
|
+
The user service only starts with a graphical session. On a headless machine (a Raspberry Pi or server driving Hue or Elgato lights, say), install it system-wide so it starts at boot:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
sudo "$(command -v pymidi)" service --system install
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Start it through `sudo` from your own account: the service runs as the user named in `SUDO_USER`, which `sudo` sets (it also survives `sudo -i` and `sudo -s`). A root login, `su` or `doas` doesn't set it, so the command refuses to install rather than guess. `sudo` resets `PATH`, so pass the full path to `pymidi` as shown. The service reads that user's config from `~/.config/pymidi-controller`, and joins the `audio` group (when the system has one) so it can reach the ALSA sequencer without a login session. The unit records the Python interpreter that ran the install, so reinstall after moving or recreating a venv.
|
|
334
|
+
|
|
335
|
+
A system service has no desktop session (no `DISPLAY`, `WAYLAND_DISPLAY` or session D-Bus), so bindings that launch desktop programs or use `notify-send` won't work. Use the `--user` service for those; Hue and Elgato actions over the network are fine.
|
|
336
|
+
|
|
337
|
+
Logs and removal:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
pymidi service --system log
|
|
341
|
+
sudo "$(command -v pymidi)" service --system uninstall
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
> **Already have a `--system` install from 0.3.x?** Rerun the install command above once. The old unit ran as root and looked for `pymidi-controller` on a `PATH` that never included `~/.local/bin`, so it was unlikely to have worked; rerunning the install replaces the unit and restarts the service.
|
|
345
|
+
|
|
321
346
|
---
|
|
322
347
|
|
|
323
348
|
## 📜 License
|
|
@@ -133,15 +133,17 @@ Use `pymidi <group> --help` for details on each context.
|
|
|
133
133
|
|
|
134
134
|
### Service
|
|
135
135
|
|
|
136
|
-
| Command
|
|
137
|
-
|
|
|
138
|
-
| `pymidi service
|
|
139
|
-
| `pymidi service
|
|
140
|
-
| `pymidi service
|
|
141
|
-
| `pymidi service
|
|
142
|
-
| `pymidi service stop`
|
|
143
|
-
| `pymidi service enable`
|
|
144
|
-
| `pymidi service log`
|
|
136
|
+
| Command | Description |
|
|
137
|
+
| -------------------------------------------- | ----------------------------------- |
|
|
138
|
+
| `pymidi service --user install` | Install & start as a user service |
|
|
139
|
+
| `pymidi service --system install` | Install & start system-wide service |
|
|
140
|
+
| `pymidi service --user uninstall` | Stop & remove user service |
|
|
141
|
+
| `pymidi service --system uninstall` | Stop & remove system service |
|
|
142
|
+
| `pymidi service --user\|--system stop` | Stop the running service |
|
|
143
|
+
| `pymidi service --user\|--system enable` | Enable service |
|
|
144
|
+
| `pymidi service --user\|--system log` | Tail service logs |
|
|
145
|
+
|
|
146
|
+
Every service subcommand requires a scope flag before the subcommand. Choose either `--user` or `--system` where the table shows `--user|--system`, for example `pymidi service --user stop`. `--system install` and `--system uninstall` must be run through `sudo`; see [System-wide install](#system-wide-install-headless).
|
|
145
147
|
|
|
146
148
|
### Hue Commands
|
|
147
149
|
|
|
@@ -279,21 +281,42 @@ bindings:
|
|
|
279
281
|
To run automatically at login:
|
|
280
282
|
|
|
281
283
|
```bash
|
|
282
|
-
pymidi service
|
|
284
|
+
pymidi service --user install
|
|
283
285
|
```
|
|
284
286
|
|
|
285
287
|
The user service starts with your graphical session (`graphical-session.target`), after the desktop compositor is up. It does not pull that target in itself, so it can't make other autostart apps start early.
|
|
286
288
|
|
|
287
289
|
If your window manager doesn't start `graphical-session.target` (Plasma and GNOME do; some minimal setups such as i3, sway or Hyprland don't unless configured to), the service won't start at login. Start the target from your session startup, e.g. `systemctl --user start graphical-session.target`.
|
|
288
290
|
|
|
289
|
-
> **Upgrading from 0.3.0 or earlier?** Rerun `pymidi service
|
|
291
|
+
> **Upgrading from 0.3.0 or earlier?** Rerun `pymidi service --user install` (or the `--system` command below for a system-wide install) once. It replaces the old unit and removes its stale `default.target` link. `pymidi service --user enable` alone doesn't refresh the unit file.
|
|
290
292
|
|
|
291
293
|
Logs:
|
|
292
294
|
|
|
293
295
|
```bash
|
|
294
|
-
pymidi service log
|
|
296
|
+
pymidi service --user log
|
|
295
297
|
```
|
|
296
298
|
|
|
299
|
+
### System-wide install (headless)
|
|
300
|
+
|
|
301
|
+
The user service only starts with a graphical session. On a headless machine (a Raspberry Pi or server driving Hue or Elgato lights, say), install it system-wide so it starts at boot:
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
sudo "$(command -v pymidi)" service --system install
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Start it through `sudo` from your own account: the service runs as the user named in `SUDO_USER`, which `sudo` sets (it also survives `sudo -i` and `sudo -s`). A root login, `su` or `doas` doesn't set it, so the command refuses to install rather than guess. `sudo` resets `PATH`, so pass the full path to `pymidi` as shown. The service reads that user's config from `~/.config/pymidi-controller`, and joins the `audio` group (when the system has one) so it can reach the ALSA sequencer without a login session. The unit records the Python interpreter that ran the install, so reinstall after moving or recreating a venv.
|
|
308
|
+
|
|
309
|
+
A system service has no desktop session (no `DISPLAY`, `WAYLAND_DISPLAY` or session D-Bus), so bindings that launch desktop programs or use `notify-send` won't work. Use the `--user` service for those; Hue and Elgato actions over the network are fine.
|
|
310
|
+
|
|
311
|
+
Logs and removal:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
pymidi service --system log
|
|
315
|
+
sudo "$(command -v pymidi)" service --system uninstall
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
> **Already have a `--system` install from 0.3.x?** Rerun the install command above once. The old unit ran as root and looked for `pymidi-controller` on a `PATH` that never included `~/.local/bin`, so it was unlikely to have worked; rerunning the install replaces the unit and restarts the service.
|
|
319
|
+
|
|
297
320
|
---
|
|
298
321
|
|
|
299
322
|
## 📜 License
|
|
@@ -5,8 +5,9 @@ Wants=sound.target
|
|
|
5
5
|
|
|
6
6
|
[Service]
|
|
7
7
|
Type=simple
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
User=@USER@
|
|
9
|
+
@SUPPLEMENTARY_GROUPS@
|
|
10
|
+
ExecStart=@EXEC_START@
|
|
10
11
|
Restart=always
|
|
11
12
|
RestartSec=5
|
|
12
13
|
StandardOutput=journal
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import grp
|
|
2
|
+
import os
|
|
3
|
+
import pwd
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from enum import Enum, auto
|
|
11
|
+
|
|
12
|
+
# -------------------------------------------------------------------
|
|
13
|
+
# Service Installation & Management with Scoped Control
|
|
14
|
+
# -------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
class ServiceScope(Enum):
|
|
17
|
+
USER = auto()
|
|
18
|
+
SYSTEM = auto()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SYSTEM_UNIT_DIR = Path("/etc/systemd/system")
|
|
22
|
+
|
|
23
|
+
# Conservative, but allows realm-style names (alice@corp.com); excludes % and whitespace
|
|
24
|
+
_USERNAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.@-]*\$?")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _exec_start_arg(path: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Escape a path for use as the executable in a unit's ExecStart=.
|
|
30
|
+
|
|
31
|
+
systemd rejects quotes and backslashes in the executable word whatever the
|
|
32
|
+
quoting, so those paths are refused. A literal $ is fine there ($$ is only
|
|
33
|
+
collapsed in arguments).
|
|
34
|
+
"""
|
|
35
|
+
bad = [c for c in path if ord(c) < 32 or ord(c) == 127 or c in "'\"\\"]
|
|
36
|
+
if bad:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Can't write a systemd unit for the interpreter at {path!r}: its path contains "
|
|
39
|
+
"a control, quote or backslash character. Install pymidi from a path without those."
|
|
40
|
+
)
|
|
41
|
+
path = path.replace("%", "%%") # % is a unit specifier
|
|
42
|
+
if any(c.isspace() for c in path):
|
|
43
|
+
path = '"' + path + '"'
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_system_unit(user: str, python: str, audio_group: bool = True) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Render the system-wide unit for ``user``, running ``python -m pymidi_controller``.
|
|
50
|
+
|
|
51
|
+
The absolute interpreter path means the unit doesn't depend on PATH, which the
|
|
52
|
+
system manager doesn't set up for a user (pipx, venv and pip --user installs).
|
|
53
|
+
``audio_group`` adds SupplementaryGroups=audio; leave it off where the group
|
|
54
|
+
doesn't exist, as systemd refuses to start a unit naming a missing group.
|
|
55
|
+
"""
|
|
56
|
+
if not _USERNAME_RE.fullmatch(user):
|
|
57
|
+
raise ValueError(f"Unsupported user name for a systemd unit: {user!r}")
|
|
58
|
+
template = (resources.files("pymidi_controller") / "data/systemd/pymidi-controller.system.service").read_text(encoding="utf-8")
|
|
59
|
+
exec_start = f"{_exec_start_arg(python)} -m pymidi_controller run --mode blocking"
|
|
60
|
+
groups = "SupplementaryGroups=audio\n" if audio_group else ""
|
|
61
|
+
return (
|
|
62
|
+
template.replace("@USER@", user)
|
|
63
|
+
.replace("@SUPPLEMENTARY_GROUPS@\n", groups)
|
|
64
|
+
.replace("@EXEC_START@", exec_start)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _require_root(action: str):
|
|
69
|
+
"""Exit with a clear message instead of a PermissionError traceback."""
|
|
70
|
+
if os.geteuid() != 0:
|
|
71
|
+
sys.exit(
|
|
72
|
+
f"⚠️ `service --system {action}` must run as root. "
|
|
73
|
+
'Use: sudo "$(command -v pymidi)" service --system ' + action
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _invoking_user() -> str:
|
|
78
|
+
"""The user to run the system service as: whoever invoked sudo."""
|
|
79
|
+
user = os.environ.get("SUDO_USER")
|
|
80
|
+
if not user or user == "root":
|
|
81
|
+
sys.exit(
|
|
82
|
+
"⚠️ Can't tell which user the service should run as. Run this from your own "
|
|
83
|
+
'account with sudo, e.g. sudo "$(command -v pymidi)" service --system install '
|
|
84
|
+
"(running directly as root isn't supported)."
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
pwd.getpwnam(user)
|
|
88
|
+
except KeyError:
|
|
89
|
+
sys.exit(f"⚠️ SUDO_USER {user!r} isn't a known user.")
|
|
90
|
+
return user
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _audio_group_exists() -> bool:
|
|
94
|
+
try:
|
|
95
|
+
grp.getgrnam("audio")
|
|
96
|
+
except KeyError:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _warn_audio_group(user: str):
|
|
102
|
+
try:
|
|
103
|
+
audio = grp.getgrnam("audio")
|
|
104
|
+
except KeyError:
|
|
105
|
+
print("⚠️ No 'audio' group on this system, so the unit omits SupplementaryGroups=audio. The service may not be able to open /dev/snd/seq.")
|
|
106
|
+
return
|
|
107
|
+
gids = os.getgrouplist(user, pwd.getpwnam(user).pw_gid)
|
|
108
|
+
if audio.gr_gid not in gids:
|
|
109
|
+
print(f"⚠️ {user} isn't in the 'audio' group. The service adds it itself, but `pymidi` run from a shell may not reach /dev/snd/seq (sudo usermod -aG audio {user}).")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install(scope: ServiceScope):
|
|
113
|
+
"""
|
|
114
|
+
Install and enable the systemd service unit.
|
|
115
|
+
|
|
116
|
+
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
117
|
+
"""
|
|
118
|
+
unit_text = None
|
|
119
|
+
if scope == ServiceScope.USER:
|
|
120
|
+
target_dir = Path.home() / ".config/systemd/user"
|
|
121
|
+
ctl = ["systemctl", "--user"]
|
|
122
|
+
src_name = "pymidi-controller.service"
|
|
123
|
+
elif scope == ServiceScope.SYSTEM:
|
|
124
|
+
_require_root("install")
|
|
125
|
+
user = _invoking_user()
|
|
126
|
+
target_dir = SYSTEM_UNIT_DIR
|
|
127
|
+
ctl = ["systemctl"]
|
|
128
|
+
try:
|
|
129
|
+
unit_text = render_system_unit(user, sys.executable, audio_group=_audio_group_exists())
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
sys.exit(f"⚠️ {e}")
|
|
132
|
+
else:
|
|
133
|
+
print("⚠️ Invalid scope for install.")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
dst = target_dir / "pymidi-controller.service"
|
|
138
|
+
if unit_text is None:
|
|
139
|
+
src = resources.files("pymidi_controller") / "data/systemd" / src_name
|
|
140
|
+
shutil.copy(src, dst)
|
|
141
|
+
else:
|
|
142
|
+
dst.write_text(unit_text, encoding="utf-8")
|
|
143
|
+
print(f"✔️ Installed service unit to {dst}")
|
|
144
|
+
if unit_text is not None:
|
|
145
|
+
print(f" User={user}")
|
|
146
|
+
print(f" ExecStart={sys.executable} -m pymidi_controller run --mode blocking")
|
|
147
|
+
_warn_audio_group(user)
|
|
148
|
+
|
|
149
|
+
subprocess.run(ctl + ["daemon-reload"], check=True)
|
|
150
|
+
# Drop links from older units (e.g. default.target.wants) before enabling
|
|
151
|
+
subprocess.run(ctl + ["disable", "pymidi-controller.service"], check=False)
|
|
152
|
+
subprocess.run(ctl + ["enable", "pymidi-controller.service"], check=True)
|
|
153
|
+
# restart (not enable --now) so a reinstall replaces an already-running old process
|
|
154
|
+
subprocess.run(ctl + ["restart", "pymidi-controller.service"], check=True)
|
|
155
|
+
print("✅ Service enabled and started.")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def uninstall(scope: ServiceScope):
|
|
159
|
+
"""
|
|
160
|
+
Disable and remove the systemd service unit.
|
|
161
|
+
|
|
162
|
+
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
163
|
+
"""
|
|
164
|
+
if scope == ServiceScope.USER:
|
|
165
|
+
target_dir = Path.home() / ".config/systemd/user"
|
|
166
|
+
ctl = ["systemctl", "--user"]
|
|
167
|
+
elif scope == ServiceScope.SYSTEM:
|
|
168
|
+
_require_root("uninstall")
|
|
169
|
+
target_dir = SYSTEM_UNIT_DIR
|
|
170
|
+
ctl = ["systemctl"]
|
|
171
|
+
else:
|
|
172
|
+
print("⚠️ Invalid scope for uninstall.")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
service_name = "pymidi-controller.service"
|
|
176
|
+
dst = target_dir / service_name
|
|
177
|
+
|
|
178
|
+
subprocess.run(ctl + ["stop", service_name], check=False)
|
|
179
|
+
subprocess.run(ctl + ["disable", service_name], check=False)
|
|
180
|
+
|
|
181
|
+
if dst.exists():
|
|
182
|
+
dst.unlink()
|
|
183
|
+
print(f"🗑️ Removed service unit at {dst}")
|
|
184
|
+
else:
|
|
185
|
+
print(f"⚠️ No service unit found at {dst}")
|
|
186
|
+
|
|
187
|
+
subprocess.run(ctl + ["daemon-reload"], check=True)
|
|
188
|
+
print("✅ Service uninstalled.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def stop(scope: ServiceScope = ServiceScope.USER):
|
|
192
|
+
"""
|
|
193
|
+
Stop the systemd service.
|
|
194
|
+
|
|
195
|
+
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
196
|
+
"""
|
|
197
|
+
cmd = ["systemctl"]
|
|
198
|
+
if scope == ServiceScope.USER:
|
|
199
|
+
cmd.insert(1, "--user")
|
|
200
|
+
cmd.append("stop")
|
|
201
|
+
cmd.append("pymidi-controller.service")
|
|
202
|
+
subprocess.run(cmd, check=True)
|
|
203
|
+
print("✅ Service stopped.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def enable(scope: ServiceScope = ServiceScope.USER):
|
|
207
|
+
"""
|
|
208
|
+
Enable the systemd service.
|
|
209
|
+
|
|
210
|
+
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
211
|
+
"""
|
|
212
|
+
cmd = ["systemctl"]
|
|
213
|
+
if scope == ServiceScope.USER:
|
|
214
|
+
cmd.insert(1, "--user")
|
|
215
|
+
cmd.extend(["enable", "pymidi-controller.service"])
|
|
216
|
+
subprocess.run(cmd, check=True)
|
|
217
|
+
print("✅ Service enabled.")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def log(scope: ServiceScope = ServiceScope.USER):
|
|
221
|
+
"""
|
|
222
|
+
Show the systemd service logs.
|
|
223
|
+
|
|
224
|
+
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
225
|
+
"""
|
|
226
|
+
cmd = ["journalctl"]
|
|
227
|
+
if scope == ServiceScope.USER:
|
|
228
|
+
cmd.extend(["--user"])
|
|
229
|
+
cmd.extend(["-u", "pymidi-controller.service", "--no-pager"])
|
|
230
|
+
subprocess.run(cmd, check=True)
|
|
231
|
+
print("✅ Service logs displayed.")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def dispatch(action: str, scope: ServiceScope = ServiceScope.USER):
|
|
235
|
+
"""
|
|
236
|
+
Dispatcher for service subcommands.
|
|
237
|
+
|
|
238
|
+
:param action: one of 'install', 'uninstall', 'stop', 'enable', 'log'
|
|
239
|
+
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
240
|
+
"""
|
|
241
|
+
if action == "install":
|
|
242
|
+
install(scope)
|
|
243
|
+
elif action == "uninstall":
|
|
244
|
+
uninstall(scope)
|
|
245
|
+
elif action == "stop":
|
|
246
|
+
stop(scope)
|
|
247
|
+
elif action == "enable":
|
|
248
|
+
enable(scope)
|
|
249
|
+
elif action == "log":
|
|
250
|
+
log(scope)
|
|
251
|
+
else:
|
|
252
|
+
print(f"⚠️ Unknown service action: {action}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymidi-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Daemon that maps MIDI events to system actions (Elgato, Hue, or custom functions).
|
|
5
5
|
Author-email: Alex Sandilands <sandilands.alex@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Requires-Dist: python-rtmidi>=1.4.10
|
|
|
20
20
|
Requires-Dist: requests>=2.0
|
|
21
21
|
Requires-Dist: zeroconf>=0.40
|
|
22
22
|
Requires-Dist: PyYAML>=6.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# 🎹 PyMIDI Controller
|
|
@@ -157,15 +159,17 @@ Use `pymidi <group> --help` for details on each context.
|
|
|
157
159
|
|
|
158
160
|
### Service
|
|
159
161
|
|
|
160
|
-
| Command
|
|
161
|
-
|
|
|
162
|
-
| `pymidi service
|
|
163
|
-
| `pymidi service
|
|
164
|
-
| `pymidi service
|
|
165
|
-
| `pymidi service
|
|
166
|
-
| `pymidi service stop`
|
|
167
|
-
| `pymidi service enable`
|
|
168
|
-
| `pymidi service log`
|
|
162
|
+
| Command | Description |
|
|
163
|
+
| -------------------------------------------- | ----------------------------------- |
|
|
164
|
+
| `pymidi service --user install` | Install & start as a user service |
|
|
165
|
+
| `pymidi service --system install` | Install & start system-wide service |
|
|
166
|
+
| `pymidi service --user uninstall` | Stop & remove user service |
|
|
167
|
+
| `pymidi service --system uninstall` | Stop & remove system service |
|
|
168
|
+
| `pymidi service --user\|--system stop` | Stop the running service |
|
|
169
|
+
| `pymidi service --user\|--system enable` | Enable service |
|
|
170
|
+
| `pymidi service --user\|--system log` | Tail service logs |
|
|
171
|
+
|
|
172
|
+
Every service subcommand requires a scope flag before the subcommand. Choose either `--user` or `--system` where the table shows `--user|--system`, for example `pymidi service --user stop`. `--system install` and `--system uninstall` must be run through `sudo`; see [System-wide install](#system-wide-install-headless).
|
|
169
173
|
|
|
170
174
|
### Hue Commands
|
|
171
175
|
|
|
@@ -303,21 +307,42 @@ bindings:
|
|
|
303
307
|
To run automatically at login:
|
|
304
308
|
|
|
305
309
|
```bash
|
|
306
|
-
pymidi service
|
|
310
|
+
pymidi service --user install
|
|
307
311
|
```
|
|
308
312
|
|
|
309
313
|
The user service starts with your graphical session (`graphical-session.target`), after the desktop compositor is up. It does not pull that target in itself, so it can't make other autostart apps start early.
|
|
310
314
|
|
|
311
315
|
If your window manager doesn't start `graphical-session.target` (Plasma and GNOME do; some minimal setups such as i3, sway or Hyprland don't unless configured to), the service won't start at login. Start the target from your session startup, e.g. `systemctl --user start graphical-session.target`.
|
|
312
316
|
|
|
313
|
-
> **Upgrading from 0.3.0 or earlier?** Rerun `pymidi service
|
|
317
|
+
> **Upgrading from 0.3.0 or earlier?** Rerun `pymidi service --user install` (or the `--system` command below for a system-wide install) once. It replaces the old unit and removes its stale `default.target` link. `pymidi service --user enable` alone doesn't refresh the unit file.
|
|
314
318
|
|
|
315
319
|
Logs:
|
|
316
320
|
|
|
317
321
|
```bash
|
|
318
|
-
pymidi service log
|
|
322
|
+
pymidi service --user log
|
|
319
323
|
```
|
|
320
324
|
|
|
325
|
+
### System-wide install (headless)
|
|
326
|
+
|
|
327
|
+
The user service only starts with a graphical session. On a headless machine (a Raspberry Pi or server driving Hue or Elgato lights, say), install it system-wide so it starts at boot:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
sudo "$(command -v pymidi)" service --system install
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Start it through `sudo` from your own account: the service runs as the user named in `SUDO_USER`, which `sudo` sets (it also survives `sudo -i` and `sudo -s`). A root login, `su` or `doas` doesn't set it, so the command refuses to install rather than guess. `sudo` resets `PATH`, so pass the full path to `pymidi` as shown. The service reads that user's config from `~/.config/pymidi-controller`, and joins the `audio` group (when the system has one) so it can reach the ALSA sequencer without a login session. The unit records the Python interpreter that ran the install, so reinstall after moving or recreating a venv.
|
|
334
|
+
|
|
335
|
+
A system service has no desktop session (no `DISPLAY`, `WAYLAND_DISPLAY` or session D-Bus), so bindings that launch desktop programs or use `notify-send` won't work. Use the `--user` service for those; Hue and Elgato actions over the network are fine.
|
|
336
|
+
|
|
337
|
+
Logs and removal:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
pymidi service --system log
|
|
341
|
+
sudo "$(command -v pymidi)" service --system uninstall
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
> **Already have a `--system` install from 0.3.x?** Rerun the install command above once. The old unit ran as root and looked for `pymidi-controller` on a `PATH` that never included `~/.local/bin`, so it was unlikely to have worked; rerunning the install replaces the unit and restarts the service.
|
|
345
|
+
|
|
321
346
|
---
|
|
322
347
|
|
|
323
348
|
## 📜 License
|
|
@@ -3,6 +3,7 @@ MANIFEST.in
|
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
5
|
pymidi_controller/__init__.py
|
|
6
|
+
pymidi_controller/__main__.py
|
|
6
7
|
pymidi_controller/cli.py
|
|
7
8
|
pymidi_controller/config.py
|
|
8
9
|
pymidi_controller/core.py
|
|
@@ -26,4 +27,5 @@ pymidi_controller/utils/config_manager.py
|
|
|
26
27
|
pymidi_controller/utils/function_manager.py
|
|
27
28
|
pymidi_controller/utils/midi_utils.py
|
|
28
29
|
pymidi_controller/utils/service_manager.py
|
|
29
|
-
pymidi_controller/utils/state_manager.py
|
|
30
|
+
pymidi_controller/utils/state_manager.py
|
|
31
|
+
tests/test_service_unit.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymidi-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Daemon that maps MIDI events to system actions (Elgato, Hue, or custom functions)."
|
|
5
5
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
6
|
license = "MIT"
|
|
@@ -26,6 +26,9 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Utilities"
|
|
27
27
|
]
|
|
28
28
|
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest"]
|
|
31
|
+
|
|
29
32
|
[project.scripts]
|
|
30
33
|
pymidi-controller = "pymidi_controller.cli:main"
|
|
31
34
|
pymidi = "pymidi_controller.cli:main"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from pymidi_controller.utils import service_manager
|
|
9
|
+
from pymidi_controller.utils.service_manager import ServiceScope, render_system_unit
|
|
10
|
+
|
|
11
|
+
PY = "/opt/venv/bin/python"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _exec_start_line(unit):
|
|
15
|
+
return next(l for l in unit.splitlines() if l.startswith("ExecStart="))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_unit_runs_as_given_user_with_audio_group():
|
|
19
|
+
unit = render_system_unit("alice", PY)
|
|
20
|
+
assert "\nUser=alice\n" in unit
|
|
21
|
+
assert "\nSupplementaryGroups=audio\n" in unit
|
|
22
|
+
assert "@" not in unit
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_unit_omits_audio_group_when_missing():
|
|
26
|
+
unit = render_system_unit("alice", PY, audio_group=False)
|
|
27
|
+
assert "SupplementaryGroups" not in unit
|
|
28
|
+
assert "@SUPPLEMENTARY_GROUPS@" not in unit
|
|
29
|
+
assert "\nUser=alice\nExecStart=" in unit
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_exec_start_uses_absolute_interpreter():
|
|
33
|
+
unit = render_system_unit("alice", PY)
|
|
34
|
+
assert _exec_start_line(unit) == f"ExecStart={PY} -m pymidi_controller run --mode blocking"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_no_home_specifier_or_path_override():
|
|
38
|
+
# %h is /root in the system manager, so it must not appear
|
|
39
|
+
unit = render_system_unit("alice", PY)
|
|
40
|
+
assert "%h" not in unit
|
|
41
|
+
assert "Environment=PATH" not in unit
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize(
|
|
45
|
+
"path, expected",
|
|
46
|
+
[
|
|
47
|
+
("/home/a b/python", '"/home/a b/python"'),
|
|
48
|
+
("/home/50%/python", "/home/50%%/python"),
|
|
49
|
+
("/home/a b/50%/python", '"/home/a b/50%%/python"'),
|
|
50
|
+
("/home/a$b/python", "/home/a$b/python"),
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
def test_interpreter_path_is_escaped(path, expected):
|
|
54
|
+
unit = render_system_unit("alice", path)
|
|
55
|
+
assert _exec_start_line(unit) == f"ExecStart={expected} -m pymidi_controller run --mode blocking"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.skipif(shutil.which("systemd-analyze") is None, reason="systemd-analyze not available")
|
|
59
|
+
@pytest.mark.parametrize("dirname", ["plain", "with space", "with%percent", "with space%and percent", "with$dollar", "with $dollar space"])
|
|
60
|
+
def test_systemd_resolves_the_rendered_interpreter(tmp_path, dirname):
|
|
61
|
+
interpreter = tmp_path / dirname / "python"
|
|
62
|
+
interpreter.parent.mkdir()
|
|
63
|
+
interpreter.write_text("#!/bin/sh\n")
|
|
64
|
+
interpreter.chmod(0o755)
|
|
65
|
+
unit_file = tmp_path / "pymidi-controller.service"
|
|
66
|
+
unit_file.write_text(render_system_unit("alice", str(interpreter)))
|
|
67
|
+
out = subprocess.run(["systemd-analyze", "verify", str(unit_file)], capture_output=True, text=True)
|
|
68
|
+
assert "not executable" not in out.stdout + out.stderr
|
|
69
|
+
assert "quoting" not in (out.stdout + out.stderr).lower()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("path", ["/tmp/o'x/python", '/tmp/q"x/python', "/tmp/b\\s/python"])
|
|
73
|
+
def test_paths_systemd_cant_express_are_rejected(path):
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
render_system_unit("alice", path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize("path", ["/tmp/a\nExecStartPre=/bin/evil", "/tmp/a\rb"])
|
|
79
|
+
def test_control_characters_in_interpreter_path_are_rejected(path):
|
|
80
|
+
with pytest.raises(ValueError):
|
|
81
|
+
render_system_unit("alice", path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("user", ["", "a b", "alice\n", "a\nUser=root", "50%", "-lead", "a/b"])
|
|
85
|
+
def test_unsupported_user_names_are_rejected(user):
|
|
86
|
+
with pytest.raises(ValueError):
|
|
87
|
+
render_system_unit(user, PY)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("user", ["alice", "pi", "_svc", "first.last", "name-1", "machine$", "alice@corp.com"])
|
|
91
|
+
def test_ordinary_user_names_are_accepted(user):
|
|
92
|
+
assert f"\nUser={user}\n" in render_system_unit(user, PY)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---- install / uninstall -------------------------------------------------
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def calls(monkeypatch):
|
|
99
|
+
recorded = []
|
|
100
|
+
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: recorded.append(cmd))
|
|
101
|
+
return recorded
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _as_root(monkeypatch, tmp_path, sudo_user="alice"):
|
|
105
|
+
monkeypatch.setattr(os, "geteuid", lambda: 0)
|
|
106
|
+
monkeypatch.setattr(service_manager, "SYSTEM_UNIT_DIR", tmp_path / "system")
|
|
107
|
+
monkeypatch.setattr(service_manager.sys, "executable", PY)
|
|
108
|
+
monkeypatch.setattr(service_manager.pwd, "getpwnam", lambda name: type("P", (), {"pw_gid": 1000})())
|
|
109
|
+
monkeypatch.setattr(service_manager, "_audio_group_exists", lambda: True)
|
|
110
|
+
monkeypatch.setattr(service_manager, "_warn_audio_group", lambda user: None)
|
|
111
|
+
if sudo_user is None:
|
|
112
|
+
monkeypatch.delenv("SUDO_USER", raising=False)
|
|
113
|
+
else:
|
|
114
|
+
monkeypatch.setenv("SUDO_USER", sudo_user)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_system_install_writes_rendered_unit_and_restarts(monkeypatch, tmp_path, calls):
|
|
118
|
+
_as_root(monkeypatch, tmp_path)
|
|
119
|
+
service_manager.install(ServiceScope.SYSTEM)
|
|
120
|
+
|
|
121
|
+
unit = (tmp_path / "system" / "pymidi-controller.service").read_text()
|
|
122
|
+
assert unit == render_system_unit("alice", PY)
|
|
123
|
+
# restart, not just enable --now, so a running old instance is replaced
|
|
124
|
+
assert ["systemctl", "restart", "pymidi-controller.service"] in calls
|
|
125
|
+
assert calls.index(["systemctl", "daemon-reload"]) < calls.index(["systemctl", "restart", "pymidi-controller.service"])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_system_install_omits_audio_group_line_when_group_missing(monkeypatch, tmp_path, calls):
|
|
129
|
+
_as_root(monkeypatch, tmp_path)
|
|
130
|
+
monkeypatch.setattr(service_manager, "_audio_group_exists", lambda: False)
|
|
131
|
+
service_manager.install(ServiceScope.SYSTEM)
|
|
132
|
+
assert "SupplementaryGroups" not in (tmp_path / "system" / "pymidi-controller.service").read_text()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_system_uninstall_removes_unit(monkeypatch, tmp_path, calls):
|
|
136
|
+
_as_root(monkeypatch, tmp_path)
|
|
137
|
+
service_manager.install(ServiceScope.SYSTEM)
|
|
138
|
+
service_manager.uninstall(ServiceScope.SYSTEM)
|
|
139
|
+
assert not (tmp_path / "system" / "pymidi-controller.service").exists()
|
|
140
|
+
assert ["systemctl", "stop", "pymidi-controller.service"] in calls
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_user_install_still_copies_unmodified_unit(monkeypatch, tmp_path, calls):
|
|
144
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
145
|
+
service_manager.install(ServiceScope.USER)
|
|
146
|
+
dst = tmp_path / ".config/systemd/user/pymidi-controller.service"
|
|
147
|
+
assert "graphical-session.target" in dst.read_text()
|
|
148
|
+
assert "@" not in dst.read_text()
|
|
149
|
+
assert ["systemctl", "--user", "restart", "pymidi-controller.service"] in calls
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_system_install_refuses_non_root(monkeypatch):
|
|
153
|
+
monkeypatch.setattr(os, "geteuid", lambda: 1000)
|
|
154
|
+
with pytest.raises(SystemExit) as exc:
|
|
155
|
+
service_manager.install(ServiceScope.SYSTEM)
|
|
156
|
+
assert "must run as root" in str(exc.value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_system_uninstall_refuses_non_root(monkeypatch):
|
|
160
|
+
monkeypatch.setattr(os, "geteuid", lambda: 1000)
|
|
161
|
+
with pytest.raises(SystemExit) as exc:
|
|
162
|
+
service_manager.uninstall(ServiceScope.SYSTEM)
|
|
163
|
+
assert "must run as root" in str(exc.value)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.parametrize("sudo_user", [None, "root"])
|
|
167
|
+
def test_system_install_refuses_without_real_sudo_user(monkeypatch, tmp_path, sudo_user):
|
|
168
|
+
_as_root(monkeypatch, tmp_path, sudo_user)
|
|
169
|
+
with pytest.raises(SystemExit) as exc:
|
|
170
|
+
service_manager.install(ServiceScope.SYSTEM)
|
|
171
|
+
assert "which user" in str(exc.value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_system_install_refuses_unknown_sudo_user(monkeypatch, tmp_path):
|
|
175
|
+
_as_root(monkeypatch, tmp_path, "ghost")
|
|
176
|
+
def missing(name):
|
|
177
|
+
raise KeyError(name)
|
|
178
|
+
monkeypatch.setattr(service_manager.pwd, "getpwnam", missing)
|
|
179
|
+
with pytest.raises(SystemExit) as exc:
|
|
180
|
+
service_manager.install(ServiceScope.SYSTEM)
|
|
181
|
+
assert "isn't a known user" in str(exc.value)
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import shutil
|
|
2
|
-
import subprocess
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from importlib import resources
|
|
5
|
-
from enum import Enum, auto
|
|
6
|
-
|
|
7
|
-
# -------------------------------------------------------------------
|
|
8
|
-
# Service Installation & Management with Scoped Control
|
|
9
|
-
# -------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
class ServiceScope(Enum):
|
|
12
|
-
USER = auto()
|
|
13
|
-
SYSTEM = auto()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def install(scope: ServiceScope):
|
|
17
|
-
"""
|
|
18
|
-
Install and enable the systemd service unit.
|
|
19
|
-
|
|
20
|
-
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
21
|
-
"""
|
|
22
|
-
if scope == ServiceScope.USER:
|
|
23
|
-
target_dir = Path.home() / ".config/systemd/user"
|
|
24
|
-
ctl = ["systemctl", "--user"]
|
|
25
|
-
src_name = "pymidi-controller.service"
|
|
26
|
-
elif scope == ServiceScope.SYSTEM:
|
|
27
|
-
target_dir = Path("/etc/systemd/system")
|
|
28
|
-
ctl = ["systemctl"]
|
|
29
|
-
src_name = "pymidi-controller.system.service"
|
|
30
|
-
else:
|
|
31
|
-
print("⚠️ Invalid scope for install.")
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
target_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
-
src = resources.files("pymidi_controller") / "data/systemd" / src_name
|
|
36
|
-
dst = target_dir / "pymidi-controller.service"
|
|
37
|
-
shutil.copy(src, dst)
|
|
38
|
-
print(f"✔️ Installed service unit to {dst}")
|
|
39
|
-
|
|
40
|
-
subprocess.run(ctl + ["daemon-reload"], check=True)
|
|
41
|
-
# Drop links from older units (e.g. default.target.wants) before enabling
|
|
42
|
-
subprocess.run(ctl + ["disable", "pymidi-controller.service"], check=False)
|
|
43
|
-
subprocess.run(ctl + ["enable", "--now", "pymidi-controller.service"], check=True)
|
|
44
|
-
print("✅ Service enabled and started.")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def uninstall(scope: ServiceScope):
|
|
48
|
-
"""
|
|
49
|
-
Disable and remove the systemd service unit.
|
|
50
|
-
|
|
51
|
-
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
52
|
-
"""
|
|
53
|
-
if scope == ServiceScope.USER:
|
|
54
|
-
target_dir = Path.home() / ".config/systemd/user"
|
|
55
|
-
ctl = ["systemctl", "--user"]
|
|
56
|
-
elif scope == ServiceScope.SYSTEM:
|
|
57
|
-
target_dir = Path("/etc/systemd/system")
|
|
58
|
-
ctl = ["systemctl"]
|
|
59
|
-
else:
|
|
60
|
-
print("⚠️ Invalid scope for uninstall.")
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
service_name = "pymidi-controller.service"
|
|
64
|
-
dst = target_dir / service_name
|
|
65
|
-
|
|
66
|
-
subprocess.run(ctl + ["stop", service_name], check=False)
|
|
67
|
-
subprocess.run(ctl + ["disable", service_name], check=False)
|
|
68
|
-
|
|
69
|
-
if dst.exists():
|
|
70
|
-
dst.unlink()
|
|
71
|
-
print(f"🗑️ Removed service unit at {dst}")
|
|
72
|
-
else:
|
|
73
|
-
print(f"⚠️ No service unit found at {dst}")
|
|
74
|
-
|
|
75
|
-
subprocess.run(ctl + ["daemon-reload"], check=True)
|
|
76
|
-
print("✅ Service uninstalled.")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def stop(scope: ServiceScope = ServiceScope.USER):
|
|
80
|
-
"""
|
|
81
|
-
Stop the systemd service.
|
|
82
|
-
|
|
83
|
-
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
84
|
-
"""
|
|
85
|
-
cmd = ["systemctl"]
|
|
86
|
-
if scope == ServiceScope.USER:
|
|
87
|
-
cmd.insert(1, "--user")
|
|
88
|
-
cmd.append("stop")
|
|
89
|
-
cmd.append("pymidi-controller.service")
|
|
90
|
-
subprocess.run(cmd, check=True)
|
|
91
|
-
print("✅ Service stopped.")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def enable(scope: ServiceScope = ServiceScope.USER):
|
|
95
|
-
"""
|
|
96
|
-
Enable the systemd service.
|
|
97
|
-
|
|
98
|
-
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
99
|
-
"""
|
|
100
|
-
cmd = ["systemctl"]
|
|
101
|
-
if scope == ServiceScope.USER:
|
|
102
|
-
cmd.insert(1, "--user")
|
|
103
|
-
cmd.extend(["enable", "pymidi-controller.service"])
|
|
104
|
-
subprocess.run(cmd, check=True)
|
|
105
|
-
print("✅ Service enabled.")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def log(scope: ServiceScope = ServiceScope.USER):
|
|
109
|
-
"""
|
|
110
|
-
Show the systemd service logs.
|
|
111
|
-
|
|
112
|
-
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
113
|
-
"""
|
|
114
|
-
cmd = ["journalctl"]
|
|
115
|
-
if scope == ServiceScope.USER:
|
|
116
|
-
cmd.extend(["--user"])
|
|
117
|
-
cmd.extend(["-u", "pymidi-controller.service", "--no-pager"])
|
|
118
|
-
subprocess.run(cmd, check=True)
|
|
119
|
-
print("✅ Service logs displayed.")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def dispatch(action: str, scope: ServiceScope = ServiceScope.USER):
|
|
123
|
-
"""
|
|
124
|
-
Dispatcher for service subcommands.
|
|
125
|
-
|
|
126
|
-
:param action: one of 'install', 'uninstall', 'stop', 'enable', 'log'
|
|
127
|
-
:param scope: ServiceScope.USER or ServiceScope.SYSTEM
|
|
128
|
-
"""
|
|
129
|
-
if action == "install":
|
|
130
|
-
install(scope)
|
|
131
|
-
elif action == "uninstall":
|
|
132
|
-
uninstall(scope)
|
|
133
|
-
elif action == "stop":
|
|
134
|
-
stop(scope)
|
|
135
|
-
elif action == "enable":
|
|
136
|
-
enable(scope)
|
|
137
|
-
elif action == "log":
|
|
138
|
-
log(scope)
|
|
139
|
-
else:
|
|
140
|
-
print(f"⚠️ Unknown service action: {action}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/elgato_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/actions/hue_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/data/default_config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/config_manager.py
RENAMED
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/function_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller/utils/state_manager.py
RENAMED
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{pymidi_controller-0.3.1 → pymidi_controller-0.4.0}/pymidi_controller.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|