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.
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)
]
]
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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,88 @@ def __init__(self, camera: Camera):
def cleanup(self):
self._session.close()

def get_diagnostics(self) -> dict:
"""Return diagnostics gathered from the active gphoto2 controller session."""
relevant_keys = [
"/main/settings/capturetarget",
"/main/settings/capture",
"/main/settings/recordingmedia",
"/main/settings/remotemode",
"/main/capturesettings/shutterspeed",
"/main/capturesettings/aperture",
"/main/capturesettings/autoexposuremode",
"/main/capturesettings/focusmode",
"/main/imgsettings/imageformat",
"/main/imgsettings/imagequality",
"/main/imgsettings/iso",
"/main/status/liveviewstatus",
"/main/status/liveviewselector",
"/main/other/applicationmode",
"capturetarget",
"capture",
"recordingmedia",
"remotemode",
"shutterspeed",
"aperture",
"autoexposuremode",
"focusmode",
"imageformat",
"imagequality",
"iso",
"liveviewstatus",
"liveviewselector",
"applicationmode",
]
with self._hw_lock:
camera = self._session.ensure_connected()
summary = None
about = None
groups: list[str] = []
relevant: list[dict] = []

try:
summary = str(getattr(camera.get_summary(), "text", "")).strip() or None
except Exception:
summary = None
try:
about = str(getattr(camera.get_about(), "text", "")).strip() or None
except Exception:
about = None

try:
config = camera.get_config()
child_count = config.count_children()
for child_idx in range(child_count):
child = config.get_child(child_idx)
groups.append(f"{child.get_name()}: {child.get_label()}")
except Exception:
groups = []

seen_paths: set[str] = set()
for key in relevant_keys:
read_result = self._session.read_config(key)
if not read_result.success or read_result.details is None:
continue
details = read_result.details
key_path = str(details.get("key", key))
if key_path in seen_paths:
continue
seen_paths.add(key_path)
relevant.append(details)

identity = self._session.identity
return {
"model": identity.model or self.camera.name,
"path": identity.port or self.camera.path,
"summary": summary,
"about": about,
"config_groups": groups,
"relevant_config": relevant,
"profile": self._profile.profile_id,
"in_use_by_openscan": True,
"error": None,
}

def _apply_settings_to_hardware(self, settings: CameraSettings):
self._set_busy(True)
try:
Expand Down Expand Up @@ -67,7 +149,7 @@ def capture_dng(self) -> PhotoData:
self._set_busy(True)
try:
content, extra = self._profile.capture_dng(self._session)
return self._create_photo_data(io.BytesIO(content), "dng", extra)
return self._create_photo_data(io.BytesIO(content), "raw", extra)
finally:
self._set_busy(False)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from openscan_firmware.config.camera import CameraSettings

from .profile_helpers import select_best_shutter_choice

logger = logging.getLogger(__name__)


Expand All @@ -18,9 +20,10 @@ class CameraIdentity:


class GPhoto2Profile:
"""Base profile for model-specific GPhoto2 tuning."""
"""Base profile contract for camera model-specific GPhoto2 behavior."""

profile_id = "generic"
register_in_registry = True

def matches(self, identity: CameraIdentity) -> bool:
return True
Expand Down Expand Up @@ -51,3 +54,16 @@ def build_metadata(
if extra:
metadata.update(extra)
return metadata

# Shared helper methods for profile implementations.
def _set_first(self, session: Any, keys: list[str], value: Any) -> bool:
return session.write_first_config(keys, value).success

def _get_first_details(self, session: Any, keys: list[str]) -> dict[str, Any] | None:
result = session.read_first_config(keys)
return result.details if result.success else None

def _pick_best_shutter(self, session: Any, keys: list[str], shutter_ms: float) -> str:
details = self._get_first_details(session, keys)
choices = [] if details is None else list(details.get("choices") or [])
return select_best_shutter_choice(shutter_ms=shutter_ms, available_choices=choices)
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Reusable helpers for GPhoto2 profile implementations."""

from __future__ import annotations

import time
from fractions import Fraction
from typing import Any


def format_shutter_value_ms(shutter_ms: float) -> str:
seconds = max(shutter_ms / 1000.0, 0.000125)
if seconds >= 1.0:
return f"{seconds:.1f}".rstrip("0").rstrip(".")
reciprocal = round(1.0 / seconds)
return f"1/{max(reciprocal, 1)}"


def parse_shutter_choice_seconds(value: str) -> float | None:
normalized = value.strip().lower()
if not normalized or normalized == "bulb":
return None
if "/" in normalized:
try:
return float(Fraction(normalized))
except Exception:
return None
try:
return float(normalized)
except Exception:
return None


def select_best_shutter_choice(shutter_ms: float, available_choices: list[Any]) -> str:
target_seconds = max(shutter_ms / 1000.0, 0.000125)
if not available_choices:
return format_shutter_value_ms(shutter_ms)

best_choice: str | None = None
best_error = float("inf")
for choice in available_choices:
parsed_seconds = parse_shutter_choice_seconds(str(choice))
if parsed_seconds is None:
continue
error = abs(parsed_seconds - target_seconds)
if error < best_error:
best_error = error
best_choice = str(choice)
return best_choice or format_shutter_value_ms(shutter_ms)


def map_gain_to_iso_choice(gain: float | None, iso_choices: list[int]) -> str | None:
if gain is None:
return None
target = max(float(gain), 0.0) * 100.0
nearest = min(iso_choices, key=lambda iso: abs(iso - target))
return str(nearest)


def is_raw_filename(name: str, raw_extensions: tuple[str, ...]) -> bool:
return name.lower().endswith(raw_extensions)


def pick_raw_choice_from_details(details: dict[str, Any] | None, markers: tuple[str, ...] = ("raw", "nef")) -> str:
if not details:
return "RAW"
choices = details.get("choices") or []
for choice in choices:
text = str(choice).strip().lower()
if any(marker in text for marker in markers):
return str(choice)
return "RAW"


def restore_previous_config_value(session, keys: list[str], previous_value: Any | None) -> None:
if previous_value is None:
return
session.write_first_config(keys, previous_value)


def capture_with_route_fallbacks(
session,
routes: list[dict[str, str]],
capture_route_applier,
raw_filename_checker,
attempts_per_route: int = 3,
retry_delay_step_s: float = 0.15,
) -> tuple[bytes, dict[str, Any], dict[str, Any]]:
"""Capture and try fallback routes until a RAW filename is observed."""
capture_name = ""
last_error: Exception | None = None

for route_index, route in enumerate(routes):
capture_route_applier(session, route)
for attempt in range(1, attempts_per_route + 1):
try:
content, extra = session.capture_image()
except Exception as exc:
last_error = exc
if attempt < attempts_per_route:
time.sleep(retry_delay_step_s * attempt)
continue
break

capture_name = str(extra.get("capture_name", "")).lower()
if raw_filename_checker(capture_name):
diagnostics = {
"capture_route_index": route_index,
"capture_route": route,
"capture_attempt": attempt,
}
return content, extra, diagnostics

if attempt < attempts_per_route:
time.sleep(retry_delay_step_s * attempt)

if last_error is not None:
raise RuntimeError(f"All RAW capture routes failed: {last_error}") from last_error
raise RuntimeError(
"Camera returned a non-RAW file while RAW was requested "
f"(last capture_name='{capture_name or 'unknown'}')."
)
Loading