Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/PWM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# PWM in OpenScan3

This is a short developer note about the PWM abstraction used in OpenScan3.

## Summary

OpenScan3 handles light intensity as a percentage (`0..100`) at controller/API level, then maps that value to a PWM duty cycle (`0..1`) based on configured voltage bounds (`pwm_min`, `pwm_max`).

Main modules:

- `openscan_firmware/controllers/hardware/lights.py`
- Owns brightness state (`value` in percent) and mapping logic.
- `openscan_firmware/controllers/hardware/gpio.py`
- Selects hardware PWM when available, otherwise software PWM fallback.
- `openscan_firmware/utils/pwm_hardware.py`
- Low-level hardware PWM implementation for Raspberry Pi (`/sys/class/pwm` + pinctrl).

## Raspberry Pi Setup Requirement

For hardware PWM support, add the following to `/boot/firmware/config.txt`:

```txt
dtparam=audio=off
dtoverlay=pwm-2chan
```

Important:

- PWM and onboard audio are mutually exclusive with this setup.
- If audio is required, use a separate external PWM chip on the board.

## Practical Note

`pwm_hardware.py` is the utility-layer solution for hardware PWM.
As long as the boot config above is applied, the rest is handled by the OpenScan3 abstraction.
15 changes: 14 additions & 1 deletion openscan_firmware/config/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class LightConfig(BaseModel):
default=False,
description="Indicates whether this light hardware can handle PWM (otherwise only on/off).",
)
pwm_frequency: float = Field(10000.0, ge=50.0, le=100000.0, description="PWM frequency for led driver.")
pwm_min: float = Field(0.0, ge=0, le=3.3, description="Minimum pwm voltage for led driver.")
pwm_max: float = Field(3.3, ge=0, le=3.3, description="Maximum pwm voltage for led driver.")

@model_validator(mode="before")
@classmethod
Expand All @@ -37,4 +40,14 @@ def ensure_pins(cls, values):
merged_pins.append(pin)

values["pins"] = list(dict.fromkeys(merged_pins))
return values
return values

@model_validator(mode="after")
def validate_pwm_range(self):
"""
Ensures a valid range when PWM mode is enabled.
"""
if self.pwm_support and self.pwm_min >= self.pwm_max:
raise ValueError("pwm_min must be less than pwm_max")

return self
24 changes: 16 additions & 8 deletions openscan_firmware/config/scan.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, confloat, model_serializer
from typing import Tuple, Literal
from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, model_serializer
from typing import Annotated, Literal

from openscan_firmware.models.paths import PathMethod


FocusValue = Annotated[float, Field(ge=0.0, le=15.0)]


