"""Hardware-PWM backend for Raspberry Pi 5 generator sweep gating.""" from __future__ import annotations from dataclasses import dataclass, field import logging from pathlib import Path logger = logging.getLogger(__name__) _PWM_CHANNEL_BY_PIN = { 12: 0, 13: 1, 18: 2, 19: 3, } @dataclass(slots=True) class HardwarePwmGate: """Thin wrapper around Raspberry Pi hardware PWM output.""" pin: int frequency_hz: int duty_cycle: float _pwm: object | None = field(init=False, default=None, repr=False) _running: bool = field(init=False, default=False, repr=False) def __post_init__(self) -> None: """Validate static PWM configuration.""" if self.pin not in _PWM_CHANNEL_BY_PIN: raise ValueError( "generator_sweep.pwm_pin must be one of GPIO12, GPIO13, GPIO18, or GPIO19 on Raspberry Pi 5" ) if self.frequency_hz <= 0: raise ValueError("generator_sweep.pwm_frequency_hz must be > 0") if not (0.0 < self.duty_cycle <= 1.0): raise ValueError("generator_sweep.pwm_duty_cycle must be within (0, 1]") def open(self) -> None: """Initialize hardware PWM handle.""" if self._pwm is not None: return try: from rpi_hardware_pwm import HardwarePWM except ImportError as exc: raise RuntimeError( "rpi_hardware_pwm is required for generator_sweep PWM output. " "Install project dependencies on the Raspberry Pi before running this script." ) from exc channel = _PWM_CHANNEL_BY_PIN[self.pin] try: self._pwm = HardwarePWM(pwm_channel=channel, hz=self.frequency_hz, chip=0) except Exception as exc: config_hint = _boot_config_hint() logger.exception("Failed to initialize hardware PWM on GPIO%d (channel %d)", self.pin, channel) raise RuntimeError( "Failed to initialize Raspberry Pi hardware PWM. " f"Enable the PWM overlay in {config_hint} by adding " "'dtoverlay=pwm-2chan', then reboot the Raspberry Pi." ) from exc self._running = False logger.info("Hardware PWM opened on GPIO%d at %d Hz", self.pin, self.frequency_hz) def enable(self) -> None: """Start PWM output when not already running.""" if self._pwm is None: raise RuntimeError("Hardware PWM gate is not open") if self._running: return duty_percent = self.duty_cycle * 100.0 self._pwm.start(duty_percent) self._running = True def disable(self) -> None: """Stop PWM output when active.""" if self._pwm is None or not self._running: return self._pwm.stop() self._running = False def close(self) -> None: """Stop PWM and release runtime state.""" self.disable() if self._pwm is not None: logger.info("Hardware PWM closed on GPIO%d", self.pin) self._pwm = None def _boot_config_hint() -> str: """Return the most likely active Raspberry Pi boot config path.""" firmware_config = Path("/boot/firmware/config.txt") if firmware_config.exists(): return str(firmware_config) return "/boot/config.txt"