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
# ═══════════════════════════════════════════════════════════════════