class ScanSetting(BaseModel):
path_method: PathMethod = Field(
default=PathMethod.FIBONACCI,
Expand All @@ -22,13 +25,13 @@ class ScanSetting(BaseModel):
max_theta: float = Field(125.0, ge=0.0, le=180.0,
description="Maximum theta angle in degrees for constrained paths.")
min_phi: float | None = Field(
default=None,
default=0,
ge=0.0,
le=360.0,
description="Optional minimum phi angle in degrees for constrained paths.",
)
max_phi: float | None = Field(
default=None,
default=360.0,
ge=0.0,
le=360.0,
description="Optional maximum phi angle in degrees for constrained paths.",
Expand All @@ -41,10 +44,15 @@ class ScanSetting(BaseModel):
focus_stacks: int = Field(1, ge=1, le=99, description="Number of photos with different focus per position."
"This ignores AF and you need to set a focus range."
"Focus values will then be evenly spaced between min and max.")
focus_range: Tuple[
confloat(ge=0.0, le=15.0),
confloat(ge=0.0, le=15.0)] = Field(default=(10.0, 15.0),
description="Minimum and maximum focus distance in diopters.")
pause_before_capture_ms: int = Field(
0,
ge=0,
description="Pause in milliseconds before capture to let vibrations settle.",
)
focus_range: tuple[FocusValue, FocusValue] = Field(
default=(10.0, 15.0),
description="Minimum and maximum focus distance in diopters.",
)

@property
def focus_positions(self) -> list[float]:
Expand Down
11 changes: 7 additions & 4 deletions openscan_firmware/controllers/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def _runtime_to_persisted_config() -> ScannerDeviceConfig:
name: PersistedEndstopConfig(settings=endstop.settings)
for name, endstop in _scanner_device.endstops.items()
},
idle_timeout=_scanner_device.idle_timeout,
motors_timeout=_scanner_device.motors_timeout,
scan_radius_mm=_scanner_device.scan_radius_mm,
startup_mode=_scanner_device.startup_mode.value if _scanner_device.startup_mode else None,
Expand Down Expand Up @@ -270,6 +271,7 @@ def get_device_info():
"lights": {name: controller.get_status() for name, controller in get_all_light_controllers().items()},
"triggers": {name: controller.get_status() for name, controller in get_all_trigger_controllers().items()},

"idle_timeout": _scanner_device.idle_timeout,
"motors_timeout": _scanner_device.motors_timeout,
"scan_radius_mm": _scanner_device.scan_radius_mm,
"startup_mode": _scanner_device.startup_mode,
Expand Down Expand Up @@ -696,7 +698,8 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
triggers=trigger_objects,
endstops=endstop_objects,

# motors timeout in seconds - 0 to disable
# device idle timeout in seconds - 0 to disable
idle_timeout=config_dict["idle_timeout"],
motors_timeout=config_dict["motors_timeout"],
scan_radius_mm=config_dict["scan_radius_mm"],

Expand All @@ -709,11 +712,11 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
_scanner_device._initialized=True

# initialize inactivity timer
if _scanner_device.motors_timeout > 0:
inactivity_timer.set_timeout(_scanner_device.motors_timeout)
if _scanner_device.idle_timeout > 0:
inactivity_timer.set_timeout(_scanner_device.idle_timeout)
inactivity_timer.on_timeout = go_to_idle
inactivity_timer.enable()
logger.info(f"Inactivity timer set to {_scanner_device.motors_timeout} seconds.")
logger.info(f"Inactivity timer set to {_scanner_device.idle_timeout} seconds.")
else:
inactivity_timer.disable()
logger.info("Inactivity timer disabled.")
Expand Down
107 changes: 94 additions & 13 deletions openscan_firmware/controllers/hardware/gpio.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import logging

from gpiozero import DigitalOutputDevice, Button
from gpiozero import DigitalOutputDevice, PWMOutputDevice, Button
from typing import Dict, List, Optional, Callable

# hardware PWM module
from openscan_firmware.utils.pwm_hardware import hwpwm

logger = logging.getLogger(__name__)

# Track pins and buttons
_output_pins = {}
_pwm_pins = {}
_buttons = {}


Expand All @@ -15,8 +19,10 @@ def initialize_output_pins(pins: List[int]):
for pin in pins:
if pin in _output_pins:
logger.warning(f"Warning: Output pin {pin} already initialized.")
elif pin in _pwm_pins:
logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as PWM.")
elif pin in _buttons:
logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as Button.")
logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as button.")
else:
try:
_output_pins[pin] = DigitalOutputDevice(pin, initial_value=False)
Expand All @@ -27,7 +33,6 @@ def initialize_output_pins(pins: List[int]):
if pin in _output_pins:
del _output_pins[pin]


def toggle_output_pin(pin: int):
"""Toggles the state of an output pin."""
if pin in _output_pins:
Expand Down Expand Up @@ -65,14 +70,6 @@ def set_output_pin(pin: int, status: bool, auto_initialize: bool = False):
raise ValueError(message)


def get_initialized_pins() -> Dict[str, List[int]]:
"""Returns a dictionary listing initialized output pins and buttons."""
return {
"output_pins": list(_output_pins.keys()),
"buttons": list(_buttons.keys())
}


def get_output_pin(pin: int):
"""Returns the state of an output pin."""
if pin in _output_pins:
Expand All @@ -83,6 +80,63 @@ def get_output_pin(pin: int):
raise ValueError(message)


def initialize_pwm_pins(pins: List[int], freq: int):
"""Initializes one or more GPIO pins as pwm outputs."""
for pin in pins:
if pin in _pwm_pins:
logger.warning(f"Warning: PWM pin {pin} already initialized.")
elif pin in _output_pins:
logger.error(f"Error: Cannot initialize pin {pin} as PWM. Already initialized as output.")
elif pin in _buttons:
logger.error(f"Error: Cannot initialize pin {pin} as PWM. Already initialized as button.")
else:
try:
if hwpwm.supports(pin):
_pwm_pins[pin] = pin
hwpwm.setup(pin)
hwpwm.set_frequency(pin, freq)
logger.info(f"Initialized pin {pin} as hardware PWM.")
else:
_pwm_pins[pin] = PWMOutputDevice(pin, active_high=True, initial_value=0.0, frequency=freq)
logger.info(f"Initialized pin {pin} as software PWM.")
except Exception as e:
logger.error(f"Error initializing PWM pin {pin}: {e}", exc_info=True)
# Clean up if initialization failed partially
if pin in _pwm_pins:
del _pwm_pins[pin]

def set_pwm_pin(pin: int, value: float):
"""Sets the value of a PWM pin."""
if pin in _pwm_pins:
dev = _pwm_pins[pin]

# on hw pwm we store just pin number here, not the device
if isinstance(dev, int):
# hw pwm
hwpwm.set_duty_cycle(dev, value)
else:
# soft pwm
_pwm_pins[pin].value = value
else:
logger.warning(f"Warning: Cannot set pin {pin}. Not initialized as PWM.")

def get_pwm_pin(pin: int):
"""Returns the state of an output pin."""
if pin in _pwm_pins:
dev = _pwm_pins[pin]

# on hw pwm we store just pin number here, not the device
if isinstance(dev, int):
# hw pwm
return hwpwm.get_duty_cycle(dev)
else:
# soft pwm
return _pwm_pins[pin].value
else:
logger.warning(f"Warning: Pin {pin} not initialized as PWM.")
return None


def initialize_button(pin: int, pull_up: Optional[bool] = True, bounce_time: Optional[float] = 0.05):
"""
Initializes a GPIO pin as button input using gpiozero.Button.
Expand All @@ -98,6 +152,8 @@ def initialize_button(pin: int, pull_up: Optional[bool] = True, bounce_time: Opt
logger.warning(f"Warning: Button on pin {pin} already initialized.")
elif pin in _output_pins:
logger.error(f"Error: Cannot initialize pin {pin} as Button. Already initialized as output.")
elif pin in _pwm_pins:
logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as PWM.")
else:
try:
_buttons[pin] = Button(pin, pull_up=pull_up, bounce_time=bounce_time, hold_time=0.01)
Expand Down Expand Up @@ -182,10 +238,32 @@ def is_button_pressed(pin: int) -> Optional[bool]:
# Returning None indicates it's not a known button.
return None

def get_initialized_pins() -> Dict[str, List[int]]:
"""Returns a dictionary listing initialized output pins and buttons."""
return {
"output_pins": list(_output_pins.keys()),
"pwm_pins": list(_pwm_pins.keys()),
"buttons": list(_buttons.keys())
}

def cleanup_all_pins():
"""Closes all initialized GPIO devices (output pins and buttons)."""
logger.debug("Cleaning up GPIO resources...")

# Close PWM pins
pins_to_remove = list(_pwm_pins.keys()) # Create a copy of keys to iterate over
for pin in pins_to_remove:
try:
dev = _pwm_pins[pin]
if isinstance(dev, int):
hwpwm.release(dev)
else:
dev.close()
del _pwm_pins[pin] # Remove from tracking dict after successful close
logger.debug(f"PWM pin {pin} closed.")
except Exception as e:
logger.error(f"Error closing PWM pin {pin}: {e}", exc_info=True)

# Close output pins
pins_to_remove = list(_output_pins.keys()) # Create a copy of keys to iterate over
for pin in pins_to_remove:
Expand All @@ -207,7 +285,10 @@ def cleanup_all_pins():
logger.error(f"Error closing button on pin {pin}: {e}", exc_info=True)

# Double check if dictionaries are empty
if not _output_pins and not _buttons:
if not _output_pins and not _pwm_pins and not _buttons:
logger.info("GPIO cleanup successful. All tracked pins released.")
else:
logger.warning(f"GPIO cleanup potentially incomplete. Remaining outputs: {list(_output_pins.keys())}, Remaining buttons: {list(_buttons.keys())}")
logger.warning(
f"GPIO cleanup potentially incomplete. Remaining outputs: {list(_output_pins.keys())}, "
f"Remaining PWM: {list(_pwm_pins.keys())}, Remaining buttons: {list(_buttons.keys())}"
)
Loading
Loading