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
94 changes: 94 additions & 0 deletions docs/Camera/GPHOTO2_ADD_CAMERA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Add a GPhoto2 Camera Profile

This guide shows how to add support for a new gphoto2-compatible camera using a
Python profile class.

## 1. Detect your camera

Connect camera over USB and run:

```bash
gphoto2 --auto-detect
```

Copy the detected model string. You will use parts of this string in
`_MODEL_MARKERS`.

## 2. Inspect config keys and choices

List available keys:

```bash
gphoto2 --list-config
```

Inspect important keys:

```bash
gphoto2 --get-config /main/settings/capturetarget
gphoto2 --get-config /main/capturesettings/shutterspeed
gphoto2 --get-config /main/imgsettings/imageformat
gphoto2 --get-config /main/imgsettings/iso
```

## 3. Copy the template profile

Copy:

`openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/template_camera.py`

Create a new file with your camera name, for example:

`openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/my_camera.py`

Then update:

- `profile_id`
- `_MODEL_MARKERS`
- config key lists (`_SHUTTER_KEYS`, `_ISO_KEYS`, `_RAW_FORMAT_KEYS`, ...)
- startup defaults in `apply_startup_config`
- optional RAW behavior in `capture_dng`

## 4. Register the profile

No manual registry edit is needed.

All Python modules in:

`openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/`

are auto-discovered by the profile registry at startup.

Requirements:

- your class must inherit from `GPhoto2Profile` (or `GenericGPhoto2Profile`)
- `register_in_registry` must be `True` (default)
- `matches(identity)` should return `True` only for your target camera model

## 5. Run a JPEG test

Use the firmware API/flow to capture a JPEG and check:

- image is captured successfully
- expected shutter and quality values are applied
- diagnostics show expected config keys

## 6. Run a RAW test

Capture RAW/DNG via the firmware flow and verify:

- file extension is RAW-like for your camera (`.nef`, `.cr2`, `.raw`, ...)
- profile can switch to RAW mode
- profile restores previous image format after capture

## 7. Debug setting failures

`write_first_config(...)` returns explicit result details:

- attempted keys
- requested value
- success/failure
- failure message

If a setting fails, inspect these values first, then compare with
`gphoto2 --get-config <key>` output.
65 changes: 65 additions & 0 deletions openscan_firmware/config/external_trigger_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

from pydantic import BaseModel, Field

from openscan_firmware.config.scan import ScanSetting
from openscan_firmware.models.paths import PathMethod


class ExternalTriggerRunSettings(BaseModel):
path_method: PathMethod = Field(
default=PathMethod.FIBONACCI,
description="Scanning path generator for the external trigger run.",
)
points: int = Field(130, ge=1, le=999, description="Number of trigger positions.")
min_theta: float = Field(
12.0,
ge=0.0,
le=180.0,
description="Minimum theta angle in degrees for constrained paths.",
)
max_theta: float = Field(
125.0,
ge=0.0,
le=180.0,
description="Maximum theta angle in degrees for constrained paths.",
)
optimize_path: bool = Field(
True,
description="Enable path optimization based on the configured motor parameters.",
)
optimization_algorithm: str = Field(
"nearest_neighbor",
description="Path optimization algorithm to use when optimize_path is enabled.",
)
trigger_name: str = Field(
...,
min_length=1,
description="Name of the configured trigger device to fire at each scan point.",
)
pre_trigger_delay_ms: int = Field(
default=0,
ge=0,
le=600_000,
description="Delay after reaching the scan position and before asserting the trigger.",
)
post_trigger_delay_ms: int = Field(
default=0,
ge=0,
le=600_000,
description="Delay after releasing the trigger before the next scan step starts.",
)

