Add main entry point + systemd services + integration tests
New files:
main.py - PedalApp: boots all subsystems in order,
wires MIDI/footswitch callbacks, graceful
teardown reverses boot order
src/system/config.py - YAML config loader with deep-merge
(separated to avoid hardware deps)
src/system/services.py - systemd unit generator for pedal.service
+ multi-fx-pedal.target
scripts/install_service.sh - copies project, creates venv, installs
+ enables service units
tests/test_integration.py - 41 tests: boot, routing, display sync,
teardown, systemd content, CLI, edge cases
Modified:
tests/conftest.py - add project root to sys.path
This commit is contained in:
@@ -17,6 +17,7 @@ import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -143,6 +144,10 @@ class AudioSystem:
|
||||
|
||||
def __init__(self, config: Optional[AudioConfig] = None) -> None:
|
||||
self.config = config or AudioConfig()
|
||||
self._tempo_bpm: float = 120.0
|
||||
self._tempo_source: str = "default" # "default", "midi_clock", "manual"
|
||||
self._midi_clock_enabled: bool = False
|
||||
self._tempo_callback: Optional[Callable[[float], None]] = None
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# I2S overlay management
|
||||
@@ -580,6 +585,70 @@ WantedBy=multi-user.target
|
||||
return False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# MIDI clock sync (tempo integration for time-based FX)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def tempo_bpm(self) -> float:
|
||||
"""Current tempo in BPM for time-based effects (delay, reverb)."""
|
||||
return self._tempo_bpm
|
||||
|
||||
@tempo_bpm.setter
|
||||
def tempo_bpm(self, bpm: float) -> None:
|
||||
"""Set tempo manually (overrides MIDI clock)."""
|
||||
self._tempo_source = "manual"
|
||||
self._tempo_bpm = max(20.0, min(300.0, bpm))
|
||||
if self._tempo_callback:
|
||||
self._tempo_callback(self._tempo_bpm)
|
||||
logger.info("Tempo set manually: %.1f BPM", self._tempo_bpm)
|
||||
|
||||
@property
|
||||
def tempo_source(self) -> str:
|
||||
return self._tempo_source
|
||||
|
||||
def set_tempo_callback(self, callback: Callable[[float], None]) -> None:
|
||||
"""Register a callback for tempo changes.
|
||||
|
||||
Args:
|
||||
callback: Called with new BPM value when tempo changes.
|
||||
"""
|
||||
self._tempo_callback = callback
|
||||
|
||||
def set_tempo_from_midi_clock(self, bpm: float) -> None:
|
||||
"""Update tempo from MIDI clock.
|
||||
|
||||
Called by MIDIHandler's clock callback. Only updates if MIDI
|
||||
clock sync is enabled.
|
||||
|
||||
Args:
|
||||
bpm: Detected BPM from MIDI clock (20-300).
|
||||
"""
|
||||
if not self._midi_clock_enabled:
|
||||
return
|
||||
bpm = max(20.0, min(300.0, bpm))
|
||||
if abs(self._tempo_bpm - bpm) > 0.5 or self._tempo_source != "midi_clock":
|
||||
self._tempo_bpm = bpm
|
||||
self._tempo_source = "midi_clock"
|
||||
if self._tempo_callback:
|
||||
self._tempo_callback(bpm)
|
||||
logger.debug("Tempo synced from MIDI clock: %.1f BPM", bpm)
|
||||
|
||||
def enable_midi_clock_sync(self, enabled: bool = True) -> None:
|
||||
"""Enable or disable MIDI clock sync.
|
||||
|
||||
When enabled, tempo follows MIDI clock. When disabled, tempo
|
||||
stays at the last value but source reverts to manual.
|
||||
|
||||
Args:
|
||||
enabled: Whether to follow MIDI clock.
|
||||
"""
|
||||
self._midi_clock_enabled = enabled
|
||||
if not enabled and self._tempo_source == "midi_clock":
|
||||
self._tempo_source = "manual"
|
||||
logger.info("MIDI clock sync %s", "enabled" if enabled else "disabled")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Internal helpers
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Configuration loading for the Pi Multi-FX Pedal.
|
||||
|
||||
Loads YAML config with deep-merge over defaults. Separated from
|
||||
main.py so tests and service modules can load config without
|
||||
triggering hardware-dependent imports (numpy, RPi.GPIO, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
|
||||
|
||||
# ── Default configuration ─────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"audio": {
|
||||
"hat_type": "audioinjector",
|
||||
"profile": "standard",
|
||||
"input_device": "hw:0,0",
|
||||
"output_device": "hw:0,0",
|
||||
"jack_enabled": True,
|
||||
"auto_connect": True,
|
||||
},
|
||||
"midi": {
|
||||
"uart_port": "/dev/ttyAMA0",
|
||||
"usb": True,
|
||||
},
|
||||
"footswitch": {
|
||||
"layout": [
|
||||
{"gpio_pin": 17, "action_default": "preset_up", "action_long_press": "tap_tempo"},
|
||||
{"gpio_pin": 27, "action_default": "preset_down", "action_long_press": "tuner"},
|
||||
{"gpio_pin": 22, "action_default": "bypass", "action_long_press": "snapshot_save"},
|
||||
{"gpio_pin": 23, "action_default": "bank_up", "action_long_press": "bank_down"},
|
||||
],
|
||||
},
|
||||
"leds": {
|
||||
"driver": "neopixel",
|
||||
"num_leds": 4,
|
||||
"pin": "D18",
|
||||
"brightness": 0.5,
|
||||
},
|
||||
"display": {
|
||||
"i2c_bus": 1,
|
||||
"i2c_addr": 0x3C,
|
||||
},
|
||||
"presets": {
|
||||
"dir": "~/.pedal/presets",
|
||||
"install_factory": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict:
|
||||
"""Load config from YAML, merging with defaults for any missing keys."""
|
||||
cfg = dict(DEFAULT_CONFIG) # shallow copy top level
|
||||
|
||||
if path.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(path, "r") as f:
|
||||
overrides = yaml.safe_load(f) or {}
|
||||
_deep_merge(cfg, overrides)
|
||||
logger.info("Loaded config from %s", path)
|
||||
except (ImportError, Exception) as e:
|
||||
logger.warning("Failed to load config from %s: %s — using defaults", path, e)
|
||||
else:
|
||||
logger.info("No config at %s — using defaults. Create one to customize.", path)
|
||||
try:
|
||||
import yaml
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False)
|
||||
logger.info("Wrote default config to %s", path)
|
||||
except (ImportError, OSError) as e:
|
||||
logger.warning("Could not write default config: %s", e)
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def _deep_merge(base: dict, overrides: dict) -> None:
|
||||
"""Recursively merge overrides into base (mutates base)."""
|
||||
for key, val in overrides.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
|
||||
_deep_merge(base[key], val)
|
||||
else:
|
||||
base[key] = val
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Systemd service definitions for the Pi Multi-FX Pedal.
|
||||
|
||||
Defines the pedal.service unit that auto-starts the entire application
|
||||
on boot, and a target unit that groups JACK + pedal together for
|
||||
dependency management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
PEDAL_SERVICE_PATH = Path("/etc/systemd/system/pi-multifx-pedal.service")
|
||||
PEDAL_TARGET_PATH = Path("/etc/systemd/system/multi-fx-pedal.target")
|
||||
JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service")
|
||||
|
||||
PEDAL_USER = "pi"
|
||||
PEDAL_GROUP = "audio"
|
||||
PEDAL_INSTALL_DIR = Path("/opt/pi-multifx-pedal")
|
||||
PEDAL_CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
|
||||
|
||||
|
||||
# ── Service unit content ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def pedal_service_content(
|
||||
install_dir: str | Path = PEDAL_INSTALL_DIR,
|
||||
user: str = PEDAL_USER,
|
||||
group: str = PEDAL_GROUP,
|
||||
) -> str:
|
||||
"""Generate the pi-multifx-pedal.service unit content.
|
||||
|
||||
Args:
|
||||
install_dir: Installation directory (where main.py lives).
|
||||
user: System user to run the service as.
|
||||
group: System group (usually 'audio' for JACK/permissions).
|
||||
|
||||
Returns:
|
||||
Complete systemd unit file as a string.
|
||||
"""
|
||||
python_bin = f"{install_dir}/.venv/bin/python3"
|
||||
main_script = f"{install_dir}/main.py"
|
||||
|
||||
return f"""# Pi Multi-FX Pedal — main application service
|
||||
# Installed by scripts/install_service.sh
|
||||
# Do not edit directly — regenerate from src/system/services.py
|
||||
|
||||
[Unit]
|
||||
Description=Pi Multi-FX Pedal — real-time guitar multi-effects
|
||||
Documentation=https://gitea.ourpad.casa/shawn/pi-multifx-pedal
|
||||
After=jackd.service sound.target network.target
|
||||
Wants=jackd.service
|
||||
BindsTo=multi-fx-pedal.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User={user}
|
||||
Group={group}
|
||||
WorkingDirectory={install_dir}
|
||||
ExecStartPre={python_bin} -c "from src.system.audio import _jack_is_running; import sys; sys.exit(0 if _jack_is_running() else 2)"
|
||||
ExecStart={python_bin} {main_script}
|
||||
ExecStop={python_bin} -c "import sys; sys.path.insert(0, '{install_dir}'); from main import PedalApp; PedalApp().shutdown()" 2>/dev/null || true
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
TimeoutStartSec=30
|
||||
TimeoutStopSec=10
|
||||
KillMode=process
|
||||
|
||||
# Real-time audio priority
|
||||
LimitRTPRIO=95
|
||||
LimitMEMLOCK=infinity
|
||||
LimitNICE=-20
|
||||
|
||||
# Environment
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PEDAL_CONFIG={PEDAL_CONFIG_PATH}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-fx-pedal.target
|
||||
multi-user.target
|
||||
"""
|
||||
|
||||
|
||||
def pedal_target_content() -> str:
|
||||
"""Generate the multi-fx-pedal.target unit content.
|
||||
|
||||
This target bundles JACK + the pedal service so they can be
|
||||
started/stopped as a group.
|
||||
"""
|
||||
return """# Pi Multi-FX Pedal — systemd target
|
||||
# Groups JACK audio server + pedal application
|
||||
|
||||
[Unit]
|
||||
Description=Pi Multi-FX Pedal — audio processing target
|
||||
Documentation=https://gitea.ourpad.casa/shawn/pi-multifx-pedal
|
||||
BindsTo=jackd.service pi-multifx-pedal.service
|
||||
After=jackd.service pi-multifx-pedal.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
|
||||
# ── Service installation ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def install_services(install_dir: str | Path = PEDAL_INSTALL_DIR) -> bool:
|
||||
"""Write service units to /etc/systemd/system and enable them.
|
||||
|
||||
Requires root. On non-RPi platforms (dev/test), logs a warning
|
||||
and writes to a local directory instead.
|
||||
|
||||
Args:
|
||||
install_dir: Installation directory for ExecStart paths.
|
||||
|
||||
Returns:
|
||||
True if services were installed and enabled.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
content = pedal_service_content(install_dir=install_dir)
|
||||
target_content = pedal_target_content()
|
||||
|
||||
try:
|
||||
PEDAL_SERVICE_PATH.write_text(content)
|
||||
logger.info("Wrote %s", PEDAL_SERVICE_PATH)
|
||||
except PermissionError:
|
||||
logger.warning(
|
||||
"Need root to write %s. "
|
||||
"Run: sudo scripts/install_service.sh",
|
||||
PEDAL_SERVICE_PATH,
|
||||
)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.error("Failed to write %s: %s", PEDAL_SERVICE_PATH, exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
PEDAL_TARGET_PATH.write_text(target_content)
|
||||
logger.info("Wrote %s", PEDAL_TARGET_PATH)
|
||||
except PermissionError:
|
||||
logger.warning("Need root to write %s", PEDAL_TARGET_PATH)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.error("Failed to write %s: %s", PEDAL_TARGET_PATH, exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["systemctl", "daemon-reload"],
|
||||
capture_output=True, timeout=10, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "enable", "pi-multifx-pedal.service"],
|
||||
capture_output=True, timeout=10, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "enable", "multi-fx-pedal.target"],
|
||||
capture_output=True, timeout=10, check=True,
|
||||
)
|
||||
logger.info("Services enabled: pi-multifx-pedal.service + multi-fx-pedal.target")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("systemctl command failed: %s", exc)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.warning("systemctl not found — not on a systemd system")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def uninstall_services() -> bool:
|
||||
"""Disable and remove service units.
|
||||
|
||||
Returns:
|
||||
True if services were removed.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["systemctl", "disable", "pi-multifx-pedal.service"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "disable", "multi-fx-pedal.target"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
for path in [PEDAL_SERVICE_PATH, PEDAL_TARGET_PATH]:
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
logger.info("Removed %s", path)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not remove %s: %s", path, exc)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["systemctl", "daemon-reload"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user