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:
2026-06-07 23:39:50 -04:00
parent d9682f3bea
commit c38a7b0fd8
32 changed files with 5428 additions and 342 deletions
+69
View File
@@ -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
# ═══════════════════════════════════════════════════════════════════
+89
View File
@@ -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
+212
View File
@@ -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