pymidi-controller 0.3.1__tar.gz → 0.4.1__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.
Files changed (35) hide show
  1. {pymidi_controller-0.3.1/pymidi_controller.egg-info → pymidi_controller-0.4.1}/PKG-INFO +38 -13
  2. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/README.md +35 -12
  3. pymidi_controller-0.4.1/pymidi_controller/__main__.py +4 -0
  4. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/config.py +5 -2
  5. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/core.py +4 -1
  6. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/data/systemd/pymidi-controller.system.service +3 -2
  7. pymidi_controller-0.4.1/pymidi_controller/utils/service_manager.py +252 -0
  8. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1/pymidi_controller.egg-info}/PKG-INFO +38 -13
  9. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller.egg-info/SOURCES.txt +4 -1
  10. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller.egg-info/requires.txt +3 -0
  11. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pyproject.toml +4 -1
  12. pymidi_controller-0.4.1/tests/test_core.py +38 -0
  13. pymidi_controller-0.4.1/tests/test_service_unit.py +181 -0
  14. pymidi_controller-0.3.1/pymidi_controller/utils/service_manager.py +0 -140
  15. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/LICENSE +0 -0
  16. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/MANIFEST.in +0 -0
  17. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/__init__.py +0 -0
  18. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/actions/__init__.py +0 -0
  19. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/actions/elgato.py +0 -0
  20. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/actions/elgato_discovery.py +0 -0
  21. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/actions/hue.py +0 -0
  22. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/actions/hue_discovery.py +0 -0
  23. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/cli.py +0 -0
  24. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/data/default_config.yaml +0 -0
  25. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/data/systemd/pymidi-controller.service +0 -0
  26. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/utils/__init__.py +0 -0
  27. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/utils/color_cycle.py +0 -0
  28. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/utils/config_manager.py +0 -0
  29. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/utils/function_manager.py +0 -0
  30. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/utils/midi_utils.py +0 -0
  31. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller/utils/state_manager.py +0 -0
  32. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller.egg-info/dependency_links.txt +0 -0
  33. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller.egg-info/entry_points.txt +0 -0
  34. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/pymidi_controller.egg-info/top_level.txt +0 -0
  35. {pymidi_controller-0.3.1 → pymidi_controller-0.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymidi-controller
3
- Version: 0.3.1
3
+ Version: 0.4.1
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 | Description |
161
- | ----------------------------------- | ----------------------------------- |
162
- | `pymidi service install --user` | Install & start as a user service |
163
- | `pymidi service install --system` | Install & start system-wide service |
164
- | `pymidi service uninstall --user` | Stop & remove user service |
165
- | `pymidi service uninstall --system` | Stop & remove system service |
166
- | `pymidi service stop` | Stop the running service |
167
- | `pymidi service enable` | Enable service (user scope) |
168
- | `pymidi service log` | Tail service logs |
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 install --user
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 install --user` (or `--system` for a system-wide install) once. It replaces the old unit and removes its stale `default.target` link. `pymidi service enable` alone doesn't refresh the unit file.
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 | Description |
137
- | ----------------------------------- | ----------------------------------- |
138
- | `pymidi service install --user` | Install & start as a user service |
139
- | `pymidi service install --system` | Install & start system-wide service |
140
- | `pymidi service uninstall --user` | Stop & remove user service |
141
- | `pymidi service uninstall --system` | Stop & remove system service |
142
- | `pymidi service stop` | Stop the running service |
143
- | `pymidi service enable` | Enable service (user scope) |
144
- | `pymidi service log` | Tail service logs |
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 install --user
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 install --user` (or `--system` for a system-wide install) once. It replaces the old unit and removes its stale `default.target` link. `pymidi service enable` alone doesn't refresh the unit file.
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
@@ -0,0 +1,4 @@
1
+ from pymidi_controller.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -1,3 +1,5 @@
1
+ import sys
2
+
1
3
  from pymidi_controller.utils.config_manager import load_config
2
4
 
3
5
  _cfg = load_config()
@@ -13,5 +15,6 @@ MIDI_DEVICES = _m.get("devices", [])
13
15
  MIDI_BINDINGS = _m.get("bindings", {})
14
16
  COLOR_CYCLES = _m.get("color_cycles", {})
15
17
 
16
- # CLI entrypoint
17
- CLI_COMMAND = "pymidi-controller"
18
+ # CLI entrypoint: the running interpreter, so bindings work without the
19
+ # console script on PATH (the system service has no ~/.local/bin)
20
+ CLI_COMMAND = [sys.executable, "-m", "pymidi_controller"]
@@ -18,7 +18,7 @@ def handle_midi_message(msg, bindings):
18
18
 
19
19
  key = format_midi_key(msg)
20
20
  if key and key in bindings:
21
- command = [CLI_COMMAND] + bindings[key]
21
+ command = CLI_COMMAND + bindings[key]
22
22
  print(f"🎯 Matched {key} → Running: {' '.join(command)}")
23
23
  try:
24
24
  subprocess.Popen(command)
@@ -60,6 +60,9 @@ def run(mode: str = "blocking"):
60
60
  Entry point for the long-running MIDI listener.
61
61
  mode: 'interactive' or 'blocking'
62
62
  """
63
+ # Under systemd stdout is a pipe, so flush per line to reach the journal
64
+ sys.stdout.reconfigure(line_buffering=True)
65
+
63
66
  bindings = MIDI_BINDINGS
64
67
  if not bindings:
65
68
  print("❌ No MIDI bindings defined in config.yaml.")
@@ -5,8 +5,9 @@ Wants=sound.target
5
5
 
6
6
  [Service]
7
7
  Type=simple
8
- Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
9
- ExecStart=/usr/bin/env pymidi-controller run --mode blocking
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.1
3
+ Version: 0.4.1
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 | Description |
161
- | ----------------------------------- | ----------------------------------- |
162
- | `pymidi service install --user` | Install & start as a user service |
163
- | `pymidi service install --system` | Install & start system-wide service |
164
- | `pymidi service uninstall --user` | Stop & remove user service |
165
- | `pymidi service uninstall --system` | Stop & remove system service |
166
- | `pymidi service stop` | Stop the running service |
167
- | `pymidi service enable` | Enable service (user scope) |
168
- | `pymidi service log` | Tail service logs |
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 install --user
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 install --user` (or `--system` for a system-wide install) once. It replaces the old unit and removes its stale `default.target` link. `pymidi service enable` alone doesn't refresh the unit file.
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,6 @@ 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_core.py
32
+ tests/test_service_unit.py
@@ -3,3 +3,6 @@ python-rtmidi>=1.4.10
3
3
  requests>=2.0
4
4
  zeroconf>=0.40
5
5
  PyYAML>=6.0
6
+
7
+ [dev]
8
+ pytest
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymidi-controller"
3
- version = "0.3.1"
3
+ version = "0.4.1"
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,38 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import mido
5
+
6
+ from pymidi_controller import core
7
+
8
+
9
+ def _press(bindings, monkeypatch):
10
+ procs = []
11
+ real_popen = subprocess.Popen
12
+
13
+ def popen(cmd, **kwargs):
14
+ proc = real_popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
15
+ procs.append((cmd, proc))
16
+ return proc
17
+
18
+ monkeypatch.setattr(core.subprocess, "Popen", popen)
19
+ msg = mido.Message("control_change", channel=0, control=99, value=1)
20
+ core.handle_midi_message(msg, bindings)
21
+ return procs
22
+
23
+
24
+ def test_binding_runs_cli_through_current_interpreter(monkeypatch):
25
+ procs = _press({"control_change:0:99:1": ["hue", "--help"]}, monkeypatch)
26
+ [(cmd, _)] = procs
27
+ assert cmd == [sys.executable, "-m", "pymidi_controller", "hue", "--help"]
28
+
29
+
30
+ def test_binding_runs_without_console_script_on_path(monkeypatch):
31
+ # The system service's PATH has no ~/.local/bin, so the action must not
32
+ # depend on finding the pymidi-controller script there
33
+ monkeypatch.setenv("PATH", "/nonexistent")
34
+ procs = _press({"control_change:0:99:1": ["--help"]}, monkeypatch)
35
+ [(_, proc)] = procs
36
+ out, _ = proc.communicate(timeout=30)
37
+ assert proc.returncode == 0, out
38
+ assert "usage:" in out
@@ -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}")