100 lines
3.3 KiB
Python
100 lines
3.3 KiB
Python
"""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"
|