def to_scan_settings(self) -> ScanSetting:
"""Adapt the path-related settings to the shared scan path generator."""
return ScanSetting(
path_method=self.path_method,
points=self.points,
min_theta=self.min_theta,
max_theta=self.max_theta,
optimize_path=self.optimize_path,
optimization_algorithm=self.optimization_algorithm,
focus_stacks=1,
focus_range=(10.0, 15.0),
image_format="jpeg",
)
7 changes: 7 additions & 0 deletions openscan_firmware/config/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class FirmwareSettings(BaseModel):
detected.
enable_cloud: When True the firmware enables cloud-facing features and
UX affordances.
camera_preview_enabled: When False the system is expected to operate
without a live camera preview workflow, for example on trigger-only
DSLR setups.
"""

qr_wifi_scan_enabled: bool = Field(
Expand All @@ -40,6 +43,10 @@ class FirmwareSettings(BaseModel):
default=False,
description="Enable integrations with OpenScan Cloud services.",
)
camera_preview_enabled: bool = Field(
default=True,
description="Expose camera preview-oriented workflows. Disable for trigger-only systems without a live camera feed.",
)


# Module-level singleton – loaded once, then reused.
Expand Down
4 changes: 2 additions & 2 deletions openscan_firmware/config/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ScanSetting(BaseModel):
)
points: int = Field(130, ge=1, le=999, description="Number of points in scanning path.")

image_format: Literal['jpeg','dng','rgb_array', 'yuv_array'] = Field(
image_format: Literal['jpeg', 'raw', 'dng', 'rgb_array', 'yuv_array'] = Field(
default='jpeg',
description='Output image format (JPEG, DNG, RGB array or YUV array).'
)
Expand Down Expand Up @@ -49,4 +49,4 @@ def focus_positions(self) -> list[float]:
return [
min_focus + i * (max_focus - min_focus) / (self.focus_stacks - 1)
for i in range(self.focus_stacks)
]
]
30 changes: 30 additions & 0 deletions openscan_firmware/config/trigger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

from enum import Enum

from pydantic import AliasChoices, BaseModel, Field


class TriggerActiveLevel(str, Enum):
ACTIVE_HIGH = "active_high"
ACTIVE_LOW = "active_low"


class TriggerConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether this trigger can be fired.")
pin: int = Field(..., ge=0, description="BCM GPIO pin used for the trigger line.")
active_level: TriggerActiveLevel = Field(
default=TriggerActiveLevel.ACTIVE_HIGH,
validation_alias=AliasChoices("active_level", "polarity"),
description="Defines which logic level is considered active. The idle level is the inverse.",
)
pulse_width_ms: int = Field(
default=100,
ge=1,
le=5_000,
description="How long the trigger line stays active for each trigger pulse in ms.",
)


# Backwards-compatible alias for older code/config payloads.
TriggerPolarity = TriggerActiveLevel
39 changes: 39 additions & 0 deletions openscan_firmware/controllers/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from openscan_firmware.models.camera import Camera, CameraType
from openscan_firmware.models.motor import Motor, Endstop
from openscan_firmware.models.light import Light
from openscan_firmware.models.trigger import Trigger
from openscan_firmware.models.scanner import (
ScannerDevice,
ScannerDeviceConfig,
Expand All @@ -39,6 +40,7 @@
from openscan_firmware.config.motor import MotorConfig
from openscan_firmware.config.light import LightConfig
from openscan_firmware.config.endstop import EndstopConfig
from openscan_firmware.config.trigger import TriggerConfig
from openscan_firmware.config.cloud import (
load_cloud_settings_from_env,
set_cloud_settings,
Expand All @@ -60,6 +62,11 @@
remove_motor_controller
from openscan_firmware.controllers.hardware.lights import create_light_controller, get_all_light_controllers, remove_light_controller, \
get_light_controller
from openscan_firmware.controllers.hardware.triggers import (
create_trigger_controller,
get_all_trigger_controllers,
remove_trigger_controller,
)
from openscan_firmware.controllers.hardware.endstops import EndstopController
from openscan_firmware.controllers.hardware.gpio import cleanup_all_pins

Expand Down Expand Up @@ -88,6 +95,7 @@ def _create_default_scanner_device() -> ScannerDevice:
cameras={},
motors={},
lights={},
triggers={},
endstops={},
)
# beware, PrivateAttr are NOT initialized in constructor
Expand All @@ -105,6 +113,7 @@ def _create_default_scanner_device() -> ScannerDevice:
cameras={},
motors={},
lights={},
triggers={},
endstops={},
).model_dump(mode="json")

Expand All @@ -129,6 +138,7 @@ def _runtime_to_persisted_config() -> ScannerDeviceConfig:
},
motors={name: motor.settings for name, motor in _scanner_device.motors.items()},
lights={name: light.settings for name, light in _scanner_device.lights.items()},
triggers={name: trigger.settings for name, trigger in _scanner_device.triggers.items()},
endstops={
name: PersistedEndstopConfig(settings=endstop.settings)
for name, endstop in _scanner_device.endstops.items()
Expand Down Expand Up @@ -256,6 +266,7 @@ def get_device_info():
"cameras": {name: controller.get_status() for name, controller in get_all_camera_controllers().items()},
"motors": {name: controller.get_status() for name, controller in get_all_motor_controllers().items()},
"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()},

"motors_timeout": _scanner_device.motors_timeout,
"startup_mode": _scanner_device.startup_mode,
Expand Down Expand Up @@ -295,6 +306,15 @@ def _load_light_config(settings: dict) -> LightConfig:
return LightConfig()


def _load_trigger_config(settings: dict) -> TriggerConfig:
"""Load trigger configuration for the current model."""
try:
return TriggerConfig(**settings)
except Exception as e:
logger.error("Error loading trigger settings: ", e)
raise


def _load_endstop_config(settings: dict) -> EndstopConfig:
"""Helper function to load and validate endstop settings from a dictionary."""
try:
Expand Down Expand Up @@ -523,6 +543,8 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
remove_motor_controller(controller)
for controller in get_all_light_controllers():
remove_light_controller(controller)
for controller in get_all_trigger_controllers():
remove_trigger_controller(controller)
for controller in get_all_camera_controllers():
remove_camera_controller(controller)
cleanup_all_pins()
Expand Down Expand Up @@ -561,6 +583,16 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
light_objects[light_name] = light
logger.debug(f"Loaded light {light_name} with settings: {light.settings}")

# Create trigger objects
trigger_objects = {}
for trigger_name in config_dict["triggers"]:
trigger = Trigger(
name=trigger_name,
settings=_load_trigger_config(config_dict["triggers"][trigger_name])
)
trigger_objects[trigger_name] = trigger
logger.debug(f"Loaded trigger {trigger_name} with settings: {trigger.settings}")

# Cloud settings
persistent_settings = load_persistent_cloud_settings()
if persistent_settings:
Expand Down Expand Up @@ -635,6 +667,12 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
except Exception as e:
logger.error(f"Error initializing light controller for {name}: {e}")

for name, trigger in trigger_objects.items():
try:
create_trigger_controller(trigger)
except Exception as e:
logger.error(f"Error initializing trigger controller for {name}: {e}")

# initialize project manager
try:
project_manager = get_project_manager()
Expand All @@ -652,6 +690,7 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
cameras=camera_objects,
motors=motor_objects,
lights=light_objects,
triggers=trigger_objects,
endstops=endstop_objects,

# motors timeout in seconds - 0 to disable
Expand Down
1 change: 1 addition & 0 deletions openscan_firmware/controllers/hardware/cameras/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def photo(self, image_format: str = "jpeg") -> PhotoData:
"""
handler = {
"jpeg": self.capture_jpeg,
"raw": self.capture_dng, # legacy implementation hook kept as capture_dng
"dng": self.capture_dng,
"rgb_array": self.capture_rgb_array,
"yuv_array": self.capture_yuv_array,
Expand Down
Loading